openmerit 0.2.0 → 0.3.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +12 -25
  3. package/dist/pi/scout-extension.mjs +20 -0
  4. package/dist/terminal/scout-update.json +1 -1
  5. package/dist/terminal/src/browser.mjs +2 -4
  6. package/dist/terminal/src/cli.mjs +4 -56
  7. package/dist/terminal/src/install.mjs +2 -2
  8. package/dist/terminal/src/scout-instructions.js +20 -0
  9. package/dist/terminal/src/scout-pi.js +1 -1
  10. package/dist/terminal/src/scout.js +35 -9
  11. package/dist/terminal/src/telemetry.mjs +8 -60
  12. package/docs/agent-evaluations.md +8 -148
  13. package/docs/commands.md +3 -9
  14. package/docs/getting-started.md +2 -16
  15. package/docs/index.md +4 -18
  16. package/docs/inference-terminal.md +13 -594
  17. package/docs/roadmap.md +2 -2
  18. package/docs/scout-data-policy.md +2 -1
  19. package/docs/scout.md +21 -2
  20. package/docs/security.md +5 -18
  21. package/docs/testing.md +4 -16
  22. package/docs/troubleshooting.md +2 -2
  23. package/package.json +10 -23
  24. package/dist/eval/src/adapters.mjs +0 -112
  25. package/dist/eval/src/cli.mjs +0 -151
  26. package/dist/eval/src/config.mjs +0 -108
  27. package/dist/eval/src/process.mjs +0 -44
  28. package/dist/eval/src/report.mjs +0 -59
  29. package/dist/eval/src/runner.mjs +0 -123
  30. package/dist/eval/src/scout.mjs +0 -48
  31. package/dist/terminal/public/app.js +0 -298
  32. package/dist/terminal/public/brands/anthropic.png +0 -0
  33. package/dist/terminal/public/brands/baai.png +0 -0
  34. package/dist/terminal/public/brands/baseten.png +0 -0
  35. package/dist/terminal/public/brands/cerebras.png +0 -0
  36. package/dist/terminal/public/brands/cohere.png +0 -0
  37. package/dist/terminal/public/brands/deepseek.ico +0 -0
  38. package/dist/terminal/public/brands/google.png +0 -0
  39. package/dist/terminal/public/brands/groq.ico +0 -0
  40. package/dist/terminal/public/brands/huggingface.svg +0 -8
  41. package/dist/terminal/public/brands/lm-studio.png +0 -0
  42. package/dist/terminal/public/brands/meta.ico +0 -0
  43. package/dist/terminal/public/brands/mistral.png +0 -0
  44. package/dist/terminal/public/brands/nomic.png +0 -0
  45. package/dist/terminal/public/brands/ollama.png +0 -0
  46. package/dist/terminal/public/brands/openai.png +0 -0
  47. package/dist/terminal/public/brands/openrouter.png +0 -0
  48. package/dist/terminal/public/brands/qwen.png +0 -0
  49. package/dist/terminal/public/brands/vllm.png +0 -0
  50. package/dist/terminal/public/favicon.svg +0 -1
  51. package/dist/terminal/public/index.html +0 -22
  52. package/dist/terminal/public/styles.css +0 -921
  53. package/dist/terminal/src/activity-merge.mjs +0 -66
  54. package/dist/terminal/src/collect.mjs +0 -370
  55. package/dist/terminal/src/coverage.mjs +0 -33
  56. package/dist/terminal/src/discovery.mjs +0 -99
  57. package/dist/terminal/src/hardware.mjs +0 -57
  58. package/dist/terminal/src/history.mjs +0 -32
  59. package/dist/terminal/src/log-continuity.mjs +0 -26
  60. package/dist/terminal/src/project-activity.mjs +0 -157
  61. package/dist/terminal/src/repository-background.mjs +0 -37
  62. package/dist/terminal/src/repository-index.mjs +0 -267
  63. package/dist/terminal/src/repository-policy.mjs +0 -3
  64. package/dist/terminal/src/repository-syntax.mjs +0 -236
  65. package/dist/terminal/src/repository-worker.mjs +0 -15
  66. package/dist/terminal/src/sample.mjs +0 -171
  67. package/dist/terminal/src/scout-credentials.d.ts +0 -12
  68. package/dist/terminal/src/scout-credentials.ts +0 -44
  69. package/dist/terminal/src/scout-instructions.d.ts +0 -5
  70. package/dist/terminal/src/scout-instructions.ts +0 -119
  71. package/dist/terminal/src/scout-pi.d.ts +0 -44
  72. package/dist/terminal/src/scout-pi.ts +0 -76
  73. package/dist/terminal/src/scout.d.ts +0 -77
  74. package/dist/terminal/src/scout.ts +0 -164
  75. package/dist/terminal/src/server.mjs +0 -95
  76. package/dist/terminal/src/service-registry.mjs +0 -78
  77. package/dist/terminal/src/services.mjs +0 -44
  78. package/dist/terminal/src/topology.mjs +0 -30
  79. package/dist/terminal/src/updates.d.ts +0 -62
  80. package/dist/terminal/src/updates.ts +0 -313
