minutes-mcp 0.25.5 → 0.26.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.
@@ -2,6 +2,12 @@ type ExecFileAsync = (file: string, args: readonly string[], options?: {
2
2
  timeout?: number;
3
3
  env?: NodeJS.ProcessEnv;
4
4
  }) => Promise<unknown>;
5
+ export declare const MACOS_SHERPA_ARCHIVE = "minutes-macos-arm64-sherpa.tar.gz";
6
+ export declare const MACOS_BARE_BINARY = "minutes-macos-arm64";
7
+ export declare class ReleaseAssetUnavailableError extends Error {
8
+ readonly assetName: string;
9
+ constructor(assetName: string, message: string);
10
+ }
5
11
  export type Sha256Entry = {
6
12
  filename: string;
7
13
  sha256: string;
@@ -10,6 +16,49 @@ export declare function parseSha256Sums(raw: string): Sha256Entry[];
10
16
  export declare function findSha256ForAsset(raw: string, assetName: string): string | null;
11
17
  export declare function computeFileSha256(path: string): Promise<string>;
12
18
  export declare function verifyDownloadedAsset(path: string, expectedSha256: string): Promise<void>;
19
+ /**
20
+ * Extract the two files from the macOS sherpa release archive.
21
+ *
22
+ * The release archive has one fixed directory and two fixed regular files.
23
+ * Keeping this reader deliberately narrow avoids a package dependency and,
24
+ * more importantly, prevents path traversal, links, devices, or unexpected
25
+ * files from ever being materialized. The archive itself is SHA-256 verified
26
+ * before this function is called.
27
+ */
28
+ export declare function extractMacSherpaArchive(options: {
29
+ archivePath: string;
30
+ destDir: string;
31
+ }): Promise<{
32
+ binaryPath: string;
33
+ pluginPath: string;
34
+ }>;
35
+ /**
36
+ * Commit a verified binary/plugin pair. The binary rename is the commit point:
37
+ * it is never touched until the staged binary passes `--version`. If that
38
+ * final rename fails, restore the previous plugin so callers never observe a
39
+ * completed-but-partial install.
40
+ */
41
+ export declare function atomicReplaceMacSherpaFiles(options: {
42
+ stagedBinaryPath: string;
43
+ stagedPluginPath: string;
44
+ targetBinaryPath: string;
45
+ targetPluginPath: string;
46
+ backupDir: string;
47
+ }): Promise<void>;
48
+ export type MacSherpaInstallResult = {
49
+ assetName: string;
50
+ usedFallback: boolean;
51
+ };
52
+ /** Install the signed macOS sherpa archive, falling back only when unavailable. */
53
+ export declare function installMacSherpaArchiveWithFallback(options: {
54
+ installDir: string;
55
+ execFileAsync: ExecFileAsync;
56
+ baseUrl?: string;
57
+ log?: (message: string) => void;
58
+ downloadAsset?: typeof downloadReleaseBinaryWithChecksum;
59
+ extractArchive?: typeof extractMacSherpaArchive;
60
+ verifyExecutable?: (binaryPath: string) => Promise<void>;
61
+ }): Promise<MacSherpaInstallResult>;
13
62
  /**
14
63
  * Extract a zip on Windows via PowerShell's Expand-Archive.
15
64
  *
@@ -1,6 +1,19 @@
1
1
  import { createHash } from "crypto";
2
- import { basename } from "path";
3
- import { readFile, rename, rm } from "fs/promises";
2
+ import { gunzip } from "zlib";
3
+ import { promisify } from "util";
4
+ import { basename, join } from "path";
5
+ import { chmod, copyFile, mkdir, mkdtemp, readFile, rename, rm, stat, writeFile, } from "fs/promises";
6
+ const gunzipAsync = promisify(gunzip);
7
+ export const MACOS_SHERPA_ARCHIVE = "minutes-macos-arm64-sherpa.tar.gz";
8
+ export const MACOS_BARE_BINARY = "minutes-macos-arm64";
9
+ export class ReleaseAssetUnavailableError extends Error {
10
+ assetName;
11
+ constructor(assetName, message) {
12
+ super(message);
13
+ this.name = "ReleaseAssetUnavailableError";
14
+ this.assetName = assetName;
15
+ }
16
+ }
4
17
  export function parseSha256Sums(raw) {
5
18
  const entries = [];
6
19
  for (const line of raw.split(/\r?\n/)) {
@@ -36,6 +49,174 @@ export async function verifyDownloadedAsset(path, expectedSha256) {
36
49
  throw new Error(`checksum mismatch: expected ${expectedSha256.toLowerCase()}, got ${actual}`);
37
50
  }
38
51
  }
52
+ function tarString(header, offset, length) {
53
+ const field = header.subarray(offset, offset + length);
54
+ const nul = field.indexOf(0);
55
+ return field.subarray(0, nul === -1 ? field.length : nul).toString("utf8").trim();
56
+ }
57
+ function tarSize(header) {
58
+ const raw = tarString(header, 124, 12).replace(/\0/g, "").trim();
59
+ if (!/^[0-7]+$/.test(raw)) {
60
+ throw new Error(`invalid tar entry size ${JSON.stringify(raw)}`);
61
+ }
62
+ return Number.parseInt(raw, 8);
63
+ }
64
+ /**
65
+ * Extract the two files from the macOS sherpa release archive.
66
+ *
67
+ * The release archive has one fixed directory and two fixed regular files.
68
+ * Keeping this reader deliberately narrow avoids a package dependency and,
69
+ * more importantly, prevents path traversal, links, devices, or unexpected
70
+ * files from ever being materialized. The archive itself is SHA-256 verified
71
+ * before this function is called.
72
+ */
73
+ export async function extractMacSherpaArchive(options) {
74
+ const compressed = await readFile(options.archivePath);
75
+ const tar = await gunzipAsync(compressed);
76
+ const expected = new Map();
77
+ const root = "minutes-macos-arm64-sherpa";
78
+ const expectedNames = new Set([
79
+ `${root}/minutes`,
80
+ `${root}/libminutes_sherpa.dylib`,
81
+ ]);
82
+ let offset = 0;
83
+ while (offset + 512 <= tar.length) {
84
+ const header = tar.subarray(offset, offset + 512);
85
+ if (header.every((byte) => byte === 0))
86
+ break;
87
+ const name = tarString(header, 0, 100);
88
+ const prefix = tarString(header, 345, 155);
89
+ const archivePath = `${prefix ? `${prefix}/` : ""}${name}`.replace(/^\.\//, "");
90
+ const size = tarSize(header);
91
+ const type = String.fromCharCode(header[156] || 0);
92
+ const dataStart = offset + 512;
93
+ const dataEnd = dataStart + size;
94
+ if (dataEnd > tar.length) {
95
+ throw new Error(`truncated tar entry ${archivePath}`);
96
+ }
97
+ if (type === "\0" || type === "0") {
98
+ if (!expectedNames.has(archivePath)) {
99
+ throw new Error(`unexpected file in macOS sherpa archive: ${archivePath}`);
100
+ }
101
+ if (expected.has(archivePath)) {
102
+ throw new Error(`duplicate file in macOS sherpa archive: ${archivePath}`);
103
+ }
104
+ expected.set(archivePath, Buffer.from(tar.subarray(dataStart, dataEnd)));
105
+ }
106
+ else if (type !== "5" && type !== "x" && type !== "g") {
107
+ throw new Error(`unsupported tar entry type ${JSON.stringify(type)} for ${archivePath}`);
108
+ }
109
+ offset = dataStart + Math.ceil(size / 512) * 512;
110
+ }
111
+ for (const name of expectedNames) {
112
+ if (!expected.has(name)) {
113
+ throw new Error(`macOS sherpa archive is missing ${name}`);
114
+ }
115
+ }
116
+ await mkdir(options.destDir, { recursive: true });
117
+ const binaryPath = join(options.destDir, "minutes");
118
+ const pluginPath = join(options.destDir, "libminutes_sherpa.dylib");
119
+ await writeFile(binaryPath, expected.get(`${root}/minutes`), { mode: 0o755 });
120
+ await writeFile(pluginPath, expected.get(`${root}/libminutes_sherpa.dylib`), {
121
+ mode: 0o755,
122
+ });
123
+ return { binaryPath, pluginPath };
124
+ }
125
+ /**
126
+ * Commit a verified binary/plugin pair. The binary rename is the commit point:
127
+ * it is never touched until the staged binary passes `--version`. If that
128
+ * final rename fails, restore the previous plugin so callers never observe a
129
+ * completed-but-partial install.
130
+ */
131
+ export async function atomicReplaceMacSherpaFiles(options) {
132
+ const pluginBackup = join(options.backupDir, "previous-libminutes_sherpa.dylib");
133
+ let hadPlugin = false;
134
+ try {
135
+ await stat(options.targetPluginPath);
136
+ hadPlugin = true;
137
+ await copyFile(options.targetPluginPath, pluginBackup);
138
+ }
139
+ catch (error) {
140
+ if (error.code !== "ENOENT")
141
+ throw error;
142
+ }
143
+ let pluginReplaced = false;
144
+ try {
145
+ await rename(options.stagedPluginPath, options.targetPluginPath);
146
+ pluginReplaced = true;
147
+ await rename(options.stagedBinaryPath, options.targetBinaryPath);
148
+ }
149
+ catch (error) {
150
+ if (pluginReplaced) {
151
+ if (hadPlugin) {
152
+ await rename(pluginBackup, options.targetPluginPath).catch(() => { });
153
+ }
154
+ else {
155
+ await rm(options.targetPluginPath, { force: true }).catch(() => { });
156
+ }
157
+ }
158
+ throw error;
159
+ }
160
+ finally {
161
+ await rm(pluginBackup, { force: true }).catch(() => { });
162
+ }
163
+ }
164
+ /** Install the signed macOS sherpa archive, falling back only when unavailable. */
165
+ export async function installMacSherpaArchiveWithFallback(options) {
166
+ const log = options.log ?? (() => { });
167
+ const downloadAsset = options.downloadAsset ?? downloadReleaseBinaryWithChecksum;
168
+ const extractArchive = options.extractArchive ?? extractMacSherpaArchive;
169
+ await mkdir(options.installDir, { recursive: true });
170
+ const temporaryDir = await mkdtemp(join(options.installDir, ".minutes-sherpa-install-"));
171
+ try {
172
+ const archivePath = join(temporaryDir, MACOS_SHERPA_ARCHIVE);
173
+ try {
174
+ await downloadAsset({
175
+ binaryName: MACOS_SHERPA_ARCHIVE,
176
+ targetPath: archivePath,
177
+ execFileAsync: options.execFileAsync,
178
+ baseUrl: options.baseUrl,
179
+ });
180
+ }
181
+ catch (error) {
182
+ if (!(error instanceof ReleaseAssetUnavailableError))
183
+ throw error;
184
+ log(`[Minutes] ${MACOS_SHERPA_ARCHIVE} is unavailable in this release (${error.message}); ` +
185
+ `falling back to the bare ${MACOS_BARE_BINARY} asset`);
186
+ const targetBinaryPath = join(options.installDir, "minutes");
187
+ await downloadAsset({
188
+ binaryName: MACOS_BARE_BINARY,
189
+ targetPath: targetBinaryPath,
190
+ execFileAsync: options.execFileAsync,
191
+ baseUrl: options.baseUrl,
192
+ });
193
+ await chmod(targetBinaryPath, 0o755);
194
+ return { assetName: MACOS_BARE_BINARY, usedFallback: true };
195
+ }
196
+ const extractedDir = join(temporaryDir, "extracted");
197
+ await mkdir(extractedDir, { recursive: true });
198
+ const { binaryPath, pluginPath } = await extractArchive({
199
+ archivePath,
200
+ destDir: extractedDir,
201
+ });
202
+ await chmod(binaryPath, 0o755);
203
+ const verifyExecutable = options.verifyExecutable ?? (async (path) => {
204
+ await options.execFileAsync(path, ["--version"], { timeout: 5000 });
205
+ });
206
+ await verifyExecutable(binaryPath);
207
+ await atomicReplaceMacSherpaFiles({
208
+ stagedBinaryPath: binaryPath,
209
+ stagedPluginPath: pluginPath,
210
+ targetBinaryPath: join(options.installDir, "minutes"),
211
+ targetPluginPath: join(options.installDir, "libminutes_sherpa.dylib"),
212
+ backupDir: temporaryDir,
213
+ });
214
+ return { assetName: MACOS_SHERPA_ARCHIVE, usedFallback: false };
215
+ }
216
+ finally {
217
+ await rm(temporaryDir, { recursive: true, force: true }).catch(() => { });
218
+ }
219
+ }
39
220
  /**
40
221
  * Extract a zip on Windows via PowerShell's Expand-Archive.
41
222
  *
@@ -82,11 +263,20 @@ export async function downloadReleaseBinaryWithChecksum(options) {
82
263
  const sums = await readFile(tempSumsPath, "utf8");
83
264
  const expectedSha256 = findSha256ForAsset(sums, binaryName);
84
265
  if (!expectedSha256) {
85
- throw new Error(`SHA256SUMS.txt has no entry for ${binaryName}`);
266
+ throw new ReleaseAssetUnavailableError(binaryName, `SHA256SUMS.txt has no entry for ${binaryName}`);
267
+ }
268
+ try {
269
+ await execFileAsync("curl", ["-fSL", "-o", tempBinaryPath, binaryUrl], {
270
+ timeout: 120000,
271
+ });
272
+ }
273
+ catch (error) {
274
+ const stderr = String(error?.stderr ?? "");
275
+ if (/\b404\b|not found/i.test(stderr)) {
276
+ throw new ReleaseAssetUnavailableError(binaryName, `${binaryName} returned HTTP 404`);
277
+ }
278
+ throw error;
86
279
  }
87
- await execFileAsync("curl", ["-fSL", "-o", tempBinaryPath, binaryUrl], {
88
- timeout: 120000,
89
- });
90
280
  await verifyDownloadedAsset(tempBinaryPath, expectedSha256);
91
281
  await rename(tempBinaryPath, targetPath);
92
282
  }
@@ -133,6 +133,17 @@ export type CorpusLeaseHooks = {
133
133
  export declare function awaitDeferredCorpusReleasesForTests(timeoutMs?: number): Promise<boolean>;
134
134
  /** Test-only: the live retained-corpus charge, for leak assertions. */
135
135
  export declare function reservedCorpusMemoryBytesForTests(): number;
136
+ /** @internal Pure policy seam for test-harness gate regression coverage. */
137
+ export declare function resolveAuthorizationTimeoutMsForTest(timeoutMs: number | undefined, environment: Readonly<NodeJS.ProcessEnv>): number;
138
+ /** @internal Pure policy seam that pins the derived production envelope. */
139
+ export declare function authorizationWorkEnvelopeForTest(): Readonly<{
140
+ maxCorpusBytes: number;
141
+ manifestsPerAttempt: 3;
142
+ physicalReadsPerManifest: 2;
143
+ maxAttempts: 2;
144
+ minimumReadBytesPerSecond: number;
145
+ timeoutMs: number;
146
+ }>;
136
147
  export type CorpusLeaseWorkerRequest = Readonly<{
137
148
  root: string;
138
149
  budgets: Readonly<CorpusReadBudgets>;
@@ -8,7 +8,7 @@ import { decodePolicyUtf8, fingerprintTextFileFromBoundParent, readTextFileWithR
8
8
  import { nodeChildEnvironment } from "./node-child.js";
9
9
  const MAX_AUTHORIZATION_ATTEMPTS = 2;
10
10
  const DEFAULT_FENCE_TIMEOUT_MS = 5_000;
11
- const DEFAULT_AUTHORIZATION_TIMEOUT_MS = 15_000;
11
+ const MAX_TEST_HARNESS_AUTHORIZATION_TIMEOUT_MS = 120_000;
12
12
  const MAX_ACTIVE_WATCHERS = 64;
13
13
  // Snapshot content is retained as JavaScript strings, whose backing storage
14
14
  // may require two bytes per source byte. Reserve that worst case for the full
@@ -53,6 +53,30 @@ export const DEFAULT_CORPUS_READ_BUDGETS = Object.freeze({
53
53
  maxWatcherCount: 512,
54
54
  maxReaderCount: 64,
55
55
  });
56
+ // One successful attempt materializes a baseline, verifies it before the
57
+ // final fence, then verifies it again at the authorization point. Every
58
+ // manifest reads each file twice to prove the bytes were stable while its
59
+ // descriptor was held. The complete sequence may retry once under the same
60
+ // cumulative deadline.
61
+ const CORPUS_MANIFESTS_PER_AUTHORIZATION_ATTEMPT = 3;
62
+ const CORPUS_PHYSICAL_READS_PER_MANIFEST = 2;
63
+ // Match the native corpus reader's documented storage floor. Meeting libraries
64
+ // may live on synced folders, external disks, and ordinary spinning disks; a
65
+ // corpus inside every published resource ceiling must not require fast local
66
+ // SSD throughput merely to pass authorization.
67
+ const MIN_ASSUMED_CORPUS_READ_BYTES_PER_SECOND = 16 * 1024 * 1024;
68
+ // This deadline is a backstop, not the primary safety control. File, byte,
69
+ // directory, watcher, reader, retained-memory, and worker-process ceilings
70
+ // still bound what one request may consume. Deriving the wall-clock ceiling
71
+ // from that work envelope keeps those promises consistent when a pass count or
72
+ // byte ceiling changes. At today's limits this is 60 seconds, rather than the
73
+ // old hardcoded 15 seconds that denied valid large or slow corpora (#933).
74
+ const DEFAULT_AUTHORIZATION_TIMEOUT_MS = Math.ceil((DEFAULT_CORPUS_READ_BUDGETS.maxCorpusBytes *
75
+ CORPUS_MANIFESTS_PER_AUTHORIZATION_ATTEMPT *
76
+ CORPUS_PHYSICAL_READS_PER_MANIFEST *
77
+ MAX_AUTHORIZATION_ATTEMPTS *
78
+ 1_000) /
79
+ MIN_ASSUMED_CORPUS_READ_BYTES_PER_SECOND);
56
80
  let activeWatcherCount = 0;
57
81
  let reservedCorpusMemoryBytes = 0;
58
82
  let retainedSentinelUseSequence = 0;
@@ -185,12 +209,53 @@ function resolveFenceTimeout(timeoutMs) {
185
209
  }
186
210
  return Math.min(requested, DEFAULT_FENCE_TIMEOUT_MS);
187
211
  }
188
- function authorizationDeadline(timeoutMs) {
189
- const requested = timeoutMs ?? DEFAULT_AUTHORIZATION_TIMEOUT_MS;
212
+ function resolveAuthorizationTimeoutMs(timeoutMs, environment = process.env) {
213
+ const configuredOverride = environment.MINUTES_CORPUS_AUTH_TIMEOUT_MS;
214
+ // A deadline beyond the derived production envelope is test infrastructure,
215
+ // not application configuration. Requiring both markers keeps an ambient
216
+ // production environment variable from relaxing the fail-closed cap. Only
217
+ // the repository's test harness sets this pair; the spawned lease worker
218
+ // inherits it from the parent. Invalid gated values fail closed instead of
219
+ // silently relaxing or changing the requested budget.
220
+ const testHarnessOverride = environment.NODE_ENV === "test" &&
221
+ environment.MINUTES_TEST_HARNESS === "1" &&
222
+ configuredOverride !== undefined
223
+ ? configuredOverride
224
+ : undefined;
225
+ if (testHarnessOverride !== undefined &&
226
+ !/^[1-9]\d*$/.test(testHarnessOverride)) {
227
+ throw new Error("Access denied: invalid meeting corpus authorization timeout");
228
+ }
229
+ const cap = testHarnessOverride === undefined
230
+ ? DEFAULT_AUTHORIZATION_TIMEOUT_MS
231
+ : Math.min(Number(testHarnessOverride), MAX_TEST_HARNESS_AUTHORIZATION_TIMEOUT_MS);
232
+ const requested = timeoutMs ?? cap;
190
233
  if (!Number.isSafeInteger(requested) || requested < 1) {
191
234
  throw new Error("Access denied: invalid meeting corpus authorization timeout");
192
235
  }
193
- return process.hrtime.bigint() + BigInt(Math.min(requested, DEFAULT_AUTHORIZATION_TIMEOUT_MS)) * 1000000n;
236
+ return Math.min(requested, cap);
237
+ }
238
+ /** @internal Pure policy seam for test-harness gate regression coverage. */
239
+ export function resolveAuthorizationTimeoutMsForTest(timeoutMs, environment) {
240
+ return resolveAuthorizationTimeoutMs(timeoutMs, environment);
241
+ }
242
+ /** @internal Pure policy seam that pins the derived production envelope. */
243
+ export function authorizationWorkEnvelopeForTest() {
244
+ return Object.freeze({
245
+ maxCorpusBytes: DEFAULT_CORPUS_READ_BUDGETS.maxCorpusBytes,
246
+ manifestsPerAttempt: CORPUS_MANIFESTS_PER_AUTHORIZATION_ATTEMPT,
247
+ physicalReadsPerManifest: CORPUS_PHYSICAL_READS_PER_MANIFEST,
248
+ maxAttempts: MAX_AUTHORIZATION_ATTEMPTS,
249
+ minimumReadBytesPerSecond: MIN_ASSUMED_CORPUS_READ_BYTES_PER_SECOND,
250
+ timeoutMs: DEFAULT_AUTHORIZATION_TIMEOUT_MS,
251
+ });
252
+ }
253
+ function authorizationWindow(timeoutMs) {
254
+ const budgetMs = resolveAuthorizationTimeoutMs(timeoutMs);
255
+ return {
256
+ budgetMs,
257
+ deadline: process.hrtime.bigint() + BigInt(budgetMs) * 1000000n,
258
+ };
194
259
  }
195
260
  function remainingAuthorizationMs(deadline) {
196
261
  const remainingNs = deadline - process.hrtime.bigint();
@@ -1012,7 +1077,7 @@ class WatchedCorpusAttempt {
1012
1077
  */
1013
1078
  async function withStableCorpusLeaseInProcess(root, operation, hooks = {}) {
1014
1079
  const budgets = resolveCorpusReadBudgets(hooks.budgets);
1015
- const deadline = authorizationDeadline(hooks.timeoutMs);
1080
+ const { deadline } = authorizationWindow(hooks.timeoutMs);
1016
1081
  const fenceTimeoutMs = resolveFenceTimeout(hooks.timeoutMs);
1017
1082
  const memoryReservation = reserveCorpusMemory(budgets);
1018
1083
  try {
@@ -1358,8 +1423,16 @@ function parentControls(commands, pauses) {
1358
1423
  * those name a caller-actionable limit rather than an authorization outcome.
1359
1424
  */
1360
1425
  export async function withStableCorpusLease(root, operation, hooks = {}) {
1426
+ const authorizationStarted = process.hrtime.bigint();
1361
1427
  try {
1362
- return await dispatchStableCorpusLease(root, operation, hooks);
1428
+ const result = await dispatchStableCorpusLease(root, operation, hooks);
1429
+ const durationMs = Number((process.hrtime.bigint() - authorizationStarted + 999999n) / 1000000n);
1430
+ // Match denial diagnostics: content-free, operator-visible, deferred, and
1431
+ // unable to turn an authorization result into an application failure.
1432
+ setImmediate(() => {
1433
+ writeOperatorDiagnostic(`[corpus-lease] authorized (authorization duration ${durationMs}ms)\n`);
1434
+ });
1435
+ return result;
1363
1436
  }
1364
1437
  catch (error) {
1365
1438
  if (error instanceof CorpusLeaseChangedError) {
@@ -1379,7 +1452,7 @@ async function dispatchStableCorpusLease(root, operation, hooks = {}) {
1379
1452
  throw new Error("Access denied: a meeting corpus worker was killed without confirming it died");
1380
1453
  }
1381
1454
  const budgets = resolveCorpusReadBudgets(hooks.budgets);
1382
- const deadline = authorizationDeadline(hooks.timeoutMs);
1455
+ const { budgetMs: authorizationBudgetMs, deadline } = authorizationWindow(hooks.timeoutMs);
1383
1456
  const timeoutMs = remainingAuthorizationMs(deadline);
1384
1457
  const invocation = corpusWorkerInvocation(hooks.workerScriptForTest);
1385
1458
  const reservation = reserveCorpusMemory(budgets);
@@ -1502,14 +1575,20 @@ async function dispatchStableCorpusLease(root, operation, hooks = {}) {
1502
1575
  // "0ms remained" rather than an overrun.
1503
1576
  const overran = remainingNs < 0n;
1504
1577
  const magnitudeMs = Number((overran ? -remainingNs : remainingNs) / 1000000n);
1505
- const budget = overran
1578
+ const remainingBudget = overran
1506
1579
  ? `authorization budget overrun by ${magnitudeMs}ms`
1507
1580
  : `${magnitudeMs}ms of authorization budget remained`;
1508
1581
  // Shared with the bound-reader refusal path, because a plain
1509
1582
  // try/catch here never covered an asynchronous EPIPE: that
1510
1583
  // arrives as an 'error' event on a later tick and is fatal
1511
1584
  // without a listener.
1512
- writeOperatorDiagnostic(`[corpus-lease] denied: ${message} (${budget})\n`);
1585
+ const elapsedMs = overran
1586
+ ? authorizationBudgetMs + magnitudeMs
1587
+ : Math.max(0, authorizationBudgetMs - magnitudeMs);
1588
+ writeOperatorDiagnostic(`[corpus-lease] denied: ${message} (` +
1589
+ `authorization duration ${elapsedMs}ms; ` +
1590
+ `${authorizationBudgetMs}ms authorization budget; ` +
1591
+ `${remainingBudget})\n`);
1513
1592
  }
1514
1593
  catch {
1515
1594
  // A broken stderr must never turn a clean denial into a crash.
package/dist/index.d.ts CHANGED
@@ -315,6 +315,7 @@ export declare function liveMeetingSnippet(body: string, query?: string): string
315
315
  export declare function shouldRunMainEntry(argv1: string | null | undefined, moduleFilename: string): boolean;
316
316
  export declare function parseKnowledgeConfig(configContent: string): KnowledgeConfigStatus | null;
317
317
  export declare function createCapabilityRepairCoordinator(repair: () => Promise<boolean>, maximumAttempts?: number): () => Promise<boolean>;
318
+ export declare function getReleaseBinaryName(platform?: NodeJS.Platform, arch?: string): string | null;
318
319
  export type HealthItem = {
319
320
  label?: unknown;
320
321
  state?: unknown;
@@ -325,6 +326,7 @@ export type HealthOutput = {
325
326
  items: HealthItem[] | null;
326
327
  engine?: string;
327
328
  effectiveEngine?: string;
329
+ reason?: string;
328
330
  model?: string;
329
331
  };
330
332
  export declare function parseHealthOutput(stdout: string): HealthOutput;
@@ -332,10 +334,11 @@ type WhisperModelCheckState = {
332
334
  done: boolean;
333
335
  };
334
336
  export type EnsureWhisperModelOptions = {
337
+ autoSetup?: string;
335
338
  checkState?: WhisperModelCheckState;
336
339
  health?: () => Promise<string>;
337
340
  configFileExists?: () => Promise<boolean>;
338
- setup?: (model: string) => Promise<void>;
341
+ setup?: (model?: string) => Promise<void>;
339
342
  log?: (message: string) => void;
340
343
  };
341
344
  export declare function ensureWhisperModel(options?: EnsureWhisperModelOptions): Promise<void>;
package/dist/index.js CHANGED
@@ -54,7 +54,7 @@ import { canonicalizeRoot, expandHomeLikePath, readTextFileInDirectory, validate
54
54
  import { isCliCompatible, parseVersion } from "./version.js";
55
55
  import { nodeChildEnvironment } from "./node-child.js";
56
56
  import { hasFeature, probeCapabilitiesSync, } from "./capabilities.js";
57
- import { downloadReleaseBinaryWithChecksum, extractZipWithPowerShell, } from "./autoInstall.js";
57
+ import { downloadReleaseBinaryWithChecksum, extractZipWithPowerShell, installMacSherpaArchiveWithFallback, MACOS_SHERPA_ARCHIVE, } from "./autoInstall.js";
58
58
  import { attachCaptureRelay, } from "./captureRelay.js";
59
59
  import { withStableCorpusLease, } from "./corpus-lease.js";
60
60
  import { HTTP_BIND_HOST, DEFAULT_HTTP_PORT, DEFAULT_MAX_SESSIONS, startMinutesHttpServer, } from "./httpTransport.js";
@@ -104,6 +104,7 @@ function handleDemoSetup() {
104
104
  args: ["minutes-mcp"],
105
105
  env: {
106
106
  MEETINGS_DIR: demoDir,
107
+ MINUTES_MCP_AUTO_SETUP: "0",
107
108
  },
108
109
  },
109
110
  },
@@ -122,7 +123,7 @@ function handleDemoSetup() {
122
123
  console.log(" • What action items are still open, and who owns each?");
123
124
  console.log(" • Summarize the Northwind customer thread.");
124
125
  console.log("");
125
- console.log("Note: some structured tools (consistency report, person profile) auto-install the Minutes CLI on first use.");
126
+ console.log("The server installs the Minutes CLI if needed to enforce meeting access rules. This demo config disables speech model auto-downloads.");
126
127
  console.log("Full setup (real audio capture, transcription, real meetings): https://useminutes.app");
127
128
  console.log("");
128
129
  process.exit(0);
@@ -1570,7 +1571,7 @@ const COPILOT_SUPPORTED = hasFeature(CLI_CAPABILITIES, "copilot_realtime");
1570
1571
  // `./version.ts` (see issue #183). Hosted `.mcpb` bundles will run
1571
1572
  // against CLIs with different minor/patch numbers within the same
1572
1573
  // major; that is explicitly supported.
1573
- const MCP_SERVER_VERSION = "0.25.5";
1574
+ const MCP_SERVER_VERSION = "0.26.0";
1574
1575
  export function parseKnowledgeConfig(configContent) {
1575
1576
  const knowledgeMatch = configContent.match(/\[knowledge\][\s\S]*?(?=\n\[|$)/);
1576
1577
  if (!knowledgeMatch) {
@@ -1617,17 +1618,15 @@ export function createCapabilityRepairCoordinator(repair, maximumAttempts = MAX_
1617
1618
  };
1618
1619
  }
1619
1620
  const runCapabilityRepair = createCapabilityRepairCoordinator(() => tryAutoInstallAttempt(true));
1620
- function getReleaseBinaryName() {
1621
- const platform = process.platform;
1622
- const arch = process.arch;
1621
+ export function getReleaseBinaryName(platform = process.platform, arch = process.arch) {
1623
1622
  if (platform === "darwin" && arch === "arm64")
1624
- return "minutes-macos-arm64";
1623
+ return MACOS_SHERPA_ARCHIVE;
1625
1624
  if (platform === "darwin" && arch === "x64")
1626
- return "minutes-macos-arm64"; // Rosetta handles it
1625
+ return MACOS_SHERPA_ARCHIVE; // Rosetta runs arm64 CLI
1627
1626
  if (platform === "linux" && arch === "x64")
1628
1627
  return "minutes-linux-x64";
1629
1628
  if (platform === "win32" && arch === "x64")
1630
- return "minutes-windows-x64.exe";
1629
+ return "minutes-windows-x64.zip";
1631
1630
  return null;
1632
1631
  }
1633
1632
  function getInstallDir() {
@@ -1662,7 +1661,20 @@ async function tryAutoInstallAttempt(capabilityRepair = false) {
1662
1661
  const targetPath = join(installDir, targetName);
1663
1662
  // Ensure install directory exists
1664
1663
  await mkdir(installDir, { recursive: true });
1665
- if (isWindows) {
1664
+ if (binaryName === MACOS_SHERPA_ARCHIVE) {
1665
+ console.error(`[Minutes] Selected ${MACOS_SHERPA_ARCHIVE}: Apple Silicon auto-install ` +
1666
+ "includes the Parakeet plugin beside the CLI");
1667
+ const result = await installMacSherpaArchiveWithFallback({
1668
+ installDir,
1669
+ execFileAsync,
1670
+ log: (message) => console.error(message),
1671
+ });
1672
+ if (!result.usedFallback) {
1673
+ console.error(`[Minutes] Verified ${result.assetName}, validated its CLI with --version, ` +
1674
+ "and atomically installed the binary + sherpa plugin");
1675
+ }
1676
+ }
1677
+ else if (isWindows) {
1666
1678
  // The Windows binary imports the MSVC runtime (VCRUNTIME140.dll,
1667
1679
  // MSVCP140.dll and friends), which is not part of Windows. On a PC
1668
1680
  // that has never had the Visual C++ Redistributable, the bare .exe
@@ -1673,11 +1685,10 @@ async function tryAutoInstallAttempt(capabilityRepair = false) {
1673
1685
  // resolves the application's own directory ahead of the system path,
1674
1686
  // so extracting them next to the executable is sufficient and needs no
1675
1687
  // installer and no admin rights. Verified on a clean Windows 11 VM.
1676
- const archiveName = "minutes-windows-x64.zip";
1677
- const archivePath = join(installDir, archiveName);
1678
- console.error(`[Minutes] Downloading ${archiveName} from latest release...`);
1688
+ const archivePath = join(installDir, binaryName);
1689
+ console.error(`[Minutes] Selected ${binaryName}: Windows needs the bundled MSVC runtime DLLs`);
1679
1690
  await downloadReleaseBinaryWithChecksum({
1680
- binaryName: archiveName,
1691
+ binaryName,
1681
1692
  targetPath: archivePath,
1682
1693
  execFileAsync,
1683
1694
  });
@@ -1693,7 +1704,7 @@ async function tryAutoInstallAttempt(capabilityRepair = false) {
1693
1704
  await stat(targetPath);
1694
1705
  }
1695
1706
  else {
1696
- console.error(`[Minutes] Downloading ${binaryName} from latest release...`);
1707
+ console.error(`[Minutes] Selected ${binaryName}: this platform uses the standalone CLI asset`);
1697
1708
  // Download with curl, verify SHA256SUMS.txt, then move the verified
1698
1709
  // binary into place.
1699
1710
  await downloadReleaseBinaryWithChecksum({
@@ -1821,6 +1832,7 @@ export function parseHealthOutput(stdout) {
1821
1832
  ...(typeof dataRecord.effective_engine === "string"
1822
1833
  ? { effectiveEngine: dataRecord.effective_engine }
1823
1834
  : {}),
1835
+ ...(typeof dataRecord.reason === "string" ? { reason: dataRecord.reason } : {}),
1824
1836
  ...(typeof dataRecord.model === "string" ? { model: dataRecord.model } : {}),
1825
1837
  };
1826
1838
  }
@@ -1837,6 +1849,10 @@ async function configFileExists() {
1837
1849
  }
1838
1850
  }
1839
1851
  export async function ensureWhisperModel(options = {}) {
1852
+ // Reading existing transcripts does not need a speech model. Demo and
1853
+ // recall-only hosts can opt out without changing the user's capture config.
1854
+ if ((options.autoSetup ?? process.env.MINUTES_MCP_AUTO_SETUP) === "0")
1855
+ return;
1840
1856
  const checkState = options.checkState;
1841
1857
  if (checkState ? checkState.done : modelCheckDone)
1842
1858
  return;
@@ -1853,7 +1869,8 @@ export async function ensureWhisperModel(options = {}) {
1853
1869
  return stdout;
1854
1870
  });
1855
1871
  const setup = options.setup ?? (async (model) => {
1856
- await execFileAsync(MINUTES_BIN, ["setup", "--model", model], {
1872
+ const args = model === undefined ? ["setup"] : ["setup", "--model", model];
1873
+ await execFileAsync(MINUTES_BIN, args, {
1857
1874
  timeout: 300000,
1858
1875
  env: mcpCliChildEnv(),
1859
1876
  });
@@ -1872,6 +1889,28 @@ export async function ensureWhisperModel(options = {}) {
1872
1889
  log("[Minutes] Unrecognized health --json output — skipping Whisper auto-setup");
1873
1890
  return;
1874
1891
  }
1892
+ const parakeetModelMissing = healthOutput.engine?.toLowerCase() === "auto" &&
1893
+ healthOutput.effectiveEngine?.toLowerCase() === "whisper" &&
1894
+ healthOutput.reason === "whisper: parakeet model missing — run minutes setup";
1895
+ if (parakeetModelMissing) {
1896
+ log("[Minutes] Auto found the sherpa plugin but Parakeet is missing — running minutes setup...");
1897
+ try {
1898
+ await setup();
1899
+ log("[Minutes] ✓ Parakeet and Whisper fallback models downloaded — recording is ready");
1900
+ }
1901
+ catch (error) {
1902
+ const message = error instanceof Error ? error.message : String(error);
1903
+ log(`[Minutes] Model download failed: ${message}. Run manually: minutes setup`);
1904
+ }
1905
+ return;
1906
+ }
1907
+ // Auto may resolve to sherpa when Parakeet v3 is installed. In that case
1908
+ // Whisper readiness is irrelevant to MCP startup and must not trigger the
1909
+ // legacy `setup --model tiny` path.
1910
+ if (healthOutput.effectiveEngine?.toLowerCase() === "sherpa") {
1911
+ log("[Minutes] Auto transcription resolved to sherpa — skipping Whisper auto-setup");
1912
+ return;
1913
+ }
1875
1914
  const modelItem = healthOutput.items.find((item) => item.label === "Speech model");
1876
1915
  if (!modelItem) {
1877
1916
  log("[Minutes] unrecognized health items — skipping Whisper auto-setup");
@@ -4363,7 +4402,12 @@ registerDocsAppTool("consistency_report", {
4363
4402
  if (!(await isCliAvailable())) {
4364
4403
  return { content: [{ type: "text", text: `Consistency reports require the full CLI for structured intent analysis.\n\n${CLI_INSTALL_MSG}` }] };
4365
4404
  }
4366
- const args = ["consistency", "--stale-after-days", String(stale_after_days)];
4405
+ const capabilities = await ensureCliCapabilities(["consistency_corpus_root_v1"]);
4406
+ if (capabilities.kind !== "report" || !hasFeature(capabilities, "consistency_corpus_root_v1")) {
4407
+ throw new Error("Consistency reports require a Minutes CLI that supports the selected meeting folder. Update Minutes, then try again.");
4408
+ }
4409
+ const meetingsDir = await getEffectiveMeetingsDir();
4410
+ const args = ["consistency", "--dir", meetingsDir, "--stale-after-days", String(stale_after_days)];
4367
4411
  if (owner)
4368
4412
  args.push("--owner", owner);
4369
4413
  const { stdout, stderr } = await runMinutes(args);
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "minutes-mcp",
3
- "version": "0.25.5",
3
+ "version": "0.26.0",
4
+ "mcpName": "io.github.silverstein/minutes",
4
5
  "description": "MCP server for minutes \u2014 conversation memory for AI assistants. Works with Claude Desktop, Mistral Vibe, Cursor, Windsurf, and any MCP client.",
5
6
  "main": "dist/index.js",
6
7
  "type": "module",
@@ -32,20 +33,21 @@
32
33
  "scripts": {
33
34
  "build": "tsc && vite build",
34
35
  "build:ui": "vite build",
35
- "test": "npm run build && node test/mcp_tools_test.mjs && node test/copilot_mcp_contract.mjs && node test/mcp_http_transport_test.mjs && node test/surface_parity_test.mjs",
36
- "test:unit": "vitest run",
36
+ "test": "npm run build && node ../../scripts/run_corpus_test_harness.mjs npm run test:integration",
37
+ "test:integration": "node test/mcp_tools_test.mjs && node test/copilot_mcp_contract.mjs && node test/mcp_http_transport_test.mjs && node test/surface_parity_test.mjs && node test/first_recall_test.mjs",
38
+ "test:unit": "node ../../scripts/run_corpus_test_harness.mjs npx vitest run",
37
39
  "test:host-compat": "node test/live_events_host_compat.mjs",
38
40
  "test:copilot": "node test/copilot_mcp_contract.mjs",
39
41
  "test:http": "node test/mcp_http_transport_test.mjs",
40
42
  "test:surface-parity": "node test/surface_parity_test.mjs",
41
- "test:all": "vitest run && npm run build && node test/mcp_tools_test.mjs && node test/copilot_mcp_contract.mjs && node test/mcp_http_transport_test.mjs && node test/surface_parity_test.mjs",
43
+ "test:all": "npm run test:unit && npm test",
42
44
  "start": "node dist/index.js",
43
45
  "dev": "tsx src/index.ts"
44
46
  },
45
47
  "dependencies": {
46
48
  "@modelcontextprotocol/ext-apps": "^1.6.0",
47
- "@modelcontextprotocol/sdk": "^1.29.0",
48
- "minutes-sdk": "0.25.5",
49
+ "@modelcontextprotocol/sdk": "^1.30.0",
50
+ "minutes-sdk": "0.26.0",
49
51
  "yaml": "^2.8.3",
50
52
  "zod": "^3.25 || ^4.0"
51
53
  },