minutes-mcp 0.25.6 → 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.
- package/dist/autoInstall.d.ts +49 -0
- package/dist/autoInstall.js +196 -6
- package/dist/corpus-lease.d.ts +9 -0
- package/dist/corpus-lease.js +57 -14
- package/dist/index.d.ts +4 -1
- package/dist/index.js +61 -17
- package/package.json +5 -4
package/dist/autoInstall.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/autoInstall.js
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import { createHash } from "crypto";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
|
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
|
}
|
package/dist/corpus-lease.d.ts
CHANGED
|
@@ -135,6 +135,15 @@ export declare function awaitDeferredCorpusReleasesForTests(timeoutMs?: number):
|
|
|
135
135
|
export declare function reservedCorpusMemoryBytesForTests(): number;
|
|
136
136
|
/** @internal Pure policy seam for test-harness gate regression coverage. */
|
|
137
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
|
+
}>;
|
|
138
147
|
export type CorpusLeaseWorkerRequest = Readonly<{
|
|
139
148
|
root: string;
|
|
140
149
|
budgets: Readonly<CorpusReadBudgets>;
|
package/dist/corpus-lease.js
CHANGED
|
@@ -8,7 +8,6 @@ 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;
|
|
12
11
|
const MAX_TEST_HARNESS_AUTHORIZATION_TIMEOUT_MS = 120_000;
|
|
13
12
|
const MAX_ACTIVE_WATCHERS = 64;
|
|
14
13
|
// Snapshot content is retained as JavaScript strings, whose backing storage
|
|
@@ -54,6 +53,30 @@ export const DEFAULT_CORPUS_READ_BUDGETS = Object.freeze({
|
|
|
54
53
|
maxWatcherCount: 512,
|
|
55
54
|
maxReaderCount: 64,
|
|
56
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);
|
|
57
80
|
let activeWatcherCount = 0;
|
|
58
81
|
let reservedCorpusMemoryBytes = 0;
|
|
59
82
|
let retainedSentinelUseSequence = 0;
|
|
@@ -188,12 +211,12 @@ function resolveFenceTimeout(timeoutMs) {
|
|
|
188
211
|
}
|
|
189
212
|
function resolveAuthorizationTimeoutMs(timeoutMs, environment = process.env) {
|
|
190
213
|
const configuredOverride = environment.MINUTES_CORPUS_AUTH_TIMEOUT_MS;
|
|
191
|
-
// A
|
|
192
|
-
// Requiring both markers keeps an ambient
|
|
193
|
-
// from
|
|
194
|
-
// harness sets this pair; the spawned lease worker
|
|
195
|
-
// parent. Invalid gated values fail closed instead of
|
|
196
|
-
// changing the requested budget.
|
|
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.
|
|
197
220
|
const testHarnessOverride = environment.NODE_ENV === "test" &&
|
|
198
221
|
environment.MINUTES_TEST_HARNESS === "1" &&
|
|
199
222
|
configuredOverride !== undefined
|
|
@@ -216,9 +239,23 @@ function resolveAuthorizationTimeoutMs(timeoutMs, environment = process.env) {
|
|
|
216
239
|
export function resolveAuthorizationTimeoutMsForTest(timeoutMs, environment) {
|
|
217
240
|
return resolveAuthorizationTimeoutMs(timeoutMs, environment);
|
|
218
241
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
+
};
|
|
222
259
|
}
|
|
223
260
|
function remainingAuthorizationMs(deadline) {
|
|
224
261
|
const remainingNs = deadline - process.hrtime.bigint();
|
|
@@ -1040,7 +1077,7 @@ class WatchedCorpusAttempt {
|
|
|
1040
1077
|
*/
|
|
1041
1078
|
async function withStableCorpusLeaseInProcess(root, operation, hooks = {}) {
|
|
1042
1079
|
const budgets = resolveCorpusReadBudgets(hooks.budgets);
|
|
1043
|
-
const deadline =
|
|
1080
|
+
const { deadline } = authorizationWindow(hooks.timeoutMs);
|
|
1044
1081
|
const fenceTimeoutMs = resolveFenceTimeout(hooks.timeoutMs);
|
|
1045
1082
|
const memoryReservation = reserveCorpusMemory(budgets);
|
|
1046
1083
|
try {
|
|
@@ -1415,7 +1452,7 @@ async function dispatchStableCorpusLease(root, operation, hooks = {}) {
|
|
|
1415
1452
|
throw new Error("Access denied: a meeting corpus worker was killed without confirming it died");
|
|
1416
1453
|
}
|
|
1417
1454
|
const budgets = resolveCorpusReadBudgets(hooks.budgets);
|
|
1418
|
-
const deadline =
|
|
1455
|
+
const { budgetMs: authorizationBudgetMs, deadline } = authorizationWindow(hooks.timeoutMs);
|
|
1419
1456
|
const timeoutMs = remainingAuthorizationMs(deadline);
|
|
1420
1457
|
const invocation = corpusWorkerInvocation(hooks.workerScriptForTest);
|
|
1421
1458
|
const reservation = reserveCorpusMemory(budgets);
|
|
@@ -1538,14 +1575,20 @@ async function dispatchStableCorpusLease(root, operation, hooks = {}) {
|
|
|
1538
1575
|
// "0ms remained" rather than an overrun.
|
|
1539
1576
|
const overran = remainingNs < 0n;
|
|
1540
1577
|
const magnitudeMs = Number((overran ? -remainingNs : remainingNs) / 1000000n);
|
|
1541
|
-
const
|
|
1578
|
+
const remainingBudget = overran
|
|
1542
1579
|
? `authorization budget overrun by ${magnitudeMs}ms`
|
|
1543
1580
|
: `${magnitudeMs}ms of authorization budget remained`;
|
|
1544
1581
|
// Shared with the bound-reader refusal path, because a plain
|
|
1545
1582
|
// try/catch here never covered an asynchronous EPIPE: that
|
|
1546
1583
|
// arrives as an 'error' event on a later tick and is fatal
|
|
1547
1584
|
// without a listener.
|
|
1548
|
-
|
|
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`);
|
|
1549
1592
|
}
|
|
1550
1593
|
catch {
|
|
1551
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
|
|
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("
|
|
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.
|
|
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
|
|
1623
|
+
return MACOS_SHERPA_ARCHIVE;
|
|
1625
1624
|
if (platform === "darwin" && arch === "x64")
|
|
1626
|
-
return
|
|
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.
|
|
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 (
|
|
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
|
|
1677
|
-
|
|
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
|
|
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]
|
|
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
|
-
|
|
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
|
|
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.
|
|
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",
|
|
@@ -33,7 +34,7 @@
|
|
|
33
34
|
"build": "tsc && vite build",
|
|
34
35
|
"build:ui": "vite build",
|
|
35
36
|
"test": "npm run build && node ../../scripts/run_corpus_test_harness.mjs npm run test:integration",
|
|
36
|
-
"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",
|
|
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",
|
|
37
38
|
"test:unit": "node ../../scripts/run_corpus_test_harness.mjs npx vitest run",
|
|
38
39
|
"test:host-compat": "node test/live_events_host_compat.mjs",
|
|
39
40
|
"test:copilot": "node test/copilot_mcp_contract.mjs",
|
|
@@ -45,8 +46,8 @@
|
|
|
45
46
|
},
|
|
46
47
|
"dependencies": {
|
|
47
48
|
"@modelcontextprotocol/ext-apps": "^1.6.0",
|
|
48
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
49
|
-
"minutes-sdk": "0.
|
|
49
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
50
|
+
"minutes-sdk": "0.26.0",
|
|
50
51
|
"yaml": "^2.8.3",
|
|
51
52
|
"zod": "^3.25 || ^4.0"
|
|
52
53
|
},
|