minutes-mcp 0.7.0 → 0.8.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
@@ -12,10 +12,14 @@
12
12
  * - process_audio: Process an audio file through the pipeline
13
13
  * - add_note: Add a timestamped note to a recording or meeting
14
14
  * - consistency_report: Flag conflicting decisions and stale commitments
15
- * - get_person_profile: Build a profile for a person across meetings
15
+ * - get_person_profile: Rich relationship profile for a person (graph index)
16
+ * - track_commitments: List open/stale commitments, filter by person
17
+ * - relationship_map: All contacts with scores and losing-touch alerts
16
18
  * - research_topic: Cross-meeting topic research
17
19
  * - qmd_collection_status: Check QMD collection registration
18
20
  * - register_qmd_collection: Register Minutes output as QMD collection
21
+ * - list_voices: List enrolled voice profiles for speaker identification
22
+ * - confirm_speaker: Confirm/correct speaker attribution in a meeting
19
23
  *
20
24
  * All tools use execFile (not exec) to shell out to the `minutes` CLI binary.
21
25
  * No shell interpolation — safe from injection.
package/dist/index.js CHANGED
@@ -12,17 +12,21 @@
12
12
  * - process_audio: Process an audio file through the pipeline
13
13
  * - add_note: Add a timestamped note to a recording or meeting
14
14
  * - consistency_report: Flag conflicting decisions and stale commitments
15
- * - get_person_profile: Build a profile for a person across meetings
15
+ * - get_person_profile: Rich relationship profile for a person (graph index)
16
+ * - track_commitments: List open/stale commitments, filter by person
17
+ * - relationship_map: All contacts with scores and losing-touch alerts
16
18
  * - research_topic: Cross-meeting topic research
17
19
  * - qmd_collection_status: Check QMD collection registration
18
20
  * - register_qmd_collection: Register Minutes output as QMD collection
21
+ * - list_voices: List enrolled voice profiles for speaker identification
22
+ * - confirm_speaker: Confirm/correct speaker attribution in a meeting
19
23
  *
20
24
  * All tools use execFile (not exec) to shell out to the `minutes` CLI binary.
21
25
  * No shell interpolation — safe from injection.
22
26
  */
23
27
  import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
24
28
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
25
- import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server";
29
+ import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, EXTENSION_ID, } from "@modelcontextprotocol/ext-apps/server";
26
30
  import { z } from "zod";
27
31
  import { execFile, spawn } from "child_process";
28
32
  import { promisify } from "util";
