Commit Graph
538 Commits
Author SHA1 Message Date
Michael Neale 2d53bc2ecc Pin mesh SDK example to direct shutdown commit 2026-05-29 14:22:44 +10:00
Michael Neale 5b9f018d84 showing how to use nascent sdk approach 2026-05-29 13:49:15 +10:00
tlongwell-blockandDawn 1cc536b03e mesh-llm: silence dead_code in client-side NIP-98 bearer module
The verifier side of the iroh-relay NIP-98 admission is fully wired and
tested in sprout-relay::iroh_relay. The client side (build_nip98_bearer)
is reserved for the deferred dial path — it's complete and unit-tested
on its own, but has no live caller until upstream mesh-llm PR A lands.

Adds a module-level #![allow(dead_code)] with a comment pointing at the
deferred-dial dependency, so the workspace stays warning-clean without
losing the test coverage on the helper.

cargo clippy --workspace -- -D warnings + cargo fmt --check clean.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
2026-05-21 08:57:28 -04:00
tlongwell-blockandDawn d1262beb8a mesh-llm: e2e tests against a live relay (#[ignore]'d by default)
crates/sprout-test-client/tests/e2e_mesh_llm_discovery.rs (new): four
end-to-end tests verifying the kind:31990 publish/discover loop against
a real running sprout-relay. Follows the same #[ignore]+RELAY_URL
pattern as e2e_long_form.rs etc.

Tests:
- test_offer_publish_then_retrieve: publish a kind:31990 with a
  far-future expires_at, REQ it back via kinds+author filter, confirm
  the serialised MeshLlmOffer round-trips through the relay intact
  (expires_at, d_tag).
- test_offer_replace_by_d_tag: NIP-33 replace semantics — publishing
  a second event with the same (pubkey, d_tag) must replace the first;
  a subsequent REQ returns only the latest.
- test_offer_delete_by_empty_replace: the desktop publisher's
  delete-by-replace tombstone — publish a real offer, then publish
  empty content at the same address; only the tombstone remains.
- test_offer_stray_h_tag_is_ignored: kind:31990 is global
  (is_global_only_kind); a stray h-tag must not channel-scope the
  event. Verifies the unit-tested behaviour holds end-to-end on
  real relay traffic.

These exercise the slice that pure unit tests can't reach: the actual
NIP-43 fan-out gate, the relay's ingest validation against
required_scope_for_kind / is_global_only_kind, and NIP-33 storage
semantics. They are #[ignore]'d so 'just test-unit' is unaffected;
'cargo test --test e2e_mesh_llm_discovery -- --ignored' runs them
against the relay pointed at by RELAY_URL (default ws://localhost:3000).

clippy + fmt clean across the workspace.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
2026-05-19 17:27:13 -04:00
tlongwell-blockandDawn 16299b4e3f mesh-llm: heartbeat republish — keep offers fresh while the panel is mounted
Without heartbeat, the expires_at TTL added in the previous commit makes
offers vanish from consumer UIs 15 min after the user toggles on, even
if the publisher is still running fine. The original commit message
even noted this gap but didn't implement it. Fixing now.

desktop/src/features/settings/hooks/useMeshOfferHeartbeat.ts (new):
- HEARTBEAT_MS = 5 minutes (~OFFER_TTL_SECS / 3 — so one missed beat
  still leaves the offer visible; only two consecutive misses drop us).
- While { enabled, irohRelayUrl } are both truthy, setInterval calls
  mesh_publish_offer, which re-stamps expires_at = now + 15 min as a
  NIP-33 replace under the same (pubkey, d_tag) address.
- Errors are logged and ignored — the user isn't waiting in front of the
  panel; the next tick or an explicit prefs change will surface a fresh
  error if the relay is durably down.

desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx:
- New irohRelayUrl state, populated on mount and after every persist().
- useMeshOfferHeartbeat({ enabled, irohRelayUrl }) wired in.
- Mount-time relay probe so a user opening the panel with sharing
  already enabled from a previous session starts heartbeating
  immediately; failures are non-fatal (console.warn, prefs still
  editable).

pnpm typecheck + pnpm check clean.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
2026-05-19 17:22:00 -04:00
tlongwell-blockandDawn f711e37a7a mesh-llm: Max review fixups — expires_at, EndpointAddr, same-relay filter
Per Max's pre-PR review of d9a791f:

1. Add MeshLlmOffer.expires_at: u64 (unix seconds). Hard-required by
   serde (no default) since consumers depend on it for correctness:
   crashed publishers cannot send the NIP-33 delete-by-replace
   tombstone, so the TTL is the only thing that reaps stale offers.

   New helpers + tests in sprout-core::mesh_llm:
   - is_expired(now) returns true when expires_at <= now.
   - matches_local_relay(current_relay) compares against the relay's
     NIP-11 iroh_relay_url with lightweight canonicalisation
     (lower-case scheme/host, trailing-slash collapse, query/fragment
     drop). v1 invariant: one relay = one mesh boundary.
   - is_publishable now rejects expires_at == 0.
   - canonical_relay_url() is a small private helper, deliberately
     separate from sprout_auth::nip98_canonical_url (different jobs).

   5 new tests (expires_at_required, is_expired_filter,
   matches_local_relay_canonicalises, is_publishable_rejects_zero_*,
   plus updated JSON fixtures throughout).

2. Fix doc wording: iroh NodeAddr -> EndpointAddr (rc.0 naming).
   Soften 'multiple relays bridging into membership scope' language to
   reflect that v1 is strictly single-relay and the field is reserved
   for future cross-relay only.

3. Desktop publisher (mesh_publish_offer) now computes
   expires_at = now + OFFER_TTL_SECS (15 min) and threads it through
   build_offer(endpoint_id, iroh_relay_url, expires_at). The frontend
   hook is expected to re-invoke publish on heartbeat well before the
   deadline (heartbeat plumbing is in the next commit).

4. Frontend useMeshLlmOffers hook:
   - probes the relay's NIP-11 iroh_relay_url once on mount and
     filters offers whose iroh_relay_url doesn't match (v1 same-relay
     invariant). When the relay doesn't advertise mesh-LLM, the filter
     correctly empties the list.
   - rejects events with schema v != 1 before storing.
   - 30s setInterval ticks 'now' so expired offers drop without waiting
     for a new event. O(1) timer regardless of how many offers are in
     the cache.
   - mirrors the canonicaliser logic from sprout-core in TS so JS-side
     accept/reject decisions match the Rust check.

Tests after change:
- sprout-core --lib: 174 -> 178 (+4 mesh_llm)
- desktop mesh_llm --lib: 11 unchanged (publisher signature change
  required updating build_offer call sites; tests pass exact-same set).
- pnpm typecheck + pnpm check (biome + file-sizes): clean.
- cargo clippy --workspace -- -D warnings: clean.
- cargo fmt --all -- --check: clean.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
2026-05-19 17:20:29 -04:00
tlongwell-blockandDawn 72824221c0 mesh-llm: B5 discovery — subscribe + render in settings card
shared/constants/kinds.ts: KIND_MESH_LLM_DISCOVERY = 31990 (matches
sprout_core::kind::KIND_MESH_LLM_DISCOVERY).

shared/api/relayClientSession.ts: subscribeToMeshLlmOffers(onEvent)
issues a NIP-01 REQ for kinds=[31990], limit=200. Returns the latest
snapshot plus a live stream. Membership is enforced relay-side via the
existing NIP-43 fan-out gate, so consumers see only offers authored by
relay members.

features/settings/hooks/useMeshLlmOffers.ts: parses each event's
content as MeshLlmOffer (mirrors sprout_core::mesh_llm shape), keys
offers by (pubkey, d_tag) per NIP-33. Empty content => drop entry
(matches the Rust publisher's delete-by-replace path). Newest-first
sort. Returns { offers, error } for the consumer hook contract.

features/settings/ui/MeshComputeSettingsCard.tsx: new 'Compute offered
by other members' section between the caps fieldset and the identity
footer. Empty-state copy when no offers; otherwise a list with
pubkey-short / d_tag / advertised models / caps for each.

scripts/check-file-sizes.mjs: relayClientSession override 930 -> 960
to absorb subscribeToMeshLlmOffers; comment updated to mention it so
future readers know why.

pnpm check, pnpm typecheck both clean. The full publish/discover loop
runs end-to-end: user A toggles -> kind:31990 hits relay -> user B's
useMeshLlmOffers hook receives it -> card renders 'A is offering ...'.
The only remaining gap to actually use the compute is the iroh dial,
which is the deferred question for Tyler.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
2026-05-19 17:10:26 -04:00
tlongwell-blockandDawn c5ec624ebc mesh-llm: B6 kind:31990 publisher + settings-card publish-on-save
desktop/src-tauri/src/commands/mesh_llm.rs: new mesh_publish_offer
command. Reads persisted prefs and the local iroh endpoint id; on
enabled=true, builds a kind:31990 event with the JSON-serialised
MeshLlmOffer envelope and a 'd' tag matching prefs.d_tag, then signs
+ POSTs via the existing submit_event pipeline (NIP-98 to /events).

On enabled=false, publishes the *same address* with empty content —
NIP-33's 'delete by replace' idiom — so consumers know the offer has
been withdrawn. PublishOfferResult.published_offer reports which path
was taken.

desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx: persist()
now follows save-prefs with a relay capability probe and (when the
relay advertises iroh_relay_url) a publish call. If the relay doesn't
support mesh-LLM, prefs are still saved locally and the UI surfaces a
specific 'this relay does not advertise iroh_relay_url' message rather
than a confusing 'publish failed'.

The user-facing flow is now: open settings -> Share compute -> toggle
on -> a kind:31990 event hits the relay, NIP-43-fanned-out to other
members. Toggling off publishes the empty-content replacement.

Tests: 208 sprout-relay, 174 sprout-core, 11 desktop mesh_llm — all
unchanged-and-pass. desktop typecheck + biome clean.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
2026-05-19 17:06:52 -04:00
tlongwell-blockandDawn e6b968a9de mesh-llm: C1 MeshComputeSettingsCard
desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx (new):
- Toggle: 'Share this machine's compute' (master switch).
- Three numeric inputs: max VRAM (MB), max RAM (MB), concurrent peers.
  Empty = no cap, validated to non-negative integers.
- Displays the local iroh endpoint id (canonical Display form) so the
  user knows which device identity is publishing.
- All state persisted through the mesh_set_sharing_prefs Tauri command;
  loads via mesh_get_sharing_prefs + mesh_get_endpoint_id on mount.
- Error and saving states surfaced inline.

Registered as a new 'compute' SettingsSection in SettingsPanels.tsx
between 'agents' and 'channel-templates', with a Cpu icon. Reached
through the existing avatar-menu -> Settings flow (no popover
restructuring needed for the MVP).

desktop typecheck (tsc --noEmit): clean.
desktop biome check: clean.

UX (matches Tyler's [1]):
- avatar bottom-left -> ProfilePopover -> Settings -> Share compute
- one switch + three caps; the offering side decides everything.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
2026-05-19 17:03:20 -04:00
tlongwell-blockandDawn d8ca1a9c3e mesh-llm: B7 Tauri commands surface
desktop/src-tauri/src/commands/mesh_llm.rs (new):
- mesh_get_endpoint_id(app) -> { endpoint_id }: creates the persisted
  iroh keypair on first call; returns the canonical Display form of
  the public key so the UI can show 'this device' identity.
- mesh_get_sharing_prefs(app) -> ComputeSharingPrefs: reads the
  persisted prefs file (defaults applied when absent).
- mesh_set_sharing_prefs(app, prefs) -> (): atomic write-through.
- mesh_relay_iroh_url(state, relay_ws_url) -> Option<String>: probes
  the relay's NIP-11 doc for iroh_relay_url; returns None gracefully
  when the relay doesn't advertise mesh-LLM.

All four registered in lib.rs's tauri::generate_handler! block.
Frontend hooks land in C1 (avatar-menu MeshComputeSettingsCard).

Cleanup: drop wildcard re-exports from mesh_llm/mod.rs so unused
publisher/dialer helpers don't trip top-level dead-code lints before
B5/B6 are wired in.

cargo check passes; clippy + fmt clean (relay-side workspace).

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
2026-05-19 16:59:57 -04:00
tlongwell-blockandDawn 000c78c203 mesh-llm: B1 NIP-11 fetcher for iroh_relay_url
desktop/src-tauri/src/mesh_llm/nip11.rs: fetch_iroh_relay_url(ws_url)
converts ws:// -> http://, GETs / with Accept: application/nostr+json,
extracts the iroh_relay_url field from the NIP-11 JSON. Returns
Ok(None) on unreachable relays / malformed responses / missing field
so mesh-LLM silently disables itself rather than producing a deploy
mystery; only returns Err for un-fixable caller mistakes (non-ws URL).

Mirrors the probe_relay_supports_nip43 helper already in
commands/pairing.rs; deliberately doesn't share code since this is a
different decode shape with different graceful-failure semantics.

desktop mesh_llm tests: 9 -> 11 (+2 nip11 helper).

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
2026-05-19 16:58:14 -04:00
tlongwell-blockandDawn 1dbc89cc11 mesh-llm: offer envelope (sprout-core) + desktop building blocks
B0 — sprout-core/src/mesh_llm.rs (new): MeshLlmOffer envelope, the
content of a kind:31990 event. Schema versioned (v: u32), with
deny_unknown_fields at the top level and a freeform 'extra' Value
escape hatch. ResourceCaps + ModelOffer sub-structs. d_tag charset is
limited to [A-Za-z0-9_-] (NIP-33 stability). 9 unit tests covering
round-trip JSON, optional caps, unknown-field rejection, d_tag
validation, is_publishable rule set.

B2 — desktop/src-tauri/src/mesh_llm/endpoint.rs: persists the iroh
endpoint keypair to {app_data_dir}/mesh_iroh.key as 32 hex bytes.
Atomic write via tempfile.persist; corrupt files quarantined to
.bad.{epoch} (same pattern as identity.key). 2 unit tests.

  Design note in the module doc: we deliberately do NOT derive the
  iroh key from the Nostr key, because that would couple key rotation
  (rotating the Nostr key would silently break active offers) and
  invent a new key-custody convention. Separate file, same Tauri
  sandbox.

B3 — desktop/src-tauri/src/mesh_llm/nip98.rs: build_nip98_bearer(keys,
iroh_relay_public_url) signs a kind:27235 event with the user's Nostr
key over the canonical relay URL (sprout_auth::nip98_canonical_url
with path '/relay'), base64-encodes the event JSON. This is the exact
token the relay's iroh_relay::verify_bearer decodes + verifies.
3 unit tests.

B-offer prefs — desktop/src-tauri/src/mesh_llm/offer.rs: persisted
ComputeSharingPrefs (the avatar-menu sliders). Default is disabled,
1 concurrent consumer cap. build_offer() returns None when disabled
so callers know to *delete* any prior offer rather than re-publish.
JSON round-trip + helper tests, 4 tests.

Workspace deps added:
- desktop pulls sprout-auth (for the canonical URL helper, nostr-free
  at the API surface so the 0.36/0.37 nostr split doesn't matter).
- desktop pulls iroh-base = =1.0.0-rc.0 with the 'key' feature for
  SecretKey/PublicKey/EndpointId.
- desktop pulls thiserror = '2'.

Tests: 9 desktop mesh_llm tests pass + 9 sprout-core mesh_llm tests
pass. Workspace clippy + fmt clean (relay side; desktop has expected
dead_code warnings until B4-B6 wire these in).

Note: desktop crate requires sidecar binary stubs in
desktop/src-tauri/binaries/ to typecheck; created via the existing
'just _ensure-sidecar-stubs' helper.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
2026-05-19 16:55:45 -04:00
tlongwell-blockandDawn db6c1103aa iroh-relay: wire spawn() into fn main with graceful shutdown
- IrohRelayHandle::shutdown() consumes self and awaits Server::shutdown()
  (which drains in-flight QUIC sessions before returning), replacing the
  previous drop-aborts-supervisor pattern.

- main.rs::serve() now starts the embedded iroh-relay when *both*
  SPROUT_IROH_RELAY_PUBLIC_URL and SPROUT_IROH_RELAY_BIND_ADDR are set.
  Spawned alongside the HTTP listener; subscribed to the same shutdown_tx
  watcher so SIGTERM/Ctrl-C drains the iroh-relay together with axum.

- Mismatched config logs a warn! and starts neither:
    * URL only  -> NIP-11 advertises a phantom endpoint -> mesh-LLM is broken
    * bind only -> clients can't build the NIP-98 'u' tag -> 100% denial
  In both cases we fail loud and refuse to lie to clients.

- Both serve() paths (UDS-enabled + TCP-only) await the iroh drain task
  before returning so shutdown is actually graceful end-to-end.

sprout-relay --lib: 208/208 unit tests pass (unchanged; this is a wiring
change, not an auth change).
workspace clippy -D warnings: clean.
workspace cargo fmt --check: clean.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
2026-05-19 16:45:31 -04:00
tlongwell-blockandDawn 2d818b6efd iroh-relay: review fixups from Mari + Max
Mari (trust):
- Add 64 KiB pre-decode length cap on the bearer token. NIP-98 events are
  well under a kilobyte; rejecting oversized inputs before allocating the
  base64 decode buffer prevents an admission request from coercing the
  relay into multi-megabyte allocations. New const MAX_BEARER_LEN.
- New verify_bearer_rejects_oversized_token test.
- New verify_bearer_rejects_internal_whitespace test: pins the fact that
  base64 0.22's general_purpose engines reject mid-token whitespace
  (no MIME mode), which is what we want.

Max (review):
- Soften the module-level 'patched-fork hooks' docs so they don't imply
  the per-client max-lifetime hook is already wired, and add an explicit
  TODO(patched-iroh-relay) marker at the future insertion site in spawn.
- Add SPROUT_IROH_RELAY_BIND_ADDR to Config (iroh_relay_bind_addr:
  Option<SocketAddr>) now, so the main.rs wiring follow-up can read it
  without a separate config churn. Server::spawn owns its own listener,
  so this is independent of the Sprout HTTP bind_addr.

sprout-relay --lib: 206 -> 208 tests pass (+2).
workspace clippy -D warnings: clean.
workspace cargo fmt --check: clean.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
2026-05-19 15:46:31 -04:00
tlongwell-blockandDawn 3ca6918078 mesh-llm plan v6.1: Step 3 — embedded iroh-relay with NIP-98 admission
- New module crates/sprout-relay/src/iroh_relay.rs (~290 lines incl. tests).
- pub fn spawn(state, bind_addr) constructs an iroh_relay::server::Server
  with AccessConfig::Restricted set to a closure that:
    1. Pulls the Bearer token from ClientRequest::auth_token().
    2. base64-decodes (accepts STANDARD + URL_SAFE, padded or not).
    3. Calls sprout_auth::verify_nip98_event against canonical URL
       (= sprout_auth::nip98_canonical_url(public_url, '/relay')).
    4. Runs check_relay_membership against the NIP-98 pubkey.
       Anything other than Member/ViaOwner/OpenRelay -> Deny.
  Per Max's review notes: fail-closed on missing/invalid token, run
  membership only after NIP-98 verifies the pubkey, no caching.
- Returns Ok(None) gracefully when SPROUT_IROH_RELAY_PUBLIC_URL is unset
  (the canonical URL can't be built without it).
- patched-iroh-relay feature flag reserved for upstream PR C's per-client
  max-lifetime hook (kept behind cfg so unpatched rc.0 still compiles).

- MSRV bumped from 1.88.0 -> 1.91.0 (iroh-relay rc.0's MSRV). Repo's
  rust-toolchain.toml already pins 1.95.0 so builds are unaffected; the
  bump just keeps Cargo.toml honest with the actual transitive floor.
- README updated: 'Rust 1.88+' -> 'Rust 1.91+'.
- crates/sprout-relay/Cargo.toml: added
  iroh-relay = { version = "=1.0.0-rc.0", features = ["server"] }
  plus the patched-iroh-relay feature.

Tests (rustc 1.95, via rust-toolchain.toml; also verified independently
on 1.91.1):
- sprout-relay --lib: 195 -> 206 (+11 iroh_relay tests covering valid
  admission, missing/empty/non-base64/wrong-method/wrong-URL/wrong-kind/
  stale-timestamp denials, and bearer-encoding round-trips).
- cargo clippy --workspace --all-targets -- -D warnings: clean.
- cargo fmt --all -- --check: clean.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
2026-05-19 15:39:35 -04:00
tlongwell-blockandDawn f4ca099b1c mesh-llm plan v6.1: Steps 1, 2, 4, 5
- Step 1: KIND_MESH_LLM_DISCOVERY = 31990 (parameterized replaceable,
  global-only, MessagesWrite scope) — relay members announce compute offers
  through the same NIP-43-gated fan-out path as messages.

- Step 2: extract transport-neutral check_relay_membership returning
  MembershipDecision. HTTP enforce_relay_membership becomes a thin
  wrapper that maps Denied -> 403 JSON. Same behavior for all 6 existing
  HTTP callers; non-HTTP gates (iroh-relay AccessConfig) can call the
  core directly without a StatusCode in their return type.

- Step 4: NIP-11 iroh_relay_url field, fed from new
  SPROUT_IROH_RELAY_PUBLIC_URL config. Absent unless configured (older
  clients unaffected). This is what mesh-llm sidecars read to wire their
  iroh endpoints to Sprout's own relay -- no out-of-band config required.

- Step 5: sprout-auth::nip98_canonical_url helper. Single source of truth
  for the NIP-98 'u'-tag value, used by both signer and verifier. Suffix-
  aware path join (preserves /iroh prefix when joining /relay), localhost/
  IPv6 loopback collapse, query+fragment stripping. Round-trip test signs
  with the helper and verifies through verify_nip98_event to prevent drift.

sprout-core: 165 tests pass
sprout-auth: 36 -> 48 tests pass (+12 nip98_url)
sprout-relay: 190 -> 195 tests pass (+3 mesh-llm, +2 iroh_relay_url)
workspace clippy -D warnings: clean
workspace cargo fmt --check: clean

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
2026-05-19 15:33:13 -04:00
2fed74bdcd Show channels in command search (#617)
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
2026-05-19 14:33:53 -04:00
tlongwell-blockandGitHub 24520aa2b1 NIP-AE: agent engrams (kind:30174) — core memory injection + sprout mem CLI (#593)
Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
2026-05-19 13:16:37 -04:00
3973080dce Refine conversation visual affordances (#615)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 08:54:41 -04:00
Will PflegerandGitHub 50b350dc58 fix(desktop): worktree support — workspace boundary + shared identity (#610) 2026-05-18 19:11:34 -04:00
tlongwell-blockandGitHub 6e1418e571 feat(huddle): fix concurrent-speaker mixing + jitter buffer + protocol v2 (up to 10 peers) (#609)
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-05-18 16:34:52 -04:00
c0eb5affcf refactor(huddle): switch local TTS to Pocket TTS (Kyutai/sherpa-onnx) (#607)
Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
Signed-off-by: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
2026-05-18 14:28:27 -04:00
137a4e268f Update inbox conversation experience (#608)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 13:48:47 -04:00
tlongwell-blockandGitHub 70cb53e2c7 Add Sprig all-in-one agent binary (#605)
Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
2026-05-17 13:31:58 -04:00
17eea2d267 Fix full-width message layouts (#591)
Co-authored-by: Cursor <cursoragent@cursor.com>
sprout-agent-bundle-latest
2026-05-17 09:04:09 -04:00
tlongwell-blockandGitHub e4e9923799 feat(agent): add OpenAI Responses API with auto endpoint detection (#604)
Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
2026-05-16 21:23:33 -04:00
tlongwell-blockandGitHub edcef511eb docs(readme): refresh as a landing page (#596)
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-05-16 20:04:37 -04:00
tlongwell-blockandGitHub 6ba46f21da ci: publish Sprout Agent Bundle (sprout-acp + sprout-agent + sprout-dev-mcp) to GitHub Releases (#603)
Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
2026-05-16 21:04:00 +00:00
tlongwell-blockandGitHub 3daf8d6ae1 feat(huddle): swap STT default to Parakeet TDT-CTC 110M (EN, int8) (#562)
Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
2026-05-15 22:22:05 -04:00
tlongwell-blockandGitHub d1097aa91f chore(desktop): upgrade Tauri to 2.11.x to fix blank window after sleep on macOS (#600)
Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
2026-05-15 18:59:16 -04:00
tlongwell-blockandGitHub 92c859043c dev-mcp: add view_image tool (#602)
Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
2026-05-15 16:21:16 -04:00
tlongwell-blockandGitHub 7171d4954d fix(relay,desktop): only advertise NIP-43 when enforced; probe pairing by supported_nips (#601)
Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
2026-05-15 15:11:37 -04:00
tlongwell-blockandGitHub 7763072ef9 fix(desktop): derive unread state from NIP-RS + relay catch-up only (#599)
Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
2026-05-15 14:46:33 -04:00
tlongwell-blockandGitHub 4cbcfa86ee docs(testing): rewrite TESTING.md for current API and CLI-first workflow (#597)
Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
2026-05-15 18:11:04 +00:00
tlongwell-blockandGitHub 9e94a7c475 fix(agent): fix OpenAI-compat request body serialization and max_tokens (#595) 2026-05-15 11:07:14 -04:00
tlongwell-blockandGitHub 32347d117c feat(desktop): per-persona and per-agent env var overrides (#594) 2026-05-15 10:43:00 -04:00
tlongwell-blockandGitHub 13dc0df688 fix(desktop): stop pinning agents to deprecated SPROUT_ACP_TURN_TIMEOUT (#592)
Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
2026-05-14 21:51:38 -04:00
tlongwell-blockandGitHub 5cdb3062ed fix(desktop): populate member_count in get_channels so channel browser shows real counts (#548)
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-05-14 20:38:01 -04:00
tlongwell-blockandGitHub 3a3501c77b fix(desktop): autofocus message composer on channel/thread open (#572)
Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
2026-05-14 20:35:44 -04:00
Will PflegerandGitHub 1d8a130b32 refactor(cli): restructure flat commands into 12 subcommand groups (#585) 2026-05-14 19:13:49 -04:00
Will PflegerandGitHub 01a3df7f1f feat(sdk): add builder functions for workflows, DMs, and presence (#589) v0.0.16 2026-05-14 18:49:57 -04:00
4425f1f713 feat(desktop): add message more-actions dropdown menu (#590)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-14 15:31:55 -07:00
586b3678d9 fix(mobile): preserve channel list across background/resume reconnection (#588)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-14 21:24:54 +00:00
8a2d24e4fd Redesign Home as an inbox (#582)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 13:35:17 -07:00
tlongwell-blockandGitHub 1858e98d5e fix(desktop): drive unread badges from live subscription, not refetched lastMessageAt (#581)
Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
2026-05-14 13:25:24 -04:00
9e76a08a47 fix(desktop): refine header scaling and shadow (#573)
Co-authored-by: Cursor <cursoragent@cursor.com>
v0.0.15
2026-05-14 10:07:15 -07:00
b74ec95b8e fix(desktop): keep day dividers below header (#574)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 09:58:21 -07:00
aad564b130 Move agent activity below composer (#579)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Wes <wesb@block.xyz>
2026-05-14 09:46:52 -07:00
bda98da771 docs(nips): NIP-AE — Agent Engrams (#575)
Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
2026-05-14 12:10:56 -04:00
1b87a0905f refactor: extract shared @mention resolver into sprout-sdk (#580)
Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Dawn <dawn@sprout.local>
2026-05-14 11:38:52 -04:00