minutes-mcp 0.8.4 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -20,6 +20,7 @@
20
20
  * - register_qmd_collection: Register Minutes output as QMD collection
21
21
  * - list_voices: List enrolled voice profiles for speaker identification
22
22
  * - confirm_speaker: Confirm/correct speaker attribution in a meeting
23
+ * - get_meeting_insights: Query structured insights (decisions, commitments, etc.) with confidence filtering
23
24
  *
24
25
  * All tools use execFile (not exec) to shell out to the `minutes` CLI binary.
25
26
  * No shell interpolation — safe from injection.
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@
20
20
  * - register_qmd_collection: Register Minutes output as QMD collection
21
21
  * - list_voices: List enrolled voice profiles for speaker identification
22
22
  * - confirm_speaker: Confirm/correct speaker attribution in a meeting
23
+ * - get_meeting_insights: Query structured insights (decisions, commitments, etc.) with confidence filtering
23
24
  *
24
25
  * All tools use execFile (not exec) to shell out to the `minutes` CLI binary.
25
26
  * No shell interpolation — safe from injection.
@@ -32,7 +33,7 @@ import { execFile, spawn } from "child_process";
32
33
  import { promisify } from "util";
33
34
  import { existsSync } from "fs";
34
35
  import { readFile } from "fs/promises";
35
- import { dirname, join } from "path";
36
+ import { delimiter, dirname, join } from "path";
36
37
  import { fileURLToPath } from "url";
37
38
  import { homedir } from "os";
38
39
  import * as reader from "minutes-sdk";
