minutes-mcp 0.7.3 → 0.8.1
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 +2 -0
- package/dist/index.js +364 -73
- package/dist/paths.d.ts +6 -0
- package/dist/paths.js +58 -0
- package/dist-ui/index.html +95 -72
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* - research_topic: Cross-meeting topic research
|
|
19
19
|
* - qmd_collection_status: Check QMD collection registration
|
|
20
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
|
|
21
23
|
*
|
|
22
24
|
* All tools use execFile (not exec) to shell out to the `minutes` CLI binary.
|
|
23
25
|
* No shell interpolation — safe from injection.
|
package/dist/index.js
CHANGED
|
@@ -18,22 +18,25 @@
|
|
|
18
18
|
* - research_topic: Cross-meeting topic research
|
|
19
19
|
* - qmd_collection_status: Check QMD collection registration
|
|
20
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
|
|
21
23
|
*
|
|
22
24
|
* All tools use execFile (not exec) to shell out to the `minutes` CLI binary.
|
|
23
25
|
* No shell interpolation — safe from injection.
|
|
24
26
|
*/
|
|
25
27
|
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
26
28
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
27
|
-
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";
|
|
28
30
|
import { z } from "zod";
|
|
29
31
|
import { execFile, spawn } from "child_process";
|
|
30
32
|
import { promisify } from "util";
|
|
31
|
-
import { existsSync
|
|
33
|
+
import { existsSync } from "fs";
|
|
32
34
|
import { readFile } from "fs/promises";
|
|
33
|
-
import { dirname,
|
|
35
|
+
import { dirname, join } from "path";
|
|
34
36
|
import { fileURLToPath } from "url";
|
|
35
37
|
import { homedir } from "os";
|
|
36
38
|
import * as reader from "minutes-sdk";
|
|
39
|
+
import { canonicalizeRoot, expandHomeLikePath, validatePathInDirectories, validatePathInDirectory, } from "./paths.js";
|
|
37
40
|
const UI_RESOURCE_URI = "ui://minutes/dashboard";
|
|
38
41
|
const execFileAsync = promisify(execFile);
|
|
39
42
|
// ── QMD semantic search (optional — falls back to CLI) ──────
|
|
@@ -141,7 +144,152 @@ function findMinutesBinary() {
|
|
|
141
144
|
// Fall back to PATH lookup
|
|
142
145
|
return "minutes";
|
|
143
146
|
}
|
|
144
|
-
|
|
147
|
+
let MINUTES_BIN = findMinutesBinary();
|
|
148
|
+
// ── Expected CLI version (must match this MCP server release) ──
|
|
149
|
+
const EXPECTED_CLI_VERSION = "0.8.1";
|
|
150
|
+
const RELEASE_TAG = "v0.8.1-live-coach";
|
|
151
|
+
// ── CLI auto-install ────────────────────────────────────────
|
|
152
|
+
// When installed via MCPB or `npx minutes-mcp`, the Rust CLI binary
|
|
153
|
+
// may not be present. We attempt to install it automatically so
|
|
154
|
+
// non-technical users don't hit a "binary not found" dead end.
|
|
155
|
+
let installAttempted = false;
|
|
156
|
+
function getReleaseBinaryName() {
|
|
157
|
+
const platform = process.platform;
|
|
158
|
+
const arch = process.arch;
|
|
159
|
+
if (platform === "darwin" && arch === "arm64")
|
|
160
|
+
return "minutes-macos-arm64";
|
|
161
|
+
if (platform === "darwin" && arch === "x64")
|
|
162
|
+
return "minutes-macos-arm64"; // Rosetta handles it
|
|
163
|
+
if (platform === "linux" && arch === "x64")
|
|
164
|
+
return "minutes-linux-x64";
|
|
165
|
+
if (platform === "win32" && arch === "x64")
|
|
166
|
+
return "minutes-windows-x64.exe";
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
function getInstallDir() {
|
|
170
|
+
const localBin = join(homedir(), ".local", "bin");
|
|
171
|
+
if (process.platform === "win32") {
|
|
172
|
+
return join(homedir(), ".cargo", "bin"); // common writable dir on Windows
|
|
173
|
+
}
|
|
174
|
+
return localBin;
|
|
175
|
+
}
|
|
176
|
+
async function tryAutoInstall() {
|
|
177
|
+
if (installAttempted)
|
|
178
|
+
return false;
|
|
179
|
+
installAttempted = true;
|
|
180
|
+
console.error("[Minutes] CLI not found — attempting automatic install...");
|
|
181
|
+
// Strategy 1: Download pre-built binary from GitHub release (fastest, no deps)
|
|
182
|
+
const binaryName = getReleaseBinaryName();
|
|
183
|
+
if (binaryName) {
|
|
184
|
+
try {
|
|
185
|
+
const url = `https://github.com/silverstein/minutes/releases/download/${RELEASE_TAG}/${binaryName}`;
|
|
186
|
+
const installDir = getInstallDir();
|
|
187
|
+
const isWindows = process.platform === "win32";
|
|
188
|
+
const targetName = isWindows ? "minutes.exe" : "minutes";
|
|
189
|
+
const targetPath = join(installDir, targetName);
|
|
190
|
+
console.error(`[Minutes] Downloading ${binaryName} from ${RELEASE_TAG} release...`);
|
|
191
|
+
// Ensure install directory exists
|
|
192
|
+
await execFileAsync("mkdir", ["-p", installDir], { timeout: 5000 }).catch(() => { });
|
|
193
|
+
// Download with curl (available on macOS, Linux, and modern Windows)
|
|
194
|
+
await execFileAsync("curl", ["-fSL", "-o", targetPath, url], { timeout: 120000 });
|
|
195
|
+
// Make executable (not needed on Windows)
|
|
196
|
+
if (!isWindows) {
|
|
197
|
+
await execFileAsync("chmod", ["+x", targetPath], { timeout: 5000 });
|
|
198
|
+
}
|
|
199
|
+
console.error(`[Minutes] ✓ Installed to ${targetPath}`);
|
|
200
|
+
MINUTES_BIN = targetPath;
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
catch (e) {
|
|
204
|
+
console.error(`[Minutes] Binary download failed: ${e.message || e}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// Strategy 2: Homebrew (macOS only)
|
|
208
|
+
if (process.platform === "darwin") {
|
|
209
|
+
try {
|
|
210
|
+
console.error("[Minutes] Trying: brew tap silverstein/tap && brew install minutes");
|
|
211
|
+
await execFileAsync("brew", ["tap", "silverstein/tap"], { timeout: 120000 });
|
|
212
|
+
await execFileAsync("brew", ["install", "minutes"], { timeout: 300000 });
|
|
213
|
+
console.error("[Minutes] ✓ Installed via Homebrew");
|
|
214
|
+
MINUTES_BIN = findMinutesBinary();
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
catch (e) {
|
|
218
|
+
console.error(`[Minutes] Homebrew install failed: ${e.message || e}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// Strategy 3: Cargo (if Rust is installed)
|
|
222
|
+
try {
|
|
223
|
+
console.error("[Minutes] Trying: cargo install minutes-cli");
|
|
224
|
+
await execFileAsync("cargo", ["install", "minutes-cli"], { timeout: 600000 });
|
|
225
|
+
console.error("[Minutes] ✓ Installed via cargo");
|
|
226
|
+
MINUTES_BIN = findMinutesBinary();
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
catch (e) {
|
|
230
|
+
console.error(`[Minutes] cargo install failed: ${e.message || e}`);
|
|
231
|
+
}
|
|
232
|
+
console.error("[Minutes] Auto-install failed. Install manually:\n" +
|
|
233
|
+
" macOS: brew tap silverstein/tap && brew install minutes\n" +
|
|
234
|
+
" Any: cargo install minutes-cli\n" +
|
|
235
|
+
" Source: https://github.com/silverstein/minutes");
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
// ── CLI version check ───────────────────────────────────────
|
|
239
|
+
async function checkCliVersion() {
|
|
240
|
+
try {
|
|
241
|
+
const { stdout } = await execFileAsync(MINUTES_BIN, ["--version"], { timeout: 5000 });
|
|
242
|
+
// Output is like "minutes 0.8.0" or just "0.8.0"
|
|
243
|
+
const match = stdout.trim().match(/(\d+\.\d+\.\d+)/);
|
|
244
|
+
if (match) {
|
|
245
|
+
const installedVersion = match[1];
|
|
246
|
+
if (installedVersion !== EXPECTED_CLI_VERSION) {
|
|
247
|
+
console.error(`[Minutes] ⚠ CLI version mismatch: installed ${installedVersion}, server expects ${EXPECTED_CLI_VERSION}. ` +
|
|
248
|
+
`Update with: brew upgrade minutes (or cargo install minutes-cli)`);
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
console.error(`[Minutes] CLI v${installedVersion} — up to date`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
// Version check is best-effort — don't block on failure
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// ── Auto-setup: download whisper model if missing ───────────
|
|
260
|
+
// Recording needs a whisper model (~75MB for tiny). If the CLI is
|
|
261
|
+
// available but the model isn't downloaded, trigger setup automatically
|
|
262
|
+
// in the background so the first "start recording" just works.
|
|
263
|
+
let modelCheckDone = false;
|
|
264
|
+
async function ensureWhisperModel() {
|
|
265
|
+
if (modelCheckDone)
|
|
266
|
+
return;
|
|
267
|
+
modelCheckDone = true;
|
|
268
|
+
try {
|
|
269
|
+
// health --json returns an array of { label, state, detail, optional } items.
|
|
270
|
+
// The "Speech model" item has state "ready" when downloaded.
|
|
271
|
+
const { stdout } = await execFileAsync(MINUTES_BIN, ["health", "--json"], { timeout: 10000 });
|
|
272
|
+
const items = JSON.parse(stdout);
|
|
273
|
+
const modelItem = Array.isArray(items) && items.find((i) => i.label === "Speech model");
|
|
274
|
+
if (modelItem && modelItem.state === "ready") {
|
|
275
|
+
console.error("[Minutes] Whisper model ready");
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
// health command may not exist in older CLI versions — fall through to setup
|
|
281
|
+
}
|
|
282
|
+
// Model not found — download tiny model in background
|
|
283
|
+
console.error("[Minutes] Whisper model not found — downloading tiny model (~75MB)...");
|
|
284
|
+
try {
|
|
285
|
+
await execFileAsync(MINUTES_BIN, ["setup", "--model", "tiny"], { timeout: 300000 });
|
|
286
|
+
console.error("[Minutes] ✓ Whisper tiny model downloaded — recording is ready");
|
|
287
|
+
}
|
|
288
|
+
catch (e) {
|
|
289
|
+
console.error(`[Minutes] Model download failed: ${e.message || e}. ` +
|
|
290
|
+
`Run manually: minutes setup --model tiny`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
145
293
|
// ── CLI availability detection ──────────────────────────────
|
|
146
294
|
// When installed via `npx minutes-mcp`, the Rust CLI may not be present.
|
|
147
295
|
// In that case, read-only tools use the pure-TS reader module.
|
|
@@ -160,11 +308,32 @@ async function isCliAvailable() {
|
|
|
160
308
|
cliAvailable = true;
|
|
161
309
|
cliCheckedAt = Date.now();
|
|
162
310
|
console.error("[Minutes] CLI found — full mode (all tools enabled)");
|
|
311
|
+
// Check version and ensure whisper model in background (non-blocking)
|
|
312
|
+
checkCliVersion();
|
|
313
|
+
ensureWhisperModel();
|
|
163
314
|
}
|
|
164
315
|
catch {
|
|
316
|
+
// CLI not found — try to install it automatically
|
|
317
|
+
if (!installAttempted) {
|
|
318
|
+
const installed = await tryAutoInstall();
|
|
319
|
+
if (installed) {
|
|
320
|
+
try {
|
|
321
|
+
await execFileAsync(MINUTES_BIN, ["--version"], { timeout: 5000 });
|
|
322
|
+
cliAvailable = true;
|
|
323
|
+
cliCheckedAt = Date.now();
|
|
324
|
+
console.error("[Minutes] CLI now available after auto-install — full mode");
|
|
325
|
+
checkCliVersion();
|
|
326
|
+
ensureWhisperModel();
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
// Install succeeded but binary still not found — path issue
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
165
334
|
cliAvailable = false;
|
|
166
335
|
cliCheckedAt = Date.now();
|
|
167
|
-
console.error("[Minutes] CLI not
|
|
336
|
+
console.error("[Minutes] CLI not available — read-only mode (search and browse only)");
|
|
168
337
|
}
|
|
169
338
|
return cliAvailable;
|
|
170
339
|
}
|
|
@@ -198,52 +367,43 @@ function parseJsonOutput(stdout) {
|
|
|
198
367
|
return { raw: stdout };
|
|
199
368
|
}
|
|
200
369
|
}
|
|
201
|
-
function canonicalizeFilePath(path) {
|
|
202
|
-
if (!existsSync(path)) {
|
|
203
|
-
throw new Error(`Path does not exist: ${path}`);
|
|
204
|
-
}
|
|
205
|
-
return realpathSync(path);
|
|
206
|
-
}
|
|
207
|
-
function canonicalizeRoot(root) {
|
|
208
|
-
// Roots may not exist yet (e.g. ~/.minutes/inbox on first run).
|
|
209
|
-
// Use realpath if it exists, otherwise lexical resolve.
|
|
210
|
-
return existsSync(root) ? realpathSync(root) : resolve(root);
|
|
211
|
-
}
|
|
212
|
-
function isWithinDirectory(candidate, root) {
|
|
213
|
-
// Ensure root ends with separator to prevent prefix attacks (e.g. ~/meetings-evil)
|
|
214
|
-
const rootWithSep = root.endsWith("/") ? root : root + "/";
|
|
215
|
-
return candidate === root || candidate.startsWith(rootWithSep);
|
|
216
|
-
}
|
|
217
|
-
function validatePathInDirectory(path, root, allowedExts) {
|
|
218
|
-
const canonicalPath = canonicalizeFilePath(path);
|
|
219
|
-
const canonicalRoot = canonicalizeRoot(root);
|
|
220
|
-
if (!allowedExts.includes(extname(canonicalPath).toLowerCase())) {
|
|
221
|
-
throw new Error(`Access denied: path must be within ${canonicalRoot} and end with ${allowedExts.join(", ")}`);
|
|
222
|
-
}
|
|
223
|
-
if (!isWithinDirectory(canonicalPath, canonicalRoot)) {
|
|
224
|
-
throw new Error(`Access denied: path must be within ${canonicalRoot}`);
|
|
225
|
-
}
|
|
226
|
-
return canonicalPath;
|
|
227
|
-
}
|
|
228
|
-
function validatePathInDirectories(path, roots, allowedExts) {
|
|
229
|
-
const canonicalPath = canonicalizeFilePath(path);
|
|
230
|
-
if (!allowedExts.includes(extname(canonicalPath).toLowerCase())) {
|
|
231
|
-
throw new Error(`Access denied: path must end with one of ${allowedExts.join(", ")}`);
|
|
232
|
-
}
|
|
233
|
-
const canonicalRoots = roots.map((root) => canonicalizeRoot(root));
|
|
234
|
-
if (!canonicalRoots.some((root) => isWithinDirectory(canonicalPath, root))) {
|
|
235
|
-
throw new Error(`Access denied: file must be inside one of ${canonicalRoots.join(", ")}`);
|
|
236
|
-
}
|
|
237
|
-
return canonicalPath;
|
|
238
|
-
}
|
|
239
370
|
// ── MCP Server ──────────────────────────────────────────────
|
|
240
371
|
const server = new McpServer({
|
|
241
372
|
name: "minutes",
|
|
242
|
-
version: "0.
|
|
373
|
+
version: "0.8.1",
|
|
374
|
+
});
|
|
375
|
+
// Declare MCP Apps extension support so hosts classify this server as interactive.
|
|
376
|
+
// The `extensions` field is part of the draft MCP spec (SEP-1724) — not yet in the
|
|
377
|
+
// stable SDK types, so we cast through `any`.
|
|
378
|
+
server.server.registerCapabilities({
|
|
379
|
+
extensions: { [EXTENSION_ID]: {} },
|
|
243
380
|
});
|
|
244
381
|
// Configurable directories — override via env vars in Claude Desktop extension settings
|
|
245
|
-
const MEETINGS_DIR = process.env.MEETINGS_DIR || join(homedir(), "meetings");
|
|
246
|
-
const MINUTES_HOME = process.env.MINUTES_HOME || join(homedir(), ".minutes");
|
|
382
|
+
const MEETINGS_DIR = canonicalizeRoot(expandHomeLikePath(process.env.MEETINGS_DIR || join(homedir(), "meetings")));
|
|
383
|
+
const MINUTES_HOME = canonicalizeRoot(expandHomeLikePath(process.env.MINUTES_HOME || join(homedir(), ".minutes")));
|
|
384
|
+
let effectiveMeetingsDirPromise = null;
|
|
385
|
+
async function getEffectiveMeetingsDir() {
|
|
386
|
+
if (effectiveMeetingsDirPromise) {
|
|
387
|
+
return effectiveMeetingsDirPromise;
|
|
388
|
+
}
|
|
389
|
+
effectiveMeetingsDirPromise = (async () => {
|
|
390
|
+
if (!(await isCliAvailable())) {
|
|
391
|
+
return MEETINGS_DIR;
|
|
392
|
+
}
|
|
393
|
+
try {
|
|
394
|
+
const { stdout } = await runMinutes(["paths", "--json"]);
|
|
395
|
+
const parsed = parseJsonOutput(stdout);
|
|
396
|
+
if (parsed && typeof parsed.output_dir === "string" && parsed.output_dir.length > 0) {
|
|
397
|
+
return canonicalizeRoot(parsed.output_dir);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
// Fall back to the MCP-configured default when the CLI cannot report paths.
|
|
402
|
+
}
|
|
403
|
+
return MEETINGS_DIR;
|
|
404
|
+
})();
|
|
405
|
+
return effectiveMeetingsDirPromise;
|
|
406
|
+
}
|
|
247
407
|
// ── UI Resource: MCP App dashboard ──────────────────────────
|
|
248
408
|
registerAppResource(server, "Minutes Dashboard", UI_RESOURCE_URI, { description: "Interactive meeting dashboard and detail viewer" }, async () => {
|
|
249
409
|
const htmlPath = join(__dirname, "..", "dist-ui", "index.html");
|
|
@@ -754,7 +914,7 @@ registerAppTool(server, "get_meeting", {
|
|
|
754
914
|
_meta: { ui: { resourceUri: UI_RESOURCE_URI } },
|
|
755
915
|
}, async ({ path: filePath }) => {
|
|
756
916
|
try {
|
|
757
|
-
const resolved = validatePathInDirectory(filePath,
|
|
917
|
+
const resolved = validatePathInDirectory(filePath, await getEffectiveMeetingsDir(), [".md"]);
|
|
758
918
|
const content = await readFile(resolved, "utf-8");
|
|
759
919
|
return {
|
|
760
920
|
content: [{ type: "text", text: content }],
|
|
@@ -779,7 +939,7 @@ server.tool("process_audio", "Process an audio file through the transcription pi
|
|
|
779
939
|
}
|
|
780
940
|
const allowedDirs = [
|
|
781
941
|
join(MINUTES_HOME, "inbox"),
|
|
782
|
-
|
|
942
|
+
await getEffectiveMeetingsDir(),
|
|
783
943
|
join(homedir(), "Downloads"),
|
|
784
944
|
];
|
|
785
945
|
const audioExts = [".wav", ".m4a", ".mp3", ".ogg", ".webm"];
|
|
@@ -821,7 +981,7 @@ server.tool("add_note", "Add a note to the current recording. Notes are timestam
|
|
|
821
981
|
try {
|
|
822
982
|
const args = ["note", text];
|
|
823
983
|
if (meeting_path) {
|
|
824
|
-
const resolved = validatePathInDirectory(meeting_path,
|
|
984
|
+
const resolved = validatePathInDirectory(meeting_path, await getEffectiveMeetingsDir(), [".md"]);
|
|
825
985
|
args.push("--meeting", resolved);
|
|
826
986
|
}
|
|
827
987
|
const { stdout, stderr } = await runMinutes(args);
|
|
@@ -933,28 +1093,16 @@ registerAppTool(server, "track_commitments", {
|
|
|
933
1093
|
annotations: { title: "Track Commitments", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
934
1094
|
_meta: { ui: { resourceUri: UI_RESOURCE_URI } },
|
|
935
1095
|
}, async ({ person }) => {
|
|
936
|
-
// Use CLI: minutes people --json (rebuild if needed, read from SQLite)
|
|
937
|
-
const args = ["people", "--json"];
|
|
938
1096
|
if (!(await isCliAvailable())) {
|
|
939
1097
|
return { content: [{ type: "text", text: "Minutes CLI not available. Install with: cargo install minutes-cli" }] };
|
|
940
1098
|
}
|
|
941
|
-
//
|
|
1099
|
+
// Use dedicated commitments command for full text detail
|
|
1100
|
+
const args = ["commitments", "--json"];
|
|
1101
|
+
if (person)
|
|
1102
|
+
args.push("--person", person);
|
|
942
1103
|
const { stdout } = await runMinutes(args);
|
|
943
|
-
const
|
|
944
|
-
if (!Array.isArray(
|
|
945
|
-
return { content: [{ type: "text", text: "No relationship data found. Run: minutes people --rebuild" }] };
|
|
946
|
-
}
|
|
947
|
-
// Filter to the requested person if specified
|
|
948
|
-
let relevantPeople = people;
|
|
949
|
-
if (person) {
|
|
950
|
-
const personLower = person.toLowerCase();
|
|
951
|
-
relevantPeople = people.filter((p) => p.name?.toLowerCase().includes(personLower) ||
|
|
952
|
-
p.slug?.toLowerCase().includes(personLower));
|
|
953
|
-
}
|
|
954
|
-
// Build commitment summary from open_commitments counts
|
|
955
|
-
const sections = [];
|
|
956
|
-
const withCommitments = relevantPeople.filter((p) => p.open_commitments > 0);
|
|
957
|
-
if (withCommitments.length === 0) {
|
|
1104
|
+
const commitments = parseJsonOutput(stdout);
|
|
1105
|
+
if (!Array.isArray(commitments) || commitments.length === 0) {
|
|
958
1106
|
const scope = person ? ` for ${person}` : "";
|
|
959
1107
|
return {
|
|
960
1108
|
content: [{ type: "text", text: `No open commitments found${scope}.` }],
|
|
@@ -962,13 +1110,30 @@ registerAppTool(server, "track_commitments", {
|
|
|
962
1110
|
_meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "commitments" },
|
|
963
1111
|
};
|
|
964
1112
|
}
|
|
965
|
-
|
|
966
|
-
|
|
1113
|
+
// Group by status
|
|
1114
|
+
const stale = commitments.filter((c) => c.status === "stale");
|
|
1115
|
+
const open = commitments.filter((c) => c.status === "open");
|
|
1116
|
+
const lines = [];
|
|
1117
|
+
if (stale.length > 0) {
|
|
1118
|
+
lines.push(`STALE (${stale.length} overdue):`);
|
|
1119
|
+
for (const c of stale) {
|
|
1120
|
+
const who = c.person_name || "unassigned";
|
|
1121
|
+
lines.push(` ⚠ ${c.text} (${who}; due: ${c.due_date || "no date"}; from: ${c.meeting_title})`);
|
|
1122
|
+
}
|
|
967
1123
|
}
|
|
968
|
-
|
|
1124
|
+
if (open.length > 0) {
|
|
1125
|
+
if (stale.length > 0)
|
|
1126
|
+
lines.push("");
|
|
1127
|
+
lines.push(`OPEN (${open.length}):`);
|
|
1128
|
+
for (const c of open) {
|
|
1129
|
+
const who = c.person_name || "unassigned";
|
|
1130
|
+
lines.push(` · ${c.text} (${who}; from: ${c.meeting_title})`);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
const text = `Commitments${person ? ` for ${person}` : ""}:\n\n${lines.join("\n")}`;
|
|
969
1134
|
return {
|
|
970
1135
|
content: [{ type: "text", text }],
|
|
971
|
-
structuredContent: {
|
|
1136
|
+
structuredContent: { commitments, person: person || null, stale_count: stale.length, open_count: open.length, view: "commitments" },
|
|
972
1137
|
_meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "commitments" },
|
|
973
1138
|
};
|
|
974
1139
|
});
|
|
@@ -1070,7 +1235,7 @@ server.resource("meeting", new ResourceTemplate("minutes://meetings/{slug}", { l
|
|
|
1070
1235
|
const { stdout } = await runMinutes(["resolve", slug]);
|
|
1071
1236
|
const parsed = parseJsonOutput(stdout);
|
|
1072
1237
|
if (parsed.path) {
|
|
1073
|
-
const validated = validatePathInDirectory(parsed.path,
|
|
1238
|
+
const validated = validatePathInDirectory(parsed.path, await getEffectiveMeetingsDir(), [".md"]);
|
|
1074
1239
|
const content = await readFile(validated, "utf-8");
|
|
1075
1240
|
return { contents: [{ uri: uri.href, mimeType: "text/markdown", text: content }] };
|
|
1076
1241
|
}
|
|
@@ -1078,7 +1243,7 @@ server.resource("meeting", new ResourceTemplate("minutes://meetings/{slug}", { l
|
|
|
1078
1243
|
});
|
|
1079
1244
|
// ── Resource: recent_ideas (voice memos from last N days) ──
|
|
1080
1245
|
server.resource("recent-ideas", "minutes://ideas/recent", { description: "Recent voice memos and ideas captured from any device (last 14 days)" }, async (uri) => {
|
|
1081
|
-
const meetings = await reader.listMeetings(
|
|
1246
|
+
const meetings = await reader.listMeetings(await getEffectiveMeetingsDir(), 200);
|
|
1082
1247
|
const cutoff = new Date();
|
|
1083
1248
|
cutoff.setDate(cutoff.getDate() - 14);
|
|
1084
1249
|
const memos = meetings.filter((m) => {
|
|
@@ -1177,6 +1342,132 @@ server.tool("stop_dictation", "Stop the current dictation session.", {}, { title
|
|
|
1177
1342
|
],
|
|
1178
1343
|
};
|
|
1179
1344
|
});
|
|
1345
|
+
// ── Tool: list_voices ────────────────────────────────────────
|
|
1346
|
+
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 () => {
|
|
1347
|
+
if (!(await isCliAvailable())) {
|
|
1348
|
+
return { content: [{ type: "text", text: "Minutes CLI not available." }] };
|
|
1349
|
+
}
|
|
1350
|
+
const { stdout, stderr } = await runMinutes(["voices", "--json"]);
|
|
1351
|
+
const profiles = parseJsonOutput(stdout);
|
|
1352
|
+
if (!Array.isArray(profiles) || profiles.length === 0) {
|
|
1353
|
+
return {
|
|
1354
|
+
content: [{ type: "text", text: "No voice profiles enrolled. The user can enroll with: minutes enroll" }],
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
const lines = profiles.map((p) => `${p.name} — ${p.sample_count} samples, ${p.source} (${p.model_version})`);
|
|
1358
|
+
return {
|
|
1359
|
+
content: [{ type: "text", text: `Voice profiles (${profiles.length}):\n\n${lines.join("\n")}` }],
|
|
1360
|
+
structuredContent: { profiles, view: "voices" },
|
|
1361
|
+
};
|
|
1362
|
+
});
|
|
1363
|
+
// ── Tool: confirm_speaker ────────────────────────────────────
|
|
1364
|
+
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.", {
|
|
1365
|
+
meeting: z.string().describe("Path to the meeting markdown file"),
|
|
1366
|
+
speaker_label: z.string().describe("Speaker label to confirm (e.g., SPEAKER_1)"),
|
|
1367
|
+
name: z.string().describe("Real name to assign to this speaker"),
|
|
1368
|
+
save_voice: z.boolean().optional().default(false).describe("Save this speaker's voice profile for future automatic identification"),
|
|
1369
|
+
}, { title: "Confirm Speaker", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ meeting, speaker_label, name, save_voice }) => {
|
|
1370
|
+
if (!(await isCliAvailable())) {
|
|
1371
|
+
return { content: [{ type: "text", text: "Minutes CLI not available." }] };
|
|
1372
|
+
}
|
|
1373
|
+
const args = ["confirm", "--meeting", meeting, "--speaker", speaker_label, "--name", name];
|
|
1374
|
+
if (save_voice)
|
|
1375
|
+
args.push("--save-voice");
|
|
1376
|
+
try {
|
|
1377
|
+
const { stdout, stderr } = await runMinutes(args);
|
|
1378
|
+
const output = (stderr || stdout || "").trim();
|
|
1379
|
+
return {
|
|
1380
|
+
content: [{ type: "text", text: output || `Confirmed: ${speaker_label} = ${name}` }],
|
|
1381
|
+
structuredContent: { meeting, speaker_label, name, save_voice, confirmed: true },
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
catch (error) {
|
|
1385
|
+
const msg = error?.stderr || error?.message || String(error);
|
|
1386
|
+
return {
|
|
1387
|
+
content: [{ type: "text", text: `Failed to confirm speaker: ${msg}` }],
|
|
1388
|
+
isError: true,
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
});
|
|
1392
|
+
// ── 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 () => {
|
|
1394
|
+
if (!(await isCliAvailable())) {
|
|
1395
|
+
return { content: [{ type: "text", text: CLI_INSTALL_MSG }] };
|
|
1396
|
+
}
|
|
1397
|
+
// Pre-flight checks with short timeouts (these are instant file reads)
|
|
1398
|
+
const { stdout: statusOut } = await runMinutes(["status"], 5000);
|
|
1399
|
+
const status = parseJsonOutput(statusOut);
|
|
1400
|
+
if (status.recording) {
|
|
1401
|
+
return {
|
|
1402
|
+
content: [{ type: "text", text: "Recording in progress — stop recording before starting live transcript." }],
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
// Check if a live transcript is already running
|
|
1406
|
+
try {
|
|
1407
|
+
const { stdout: ltStatus } = await runMinutes(["transcript", "--status", "--format", "json"], 5000);
|
|
1408
|
+
const ltParsed = parseJsonOutput(ltStatus);
|
|
1409
|
+
if (ltParsed?.active) {
|
|
1410
|
+
return {
|
|
1411
|
+
content: [{ type: "text", text: "Live transcript already running. Use read_live_transcript to read it, or minutes stop to end it." }],
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
catch { /* no active session, proceed */ }
|
|
1416
|
+
// Spawn detached live transcript process
|
|
1417
|
+
const child = spawn(MINUTES_BIN, ["live"], {
|
|
1418
|
+
detached: true,
|
|
1419
|
+
stdio: "ignore",
|
|
1420
|
+
env: { ...process.env, RUST_LOG: "info" },
|
|
1421
|
+
});
|
|
1422
|
+
child.unref();
|
|
1423
|
+
// Verify the session actually started
|
|
1424
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
1425
|
+
try {
|
|
1426
|
+
const { stdout: verifyOut } = await runMinutes(["transcript", "--status", "--format", "json"], 5000);
|
|
1427
|
+
const verifyStatus = parseJsonOutput(verifyOut);
|
|
1428
|
+
if (verifyStatus?.active) {
|
|
1429
|
+
return {
|
|
1430
|
+
content: [{ type: "text", text: "Live transcript started. Use read_live_transcript to read the transcript. Use minutes stop to end the session." }],
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
catch { /* fall through to error */ }
|
|
1435
|
+
return {
|
|
1436
|
+
content: [{ type: "text", text: "Live transcript may have failed to start. Check minutes health or try again. Common causes: no microphone, whisper model not downloaded, or another session already active." }],
|
|
1437
|
+
isError: true,
|
|
1438
|
+
};
|
|
1439
|
+
});
|
|
1440
|
+
// ── Tool: read_live_transcript ──────────────────────────────
|
|
1441
|
+
server.tool("read_live_transcript", "Read the live transcript. Returns utterances as JSON lines. Use 'since' to get only new lines since a cursor (line number) or time window (e.g., '5m', '30s'). Use 'status' mode to check if a session is active.", {
|
|
1442
|
+
since: z.string().optional().describe("Line number (e.g., '42') or duration (e.g., '5m', '30s'). Omit to get all lines."),
|
|
1443
|
+
status_only: z.boolean().optional().default(false).describe("If true, return session status instead of transcript lines"),
|
|
1444
|
+
}, { title: "Read Live Transcript", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ since, status_only }) => {
|
|
1445
|
+
if (!(await isCliAvailable())) {
|
|
1446
|
+
return { content: [{ type: "text", text: CLI_INSTALL_MSG }] };
|
|
1447
|
+
}
|
|
1448
|
+
const args = ["transcript", "--format", "json"];
|
|
1449
|
+
if (status_only) {
|
|
1450
|
+
args.push("--status");
|
|
1451
|
+
}
|
|
1452
|
+
else if (since) {
|
|
1453
|
+
args.push("--since", since);
|
|
1454
|
+
}
|
|
1455
|
+
try {
|
|
1456
|
+
const { stdout } = await runMinutes(args, 10000);
|
|
1457
|
+
// For status queries, a message is helpful. For transcript reads, empty = no new lines.
|
|
1458
|
+
const fallback = status_only ? "No transcript data available." : "";
|
|
1459
|
+
return {
|
|
1460
|
+
content: [{ type: "text", text: stdout || fallback }],
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
catch (error) {
|
|
1464
|
+
const msg = error?.stderr || error?.message || String(error);
|
|
1465
|
+
return {
|
|
1466
|
+
content: [{ type: "text", text: `Failed to read transcript: ${msg}` }],
|
|
1467
|
+
isError: true,
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
});
|
|
1180
1471
|
// ── Start server ────────────────────────────────────────────
|
|
1181
1472
|
async function main() {
|
|
1182
1473
|
const transport = new StdioServerTransport();
|
package/dist/paths.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function expandHomeLikePath(input: string): string;
|
|
2
|
+
export declare function canonicalizeFilePath(path: string): string;
|
|
3
|
+
export declare function canonicalizeRoot(root: string): string;
|
|
4
|
+
export declare function isWithinDirectory(candidate: string, root: string): boolean;
|
|
5
|
+
export declare function validatePathInDirectory(path: string, root: string, allowedExts: string[]): string;
|
|
6
|
+
export declare function validatePathInDirectories(path: string, roots: string[], allowedExts: string[]): string;
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { extname, join, resolve } from "path";
|
|
4
|
+
export function expandHomeLikePath(input) {
|
|
5
|
+
const home = homedir();
|
|
6
|
+
if (input === "~") {
|
|
7
|
+
return home;
|
|
8
|
+
}
|
|
9
|
+
if (input.startsWith("~/") || input.startsWith("~\\")) {
|
|
10
|
+
return join(home, input.slice(2));
|
|
11
|
+
}
|
|
12
|
+
if (input === "$HOME" || input.startsWith("$HOME/") || input.startsWith("$HOME\\")) {
|
|
13
|
+
return home + input.slice("$HOME".length);
|
|
14
|
+
}
|
|
15
|
+
if (input === "${HOME}" || input.startsWith("${HOME}/") || input.startsWith("${HOME}\\")) {
|
|
16
|
+
return home + input.slice("${HOME}".length);
|
|
17
|
+
}
|
|
18
|
+
return input;
|
|
19
|
+
}
|
|
20
|
+
export function canonicalizeFilePath(path) {
|
|
21
|
+
if (!existsSync(path)) {
|
|
22
|
+
throw new Error(`Path does not exist: ${path}`);
|
|
23
|
+
}
|
|
24
|
+
return realpathSync(path);
|
|
25
|
+
}
|
|
26
|
+
export function canonicalizeRoot(root) {
|
|
27
|
+
const expandedRoot = expandHomeLikePath(root);
|
|
28
|
+
// Roots may not exist yet (e.g. ~/.minutes/inbox on first run).
|
|
29
|
+
// Use realpath if it exists, otherwise lexical resolve.
|
|
30
|
+
return existsSync(expandedRoot) ? realpathSync(expandedRoot) : resolve(expandedRoot);
|
|
31
|
+
}
|
|
32
|
+
export function isWithinDirectory(candidate, root) {
|
|
33
|
+
// Ensure root ends with separator to prevent prefix attacks (e.g. ~/meetings-evil)
|
|
34
|
+
const rootWithSep = root.endsWith("/") ? root : root + "/";
|
|
35
|
+
return candidate === root || candidate.startsWith(rootWithSep);
|
|
36
|
+
}
|
|
37
|
+
export function validatePathInDirectory(path, root, allowedExts) {
|
|
38
|
+
const canonicalPath = canonicalizeFilePath(path);
|
|
39
|
+
const canonicalRoot = canonicalizeRoot(root);
|
|
40
|
+
if (!allowedExts.includes(extname(canonicalPath).toLowerCase())) {
|
|
41
|
+
throw new Error(`Access denied: path must be within ${canonicalRoot} and end with ${allowedExts.join(", ")}`);
|
|
42
|
+
}
|
|
43
|
+
if (!isWithinDirectory(canonicalPath, canonicalRoot)) {
|
|
44
|
+
throw new Error(`Access denied: path must be within ${canonicalRoot}`);
|
|
45
|
+
}
|
|
46
|
+
return canonicalPath;
|
|
47
|
+
}
|
|
48
|
+
export function validatePathInDirectories(path, roots, allowedExts) {
|
|
49
|
+
const canonicalPath = canonicalizeFilePath(path);
|
|
50
|
+
if (!allowedExts.includes(extname(canonicalPath).toLowerCase())) {
|
|
51
|
+
throw new Error(`Access denied: path must end with one of ${allowedExts.join(", ")}`);
|
|
52
|
+
}
|
|
53
|
+
const canonicalRoots = roots.map((root) => canonicalizeRoot(root));
|
|
54
|
+
if (!canonicalRoots.some((root) => isWithinDirectory(canonicalPath, root))) {
|
|
55
|
+
throw new Error(`Access denied: file must be inside one of ${canonicalRoots.join(", ")}`);
|
|
56
|
+
}
|
|
57
|
+
return canonicalPath;
|
|
58
|
+
}
|