@@ -1,78 +0,0 @@
1
- // Shared, dependency-free service identities for passive discovery and the UI.
2
- // Endpoint formats checked against official API docs on 2026-10-10; see the guide.
3
- // A match identifies configuration, never connectivity, usage, a model, or a host.
4
- const define = (key, name, type, options = {}) => Object.freeze({ key, name, type, aliases: [], credentials: [], endpoints: [], hosts: [], ...options });
5
- const label = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
6
- const subdomains = suffix => new RegExp(`^(?:${label}\\.)+${suffix.replaceAll('.', '\\.')}$`);
7
- const resource = suffix => new RegExp(`^${label}\\.${suffix.replaceAll('.', '\\.')}$`);
8
- const region = '[a-z]{2}(?:-[a-z]+)+-\\d+';
9
- const hf = subdomains('endpoints.huggingface.cloud');
10
- const azureOpenAI = resource('openai.azure.com');
11
- const azureFoundry = resource('services.ai.azure.com');
12
- const bedrock = new RegExp(`^bedrock-runtime(?:-fips)?\\.${region}\\.amazonaws\\.com(?:\\.cn)?$`);
13
- const sagemaker = new RegExp(`^runtime(?:-fips)?\\.sagemaker\\.${region}\\.amazonaws\\.com(?:\\.cn)?$`);
14
- const vertex = /^(?:[a-z]+(?:-[a-z]+)*\d+-)?aiplatform\.googleapis\.com$/;
15
- const maas = new RegExp(`^${label}\\.(?:cn-beijing|cn-hongkong|ap-southeast-1|ap-northeast-1)\\.maas\\.aliyuncs\\.com$`);
16
-
17
- export const SERVICE_REGISTRY = Object.freeze([
18
- define('openai', 'OpenAI', 'model-api', { aliases: ['openai api'], credentials: ['OPENAI_API_KEY'], endpoints: ['OPENAI_BASE_URL', 'OPENAI_API_BASE'], hosts: ['api.openai.com'] }),
19
- define('anthropic', 'Anthropic', 'model-api', { aliases: ['anthropic api'], credentials: ['ANTHROPIC_API_KEY'], endpoints: ['ANTHROPIC_BASE_URL'], hosts: ['api.anthropic.com'] }),
20
- define('openrouter', 'OpenRouter', 'gateway', { credentials: ['OPENROUTER_API_KEY'], endpoints: ['OPENROUTER_BASE_URL'], hosts: ['openrouter.ai'] }),
21
- define('google', 'Google', 'model-api', { aliases: ['google ai', 'google api', 'gemini api'], credentials: ['GOOGLE_GENERATIVE_AI_API_KEY', 'GEMINI_API_KEY'], endpoints: ['GOOGLE_GEMINI_BASE_URL', 'GEMINI_BASE_URL'], hosts: ['generativelanguage.googleapis.com'] }),
22
- define('groq', 'Groq', 'inference-cloud', { aliases: ['groq api', 'groqcloud'], credentials: ['GROQ_API_KEY'], endpoints: ['GROQ_BASE_URL'], hosts: ['api.groq.com'] }),
23
- define('cerebras', 'Cerebras', 'inference-cloud', { aliases: ['cerebras api'], credentials: ['CEREBRAS_API_KEY'], endpoints: ['CEREBRAS_BASE_URL'], hosts: ['api.cerebras.ai'] }),
24
- define('baseten', 'Baseten', 'inference-cloud', { aliases: ['baseten api'], credentials: ['BASETEN_API_KEY'], endpoints: ['BASETEN_BASE_URL'], hosts: ['inference.baseten.co'], match: url => /^model-[a-z0-9-]+\.api\.baseten\.co$/.test(url.hostname), redact: host => host.replace(/^model-[a-z0-9-]+(?=\.api\.baseten\.co$)/, 'model-…') }),
25
- // Hub and general cloud tokens can authorize unrelated products or downloads.
26
- define('huggingface-endpoints', 'Hugging Face Endpoints', 'inference-cloud', { aliases: ['hugging face inference endpoints'], endpoints: ['HF_INFERENCE_ENDPOINT', 'HF_ENDPOINT_URL', 'HUGGINGFACE_ENDPOINT_URL'], match: url => hf.test(url.hostname), redact: () => '….endpoints.huggingface.cloud' }),
27
- define('huggingface-router', 'Hugging Face Router', 'router', { aliases: ['hugging face inference providers'], hosts: ['router.huggingface.co'] }),
28
- define('mistral', 'Mistral', 'model-api', { aliases: ['mistral ai', 'mistral api'], credentials: ['MISTRAL_API_KEY'], endpoints: ['MISTRAL_BASE_URL', 'MISTRAL_SERVER_URL'], hosts: ['api.mistral.ai'] }),
29
- define('deepseek', 'DeepSeek', 'model-api', { aliases: ['deepseek api'], credentials: ['DEEPSEEK_API_KEY'], endpoints: ['DEEPSEEK_BASE_URL'], hosts: ['api.deepseek.com'] }),
30
- define('cohere', 'Cohere', 'model-api', { aliases: ['cohere api'], credentials: ['COHERE_API_KEY', 'CO_API_KEY'], endpoints: ['COHERE_BASE_URL'], hosts: ['api.cohere.ai', 'api.cohere.com'] }),
31
- define('xai', 'xAI', 'model-api', { aliases: ['xai api'], credentials: ['XAI_API_KEY'], endpoints: ['XAI_BASE_URL'], hosts: ['api.x.ai'] }),
32
- define('voyage', 'Voyage AI', 'model-api', { aliases: ['voyage', 'voyageai'], credentials: ['VOYAGE_API_KEY'], endpoints: ['VOYAGE_BASE_URL'], hosts: ['api.voyageai.com'] }),
33
- define('jina', 'Jina AI', 'model-api', { aliases: ['jina'], endpoints: ['JINA_BASE_URL'], hosts: ['api.jina.ai'] }),
34
- define('together', 'Together AI', 'inference-cloud', { aliases: ['together'], credentials: ['TOGETHER_API_KEY'], endpoints: ['TOGETHER_BASE_URL'], hosts: ['api.together.ai', 'api.together.xyz'] }),
35
- define('fireworks', 'Fireworks AI', 'inference-cloud', { aliases: ['fireworks'], credentials: ['FIREWORKS_API_KEY'], endpoints: ['FIREWORKS_BASE_URL'], hosts: ['api.fireworks.ai'] }),
36
- define('deepinfra', 'DeepInfra', 'inference-cloud', { credentials: ['DEEPINFRA_API_KEY', 'DEEPINFRA_API_TOKEN'], endpoints: ['DEEPINFRA_BASE_URL'], hosts: ['api.deepinfra.com'] }),
37
- define('replicate', 'Replicate', 'inference-cloud', { credentials: ['REPLICATE_API_TOKEN'], endpoints: ['REPLICATE_BASE_URL'], hosts: ['api.replicate.com'] }),
38
- define('nvidia-api', 'NVIDIA API Catalog', 'inference-cloud', { endpoints: ['NVIDIA_BASE_URL', 'NVIDIA_NIM_BASE_URL'], hosts: ['integrate.api.nvidia.com'] }),
39
- define('sambanova', 'SambaNova', 'inference-cloud', { credentials: ['SAMBANOVA_API_KEY'], endpoints: ['SAMBANOVA_BASE_URL'], hosts: ['api.sambanova.ai'] }),
40
- define('novita', 'Novita AI', 'inference-cloud', { aliases: ['novita'], credentials: ['NOVITA_API_KEY'], endpoints: ['NOVITA_BASE_URL'], hosts: ['api.novita.ai'] }),
41
- define('nebius', 'Nebius Token Factory', 'inference-cloud', { aliases: ['nebius ai studio'], endpoints: ['NEBIUS_BASE_URL'], hosts: ['api.studio.nebius.ai', 'api.studio.nebius.com'] }),
42
- define('siliconflow', 'SiliconFlow', 'inference-cloud', { credentials: ['SILICONFLOW_API_KEY'], endpoints: ['SILICONFLOW_BASE_URL'], hosts: ['api.siliconflow.com', 'api.siliconflow.cn'] }),
43
- define('azure-openai', 'Azure OpenAI', 'inference-cloud', { endpoints: ['AZURE_OPENAI_ENDPOINT', 'AZURE_OPENAI_BASE_URL', 'AZURE_OPENAI_API_BASE'], match: url => azureOpenAI.test(url.hostname), redact: () => '….openai.azure.com' }),
44
- define('azure-foundry', 'Azure AI Foundry', 'inference-cloud', { aliases: ['microsoft foundry'], endpoints: ['AZURE_AI_ENDPOINT', 'AZURE_AI_INFERENCE_ENDPOINT', 'AZURE_INFERENCE_ENDPOINT'], match: url => azureFoundry.test(url.hostname) && /^\/(?:$|(?:openai|models)(?:\/|$))/.test(url.pathname), redact: () => '….services.ai.azure.com' }),
45
- define('bedrock', 'Amazon Bedrock', 'inference-cloud', { aliases: ['aws bedrock', 'bedrock'], credentials: ['AWS_BEARER_TOKEN_BEDROCK'], endpoints: ['AWS_ENDPOINT_URL_BEDROCK_RUNTIME', 'BEDROCK_ENDPOINT_URL', 'BEDROCK_BASE_URL'], match: url => bedrock.test(url.hostname) }),
46
- define('sagemaker', 'Amazon SageMaker', 'inference-cloud', { aliases: ['aws sagemaker', 'sagemaker'], endpoints: ['AWS_ENDPOINT_URL_SAGEMAKER_RUNTIME', 'SAGEMAKER_ENDPOINT_URL', 'SAGEMAKER_BASE_URL'], match: url => sagemaker.test(url.hostname) }),
47
- define('vertex', 'Google Vertex AI', 'inference-cloud', { aliases: ['vertex ai', 'vertex'], endpoints: ['VERTEX_AI_ENDPOINT', 'VERTEX_AI_BASE_URL'], match: url => vertex.test(url.hostname) }),
48
- define('cloudflare-workers-ai', 'Cloudflare Workers AI', 'inference-cloud', { aliases: ['workers ai'], endpoints: ['CLOUDFLARE_WORKERS_AI_BASE_URL'], match: url => url.hostname === 'api.cloudflare.com' && /^\/client\/v4\/accounts\/[a-zA-Z0-9_-]+\/ai(?:\/|$)/.test(url.pathname) }),
49
- define('cloudflare-ai-gateway', 'Cloudflare AI Gateway', 'gateway', { endpoints: ['CLOUDFLARE_AI_GATEWAY_URL'], hosts: ['gateway.ai.cloudflare.com'] }),
50
- define('portkey', 'Portkey', 'gateway', { credentials: ['PORTKEY_API_KEY'], endpoints: ['PORTKEY_BASE_URL', 'PORTKEY_GATEWAY_URL'], hosts: ['api.portkey.ai'] }),
51
- // The generic AI_GATEWAY_API_KEY name is shared by applications; require the URL.
52
- define('vercel-ai-gateway', 'Vercel AI Gateway', 'gateway', { endpoints: ['AI_GATEWAY_BASE_URL', 'VERCEL_AI_GATEWAY_BASE_URL'], hosts: ['ai-gateway.vercel.sh'] }),
53
- define('dashscope', 'Alibaba Cloud Model Studio', 'inference-cloud', { aliases: ['dashscope', 'alibaba model studio'], credentials: ['DASHSCOPE_API_KEY'], endpoints: ['DASHSCOPE_BASE_URL', 'DASHSCOPE_API_BASE'], hosts: ['dashscope.aliyuncs.com', 'dashscope-intl.aliyuncs.com', 'dashscope-us.aliyuncs.com', 'cn-hongkong.dashscope.aliyuncs.com'], match: url => maas.test(url.hostname), redact: host => maas.test(host) ? '….maas.aliyuncs.com' : host }),
54
- define('minimax', 'MiniMax', 'model-api', { credentials: ['MINIMAX_API_KEY'], endpoints: ['MINIMAX_BASE_URL'], hosts: ['api.minimax.io'] }),
55
- define('moonshot', 'Moonshot AI', 'model-api', { aliases: ['moonshot', 'kimi api'], credentials: ['MOONSHOT_API_KEY'], endpoints: ['MOONSHOT_BASE_URL'], hosts: ['api.moonshot.ai'] }),
56
- define('zai', 'Z.AI', 'model-api', { aliases: ['zai'], credentials: ['ZAI_API_KEY'], endpoints: ['ZAI_BASE_URL'], hosts: ['api.z.ai'] }),
57
- // Perplexity shares a host across products. Never turn a Search URL into Sonar.
58
- define('perplexity-sonar', 'Perplexity Sonar', 'model-api', { endpoints: ['PERPLEXITY_BASE_URL'], match: url => url.hostname === 'api.perplexity.ai' && /^(?:\/?|\/chat\/completions\/?|\/v1\/?)$/.test(url.pathname) }),
59
- define('perplexity-router', 'Perplexity Router', 'router', { match: url => url.hostname === 'api.perplexity.ai' && /^\/router(?:\/|$)/.test(url.pathname) }),
60
- define('ollama', 'Ollama', 'runtime'),
61
- define('vllm', 'vLLM', 'runtime'),
62
- define('lm-studio', 'LM Studio', 'runtime'),
63
- ]);
64
- const names = new Map(SERVICE_REGISTRY.flatMap(service => [service.name, ...service.aliases].map(name => [name.toLowerCase(), service])));
65
- export const registeredService = name => names.get(String(name ?? '').trim().toLowerCase()) ?? null;
66
- export const SERVICE_GROUPS = Object.freeze(SERVICE_REGISTRY.filter(service => service.credentials.length || service.endpoints.length));
67
- export const SERVICE_CREDENTIAL_FIELDS = Object.freeze(SERVICE_GROUPS.flatMap(service => service.credentials));
68
- export const SERVICE_ENDPOINT_FIELDS = Object.freeze(SERVICE_GROUPS.flatMap(service => service.endpoints));
69
-
70
- export function endpointService(value) {
71
- if (typeof value !== 'string' || value.length > 2048 || /[\x00-\x20\x7f\\]/.test(value)) return null;
72
- try {
73
- const url = new URL(value);
74
- if (url.protocol !== 'https:' || url.username || url.password || url.port) return null;
75
- const service = SERVICE_REGISTRY.find(service => service.hosts.includes(url.hostname) || service.match?.(url));
76
- return service ? { service, endpointHost: service.redact?.(url.hostname) ?? url.hostname } : null;
77
- } catch { return null; }
78
- }
@@ -1,44 +0,0 @@
1
- // Configuration evidence only. Never call services or return credentials or raw URLs.
2
- import { SERVICE_GROUPS, endpointService } from './service-registry.mjs';
3
-
4
- export const serviceFromEndpoint = value => endpointService(value)?.service.name ?? null;
5
- export function configuredServices(env = {}) {
6
- const found = new Map(), unrecognizedEndpoints = [];
7
- const present = key => typeof env[key] === 'string' && env[key].trim().length > 0;
8
- const add = (service, field, basis, endpointHost) => {
9
- const { name, type } = service;
10
- const kind = ['router', 'gateway'].includes(type) ? type : 'provider';
11
- const item = found.get(name) ?? { name, kind, ...(['model-api', 'inference-cloud'].includes(type) ? { serviceType: type } : {}), location: 'cloud', state: 'configured', sourceId: 'environment', evidence: [] };
12
- item.evidence.push({ field, basis, ...(endpointHost ? { endpointHost } : {}) }); found.set(name, item);
13
- };
14
- for (const group of SERVICE_GROUPS) {
15
- const overrides = group.endpoints.filter(present);
16
- if (overrides.length) {
17
- for (const field of overrides) {
18
- const match = endpointService(env[field]);
19
- if (match) add(match.service, field, 'endpoint-host', match.endpointHost);
20
- else unrecognizedEndpoints.push(field);
21
- }
22
- // An override can repurpose the SDK credential. Unknown/local overrides
23
- // must suppress the vendor fallback too; the key name is not enough.
24
- } else for (const field of group.credentials.filter(present)) add(group, field, 'credential-name');
25
- }
26
- return { services: [...found.values()], unrecognizedEndpoints };
27
- }
28
-
29
- // Multiple reports for one model/service may disagree. Preserve the gap rather
30
- // than choosing whichever file or record happened to be processed first.
31
- export function modelCreators(models, reports) {
32
- const evidence = new Map();
33
- for (const item of [...models, ...reports]) {
34
- if (!item.modelCreator) continue;
35
- const key = JSON.stringify([item.provider, item.model ?? item.name]);
36
- const entry = evidence.get(key) ?? { names: new Set(), sources: new Set() };
37
- entry.names.add(item.modelCreator); if (item.sourceId) entry.sources.add(item.sourceId);
38
- evidence.set(key, entry);
39
- }
40
- return models.map(model => {
41
- const entry = evidence.get(JSON.stringify([model.provider, model.name]));
42
- return { ...model, modelCreator: entry?.names.size === 1 ? [...entry.names][0] : null, creatorConflict: (entry?.names.size ?? 0) > 1, creatorSources: [...(entry?.sources ?? [])] };
43
- });
44
- }
@@ -1,30 +0,0 @@
1
- // Bounded metadata contracts. Never retain endpoints, credentials, or payloads.
2
- const label = value => typeof value === 'string' ? value.slice(0, 160).replace(/[\x00-\x1f]/g, '') : '';
3
- const count = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
4
- const timestamp = value => typeof value === 'string' && Number.isFinite(Date.parse(value)) ? new Date(value).toISOString() : null;
5
- const list = (value, limit) => Array.isArray(value) ? value.slice(0, limit).filter(item => item && typeof item === 'object') : [];
6
- export const poolIds = value => Array.isArray(value) ? [...new Set(value.slice(0, 16).map(label).filter(Boolean))] : [];
7
- export function normalizePath(value) {
8
- // Do not turn a broken path into an apparently complete chain by dropping hops.
9
- if (!Array.isArray(value) || !value.length || value.length > 8 || value.some(hop => !hop || !label(hop.name))) return [];
10
- return value.map(hop => ({ name: label(hop.name), kind: ['direct', 'local', 'router', 'gateway'].includes(hop.kind) ? hop.kind : 'unknown' }));
11
- }
12
- function unique(items) {
13
- const counts = new Map();
14
- for (const item of items) counts.set(item.id, (counts.get(item.id) ?? 0) + 1);
15
- return items.filter(item => item.id && counts.get(item.id) === 1);
16
- }
17
- export function normalizeDeployments(value) {
18
- return unique(list(value, 200).map(item => ({ id: label(item.id), name: label(item.name) || label(item.id), model: label(item.model), modelCreator: label(item.modelCreator) || null, servingMode: ['shared', 'dedicated', 'self-hosted'].includes(item.servingMode) ? item.servingMode : 'unknown', provider: label(item.provider) || 'Unattributed', location: ['local', 'private', 'cloud'].includes(item.location) ? item.location : 'unknown', poolIds: poolIds(item.poolIds), hostId: label(item.hostId) || null, sourceId: 'inventory', state: 'configured' })).filter(item => item.model && !item.id.startsWith('runtime:')));
19
- }
20
- export function normalizePools(value) {
21
- return unique(list(value, 100).map(item => ({ id: label(item.id), name: label(item.name) || label(item.id), kind: ['quota', 'runtime', 'gateway'].includes(item.kind) ? item.kind : 'unknown', windowSeconds: count(item.windowSeconds), sourceId: 'capacity', observedAt: null, metrics: {} })).filter(item => !item.id.startsWith('runtime:')));
22
- }
23
- export function normalizeCapacity(value, now = Date.now()) {
24
- return unique(list(value?.pools, 100).map(item => {
25
- const observedAt = timestamp(item.observedAt);
26
- const metrics = Object.fromEntries(['running', 'waiting', 'concurrencyLimit', 'requestsUsed', 'requestsLimit', 'tokensUsed', 'tokensLimit', 'cachePercent', 'memoryUsedBytes', 'memoryTotalBytes'].map(key => [key, count(item.metrics?.[key])]).filter(([, value]) => value !== null));
27
- if (metrics.cachePercent > 100) delete metrics.cachePercent;
28
- return { id: label(item.id), observedAt, resetAt: timestamp(item.resetAt), metrics };
29
- }).filter(item => item.observedAt && Date.parse(item.observedAt) <= now));
30
- }
@@ -1,62 +0,0 @@
1
- type ObjectValue = Record<string, unknown>;
2
- interface UpdateRecord {
3
- enabled: boolean;
4
- version: string;
5
- runnerHash: string;
6
- hook: ObjectValue;
7
- }
8
- export declare const updateContract = "scout-recommend-v1";
9
- export declare function updatePaths(base: string, scope: string): {
10
- record: string;
11
- runner: string;
12
- settings: string;
13
- state: string;
14
- backup: string;
15
- skill: string;
16
- rule: string;
17
- };
18
- export declare function withUpdateLock<T>(base: string, work: () => Promise<T>): Promise<T>;
19
- export declare function inspectUpdates(base: string, scope: string, record: ObjectValue): Promise<{
20
- paths: {
21
- record: string;
22
- runner: string;
23
- settings: string;
24
- state: string;
25
- backup: string;
26
- skill: string;
27
- rule: string;
28
- };
29
- saved: UpdateRecord | null;
30
- runner: string | null;
31
- settings: ObjectValue;
32
- hooks: ObjectValue;
33
- conflicts: string[];
34
- active: boolean;
35
- }>;
36
- export declare function configureUpdates(base: string, scope: string, record: ObjectValue, version: string, enabled: boolean): Promise<{
37
- enabled: boolean;
38
- version: string;
39
- runnerHash: string;
40
- hook: {
41
- hooks: {
42
- type: string;
43
- command: string;
44
- async: boolean;
45
- timeout: number;
46
- }[];
47
- };
48
- }>;
49
- export declare function removeUpdates(base: string, scope: string, record: ObjectValue): Promise<void>;
50
- export declare function recoverUpdates(base: string, scope: string): Promise<void>;
51
- export interface UpdateResult {
52
- status: string;
53
- message: string;
54
- version?: string;
55
- }
56
- export declare function runUpdate(base: string, scope: string, options?: {
57
- hook?: boolean;
58
- enabled?: boolean;
59
- fetcher?: typeof fetch;
60
- now?: number;
61
- }): Promise<UpdateResult>;
62
- export {};
@@ -1,313 +0,0 @@
1
- // Standalone updater copied into Claude's configuration directory. Only Node
2
- // built-ins: it must survive the npx cache disappearing, without installing or
3
- // executing code from the release it downloads.
4
- import { createHash, randomUUID } from 'node:crypto';
5
- import { realpathSync } from 'node:fs';
6
- import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
7
- import { dirname, join, resolve } from 'node:path';
8
- import { fileURLToPath } from 'node:url';
9
- import { gunzipSync } from 'node:zlib';
10
-
11
- type ObjectValue = Record<string, unknown>;
12
- interface Bundle { format: number; contract: string; version: string; skill: string; rule: string }
13
- interface UpdateRecord { enabled: boolean; version: string; runnerHash: string; hook: ObjectValue }
14
- export const updateContract = 'scout-recommend-v1';
15
- const registry = 'https://registry.npmjs.org/openmerit/latest';
16
- const day = 24 * 60 * 60 * 1000;
17
- const retry = 15 * 60 * 1000;
18
- const digest = (text: string | Buffer) => createHash('sha256').update(text).digest('hex');
19
- const isObject = (value: unknown): value is ObjectValue => !!value && typeof value === 'object' && !Array.isArray(value);
20
- const parsed = (text: string): ObjectValue => {
21
- const value: unknown = JSON.parse(text);
22
- if (!isObject(value)) throw new Error('Expected a JSON object.');
23
- return value;
24
- };
25
- async function optional(path: string): Promise<string | null> {
26
- try { return await readFile(path, 'utf8'); }
27
- catch (error) { if (isObject(error) && error.code === 'ENOENT') return null; throw error; }
28
- }
29
- async function object(path: string): Promise<ObjectValue> { return parsed(await optional(path) ?? '{}'); }
30
- async function atomic(path: string, value: unknown) {
31
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
32
- const temp = `${path}.${randomUUID()}.tmp`;
33
- try {
34
- await writeFile(temp, typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
35
- await rename(temp, path);
36
- } finally { await rm(temp, { force: true }); }
37
- }
38
- export function updatePaths(base: string, scope: string) {
39
- return {
40
- record: join(base, 'openmerit-install.json'), runner: join(base, 'openmerit-updater.mts'),
41
- settings: join(base, scope === 'user' ? 'settings.json' : 'settings.local.json'),
42
- state: join(base, 'openmerit-updates.json'), backup: join(base, 'openmerit-update-backup.json'),
43
- skill: join(base, 'skills/openmerit-scout/SKILL.md'), rule: join(base, 'rules/openmerit-scout.md'),
44
- };
45
- }
46
- function updateRecord(record: ObjectValue): UpdateRecord | null {
47
- const value = record.updates;
48
- if (!isObject(value) || typeof value.enabled !== 'boolean' || typeof value.version !== 'string'
49
- || typeof value.runnerHash !== 'string' || !isObject(value.hook)) return null;
50
- return value as unknown as UpdateRecord;
51
- }
52
-
53
- // Serialize installs, removals and hooks. A dead process's lock is recoverable;
54
- // an active login is not mistaken for a stale updater just because it is slow.
55
- export async function withUpdateLock<T>(base: string, work: () => Promise<T>): Promise<T> {
56
- await mkdir(base, { recursive: true, mode: 0o700 });
57
- const path = join(base, 'openmerit-update.lock');
58
- let handle;
59
- for (let attempt = 0; attempt < 2; attempt++) {
60
- try { handle = await open(path, 'wx', 0o600); break; }
61
- catch (error) {
62
- if (!isObject(error) || error.code !== 'EEXIST') throw error;
63
- // Only one process may reap a dead lock. Without this guard, two stale
64
- // observers can unlink each other's newly acquired live lock.
65
- const recoveryPath = `${path}.recovery`;
66
- let recovery;
67
- try { recovery = await open(recoveryPath, 'wx', 0o600); }
68
- catch { throw new Error('Another OpenMerit lock recovery is running. If it was interrupted, review openmerit-update.lock.recovery before removing it.'); }
69
- try {
70
- await recovery.writeFile(JSON.stringify({ pid: process.pid }));
71
- const text = await optional(path);
72
- if (text === null) continue;
73
- let alive = true;
74
- try {
75
- const pid = parsed(text).pid;
76
- if (typeof pid === 'number' && pid > 0) {
77
- try { process.kill(pid, 0); }
78
- catch (failure) { if (isObject(failure) && failure.code === 'ESRCH') alive = false; }
79
- } else alive = Date.now() - (await stat(path)).mtimeMs < 10 * 60_000;
80
- } catch { alive = Date.now() - (await stat(path)).mtimeMs < 10 * 60_000; }
81
- if (alive) throw new Error('Another OpenMerit install or update is running. Retry shortly.');
82
- await rm(path, { force: true });
83
- } finally { await recovery.close(); await rm(recoveryPath, { force: true }); }
84
- }
85
- }
86
- if (!handle) throw new Error('Another OpenMerit update is running.');
87
- try { await handle.writeFile(JSON.stringify({ pid: process.pid })); return await work(); }
88
- finally { await handle.close(); await rm(path, { force: true }); }
89
- }
90
-
91
- function groups(settings: ObjectValue): ObjectValue {
92
- if (settings.hooks === undefined) return {};
93
- if (!isObject(settings.hooks)) throw new Error('Claude settings.hooks is not an object; nothing changed.');
94
- return settings.hooks;
95
- }
96
- function eventGroups(hooks: ObjectValue, event: string): unknown[] {
97
- const value = hooks[event];
98
- if (value === undefined) return [];
99
- if (!Array.isArray(value)) throw new Error(`Claude ${event} hooks are not an array; nothing changed.`);
100
- return value;
101
- }
102
- function usesRunner(group: unknown, runner: string): boolean {
103
- return isObject(group) && Array.isArray(group.hooks) && group.hooks.some(hook =>
104
- isObject(hook) && typeof hook.command === 'string' && (hook.command.includes(runner) || hook.command.includes(quote(runner))));
105
- }
106
- export async function inspectUpdates(base: string, scope: string, record: ObjectValue) {
107
- const paths = updatePaths(base, scope), saved = updateRecord(record);
108
- const pending = updateRecord({ updates: record.pendingUpdates });
109
- const owners = [saved, pending].filter(value => value !== null);
110
- const runner = await optional(paths.runner), settings = await object(paths.settings), hooks = groups(settings);
111
- const conflicts: string[] = [];
112
- if (runner !== null && !owners.some(owner => digest(runner) === owner.runnerHash)) conflicts.push('OpenMerit updater was edited or is not owned; nothing changed.');
113
- for (const event of ['SessionStart', 'Stop']) {
114
- for (const group of eventGroups(hooks, event)) {
115
- if (usesRunner(group, paths.runner) && !owners.some(owner => JSON.stringify(group) === JSON.stringify(owner.hook))) {
116
- conflicts.push(`OpenMerit ${event} hook was edited or is not owned; nothing changed.`);
117
- }
118
- }
119
- }
120
- const active = runner !== null && ['SessionStart', 'Stop'].every(event =>
121
- eventGroups(hooks, event).some(group => owners.some(owner => JSON.stringify(group) === JSON.stringify(owner.hook))));
122
- return { paths, saved: saved ?? pending, runner, settings, hooks, conflicts, active };
123
- }
124
- // Claude Code uses a shell for command hooks, including Git Bash on Windows.
125
- const quote = (value: string) => `'${value.replaceAll("'", "'\"'\"'")}'`;
126
- export async function configureUpdates(base: string, scope: string, record: ObjectValue, version: string, enabled: boolean) {
127
- const state = await inspectUpdates(base, scope, record);
128
- if (state.conflicts.length) throw new Error(state.conflicts.join('\n'));
129
- const source = await readFile(new URL('./updates.ts', import.meta.url), 'utf8');
130
- const hook = { hooks: [{ type: 'command', command: `${quote(process.execPath)} ${quote(state.paths.runner)} --base ${quote(base)} --scope ${scope} --hook`, async: true, timeout: 150 }] };
131
- const hooks = { ...state.hooks };
132
- for (const event of ['SessionStart', 'Stop']) {
133
- const kept = eventGroups(hooks, event).filter(group => !usesRunner(group, state.paths.runner));
134
- if (enabled) kept.push(hook);
135
- if (kept.length) hooks[event] = kept; else delete hooks[event];
136
- }
137
- // Avoid clobbering settings that changed while the installer was signing in.
138
- const latest = await object(state.paths.settings);
139
- if (JSON.stringify(latest) !== JSON.stringify(state.settings)) throw new Error('Claude settings changed during setup. Retry.');
140
- const next = { enabled, version, runnerHash: digest(source), hook };
141
- await atomic(state.paths.record, { ...await object(state.paths.record), pendingUpdates: next });
142
- await atomic(state.paths.runner, source);
143
- await atomic(state.paths.settings, { ...state.settings, hooks });
144
- return next;
145
- }
146
- export async function removeUpdates(base: string, scope: string, record: ObjectValue) {
147
- const state = await inspectUpdates(base, scope, record);
148
- const hooks = { ...state.hooks };
149
- for (const event of ['SessionStart', 'Stop']) {
150
- const kept = eventGroups(hooks, event).filter(group => !state.saved || JSON.stringify(group) !== JSON.stringify(state.saved.hook));
151
- if (kept.length) hooks[event] = kept; else delete hooks[event];
152
- }
153
- if (state.saved) await atomic(state.paths.settings, { ...state.settings, hooks });
154
- const stillReferenced = ['SessionStart', 'Stop'].some(event => eventGroups(hooks, event).some(group => usesRunner(group, state.paths.runner)));
155
- if (!stillReferenced && state.runner !== null && state.saved && digest(state.runner) === state.saved.runnerHash) await rm(state.paths.runner, { force: true });
156
- await rm(state.paths.state, { force: true });
157
- }
158
-
159
- // Read one fixed regular file in memory, never extract archive paths onto disk.
160
- function instructionBundle(bytes: Buffer): ObjectValue | null {
161
- const tar = gunzipSync(bytes, { maxOutputLength: 32 * 1024 * 1024 });
162
- let found: ObjectValue | null = null;
163
- for (let offset = 0; offset + 512 <= tar.length;) {
164
- const header = tar.subarray(offset, offset + 512);
165
- if (header.every(byte => byte === 0)) break;
166
- const name = header.subarray(0, 100).toString().replace(/\0.*$/s, '');
167
- const rawSize = header.subarray(124, 136).toString().replace(/\0.*$/s, '').trim();
168
- if (!/^[0-7]+$/.test(rawSize)) throw new Error('Invalid package archive.');
169
- const size = Number.parseInt(rawSize, 8), start = offset + 512;
170
- if (start + size > tar.length) throw new Error('Truncated package archive.');
171
- if (name === 'package/dist/terminal/scout-update.json') {
172
- if (found || size > 256 * 1024 || ![0, 48].includes(header[156] ?? -1)) throw new Error('Invalid instruction bundle.');
173
- found = parsed(tar.subarray(start, start + size).toString('utf8'));
174
- }
175
- offset = start + Math.ceil(size / 512) * 512;
176
- }
177
- return found;
178
- }
179
- const stableVersion = (version: string) => /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version);
180
- function newer(next: string, installed: string) {
181
- if (!stableVersion(next) || !stableVersion(installed)) return false;
182
- const a = next.split('.').map(Number), b = installed.split('.').map(Number);
183
- for (let i = 0; i < 3; i++) { if (a[i] !== b[i]) return a[i]! > b[i]!; }
184
- return false;
185
- }
186
- async function boundedFetch(url: string, max: number, fetcher: typeof fetch, timeout = 12_000): Promise<Buffer> {
187
- const controller = new AbortController();
188
- let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
189
- let timer: ReturnType<typeof setTimeout> | undefined;
190
- const deadline = new Promise<never>((_, reject) => {
191
- timer = setTimeout(() => { controller.abort(); reject(new Error('Update download timed out.')); }, timeout);
192
- });
193
- const download = async () => {
194
- const response = await fetcher(url, { redirect: 'error', signal: controller.signal, headers: { Accept: 'application/json' } });
195
- if (!response.ok || !response.body || controller.signal.aborted) throw new Error('Update server unavailable.');
196
- const parts: Uint8Array[] = []; let size = 0;
197
- reader = response.body.getReader();
198
- for (;;) {
199
- const { value, done } = await reader.read();
200
- if (done) break;
201
- size += value.length;
202
- if (size > max) throw new Error('Update download exceeded its size limit.');
203
- parts.push(value);
204
- }
205
- return Buffer.concat(parts);
206
- };
207
- try {
208
- return await Promise.race([download(), deadline]);
209
- } finally {
210
- clearTimeout(timer); controller.abort();
211
- // Cancellation itself must not keep a timed-out hook alive.
212
- void reader?.cancel().catch(() => {});
213
- }
214
- }
215
- async function recover(base: string, scope: string) {
216
- const paths = updatePaths(base, scope), text = await optional(paths.backup);
217
- if (!text) return;
218
- const backup = parsed(text);
219
- if (typeof backup.skill !== 'string' || typeof backup.rule !== 'string' || !isObject(backup.record)) throw new Error('Update recovery needs manual review.');
220
- for (const field of ['skill', 'rule'] as const) {
221
- const current = await optional(paths[field]);
222
- if (current !== null && digest(current) !== digest(backup[field] as string) && digest(current) !== backup[`${field}Hash`]) throw new Error('An interrupted update has user edits; nothing changed.');
223
- }
224
- // Recover the previous complete pair before any retry. Atomic per-file writes
225
- // plus a durable journal cover failure between the two renames.
226
- await atomic(paths.skill, backup.skill); await atomic(paths.rule, backup.rule);
227
- await atomic(paths.record, backup.record); await rm(paths.backup, { force: true });
228
- }
229
- export async function recoverUpdates(base: string, scope: string) { await recover(base, scope); }
230
-
231
- export interface UpdateResult { status: string; message: string; version?: string }
232
- export async function runUpdate(base: string, scope: string, options: { hook?: boolean; enabled?: boolean; fetcher?: typeof fetch; now?: number } = {}): Promise<UpdateResult> {
233
- return withUpdateLock(base, async () => {
234
- await recover(base, scope);
235
- const paths = updatePaths(base, scope), record = await object(paths.record), saved = updateRecord(record);
236
- if (record.version !== 3 || record.scope !== scope || !saved) return { status: 'manual_required', message: 'Run npx -y openmerit@latest install --client claude-code to bootstrap updates in this scope.' };
237
- if (options.hook && (!saved.enabled || process.env.OPENMERIT_AUTO_UPDATE === 'off')) return { status: 'disabled', message: 'Automatic updates disabled.' };
238
- const now = options.now ?? Date.now(), state = options.enabled === undefined ? await object(paths.state) : {};
239
- if (options.hook && typeof state.nextCheckAt === 'number' && now < state.nextCheckAt) return { status: 'not_due', message: 'Update check not due.' };
240
- const support = await inspectUpdates(base, scope, record);
241
- if (support.conflicts.length) {
242
- if (options.hook) await atomic(paths.state, { nextCheckAt: now + day, status: 'conflict' });
243
- return { status: 'conflict', message: `${support.conflicts.join(' ')} Say “update openmerit” after resolving the conflict.` };
244
- }
245
- if (options.enabled !== undefined) {
246
- record.updates = await configureUpdates(base, scope, record, saved.version, options.enabled);
247
- delete record.pendingUpdates;
248
- await atomic(paths.record, record);
249
- return { status: options.enabled ? 'enabled' : 'disabled', message: `Automatic Scout instruction updates ${options.enabled ? 'enabled' : 'disabled'}. Start a new Claude Code session to reload hooks.` };
250
- }
251
- // Record the attempt before networking so a crashed hook cannot hammer npm.
252
- await atomic(paths.state, { ...state, nextCheckAt: now + retry });
253
- try {
254
- const fetcher = options.fetcher ?? fetch;
255
- const release = parsed((await boundedFetch(registry, 512 * 1024, fetcher)).toString('utf8'));
256
- const version = release.version;
257
- if (release.name !== 'openmerit' || typeof version !== 'string' || !stableVersion(version)) throw new Error('Invalid stable release metadata.');
258
- if (!newer(version, saved.version)) {
259
- await atomic(paths.state, { checkedAt: now, nextCheckAt: now + day, status: 'current' });
260
- return { status: 'current', version: saved.version, message: `Scout instructions ${saved.version} are current.` };
261
- }
262
- const dist = release.dist;
263
- const tarball = `https://registry.npmjs.org/openmerit/-/openmerit-${version}.tgz`;
264
- if (!isObject(dist) || dist.tarball !== tarball || typeof dist.integrity !== 'string' || !/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(dist.integrity)) throw new Error('Release has no trusted SHA-512 package integrity.');
265
- const bytes = await boundedFetch(tarball, 16 * 1024 * 1024, fetcher, 120_000);
266
- if (`sha512-${createHash('sha512').update(bytes).digest('base64')}` !== dist.integrity) throw new Error('Package integrity verification failed.');
267
- const bundle = instructionBundle(bytes);
268
- if (!bundle || bundle.format !== 1 || bundle.contract !== updateContract || bundle.version !== version) {
269
- await atomic(paths.state, { checkedAt: now, nextCheckAt: now + day, status: 'manual_required' });
270
- return { status: 'manual_required', version, message: `Release ${version} needs a reviewed installer upgrade. Review its changes, then run npx -y openmerit@latest install --client claude-code --scope ${scope}. Do not bypass permissions or rerun login automatically.` };
271
- }
272
- if (typeof bundle.skill !== 'string' || typeof bundle.rule !== 'string' || !bundle.skill.startsWith('---\nname: openmerit-scout\n') || !bundle.rule.trim()) throw new Error('Invalid Scout instructions.');
273
- const instructions = bundle as unknown as Bundle;
274
- const skill = await optional(paths.skill), rule = await optional(paths.rule);
275
- if (skill === null || rule === null || digest(skill) !== record.skillHash || digest(rule) !== record.ruleHash) {
276
- await atomic(paths.state, { checkedAt: now, nextCheckAt: now + day, status: 'conflict' });
277
- return { status: 'conflict', message: 'Scout instructions are missing or edited. Nothing changed. Say “update openmerit” after reviewing the files.' };
278
- }
279
- const skillHash = digest(instructions.skill), ruleHash = digest(instructions.rule);
280
- await atomic(paths.backup, { record, skill, rule, skillHash, ruleHash });
281
- try {
282
- // Do not overwrite edits made during the download or journal write.
283
- if (await optional(paths.skill) !== skill || await optional(paths.rule) !== rule) throw new Error('Scout instructions changed during the update.');
284
- await atomic(paths.skill, instructions.skill); await atomic(paths.rule, instructions.rule);
285
- await atomic(paths.record, { ...record, skillHash, ruleHash, updates: { ...saved, version } });
286
- await rm(paths.backup, { force: true });
287
- } catch (error) {
288
- try { await recover(base, scope); }
289
- catch { return { status: 'conflict', message: 'An interrupted Scout update could not be recovered safely. Review the installed files and openmerit-update-backup.json; no user edits were overwritten.' }; }
290
- throw error;
291
- }
292
- // A failed schedule write must not misreport an already applied update.
293
- await atomic(paths.state, { checkedAt: now, nextCheckAt: now + day, status: 'updated', version }).catch(() => {});
294
- return { status: 'updated', version, message: `Scout instructions updated to ${version}. Start a new Claude Code session to load them; the running session may still use its previous instructions.` };
295
- } catch {
296
- await atomic(paths.state, { nextCheckAt: now + retry, status: 'retry' });
297
- return { status: 'retry', message: 'Could not safely update Scout. The installed version was retained. Automatic checks retry after 15 minutes while Claude Code is in use; say “update openmerit” to retry now.' };
298
- }
299
- });
300
- }
301
-
302
- // Hooks deliberately never ask Claude to continue, read transcripts or emit
303
- // blocking decisions. Offline and concurrent-hook failures exit successfully.
304
- if (process.argv[1] && realpathSync(resolve(process.argv[1])) === fileURLToPath(import.meta.url)) {
305
- const args = process.argv.slice(2), base = args[args.indexOf('--base') + 1], scope = args[args.indexOf('--scope') + 1];
306
- if (base && ['user', 'project'].includes(scope ?? '')) {
307
- process.stdin.resume();
308
- try {
309
- const result = await runUpdate(resolve(base), scope!, { hook: args.includes('--hook') });
310
- if (['updated', 'conflict', 'manual_required', 'retry'].includes(result.status)) console.log(JSON.stringify({ systemMessage: result.message }));
311
- } catch { /* Maintenance cannot block the user's task. */ }
312
- }
313
- }