@@ -238,7 +239,7 @@ async function tryAutoInstall() {
238
239
  // ── CLI version check ───────────────────────────────────────
239
240
  async function checkCliVersion() {
240
241
  try {
241
- const { stdout } = await execFileAsync(MINUTES_BIN, ["--version"], { timeout: 5000 });
242
+ const { stdout } = await execFileAsync(MINUTES_BIN, ["--version"], { timeout: 5000, env: augmentedEnv() });
242
243
  // Output is like "minutes 0.8.0" or just "0.8.0"
243
244
  const match = stdout.trim().match(/(\d+\.\d+\.\d+)/);
244
245
  if (match) {
@@ -268,7 +269,7 @@ async function ensureWhisperModel() {
268
269
  try {
269
270
  // health --json returns an array of { label, state, detail, optional } items.
270
271
  // The "Speech model" item has state "ready" when downloaded.
271
- const { stdout } = await execFileAsync(MINUTES_BIN, ["health", "--json"], { timeout: 10000 });
272
+ const { stdout } = await execFileAsync(MINUTES_BIN, ["health", "--json"], { timeout: 10000, env: augmentedEnv() });
272
273
  const items = JSON.parse(stdout);
273
274
  const modelItem = Array.isArray(items) && items.find((i) => i.label === "Speech model");
274
275
  if (modelItem && modelItem.state === "ready") {
@@ -282,7 +283,7 @@ async function ensureWhisperModel() {
282
283
  // Model not found — download tiny model in background
283
284
  console.error("[Minutes] Whisper model not found — downloading tiny model (~75MB)...");
284
285
  try {
285
- await execFileAsync(MINUTES_BIN, ["setup", "--model", "tiny"], { timeout: 300000 });
286
+ await execFileAsync(MINUTES_BIN, ["setup", "--model", "tiny"], { timeout: 300000, env: augmentedEnv() });
286
287
  console.error("[Minutes] ✓ Whisper tiny model downloaded — recording is ready");
287
288
  }
288
289
  catch (e) {
@@ -304,7 +305,7 @@ async function isCliAvailable() {
304
305
  if (cliAvailable === false && Date.now() - cliCheckedAt < CLI_CACHE_TTL_MS)
305
306
  return false;
306
307
  try {
307
- await execFileAsync(MINUTES_BIN, ["--version"], { timeout: 5000 });
308
+ await execFileAsync(MINUTES_BIN, ["--version"], { timeout: 5000, env: augmentedEnv() });
308
309
  cliAvailable = true;
309
310
  cliCheckedAt = Date.now();
310
311
  console.error("[Minutes] CLI found — full mode (all tools enabled)");
@@ -318,7 +319,7 @@ async function isCliAvailable() {
318
319
  const installed = await tryAutoInstall();
319
320
  if (installed) {
320
321
  try {
321
- await execFileAsync(MINUTES_BIN, ["--version"], { timeout: 5000 });
322
+ await execFileAsync(MINUTES_BIN, ["--version"], { timeout: 5000, env: augmentedEnv() });
322
323
  cliAvailable = true;
323
324
  cliCheckedAt = Date.now();
324
325
  console.error("[Minutes] CLI now available after auto-install — full mode");
@@ -337,16 +338,32 @@ async function isCliAvailable() {
337
338
  }
338
339
  return cliAvailable;
339
340
  }
340
- const CLI_INSTALL_MSG = "Recording requires the minutes CLI binary. Install it:\n" +
341
- " macOS: brew tap silverstein/tap && brew install minutes\n" +
342
- " Any: cargo install minutes-cli\n" +
343
- " Source: https://github.com/silverstein/minutes";
341
+ const CLI_INSTALL_MSG = `Recording requires the minutes CLI binary.\n` +
342
+ `Searched: ${MINUTES_BIN}\n\n` +
343
+ `Install it:\n` +
344
+ ` macOS: brew tap silverstein/tap && brew install minutes\n` +
345
+ ` Any: cargo install minutes-cli\n` +
346
+ ` Source: https://github.com/silverstein/minutes\n\n` +
347
+ `If already installed via Homebrew, try:\n` +
348
+ ` sudo ln -s /opt/homebrew/bin/minutes /usr/local/bin/minutes`;
349
+ // Common binary locations that may not be in Claude Desktop's restricted PATH.
350
+ const EXTRA_PATH_DIRS = [
351
+ join(homedir(), ".local", "bin"),
352
+ join(homedir(), ".cargo", "bin"),
353
+ "/opt/homebrew/bin",
354
+ "/usr/local/bin",
355
+ ];
356
+ function augmentedEnv(extra) {
357
+ const currentPath = process.env.PATH || "";
358
+ const augmentedPath = [...EXTRA_PATH_DIRS, currentPath].join(delimiter);
359
+ return { ...process.env, PATH: augmentedPath, ...extra };
360
+ }
344
361
  // ── Helper: run minutes CLI command (uses execFile, not exec) ──
345
362
  async function runMinutes(args, timeoutMs = 30000) {
346
363
  try {
347
364
  const { stdout, stderr } = await execFileAsync(MINUTES_BIN, args, {
348
365
  timeout: timeoutMs,
349
- env: { ...process.env, RUST_LOG: "info" },
366
+ env: augmentedEnv({ RUST_LOG: "info" }),
350
367
  });
351
368
  return { stdout: stdout.trim(), stderr: stderr.trim() };
352
369
  }
@@ -370,7 +387,7 @@ function parseJsonOutput(stdout) {
370
387
  // ── MCP Server ──────────────────────────────────────────────
371
388
  const server = new McpServer({
372
389
  name: "minutes",
373
- version: "0.8.4",
390
+ version: "0.9.0",
374
391
  });
375
392
  // Declare MCP Apps extension support so hosts classify this server as interactive.
376
393
  // The `extensions` field is part of the draft MCP spec (SEP-1724) — not yet in the
@@ -424,7 +441,8 @@ server.tool("start_recording", "Start recording audio from the default input dev
424
441
  .optional()
425
442
  .default("meeting")
426
443
  .describe("Live capture mode"),
427
- }, { title: "Start Recording", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ title, mode }) => {
444
+ language: z.string().optional().describe("Transcription language code (e.g. 'en', 'ur', 'es', 'zh'). Overrides config.toml setting."),
445
+ }, { title: "Start Recording", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ title, mode, language }) => {
428
446
  if (!(await isCliAvailable())) {
429
447
  return { content: [{ type: "text", text: CLI_INSTALL_MSG }] };
430
448
  }
@@ -445,6 +463,8 @@ server.tool("start_recording", "Start recording audio from the default input dev
445
463
  const args = ["record", "--mode", mode];
446
464
  if (title)
447
465
  args.push("--title", title);
466
+ if (language)
467
+ args.push("--language", language);
448
468
  const child = spawn(MINUTES_BIN, args, {
449
469
  detached: true,
450
470
  stdio: "ignore",
@@ -474,13 +494,60 @@ server.tool("stop_recording", "Stop the current recording and process it (transc
474
494
  try {
475
495
  const { stdout, stderr } = await runMinutes(["stop"], 180000);
476
496
  const result = parseJsonOutput(stdout);
477
- const message = result.file
478
- ? `Recording saved: ${result.file}\nTitle: ${result.title}\nWords: ${result.words}`
479
- : stderr || "Recording stopped.";
497
+ if (result.status === "queued") {
498
+ const title = result.title ? ` for ${result.title}` : "";
499
+ const jobLine = result.job_id ? ` Job: ${result.job_id}.` : "";
500
+ return {
501
+ content: [
502
+ {
503
+ type: "text",
504
+ text: `Recording stopped. Processing queued${title}.${jobLine}`,
505
+ },
506
+ ],
507
+ };
508
+ }
509
+ if (!result.file) {
510
+ return { content: [{ type: "text", text: stderr || "Recording stopped." }] };
511
+ }
480
512
  // Trigger QMD re-index so new meeting is immediately searchable
481
- if (result.file)
482
- triggerQmdIndex();
483
- return { content: [{ type: "text", text: message }] };
513
+ triggerQmdIndex();
514
+ // Build a rich summary by reading the meeting frontmatter
515
+ let summary = `## ${result.title ?? "Recording"}\n\n`;
516
+ summary += `**Saved:** ${result.file}\n`;
517
+ if (result.words != null)
518
+ summary += `**Words:** ${result.words}\n`;
519
+ try {
520
+ const meeting = await reader.getMeeting(result.file);
521
+ if (meeting) {
522
+ const fm = meeting.frontmatter;
523
+ if (fm.duration)
524
+ summary += `**Duration:** ${fm.duration}\n`;
525
+ if (fm.people?.length)
526
+ summary += `**People:** ${fm.people.join(", ")}\n`;
527
+ const actions = fm.action_items?.filter((a) => a.status === "open") || [];
528
+ if (actions.length > 0) {
529
+ summary += `\n### Action Items\n`;
530
+ for (const item of actions) {
531
+ summary += `- [ ] ${item.task}`;
532
+ if (item.assignee)
533
+ summary += ` (${item.assignee})`;
534
+ if (item.due)
535
+ summary += ` — due ${item.due}`;
536
+ summary += `\n`;
537
+ }
538
+ }
539
+ if (fm.decisions?.length) {
540
+ summary += `\n### Decisions\n`;
541
+ for (const d of fm.decisions) {
542
+ summary += `- ${d.text}\n`;
543
+ }
544
+ }
545
+ }
546
+ }
547
+ catch {
548
+ // Frontmatter read is best-effort — basic info is already in the summary
549
+ }
550
+ return { content: [{ type: "text", text: summary }] };
484
551
  }
485
552
  catch (error) {
486
553
  return {
@@ -500,10 +567,47 @@ server.tool("get_status", "Check if a recording is currently in progress.", {},
500
567
  const text = status.recording
501
568
  ? `${modeLabel} in progress (PID: ${status.pid})`
502
569
  : status.processing
503
- ? `${processingLabel}${status.processing_stage ? `: ${status.processing_stage}` : "."}`
570
+ ? `${processingLabel}${status.processing_title ? ` for ${status.processing_title}` : ""}${status.processing_stage ? `: ${status.processing_stage}` : "."}${status.processing_job_count > 1 ? ` (${status.processing_job_count} jobs queued)` : ""}`
504
571
  : "No recording in progress.";
505
572
  return { content: [{ type: "text", text }] };
506
573
  });
574
+ server.tool("list_processing_jobs", "List background processing jobs for recent recordings, including queued, transcript-ready, failed, and completed work.", {
575
+ limit: z.number().optional().default(10).describe("Maximum number of jobs"),
576
+ include_completed: z.boolean().optional().default(true).describe("Include completed and failed jobs"),
577
+ }, { title: "Processing Jobs", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ limit, include_completed }) => {
578
+ if (!(await isCliAvailable())) {
579
+ return { content: [{ type: "text", text: CLI_INSTALL_MSG }] };
580
+ }
581
+ const args = ["jobs", "--json", "--limit", String(limit)];
582
+ if (include_completed)
583
+ args.push("--all");
584
+ try {
585
+ const { stdout } = await runMinutes(args);
586
+ const jobs = parseJsonOutput(stdout);
587
+ if (!Array.isArray(jobs) || jobs.length === 0) {
588
+ return {
589
+ content: [{ type: "text", text: "No processing jobs right now." }],
590
+ structuredContent: { jobs: [] },
591
+ };
592
+ }
593
+ const lines = jobs.map((job) => {
594
+ const title = job.title || "Queued recording";
595
+ const state = job.state || "queued";
596
+ const stage = job.stage ? ` — ${job.stage}` : "";
597
+ return `- ${job.id}: ${state} — ${title}${stage}`;
598
+ });
599
+ return {
600
+ content: [{ type: "text", text: `Processing jobs:\n\n${lines.join("\n")}` }],
601
+ structuredContent: { jobs },
602
+ };
603
+ }
604
+ catch (error) {
605
+ return {
606
+ content: [{ type: "text", text: `Failed to list processing jobs: ${error.message}` }],
607
+ isError: true,
608
+ };
609
+ }
610
+ });
507
611
  // ── Tool: list_meetings ─────────────────────────────────────
508
612
  registerAppTool(server, "list_meetings", {
509
613
  description: "List recent meetings and voice memos.",
@@ -933,7 +1037,8 @@ server.tool("process_audio", "Process an audio file through the transcription pi
933
1037
  file_path: z.string().describe("Path to audio file (.wav, .m4a, .mp3)"),
934
1038
  type: z.enum(["meeting", "memo"]).optional().default("memo").describe("Content type"),
935
1039
  title: z.string().optional().describe("Optional title"),
936
- }, { title: "Process Audio", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ file_path, type: contentType, title }) => {
1040
+ language: z.string().optional().describe("Transcription language code (e.g. 'en', 'ur', 'es', 'zh'). Overrides config.toml setting."),
1041
+ }, { title: "Process Audio", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ file_path, type: contentType, title, language }) => {
937
1042
  if (!(await isCliAvailable())) {
938
1043
  return { content: [{ type: "text", text: CLI_INSTALL_MSG }] };
939
1044
  }
@@ -948,6 +1053,8 @@ server.tool("process_audio", "Process an audio file through the transcription pi
948
1053
  const args = ["process", resolved, "-t", contentType];
949
1054
  if (title)
950
1055
  args.push("--title", title);
1056
+ if (language)
1057
+ args.push("--language", language);
951
1058
  const { stdout } = await runMinutes(args, 300000);
952
1059
  const result = parseJsonOutput(stdout);
953
1060
  return {
@@ -1282,7 +1389,9 @@ server.resource("recent-ideas", "minutes://ideas/recent", { description: "Recent
1282
1389
  };
1283
1390
  });
1284
1391
  // ── Tool: start_dictation ──────────────────────────────────
1285
- server.tool("start_dictation", "Start dictation mode. Speak naturally — text accumulates across pauses and the combined result is written when dictation ends. Runs until stop_dictation is called or silence timeout.", {}, { title: "Start Dictation", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async () => {
1392
+ server.tool("start_dictation", "Start dictation mode. Speak naturally — text accumulates across pauses and the combined result is written when dictation ends. Runs until stop_dictation is called or silence timeout.", {
1393
+ language: z.string().optional().describe("Transcription language code (e.g. 'en', 'ur', 'es', 'zh'). Overrides config.toml setting."),
1394
+ }, { title: "Start Dictation", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ language }) => {
1286
1395
  if (!(await isCliAvailable())) {
1287
1396
  return { content: [{ type: "text", text: CLI_INSTALL_MSG }] };
1288
1397
  }
@@ -1299,7 +1408,10 @@ server.tool("start_dictation", "Start dictation mode. Speak naturally — text a
1299
1408
  };
1300
1409
  }
1301
1410
  // Spawn detached dictation process
1302
- const child = spawn(MINUTES_BIN, ["dictate"], {
1411
+ const dictArgs = ["dictate"];
1412
+ if (language)
1413
+ dictArgs.push("--language", language);
1414
+ const child = spawn(MINUTES_BIN, dictArgs, {
1303
1415
  detached: true,
1304
1416
  stdio: "ignore",
1305
1417
  env: { ...process.env, RUST_LOG: "info" },
@@ -1389,8 +1501,60 @@ server.tool("confirm_speaker", "Confirm or correct a speaker attribution in a me
1389
1501
  };
1390
1502
  }
1391
1503
  });
1504
+ // ── Tool: get_meeting_insights ─────────────────────────────
1505
+ server.tool("get_meeting_insights", "Query structured insights extracted from meetings — decisions, commitments, approvals, questions, blockers, follow-ups, and risks. Each insight has a confidence level (tentative/inferred/strong/explicit). Use this to find what was decided, who committed to what, and what's still open across all meetings. External systems can subscribe to these events for workflow automation.", {
1506
+ kind: z.enum(["decision", "commitment", "approval", "question", "blocker", "follow_up", "risk"]).optional().describe("Filter by insight type"),
1507
+ confidence: z.enum(["tentative", "inferred", "strong", "explicit"]).optional().describe("Minimum confidence level"),
1508
+ participant: z.string().optional().describe("Filter by participant name (partial match)"),
1509
+ since: z.string().optional().describe("Only insights since this date (YYYY-MM-DD)"),
1510
+ limit: z.number().optional().default(50).describe("Maximum number of results"),
1511
+ actionable_only: z.boolean().optional().default(false).describe("Only return actionable insights (Strong or Explicit confidence)"),
1512
+ }, { title: "Get Meeting Insights", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ kind, confidence, participant, since, limit, actionable_only }) => {
1513
+ if (!(await isCliAvailable())) {
1514
+ return { content: [{ type: "text", text: CLI_INSTALL_MSG }] };
1515
+ }
1516
+ const args = ["insights", "--limit", String(limit ?? 50)];
1517
+ if (kind) {
1518
+ args.push("--kind", kind);
1519
+ }
1520
+ if (actionable_only) {
1521
+ args.push("--actionable");
1522
+ }
1523
+ else if (confidence) {
1524
+ args.push("--confidence", confidence);
1525
+ }
1526
+ if (participant) {
1527
+ args.push("--participant", participant);
1528
+ }
1529
+ if (since) {
1530
+ args.push("--since", since);
1531
+ }
1532
+ try {
1533
+ const { stdout } = await runMinutes(args);
1534
+ const insights = parseJsonOutput(stdout);
1535
+ const count = Array.isArray(insights) ? insights.length : 0;
1536
+ if (count === 0) {
1537
+ return {
1538
+ content: [{ type: "text", text: "No meeting insights found matching the filter criteria. Insights are extracted when meetings are processed with summarization enabled." }],
1539
+ };
1540
+ }
1541
+ return {
1542
+ content: [{ type: "text", text: `Found ${count} insight(s):\n\n${JSON.stringify(insights, null, 2)}` }],
1543
+ structuredContent: { count, insights },
1544
+ };
1545
+ }
1546
+ catch (error) {
1547
+ const msg = error?.stderr || error?.message || String(error);
1548
+ return {
1549
+ content: [{ type: "text", text: `Failed to query insights: ${msg}` }],
1550
+ isError: true,
1551
+ };
1552
+ }
1553
+ });
1392
1554
  // ── Tool: start_live_transcript ──────────────────────────────
1393
- server.tool("start_live_transcript", "Start a live transcript session. Records audio and transcribes in real-time, writing utterances to a JSONL file. Use read_live_transcript to read the transcript during the session. Runs until stop is called.", {}, { title: "Start Live Transcript", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async () => {
1555
+ server.tool("start_live_transcript", "Start a live transcript session. Records audio and transcribes in real-time, writing utterances to a JSONL file. Use read_live_transcript to read the transcript during the session. Runs until stop is called.", {
1556
+ language: z.string().optional().describe("Transcription language code (e.g. 'en', 'ur', 'es', 'zh'). Overrides config.toml setting."),
1557
+ }, { title: "Start Live Transcript", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ language }) => {
1394
1558
  if (!(await isCliAvailable())) {
1395
1559
  return { content: [{ type: "text", text: CLI_INSTALL_MSG }] };
1396
1560
  }
@@ -1414,7 +1578,10 @@ server.tool("start_live_transcript", "Start a live transcript session. Records a
1414
1578
  }
1415
1579
  catch { /* no active session, proceed */ }
1416
1580
  // Spawn detached live transcript process
1417
- const child = spawn(MINUTES_BIN, ["live"], {
1581
+ const liveArgs = ["live"];
1582
+ if (language)
1583
+ liveArgs.push("--language", language);
1584
+ const child = spawn(MINUTES_BIN, liveArgs, {
1418
1585
  detached: true,
1419
1586
  stdio: "ignore",
1420
1587
  env: { ...process.env, RUST_LOG: "info" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minutes-mcp",
3
- "version": "0.8.4",
3
+ "version": "0.9.0",
4
4
  "description": "MCP server for minutes — conversation memory for AI assistants. Works with Claude Desktop, Mistral Vibe, Cursor, Windsurf, and any MCP client.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -40,7 +40,7 @@
40
40
  "dependencies": {
41
41
  "@modelcontextprotocol/ext-apps": "^1.2.2",
42
42
  "@modelcontextprotocol/sdk": "^1.27.1",
43
- "minutes-sdk": "^0.8.3",
43
+ "minutes-sdk": "^0.9.0",
44
44
  "yaml": "^2.8.3"
45
45
  },
46
46
  "devDependencies": {