Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,15 @@ GROQ_API_KEY=
# its STT model or endpoint (OpenRouter's base64-JSON transcription API):
# OPENROUTER_STT_MODEL=openai/whisper-large-v3
# OPENROUTER_STT_URL=https://openrouter.ai/api/v1/audio/transcriptions
# Diarizing STT primary (cloud lane). Leave blank to fall back to Groq (no diarization).
# Diarizing STT (cloud lane): the only cloud provider that returns speaker labels.
# Set this AND TRANSCRIPTION_PROVIDER=assemblyai for "who said what"; leave blank to
# fall back to Groq (transcription only, no speaker labels).
ASSEMBLYAI_API_KEY=
# Reserved for the deepgram provider (not yet wired): https://console.deepgram.com
DEEPGRAM_API_KEY=
# Keyless self-host diarization: URL of the optional WhisperX+pyannote sidecar.
# When set and TRANSCRIPTION_PROVIDER=local, audio never leaves the instance.
# Diarizing STT (self-host lane): URL of the optional WhisperX+pyannote sidecar, the
# only keyless way to get speaker labels. Set this AND TRANSCRIPTION_PROVIDER=local;
# audio never leaves the instance.
TRANSCRIPTION_LOCAL_URL=

# ---------------------------------------------------------------------------
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,15 @@ Minutia works with zero AI, recording, or calendar; the data model is AI-ready a

Self-hosters bring their own key: set `OPENROUTER_API_KEY` (or an OpenAI-compatible key) in your environment to enable AI, or leave it unset to run fully AI-free.

### Speaker diarization

Speaker labels ("who said what") need a diarizing transcription provider. Groq and OpenAI-compatible Whisper transcribe accurately but return unlabeled text; only two providers diarize:

- **AssemblyAI** - set `ASSEMBLYAI_API_KEY` and `TRANSCRIPTION_PROVIDER=assemblyai`.
- **Local WhisperX sidecar** - run the sidecar and point `TRANSCRIPTION_LOCAL_URL` at it, with `TRANSCRIPTION_PROVIDER=local`, to keep audio on your own infrastructure.

With neither configured, transcripts are produced without speaker labels. Admin > Health shows the current transcription mode ("diarization on" or "transcription only").

## Capture the meeting, no bot in the room