@@ -237,7 +241,13 @@ function validatePathInDirectories(path, roots, allowedExts) {
237
241
  // ── MCP Server ──────────────────────────────────────────────
238
242
  const server = new McpServer({
239
243
  name: "minutes",
240
- version: "0.7.0",
244
+ version: "0.8.0",
245
+ });
246
+ // Declare MCP Apps extension support so hosts classify this server as interactive.
247
+ // The `extensions` field is part of the draft MCP spec (SEP-1724) — not yet in the
248
+ // stable SDK types, so we cast through `any`.
249
+ server.server.registerCapabilities({
250
+ extensions: { [EXTENSION_ID]: {} },
241
251
  });
242
252
  // Configurable directories — override via env vars in Claude Desktop extension settings
243
253
  const MEETINGS_DIR = process.env.MEETINGS_DIR || join(homedir(), "meetings");
@@ -583,74 +593,84 @@ registerAppTool(server, "consistency_report", {
583
593
  });
584
594
  // ── Tool: get_person_profile ───────────────────────────────
585
595
  registerAppTool(server, "get_person_profile", {
586
- description: "Build a first-pass profile for a person across meetings using structured intent data.",
596
+ description: "Get a rich relationship profile for a person: meetings, commitments, topics, relationship score, and trend. Uses the conversation graph index for instant results.",
587
597
  inputSchema: {
588
598
  name: z.string().describe("Person / attendee name to profile"),
589
599
  },
590
600
  annotations: { title: "Person Profile", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
591
601
  _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
592
602
  }, async ({ name }) => {
593
- // Pure-TS fallback when CLI is not available
594
- if (!(await isCliAvailable())) {
595
- const profile = await reader.getPersonProfile(MEETINGS_DIR, name);
596
- const sections = [];
597
- if (profile.topics.length > 0)
598
- sections.push("Topics: " + profile.topics.join(", "));
599
- if (profile.meetings.length > 0) {
600
- sections.push("Meetings:\n" + profile.meetings.map((m) => `- ${m.date} — ${m.title}`).join("\n"));
603
+ // Try graph index first (via CLI `minutes people --json`)
604
+ if (await isCliAvailable()) {
605
+ const { stdout } = await runMinutes(["people", "--json"]);
606
+ const people = parseJsonOutput(stdout);
607
+ if (Array.isArray(people)) {
608
+ const nameLower = name.toLowerCase();
609
+ const match = people.find((p) => p.name?.toLowerCase().includes(nameLower) ||
610
+ p.slug?.toLowerCase().includes(nameLower));
611
+ if (match) {
612
+ const daysSince = Math.round(match.days_since || 0);
613
+ const last = daysSince < 1 ? "today" : daysSince < 2 ? "yesterday" : `${daysSince}d ago`;
614
+ const sections = [];
615
+ sections.push(`Relationship score: ${(match.score || 0).toFixed(1)} | ${match.meeting_count} meetings | last: ${last}`);
616
+ if (match.losing_touch) {
617
+ sections.push("⚠ LOSING TOUCH — meeting frequency has declined");
618
+ }
619
+ if (match.top_topics?.length > 0) {
620
+ sections.push("Top topics: " + match.top_topics.join(", "));
621
+ }
622
+ if (match.open_commitments > 0) {
623
+ sections.push(`Open commitments: ${match.open_commitments}`);
624
+ }
625
+ return {
626
+ content: [{ type: "text", text: `Profile for ${match.name}:\n\n${sections.join("\n")}` }],
627
+ structuredContent: { ...match, view: "person" },
628
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "person" },
629
+ };
630
+ }
601
631
  }
602
- if (profile.openActions.length > 0) {
603
- sections.push("Open actions:\n" + profile.openActions.map((a) => `- ${a.task} (${a.status})`).join("\n"));
632
+ // Fall back to legacy CLI person command for richer meeting-level data
633
+ const { stdout: legacyOut, stderr } = await runMinutes(["person", name]);
634
+ const profile = parseJsonOutput(legacyOut);
635
+ if (profile && typeof profile === "object") {
636
+ const topics = Array.isArray(profile.top_topics) ? profile.top_topics : [];
637
+ const openIntents = Array.isArray(profile.open_intents) ? profile.open_intents : [];
638
+ const recentMeetings = Array.isArray(profile.recent_meetings) ? profile.recent_meetings : [];
639
+ if (topics.length === 0 && openIntents.length === 0 && recentMeetings.length === 0) {
640
+ return {
641
+ content: [{ type: "text", text: `No profile data found for ${name}.` }],
642
+ structuredContent: { name, top_topics: [], open_intents: [], recent_meetings: [], view: "person" },
643
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "person" },
644
+ };
645
+ }
646
+ const sections = [];
647
+ if (topics.length > 0)
648
+ sections.push("Top topics:\n" + topics.map((t) => `- ${t.topic} (${t.count})`).join("\n"));
649
+ if (openIntents.length > 0)
650
+ sections.push("Open commitments:\n" + openIntents.map((i) => `- ${i.kind}: ${i.what}${i.by_date ? ` by ${i.by_date}` : ""}`).join("\n"));
651
+ if (recentMeetings.length > 0)
652
+ sections.push("Recent meetings:\n" + recentMeetings.map((m) => `- ${m.date} — ${m.title}`).join("\n"));
653
+ return {
654
+ content: [{ type: "text", text: `Profile for ${profile.name}:\n\n${sections.join("\n\n")}` }],
655
+ structuredContent: { ...profile, view: "person" },
656
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "person" },
657
+ };
604
658
  }
605
- const text = sections.length > 0 ? sections.join("\n\n") : `No profile data found for ${name}.`;
606
- return {
607
- content: [{ type: "text", text }],
608
- structuredContent: { name, top_topics: profile.topics.map((t) => ({ topic: t, count: 1 })), open_intents: profile.openActions, recent_meetings: profile.meetings, view: "person" },
609
- _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "person" },
610
- };
611
- }
612
- const { stdout, stderr } = await runMinutes(["person", name]);
613
- const profile = parseJsonOutput(stdout);
614
- if (!profile || typeof profile !== "object") {
615
- return { content: [{ type: "text", text: stderr || stdout }] };
616
- }
617
- const topics = Array.isArray(profile.top_topics) ? profile.top_topics : [];
618
- const openIntents = Array.isArray(profile.open_intents) ? profile.open_intents : [];
619
- const recentMeetings = Array.isArray(profile.recent_meetings)
620
- ? profile.recent_meetings
621
- : [];
622
- if (topics.length === 0 && openIntents.length === 0 && recentMeetings.length === 0) {
623
- return {
624
- content: [{ type: "text", text: `No profile data found for ${name}.` }],
625
- structuredContent: { name, top_topics: [], open_intents: [], recent_meetings: [], view: "person" },
626
- _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "person" },
627
- };
659
+ return { content: [{ type: "text", text: stderr || legacyOut || `No data found for ${name}.` }] };
628
660
  }
661
+ // Pure-TS fallback when CLI is not available
662
+ const profile = await reader.getPersonProfile(MEETINGS_DIR, name);
629
663
  const sections = [];
630
- if (topics.length > 0) {
631
- sections.push("Top topics:\n" +
632
- topics.map((topic) => `- ${topic.topic} (${topic.count})`).join("\n"));
633
- }
634
- if (openIntents.length > 0) {
635
- sections.push("Open commitments/actions:\n" +
636
- openIntents
637
- .map((intent) => `- ${intent.kind}: ${intent.what}${intent.by_date ? ` by ${intent.by_date}` : ""}`)
638
- .join("\n"));
639
- }
640
- if (recentMeetings.length > 0) {
641
- sections.push("Recent meetings:\n" +
642
- recentMeetings
643
- .map((meeting) => `- ${meeting.date} — ${meeting.title}`)
644
- .join("\n"));
645
- }
664
+ if (profile.topics.length > 0)
665
+ sections.push("Topics: " + profile.topics.join(", "));
666
+ if (profile.meetings.length > 0)
667
+ sections.push("Meetings:\n" + profile.meetings.map((m) => `- ${m.date} — ${m.title}`).join("\n"));
668
+ if (profile.openActions.length > 0)
669
+ sections.push("Open actions:\n" + profile.openActions.map((a) => `- ${a.task} (${a.status})`).join("\n"));
670
+ const text = sections.length > 0 ? sections.join("\n\n") : `No profile data found for ${name}.`;
646
671
  return {
647
- content: [
648
- {
649
- type: "text",
650
- text: `Profile for ${profile.name}:\n\n${sections.join("\n\n")}`,
651
- },
652
- ],
653
- structuredContent: { ...profile, view: "person" },
672
+ content: [{ type: "text", text }],
673
+ structuredContent: { name, top_topics: profile.topics.map((t) => ({ topic: t, count: 1 })), open_intents: profile.openActions, recent_meetings: profile.meetings, view: "person" },
654
674
  _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "person" },
655
675
  };
656
676
  });
