Skip to content

refactor: extract shared @mention resolver into sprout-sdk#580

Merged
tlongwell-block merged 1 commit into
mainfrom
dawn/dry-mention-resolver
May 14, 2026
Merged

refactor: extract shared @mention resolver into sprout-sdk#580
tlongwell-block merged 1 commit into
mainfrom
dawn/dry-mention-resolver

Conversation

@tlongwell-block
Copy link
Copy Markdown
Collaborator

Summary

The @name mention extraction, merge, and resolver logic was duplicated across sprout-cli and sprout-mcp, with the CLI variant being a stub that never actually resolved @name tokens to p-tags. This consolidates the pure logic into a new sprout_sdk::mentions module that both crates consume, and un-stubs the CLI path so it auto-resolves mentions the same way MCP does.

What moved into sprout_sdk::mentions

  • extract_at_names — tokenize @name from message bodies (whitespace-or-SOS preceded, [a-zA-Z0-9._-]+)
  • merge_mentions + normalize_mention_pubkeys(Option<&str>) — dedup explicit + resolved pubkeys, drop the sender, cap at MENTION_CAP = 50
  • MentionProfile<'a> + match_names_to_profiles — pure matcher over already-fetched kind:0 profiles. Preserves profile-query order. Falls back display_namename only when the display_name key is absent (preserves MCP's current behavior — explicit no-op for empty/non-string values).

Each crate keeps its own two relay queries (typed nostr::Filter in MCP, REST JSON in CLI) and hands results to the shared matcher. A trait-based backend would have abstracted almost nothing real — the two I/O paths are different enough that the wrappers would just be thin pass-throughs.

Bugs fixed along the way

  1. CLI was querying kind:39002 channel members with #h. The relay emits these events with a d tag, so the correct filter is #d. The CLI was only working via a compatibility fallback. Standardized on #d with limit: 1.
  2. CLI's parse_member_pubkeys accepted any p tag value. Now filters to lowercase 64-char hex to match MCP's PublicKey::from_hex check.
  3. MENTION_CAP was hardcoded in two places. The SDK builder now references crate::mentions::MENTION_CAP.
  4. CLI was resolving mentions against the final body (with appended media markdown). Now uses the raw author content like MCP.

Tests

  • 18 new unit tests in sprout_sdk::mentions covering ordering, dedup, sender fallback, duplicate display names, and display_namename fallback semantics.
  • CLI gains cli_pipeline_resolves_body_at_names_to_member_pubkeys as a regression guard against the stub coming back.
  • Workspace tests + clippy -D warnings clean (53 cli, 74 mcp, 123 sdk).

Review history

Codex CLI reviewed iteratively. Final score 9/9/9 on minimalism, elegance, correctness. First two rounds caught:

  • #d vs #h bug (above)
  • MENTION_CAP not sourced from one place (above)
  • CLI resolving against final body vs raw content (above)
  • display_name fallback semantics (preserved explicitly, with a test)
  • Mention spoofing surface: duplicate display names in a channel let @alice tag multiple members. Bounded to channel members. Already true in MCP; this broadens it to CLI. Documented + test pins current behavior.

Note on signing

This commit is unsigned. DCO's Signed-off-by trailer is present. If the repo enforces verified GPG/SSH signatures on top of DCO, I (Tyler) will amend with my key locally before merge.

🤖 Co-authored-by Dawn (Sprout agent).

The `@name` mention extraction, merge, and resolver logic was duplicated
across `sprout-cli` and `sprout-mcp`, with the CLI variant being a stub
that never actually resolved `@name` tokens to p-tags. This consolidates
the pure logic into a new `sprout_sdk::mentions` module that both crates
consume, and un-stubs the CLI path so it auto-resolves mentions the same
way MCP does.

What moved into `sprout_sdk::mentions`:
- `extract_at_names` — tokenize `@name` from message bodies
- `merge_mentions` + `normalize_mention_pubkeys(Option<&str>)` — dedup
  explicit + resolved pubkeys, drop the sender, cap at `MENTION_CAP = 50`
- `MentionProfile<'a>` + `match_names_to_profiles` — pure matcher over
  already-fetched kind:0 profiles, preserves profile-query order and
  falls back `display_name` → `name` only on absence (preserves MCP's
  current behavior)

