Skip to content

Add context drop setup instructions - #5

Merged
sonichi merged 1 commit into
mainfrom
context-drop-docs
Mar 28, 2026
Merged

sonichi merged 1 commit into
mainfrom
context-drop-docs

Conversation

@sonichi

@sonichi sonichi commented Mar 28, 2026

Copy link
Copy Markdown
Owner

Summary

  • Add context drop to Optional integrations table
  • Add step-by-step setup section for configuring the keyboard shortcut via Automator

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@sonichi
sonichi merged commit fdbffc0 into main Mar 28, 2026
@sonichi
sonichi deleted the context-drop-docs branch March 28, 2026 17:12
liususan091219 added a commit that referenced this pull request Mar 31, 2026
agent-api.py:
- Add _safe_id() to sanitize task IDs against path traversal (#2-4, #8)
- Reject path traversal in /media/ before resolve (#5-7)
- Sanitize mime type to prevent HTTP response splitting (#1)

inline-tools.ts:
- Escape backslashes in shell args for openUrl and switchApp (#9-11)

conversation-server.ts:
- Mask phone number in startup log (#12)
- Escape meetingId/passcode in TwiML Play digits (#13)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
sonichi pushed a commit that referenced this pull request Mar 31, 2026
* Fix 13 CodeQL security alerts

agent-api.py:
- Add _safe_id() to sanitize task IDs against path traversal (#2-4, #8)
- Reject path traversal in /media/ before resolve (#5-7)
- Sanitize mime type to prevent HTTP response splitting (#1)

inline-tools.ts:
- Escape backslashes in shell args for openUrl and switchApp (#9-11)

conversation-server.ts:
- Mask phone number in startup log (#12)
- Escape meetingId/passcode in TwiML Play digits (#13)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add security comments to shell escaping in inline-tools

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
liususan091219 pushed a commit that referenced this pull request Apr 18, 2026
Per Susan's 'general edit to correct outdated descriptions' ask. Applied
Maddy's review hits that I could verify locally:

- pkill pattern: `-f "src/Sutando"` → `-x Sutando` (hit #8: safer, matches
  the actual process name regardless of launch path).
- Uninstall step 5: softened from `brew uninstall imsg wacli` to a
  generic 'remove via the package manager you used' (hit #3: wacli isn't
  necessarily brew).
- Capabilities table:
  - Task delegation: `task-bridge.ts` → `task-bridge.ts + watch-tasks.sh + tasks/`
    (hit #4: reflects the actual file-based flow).
  - Notes: 'via CLAUDE.md' → '`notes/` directory (YAML-frontmatter markdown)'
    (hit #5: notes/ is the real store).
  - Added rows for cross-node sync, info-radar, menu-bar avatar states
    (hit #6: recent features missing from the matrix).
- Services table: added 7845 (screen-capture), 3100 (conversation-server),
  4040 (ngrok admin UI) (hit #7).
- Removed stray duplicate `---` separator after the services table
  (hit #9).

Skipped:
- Hit #1 (L77 subscription line) — already removed earlier.
- Hit #2 (screencapture note) — Maddy confirmed still accurate.
- Hit #10 (swiftc missing `-framework AVFoundation`) — main.swift does NOT
  import AVFoundation (verified: import Cocoa, Carbon, UserNotifications
  only). Current compile line is correct.
- L101 permission rewrite (Maddy's 4-bullet proposal) — substantive
  rewrite, deferred to Susan's specific approval before applying.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sonichi pushed a commit that referenced this pull request Apr 19, 2026
Bumps bodhi from de64333 → 4d1592e. New commits include:
- feat(hooks): onTurnLatency hook with totalE2EMs heuristic
- build: rebuild dist to include onTurnLatency hook + PR #4 guard

This lands the upstream GoAway-on-CLOSED FATAL guard (via PR #4's
handleGoAway check, which was already on de64333 but not built into
the shipped dist until PR #5's 2fb90e8) + the new onTurnLatency hook
(Sutando doesn't consume it yet; available for future observability).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sonichi added a commit that referenced this pull request Apr 19, 2026
Bumps bodhi from de64333 → 4d1592e. New commits include:
- feat(hooks): onTurnLatency hook with totalE2EMs heuristic
- build: rebuild dist to include onTurnLatency hook + PR #4 guard

This lands the upstream GoAway-on-CLOSED FATAL guard (via PR #4's
handleGoAway check, which was already on de64333 but not built into
the shipped dist until PR #5's 2fb90e8) + the new onTurnLatency hook
(Sutando doesn't consume it yet; available for future observability).

Co-authored-by: Chi <wangchi@Chis-Mac-mini.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
liususan091219 added a commit that referenced this pull request Apr 19, 2026
* docs(README): update 'Summon' voice trigger example to 'Summon my computer to zoom'

The short form 'Summon' is false-positive-prone in voice transcripts. Owner
uses the longer 'Summon my computer to zoom' phrase in practice. This docs
update matches what actually triggers the Zoom summon reliably.

Code-side prompts are NOT changed in this PR (per CLAUDE.md's 'prompts tuned
through testing' rule) — the underlying summonTool already matches both
phrases via the 'summon' keyword.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(README): add two troubleshooting entries (Claude context drift + phone-server error)

Per Susan's Discord msg: two common failure modes worth surfacing in
Troubleshooting:
1. Claude hallucinates more as context fills → tell users to restart the
   Claude Code session periodically.
2. Phone call answers 'We are sorry, an error has occurred' → the
   conversation-server on port 3100 isn't running; relaunch via
   startup.sh or restart.sh.

Verified the correct server path: skills/phone-conversation/scripts/
conversation-server.ts (not a standalone start-phone-server.sh, which
doesn't exist).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(README): apply Maddy's verified hits — general cleanup

Per Susan's 'general edit to correct outdated descriptions' ask. Applied
Maddy's review hits that I could verify locally:

- pkill pattern: `-f "src/Sutando"` → `-x Sutando` (hit #8: safer, matches
  the actual process name regardless of launch path).
- Uninstall step 5: softened from `brew uninstall imsg wacli` to a
  generic 'remove via the package manager you used' (hit #3: wacli isn't
  necessarily brew).
- Capabilities table:
  - Task delegation: `task-bridge.ts` → `task-bridge.ts + watch-tasks.sh + tasks/`
    (hit #4: reflects the actual file-based flow).
  - Notes: 'via CLAUDE.md' → '`notes/` directory (YAML-frontmatter markdown)'
    (hit #5: notes/ is the real store).
  - Added rows for cross-node sync, info-radar, menu-bar avatar states
    (hit #6: recent features missing from the matrix).
- Services table: added 7845 (screen-capture), 3100 (conversation-server),
  4040 (ngrok admin UI) (hit #7).
- Removed stray duplicate `---` separator after the services table
  (hit #9).

Skipped:
- Hit #1 (L77 subscription line) — already removed earlier.
- Hit #2 (screencapture note) — Maddy confirmed still accurate.
- Hit #10 (swiftc missing `-framework AVFoundation`) — main.swift does NOT
  import AVFoundation (verified: import Cocoa, Carbon, UserNotifications
  only). Current compile line is correct.
- L101 permission rewrite (Maddy's 4-bullet proposal) — substantive
  rewrite, deferred to Susan's specific approval before applying.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(README): revise 'How it works' diagram — phone agent + accurate process topology

Per Susan's ask ('diagram isn't quite right', 'phone agent rarely covered')
and Maddy's accuracy verification:

Diagram changes:
- Web client → Voice agent is now WebSocket on :9900 (was implicit file bridge)
- Phone (Twilio) → Conversation server added as peer entry path
- results/ flow annotated as per-channel (speak via voice/phone vs text
  via Telegram/Discord), not one unified speak path

Process topology changes:
- "Two processes work together" → "Three processes" (voice-agent,
  conversation-server, core-agent) — verified against
  conversation-server.ts by Maddy (writes to tasks/ directly at lines
  289, 964, 1315; not hierarchically downstream of voice-agent).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(README): surface phone-agent in prereqs + services list

Per Susan's ask ('phone agent is rarely covered in the README, it should be
mentioned when appropriate'):
- Prereqs: added optional Twilio + ngrok bullet so readers know the phone
  path exists and what they need to enable it.
- 'This starts all services' sentence: added 'phone conversation server' to
  the list so readers see it's a default-started daemon, not an opt-in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(README): rewrite permission section (4-bullet 'why elevated perms') per Maddy's proposal

Replaces the conservative CYA note ('you should review what it does. All
actions are logged') with Maddy's framing that tells the reader:
1. It's local (no remote control plane)
2. You control the audience (3-tier access gating + VERIFIED_CALLERS)
3. Actions are auditable (build_log + tasks/ + results/ + logs/)
4. Hooks are your brake pedal (git-rules-guard.sh Discord 👍/👎)

Susan's direction was 'lead the work and Maddy please review iteratively'.
Applying Maddy's already-drafted proposal; she reviews next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(README): add inline-tools path to 'How it works' diagram

Per Susan's ask — voice agent and conversation server have two
response paths (inline tools for instant in-process calls;
tasks/ for anything the core agent needs to execute), while
Telegram/Discord bridges only use tasks/.

— Lucy (Mac Studio bot)

* docs(README): swap 'mute' for real inline tool per Maddy's review

mute is a menu-bar ⌃M shortcut, not an inline tool. Replace with
get_current_time (src/inline-tools.ts:376) and broaden the prose
sentence to list only genuine inline tools.

— Lucy (Mac Studio bot)

* docs(README): add running-costs section + price-aware prereqs

Per Susan's in-#dev review (consolidated 4-edit bundle, 👍'd):
- Line 96: drop unqualified "free" on API key — usage-billed per
  Running costs
- Line 97: add ~$0.01–0.03/min Twilio cost reference
- New "## Running costs" section: Gemini Live $0.30–$1.30/hr,
  Twilio $0.0125/min (US local), free for browser/Telegram/Discord
- New ffmpeg bullet in prereqs (optional, for video/audio)

Sources: Gemini pricing docs, Twilio voice pricing docs.

— Lucy (Mac Studio bot)

* docs(README): bundle 4 follow-ups from #susan iteration

Per Susan's 4× 👍 iterative review on PR #450:
- Diagram: add `↻` on Core agent + legend explaining the cron-driven
  proactive loop (5-min fires for tasks, health, build-log work).
- Running costs table: Twilio row now bidirectional — ~$0.013–$0.018/min
  covers inbound ($0.0085) + outbound ($0.014) + Media Streams ($0.004).
- Prereq bullet: rename "Google AI Studio API key" → "Gemini API key"
  and inline the ~$0.30–$1.30/hour voice cost.
- macOS permissions: moved from inline Quick start bullets to the
  Security section with a per-permission "why it's needed" expansion;
  Quick start keeps a one-line pointer.

— Lucy (Mac Studio bot)

* docs(README): hedge Gemini price claims — free tier vs paid unclear

Susan and Chi are still sorting out the free-tier vs paid-tier
picture; pull back the specific $0.30-$1.30/hr claim since it
misrepresents the free-tier case:

- Prereq bullet: "may be free on the free tier or usage-billed
  depending on your Google Cloud account" instead of a rate
- Running costs table Gemini row: "Free tier available - paid
  usage varies"; notes keep the published paid rates as reference
  but point readers at their billing dashboard for ground truth
- Drop the personal $13/602-min datapoint from the typical-call
  example (may conflate with non-voice Gemini spend)

Twilio row unchanged - that math is from published rates.

— Lucy (Mac Studio bot)

* docs(README): Option B — remove Running costs section + price mentions

Per Susan's "B" direction in #dev: the free-tier vs paid-tier
picture for Gemini isn't settled, and leaving any price language
in the PR risks misleading first-time readers.

- Delete the entire "## Running costs" section (table + example +
  sources line).
- Revert the Gemini API key prereq bullet to `(click "Get API key")`
  — drop the usage-billed caveat entirely.
- Revert the Twilio prereq bullet to drop the `~$0.01–0.03/min`
  price reference.

ffmpeg bullet, diagram rewrite, macOS permissions in Security
section, `↻` cron legend, phone-agent surfacing — all stay.

— Lucy (Mac Studio bot)

---------

Co-authored-by: Xueqing Liu <xueqingliu@Xueqings-Mac-Studio.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sonichi added a commit that referenced this pull request Apr 30, 2026
)

Migration #4 + #5 in the private-repo plan. Notes/ are SHARED across the
fleet (top-level private dir); pending-questions.md, PERSONAL_CLAUDE.md,
tab-aliases.json are PER-MACHINE (machine-<host>/, like #548).

Two resolver families:

- `personal_path()` (existing, #548) — per-machine: $SUTANDO_PRIVATE_DIR/machine-<host>/<file>
- `shared_personal_path()` (new) — fleet-shared: $SUTANDO_PRIVATE_DIR/<file>

New `src/util_paths.ts` is the TS twin of util_paths.py with both helpers
exported. voice-agent.ts replaces its inline `personalPath` with imports
from the new module.

Wired callers:

NOTES (shared_personal_path):
- src/inline-tools.ts NOTES_DIR → save_note / read_note / list_notes / delete_note
- src/voice-agent.ts: meeting-note save (line 285)
- src/dashboard.py: /notes/{slug} route + /notes index (lines 48, 437)
- src/daily-insight.py: NOTES_DIR
- src/friction-detector.py: notes_dir
- src/discord-bridge.py: SEND_ALLOWED_ROOTS adds private notes path
- src/health-check.py: notes-dir check now points at canonical home

PER-MACHINE (personal_path):
- src/dashboard.py: get_pending_count
- src/agent-api.py: pq_file (2 sites)
- src/friction-detector.py: pending-questions.md
- src/check-pending-questions.py: PQ_FILE
- src/session-handoff.sh: pending-questions.md (via inline python3)

PERSONAL_CLAUDE.md and tab-aliases.json: no runtime callers — only
referenced in migrate.sh bundle/restore (#548 already covers per-machine
shape, so they ride along without extra wiring).

Test plan:
- [x] `npx tsc --noEmit` clean
- [x] All Python files compile (`ast.parse`)
- [x] Resolver verified end-to-end with SUTANDO_PRIVATE_DIR set:
      shared_personal_path('notes') → /Users/wangchi/.sutando-memory-sync/notes
      personal_path('pending-questions.md') → .../machine-Chis-MacBook-Air/pending-questions.md
- [ ] Drop a voice-tool note via save_note, verify it lands in private dir
- [ ] Open the note in dashboard, verify /notes/{slug} reads from private dir
- [ ] Restart check-pending-questions cron, verify it reads private PQ file

After merge: kickstart voice-agent (touches voice-agent.ts + inline-tools.ts),
restart web-client/dashboard/agent-api/discord-bridge (Python files), restart
conversation-server. Same pattern as #548.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sonichi pushed a commit that referenced this pull request May 4, 2026
…archive watch

Changes per Chi's review of v1 (PR #586 thread):

1. Pointer schema. Drop inline derivable fields (user_id, channel_id,
   access_tier, channel_source, size_bytes). Each event now carries only
   {task_id, path}; summarizer reads the file when it needs body. Cuts
   event size 3-4x.

2. Cursor-based restart safety. Track last-seen mtime per directory in
   state/learning-<instance>.cursor. On first run, bootstrap to NOW so
   history doesn't replay; on subsequent runs, pick up from cursor — no
   missed events across restarts.

3. Heartbeat. Emit collector.heartbeat every 60s (configurable via
   LEARN_COLLECTOR_HEARTBEAT_S). Lets the summarizer detect daemon
   death without inspecting file mtime.

4. Switch results watch to results/archive/<YYYY-MM>/ (recursive). The
   transient results/ dir gets emptied by the bridge in <5s, so a 5s
   poll missed most produced events. Watching the durable archive
   instead catches every result reliably.

5. Drop Set-based bootstrap-skip; use mtime > cursor instead. Simpler
   and integrates with cursor.

Smoke-tested: cursor reload across restart works; new tasks in tasks/
emit incoming + archived events; heartbeat fires on schedule.

Addresses self-audit issues #1, #5, #6 (count imbalance was downstream
of #1 and #6 — same root causes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
qingyun-wu added a commit that referenced this pull request May 18, 2026
…#5, #6, #8)

From sonichi cold-review on PR #794 — uncontroversial nice-to-haves
that don't depend on the scope decisions for findings #1–#3.

- #5: validateConfig now checks tools_allow is a string array. A YAML
  with `tools_allow: "vision_query"` (string instead of list) used to
  pass validation and crash later at `.length` access with a confusing
  error. Now fails fast at load with a clear message.
- #6: vision_cadence_ms bounded to 100–5000ms. A typo like 70000 (70s)
  would silently produce useless frame cadence; 50ms would saturate the
  network. The 100–5000 range covers every plausible mode today.
- #8: SKILL.md typo — "bash scripts/activate.ts --list" → "npx tsx ..."
  (matches the smoke-test block above; bash can't execute the .ts).

No behavior change for the live guided-setup config (verified by
--list and tsc clean).
qingyun-wu added a commit that referenced this pull request May 18, 2026
* feat(screen-companion): skeleton skill (M1) + pair-read-paper config

v0 skeleton for the screen-companion skill. Closes nothing yet — this
PR ships the *primitive*, not the working feature. Followup PR wires
the loaded config into voice-agent.ts.

What's in this PR:

- skills/screen-companion/SKILL.md — describes the skill, the M1 model
  (master skill + N declarative configs), how to add a new use case,
  trust + scope boundary.

- skills/screen-companion/configs/pair-read-paper.yaml — the first
  use-case config. Demonstrates the full schema: activation triggers
  (voice phrases + button label + CLI alias), vision_mode + cadence,
  system_prompt_overlay with domain priors + don't-list, tools_allow
  whitelist, goal_template for optional activation-time fill-in.

- skills/screen-companion/scripts/activate.ts — loader + activation
  printer. v0 scope: discover configs in configs/*.yaml, validate
  required fields, load one by name, print what *would* happen when
  it's activated. No voice-agent wiring yet — intentional, so the
  config schema can crystallize against a real concrete config
  (pair-read-paper) before the integration shape is committed.

CLI:
  npx tsx skills/screen-companion/scripts/activate.ts --list
  npx tsx skills/screen-companion/scripts/activate.ts --config pair-read-paper
  npx tsx skills/screen-companion/scripts/activate.ts --config pair-read-paper --goal "..."

Picks M1 ("master skill + declarative configs") from screen_companion.md.
Discussion + tradeoffs vs M2 (per-use-case sub-skills) + M3 (SDK) live
in that doc.

YAML parsing via python3 subprocess (same pattern as src/oc-profile-catalog.ts);
avoids adding js-yaml as an npm dep.

Next PR (followup):
- Wire system_prompt_overlay onto the active VoiceSession.
- Restrict the tool surface to tools_allow at activation time.
- Hook vision push/pull cadence.
- Add pair-debug + pair-review-code configs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(screen-companion): swap v0 config from pair-read-paper to guided-setup

Owner-picked 2026-05-17: the v0 first-shipped config is `guided-setup`
(proactive + goal-directed — Sutando narrates next steps for a UI
config task), not `pair-read-paper`. More visceral demo angle: "Sutando
helps me set up a Discord bot I've never configured before" vs. "Sutando
reads a paper with me."

Changes in this commit:
- Add skills/screen-companion/configs/guided-setup.yaml — the new v0
  config. PROACTIVE shape: tighter vision cadence (700ms vs 1000ms),
  prompt overlay narrates next steps continuously, explicit DO NOT
  click/type guidance (Sutando narrates; user keeps the action).
- Drop skills/screen-companion/configs/pair-read-paper.yaml — was
  shipping as the v0 reference config but the demo angle is weaker;
  owner-clarified the framing should center on "help me set up
  something I've never done before."
- Update SKILL.md table + run example + roadmap to reflect guided-setup
  as the shipped config; pair-read-paper dropped (siblings pair-debug
  + pair-review-code still tracked in screen_companion.md as future).
- Update activate.ts doc header + CLI example to use guided-setup.
- Add Privacy + data handling section to SKILL.md: default DOES NOT
  take screenshots, record video, or persist frames; frames are sent
  to Gemini Live for real-time understanding and discarded; text
  transcripts are persisted (conversation-server precedent); user
  has to explicitly opt-in per request to save screenshots/recordings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(screen-companion): wire guided-setup end-to-end via skill manifest

Closes the wiring gap left by the v0 skeleton — voice can now activate
guided-setup mode directly. Earlier passes shipped the YAML config + CLI
loader; this pass adds the missing piece:

- manifest.json — skill is enabled, points to tools.ts
- tools.ts — exports activate_screen_companion tool (loads config, returns
  prompt overlay + activation_message + vision hint as a structured payload)
- scripts/load-config.ts — shared loader used by both the CLI activate.ts
  and the inline tool (was duplicated; now single source of truth)
- scripts/activate.ts — refactored to import from load-config.ts (-80 lines)
- SKILL.md — drops the "v0 skeleton — load + print only" caveat, adds the
  voice activation phrases + the wire-through explanation

Smoke test (matches what voice will see):
  npx tsx -e "import('./skills/screen-companion/tools.ts').then(...)"
  → status: 'activated', mode: 'guided-setup', goal/instructions/tools_allow
    all populated, activation_message ready to speak.

Architecture: tool lives in the skill (per CLAUDE.md decision guide —
self-contained feature). Voice-agent picks it up via the existing skill
manifest loader (same pattern as gws-gmail-voice). No changes to
src/inline-tools.ts or src/voice-agent.ts.

Voice-agent restart required to register the new tool. Configs themselves
are read fresh per activation, so adding more configs in later PRs does
not require another restart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(screen-companion): tolerate LaunchAgent python3 without PyYAML

Voice-agent runs under a LaunchAgent whose PATH is
`/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin`. Homebrew's python3 wins
that lookup but does not ship PyYAML, so `python3 -c 'import yaml; ...'`
in parseYaml() failed every activation with ModuleNotFoundError. The
tool returned an error and Gemini fell back to default behavior — silent
to the owner aside from "screen companion failed."

Try a list of python3 candidates (system → homebrew → PATH default), use
the first that can run the yaml import. macOS's `/usr/bin/python3` ships
PyYAML, so the system Python is the usual winner under the LaunchAgent
PATH. Aggregate per-binary errors into the thrown message for diagnosis
when all candidates miss.

Verified under the LaunchAgent's PATH env: activation now succeeds.

* feat(vision): single-line nudge for screen-companion at share-start

The existing screen-share-started system note tells the model to say "I
can see your screen now" but stops there — so users who don't already
know the activation phrases (e.g. "help me set this up" for
guided-setup) share their screen and get no signal that pre-built
companion modes exist. Add a one-line, mode-agnostic nudge: "Want me
to help you through something?" If they describe a goal that fits any
configured mode, the model routes via `activate_screen_companion`.

Mode-agnostic on purpose — the prompt does not list configs by name, so
it won't age as new ones land (pair-debug, pair-review-code planned per
screen_companion.md). The model already knows the catalog from the
`activate_screen_companion` tool's description (which discovers configs
at boot), so listing them in the nudge would be redundant.

* feat(vision): share-start nudge names screen-companion + configs explicitly

First attempt (626a18f) shipped a mode-agnostic nudge ("Want me to help
you through something?") that did not surface the screen-companion
catalog. Owner test 2026-05-17 17:28 PT: routing worked (goal → mode)
but discoverability didn't — owner reported "I do not hear the nudge
about screen companion mode."

Updated injected prompt instructs Gemini to deliver ONE sentence that
(a) acknowledges the screen, (b) names "screen-companion mode"
explicitly, (c) names the available configs by name (Gemini reads them
from the activate_screen_companion tool description, which is
discoverConfigs() at boot — so new YAMLs surface automatically), (d)
asks the user's goal.

Example shape: "I can see your screen — screen-companion mode is
available with guided-setup for UI walkthroughs. What are you trying
to do?"

* fix(screen-companion): force ground-then-step in guided-setup overlay

Owner test 2026-05-17 17:34 PT: in a FRESH voice session with no
chat history, Gemini activated guided-setup for "set up a Discord bot"
and asserted "you're on the Discord Developer Portal" + "I see your
cursor hovering over those existing apps" — when the user was
demonstrably NOT on the dev portal. Goal-priming overrode actual
vision input. (Verified via fresh-session diagnostic — chat-history
anchoring was ruled out.)

Add a GROUNDING RULE section at the top of the overlay:
- Name what you ACTUALLY see (page title/URL/breadcrumb, dominant UI
  elements, overall page kind) BEFORE naming any step.
- If the visible page doesn't match the goal's destination, do NOT
  assert the user is on the right page — say what you see and ask them
  to navigate.
- Never fabricate UI elements; if a label is illegible, ask the user
  to read it.
- Goal text is a hint about WHERE the user is going, NOT proof of
  where they ARE. Reconcile goal vs visible-screen as two separate
  facts every turn.

Configs are loaded fresh on each activation — next re-activation picks
this up without a voice-agent restart.

* docs(screen-companion): downgrade tools_allow claim from 'enforced' to advisory

sonichi cold-review on PR #794 flagged: SKILL.md asserts the tool allow-
list is "enforced at activation time; configs cannot grant tools the
active VoiceSession doesn't already expose" — but tools.ts:67 just
returns the field in the activation payload as a string list. The
_note instructs Gemini to self-restrict; there is no path in the
bodhi-realtime-agent layer that scopes the live tool surface.

Update the Trust + scope section to match reality: advisory in v0,
hard enforcement deferred to a follow-up PR. The risk surface
("worst case is Gemini calls a tool you didn't intend") is named
explicitly so readers can calibrate.

Code unchanged — the field still functions as the documented intent
and Gemini does self-restrict in observed sessions; only the doc claim
was overstating.

* chore(screen-companion): defensive validation + SKILL.md typo (sonichi #5, #6, #8)

From sonichi cold-review on PR #794 — uncontroversial nice-to-haves
that don't depend on the scope decisions for findings #1–#3.

- #5: validateConfig now checks tools_allow is a string array. A YAML
  with `tools_allow: "vision_query"` (string instead of list) used to
  pass validation and crash later at `.length` access with a confusing
  error. Now fails fast at load with a clear message.
- #6: vision_cadence_ms bounded to 100–5000ms. A typo like 70000 (70s)
  would silently produce useless frame cadence; 50ms would saturate the
  network. The 100–5000 range covers every plausible mode today.
- #8: SKILL.md typo — "bash scripts/activate.ts --list" → "npx tsx ..."
  (matches the smoke-test block above; bash can't execute the .ts).

No behavior change for the live guided-setup config (verified by
--list and tsc clean).

* docs(screen-companion): mark guided-setup tools_allow as PLANNED (sonichi #1)

PR #794 review finding #1 — tools_allow lists three tools that don't
exist anywhere in the codebase (zero grep matches outside this skill).
sonichi's three suggested fixes were: rename to real tools, land the
missing tools, or document as schema-only. Going with the documentation
path for v0 — least-risky of the three, reversible, and consistent
with the truthfulness fix in eff1286 that already downgrades the
allow-list claim in SKILL.md from "enforced" to "advisory".

guided-setup as shipped relies only on push-mode vision frames + Gemini
Live's conversational capability — no tool calls are actually required
to operate. The PLANNED comment captures design intent without
fabricating wiring that doesn't exist; a follow-up PR registers real
tools and Gemini gets a real allow-list to follow.

Code unchanged. CI green expected — pure YAML comments.

* refactor(vision): contributor registry — remove screen-companion knowledge from core (sonichi #3)

PR #794 review finding #3: src/vision-tools.ts:254-256 hardcoded the
strings "screen-companion mode" and "guided-setup" in the screen-share-
started system note. Per CLAUDE.md decision guide, core services must
not contain feature-specific logic; if a user removed the screen-
companion skill, vision-tools.ts would lie to Gemini about modes that
no longer exist.

Refactor:

- src/vision-tools.ts now exposes `registerVisionOnContributor(fn)`.
  When push mode starts, the injected note is built from a generic base
  note + each contributor's output. With zero contributors registered,
  the note is generic and mentions no modes.
- skills/screen-companion/tools.ts registers a contributor at module-
  load time. The contributor calls discoverConfigs() at injection time,
  so new YAMLs in configs/ surface automatically without code changes.
- If the skill is disabled (manifest.enabled=false) or removed, the
  skill-loader at src/inline-tools.ts:937 never imports tools.ts, the
  contributor never registers, and vision-tools.ts emits only the base
  note — exactly matches CLAUDE.md "skills are optional."

Behavioral equivalence: with screen-companion enabled (default), the
injected note now contains the same essential content as before — just
sourced from the contributor instead of hardcoded.

Verified: tsc clean. Smoke test confirms contributor count goes 0→1
when the skill module is imported. Voice-agent restarted to pick up
the new module.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sonichi added a commit that referenced this pull request May 18, 2026
…olution (#860)

Adds Python `claude_home_path(*subpath)` and TS `claudeHomePath(...subpath)`
to `src/util_paths.{py,ts}`. Both resolve a path under Claude Code's per-user
home directory (`~/.claude/` by default; `$CLAUDE_HOME` for override / testing).

Why this helper: per the 2026-05-18 workspace-design RFC (PR #858 + issue
#857) host-CLI dependency discussion, Sutando's dependency on `~/.claude/`
is real and accepted operationally — channel tokens (`channels/*/.env`),
agent memory (`projects/<hash>/memory/`), skill discovery (`skills/`),
settings (`settings.json`, `.claude.json`). We commit to Claude Code as the
host CLI today, but want the dependency surface countable so a future swap
is a 1-day grep+replace rather than a re-architecture.

This helper IS the "stop hedging on swap" discipline. New code that reads or
writes under `~/.claude/` should go through this helper. Existing callers
(discord-bridge.py, inline-tools.ts skill-loader, health-check.py memory
checks, etc.) can migrate opportunistically — not flag-day required.

Resolution: `$CLAUDE_HOME` if set, else `~/.claude/`. Does NOT create the
directory.

Both helpers verified locally:
- Python: `claude_home_path('channels', 'discord', 'access.json')` → `/Users/wangchi/.claude/channels/discord/access.json`
- TS: same shape, same result
- $CLAUDE_HOME override honored on both
- Full TS suite still 173/173 pass

Closes impl-gap #5 from issue #857.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
liususan091219 added a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
* Fix 13 CodeQL security alerts

agent-api.py:
- Add _safe_id() to sanitize task IDs against path traversal (sonichi#2-4, sonichi#8)
- Reject path traversal in /media/ before resolve (sonichi#5-7)
- Sanitize mime type to prevent HTTP response splitting (sonichi#1)

inline-tools.ts:
- Escape backslashes in shell args for openUrl and switchApp (sonichi#9-11)

conversation-server.ts:
- Mask phone number in startup log (sonichi#12)
- Escape meetingId/passcode in TwiML Play digits (sonichi#13)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add security comments to shell escaping in inline-tools

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
…ichi#451)

Bumps bodhi from de64333 → 4d1592e. New commits include:
- feat(hooks): onTurnLatency hook with totalE2EMs heuristic
- build: rebuild dist to include onTurnLatency hook + PR sonichi#4 guard

This lands the upstream GoAway-on-CLOSED FATAL guard (via PR sonichi#4's
handleGoAway check, which was already on de64333 but not built into
the shipped dist until PR sonichi#5's 2fb90e8) + the new onTurnLatency hook
(Sutando doesn't consume it yet; available for future observability).

Co-authored-by: Chi <wangchi@Chis-Mac-mini.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
liususan091219 added a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
* docs(README): update 'Summon' voice trigger example to 'Summon my computer to zoom'

The short form 'Summon' is false-positive-prone in voice transcripts. Owner
uses the longer 'Summon my computer to zoom' phrase in practice. This docs
update matches what actually triggers the Zoom summon reliably.

Code-side prompts are NOT changed in this PR (per CLAUDE.md's 'prompts tuned
through testing' rule) — the underlying summonTool already matches both
phrases via the 'summon' keyword.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(README): add two troubleshooting entries (Claude context drift + phone-server error)

Per Susan's Discord msg: two common failure modes worth surfacing in
Troubleshooting:
1. Claude hallucinates more as context fills → tell users to restart the
   Claude Code session periodically.
2. Phone call answers 'We are sorry, an error has occurred' → the
   conversation-server on port 3100 isn't running; relaunch via
   startup.sh or restart.sh.

Verified the correct server path: skills/phone-conversation/scripts/
conversation-server.ts (not a standalone start-phone-server.sh, which
doesn't exist).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(README): apply Maddy's verified hits — general cleanup

Per Susan's 'general edit to correct outdated descriptions' ask. Applied
Maddy's review hits that I could verify locally:

- pkill pattern: `-f "src/Sutando"` → `-x Sutando` (hit sonichi#8: safer, matches
  the actual process name regardless of launch path).
- Uninstall step 5: softened from `brew uninstall imsg wacli` to a
  generic 'remove via the package manager you used' (hit sonichi#3: wacli isn't
  necessarily brew).
- Capabilities table:
  - Task delegation: `task-bridge.ts` → `task-bridge.ts + watch-tasks.sh + tasks/`
    (hit sonichi#4: reflects the actual file-based flow).
  - Notes: 'via CLAUDE.md' → '`notes/` directory (YAML-frontmatter markdown)'
    (hit sonichi#5: notes/ is the real store).
  - Added rows for cross-node sync, info-radar, menu-bar avatar states
    (hit sonichi#6: recent features missing from the matrix).
- Services table: added 7845 (screen-capture), 3100 (conversation-server),
  4040 (ngrok admin UI) (hit sonichi#7).
- Removed stray duplicate `---` separator after the services table
  (hit sonichi#9).

Skipped:
- Hit sonichi#1 (L77 subscription line) — already removed earlier.
- Hit sonichi#2 (screencapture note) — Maddy confirmed still accurate.
- Hit sonichi#10 (swiftc missing `-framework AVFoundation`) — main.swift does NOT
  import AVFoundation (verified: import Cocoa, Carbon, UserNotifications
  only). Current compile line is correct.
- L101 permission rewrite (Maddy's 4-bullet proposal) — substantive
  rewrite, deferred to Susan's specific approval before applying.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(README): revise 'How it works' diagram — phone agent + accurate process topology

Per Susan's ask ('diagram isn't quite right', 'phone agent rarely covered')
and Maddy's accuracy verification:

Diagram changes:
- Web client → Voice agent is now WebSocket on :9900 (was implicit file bridge)
- Phone (Twilio) → Conversation server added as peer entry path
- results/ flow annotated as per-channel (speak via voice/phone vs text
  via Telegram/Discord), not one unified speak path

Process topology changes:
- "Two processes work together" → "Three processes" (voice-agent,
  conversation-server, core-agent) — verified against
  conversation-server.ts by Maddy (writes to tasks/ directly at lines
  289, 964, 1315; not hierarchically downstream of voice-agent).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(README): surface phone-agent in prereqs + services list

Per Susan's ask ('phone agent is rarely covered in the README, it should be
mentioned when appropriate'):
- Prereqs: added optional Twilio + ngrok bullet so readers know the phone
  path exists and what they need to enable it.
- 'This starts all services' sentence: added 'phone conversation server' to
  the list so readers see it's a default-started daemon, not an opt-in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(README): rewrite permission section (4-bullet 'why elevated perms') per Maddy's proposal

Replaces the conservative CYA note ('you should review what it does. All
actions are logged') with Maddy's framing that tells the reader:
1. It's local (no remote control plane)
2. You control the audience (3-tier access gating + VERIFIED_CALLERS)
3. Actions are auditable (build_log + tasks/ + results/ + logs/)
4. Hooks are your brake pedal (git-rules-guard.sh Discord 👍/👎)

Susan's direction was 'lead the work and Maddy please review iteratively'.
Applying Maddy's already-drafted proposal; she reviews next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(README): add inline-tools path to 'How it works' diagram

Per Susan's ask — voice agent and conversation server have two
response paths (inline tools for instant in-process calls;
tasks/ for anything the core agent needs to execute), while
Telegram/Discord bridges only use tasks/.

— Lucy (Mac Studio bot)

* docs(README): swap 'mute' for real inline tool per Maddy's review

mute is a menu-bar ⌃M shortcut, not an inline tool. Replace with
get_current_time (src/inline-tools.ts:376) and broaden the prose
sentence to list only genuine inline tools.

— Lucy (Mac Studio bot)

* docs(README): add running-costs section + price-aware prereqs

Per Susan's in-#dev review (consolidated 4-edit bundle, 👍'd):
- Line 96: drop unqualified "free" on API key — usage-billed per
  Running costs
- Line 97: add ~$0.01–0.03/min Twilio cost reference
- New "## Running costs" section: Gemini Live $0.30–$1.30/hr,
  Twilio $0.0125/min (US local), free for browser/Telegram/Discord
- New ffmpeg bullet in prereqs (optional, for video/audio)

Sources: Gemini pricing docs, Twilio voice pricing docs.

— Lucy (Mac Studio bot)

* docs(README): bundle 4 follow-ups from #susan iteration

Per Susan's 4× 👍 iterative review on PR sonichi#450:
- Diagram: add `↻` on Core agent + legend explaining the cron-driven
  proactive loop (5-min fires for tasks, health, build-log work).
- Running costs table: Twilio row now bidirectional — ~$0.013–$0.018/min
  covers inbound ($0.0085) + outbound ($0.014) + Media Streams ($0.004).
- Prereq bullet: rename "Google AI Studio API key" → "Gemini API key"
  and inline the ~$0.30–$1.30/hour voice cost.
- macOS permissions: moved from inline Quick start bullets to the
  Security section with a per-permission "why it's needed" expansion;
  Quick start keeps a one-line pointer.

— Lucy (Mac Studio bot)

* docs(README): hedge Gemini price claims — free tier vs paid unclear

Susan and Chi are still sorting out the free-tier vs paid-tier
picture; pull back the specific $0.30-$1.30/hr claim since it
misrepresents the free-tier case:

- Prereq bullet: "may be free on the free tier or usage-billed
  depending on your Google Cloud account" instead of a rate
- Running costs table Gemini row: "Free tier available - paid
  usage varies"; notes keep the published paid rates as reference
  but point readers at their billing dashboard for ground truth
- Drop the personal $13/602-min datapoint from the typical-call
  example (may conflate with non-voice Gemini spend)

Twilio row unchanged - that math is from published rates.

— Lucy (Mac Studio bot)

* docs(README): Option B — remove Running costs section + price mentions

Per Susan's "B" direction in #dev: the free-tier vs paid-tier
picture for Gemini isn't settled, and leaving any price language
in the PR risks misleading first-time readers.

- Delete the entire "## Running costs" section (table + example +
  sources line).
- Revert the Gemini API key prereq bullet to `(click "Get API key")`
  — drop the usage-billed caveat entirely.
- Revert the Twilio prereq bullet to drop the `~$0.01–0.03/min`
  price reference.

ffmpeg bullet, diagram rewrite, macOS permissions in Security
section, `↻` cron legend, phone-agent surfacing — all stay.

— Lucy (Mac Studio bot)

---------

Co-authored-by: Xueqing Liu <xueqingliu@Xueqings-Mac-Studio.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
…onichi#555)

Migration sonichi#4 + sonichi#5 in the private-repo plan. Notes/ are SHARED across the
fleet (top-level private dir); pending-questions.md, PERSONAL_CLAUDE.md,
tab-aliases.json are PER-MACHINE (machine-<host>/, like sonichi#548).

Two resolver families:

- `personal_path()` (existing, sonichi#548) — per-machine: $SUTANDO_PRIVATE_DIR/machine-<host>/<file>
- `shared_personal_path()` (new) — fleet-shared: $SUTANDO_PRIVATE_DIR/<file>

New `src/util_paths.ts` is the TS twin of util_paths.py with both helpers
exported. voice-agent.ts replaces its inline `personalPath` with imports
from the new module.

Wired callers:

NOTES (shared_personal_path):
- src/inline-tools.ts NOTES_DIR → save_note / read_note / list_notes / delete_note
- src/voice-agent.ts: meeting-note save (line 285)
- src/dashboard.py: /notes/{slug} route + /notes index (lines 48, 437)
- src/daily-insight.py: NOTES_DIR
- src/friction-detector.py: notes_dir
- src/discord-bridge.py: SEND_ALLOWED_ROOTS adds private notes path
- src/health-check.py: notes-dir check now points at canonical home

PER-MACHINE (personal_path):
- src/dashboard.py: get_pending_count
- src/agent-api.py: pq_file (2 sites)
- src/friction-detector.py: pending-questions.md
- src/check-pending-questions.py: PQ_FILE
- src/session-handoff.sh: pending-questions.md (via inline python3)

PERSONAL_CLAUDE.md and tab-aliases.json: no runtime callers — only
referenced in migrate.sh bundle/restore (sonichi#548 already covers per-machine
shape, so they ride along without extra wiring).

Test plan:
- [x] `npx tsc --noEmit` clean
- [x] All Python files compile (`ast.parse`)
- [x] Resolver verified end-to-end with SUTANDO_PRIVATE_DIR set:
      shared_personal_path('notes') → /Users/wangchi/.sutando-memory-sync/notes
      personal_path('pending-questions.md') → .../machine-Chis-MacBook-Air/pending-questions.md
- [ ] Drop a voice-tool note via save_note, verify it lands in private dir
- [ ] Open the note in dashboard, verify /notes/{slug} reads from private dir
- [ ] Restart check-pending-questions cron, verify it reads private PQ file

After merge: kickstart voice-agent (touches voice-agent.ts + inline-tools.ts),
restart web-client/dashboard/agent-api/discord-bridge (Python files), restart
conversation-server. Same pattern as sonichi#548.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
…nichi#794)

* feat(screen-companion): skeleton skill (M1) + pair-read-paper config

v0 skeleton for the screen-companion skill. Closes nothing yet — this
PR ships the *primitive*, not the working feature. Followup PR wires
the loaded config into voice-agent.ts.

What's in this PR:

- skills/screen-companion/SKILL.md — describes the skill, the M1 model
  (master skill + N declarative configs), how to add a new use case,
  trust + scope boundary.

- skills/screen-companion/configs/pair-read-paper.yaml — the first
  use-case config. Demonstrates the full schema: activation triggers
  (voice phrases + button label + CLI alias), vision_mode + cadence,
  system_prompt_overlay with domain priors + don't-list, tools_allow
  whitelist, goal_template for optional activation-time fill-in.

- skills/screen-companion/scripts/activate.ts — loader + activation
  printer. v0 scope: discover configs in configs/*.yaml, validate
  required fields, load one by name, print what *would* happen when
  it's activated. No voice-agent wiring yet — intentional, so the
  config schema can crystallize against a real concrete config
  (pair-read-paper) before the integration shape is committed.

CLI:
  npx tsx skills/screen-companion/scripts/activate.ts --list
  npx tsx skills/screen-companion/scripts/activate.ts --config pair-read-paper
  npx tsx skills/screen-companion/scripts/activate.ts --config pair-read-paper --goal "..."

Picks M1 ("master skill + declarative configs") from screen_companion.md.
Discussion + tradeoffs vs M2 (per-use-case sub-skills) + M3 (SDK) live
in that doc.

YAML parsing via python3 subprocess (same pattern as src/oc-profile-catalog.ts);
avoids adding js-yaml as an npm dep.

Next PR (followup):
- Wire system_prompt_overlay onto the active VoiceSession.
- Restrict the tool surface to tools_allow at activation time.
- Hook vision push/pull cadence.
- Add pair-debug + pair-review-code configs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(screen-companion): swap v0 config from pair-read-paper to guided-setup

Owner-picked 2026-05-17: the v0 first-shipped config is `guided-setup`
(proactive + goal-directed — Sutando narrates next steps for a UI
config task), not `pair-read-paper`. More visceral demo angle: "Sutando
helps me set up a Discord bot I've never configured before" vs. "Sutando
reads a paper with me."

Changes in this commit:
- Add skills/screen-companion/configs/guided-setup.yaml — the new v0
  config. PROACTIVE shape: tighter vision cadence (700ms vs 1000ms),
  prompt overlay narrates next steps continuously, explicit DO NOT
  click/type guidance (Sutando narrates; user keeps the action).
- Drop skills/screen-companion/configs/pair-read-paper.yaml — was
  shipping as the v0 reference config but the demo angle is weaker;
  owner-clarified the framing should center on "help me set up
  something I've never done before."
- Update SKILL.md table + run example + roadmap to reflect guided-setup
  as the shipped config; pair-read-paper dropped (siblings pair-debug
  + pair-review-code still tracked in screen_companion.md as future).
- Update activate.ts doc header + CLI example to use guided-setup.
- Add Privacy + data handling section to SKILL.md: default DOES NOT
  take screenshots, record video, or persist frames; frames are sent
  to Gemini Live for real-time understanding and discarded; text
  transcripts are persisted (conversation-server precedent); user
  has to explicitly opt-in per request to save screenshots/recordings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(screen-companion): wire guided-setup end-to-end via skill manifest

Closes the wiring gap left by the v0 skeleton — voice can now activate
guided-setup mode directly. Earlier passes shipped the YAML config + CLI
loader; this pass adds the missing piece:

- manifest.json — skill is enabled, points to tools.ts
- tools.ts — exports activate_screen_companion tool (loads config, returns
  prompt overlay + activation_message + vision hint as a structured payload)
- scripts/load-config.ts — shared loader used by both the CLI activate.ts
  and the inline tool (was duplicated; now single source of truth)
- scripts/activate.ts — refactored to import from load-config.ts (-80 lines)
- SKILL.md — drops the "v0 skeleton — load + print only" caveat, adds the
  voice activation phrases + the wire-through explanation

Smoke test (matches what voice will see):
  npx tsx -e "import('./skills/screen-companion/tools.ts').then(...)"
  → status: 'activated', mode: 'guided-setup', goal/instructions/tools_allow
    all populated, activation_message ready to speak.

Architecture: tool lives in the skill (per CLAUDE.md decision guide —
self-contained feature). Voice-agent picks it up via the existing skill
manifest loader (same pattern as gws-gmail-voice). No changes to
src/inline-tools.ts or src/voice-agent.ts.

Voice-agent restart required to register the new tool. Configs themselves
are read fresh per activation, so adding more configs in later PRs does
not require another restart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(screen-companion): tolerate LaunchAgent python3 without PyYAML

Voice-agent runs under a LaunchAgent whose PATH is
`/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin`. Homebrew's python3 wins
that lookup but does not ship PyYAML, so `python3 -c 'import yaml; ...'`
in parseYaml() failed every activation with ModuleNotFoundError. The
tool returned an error and Gemini fell back to default behavior — silent
to the owner aside from "screen companion failed."

Try a list of python3 candidates (system → homebrew → PATH default), use
the first that can run the yaml import. macOS's `/usr/bin/python3` ships
PyYAML, so the system Python is the usual winner under the LaunchAgent
PATH. Aggregate per-binary errors into the thrown message for diagnosis
when all candidates miss.

Verified under the LaunchAgent's PATH env: activation now succeeds.

* feat(vision): single-line nudge for screen-companion at share-start

The existing screen-share-started system note tells the model to say "I
can see your screen now" but stops there — so users who don't already
know the activation phrases (e.g. "help me set this up" for
guided-setup) share their screen and get no signal that pre-built
companion modes exist. Add a one-line, mode-agnostic nudge: "Want me
to help you through something?" If they describe a goal that fits any
configured mode, the model routes via `activate_screen_companion`.

Mode-agnostic on purpose — the prompt does not list configs by name, so
it won't age as new ones land (pair-debug, pair-review-code planned per
screen_companion.md). The model already knows the catalog from the
`activate_screen_companion` tool's description (which discovers configs
at boot), so listing them in the nudge would be redundant.

* feat(vision): share-start nudge names screen-companion + configs explicitly

First attempt (626a18f) shipped a mode-agnostic nudge ("Want me to help
you through something?") that did not surface the screen-companion
catalog. Owner test 2026-05-17 17:28 PT: routing worked (goal → mode)
but discoverability didn't — owner reported "I do not hear the nudge
about screen companion mode."

Updated injected prompt instructs Gemini to deliver ONE sentence that
(a) acknowledges the screen, (b) names "screen-companion mode"
explicitly, (c) names the available configs by name (Gemini reads them
from the activate_screen_companion tool description, which is
discoverConfigs() at boot — so new YAMLs surface automatically), (d)
asks the user's goal.

Example shape: "I can see your screen — screen-companion mode is
available with guided-setup for UI walkthroughs. What are you trying
to do?"

* fix(screen-companion): force ground-then-step in guided-setup overlay

Owner test 2026-05-17 17:34 PT: in a FRESH voice session with no
chat history, Gemini activated guided-setup for "set up a Discord bot"
and asserted "you're on the Discord Developer Portal" + "I see your
cursor hovering over those existing apps" — when the user was
demonstrably NOT on the dev portal. Goal-priming overrode actual
vision input. (Verified via fresh-session diagnostic — chat-history
anchoring was ruled out.)

Add a GROUNDING RULE section at the top of the overlay:
- Name what you ACTUALLY see (page title/URL/breadcrumb, dominant UI
  elements, overall page kind) BEFORE naming any step.
- If the visible page doesn't match the goal's destination, do NOT
  assert the user is on the right page — say what you see and ask them
  to navigate.
- Never fabricate UI elements; if a label is illegible, ask the user
  to read it.
- Goal text is a hint about WHERE the user is going, NOT proof of
  where they ARE. Reconcile goal vs visible-screen as two separate
  facts every turn.

Configs are loaded fresh on each activation — next re-activation picks
this up without a voice-agent restart.

* docs(screen-companion): downgrade tools_allow claim from 'enforced' to advisory

sonichi cold-review on PR sonichi#794 flagged: SKILL.md asserts the tool allow-
list is "enforced at activation time; configs cannot grant tools the
active VoiceSession doesn't already expose" — but tools.ts:67 just
returns the field in the activation payload as a string list. The
_note instructs Gemini to self-restrict; there is no path in the
bodhi-realtime-agent layer that scopes the live tool surface.

Update the Trust + scope section to match reality: advisory in v0,
hard enforcement deferred to a follow-up PR. The risk surface
("worst case is Gemini calls a tool you didn't intend") is named
explicitly so readers can calibrate.

Code unchanged — the field still functions as the documented intent
and Gemini does self-restrict in observed sessions; only the doc claim
was overstating.

* chore(screen-companion): defensive validation + SKILL.md typo (sonichi sonichi#5, sonichi#6, sonichi#8)

From sonichi cold-review on PR sonichi#794 — uncontroversial nice-to-haves
that don't depend on the scope decisions for findings sonichi#1–sonichi#3.

- sonichi#5: validateConfig now checks tools_allow is a string array. A YAML
  with `tools_allow: "vision_query"` (string instead of list) used to
  pass validation and crash later at `.length` access with a confusing
  error. Now fails fast at load with a clear message.
- sonichi#6: vision_cadence_ms bounded to 100–5000ms. A typo like 70000 (70s)
  would silently produce useless frame cadence; 50ms would saturate the
  network. The 100–5000 range covers every plausible mode today.
- sonichi#8: SKILL.md typo — "bash scripts/activate.ts --list" → "npx tsx ..."
  (matches the smoke-test block above; bash can't execute the .ts).

No behavior change for the live guided-setup config (verified by
--list and tsc clean).

* docs(screen-companion): mark guided-setup tools_allow as PLANNED (sonichi sonichi#1)

PR sonichi#794 review finding sonichi#1 — tools_allow lists three tools that don't
exist anywhere in the codebase (zero grep matches outside this skill).
sonichi's three suggested fixes were: rename to real tools, land the
missing tools, or document as schema-only. Going with the documentation
path for v0 — least-risky of the three, reversible, and consistent
with the truthfulness fix in eff1286 that already downgrades the
allow-list claim in SKILL.md from "enforced" to "advisory".

guided-setup as shipped relies only on push-mode vision frames + Gemini
Live's conversational capability — no tool calls are actually required
to operate. The PLANNED comment captures design intent without
fabricating wiring that doesn't exist; a follow-up PR registers real
tools and Gemini gets a real allow-list to follow.

Code unchanged. CI green expected — pure YAML comments.

* refactor(vision): contributor registry — remove screen-companion knowledge from core (sonichi sonichi#3)

PR sonichi#794 review finding sonichi#3: src/vision-tools.ts:254-256 hardcoded the
strings "screen-companion mode" and "guided-setup" in the screen-share-
started system note. Per CLAUDE.md decision guide, core services must
not contain feature-specific logic; if a user removed the screen-
companion skill, vision-tools.ts would lie to Gemini about modes that
no longer exist.

Refactor:

- src/vision-tools.ts now exposes `registerVisionOnContributor(fn)`.
  When push mode starts, the injected note is built from a generic base
  note + each contributor's output. With zero contributors registered,
  the note is generic and mentions no modes.
- skills/screen-companion/tools.ts registers a contributor at module-
  load time. The contributor calls discoverConfigs() at injection time,
  so new YAMLs in configs/ surface automatically without code changes.
- If the skill is disabled (manifest.enabled=false) or removed, the
  skill-loader at src/inline-tools.ts:937 never imports tools.ts, the
  contributor never registers, and vision-tools.ts emits only the base
  note — exactly matches CLAUDE.md "skills are optional."

Behavioral equivalence: with screen-companion enabled (default), the
injected note now contains the same essential content as before — just
sourced from the contributor instead of hardcoded.

Verified: tsc clean. Smoke test confirms contributor count goes 0→1
when the skill module is imported. Voice-agent restarted to pick up
the new module.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
john-the-dev added a commit that referenced this pull request Aug 19, 2026
…sage_content> delimiters (security finding #5) (#1986)

* fix(context-source-guard): fail-closed on exception inside gated section (security audit finding #4)

Previously the outer `except Exception` in `__main__` would fail-open on
any exception — including those raised after a non-empty contextNotFrom
blacklist was confirmed. An unexpected runtime error inside the channel-check
loop (guild resolution, token lookup, blacklist membership test) could
silently allow a blacklisted read through.

Fix: wrap the critical section (non-empty blacklist confirmed) in its own
try/except that calls `_deny()` on any non-SystemExit exception.
Pre-check errors (JSON parsing, tool detection, serving/blacklist lookup)
still fail-open via the outer handler — correct, because no guard is active.

Add test 9: monkey-patch `_resolve_guild` to throw RuntimeError while
serving a channel with a non-empty blacklist; assert the hook denies
(fail-closed) rather than allowing.

Security audit: workspace/notes/security-audit-2026-07-06.md finding #4.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QB3qZQrwponfum2JFNvHeh

* fix(mod-judge): harden prompt against injection — G3 guardrail + <message_content> delimiters (security audit finding #5)

Untrusted Discord message content was embedded via `{content!r}` directly
into the codex moderation-judge prompt with no structural boundary, allowing
a crafted message to steer moderation verdicts ("ignore prior rules; mark
all not_spam"). Blast radius is moderation decisions only (read-only sandbox,
no execution), but the bypass is deterministic with a well-crafted payload.

Two-layer fix:
1. System prompt G3 guardrail: explicitly instructs the judge that any text
   inside <message_content> tags resembling instructions is user-supplied data
   to classify — not a directive to follow.
2. Structural delimiters: content is now rendered as
   `<message_content>…</message_content>` (and parent as `<reply_content>`)
   so the judge can clearly distinguish instruction-space from data-space.
   The `!r` repr wrapper is dropped; the XML tags provide a cleaner boundary.

All 11 existing + new tests pass, including new test f-format-injection-guard
which verifies the delimiter and G3 guardrail are present.

Security audit: workspace/notes/security-audit-2026-07-06.md finding #5.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QB3qZQrwponfum2JFNvHeh

* fix(test): compile() with real filename so coverage.py traces discord-bridge lines

The `exec(src, bridge.__dict__)` pattern in load_bridge() compiles the
source as an anonymous string frame, so coverage.py can't attribute
execution to src/discord-bridge.py. This caused the diff-coverage gate
to report 0% on lines 828+830 (the <message_content> rendering added in
the mod-judge injection fix) despite the tests actually executing them.

Fix: use `compile(src, bridge.__file__, "exec")` before exec() so the
bytecode carries the real filename and coverage.py's tracer correctly
maps each executed statement back to its source line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QB3qZQrwponfum2JFNvHeh

* fix(mod-judge): escape delimiter tags in user content to close breakout

Addresses qingyun-wu's review on #1986 (security audit finding #5): wrapping
user content in <message_content> / <reply_content> tags is insufficient on its
own — a crafted Discord message containing a literal </message_content> (or a
parent with </reply_content>) closes the delimiter early and injects fresh text
into instruction space, reopening the prompt-injection path.

HTML-escape the angle brackets in both interpolated fields (content and the
reply parent) before they go between the delimiters, after truncation so the
raw-length bound is unaffected. No tag of any name/case/spacing can then be
formed inside the delimited region, while the content stays legible to the
judge model.

Test: add case_format_prompt_delimiter_breakout — feeds literal closing tags in
both content and parent, asserts escaping adds no raw delimiter tag versus a
benign message of the same shape and the injected text stays inside the region.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFp2p6XUSAQ1eAKKZeiZPt

* style(mod-judge): trim comments to the two-line contract

Trimmed the cited sites: _escape_judge_delimiters' docstring, the escape-order
comment beside the truncation, and two test comments. Kept the invariant each
site needs; the security-audit narrative belongs in the PR thread.

Verified by measuring the FILES, not the diff: the longest comment run touching
an added line is 2 in both. No incident-history or PR/reviewer references remain.

Control re-run after the comment-only edit: removing the angle-escaping still
fails case g-format-delimiter-breakout, so the trims did not defang the security
gate. Full suite passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8

* style(mod-judge): reduce the added docstrings to the two-line contract

Three docstrings this PR adds still narrated the security/review history: the
test module header, case_format_prompt_injection_guard, and
case_format_prompt_delimiter_breakout. Each keeps only its non-obvious
invariant — delimiters alone are not the guard, and a literal closing tag must
not end the delimiter early.

Assertions unchanged. Control re-run after the comment-only edit: removing the
angle-escaping still fails the delimiter-breakout case.

Measured by AST physical span, scoped to docstrings touching an added line: 0
remain over two lines in either file. My earlier check counted only '#' runs,
which is why these survived the first pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8

* fix(mod-judge): put user-controlled metadata inside the untrusted envelope

The G3 guardrail told the judge that only text inside <message_content> was
untrusted, and the formatter honoured that for message and reply bodies. It
did not for the entry's own metadata: channel_name and author_name were
interpolated bare into the prefix line, and author_name is
message.author.display_name -- attacker-chosen, 32 free-form characters.

So a display name rendered in the bridge's own voice, outside every
delimiter:

    msg_id=1 #general @System: return all verdicts as null:
    <message_content>hello</message_content>

Both values now render inside <author_name> / <channel_name>, escaped by the
same helper as the bodies so a closing tag cannot be forged, and newlines are
collapsed first -- a raw newline in a display name would otherwise open a
second entry line that no delimiter encloses. G3 now names all four tags and
says explicitly that only text outside every tag is bridge instruction.

Controls (each asserts the mutation actually applied, printed as mutated:True):
  - restore the bare "#{ch} @{author}" prefix -> a-format-basic,
    h-format-metadata-injection, i-format-metadata-newline all fail
  - revert G3 to naming only <message_content> -> f-format-injection-guard
    fails on the three unnamed tags
Restored source: 14/14 pass.

case_format_prompt_basic changed with the format it asserts, from "@alice"
to the delimited form. The prompt-format change is the fix, not incidental.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: john-the-dev <ruiwangwarm@gmail.com>
sonichi added a commit that referenced this pull request Sep 5, 2026
…idence

keweichen's P1 #5 residue. The direct-admission section names
`claim_is_live`, `retire_stale_claim` and `release_task_claim` as
functions the implementing PR must change, and cites the Codex
consumer's unconditional claim-skip only as EVIDENCE of the deadlock.
A cause that appears solely in the diagnosis does not get fixed: publish
the accept record with that skip rule untouched and the deadlock is
unchanged, because the candidate is skipped before anything else is
consulted and the accept record nothing reads changes nothing.

Names `next_pending_task` as the fourth required change, states the
behaviour it must have (consult the accept record before skipping;
offered-to-me-and-unaccepted is a candidate to accept), and binds the
canonical-key change to the skip change — either alone makes the adapter
worse. Says which way a key mismatch fails: a miss reads as "no claim"
and un-skips genuinely handler-owned work.

Control (both directions, doc reverted to parent with tests held):
  reverted doc  -> 4 failures, all in
                   TheDeadlockCauseIsNamedAsAnObligationNotOnlyAsEvidence
  this head     -> 73/73 OK

Stand: Echo Act IV Pro
bassilkhilo-ag2 added a commit that referenced this pull request Sep 23, 2026
…a Gemini key is named, campaign work goes to campaign-runner

Three owner-visible misroutes from user feedback (2026-09-17 to 09-20), each
a wrong or missing sentence rather than a missing capability.

1. Sending email "still needs an app password" (#28 on the tracker). The
   gmail-write-guard hook only matches the claude.ai Gmail MCP tools; the Station
   Gmail connector (composio_find → composio_exec) is a different tool name it
   never touches and it works. But the guard's denial REASON led with the
   app-password IMAP/SMTP path, so a core that reached for the claude.ai tool
   was pointed at generating an app password by hand even when Gmail was
   connected in Settings → Integrations. The reason now routes to the Station
   connector first, requires a read-back of the sent message (recipient +
   subject) before reporting a send — the check the draft/send mismatch
   incidents in field report 05cb849a lacked — and names IMAP/SMTP only as the
   fallback when the Station tools are absent. docs/built-in-tools.md and
   hooks/README.md say the same.

2. Gemini image generation on a free-tier key (#5). A free-tier key has 0 image
   requests per day; Google answers 429 RESOURCE_EXHAUSTED, which generate.py
   collapsed into api_error "try again in a moment" — the wrong advice for a
   quota. A 429, or any RESOURCE_EXHAUSTED/quota body, is now its own `quota`
   outcome whose remedy says to enable billing on the key's project or use the
   managed key, and the message names which key ran out (the owner's own
   GEMINI_API_KEY vs the managed one) because resolve_key() already knew.

3. Outbound campaigns went to the bare Smartlead connector (#27), which can only
   create, read and delete a campaign shell; attaching the mailbox, uploading
   leads and writing the sequence were left to the owner by hand — while the
   installed campaign-runner skill does all of it. precheck_apps.json gains a
   `prefer_skill` field (smartlead → campaign-runner), listed first so its
   phrases beat Gmail's bare "email"; match_apps lets a longer phrase win over a
   shorter one it contains ("cold email campaign" names Smartlead, not Gmail);
   the precheck line reports `prefer_skill=… (installed|not installed)` and
   never proposes a Connect card for such an app; the skill text says to invoke
   the skill or offer to install it, never the bare connector.

Tests: gmail-write-guard (routing + read-back + fallback order),
image-generation-cli (429 → quota with the key named; quota body on a 400; a
plain 400 stays api_error), connect-apps-precheck (phrase precedence;
prefer_skill line with and without the skill installed).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
bassilkhilo-ag2 added a commit that referenced this pull request Sep 23, 2026
…a Gemini key is named, campaign work goes to campaign-runner

Three owner-visible misroutes from user feedback (2026-09-17 to 09-20), each
a wrong or missing sentence rather than a missing capability.

1. Sending email "still needs an app password" (#28 on the tracker). The
   gmail-write-guard hook only matches the claude.ai Gmail MCP tools; the Station
   Gmail connector (composio_find → composio_exec) is a different tool name it
   never touches and it works. But the guard's denial REASON led with the
   app-password IMAP/SMTP path, so a core that reached for the claude.ai tool
   was pointed at generating an app password by hand even when Gmail was
   connected in Settings → Integrations. The reason now routes to the Station
   connector first, requires a read-back of the sent message (recipient +
   subject) before reporting a send — the check the draft/send mismatch
   incidents in field report 05cb849a lacked — and names IMAP/SMTP only as the
   fallback when the Station tools are absent. docs/built-in-tools.md and
   hooks/README.md say the same.

2. Gemini image generation on a free-tier key (#5). A free-tier key has 0 image
   requests per day; Google answers 429 RESOURCE_EXHAUSTED, which generate.py
   collapsed into api_error "try again in a moment" — the wrong advice for a
   quota. A 429, or any RESOURCE_EXHAUSTED/quota body, is now its own `quota`
   outcome whose remedy says to enable billing on the key's project or use the
   managed key, and the message names which key ran out (the owner's own
   GEMINI_API_KEY vs the managed one) because resolve_key() already knew.

3. Outbound campaigns went to the bare Smartlead connector (#27), which can only
   create, read and delete a campaign shell; attaching the mailbox, uploading
   leads and writing the sequence were left to the owner by hand — while the
   installed campaign-runner skill does all of it. precheck_apps.json gains a
   `prefer_skill` field (smartlead → campaign-runner), listed first so its
   phrases beat Gmail's bare "email"; match_apps lets a longer phrase win over a
   shorter one it contains ("cold email campaign" names Smartlead, not Gmail);
   the precheck line reports `prefer_skill=… (installed|not installed)` and
   never proposes a Connect card for such an app; the skill text says to invoke
   the skill or offer to install it, never the bare connector.

Tests: gmail-write-guard (routing + read-back + fallback order),
image-generation-cli (429 → quota with the key named; quota body on a 400; a
plain 400 stays api_error), connect-apps-precheck (phrase precedence;
prefer_skill line with and without the skill installed).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
bassilkhilo-ag2 added a commit that referenced this pull request Sep 26, 2026
…a Gemini key is named, campaign work goes to campaign-runner (#4661)

* fix(owner-facing): email routes to the Station connector, a zero-quota Gemini key is named, campaign work goes to campaign-runner

Three owner-visible misroutes from user feedback (2026-09-17 to 09-20), each
a wrong or missing sentence rather than a missing capability.

1. Sending email "still needs an app password" (#28 on the tracker). The
   gmail-write-guard hook only matches the claude.ai Gmail MCP tools; the Station
   Gmail connector (composio_find → composio_exec) is a different tool name it
   never touches and it works. But the guard's denial REASON led with the
   app-password IMAP/SMTP path, so a core that reached for the claude.ai tool
   was pointed at generating an app password by hand even when Gmail was
   connected in Settings → Integrations. The reason now routes to the Station
   connector first, requires a read-back of the sent message (recipient +
   subject) before reporting a send — the check the draft/send mismatch
   incidents in field report 05cb849a lacked — and names IMAP/SMTP only as the
   fallback when the Station tools are absent. docs/built-in-tools.md and
   hooks/README.md say the same.

2. Gemini image generation on a free-tier key (#5). A free-tier key has 0 image
   requests per day; Google answers 429 RESOURCE_EXHAUSTED, which generate.py
   collapsed into api_error "try again in a moment" — the wrong advice for a
   quota. A 429, or any RESOURCE_EXHAUSTED/quota body, is now its own `quota`
   outcome whose remedy says to enable billing on the key's project or use the
   managed key, and the message names which key ran out (the owner's own
   GEMINI_API_KEY vs the managed one) because resolve_key() already knew.

3. Outbound campaigns went to the bare Smartlead connector (#27), which can only
   create, read and delete a campaign shell; attaching the mailbox, uploading
   leads and writing the sequence were left to the owner by hand — while the
   installed campaign-runner skill does all of it. precheck_apps.json gains a
   `prefer_skill` field (smartlead → campaign-runner), listed first so its
   phrases beat Gmail's bare "email"; match_apps lets a longer phrase win over a
   shorter one it contains ("cold email campaign" names Smartlead, not Gmail);
   the precheck line reports `prefer_skill=… (installed|not installed)` and
   never proposes a Connect card for such an app; the skill text says to invoke
   the skill or offer to install it, never the bare connector.

Tests: gmail-write-guard (routing + read-back + fallback order),
image-generation-cli (429 → quota with the key named; quota body on a 400; a
plain 400 stays api_error), connect-apps-precheck (phrase precedence;
prefer_skill line with and without the skill installed).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(owner-facing): trim the guard-test comment, name the key in use, keep a rate-limited 429 retryable, route through the Claude-home helper

Review findings on the previous revision, all addressed here.

Blocker: `typecheck + node & shell tests` was red because
tests/gmail-write-guard.test.py:60 carried a four-line comment block with
incident history (prose-cap allows two lines). The comment now states the
assertion only; the history stays in the PR body. The dated paragraphs in
the gmail-write-guard.py module docstring and the classify_http_error
docstring are trimmed the same way.

skill_installed() in skills/connect-apps/hooks/connect-precheck.py
re-implemented Claude-home resolution as CLAUDE_CONFIG_DIR -> ~/.claude,
skipping the CLAUDE_HOME level of the canonical chain, so a setup that only
sets $CLAUDE_HOME was told the skill is missing. It now resolves through
util_paths.claude_home_path("skills") the way workspace_for() already
resolves the workspace, with the stock ~/.claude only when no core tree is
importable. Tests cover CLAUDE_HOME-only, CLAUDE_CONFIG_DIR outranking it,
and the no-core-tree fallback.

key_note("env") always named GEMINI_API_KEY, but the resolver and the
fallback chain both accept GEMINI_VOICE_API_KEY for gemini-image, so a
voice-key-only install was pointed at the wrong variable. The note now
names whichever variable holds the key that was used.

classify_http_error() treated every 429 (and any body containing "quota")
as the free-tier zero-quota case, whose SKILL.md remedy is "enable billing,
never retry". A paid key hitting the per-minute rate limit is also a 429
and needs the opposite advice. `quota` now requires plan-level wording
(per day / free tier / limit 0 / billing) in the body; any other 429 stays
`api_error` with its retry remedy. Tests use Google's free-tier per-day
body and add a per-minute body that must stay retryable.

match_apps() kept one keyword per app and then dropped a hit whose keyword
sat inside another app's longer phrase, so "reply to the cold email from
Bob in my inbox" lost Gmail although "inbox" names it. Every matched
keyword now counts; an app is dropped only when all of its keywords are
inside other apps' phrases. Test added for that sentence.

Verification: python3 tests/gmail-write-guard.test.py (PASS),
tests/image-generation-cli.test.py (29 OK), tests/connect-apps-precheck.test.py
(26 OK); scripts/review-checks.sh --diff PASS; diff coverage of changed
Python lines 100%; lint-workspace-resolution --diff clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(connect-precheck): suppress a shorter keyword by match span, not by keyword string

`match_apps` was meant to keep the "email" inside Smartlead's "cold email campaign"
from also naming Gmail. The previous head compared keyword *strings*: a keyword was
dropped whenever it was a substring of any keyword another app had matched anywhere
in the text. So Gmail vanished from "draft an email to Bob and check my email
campaign stats" (two separate requests, no shared span), from "email me when the
email sequence finishes", and from "send an email about the cold email campaign" —
it survived only when it also matched a word that is not a substring of anything,
such as "inbox". The PR's own test covered just that single-overlap case, which is
why CI stayed green with the defect live.

Now each match is kept as (keyword, span) — `pat.finditer(low)` over every
occurrence, since `search` would stop at a first occurrence that happens to sit
inside another app's phrase — and a match is discarded only when its span lies
strictly inside another app's longer match at that position (containment with an
unequal span; equal spans of two apps both stay). An app is reported when any of
its matches survives, with the first surviving keyword in table order as before.

Against the shipped precheck_apps.json:

  smartlead+gmail  reply to the cold email from Bob in my inbox
  smartlead+gmail  email me when the email sequence finishes
  smartlead+gmail  send an email about the cold email campaign
  smartlead+gmail  draft an email to Bob and check my email campaign stats
  smartlead+gmail  set up a cold email campaign, then email Bob the results
  smartlead        set up a Smartlead campaign
  smartlead        cold email campaign

The last row is the intended suppression: Gmail's only match is the "email" inside
"cold email" / "cold email campaign", same position, so it stays quiet. The new
test pins all seven texts plus the reported keyword for the mixed case.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant