Skip to content

v5.14.0

Latest

Choose a tag to compare

@github-actions github-actions released this 27 Aug 08:51
· 13 commits to main since this release
2dfde60

Minor Changes

  • a5acd6c: Outreach: extract structured outcomes from a finished voice call

    A voice campaign can now declare extractionSchema — up to 12 fields (key, label, type, description, optional options and tagPrefix) naming what to pull off each call. When a call on that campaign ends, OutreachCallOutcomeSink enqueues skill://outreach/extract-call-outcome, which reads the transcript and writes what the prospect actually said into that contact's customFields, plus the always-written callOutcome, callOutcomeAt and callOutcomeConversationId.

    The fields are static per campaign and dynamic across campaigns: the operator picks what to collect, the model fills it in. That keeps values comparable enough to filter and report on without a migration per customer, which a free-form "let the model choose the fields" pass would not.

    Two decisions worth recording. The enqueue is an EventSink on conversation.voice.call_ended rather than an inline call in ConvService.changeStatus, because the sibling identity pass lives there and adding this one would invert the module dependency — OutreachModule imports ConvModule, not the reverse. And the skill writes tags as the union of existing and new, because tags on a contact patch replaces the whole array while customFields merges key-wise; sending only the new tags would delete the segment membership that put the prospect on the call list.

    extractionSchema is rejected on non-voice campaigns rather than silently ignored, mirroring the existing email-only guard on sequenceSteps. The extraction pass owns outcome fields only — identity stays with skill://crm/extract-contact-from-message, which runs on the same close.

  • 80d6f34: Outreach: extract outcomes from email and SMS replies, not just calls

    extractionSchema now works on every outreach channel. A voice campaign still extracts once when the call ends; an email or SMS campaign extracts each time the prospect replies, so a campaign that asks a qualifying question in writing gets the same structured answer a call does.

    The skill is renamed skill://outreach/extract-call-outcomeskill://outreach/extract-outcome, and its reserved keys callOutcome / callOutcomeAt / callOutcomeConversationId become outreachOutcome / outreachOutcomeAt / outreachOutcomeConversationId, with wrong_number widening to wrong_contact — a reply from the wrong person is the same signal as a wrong number, and none of those names were true of an email thread. Migration 0084 renames both the persisted curator_jobs.job_uri rows and the existing crm_contacts.custom_fields keys.

    Three things the implementation turns on. Voice transcript turns are inserted as end_user messages and emit conversation.message.received like any inbound mail, so the sink keys the reply trigger to email and SMS channels only — otherwise every prospect utterance mid-call would enqueue an extraction job. The reply trigger dedupes per message rather than per conversation, matching the existing outreach-draft-reply:msg: key, so a multi-turn thread extracts once per answer and later answers supersede earlier ones. And the contact is now resolved from the outreach_proposals row for the conversation instead of metadata.crmContactId, which only the voice stub ever wrote.

    The voice-only guard on extractionSchema is gone, since every channel a campaign can run on now supports extraction.

  • 701413c: Scope a dashboard session's organization per request instead of per account

    The active organization was a single account-wide flag (org_members.is_default) that every /v1/* request re-read, while the dashboard read the organization name once at page load and cached it. Switching organizations in one tab therefore changed what every other open tab was served, without changing what those tabs displayed — a stale label sitting on top of another organization's data. The same applied to a second browser or device, so no tab-local mechanism could have fixed it.

    Session credentials now accept a requested organization. CredentialResolver.resolveSessionToken takes an optional organization id, checks it against the caller's memberships, and refuses with OrgAccessDeniedError when it isn't one — it never quietly serves a different organization instead. The control-plane guard reads that id from an x-munin-org request header on the session-cookie path only, so API keys and OAuth tokens stay bound to the organization they were issued for, and it maps the refusal to a 403 carrying code: org_access_denied. Every authenticated response now echoes the organization that served it in an x-munin-org response header (exposed through CORS), and the realtime websocket takes the same id as an orgId connect parameter.

    On the client, the dashboard keeps its organization in sessionStorage, which is per-tab: a tab pins itself to whichever organization served its first response and stays there, so is_default now only decides where a new tab starts. api() sends the pin, adopts the served organization when it has none, and — if the pin is refused, which is what a user switching accounts in the same tab looks like — drops it and retries once, so recovery is invisible rather than a wall of errors.

    This is the transport half of the fix. Until the organization also appears in the dashboard URL, server-rendered layouts still read is_default, so a tab pinned elsewhere can briefly render the account-wide organization name before the client corrects it.

Patch Changes

  • 12fe29d: Strip quoted history from inbound email replies that Gmail formats in a Nordic locale.

    stripQuotedReplyText missed two things that combined to leave a full quoted thread in the
    stored message body. First, the only Norwegian attribution pattern assumed a leading "den",
    so Gmail's date-first form ("ons. 26. aug. 2026 kl. 14:05 skrev Support s@x.example:")
    matched nothing — and Gmail hard-wraps that line at 76 columns, putting the address's closing
    >: on its own line, which defeated the end-of-line anchor regardless. Second, the heuristic
    fallback scans up from the end of the message over quoted lines, so it bailed immediately when
    the sender's client placed their signature below the quote rather than above it.

    Attribution lines are now matched with their quote markers removed (so a nested
    > On … wrote: is recognised too), a wrapped address is rejoined before matching, date-first
    Nordic attributions are recognised when the line also carries a time of day, and the trailing
    scan retries above a signature block. A quote cut no longer swallows a signature that sat
    below the quote — it is re-attached so the signature split still records it — and an
    attribution on the very first line no longer reduces the whole message to (no body).

  • b0753ee: Card grids on Trackers, Channels and Integrations now share one layout. The settings content column has no max-width, so the Trackers page's two-column grid stretched each card to roughly 600px on a wide viewport — twice the width of a channel or connector card, with a 160px sparkline floating in it. CardGrid and CardGridSkeleton lost the columns prop entirely: every call site rendered three columns except that one, so hardcoding the layout is what keeps the pages from drifting apart again.

    The two Integrations sections also loaded behind a single full-width CardSkeleton while Trackers and Channels loaded behind a CardGridSkeleton, so the placeholder was a wide bar where the real content is a grid of cards. Both sections now use CardGridSkeleton at the card count they actually render — three for connectors, one for the Slack bridge.

  • be82132: Stop the docs pages from widening the viewport on phones.

    Three rules in docs.css let content push the document past the viewport, which on iOS
    Safari shrink-to-fits the whole page rather than scrolling the offending element:

    • .field-block had no scroll container, so a REST parameter table (365px min-content)
      pushed /docs/rest to 410px inside a 390px viewport. It now scrolls on its own axis,
      matching how .curl > pre and .docs-switcher already behave.
    • Inline <code> had no wrap opportunity, so a single long token — e.g.
      window.mn.widget.identify(externalId, userHash) in the chat-widget guide — measured
      311px inside a 280px column. overflow-wrap: anywhere applies to inline code only
      (:not(pre) > code), so scrollable code blocks are untouched: white-space: pre
      suppresses wrapping there regardless.
    • .docs-attrs dt kept white-space: nowrap in the ≤720px single-column layout. That is
      right for short mono keys but not for the prose dts in the
      skills-vs-tools-vs-rest guide, whose min-content was a whole 399px sentence.

    Verified across 13 viewport widths (320–1024) on all 22 docs routes — 286 page-loads,
    documentElement.scrollWidth == clientWidth everywhere. Desktop rendering is unchanged:
    every rule either applies only below a breakpoint or only engages when content would
    otherwise overflow.

Published packages

  • @getmunin/analytics-tracker@5.14.0
  • @getmunin/chat-widget@5.14.0
  • @getmunin/agent-host@5.14.0
  • @getmunin/agent-runtime@5.14.0
  • @getmunin/backend-core@5.14.0
  • @getmunin/core@5.14.0
  • @getmunin/dashboard-pages@5.14.0
  • @getmunin/db@5.14.0
  • @getmunin/docs-pages@5.14.0
  • @getmunin/emails@5.14.0
  • @getmunin/inspector-app@5.14.0
  • @getmunin/mcp-toolkit@5.14.0
  • @getmunin/sdk@5.14.0
  • @getmunin/types@5.14.0
  • @getmunin/ui@5.14.0