Some conversations you want captured word for word. [Minutia Desktop](https://github.com/shiprite-dev/minutia-desktop) is a native macOS menu bar app that records the meeting the moment it starts, your microphone and the room's system audio both, whether you're on Zoom, Teams, Meet, or sitting across a table. Nothing joins the call: no recording bot in the participant list, no extra service in the middle of your conversation.
Expand Down
53 changes: 53 additions & 0 deletions e2e/regression/audio-capture.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,59 @@ async function createLiveMeetingFixture(request: APIRequestContext) {
return { seriesId, meetingId };
}

async function setCompanionLastSeen(
request: APIRequestContext,
value: string | null
) {
await rest(request, `profiles?id=eq.${TEST_USER_ID}`, {
method: "PATCH",
headers: serviceHeaders("return=minimal"),
data: { companion_last_seen_at: value },
});
}

const MAC_UA =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36";

test.describe("Companion record hand-off", () => {
test.use({ userAgent: MAC_UA });

test("offers a lowercase minutia://record deep link to a mac manager with a known companion", async ({
page,
request,
}) => {
test.skip(!HAS_SERVICE_ROLE, "Requires service role for isolated fixtures");

const fixture = await createLiveMeetingFixture(request);
await setCompanionLastSeen(request, new Date().toISOString());
// The primary platform check reads navigator.userAgentData; force macOS so the
// feature-detect resolves the same way it would on a real Mac browser.
await page.addInitScript(() => {
Object.defineProperty(navigator, "userAgentData", {
configurable: true,
get: () => ({ platform: "macOS" }),
});
});

try {
await page.goto(`/series/${fixture.seriesId}/meetings/${fixture.meetingId}`);
await waitForApp(page);
await expect(page.getByText("Live").first()).toBeVisible();

const link = page.getByRole("link", { name: "Record with companion" });
await expect(link).toBeVisible();
await expect(link).toHaveAttribute(
"href",
`minutia://record?meeting_id=${fixture.meetingId.toLowerCase()}`
);
} finally {
await setCompanionLastSeen(request, null);
await deleteSeries(request, fixture.seriesId);
}
});
});

test.describe("Meeting audio capture", () => {
test("records during live capture and uploads audio on meeting end", async ({
page,
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"test:email-layout": "node --test scripts/verify-email-layout.test.mjs",
"test:brief": "node --test scripts/verify-brief.test.mjs",
"test:auth-links": "node --test scripts/verify-auth-links.test.mjs",
"test:companion-links": "node --test scripts/verify-companion-links.test.mjs",
"test:cadence-contract": "node --test scripts/verify-cadence-contract.test.mjs",
"test:seat-billing": "node --test scripts/verify-seat-billing.test.mjs",
"test:admin-capabilities": "node --test scripts/verify-admin-capabilities.test.mjs",
Expand Down
39 changes: 38 additions & 1 deletion scripts/verify-admin-health.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ await esbuild.build({
logLevel: "silent",
absWorkingDir: root,
});
const { configStatus, overallHealth } = await import(pathToFileURL(bundled).href);
const { configStatus, overallHealth, transcriptionProbe } = await import(
pathToFileURL(bundled).href
);

test("configStatus maps presence to ok/unconfigured", () => {
assert.equal(configStatus("smtp.example.com"), "ok");
Expand Down Expand Up @@ -79,3 +81,38 @@ test("overallHealth is down when any probe reports down", () => {
test("overallHealth handles empty input as ok", () => {
assert.equal(overallHealth([]), "ok");
});

test("transcriptionProbe is unconfigured when no provider is set up", () => {
assert.deepEqual(transcriptionProbe(false, false), {
service: "transcription",
status: "unconfigured",
});
// Diarizing can never be true without being configured, but guard the shape.
assert.equal(transcriptionProbe(false, true).status, "unconfigured");
});

test("transcriptionProbe is ok with a diarization-on note when diarizing", () => {
assert.deepEqual(transcriptionProbe(true, true), {
service: "transcription",
status: "ok",
detail: "diarization on",
});
});

test("transcriptionProbe is degraded (not down) when transcription cannot diarize", () => {
assert.deepEqual(transcriptionProbe(true, false), {
service: "transcription",
status: "degraded",
detail: "transcription only",
});
});

test("a degraded transcription probe keeps overall health amber, not red", () => {
assert.equal(
overallHealth([
{ service: "database", status: "ok" },
transcriptionProbe(true, false),
]),
"degraded"
);
});
77 changes: 77 additions & 0 deletions scripts/verify-companion-links.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import * as esbuild from "esbuild";

// Bundle the pure companion-link helpers for node:test (repo verifier pattern).
const root = process.cwd();
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "minutia-companion-links-"));
const bundled = path.join(tempDir, "companion-links.mjs");
await esbuild.build({
entryPoints: ["src/lib/companion-links.ts"],
outfile: bundled,
bundle: true,
platform: "node",
format: "esm",
logLevel: "silent",
absWorkingDir: root,
});
const { buildCompanionAuthCallbackUrl, buildCompanionRecordUrl, isMacPlatform } =
await import(pathToFileURL(bundled).href);

const LOWER = "0f9c2c9a-1a2b-4c3d-8e4f-5a6b7c8d9e0f";

test("buildCompanionAuthCallbackUrl encodes the token hash into the scheme", () => {
assert.equal(
buildCompanionAuthCallbackUrl("abc123"),
"minutia://auth-callback?token_hash=abc123"
);
assert.equal(
buildCompanionAuthCallbackUrl("a b+c"),
"minutia://auth-callback?token_hash=a%20b%2Bc"
);
});

test("buildCompanionAuthCallbackUrl rejects an empty token hash", () => {
assert.throws(() => buildCompanionAuthCallbackUrl(""));
assert.throws(() => buildCompanionAuthCallbackUrl(" "));
});

test("buildCompanionRecordUrl builds the record scheme with the meeting id", () => {
assert.equal(
buildCompanionRecordUrl(LOWER),
`minutia://record?meeting_id=${LOWER}`
);
});

test("buildCompanionRecordUrl lowercases an uppercase meeting id", () => {
assert.equal(
buildCompanionRecordUrl(LOWER.toUpperCase()),
`minutia://record?meeting_id=${LOWER}`
);
});

test("buildCompanionRecordUrl rejects non-uuid meeting ids", () => {
assert.throws(() => buildCompanionRecordUrl("not-a-uuid"));
assert.throws(() => buildCompanionRecordUrl(""));
assert.throws(() => buildCompanionRecordUrl(`${LOWER} OR 1=1`));
assert.throws(() => buildCompanionRecordUrl(`${LOWER}/extra`));
});

test("isMacPlatform prefers userAgentData.platform when present", () => {
assert.equal(isMacPlatform({ platform: "macOS" }, "irrelevant"), true);
assert.equal(isMacPlatform({ platform: "Windows" }, "Mac irrelevant"), false);
});

test("isMacPlatform falls back to the userAgent string", () => {
assert.equal(
isMacPlatform(undefined, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"),
true
);
assert.equal(isMacPlatform(undefined, "Mozilla/5.0 (Windows NT 10.0)"), false);
assert.equal(isMacPlatform(undefined, undefined), false);
assert.equal(isMacPlatform({}, "Macintosh"), true);
});
16 changes: 16 additions & 0 deletions src/app/(app)/admin/(instance)/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ export default function AdminSettingsPage() {
(form.ai_provider as "openai-compatible" | "anthropic" | null) ??
"openai-compatible";
const visibleAiFields = aiFormFields(activeProvider || "openai-compatible");
const diarizationConfigured = form.diarization_configured === "true";

const showFeatureFlags =
caps.retroToggle || caps.slackWebhook || caps.reminderWebhook || caps.promptLinks;
Expand Down Expand Up @@ -520,6 +521,21 @@ export default function AdminSettingsPage() {
</div>
)}
</div>
{aiKeyConfigured && !diarizationConfigured && (
<p className="rounded-lg border border-rule bg-paper-2 px-3 py-2 text-xs text-ink-3">
Speaker labels are off. Transcripts will not identify who spoke.
Configure AssemblyAI or a local WhisperX sidecar to enable
diarization.{" "}
<a
href="https://github.com/shiprite-dev/minutia#speaker-diarization"
target="_blank"
rel="noreferrer"
className="font-medium text-ink-2 underline hover:text-ink"
>
Learn more
</a>
</p>
)}
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import * as React from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { buildCompanionAuthCallbackUrl } from "@/lib/companion-links";
import { Button } from "@/components/ui/button";
import {
Card,
Expand Down Expand Up @@ -36,9 +37,7 @@ export function CompanionAuthorizeClient() {
return;
}
const { token_hash } = (await res.json()) as { token_hash: string };
const url = `minutia://auth-callback?token_hash=${encodeURIComponent(
token_hash
)}`;
const url = buildCompanionAuthCallbackUrl(token_hash);
setCallbackUrl(url);
setStatus("done");
// Hand off to the desktop app's registered URL scheme. If no handler is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import { useSeriesDetail, useSeriesParticipantRole } from "@/lib/hooks/use-series";
import { useProfile } from "@/lib/hooks/use-profile";
import { CompanionInstallPrompt } from "@/components/minutia/companion-install-prompt";
import { buildCompanionRecordUrl, isMacPlatform } from "@/lib/companion-links";
import { useIssues, useCreateIssue, useUpdateIssueStatus, useUpdateIssue, useAssignIssue, issueKeys } from "@/lib/hooks/use-issues";
import { useCreateDecision, decisionKeys } from "@/lib/hooks/use-decisions";
import { SyncIndicator } from "@/components/minutia/sync-indicator";
Expand Down Expand Up @@ -51,7 +52,7 @@ import { RemindOwnersButton } from "@/components/minutia/remind-owners-button";
import { CarryoverBriefingPanel } from "@/components/minutia/carryover-briefing-panel";
import { AiUnavailableNotice } from "@/components/minutia/ai-unavailable-notice";
import { useAiAccess } from "@/lib/hooks/use-ai-access";
import { ArrowLeft, Square, Play, Check, X, Sparkles, Loader2, ListChecks, FileText, CheckSquare, Gavel, AlertTriangle, Ban, RotateCcw, HelpCircle, ChevronDown } from "lucide-react";
import { ArrowLeft, Square, Play, Check, X, Sparkles, Loader2, ListChecks, FileText, CheckSquare, Gavel, AlertTriangle, Ban, RotateCcw, HelpCircle, ChevronDown, Radio } from "lucide-react";
import { cn } from "@/lib/utils";
import { formatShortDate } from "@/lib/date-utils";
import type { IssueCategory, IssueStatus, Issue, Decision, Meeting, MeetingAiSuggestion } from "@/lib/types";
Expand Down Expand Up @@ -845,6 +846,18 @@ export function MeetingDetailContent({
const canManageMeeting =
participantRole === "owner" || participantRole === "facilitator";

// Hand-off to the desktop companion is offered only when a companion has checked
// in for this user and the browser is on macOS (the only companion platform).
const companionKnown = Boolean(profile?.companion_last_seen_at);
const isMacClient =
typeof navigator !== "undefined" &&
isMacPlatform(
(navigator as Navigator & { userAgentData?: { platform?: string } })
.userAgentData,
navigator.userAgent
);
const canOfferCompanionRecord = companionKnown && isMacClient;

const activePresenceLabel =
presenceUsers.length > 0
? presenceUsers
Expand Down Expand Up @@ -963,11 +976,36 @@ export function MeetingDetailContent({

async function handleStartMeeting() {
const liveMeeting = await startOrJoinMeeting.mutateAsync(seriesId);
nudgeCompanionRecord(liveMeeting.id);
if (liveMeeting.id !== meetingId) {
router.push(`/series/${seriesId}/meetings/${liveMeeting.id}`);
}
}

// After the starter's own action, ask a known macOS companion to record. Fires
// at most once per meeting per browser session; no dialog, no blocking. A
// registered scheme handler consumes the hidden-anchor click; if none exists
// the click is a harmless no-op.
function nudgeCompanionRecord(id: string) {
if (!canOfferCompanionRecord || typeof window === "undefined") return;
const key = `minutia.record-nudge.${id}`;
if (sessionStorage.getItem(key)) return;
let url: string;
try {
url = buildCompanionRecordUrl(id);
} catch {
return;
}
sessionStorage.setItem(key, "1");
const anchor = document.createElement("a");
anchor.href = url;
anchor.style.display = "none";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
toast("Asking the companion to record");
}

function handleTitleChange(issueId: string, title: string) {
updateIssue.mutate({ issueId, title });
}
Expand Down Expand Up @@ -1159,17 +1197,28 @@ export function MeetingDetailContent({

{/* Audio capture (meeting managers only) */}
{canManageMeeting && hasAccess && (
<RecordingIndicator
state={recorder.state}
durationSeconds={recorder.durationSeconds}
isSupported={recorder.isSupported}
error={recorder.error}
uploading={savingRecording}
onStart={recorder.start}
onStop={handleStopRecording}
onPause={recorder.pause}
onResume={recorder.resume}
/>
<div className="flex items-center gap-2">
<RecordingIndicator
state={recorder.state}
durationSeconds={recorder.durationSeconds}
isSupported={recorder.isSupported}
error={recorder.error}
uploading={savingRecording}
onStart={recorder.start}
onStop={handleStopRecording}
onPause={recorder.pause}
onResume={recorder.resume}
/>
{canOfferCompanionRecord && recorder.state === "idle" && (
<a
href={buildCompanionRecordUrl(meeting.id)}
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-rule px-2.5 text-xs font-medium text-ink-2 hover:bg-paper-2 hover:text-ink"
>
<Radio className="size-3.5" aria-hidden />
Record with companion
</a>
)}
</div>
)}

{recorder.state === "stopped" &&
Expand Down
Loading
Loading