Skip to content

feat(discord-voice,phone): per-channel pull path for non-delegated task-results (narrow) - #1033

Merged
sonichi merged 3 commits into
mainfrom
feat/per-channel-result-scoping-narrow
May 22, 2026
Merged

sonichi merged 3 commits into
mainfrom
feat/per-channel-result-scoping-narrow

Conversation

@sonichi

@sonichi sonichi commented May 22, 2026

Copy link
Copy Markdown
Owner

Why

The two voice surfaces that don't go through task-bridge.ts (the discord-voice and phone services) have only a per-task existsSync poll: 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)

  1. New filename namespace. results/<channel-key>.task-{id}.txt, used only when a task-result needs to reach a non-delegating consumer. Regular task results keep results/task-{id}.txt.
  2. Channel key per surface.
    • discord-voice → the voice channel id (CHANNEL_ID)
    • phone → the Twilio call SID (per-call unique)
  3. discord-voice-server.ts — readdirSync scan filtered by <CHANNEL_ID>.task- prefix; on match, injects body into the voice session via transport.sendContent (same path the work-tool resultQueue drain uses), then unlinkSync to prevent re-delivery. 3s cadence.
  4. conversation-server.ts — same scan but scoped per active call's SID. State and interval live on CallSession and are torn down in cleanupCall.
  5. Core write convention. Documented in CLAUDE.md's task-bridge section: when a task-result needs cross-consumer pickup, write results/<channel-key>.task-{id}.txt; otherwise the existing results/task-{id}.txt is the default.
  6. Existing consumers (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.
  7. Shared helper src/result-channel-key.ts (+ Python twin src/result_channel_key.py) — sanitizeKey / resultFilename / parseResultFilename / resultBelongsTo. Cribbed from the parked branch refactor/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 lookup

for task_id in list(pending_replies.keys()):
    result_file = RESULTS_DIR / f"{task_id}.txt"

task_id is one of the bridge's own tracked ids (task-...). For the scoped file to match, some ${tracked}.txt would have to equal the full scoped filename — impossible, because tracked ids are bare task-... strings and don't have a <channel-id>. prefix.

src/telegram-bridge.py:576-577 — same shape

for task_id in list(pending_replies.keys()):
    result_file = RESULTS_DIR / f"{task_id}.txt"

Same logic.

src/slack-bridge.py:565-566 — same shape

pending_ids = list(pending_replies.keys())
for task_id in pending_ids:
    result_file = RESULTS_DIR / f"{task_id}.txt"

Same logic.

src/agent-api.py:356 and :452 — task-* glob

for f in sorted(RESULT_DIR.glob("task-*.txt"), ...):

<key>.task-*.txt does not match task-*.txt — the scoped filename starts with the channel key, not task-.

src/task-bridge.ts:608, 624, 649, 668 — startsWith guards

if (file.startsWith('voice-')) { ... }
if (file.startsWith('task-') && /^\s*\[deduped:\s*task-/i.test(result)) { ... }
if (file.startsWith('task-') && _isVoiceTask(taskId)) { ... }
if (taskId.startsWith('task-chat-')) { ... }

All 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:682 has an unconditional if (result) fallthrough that fires for any non-empty .txt file when the voice client is connected. If voice-agent is the active surface AND the scoped file lingers across one 2s poll, voice-agent's onResult(result) callback would also fire. Per the narrow design, this is accepted:

  • voice-agent and discord-voice / phone are separate processes/surfaces — in practice the active scan reads-and-deletes the scoped file within its 3s cadence, narrower than voice-agent's 2s poll only in adversarial timing.
  • The fix for this is out of scope (would require modifying task-bridge, which point 6 forbids).
  • The convention documented in CLAUDE.md tells writers to use the scoped form only when discord-voice / phone is the intended consumer — not as a general broadcast channel.

What changed (2 service files + helper + docs + tests)

  • src/result-channel-key.ts (new) — TS helper
  • src/result_channel_key.py (new) — Python twin
  • skills/discord-voice/scripts/discord-voice-server.ts — channel scan loop + interval cleanup
  • skills/phone-conversation/scripts/conversation-server.ts — per-call channel scan loop + cleanup
  • CLAUDE.md — convention documented under Task bridge section
  • tests/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 pattern

What intentionally didn't change

  • All five existing consumers (point 6).
  • results/proactive-*.txt and results/voice-*.txt routes — out of scope.
  • The default task-{id}.txt filename for regular results.

Verification commands

npm run typecheck         # passes
npm test                  # 260+ TS tests + python suite pass
python3 tests/result-channel-key.test.py   # 16 tests pass
npx tsx --test tests/result-channel-key.test.ts   # 16 tests pass

🤖 Generated with Claude Code

…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>
@sonichi

sonichi commented May 22, 2026

Copy link
Copy Markdown
Owner Author

Folded both review findings into this branch.

Fix 1 — .txt guard (MacBook's would-block):

  • Added explicit name.endsWith('.txt') guard at the top of both scan loops (skills/discord-voice/scripts/discord-voice-server.ts and skills/phone-conversation/scripts/conversation-server.ts), BEFORE resultBelongsTo().
  • Tightened resultBelongsTo() (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} reject .txt.tmp, .txt.partial, .txt.sending, .txt.swp, .txt.lock, .txt~, bare .tmp/.sending/.partial, and dotfile-prefixed temps. Plus a positive canonical-.txt sanity check.

Fix 2 — channelScanSeen unbounded growth (Maddy):
Picked path (b) — keep as safety net, prune at 60s. The unlinkSync(full) calls in both loops are wrapped in try {} catch {} and fail silently on the unhappy path (permissions, file vanished mid-tick, FS race). If unlink fails AND the Set is dropped, the same body re-injects every 3s. Converted Set<string> → Map<string, number> (name → first-seen ms) and added a TTL-prune pass at the top of each tick so memory stays bounded across long calls / multi-day voice sessions.

Tracking:
task-bridge.ts:682 belt-suspenders is tracked as follow-up issue #1035 (narrow-scope-followup tag) — this PR keeps existing consumers unmodified per design point 6.

Verify:

  • npm run typecheck: clean
  • npx tsx --test tests/result-channel-key.test.ts: 18/18 pass (+2 new)
  • python3 tests/result-channel-key.test.py: 18/18 pass (+2 new)
  • npm test: 262/262 TS pass, no regressions

@sonichi
sonichi merged commit db21f61 into main May 22, 2026
2 checks passed
@sonichi
sonichi deleted the feat/per-channel-result-scoping-narrow branch May 22, 2026 22:23

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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's CHANNEL_ID is 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 + resultFilename helper 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.

sonichi added a commit that referenced this pull request May 22, 2026
…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>
sonichi added a commit that referenced this pull request May 24, 2026
…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>
sonichi added a commit that referenced this pull request May 24, 2026
…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>
sonichi added a commit that referenced this pull request May 25, 2026
…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>
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
…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>
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
…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>
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
…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>
liususan091219 added a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
…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>
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.

2 participants