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
230 changes: 229 additions & 1 deletion crates/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1498,7 +1498,13 @@ async function runMinutes(
}
const stderr = error.stderr?.trim() || "";
const stdout = error.stdout?.trim() || "";
throw new Error(stderr || stdout || error.message);
// Preserve both streams on the thrown error: some commands (e.g.
// `resummarize --json`) print a structured failure envelope to stdout
// while anyhow's text lands on stderr — message alone would lose it.
const wrapped = new Error(stderr || stdout || error.message);
(wrapped as any).stdout = stdout;
(wrapped as any).stderr = stderr;
throw wrapped;
}
}

Expand Down Expand Up @@ -1802,6 +1808,11 @@ function parseStructuredCliError(message: string): any | null {
}
}

function formatResummarizeFailure(data: any, fallback: string): string {
if (!data?.error) return fallback;
return `Resummarize failed${data.stage ? ` during ${data.stage}` : ""}: ${data.error}`;
}

async function latestEventSeqFromCli(): Promise<number> {
const events = await readRecentEventsFromCli(1);
return maxEventSeq(events);
Expand Down Expand Up @@ -4746,6 +4757,223 @@ registerTool(
}
);

// ── Tool: resummarize_meeting ───────────────────────────────

registerTool(
"resummarize_meeting",
"Re-run the AI pass (summary, action items, decisions) on an edited meeting or memo. Preview by default: returns the regenerated content WITHOUT writing — note the summarization model IS still invoked (cost applies even in preview). Set apply=true to write the file (a timestamped backup is created and derived views — graph, search index, vault, QMD — refresh automatically). User edits outside AI-owned sections are never touched; checked action items and decision notes are carried forward by the merge.",
{
path: z.string().describe("Path to the meeting/memo .md file to resummarize."),
apply: z
.boolean()
.optional()
.default(false)
.describe("Write the regenerated content (default: preview only; the model is invoked either way)."),
engine: z
.string()
.optional()
.describe("Override the summarization engine for this run (e.g. 'ollama', 'apple', 'agent'). Default: the engine from config.toml."),
template: z
.string()
.optional()
.describe("Template slug to apply for this run. Default: the template recorded in the file's frontmatter."),
ingest: z
.boolean()
.optional()
.default(false)
.describe("With apply=true, also re-ingest the artifact into the knowledge base. Off by default because the knowledge log is append-only — every ingest adds a new log entry."),
include_restricted: z
.boolean()
.optional()
.default(false)
.describe("Resummarize a meeting designated `sensitivity: restricted` (refused by default; the override is logged)."),
},
{ title: "Resummarize Meeting", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
async ({ path, apply, engine, template, ingest, include_restricted }) => {
if (!(await isCliAvailable())) {
return { content: [{ type: "text" as const, text: CLI_INSTALL_MSG }] };
}

let resolved: string;
try {
resolved = validatePathInDirectory(path, await getEffectiveMeetingsDir(), [".md"]);
} catch (error: any) {
return {
content: [{ type: "text" as const, text: error?.message || String(error) }],
isError: true,
};
}

let restrictedOverride = false;
try {
const rawContent = await readFile(resolved, "utf-8");
const parsed = reader.parseFrontmatter(rawContent, resolved);
if (meetingSensitivity(parsed) === "restricted") {
if (!include_restricted) {
return {
content: [
{
type: "text" as const,
text: "This meeting is designated `sensitivity: restricted`; preview output would expose derived content. Pass `include_restricted: true` for an explicit, logged override.",
},
],
isError: true,
};
}
console.error(
`[Minutes] include_restricted override: resummarizing restricted meeting ${resolved} via resummarize_meeting`
);
restrictedOverride = true;
}
} catch (error: any) {
return {
content: [
{
type: "text" as const,
text: `Could not read: ${error?.message || String(error)}`,
},
],
isError: true,
};
}

const args = ["resummarize", resolved, "--json"];
if (apply) args.push("--apply");
if (engine) args.push("--engine", engine);
if (template) args.push("--template", template);
if (apply && ingest) args.push("--ingest");
const ingestIgnored = ingest && !apply;

try {
const { stdout } = await runMinutes(args, 300000);
const envelope = parseJsonOutput(stdout);
const data = envelope?.data;

if (data?.error) {
return {
content: [
{
type: "text" as const,
text: formatResummarizeFailure(data, stdout),
},
],
isError: true,
};
}
if (!data || typeof data !== "object") {
return {
content: [
{
type: "text" as const,
text: `Resummarize returned an unexpected response: ${stdout}`,
},
],
isError: true,
};
}

const lines = [
apply ? `Applied: ${data.path || resolved}` : "Preview (no changes written)",
];
let engineLine = `engine: ${data.engine ?? "unknown"} (model: ${data.model ?? "unknown"})`;
if (data.template) engineLine += `, template: ${data.template}`;
lines.push(engineLine);

const sectionsReplaced = Array.isArray(data.sections_replaced)
? data.sections_replaced
: [];
lines.push(
sectionsReplaced.length > 0
? `sections replaced: ${sectionsReplaced.join(", ")}`
: "first AI pass"
);

if (Array.isArray(data.merge_notes) && data.merge_notes.length > 0) {
lines.push("merge notes needing eyes:");
for (const note of data.merge_notes) {
lines.push(
`- ${note?.kind ?? "unknown"}: ${note?.previous ?? "unknown"} — ${note?.disposition ?? "unknown"}`
);
}
}

if (apply) {
lines.push(`backup: ${data.backup ?? "none"}`);
if (data.derived_views) {
const derived = data.derived_views;
const derivedWarnings = Array.isArray(derived.warnings)
? derived.warnings
: [];
const refreshed: string[] = [];
if (derived.graph_rebuilt) {
refreshed.push(
derived.meetings_indexed == null
? "graph"
: `graph (${derived.meetings_indexed} meetings indexed)`
);
}
const searchRefreshed =
typeof derived.search_indexed === "boolean"
? derived.search_indexed
: typeof derived.search_refreshed === "boolean"
? derived.search_refreshed
: !derivedWarnings.some((warning: unknown) =>
String(warning).startsWith("search index:")
);
if (searchRefreshed) refreshed.push("search");
if (derived.vault_path) refreshed.push(`vault (${derived.vault_path})`);
if (derived.qmd_refreshed) refreshed.push("qmd");
if (derived.knowledge_ingested) {
refreshed.push(
derived.facts_written == null
? "knowledge"
: `knowledge (${derived.facts_written} facts written)`
);
}
lines.push(`refreshed views: ${refreshed.length > 0 ? refreshed.join(", ") : "none"}`);

if (derivedWarnings.length > 0) {
lines.push("derived view warnings:");
for (const warning of derivedWarnings) lines.push(`- ${warning}`);
}
}
} else {
if (ingestIgnored) lines.push("--ingest ignored in preview");
lines.push("", "--- regenerated content ---", data.new_ai_body ?? "");
}

const { new_ai_body: _newAiBody, ...leanData } = data;
const structuredContent = apply ? leanData : data;
return {
content: [{ type: "text" as const, text: lines.join("\n") }],
structuredContent: restrictedOverride
? {
...structuredContent,
sensitivity_override: { applied: true, logged: "server-log" },
}
: structuredContent,
};
} catch (error: any) {
const message = error?.message || String(error);
// The CLI's failure envelope goes to stdout while anyhow's text goes to
// stderr (which wins in the thrown message) — check stdout first.
const envelope =
parseStructuredCliError(error?.stdout || "") ??
parseStructuredCliError(message);
const data = envelope?.data;
return {
content: [
{
type: "text" as const,
text: formatResummarizeFailure(data, message),
},
],
isError: true,
};
}
}
);

// ── Tool: knowledge_status ──────────────────────────────────

registerTool(
Expand Down
61 changes: 61 additions & 0 deletions crates/mcp/test/mcp_tools_test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
* 5. List tool returns array
* 6. Path validation works on get_meeting
* 7. Path validation works on process_audio
* 8. resummarize_meeting is present in the built server with its full schema
* 9. resummarize_meeting's path guard (validatePathInDirectory) rejects outside paths
*
* Run: node crates/mcp/test/mcp_tools_test.mjs
*/
Expand All @@ -19,6 +21,7 @@ import { execFileSync } from "child_process";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { validatePathInDirectory } from "../dist/paths.js";

let passed = 0;
let failed = 0;
Expand Down Expand Up @@ -228,6 +231,64 @@ test("minutes get --json applies speaker overlay from confirm", () => {
rmSync(sandbox, { recursive: true, force: true });
});

// ── Test 10: resummarize_meeting is registered with its schema ──
// Asserts against the compiled server source, not a live MCP round-trip —
// this harness has no protocol client, so registration + schema fields +
// the exact validation call are checked textually in dist/index.js.
test("resummarize_meeting is present in the built server with its full schema", () => {
const builtSource = readFileSync(
join(import.meta.dirname, "..", "dist", "index.js"),
"utf-8"
);
const toolStart = builtSource.indexOf('"resummarize_meeting"');
const nextTool = builtSource.indexOf("registerTool(", toolStart + 1);
const toolSource = builtSource.slice(
toolStart,
nextTool === -1 ? builtSource.length : nextTool
);

assert(toolStart !== -1, "resummarize_meeting must be present in the built server");
for (const field of ["path", "apply", "engine", "template", "ingest", "include_restricted"]) {
assert(
new RegExp(`${field}: z\\s*\\.`).test(toolSource),
`resummarize_meeting schema must include ${field}`
);
}
assert(
toolSource.includes(
'validatePathInDirectory(path, await getEffectiveMeetingsDir(), [".md"])'
),
"resummarize_meeting must validate paths against the effective meetings directory"
);
});

// ── Test 11: resummarize_meeting's path guard rejects outside files ──
// Exercises the shared validator the tool calls (verified textually above),
// not the handler itself — same limitation as test 10.
test("resummarize_meeting's path guard (validatePathInDirectory) rejects outside paths", () => {
const sandbox = mkdtempSync(join(tmpdir(), "minutes-resummarize-path-"));
const meetingsDir = join(sandbox, "meetings");
const outsidePath = join(sandbox, "outside.md");
mkdirSync(meetingsDir, { recursive: true });
writeFileSync(outsidePath, "# Outside\n");

try {
let error;
try {
validatePathInDirectory(outsidePath, meetingsDir, [".md"]);
} catch (caught) {
error = caught;
}
assert(error, "an outside path must be rejected");
assert(
error.message.includes("Access denied: path must be within"),
"outside-path rejection must explain the meetings-directory boundary"
);
} finally {
rmSync(sandbox, { recursive: true, force: true });
}
});

// ── Summary ──
console.log(`\nResults: ${passed} passed, ${failed} failed, ${passed + failed} total`);
process.exit(failed > 0 ? 1 : 0);
6 changes: 5 additions & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "minutes",
"display_name": "Minutes Conversation Memory",
"description": "The private, owned conversation-memory layer for AI. Record, transcribe, and search every meeting, voice memo, and dictation entirely on your own machine — nothing uploaded — then let any AI you connect recall it. Plain-markdown output you keep forever; no cloud, no API key, no vendor to outlive.",
"long_description": "Minutes is the private, owned conversation-memory layer. Record meetings, voice memos, and dictation; transcribe them on your own machine; and get structured markdown in `~/meetings/` that Claude Desktop, Claude Code, Codex, Gemini CLI, Cursor, OpenCode, Pi, and any MCP-compatible client read from the same folder — no proprietary SDK, no API key. Nothing is uploaded: when a cloud memory tool gets acquired or subpoenaed, your recordings aren't theirs to hand over. No vendor to outlive — ten years from now, `grep` still works on your corpus.\n\n36 tools • 7 resources • Interactive MCP App dashboard • Prompt templates\n\nWhat you get:\n• On-device transcription (whisper.cpp or parakeet.cpp) and optional on-device summarization (Apple Foundation Models on macOS 26+) — your audio never leaves the machine.\n• A native chat panel that recalls your meetings, with token streaming and read-only search of your own corpus.\n• Dictation that types straight at your cursor, on the same local engine.\n• Live sessions that promote into diarized, summarized meetings when you stop.\n• Consent provenance in every file, and sensitivity gating agents must respect.\n• Diarized speaker labels, structured action items and decisions, and a cross-meeting relationship graph — all queryable via MCP.",
"long_description": "Minutes is the private, owned conversation-memory layer. Record meetings, voice memos, and dictation; transcribe them on your own machine; and get structured markdown in `~/meetings/` that Claude Desktop, Claude Code, Codex, Gemini CLI, Cursor, OpenCode, Pi, and any MCP-compatible client read from the same folder — no proprietary SDK, no API key. Nothing is uploaded: when a cloud memory tool gets acquired or subpoenaed, your recordings aren't theirs to hand over. No vendor to outlive — ten years from now, `grep` still works on your corpus.\n\n37 tools • 8 resources • Interactive MCP App dashboard • Prompt templates\n\nWhat you get:\n• On-device transcription (whisper.cpp or parakeet.cpp) and optional on-device summarization (Apple Foundation Models on macOS 26+) — your audio never leaves the machine.\n• Re-run the AI pass on edited meetings and memos with preview-first output, backup-on-apply writes, and edit-preserving merges.\n• A native chat panel that recalls your meetings, with token streaming and read-only search of your own corpus.\n• Dictation that types straight at your cursor, on the same local engine.\n• Live sessions that promote into diarized, summarized meetings when you stop.\n• Consent provenance in every file, and sensitivity gating agents must respect.\n• Diarized speaker labels, structured action items and decisions, and a cross-meeting relationship graph — all queryable via MCP.",
"version": "0.24.0",
"author": {
"name": "Mat Silverstein",
Expand Down Expand Up @@ -171,6 +171,10 @@
"name": "ingest_meeting",
"description": "Extract facts from a meeting and update the knowledge base (person profiles, log, index)"
},
{
"name": "resummarize_meeting",
"description": "Re-run the AI pass on an edited meeting or memo, previewing by default and preserving user edits"
},
{
"name": "knowledge_status",
"description": "Show the current state of the knowledge base — configuration, adapter, people count, log entries"
Expand Down
Loading