Each crate keeps its own two relay queries (typed `nostr::Filter` in
MCP, REST JSON in CLI) and hands results to the shared matcher. A
trait-based backend would have abstracted almost nothing real — the
two I/O paths are different enough that the wrappers would just be
thin pass-throughs. Pure-matcher split keeps the logic in one place
without inventing scaffolding.

Bugs fixed along the way:
- CLI was querying kind:39002 channel members with `#h`; the relay
  emits them with a `d` tag, so the correct filter is `#d`. CLI was
  only working via a compatibility fallback.
- CLI's `parse_member_pubkeys` accepted any `p` tag value; now filters
  to lowercase 64-char hex to match MCP's `PublicKey::from_hex` check.
- `MENTION_CAP` is now sourced from one place; the SDK builder no
  longer hardcodes 50.
- CLI was resolving mentions against the final body (with appended
  media markdown); now uses the raw author content like MCP.

Tests:
- 18 new unit tests in `sprout_sdk::mentions` covering ordering,
  dedup, sender fallback, duplicate display names, and `display_name`
  → `name` fallback semantics.
- CLI gains `cli_pipeline_resolves_body_at_names_to_member_pubkeys`
  as a regression guard against the stub coming back.
- Workspace tests + `clippy -D warnings` clean.

Co-authored-by: Dawn <dawn@sprout.local>
Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
@tlongwell-block tlongwell-block merged commit 1b87a09 into main May 14, 2026
15 checks passed
@tlongwell-block tlongwell-block deleted the dawn/dry-mention-resolver branch May 14, 2026 15:38
tlongwell-block added a commit that referenced this pull request May 14, 2026
Pulls in 8 commits from origin/main:
- 1858e98 fix(desktop): drive unread badges from live subscription, not refetched lastMessageAt (#581)
- 9e76a08 fix(desktop): refine header scaling and shadow (#573)
- b74ec95 fix(desktop): keep day dividers below header (#574)
- aad564b Move agent activity below composer (#579)
- bda98da docs(nips): NIP-AE — Agent Engrams (#575)
- 1b87a09 refactor: extract shared @mention resolver into sprout-sdk (#580)
- 2ee7356 fix: add default-run to sprout-relay so `cargo run -p sprout-relay` works (#577)
- f0549b5 feat(desktop): channel hover state and right-click mark-unread context menu (#578)

No conflicts.

* origin/main:
  fix(desktop): drive unread badges from live subscription, not refetched lastMessageAt (#581)
  fix(desktop): refine header scaling and shadow (#573)
  fix(desktop): keep day dividers below header (#574)
  Move agent activity below composer (#579)
  docs(nips): NIP-AE — Agent Engrams (#575)
  refactor: extract shared @mention resolver into sprout-sdk (#580)
  fix: add default-run to sprout-relay so `cargo run -p sprout-relay` works (#577)
  feat(desktop): channel hover state and right-click mark-unread context menu (#578)
tlongwell-block added a commit that referenced this pull request May 15, 2026
* origin/main: (33 commits)
  dev-mcp: add view_image tool (#602)
  fix(relay,desktop): only advertise NIP-43 when enforced; probe pairing by supported_nips (#601)
  fix(desktop): derive unread state from NIP-RS + relay catch-up only (#599)
  docs(testing): rewrite TESTING.md for current API and CLI-first workflow (#597)
  fix(agent): fix OpenAI-compat request body serialization and max_tokens (#595)
  feat(desktop): per-persona and per-agent env var overrides (#594)
  fix(desktop): stop pinning agents to deprecated SPROUT_ACP_TURN_TIMEOUT (#592)
  fix(desktop): populate member_count in get_channels so channel browser shows real counts (#548)
  fix(desktop): autofocus message composer on channel/thread open (#572)
  refactor(cli): restructure flat commands into 12 subcommand groups (#585)
  feat(sdk): add builder functions for workflows, DMs, and presence (#589)
  feat(desktop): add message more-actions dropdown menu (#590)
  fix(mobile): preserve channel list across background/resume reconnection (#588)
  Redesign Home as an inbox (#582)
  fix(desktop): drive unread badges from live subscription, not refetched lastMessageAt (#581)
  fix(desktop): refine header scaling and shadow (#573)
  fix(desktop): keep day dividers below header (#574)
  Move agent activity below composer (#579)
  docs(nips): NIP-AE — Agent Engrams (#575)
  refactor: extract shared @mention resolver into sprout-sdk (#580)
  ...

Signed-off-by: Tyler Longwell <tlongwell@squareup.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