@@ -912,6 +932,107 @@ server.tool("register_qmd_collection", "Register the Minutes output directory as
912
932
  ],
913
933
  };
914
934
  });
935
+ // ── Tool: track_commitments ─────────────────────────────────
936
+ registerAppTool(server, "track_commitments", {
937
+ description: "List open and stale commitments (action items, intents, decisions) across all meetings. Optionally filter by person. Answers: 'What did I promise Sarah?' or 'What's overdue?'",
938
+ inputSchema: {
939
+ person: z.string().optional().describe("Filter by person name or slug (optional — omit for all commitments)"),
940
+ },
941
+ annotations: { title: "Track Commitments", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
942
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
943
+ }, async ({ person }) => {
944
+ if (!(await isCliAvailable())) {
945
+ return { content: [{ type: "text", text: "Minutes CLI not available. Install with: cargo install minutes-cli" }] };
946
+ }
947
+ // Use dedicated commitments command for full text detail
948
+ const args = ["commitments", "--json"];
949
+ if (person)
950
+ args.push("--person", person);
951
+ const { stdout } = await runMinutes(args);
952
+ const commitments = parseJsonOutput(stdout);
953
+ if (!Array.isArray(commitments) || commitments.length === 0) {
954
+ const scope = person ? ` for ${person}` : "";
955
+ return {
956
+ content: [{ type: "text", text: `No open commitments found${scope}.` }],
957
+ structuredContent: { commitments: [], person: person || null, view: "commitments" },
958
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "commitments" },
959
+ };
960
+ }
961
+ // Group by status
962
+ const stale = commitments.filter((c) => c.status === "stale");
963
+ const open = commitments.filter((c) => c.status === "open");
964
+ const lines = [];
965
+ if (stale.length > 0) {
966
+ lines.push(`STALE (${stale.length} overdue):`);
967
+ for (const c of stale) {
968
+ const who = c.person_name || "unassigned";
969
+ lines.push(` ⚠ ${c.text} (${who}; due: ${c.due_date || "no date"}; from: ${c.meeting_title})`);
970
+ }
971
+ }
972
+ if (open.length > 0) {
973
+ if (stale.length > 0)
974
+ lines.push("");
975
+ lines.push(`OPEN (${open.length}):`);
976
+ for (const c of open) {
977
+ const who = c.person_name || "unassigned";
978
+ lines.push(` · ${c.text} (${who}; from: ${c.meeting_title})`);
979
+ }
980
+ }
981
+ const text = `Commitments${person ? ` for ${person}` : ""}:\n\n${lines.join("\n")}`;
982
+ return {
983
+ content: [{ type: "text", text }],
984
+ structuredContent: { commitments, person: person || null, stale_count: stale.length, open_count: open.length, view: "commitments" },
985
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "commitments" },
986
+ };
987
+ });
988
+ // ── Tool: relationship_map ──────────────────────────────────
989
+ registerAppTool(server, "relationship_map", {
990
+ description: "Show all contacts with relationship scores, meeting frequency, and 'losing touch' alerts. Overview of your entire conversation network.",
991
+ inputSchema: {
992
+ limit: z.number().optional().describe("Max people to return (default: 15)"),
993
+ },
994
+ annotations: { title: "Relationship Map", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
995
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
996
+ }, async ({ limit }) => {
997
+ if (!(await isCliAvailable())) {
998
+ return { content: [{ type: "text", text: "Minutes CLI not available. Install with: cargo install minutes-cli" }] };
999
+ }
1000
+ const maxPeople = limit || 15;
1001
+ const { stdout } = await runMinutes(["people", "--json", "--limit", String(maxPeople)]);
1002
+ const people = parseJsonOutput(stdout);
1003
+ if (!Array.isArray(people) || people.length === 0) {
1004
+ return {
1005
+ content: [{ type: "text", text: "No relationship data found. Run: minutes people --rebuild" }],
1006
+ structuredContent: { people: [], view: "relationship_map" },
1007
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "relationship_map" },
1008
+ };
1009
+ }
1010
+ // Format human-readable output
1011
+ const lines = [];
1012
+ const losingTouch = [];
1013
+ for (const p of people) {
1014
+ const daysSince = Math.round(p.days_since || 0);
1015
+ const last = daysSince < 1 ? "today" : daysSince < 2 ? "yesterday" : `${daysSince}d ago`;
1016
+ const status = p.losing_touch
1017
+ ? "⚠ losing touch"
1018
+ : p.open_commitments > 0
1019
+ ? `${p.open_commitments} open commitment${p.open_commitments !== 1 ? "s" : ""}`
1020
+ : "✓ all clear";
1021
+ lines.push(`${p.name} — ${p.meeting_count} meetings, last: ${last}, ${status} (score: ${(p.score || 0).toFixed(1)})`);
1022
+ if (p.losing_touch) {
1023
+ losingTouch.push(`${p.name} — ${p.meeting_count} meetings total, last seen ${daysSince}d ago`);
1024
+ }
1025
+ }
1026
+ let text = `Relationship Map (${people.length} contacts):\n\n${lines.join("\n")}`;
1027
+ if (losingTouch.length > 0) {
1028
+ text += `\n\nLosing Touch:\n${losingTouch.join("\n")}`;
1029
+ }
1030
+ return {
1031
+ content: [{ type: "text", text }],
1032
+ structuredContent: { people, view: "relationship_map" },
1033
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "relationship_map" },
1034
+ };
1035
+ });
915
1036
  // ── Resources ───────────────────────────────────────────────
