Repository navigation
feat(discord-voice,phone): per-channel pull path for non-delegated task-results (narrow) - #1033
Conversation
…sk-results (narrow)
Add a scoped result-filename namespace `results/<channel-key>.task-{id}.txt`
that gives the two non-delegating voice surfaces — discord-voice and phone
— a native pull path for task results without modifying any existing
consumer.
The regular `task-{id}.txt` filename remains the default for every task,
and every existing consumer (discord-bridge, telegram-bridge, slack-bridge,
task-bridge, agent-api) keeps using it. The scoped form is invisible to
them because their filters key off the legacy shape:
- discord/telegram/slack bridges look up `RESULTS_DIR / f"{task_id}.txt"`
for a specific tracked task_id; a `<key>.task-{id}.txt` filename does
not equal any tracked `${tid}.txt`.
- agent-api globs `task-*.txt`; the scoped form does not start with `task-`.
- task-bridge.ts checks `startsWith('voice-')` / `startsWith('task-')` /
`startsWith('task-chat-')` for each routing path; the scoped form
starts with the channel key.
Channel keys this PR emits:
- discord-voice → Discord voice channel id (CHANNEL_ID)
- phone → Twilio call SID (per-call unique so concurrent calls never cross)
Both new scans run inside their respective service processes, do
read-and-delete on match, and inject the body through the same
`transport.sendContent` path the work-tool resultQueue drain uses. Cadence
is 3s — this path is for cross-surface handoffs, not in-conversation
turn-taking.
What changed:
- src/result-channel-key.ts + src/result_channel_key.py — tiny shared
filename helper (sanitize/build/parse/match).
- skills/discord-voice/scripts/discord-voice-server.ts — channel scan
keyed on CHANNEL_ID, started in createVoiceSession, torn down in
cleanupSession.
- skills/phone-conversation/scripts/conversation-server.ts — per-call
channel scan keyed on callSid, started at end of createCallSession,
torn down in cleanupCall.
- CLAUDE.md — documents the convention under the Task bridge section.
- tests/result-channel-key.test.{ts,py} — unit coverage + the
load-bearing invariant tests that replay each existing consumer's
pattern against a sample scoped filename.
What intentionally didn't change:
- Existing consumers (discord-bridge.py, telegram-bridge.py,
slack-bridge.py, task-bridge.ts, agent-api.py) — out of scope by
design; touching them is what the narrow scoping avoids.
- proactive-*.txt and voice-*.txt routes — untouched.
- The default `task-{id}.txt` filename for normal results — untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Folds both #1033 review findings into the existing narrow design. Fix 1 — .txt guard against partial-write races (MacBook): - Add explicit `name.endsWith('.txt')` guard at the top of both scan loops (discord-voice-server.ts and conversation-server.ts), BEFORE `resultBelongsTo()`. A writer's atomic-write temp (`<key>.task-X.txt.tmp`, `.sending`, `.partial`, etc.) must never be picked up — reading/unlinking it before the rename completes would inject a half-written body and orphan the rename target. - Tighten `resultBelongsTo()` itself (both TS and Python) to require an exact `.txt` ending — belt-and-suspenders with the loop guard. - New test cases in tests/result-channel-key.test.{ts,py} explicitly reject `.txt.tmp`, `.txt.partial`, `.txt.sending`, `.txt.swp`, `.txt.lock`, `.txt~`, bare `.tmp`/`.sending`/`.partial` suffixes, and dotfile-prefixed names — plus a positive canonical-`.txt` sanity check to confirm no over-rejection. Fix 2 — channelScanSeen unbounded growth (Maddy): Picked PATH (b) — keep as safety net, prune at 60s. Reason: the `unlinkSync(full)` calls in both scan loops are wrapped in `try {} catch {}` and fail silently on the unhappy path (permissions, file vanished mid-tick, FS race). If unlink fails AND we drop the Set, the same body re-injects every 3s. The map (`name -> first-seen ms`) survives those failures while a 60s TTL prune at the top of each tick keeps memory bounded — even across hour-long calls / multi-day voice sessions. `Map<string, number>` instead of parallel Set+timestamp store, so prune is one pass. PR housekeeping: task-bridge.ts:682 belt-suspenders is tracked as follow-up #1035 (`narrow-scope-followup` tag), per design point 6 in #1033 body — this PR keeps existing consumers unmodified. Tests: - tests/result-channel-key.test.ts: 18/18 pass (was 16; +2 new) - tests/result-channel-key.test.py: 18/18 pass (was 16; +2 new) - npm test: 262/262 TS pass, no regressions - npm run typecheck: clean Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Folded both review findings into this branch. Fix 1 —
Fix 2 — Tracking: Verify:
|
qingyun-wu
left a comment
There was a problem hiding this comment.
Read the helper + both scans, and traced the "slides past existing consumers" claim against the actual consumer code. The helper is well-built (safe-alphabet collapse kills path/regex injection; the . separator cleanly excludes legacy task-*.txt) and the discord-voice scan is careful (TTL-pruned seen-map, .txt guard, cleanup handle). But the core safety claim doesn't hold for one consumer — and it's a data-loss / mis-delivery race.
⚠️ task-bridge.ts does NOT slide past — its catch-all grabs scoped files
The PR states all existing consumers (incl. task-bridge.ts) "key off the legacy task-{id}.txt shape." True for the dedup and voice-offline-forward branches (both gated on file.startsWith('task-')), but the final delivery branch is an unguarded catch-all.
src/task-bridge.ts:590 reads every .txt:
const files = readdirSync(RESULT_DIR).filter(f => f.endsWith('.txt')).sort();and when the voice client is connected, the final block (~682) has no task- guard:
if (result) { // any non-empty .txt that fell through
_sendTaskStatus?.(taskId, 'done', ...);
onResult(result); // delivered to the web voice client
... setTimeout(() => archiveFile(path, ...), 10_000) // and deleted
}So with the voice web client connected, a <CHANNEL_ID>.task-{id}.txt file is (a) mis-delivered to the web voice agent via onResult, and (b) archived/deleted ~10s later — racing the discord-voice/phone channel scan (3s) that's supposed to own it. Net: the scoped result reaches the wrong surface and/or vanishes. When the client is offline, the continue at ~680 leaves it alone — so the bug only bites when voice-agent's client is connected (intermittent, hard to debug).
Fix: guard the catch-all to legacy files only — e.g. if (!file.startsWith('task-')) continue; before the final delivery block (or reuse parseResultFilename and skip when key !== null). Then scoped files are genuinely invisible to task-bridge.ts and owned solely by the channel scans. A test that drops <key>.task-x.txt into results/ and asserts the task-bridge poll leaves it untouched would lock it.
(Confirmed the others are fine: agent-api.py globs task-*.txt, discord-bridge.py gates on _task_id.startswith("task-") — both miss the <key>. prefix. It's specifically task-bridge.ts's catch-all.)
Minor
- Phone key = Twilio call SID (ephemeral, per-call). How does a deliverer learn the live call's SID to address
results/<SID>.task-x.txt? discord-voice'sCHANNEL_IDis stable/knowable; the SID isn't obviously discoverable by the core agent — worth documenting how a producer obtains the key, or the phone half is unreachable in practice. - Producer? This PR adds the scan +
resultFilenamehelper but I don't see a writer emitting the scoped file. If it's intentional scaffolding ahead of the producer, fine — just confirm it's not dead code until then.
Net: helper + scans are good; the one fix that matters is guarding task-bridge.ts's catch-all, since the PR's safety hinges on a claim that isn't currently true for that consumer.
…ough (closes #1035) (#1036) * fix(task-bridge): belt-suspenders guard against non-task .txt fallthrough (closes #1035) PR #1033 introduced `results/<channel-key>.task-{id}.txt` as a new filename namespace for the per-channel pull path used by discord-voice + phone. The existing consumers don't pick those up, except for one fragile spot: `src/task-bridge.ts`'s result-watcher has an unconditional fallthrough that, when voice-agent's client is connected, fires `onResult()` for any non-empty `.txt` file past the `voice-*` / dedup / offline-forward branches. Without a guard, a `<key>.task-foo.txt` file could race the per-channel scanner and *also* be injected into voice. PR #1033 accepted the race (scanner's read-and- delete usually wins); this guard closes it. Change: - Add `_shouldFallthrough(file)` predicate near `_isVoiceTask` — returns true only for `task-*` / `voice-*` prefixes. Rejects the new-namespace files, `proactive-*` (discord-bridge's poll_proactive handles those), and any unfamiliar prefix. - Gate the fallthrough at the result-watcher with `if (!_shouldFallthrough(file)) continue;` before the existing `if (result)` block. - Test: `tests/task-bridge-fallthrough-guard.test.ts` exercises the predicate across canonical-task / voice-push / channel-pull-namespace / proactive / unknown-prefix cases. Anchored to the start of the filename — `prefix-task-` and `xtask-` are correctly rejected. No behavioral change for the existing `task-*` / `voice-*` paths (both still fall through). `proactive-*` was previously hitting the fallthrough as a latent duplicate-delivery hazard (discord-bridge's `poll_proactive` also consumes it); the guard now skips it cleanly, matching its actual intended consumer. Verified: - `npm run typecheck` clean - 268 existing TS tests pass + 6 new tests pass - No other consumer modified Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(task-bridge): allow proactive-* through the fallthrough guard The #1035 belt-suspenders guard rejected `proactive-*.txt`, framed as "closing a duplicate-delivery hazard." That framing was wrong — per the long-standing proactive-voice rule, `proactive-*` files are spoken by the voice agent when the client is connected. That delivery has no explicit handler upstream in startResultWatcher; the unconditional fallthrough IS the path. Blocking it silently disables voice-spoken proactive messages. Add `proactive-` to the `_shouldFallthrough` allowlist so the original delivery path survives. The guard still rejects `<channel-key>.task-*` (the actual #1035 target). Flip the proactive test case from "rejects" to "allows", with a docstring explaining why both consumers coexist. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…amespace, not voice-session-context.json Owner correction 2026-05-23 22:05Z: > "Core reads its own conversation state to populate active_drafts + > last_results in state/voice-session-context.json" that's not right. > I don't know if that json file works. Writing a result file has > been the approach we are discussing lately. The previous synthetic task instructed the core to update `state/voice-session-context.json` — a schema documented in CLAUDE.md but never verified to actually flow into the voice session. Switching to the per-channel pull namespace (PR #1033) which IS the documented + tested mechanism: - Core writes `results/<voice-channel-id>.task-<core-task-id>.txt` - discord-voice-server scans the namespace once connected - File body is injected into the live Gemini session via `transport.sendContent` (NOT spoken — session input, like a tool drain). Same path discord-voice and conversation-server already use. The synthetic task body still carries ONLY metadata (phrase + channel id/name). The core agent picks what conversation-state details to put in the result file body — bridge doesn't pass any of that through. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…keover + context-prep (#1080) * feat(discord-voice): "za warudo" voice-join magic word (WIP — approach pending owner call) join_trigger.py (discord-voice skill) owns all feature logic — join-phrase resolution from config, owner's voice-channel lookup, double-launch guard, discord-voice-server spawn. discord-bridge.py carries only a thin detect + delegate hook. Parked pending owner's decision: thin-bridge-hook vs pure agent-recognized magic word. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(discord-voice): za-warudo magic word — broader phrase match + synthetic context-prep task Brings the WIP commit `0dc4403` (parked 2026-05-22) to ship-ready per 2026-05-23 owner call (option A — thin bridge hook + skill takeover). ## Changes vs the parked WIP 1. **Broader phrase match** — `message_is_join_phrase()` now uses a word-boundary rule (non-alphanumeric / non-underscore after the phrase) instead of strictly requiring " " or "\n". Catches the natural `ZA WARUDO!` / `za warudo, please` / `za warudo? join` cases while still rejecting `za warudonow` (alnum boundary). 2. **Synthetic context-prep task post-spawn.** New `_enqueue_context_prep_task()` drops a task file into `<workspace>/tasks/task-{ts}.txt` immediately after a successful voice-server spawn: ``` source: system-magic-word priority: urgent access_tier: owner task: [SYSTEM] Magic word '<phrase>' fired. ... Update state/voice-session-context.json: set pending_action.kind= 'joined-voice', .where='<channel-id>', .what='joined voice via <phrase>'. Then enrich active_drafts + last_results from your current conversation state. [no-send] ... ``` The core agent picks it up via the streaming watcher and updates `state/voice-session-context.json` so the voice session's `recent_context` tool has fresh state by the second turn (first turn falls back to the default greeting if it races). **Privacy:** the synthetic task body carries ONLY metadata (which phrase fired, which channel id/name). No user content — no draft text, no result snippets. The core reads its own conversation state to populate active_drafts + last_results; we don't pass any of that through the bridge. `[no-send]` marker so the bridge silently archives the result file — there's no Discord reply owed; the user sees the bridge's "On my way to <channel>" message and the voice greeting. 3. **Non-blocking ordering.** Spawn → enqueue task → return. The bridge does not wait for the core to consume the synthetic task. Voice arrives immediately; context lands on the next watcher tick (~1-3s). If the core lags, voice degrades gracefully to default greeting on turn 1 — design verified in the owner call. 4. **Best-effort task write.** `_enqueue_context_prep_task()` swallows any exception. Observability shouldn't block the user-facing voice spawn. ## How to use Owner types `za warudo` (any casing/trailing-punctuation variants work) in any Discord surface where the bridge sees their message AND they're currently in a voice channel in the same guild. Bridge replies with "On my way to **<channel>**" and discord-voice-server connects. Phrase is config-driven via `<workspace>/config/discord-voice.json` `join_phrase` key (committed example: `skills/discord-voice/ config.json.example`). ## Tests Hand-tested all 12 phrase-match cases (exact, exclamation, comma, trailing words, leading/trailing whitespace, embedded \\n, alnum boundary rejection, etc.). Synthetic-task write tested against a temp workspace dir — confirmed correct task-file format, no real- workspace pollution after env cleanup. (Heads-up: importing this module triggers `workspace_default.py`'s one-time notes/ migration; isolate tests with $SUTANDO_WORKSPACE pointed at a tempdir AND restore any moved files post-test.) ## Out of scope (next steps) - The synthetic task body still mentions config keys (active_drafts, last_results) that this PR doesn't itself populate. Core agent knows how to enrich those — that's the design point. - Option B (pure-agent recognition) and Option B' (hybrid) remain available if owner wants paraphrase coverage later; this PR ships the deterministic bridge path first. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): move za-warudo magic-word check AFTER dedup block The cherry-picked WIP commit (0dc4403) placed the join-trigger between the access-tier determination and the dedup block. That made the access-tier block grow past the 2000-char window the structural test (`tests/discord-bridge-access-tier.test.py`) uses to grep for it, failing CI on PR #1080. Move the join-trigger AFTER dedup. Two upsides: 1. Test regex window stays valid (access-tier block ends at the next `# Dedup:` marker as before). 2. Gateway replay can't double-fire the spawn — dedup catches it at the front gate. The `seen_message_ids.add()` line the WIP did manually is redundant now; removed. No behavior change for legitimate first-fire owner messages: dedup is a no-op on never-seen ids, and the magic-word check runs immediately after with the same conditions (owner + match). Removed the `not force` check too — `force=True` only matters for the on_message_edit reprocess path, where re-firing the magic word on an edited message is fine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-voice): synthetic context-prep targets per-channel pull namespace, not voice-session-context.json Owner correction 2026-05-23 22:05Z: > "Core reads its own conversation state to populate active_drafts + > last_results in state/voice-session-context.json" that's not right. > I don't know if that json file works. Writing a result file has > been the approach we are discussing lately. The previous synthetic task instructed the core to update `state/voice-session-context.json` — a schema documented in CLAUDE.md but never verified to actually flow into the voice session. Switching to the per-channel pull namespace (PR #1033) which IS the documented + tested mechanism: - Core writes `results/<voice-channel-id>.task-<core-task-id>.txt` - discord-voice-server scans the namespace once connected - File body is injected into the live Gemini session via `transport.sendContent` (NOT spoken — session input, like a tool drain). Same path discord-voice and conversation-server already use. The synthetic task body still carries ONLY metadata (phrase + channel id/name). The core agent picks what conversation-state details to put in the result file body — bridge doesn't pass any of that through. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ner (#1113) Two bugs found in PR #1080's bridge integration during 2026-05-24 testing: A) `requireMention=true` filter precedes the join-phrase check in `src/discord-bridge.py`, so a bare "za warudo" in a guild text channel gets skipped before the magic-word path can fire. Fix: add an early owner+phrase fast-path (gated on `load_allowed()`) BEFORE the requireMention skip. The magic word is an explicit owner command; requireMention shouldn't apply. B) `message_is_join_phrase` in `skills/discord-voice/scripts/join_trigger.py` only does `text.strip()` before phrase match. When the user summons via "@lucy za warudo" in a guild channel, the raw text is "<@1494...> za warudo" which doesn't startswith("za warudo"). Fix: strip leading `<@id>` / `<@!id>` / `<@&roleid>` mention prefixes (with surrounding whitespace) before the phrase match. Verified end-to-end this session — bare "za warudo" in #General (no @-mention) now fires `[join-trigger] owner @susanliu_ said the join phrase — summoning discord-voice (bypassing requireMention)` and the context-prep handoff via #1033's per-channel pull namespace works. Unit tests for new mention-prefix stripping: 7/7 pass (bare phrase, @-prefixed, multi-mention, punctuation, false-positive "za warudonow"). Local branch `fix/za-warudo-mention-precedence`; not pushed pending Susan's approval. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
…sk-results (narrow) (sonichi#1033) * feat(discord-voice,phone): per-channel pull path for non-delegated task-results (narrow) Add a scoped result-filename namespace `results/<channel-key>.task-{id}.txt` that gives the two non-delegating voice surfaces — discord-voice and phone — a native pull path for task results without modifying any existing consumer. The regular `task-{id}.txt` filename remains the default for every task, and every existing consumer (discord-bridge, telegram-bridge, slack-bridge, task-bridge, agent-api) keeps using it. The scoped form is invisible to them because their filters key off the legacy shape: - discord/telegram/slack bridges look up `RESULTS_DIR / f"{task_id}.txt"` for a specific tracked task_id; a `<key>.task-{id}.txt` filename does not equal any tracked `${tid}.txt`. - agent-api globs `task-*.txt`; the scoped form does not start with `task-`. - task-bridge.ts checks `startsWith('voice-')` / `startsWith('task-')` / `startsWith('task-chat-')` for each routing path; the scoped form starts with the channel key. Channel keys this PR emits: - discord-voice → Discord voice channel id (CHANNEL_ID) - phone → Twilio call SID (per-call unique so concurrent calls never cross) Both new scans run inside their respective service processes, do read-and-delete on match, and inject the body through the same `transport.sendContent` path the work-tool resultQueue drain uses. Cadence is 3s — this path is for cross-surface handoffs, not in-conversation turn-taking. What changed: - src/result-channel-key.ts + src/result_channel_key.py — tiny shared filename helper (sanitize/build/parse/match). - skills/discord-voice/scripts/discord-voice-server.ts — channel scan keyed on CHANNEL_ID, started in createVoiceSession, torn down in cleanupSession. - skills/phone-conversation/scripts/conversation-server.ts — per-call channel scan keyed on callSid, started at end of createCallSession, torn down in cleanupCall. - CLAUDE.md — documents the convention under the Task bridge section. - tests/result-channel-key.test.{ts,py} — unit coverage + the load-bearing invariant tests that replay each existing consumer's pattern against a sample scoped filename. What intentionally didn't change: - Existing consumers (discord-bridge.py, telegram-bridge.py, slack-bridge.py, task-bridge.ts, agent-api.py) — out of scope by design; touching them is what the narrow scoping avoids. - proactive-*.txt and voice-*.txt routes — untouched. - The default `task-{id}.txt` filename for normal results — untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(channel-scan): .txt guard + TTL-pruned seen-map (PR sonichi#1033 review) Folds both sonichi#1033 review findings into the existing narrow design. Fix 1 — .txt guard against partial-write races (MacBook): - Add explicit `name.endsWith('.txt')` guard at the top of both scan loops (discord-voice-server.ts and conversation-server.ts), BEFORE `resultBelongsTo()`. A writer's atomic-write temp (`<key>.task-X.txt.tmp`, `.sending`, `.partial`, etc.) must never be picked up — reading/unlinking it before the rename completes would inject a half-written body and orphan the rename target. - Tighten `resultBelongsTo()` itself (both TS and Python) to require an exact `.txt` ending — belt-and-suspenders with the loop guard. - New test cases in tests/result-channel-key.test.{ts,py} explicitly reject `.txt.tmp`, `.txt.partial`, `.txt.sending`, `.txt.swp`, `.txt.lock`, `.txt~`, bare `.tmp`/`.sending`/`.partial` suffixes, and dotfile-prefixed names — plus a positive canonical-`.txt` sanity check to confirm no over-rejection. Fix 2 — channelScanSeen unbounded growth (Maddy): Picked PATH (b) — keep as safety net, prune at 60s. Reason: the `unlinkSync(full)` calls in both scan loops are wrapped in `try {} catch {}` and fail silently on the unhappy path (permissions, file vanished mid-tick, FS race). If unlink fails AND we drop the Set, the same body re-injects every 3s. The map (`name -> first-seen ms`) survives those failures while a 60s TTL prune at the top of each tick keeps memory bounded — even across hour-long calls / multi-day voice sessions. `Map<string, number>` instead of parallel Set+timestamp store, so prune is one pass. PR housekeeping: task-bridge.ts:682 belt-suspenders is tracked as follow-up sonichi#1035 (`narrow-scope-followup` tag), per design point 6 in sonichi#1033 body — this PR keeps existing consumers unmodified. Tests: - tests/result-channel-key.test.ts: 18/18 pass (was 16; +2 new) - tests/result-channel-key.test.py: 18/18 pass (was 16; +2 new) - npm test: 262/262 TS pass, no regressions - npm run typecheck: clean Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ough (closes sonichi#1035) (sonichi#1036) * fix(task-bridge): belt-suspenders guard against non-task .txt fallthrough (closes sonichi#1035) PR sonichi#1033 introduced `results/<channel-key>.task-{id}.txt` as a new filename namespace for the per-channel pull path used by discord-voice + phone. The existing consumers don't pick those up, except for one fragile spot: `src/task-bridge.ts`'s result-watcher has an unconditional fallthrough that, when voice-agent's client is connected, fires `onResult()` for any non-empty `.txt` file past the `voice-*` / dedup / offline-forward branches. Without a guard, a `<key>.task-foo.txt` file could race the per-channel scanner and *also* be injected into voice. PR sonichi#1033 accepted the race (scanner's read-and- delete usually wins); this guard closes it. Change: - Add `_shouldFallthrough(file)` predicate near `_isVoiceTask` — returns true only for `task-*` / `voice-*` prefixes. Rejects the new-namespace files, `proactive-*` (discord-bridge's poll_proactive handles those), and any unfamiliar prefix. - Gate the fallthrough at the result-watcher with `if (!_shouldFallthrough(file)) continue;` before the existing `if (result)` block. - Test: `tests/task-bridge-fallthrough-guard.test.ts` exercises the predicate across canonical-task / voice-push / channel-pull-namespace / proactive / unknown-prefix cases. Anchored to the start of the filename — `prefix-task-` and `xtask-` are correctly rejected. No behavioral change for the existing `task-*` / `voice-*` paths (both still fall through). `proactive-*` was previously hitting the fallthrough as a latent duplicate-delivery hazard (discord-bridge's `poll_proactive` also consumes it); the guard now skips it cleanly, matching its actual intended consumer. Verified: - `npm run typecheck` clean - 268 existing TS tests pass + 6 new tests pass - No other consumer modified Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(task-bridge): allow proactive-* through the fallthrough guard The sonichi#1035 belt-suspenders guard rejected `proactive-*.txt`, framed as "closing a duplicate-delivery hazard." That framing was wrong — per the long-standing proactive-voice rule, `proactive-*` files are spoken by the voice agent when the client is connected. That delivery has no explicit handler upstream in startResultWatcher; the unconditional fallthrough IS the path. Blocking it silently disables voice-spoken proactive messages. Add `proactive-` to the `_shouldFallthrough` allowlist so the original delivery path survives. The guard still rejects `<channel-key>.task-*` (the actual sonichi#1035 target). Flip the proactive test case from "rejects" to "allows", with a docstring explaining why both consumers coexist. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…keover + context-prep (sonichi#1080) * feat(discord-voice): "za warudo" voice-join magic word (WIP — approach pending owner call) join_trigger.py (discord-voice skill) owns all feature logic — join-phrase resolution from config, owner's voice-channel lookup, double-launch guard, discord-voice-server spawn. discord-bridge.py carries only a thin detect + delegate hook. Parked pending owner's decision: thin-bridge-hook vs pure agent-recognized magic word. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(discord-voice): za-warudo magic word — broader phrase match + synthetic context-prep task Brings the WIP commit `0dc4403` (parked 2026-05-22) to ship-ready per 2026-05-23 owner call (option A — thin bridge hook + skill takeover). ## Changes vs the parked WIP 1. **Broader phrase match** — `message_is_join_phrase()` now uses a word-boundary rule (non-alphanumeric / non-underscore after the phrase) instead of strictly requiring " " or "\n". Catches the natural `ZA WARUDO!` / `za warudo, please` / `za warudo? join` cases while still rejecting `za warudonow` (alnum boundary). 2. **Synthetic context-prep task post-spawn.** New `_enqueue_context_prep_task()` drops a task file into `<workspace>/tasks/task-{ts}.txt` immediately after a successful voice-server spawn: ``` source: system-magic-word priority: urgent access_tier: owner task: [SYSTEM] Magic word '<phrase>' fired. ... Update state/voice-session-context.json: set pending_action.kind= 'joined-voice', .where='<channel-id>', .what='joined voice via <phrase>'. Then enrich active_drafts + last_results from your current conversation state. [no-send] ... ``` The core agent picks it up via the streaming watcher and updates `state/voice-session-context.json` so the voice session's `recent_context` tool has fresh state by the second turn (first turn falls back to the default greeting if it races). **Privacy:** the synthetic task body carries ONLY metadata (which phrase fired, which channel id/name). No user content — no draft text, no result snippets. The core reads its own conversation state to populate active_drafts + last_results; we don't pass any of that through the bridge. `[no-send]` marker so the bridge silently archives the result file — there's no Discord reply owed; the user sees the bridge's "On my way to <channel>" message and the voice greeting. 3. **Non-blocking ordering.** Spawn → enqueue task → return. The bridge does not wait for the core to consume the synthetic task. Voice arrives immediately; context lands on the next watcher tick (~1-3s). If the core lags, voice degrades gracefully to default greeting on turn 1 — design verified in the owner call. 4. **Best-effort task write.** `_enqueue_context_prep_task()` swallows any exception. Observability shouldn't block the user-facing voice spawn. ## How to use Owner types `za warudo` (any casing/trailing-punctuation variants work) in any Discord surface where the bridge sees their message AND they're currently in a voice channel in the same guild. Bridge replies with "On my way to **<channel>**" and discord-voice-server connects. Phrase is config-driven via `<workspace>/config/discord-voice.json` `join_phrase` key (committed example: `skills/discord-voice/ config.json.example`). ## Tests Hand-tested all 12 phrase-match cases (exact, exclamation, comma, trailing words, leading/trailing whitespace, embedded \\n, alnum boundary rejection, etc.). Synthetic-task write tested against a temp workspace dir — confirmed correct task-file format, no real- workspace pollution after env cleanup. (Heads-up: importing this module triggers `workspace_default.py`'s one-time notes/ migration; isolate tests with $SUTANDO_WORKSPACE pointed at a tempdir AND restore any moved files post-test.) ## Out of scope (next steps) - The synthetic task body still mentions config keys (active_drafts, last_results) that this PR doesn't itself populate. Core agent knows how to enrich those — that's the design point. - Option B (pure-agent recognition) and Option B' (hybrid) remain available if owner wants paraphrase coverage later; this PR ships the deterministic bridge path first. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-bridge): move za-warudo magic-word check AFTER dedup block The cherry-picked WIP commit (0dc4403) placed the join-trigger between the access-tier determination and the dedup block. That made the access-tier block grow past the 2000-char window the structural test (`tests/discord-bridge-access-tier.test.py`) uses to grep for it, failing CI on PR sonichi#1080. Move the join-trigger AFTER dedup. Two upsides: 1. Test regex window stays valid (access-tier block ends at the next `# Dedup:` marker as before). 2. Gateway replay can't double-fire the spawn — dedup catches it at the front gate. The `seen_message_ids.add()` line the WIP did manually is redundant now; removed. No behavior change for legitimate first-fire owner messages: dedup is a no-op on never-seen ids, and the magic-word check runs immediately after with the same conditions (owner + match). Removed the `not force` check too — `force=True` only matters for the on_message_edit reprocess path, where re-firing the magic word on an edited message is fine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(discord-voice): synthetic context-prep targets per-channel pull namespace, not voice-session-context.json Owner correction 2026-05-23 22:05Z: > "Core reads its own conversation state to populate active_drafts + > last_results in state/voice-session-context.json" that's not right. > I don't know if that json file works. Writing a result file has > been the approach we are discussing lately. The previous synthetic task instructed the core to update `state/voice-session-context.json` — a schema documented in CLAUDE.md but never verified to actually flow into the voice session. Switching to the per-channel pull namespace (PR sonichi#1033) which IS the documented + tested mechanism: - Core writes `results/<voice-channel-id>.task-<core-task-id>.txt` - discord-voice-server scans the namespace once connected - File body is injected into the live Gemini session via `transport.sendContent` (NOT spoken — session input, like a tool drain). Same path discord-voice and conversation-server already use. The synthetic task body still carries ONLY metadata (phrase + channel id/name). The core agent picks what conversation-state details to put in the result file body — bridge doesn't pass any of that through. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ner (sonichi#1113) Two bugs found in PR sonichi#1080's bridge integration during 2026-05-24 testing: A) `requireMention=true` filter precedes the join-phrase check in `src/discord-bridge.py`, so a bare "za warudo" in a guild text channel gets skipped before the magic-word path can fire. Fix: add an early owner+phrase fast-path (gated on `load_allowed()`) BEFORE the requireMention skip. The magic word is an explicit owner command; requireMention shouldn't apply. B) `message_is_join_phrase` in `skills/discord-voice/scripts/join_trigger.py` only does `text.strip()` before phrase match. When the user summons via "@lucy za warudo" in a guild channel, the raw text is "<@1494...> za warudo" which doesn't startswith("za warudo"). Fix: strip leading `<@id>` / `<@!id>` / `<@&roleid>` mention prefixes (with surrounding whitespace) before the phrase match. Verified end-to-end this session — bare "za warudo" in #General (no @-mention) now fires `[join-trigger] owner @susanliu_ said the join phrase — summoning discord-voice (bypassing requireMention)` and the context-prep handoff via sonichi#1033's per-channel pull namespace works. Unit tests for new mention-prefix stripping: 7/7 pass (bare phrase, @-prefixed, multi-mention, punctuation, false-positive "za warudonow"). Local branch `fix/za-warudo-mention-precedence`; not pushed pending Susan's approval. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
Why
The two voice surfaces that don't go through
task-bridge.ts(the discord-voice and phone services) have only a per-taskexistsSyncpoll: each can pick up results for tasks IT delegated, but there's no native path to claim a result for a task it didn't delegate (e.g. a context handoff from another surface).This PR gives them a narrow native pull path: a scoped filename namespace
results/<channel-key>.task-{id}.txt. Existing consumers are not modified.Design (the 7 locked points)
results/<channel-key>.task-{id}.txt, used only when a task-result needs to reach a non-delegating consumer. Regular task results keepresults/task-{id}.txt.CHANNEL_ID)readdirSyncscan filtered by<CHANNEL_ID>.task-prefix; on match, injects body into the voice session viatransport.sendContent(same path thework-tool resultQueue drain uses), thenunlinkSyncto prevent re-delivery. 3s cadence.CallSessionand are torn down incleanupCall.CLAUDE.md's task-bridge section: when a task-result needs cross-consumer pickup, writeresults/<channel-key>.task-{id}.txt; otherwise the existingresults/task-{id}.txtis the default.discord-bridge.py,telegram-bridge.py,slack-bridge.py,task-bridge.ts,agent-api.py) — NOT modified. Their globs/specific-id lookups already exclude<key>.-prefixed files.src/result-channel-key.ts(+ Python twinsrc/result_channel_key.py) —sanitizeKey/resultFilename/parseResultFilename/resultBelongsTo. Cribbed from the parked branchrefactor/per-channel-task-result-scoping, stripped of legacy-form compatibility (the parked design's wrong-direction transition window).Per-consumer verification
Each existing consumer's pattern, quoted from the source, against the sample scoped filename
1485653767402553457.task-discord-voice-1700000000.txt:src/discord-bridge.py:2796-2797— pending_replies lookuptask_idis one of the bridge's own tracked ids (task-...). For the scoped file to match, some${tracked}.txtwould have to equal the full scoped filename — impossible, because tracked ids are baretask-...strings and don't have a<channel-id>.prefix.src/telegram-bridge.py:576-577— same shapeSame logic.
src/slack-bridge.py:565-566— same shapeSame logic.
src/agent-api.py:356and:452—task-*glob<key>.task-*.txtdoes not matchtask-*.txt— the scoped filename starts with the channel key, nottask-.src/task-bridge.ts:608, 624, 649, 668— startsWith guardsAll four guards key off the file (or task id) starting with
task-/voice-/task-chat-. The scoped filename starts with the channel key — none match.Known trade-off — task-bridge.ts fallthrough
task-bridge.ts:682has an unconditionalif (result)fallthrough that fires for any non-empty.txtfile when the voice client is connected. If voice-agent is the active surface AND the scoped file lingers across one 2s poll, voice-agent'sonResult(result)callback would also fire. Per the narrow design, this is accepted:What changed (2 service files + helper + docs + tests)
src/result-channel-key.ts(new) — TS helpersrc/result_channel_key.py(new) — Python twinskills/discord-voice/scripts/discord-voice-server.ts— channel scan loop + interval cleanupskills/phone-conversation/scripts/conversation-server.ts— per-call channel scan loop + cleanupCLAUDE.md— convention documented under Task bridge sectiontests/result-channel-key.test.ts+tests/result-channel-key.test.py— unit coverage + load-bearing invariant tests that lock in non-match against each existing consumer's patternWhat intentionally didn't change
results/proactive-*.txtandresults/voice-*.txtroutes — out of scope.task-{id}.txtfilename for regular results.Verification commands
🤖 Generated with Claude Code