Merge branch 'worktree-agent-af937df502b61b006' into feature_chore_bug_005

# Conflicts:
#	src-tauri/src/storage/mod.rs
This commit is contained in:
iamdoubz committed 2026-07-07 01:15:08 -05:00
commit 2582d8947f
17 files changed
+1644 -69

No files matched your search

+19 -3
View File
@@ -289,6 +289,10 @@ export interface AppSettings {
consent_acknowledged: boolean;
sync_enabled: boolean;
mcp_enabled: boolean;
mcp_transport: string; // http|stdio
mcp_port: number;
mcp_expose: string; // none|selected|all
mcp_expose_recordings: boolean;
retention_max_age_days: number | null;
retention_max_size_gb: number | null;
pst_last_path: string | null;
@@ -325,6 +329,15 @@ export interface McpAccessEntry {
client: string | null;
}
// mcp_status() response (FR-MCP-1/6). `endpoint` is empty while disabled.
export interface McpStatus {
enabled: boolean;
transport: "http" | "stdio";
endpoint: string;
tokenSet: boolean;
exposeScope: "none" | "selected" | "all";
}
// ---- Commands ----
export const api = {
// `record` controls audio RETENTION (default false / off — ADR-0009).
@@ -455,7 +468,7 @@ export const api = {
getFeatureBrief: (id: string) => invoke<FeatureBrief>("get_feature_brief", { id }),
setBriefExposed: (id: string, exposed: boolean) =>
invoke<void>("set_brief_exposed", { id, exposed }),
mcpStatus: () => invoke("mcp_status"),
mcpStatus: () => invoke<McpStatus>("mcp_status"),
setMcpEnabled: (enabled: boolean, transport?: "http" | "stdio", port?: number) =>
invoke<{ endpoint: string; token: string }>("set_mcp_enabled", { enabled, transport, port }),
setMcpScope: (expose: "none" | "selected" | "all", exposeRecordings?: boolean) =>
@@ -538,8 +551,11 @@ export const events = {
onSyncLinked: (
cb: (p: { ok: boolean; kind: string; error?: string }) => void,
): Promise<UnlistenFn> => listen("sync://linked", (e) => cb(e.payload as never)),
onMcpAccess: (cb: (p: McpAccessEntry & { client?: string }) => void): Promise<UnlistenFn> =>
listen("mcp://access", (e) => cb(e.payload as never)),
// Live tail of the FR-MCP-5 audit log (camelCase on the wire, unlike the
// snake_case McpAccessEntry rows `mcpAccessLog()` returns).
onMcpAccess: (
cb: (p: { at: number; tool: string; meetingId?: MeetingId; client?: string }) => void,
): Promise<UnlistenFn> => listen("mcp://access", (e) => cb(e.payload as never)),
onAgentProgress: (
cb: (p: { briefId: string; tool: string; line: string }) => void,
): Promise<UnlistenFn> => listen("agent://progress", (e) => cb(e.payload as never)),
+68
View File
@@ -15,6 +15,8 @@ import {
type LlmStatus,
type ModelInfo,
type PrivacySelfCheck,
type McpStatus,
type McpAccessEntry,
} from "../api";
const DEFAULT_SETTINGS: AppSettings = {
@@ -30,6 +32,10 @@ const DEFAULT_SETTINGS: AppSettings = {
consent_acknowledged: false,
sync_enabled: false, // sync OFF by default (ADR-0010)
mcp_enabled: false, // MCP server OFF by default (ADR-0011)
mcp_transport: "http",
mcp_port: 4849,
mcp_expose: "none", // scope OFF by default (FR-MCP-3)
mcp_expose_recordings: false,
retention_max_age_days: null, // no cap by default (FR-STORE-2)
retention_max_size_gb: null,
pst_last_path: null,
@@ -58,6 +64,15 @@ class SettingsStore {
// Privacy self-check (T7.6, FR-SEC-2).
privacy = $state<PrivacySelfCheck | null>(null);
// MCP server (Phase 10b, ADR-0011).
mcpStatus = $state<McpStatus | null>(null);
mcpAccessLog = $state<McpAccessEntry[]>([]);
mcpSaving = $state(false);
/** The freshly-minted token from the last `setMcpEnabled(true)` call —
* shown exactly once (it is never re-readable afterwards, same as any
* other newly-issued secret). Cleared on disable or when the panel closes. */
mcpLastToken = $state<string | null>(null);
// LLM provider status (T5.2, FR-LLM-1).
llmStatus = $state<LlmStatus | null>(null);
llmSaving = $state(false);
@@ -82,6 +97,16 @@ class SettingsStore {
await this.loadModels();
await this.loadPrivacy();
await this.loadLlmStatus();
await this.loadMcpStatus();
await this.loadMcpAccessLog();
// Live tail of the FR-MCP-5 audit log — every tool read an agent makes
// while the panel is open shows up immediately, not just on refresh.
await events.onMcpAccess(({ at, tool, meetingId, client }) => {
this.mcpAccessLog = [
{ at, tool, meeting_id: meetingId ?? null, client: client ?? null },
...this.mcpAccessLog,
].slice(0, 50);
});
await events.onHardwareChanged(({ active }) => {
if (this.hardware) this.hardware.active = active;
});
@@ -175,6 +200,49 @@ class SettingsStore {
}
}
async loadMcpStatus() {
try {
this.mcpStatus = await api.mcpStatus();
} catch {
this.mcpStatus = null;
}
}
async loadMcpAccessLog(limit = 50) {
try {
this.mcpAccessLog = await api.mcpAccessLog(limit);
} catch {
this.mcpAccessLog = [];
}
}
/** Enable/disable the loopback MCP server (FR-MCP-1/6). On enable, the
* returned token is stashed in `mcpLastToken` for the one-time reveal. */
async setMcpEnabled(enabled: boolean, transport?: "http" | "stdio", port?: number) {
this.mcpSaving = true;
try {
const res = await api.setMcpEnabled(enabled, transport, port);
this.mcpLastToken = enabled ? res.token : null;
} catch {
this.backendStub = true;
} finally {
this.mcpSaving = false;
}
await this.loadMcpStatus();
await this.loadPrivacy();
}
/** Scope control (FR-MCP-3) — takes effect immediately, no restart needed. */
async setMcpScope(expose: "none" | "selected" | "all", exposeRecordings?: boolean) {
try {
await api.setMcpScope(expose, exposeRecordings);
} catch {
this.backendStub = true;
}
await this.loadMcpStatus();
await this.loadPrivacy();
}
async loadLlmStatus() {
try {
this.llmStatus = await api.llmStatus();
+214 -1
View File
@@ -25,6 +25,9 @@
ChevronRight,
RotateCcw,
Info,
Bot,
Copy,
KeyRound,
} from "@lucide/svelte";
import {
OLLAMA_OPTIONS,
@@ -37,7 +40,7 @@
let { onClose }: { onClose: () => void } = $props();
let section = $state<
"recording" | "hardware" | "storage" | "calendar" | "sync" | "ai" | "privacy" | "about"
"recording" | "hardware" | "storage" | "calendar" | "sync" | "ai" | "mcp" | "privacy" | "about"
>("recording");
// ---- About (version + build commit + source) ----
@@ -83,6 +86,49 @@
}
}
// ---- MCP server (Phase 10b, ADR-0011) ----
let mcpTransport = $state<"http" | "stdio">(
settings.settings.mcp_transport === "stdio" ? "stdio" : "http",
);
let mcpPort = $state(settings.settings.mcp_port);
let mcpExpose = $state<"none" | "selected" | "all">(
(settings.settings.mcp_expose as "none" | "selected" | "all") ?? "none",
);
let mcpExposeRecordings = $state(settings.settings.mcp_expose_recordings);
let mcpCopied = $state<"endpoint" | "token" | null>(null);
$effect(() => {
// Re-sync the form when settings (re)load, same pattern as the LLM form above.
mcpTransport = settings.settings.mcp_transport === "stdio" ? "stdio" : "http";
mcpPort = settings.settings.mcp_port;
mcpExpose = (settings.settings.mcp_expose as "none" | "selected" | "all") ?? "none";
mcpExposeRecordings = settings.settings.mcp_expose_recordings;
});
async function toggleMcpEnabled(enabled: boolean) {
await settings.setMcpEnabled(enabled, mcpTransport, mcpPort);
}
async function saveMcpScope() {
await settings.setMcpScope(mcpExpose, mcpExposeRecordings);
}
async function copyToClipboard(text: string, what: "endpoint" | "token") {
try {
await navigator.clipboard.writeText(text);
mcpCopied = what;
setTimeout(() => {
if (mcpCopied === what) mcpCopied = null;
}, 2000);
} catch {
/* clipboard API unavailable — the value is still selectable/copyable by hand */
}
}
function relativeTime(atMs: number): string {
const diffSec = Math.round((Date.now() - atMs) / 1000);
if (diffSec < 5) return "just now";
if (diffSec < 60) return `${diffSec}s ago`;
if (diffSec < 3600) return `${Math.round(diffSec / 60)}m ago`;
if (diffSec < 86400) return `${Math.round(diffSec / 3600)}h ago`;
return new Date(atMs).toLocaleString();
}
// ---- Advanced Ollama configuration (sparse; only overrides are stored) ----
const OPTION_GROUPS: OllamaGroup[] = ["sampling", "repetition", "mirostat", "context"];
type OptVal = number | boolean | string[];
@@ -489,6 +535,9 @@
<button class:active={section === "ai"} onclick={() => (section = "ai")}>
<Sparkles size={14} aria-hidden="true" /> AI
</button>
<button class:active={section === "mcp"} onclick={() => (section = "mcp")}>
<Bot size={14} aria-hidden="true" /> MCP server
</button>
<button class:active={section === "privacy"} onclick={() => (section = "privacy")}>
<ShieldCheck size={14} aria-hidden="true" /> Privacy
</button>
@@ -1191,6 +1240,146 @@
</div>
{/if}
</section>
{:else if section === "mcp"}
<section>
<h3>MCP server</h3>
<div class="banner">
<AlertTriangle size={14} aria-hidden="true" />
<span>
This lets your own coding agent (Claude Code, Codex, Copilot, OpenCode, …) pull meeting
context on your local machine. Once connected, <strong>that agent</strong> may forward
what it reads to its own model provider's cloud — outside WhispAssist's control.
WhispAssist itself never sends this data anywhere; the server only listens on this
device (<code>127.0.0.1</code>) and every read is logged below.
</span>
</div>
<label class="row">
<input
type="checkbox"
checked={settings.mcpStatus?.enabled ?? false}
disabled={settings.mcpSaving}
onchange={(e) => toggleMcpEnabled((e.target as HTMLInputElement).checked)}
/>
<span>Enable the MCP server</span>
</label>
<p class="muted">
Off by default. Loopback-only, token-gated — nothing is reachable from the network.
</p>
<div class="grid">
<label
>Transport
<select bind:value={mcpTransport} disabled={settings.mcpStatus?.enabled}>
<option value="http">Streamable HTTP</option>
<option value="stdio">stdio (agent spawns a process)</option>
</select>
</label>
{#if mcpTransport === "http"}
<label
>Port<input
type="number"
min="1"
max="65535"
bind:value={mcpPort}
disabled={settings.mcpStatus?.enabled}
/></label
>
{/if}
</div>
{#if settings.mcpStatus?.enabled}
<p class="muted small">
To change transport/port, turn the server off first, then back on.
</p>
{/if}
<h4>Scope</h4>
<div class="grid">
<label
>Expose
<select bind:value={mcpExpose} onchange={saveMcpScope}>
<option value="none">None — nothing is shared</option>
<option value="selected">Selected — only feature briefs you've marked shared</option>
<option value="all"
>All — meetings, transcripts, action items, and shared briefs</option
>
</select>
</label>
</div>
<label class="row small">
<input
type="checkbox"
bind:checked={mcpExposeRecordings}
disabled={mcpExpose === "none"}
onchange={saveMcpScope}
/>
<span
>Also allow meetings with a saved recording (off by default — a recorded meeting's
transcript is withheld even in "All" scope until this is on)</span
>
</label>
{#if settings.mcpLastToken}
<div class="mcp-token" aria-live="polite">
<div class="row">
<KeyRound size={14} aria-hidden="true" />
<strong>New auth token — shown once, copy it now</strong>
</div>
<p class="muted small">
This won't be shown again. It's stored in your OS credential store; if you lose it,
turn the server off and back on to mint a new one.
</p>
<div class="row">
<code>{settings.mcpLastToken}</code>
<button
class="link"
onclick={() => copyToClipboard(settings.mcpLastToken ?? "", "token")}
>
<Copy size={13} aria-hidden="true" />
{mcpCopied === "token" ? "Copied" : "Copy"}
</button>
</div>
</div>
{/if}
{#if settings.mcpStatus?.enabled}
<div class="row">
Endpoint<code>{settings.mcpStatus.endpoint}</code>
<button
class="link"
onclick={() => copyToClipboard(settings.mcpStatus?.endpoint ?? "", "endpoint")}
>
<Copy size={13} aria-hidden="true" />
{mcpCopied === "endpoint" ? "Copied" : "Copy"}
</button>
</div>
<p class="muted small">
Point your agent's MCP client config at this
{settings.mcpStatus.transport === "stdio" ? "command" : "URL"}, with the token above as
a bearer credential.
</p>
{/if}
<h4>Access log</h4>
<p class="muted">Every tool read an agent makes, allowed or denied (FR-MCP-5).</p>
{#if settings.mcpAccessLog.length === 0}
<p class="muted">No agent has read anything yet.</p>
{:else}
<ul class="events">
{#each settings.mcpAccessLog as entry, i (entry.at + "-" + i)}
<li>
<code>{entry.tool}</code>
{#if entry.meeting_id}<span class="host"
>meeting {entry.meeting_id.slice(0, 8)}</span
>{/if}
{#if entry.client}<span class="badge">{entry.client}</span>{/if}
<span class="host">{relativeTime(entry.at)}</span>
</li>
{/each}
</ul>
<button class="link" onclick={() => settings.loadMcpAccessLog()}>Refresh</button>
{/if}
</section>
{:else if section === "privacy"}
<section>
<h3>Privacy</h3>
@@ -1210,6 +1399,18 @@
{settings.privacy.syncEnabled ? "enabled" : "off"}
</span>
</div>
<div class="row">
MCP server<span class="badge" class:busy={settings.mcpStatus?.enabled}>
{settings.mcpStatus?.enabled ? `on · ${settings.mcpStatus.exposeScope}` : "off"}
</span>
</div>
{#if settings.mcpStatus?.enabled}
<p class="muted confirm">
<Check size={14} aria-hidden="true" />
Inbound on loopback only — it adds nothing to the egress list above. A connected agent may
still forward what it reads to its own model provider; see the MCP server tab.
</p>
{/if}
<h4>Egress allowlist</h4>
{#if settings.privacy.allowlistedHosts.length === 0}
@@ -1660,6 +1861,18 @@
ul.hosts li {
padding: 0.2rem 0;
}
.mcp-token {
margin: 0.6rem 0;
padding: 0.6rem 0.75rem;
border: 1px solid var(--warning, #d97706);
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.mcp-token code {
word-break: break-all;
}
/* ---- AI provider: status + advanced config ---- */
.ghost {