916
1037
  server.resource("recent_meetings", "minutes://meetings/recent", { description: "List of recent meetings and memos" }, async () => {
917
1038
  if (!(await isCliAvailable())) {
@@ -1069,6 +1190,53 @@ server.tool("stop_dictation", "Stop the current dictation session.", {}, { title
1069
1190
  ],
1070
1191
  };
1071
1192
  });
1193
+ // ── Tool: list_voices ────────────────────────────────────────
1194
+ server.tool("list_voices", "List enrolled voice profiles for speaker identification. Shows who has been enrolled, sample count, and model version.", {}, { title: "Voice Profiles", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async () => {
1195
+ if (!(await isCliAvailable())) {
1196
+ return { content: [{ type: "text", text: "Minutes CLI not available." }] };
1197
+ }
1198
+ const { stdout, stderr } = await runMinutes(["voices", "--json"]);
1199
+ const profiles = parseJsonOutput(stdout);
1200
+ if (!Array.isArray(profiles) || profiles.length === 0) {
1201
+ return {
1202
+ content: [{ type: "text", text: "No voice profiles enrolled. The user can enroll with: minutes enroll" }],
1203
+ };
1204
+ }
1205
+ const lines = profiles.map((p) => `${p.name} — ${p.sample_count} samples, ${p.source} (${p.model_version})`);
1206
+ return {
1207
+ content: [{ type: "text", text: `Voice profiles (${profiles.length}):\n\n${lines.join("\n")}` }],
1208
+ structuredContent: { profiles, view: "voices" },
1209
+ };
1210
+ });
1211
+ // ── Tool: confirm_speaker ────────────────────────────────────
1212
+ server.tool("confirm_speaker", "Confirm or correct a speaker attribution in a meeting. Promotes the attribution to High confidence and rewrites the transcript label. Optionally saves the speaker's voice profile for future meetings.", {
1213
+ meeting: z.string().describe("Path to the meeting markdown file"),
1214
+ speaker_label: z.string().describe("Speaker label to confirm (e.g., SPEAKER_1)"),
1215
+ name: z.string().describe("Real name to assign to this speaker"),
1216
+ save_voice: z.boolean().optional().default(false).describe("Save this speaker's voice profile for future automatic identification"),
1217
+ }, { title: "Confirm Speaker", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ meeting, speaker_label, name, save_voice }) => {
1218
+ if (!(await isCliAvailable())) {
1219
+ return { content: [{ type: "text", text: "Minutes CLI not available." }] };
1220
+ }
1221
+ const args = ["confirm", "--meeting", meeting, "--speaker", speaker_label, "--name", name];
1222
+ if (save_voice)
1223
+ args.push("--save-voice");
1224
+ try {
1225
+ const { stdout, stderr } = await runMinutes(args);
1226
+ const output = (stderr || stdout || "").trim();
1227
+ return {
1228
+ content: [{ type: "text", text: output || `Confirmed: ${speaker_label} = ${name}` }],
1229
+ structuredContent: { meeting, speaker_label, name, save_voice, confirmed: true },
1230
+ };
1231
+ }
1232
+ catch (error) {
1233
+ const msg = error?.stderr || error?.message || String(error);
1234
+ return {
1235
+ content: [{ type: "text", text: `Failed to confirm speaker: ${msg}` }],
1236
+ isError: true,
1237
+ };
1238
+ }
1239
+ });
1072
1240
  // ── Start server ────────────────────────────────────────────
1073
1241
  async function main() {
1074
1242
  const transport = new StdioServerTransport();