mythos-sentinel 0.1.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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +362 -0
  3. package/action.yml +43 -0
  4. package/assets/banner.png +0 -0
  5. package/bin/mythos-sentinel-mcp.js +7 -0
  6. package/bin/mythos-sentinel.js +8 -0
  7. package/docs/ARCHITECTURE.md +55 -0
  8. package/docs/BASE_X402.md +33 -0
  9. package/docs/BAZAAR_ADAPTER.md +41 -0
  10. package/docs/DASHBOARD.md +22 -0
  11. package/docs/FALLBACK_ROUTING.md +37 -0
  12. package/docs/MCP.md +70 -0
  13. package/docs/PASSIVE_SCORING.md +33 -0
  14. package/docs/ROUTESCORE.md +101 -0
  15. package/docs/RUNTIME_MCP_PROXY.md +90 -0
  16. package/docs/SPEND_FIREWALL.md +50 -0
  17. package/docs/TELEMETRY.md +74 -0
  18. package/docs/THREAT_MODEL.md +28 -0
  19. package/docs/X402_RECEIPTS.md +54 -0
  20. package/examples/base/mythos.policy.json +142 -0
  21. package/examples/claude_desktop/mcp.json +8 -0
  22. package/examples/codex/AGENTS.md +31 -0
  23. package/examples/cursor/mcp.json +8 -0
  24. package/examples/github/verify.yml +29 -0
  25. package/examples/routescore/services.yml +19 -0
  26. package/examples/skill/mythos.skill.json +20 -0
  27. package/package.json +79 -0
  28. package/schemas/agent-receipt.schema.json +17 -0
  29. package/schemas/policy.schema.json +322 -0
  30. package/schemas/sentinel-report.schema.json +14 -0
  31. package/schemas/skill.manifest.schema.json +42 -0
  32. package/src/cli.js +570 -0
  33. package/src/core/fs.js +88 -0
  34. package/src/core/path-utils.js +54 -0
  35. package/src/core/policy.js +326 -0
  36. package/src/core/receipt.js +52 -0
  37. package/src/core/routescore.js +576 -0
  38. package/src/core/snapshot.js +35 -0
  39. package/src/core/telemetry.js +214 -0
  40. package/src/core/x402-receipts.js +303 -0
  41. package/src/index.js +19 -0
  42. package/src/mcp/proxy.js +493 -0
  43. package/src/mcp/server.js +226 -0
  44. package/src/report/format.js +53 -0
  45. package/src/report/sarif.js +50 -0
  46. package/src/scanner/rules.js +185 -0
  47. package/src/scanner/scan.js +118 -0
  48. package/src/ui/server.js +346 -0
  49. package/src/ui/static/app.js +210 -0
  50. package/src/ui/static/index.html +342 -0
  51. package/src/ui/static/styles.css +904 -0
  52. package/src/version.js +2 -0
@@ -0,0 +1,576 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { exists, ensureDir, writeJson, readJson } from './fs.js';
4
+ import { domainMatches, normalizeDomain } from './policy.js';
5
+
6
+ export const ROUTESCORE_VERSION = '0.10';
7
+ export const ROUTESCORE_DIR = '.mythos/routescore';
8
+ export const ROUTESCORE_SERVICES_FILE = 'services.json';
9
+ export const CDP_BAZAAR_RESOURCES_URL = 'https://api.cdp.coinbase.com/platform/v2/x402/discovery/resources';
10
+ export const CDP_BAZAAR_SEARCH_URL = 'https://api.cdp.coinbase.com/platform/v2/x402/discovery/search';
11
+
12
+ export const SERVICE_CATEGORIES = Object.freeze({
13
+ web_search: { label: 'Web search', aliases: ['search', 'serp', 'research'] },
14
+ content_extraction: { label: 'Content extraction', aliases: ['content', 'extract', 'reader', 'crawl', 'scrape'] },
15
+ browser: { label: 'Browser automation', aliases: ['browser_session', 'browserbase', 'navigate'] },
16
+ scraping: { label: 'Scraping', aliases: ['scraper', 'crawler'] },
17
+ market_data: { label: 'Market data', aliases: ['prices', 'quotes', 'news', 'ticker'] },
18
+ wallet_intel: { label: 'Wallet intelligence', aliases: ['wallet', 'risk', 'holders', 'address_intel'] },
19
+ web3_data: { label: 'Web3 data', aliases: ['rpc', 'chain_data', 'contract_data', 'token_data'] },
20
+ inference: { label: 'AI inference', aliases: ['llm', 'model', 'chat', 'completion'] },
21
+ image_generation: { label: 'Image generation', aliases: ['image', 'vision', 'creative'] },
22
+ code_execution: { label: 'Code execution', aliases: ['code', 'sandbox', 'runtime'] },
23
+ storage: { label: 'Storage', aliases: ['files', 'blob', 'ipfs'] },
24
+ identity: { label: 'Identity', aliases: ['auth', 'attestation', 'profile'] },
25
+ messaging: { label: 'Messaging', aliases: ['email', 'sms', 'notifications'] },
26
+ payments: { label: 'Payments', aliases: ['payment', 'settlement', 'wallet'] },
27
+ general: { label: 'General', aliases: ['utility', 'tool'] }
28
+ });
29
+
30
+ export function listServiceCategories() {
31
+ return Object.entries(SERVICE_CATEGORIES).map(([id, meta]) => ({ id, ...meta }));
32
+ }
33
+
34
+ export const seedX402Services = Object.freeze([
35
+ {
36
+ id: 'exa-search',
37
+ name: 'Exa Search',
38
+ category: 'web_search',
39
+ domain: 'api.exa.ai',
40
+ endpoint: 'https://api.exa.ai/search',
41
+ network: 'base',
42
+ priceUSDC: 0.005,
43
+ status: 'seed',
44
+ source: 'seed',
45
+ tags: ['search', 'research', 'agent-memory'],
46
+ note: 'Documented x402 search endpoint. Use as a seed service; refresh live metadata from Bazaar in production.'
47
+ },
48
+ {
49
+ id: 'exa-contents',
50
+ name: 'Exa Contents',
51
+ category: 'content_extraction',
52
+ domain: 'api.exa.ai',
53
+ endpoint: 'https://api.exa.ai/contents',
54
+ network: 'base',
55
+ priceUSDC: 0.01,
56
+ status: 'seed',
57
+ source: 'seed',
58
+ tags: ['content', 'url-extraction', 'research'],
59
+ note: 'Documented x402 contents endpoint. Good first class for schema and latency checks.'
60
+ },
61
+ {
62
+ id: 'venice-inference',
63
+ name: 'Venice AI',
64
+ category: 'inference',
65
+ domain: 'api.venice.ai',
66
+ endpoint: 'https://api.venice.ai',
67
+ network: 'base',
68
+ priceUSDC: 0.02,
69
+ status: 'watchlist',
70
+ source: 'seed',
71
+ tags: ['inference', 'image', 'code'],
72
+ note: 'Ecosystem-listed service. Treat as watchlist until live x402 metadata is verified.'
73
+ },
74
+ {
75
+ id: 'alchemy-web3',
76
+ name: 'Alchemy Web3 API',
77
+ category: 'web3_data',
78
+ domain: 'alchemy.com',
79
+ endpoint: 'https://alchemy.com',
80
+ network: 'base',
81
+ priceUSDC: 0.02,
82
+ status: 'watchlist',
83
+ source: 'seed',
84
+ tags: ['rpc', 'web3-data', 'base'],
85
+ note: 'Ecosystem-listed service. Replace with live payable endpoint from Bazaar before production routing.'
86
+ },
87
+ {
88
+ id: 'nansen-wallet-intel',
89
+ name: 'Nansen Wallet Intelligence',
90
+ category: 'wallet_intel',
91
+ domain: 'nansen.ai',
92
+ endpoint: 'https://nansen.ai',
93
+ network: 'base',
94
+ priceUSDC: 0.05,
95
+ status: 'watchlist',
96
+ source: 'seed',
97
+ tags: ['wallets', 'analytics', 'risk'],
98
+ note: 'Ecosystem-listed service. Useful category for future spend decisions; verify endpoint before auto-routing.'
99
+ }
100
+ ]);
101
+
102
+ const DEFAULT_TELEMETRY = Object.freeze({
103
+ successRate: 0.92,
104
+ schemaSuccessRate: 0.9,
105
+ medianLatencyMs: 1200,
106
+ quoteLatencyMs: 220,
107
+ samples: 0,
108
+ lastCheckedMinutesAgo: null,
109
+ priceMatchedQuote: true,
110
+ recentFailureCount: 0
111
+ });
112
+
113
+ export function routeScoreServicesPath(rootDir = process.cwd()) {
114
+ return path.join(rootDir, ROUTESCORE_DIR, ROUTESCORE_SERVICES_FILE);
115
+ }
116
+
117
+ export async function loadCustomServices({ rootDir = process.cwd(), filePath } = {}) {
118
+ const target = filePath ? path.resolve(rootDir, filePath) : routeScoreServicesPath(rootDir);
119
+ if (!(await exists(target))) return [];
120
+ const data = await readJson(target);
121
+ return normalizeServiceList(Array.isArray(data) ? data : data.services || [], { source: 'custom' });
122
+ }
123
+
124
+ export async function saveCustomServices(services, { rootDir = process.cwd(), filePath, replace = true } = {}) {
125
+ const target = filePath ? path.resolve(rootDir, filePath) : routeScoreServicesPath(rootDir);
126
+ const normalized = normalizeServiceList(services, { source: 'custom' });
127
+ let next = normalized;
128
+ if (!replace && await exists(target)) {
129
+ next = mergeServices(await loadCustomServices({ rootDir, filePath }), normalized);
130
+ }
131
+ await ensureDir(path.dirname(target));
132
+ await writeJson(target, {
133
+ version: ROUTESCORE_VERSION,
134
+ generatedAt: new Date().toISOString(),
135
+ services: next
136
+ });
137
+ return { ok: true, path: target, services: next, count: next.length };
138
+ }
139
+
140
+ export async function loadRouteScoreServices({ rootDir = process.cwd(), includeSeed = true, filePath } = {}) {
141
+ const custom = await loadCustomServices({ rootDir, filePath });
142
+ return includeSeed ? mergeServices(seedX402Services, custom) : custom;
143
+ }
144
+
145
+ export async function importServicesFile(filePath, { rootDir = process.cwd(), source = 'custom' } = {}) {
146
+ const absolute = path.resolve(rootDir, filePath);
147
+ const raw = await fs.readFile(absolute, 'utf8');
148
+ let parsed;
149
+ if (/\.ya?ml$/i.test(absolute)) parsed = parseSimpleYaml(raw);
150
+ else parsed = JSON.parse(raw);
151
+ const services = Array.isArray(parsed) ? parsed : parsed.services || [];
152
+ return normalizeServiceList(services, { source });
153
+ }
154
+
155
+ export function mergeServices(...groups) {
156
+ const byKey = new Map();
157
+ for (const group of groups.flat()) {
158
+ const service = normalizeService(group);
159
+ const key = service.id || service.endpoint || service.domain;
160
+ if (!key) continue;
161
+ byKey.set(key, { ...byKey.get(key), ...service });
162
+ }
163
+ return [...byKey.values()].sort((a, b) => serviceSortKey(a).localeCompare(serviceSortKey(b)));
164
+ }
165
+
166
+ export function serviceForDomain(domain, services = seedX402Services) {
167
+ const normalized = normalizeDomain(domain);
168
+ return services.find((service) => domainMatches(normalized, service.domain));
169
+ }
170
+
171
+ export function scoreService(service, telemetry = {}) {
172
+ const normalizedService = normalizeService(service);
173
+ const data = { ...DEFAULT_TELEMETRY, ...telemetry };
174
+ let score = 55;
175
+ const reasons = [];
176
+
177
+ if (normalizedService.status === 'seed') {
178
+ score += 10;
179
+ reasons.push('seed service with documented category metadata');
180
+ } else if (normalizedService.status === 'bazaar') {
181
+ score += 6;
182
+ reasons.push('live Bazaar catalog service');
183
+ } else if (normalizedService.status === 'custom') {
184
+ score += 3;
185
+ reasons.push('local custom catalog service');
186
+ } else if (normalizedService.status === 'watchlist') {
187
+ score -= 8;
188
+ reasons.push('watchlist service; verify payable endpoint before production routing');
189
+ }
190
+
191
+ if (normalizedService.hasPaymentMetadata) {
192
+ score += 4;
193
+ reasons.push('payment metadata present');
194
+ }
195
+ if (normalizedService.hasSchema) {
196
+ score += 4;
197
+ reasons.push('input/output schema metadata present');
198
+ }
199
+
200
+ if (Number.isFinite(data.successRate)) {
201
+ score += Math.round((data.successRate - 0.8) * 65);
202
+ reasons.push(`success rate ${(data.successRate * 100).toFixed(1)}%`);
203
+ }
204
+ if (Number.isFinite(data.schemaSuccessRate)) {
205
+ score += Math.round((data.schemaSuccessRate - 0.85) * 35);
206
+ reasons.push(`schema match ${(data.schemaSuccessRate * 100).toFixed(1)}%`);
207
+ }
208
+ if (Number.isFinite(data.medianLatencyMs)) {
209
+ if (data.medianLatencyMs <= 1000) score += 8;
210
+ else if (data.medianLatencyMs <= 2500) score += 2;
211
+ else score -= 8;
212
+ reasons.push(`median latency ${Math.round(data.medianLatencyMs)}ms`);
213
+ }
214
+ if (Number.isFinite(data.quoteLatencyMs)) {
215
+ if (data.quoteLatencyMs <= 400) score += 4;
216
+ else if (data.quoteLatencyMs > 1500) score -= 6;
217
+ reasons.push(`quote latency ${Math.round(data.quoteLatencyMs)}ms`);
218
+ }
219
+ if (data.priceMatchedQuote === false) {
220
+ score -= 25;
221
+ reasons.push('price mismatch observed');
222
+ }
223
+ if (Number(data.recentFailureCount) > 0) {
224
+ score -= Math.min(30, Number(data.recentFailureCount) * 7);
225
+ reasons.push(`${data.recentFailureCount} recent failures`);
226
+ }
227
+ if (Number(data.samples) === 0) {
228
+ score -= 6;
229
+ reasons.push('no passive routed-call samples yet');
230
+ } else {
231
+ score += Math.min(12, Math.floor(Number(data.samples) / 10));
232
+ reasons.push(`${data.samples} passive samples`);
233
+ }
234
+
235
+ const bounded = Math.max(0, Math.min(100, score));
236
+ return {
237
+ ...normalizedService,
238
+ score: bounded,
239
+ risk: scoreToRisk(bounded),
240
+ reasons,
241
+ telemetry: data,
242
+ recommendation: recommendationForScore(bounded)
243
+ };
244
+ }
245
+
246
+ export function recommendService({ category, maxPriceUSDC, services = seedX402Services, telemetry = {}, query } = {}) {
247
+ const maxPrice = maxPriceUSDC === undefined || maxPriceUSDC === null || maxPriceUSDC === '' ? Infinity : Number(maxPriceUSDC);
248
+ const terms = String(query || '').toLowerCase().split(/\s+/).filter(Boolean);
249
+ const candidates = services
250
+ .map((service) => normalizeService(service))
251
+ .filter((service) => !category || service.category === category)
252
+ .filter((service) => !Number.isFinite(maxPrice) || service.priceUSDC <= maxPrice)
253
+ .filter((service) => !terms.length || terms.every((term) => serviceSearchText(service).includes(term)))
254
+ .map((service) => scoreService(service, telemetry[service.id] || {}))
255
+ .sort((a, b) => b.score - a.score || a.priceUSDC - b.priceUSDC || serviceSortKey(a).localeCompare(serviceSortKey(b)));
256
+
257
+ return {
258
+ ok: candidates.length > 0,
259
+ category: category || 'any',
260
+ query: query || null,
261
+ maxPriceUSDC: Number.isFinite(maxPrice) ? maxPrice : null,
262
+ best: candidates[0] || null,
263
+ alternatives: candidates.slice(1, 5),
264
+ checkedAt: new Date().toISOString(),
265
+ note: candidates.length ? 'Use RouteScore as a pre-spend signal, not a guarantee of output quality.' : 'No service matched the requested category/price/query.'
266
+ };
267
+ }
268
+
269
+ export function routeService({ category, maxPriceUSDC, services = seedX402Services, telemetry = {}, query, minScore = 0 } = {}) {
270
+ const rec = recommendService({ category, maxPriceUSDC, services, telemetry, query });
271
+ const candidates = [rec.best, ...rec.alternatives].filter(Boolean).filter((service) => service.score >= Number(minScore || 0));
272
+ const selected = candidates[0] || null;
273
+ return {
274
+ ok: Boolean(selected),
275
+ selected,
276
+ fallbacks: candidates.slice(1, 4),
277
+ category: rec.category,
278
+ query: rec.query,
279
+ maxPriceUSDC: rec.maxPriceUSDC,
280
+ checkedAt: rec.checkedAt,
281
+ mode: 'recommend_and_fallback_plan',
282
+ note: selected
283
+ ? 'RouteScore selected a service and fallback plan. Sentinel still enforces payment policy before spend.'
284
+ : 'No service met the requested route constraints.'
285
+ };
286
+ }
287
+
288
+ export async function executeFallbackRoute({ plan, category, maxPriceUSDC, services = seedX402Services, telemetry = {}, query, minScore = 0, executor, onAttempt } = {}) {
289
+ const routePlan = plan || routeService({ category, maxPriceUSDC, services, telemetry, query, minScore });
290
+ if (!routePlan.selected) return { ok: false, plan: routePlan, attempts: [], error: 'no_route_available' };
291
+ if (typeof executor !== 'function') throw new Error('executeFallbackRoute requires an executor(service, attempt) function.');
292
+
293
+ const order = [routePlan.selected, ...(routePlan.fallbacks || [])];
294
+ const attempts = [];
295
+ for (let index = 0; index < order.length; index++) {
296
+ const service = order[index];
297
+ const started = Date.now();
298
+ try {
299
+ const result = await executor(service, { index, primary: index === 0, plan: routePlan });
300
+ const latencyMs = Date.now() - started;
301
+ const ok = result?.ok !== false;
302
+ const attempt = { service, index, primary: index === 0, ok, latencyMs, error: result?.error || null, result: result?.result ?? result };
303
+ attempts.push(attempt);
304
+ if (onAttempt) await onAttempt(attempt);
305
+ if (ok) return { ok: true, selected: service, result: attempt.result, attempts, plan: routePlan, fallbackUsed: index > 0 };
306
+ } catch (error) {
307
+ const attempt = { service, index, primary: index === 0, ok: false, latencyMs: Date.now() - started, error: error.message };
308
+ attempts.push(attempt);
309
+ if (onAttempt) await onAttempt(attempt);
310
+ }
311
+ }
312
+ return { ok: false, selected: null, attempts, plan: routePlan, error: 'all_routes_failed' };
313
+ }
314
+
315
+ export function passiveTelemetryEvent({ serviceId, domain, ok, latencyMs, schemaOk = true, priceMatchedQuote = true, amountUSDC, errorType, category, endpoint, source, decision, mode, upstream, tool } = {}) {
316
+ return {
317
+ serviceId,
318
+ domain: normalizeDomain(domain || endpoint),
319
+ endpoint: endpoint || null,
320
+ category: category || null,
321
+ source: source || null,
322
+ mode: mode || null,
323
+ upstream: upstream || null,
324
+ tool: tool || null,
325
+ decision: decision || null,
326
+ ok: ok === null || ok === undefined ? null : Boolean(ok),
327
+ latencyMs: Number(latencyMs || 0),
328
+ schemaOk: schemaOk === null || schemaOk === undefined ? null : Boolean(schemaOk),
329
+ priceMatchedQuote: priceMatchedQuote === null || priceMatchedQuote === undefined ? null : Boolean(priceMatchedQuote),
330
+ amountUSDC: Number(amountUSDC || 0),
331
+ errorType: errorType || null,
332
+ observedAt: new Date().toISOString(),
333
+ privacy: 'anonymous endpoint telemetry only; no prompts, responses, secrets, or wallet balances'
334
+ };
335
+ }
336
+
337
+ export async function fetchBazaarResources({ limit = 100, offset = 0, type = 'http', baseUrl = CDP_BAZAAR_RESOURCES_URL, fetchImpl = globalThis.fetch } = {}) {
338
+ if (typeof fetchImpl !== 'function') throw new Error('fetch is not available in this Node runtime. Use Node.js 20+ or pass fetchImpl.');
339
+ const url = new URL(baseUrl);
340
+ if (type) url.searchParams.set('type', type);
341
+ if (limit) url.searchParams.set('limit', String(limit));
342
+ if (offset) url.searchParams.set('offset', String(offset));
343
+ const res = await fetchImpl(url);
344
+ if (!res.ok) throw new Error(`Bazaar resources request failed: ${res.status} ${res.statusText}`);
345
+ const json = await res.json();
346
+ const services = normalizeBazaarPayload(json, { source: 'bazaar' });
347
+ return { ok: true, url: url.toString(), services, rawCount: Array.isArray(json.items) ? json.items.length : services.length, pagination: json.pagination || null, fetchedAt: new Date().toISOString() };
348
+ }
349
+
350
+ export async function fetchBazaarSearch({ query = '', limit = 20, network, asset, baseUrl = CDP_BAZAAR_SEARCH_URL, fetchImpl = globalThis.fetch } = {}) {
351
+ if (typeof fetchImpl !== 'function') throw new Error('fetch is not available in this Node runtime. Use Node.js 20+ or pass fetchImpl.');
352
+ const url = new URL(baseUrl);
353
+ if (query) url.searchParams.set('query', query);
354
+ if (limit) url.searchParams.set('limit', String(limit));
355
+ if (network) url.searchParams.set('network', network);
356
+ if (asset) url.searchParams.set('asset', asset);
357
+ const res = await fetchImpl(url);
358
+ if (!res.ok) throw new Error(`Bazaar search request failed: ${res.status} ${res.statusText}`);
359
+ const json = await res.json();
360
+ const services = normalizeBazaarPayload(json, { source: 'bazaar' });
361
+ return { ok: true, url: url.toString(), services, searchMethod: json.searchMethod || null, partialResults: Boolean(json.partialResults), fetchedAt: new Date().toISOString() };
362
+ }
363
+
364
+ export function normalizeBazaarPayload(payload = {}, { source = 'bazaar' } = {}) {
365
+ const items = payload.items || payload.resources || payload.results || [];
366
+ return normalizeServiceList(items, { source });
367
+ }
368
+
369
+ export function normalizeServiceList(services = [], { source = 'custom' } = {}) {
370
+ return services.map((service, index) => normalizeService(service, { source, index })).filter((service) => service.endpoint && service.domain);
371
+ }
372
+
373
+ export function normalizeService(service = {}, { source, index = 0 } = {}) {
374
+ const inferredSource = source || service.source || (service.resource ? 'bazaar' : 'custom');
375
+ const endpoint = service.endpoint || service.resource || service.url || service.uri || '';
376
+ const domain = normalizeDomain(service.domain || endpoint);
377
+ const metadata = service.metadata || service.meta || {};
378
+ const accepts = Array.isArray(service.accepts) ? service.accepts : [];
379
+ const name = service.name || metadata.name || metadata.title || readableName(domain, endpoint, index);
380
+ const category = normalizeCategory(service.category || metadata.category || inferCategory(`${name} ${endpoint} ${metadata.description || ''} ${(metadata.tags || []).join?.(' ') || ''}`));
381
+ const priceUSDC = Number.isFinite(Number(service.priceUSDC)) ? Number(service.priceUSDC) : inferPriceUSDC(accepts);
382
+ const tags = Array.isArray(service.tags) ? service.tags : Array.isArray(metadata.tags) ? metadata.tags : categoryTags(category);
383
+ const id = slugify(service.id || `${inferredSource}-${domain}-${URL_SAFE_PATH(endpoint)}`) || `service-${index}`;
384
+ return {
385
+ id,
386
+ name,
387
+ category,
388
+ domain,
389
+ endpoint,
390
+ network: normalizeNetwork(service.network || accepts[0]?.network || 'base'),
391
+ priceUSDC,
392
+ status: service.status || (inferredSource === 'bazaar' ? 'bazaar' : inferredSource === 'seed' ? service.status || 'seed' : 'custom'),
393
+ source: inferredSource,
394
+ tags,
395
+ note: service.note || metadata.description || `${category} service from ${inferredSource} catalog.`,
396
+ accepts,
397
+ hasPaymentMetadata: Boolean(accepts.length),
398
+ hasSchema: Boolean(metadata.input || metadata.output || metadata.inputSchema || metadata.outputSchema || service.inputSchema || service.outputSchema),
399
+ lastUpdated: service.lastUpdated || service.updatedAt || service.fetchedAt || null
400
+ };
401
+ }
402
+
403
+ export function parseSimpleYaml(raw) {
404
+ const lines = raw.split(/\r?\n/);
405
+ const services = [];
406
+ let current = null;
407
+ let inServices = false;
408
+ let pendingArrayKey = null;
409
+
410
+ for (const rawLine of lines) {
411
+ const line = stripYamlComment(rawLine).trimEnd();
412
+ if (!line.trim()) continue;
413
+ const trimmed = line.trim();
414
+ if (trimmed === 'services:') { inServices = true; continue; }
415
+ if (!inServices && !trimmed.startsWith('- ')) continue;
416
+
417
+ if (trimmed.startsWith('- ')) {
418
+ const rest = trimmed.slice(2).trim();
419
+ if (rest.includes(':')) {
420
+ if (current) services.push(current);
421
+ current = {};
422
+ pendingArrayKey = null;
423
+ const [key, ...valueParts] = rest.split(':');
424
+ const value = valueParts.join(':').trim();
425
+ current[key.trim()] = parseYamlValue(value);
426
+ } else if (pendingArrayKey && current) {
427
+ current[pendingArrayKey].push(parseYamlValue(rest));
428
+ }
429
+ continue;
430
+ }
431
+
432
+ if (!current || !trimmed.includes(':')) continue;
433
+ const [key, ...valueParts] = trimmed.split(':');
434
+ const cleanKey = key.trim();
435
+ const value = valueParts.join(':').trim();
436
+ if (value === '') {
437
+ current[cleanKey] = [];
438
+ pendingArrayKey = cleanKey;
439
+ } else {
440
+ current[cleanKey] = parseYamlValue(value);
441
+ pendingArrayKey = null;
442
+ }
443
+ }
444
+ if (current) services.push(current);
445
+ return { services };
446
+ }
447
+
448
+ function stripYamlComment(line) {
449
+ const value = String(line || '');
450
+ const commentIndex = value.indexOf('#');
451
+ return commentIndex === -1 ? value : value.slice(0, commentIndex);
452
+ }
453
+
454
+ function parseYamlValue(value) {
455
+ const trimmed = String(value || '').trim();
456
+ if (!trimmed) return '';
457
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) return trimmed.slice(1, -1);
458
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) return trimmed.slice(1, -1).split(',').map((item) => parseYamlValue(item)).filter(Boolean);
459
+ if (trimmed === 'true') return true;
460
+ if (trimmed === 'false') return false;
461
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
462
+ return trimmed;
463
+ }
464
+
465
+ function scoreToRisk(score) {
466
+ if (score >= 85) return 'low';
467
+ if (score >= 70) return 'medium';
468
+ if (score >= 50) return 'elevated';
469
+ return 'high';
470
+ }
471
+
472
+ function recommendationForScore(score) {
473
+ if (score >= 85) return 'prefer';
474
+ if (score >= 70) return 'use_with_limits';
475
+ if (score >= 50) return 'trial_only';
476
+ return 'avoid_or_approval';
477
+ }
478
+
479
+ function serviceSortKey(service) {
480
+ return `${service.category || ''}:${service.name || ''}:${service.endpoint || ''}`.toLowerCase();
481
+ }
482
+
483
+ function serviceSearchText(service) {
484
+ return [service.name, service.category, service.domain, service.endpoint, service.note, ...(service.tags || [])].join(' ').toLowerCase();
485
+ }
486
+
487
+ function slugify(value) {
488
+ const normalized = String(value || '')
489
+ .toLowerCase()
490
+ .replace(/https?:\/\//g, '')
491
+ .replace(/[^a-z0-9]+/g, '-');
492
+
493
+ return trimBoundaryCharacters(normalized, '-').slice(0, 80);
494
+ }
495
+
496
+ function readableName(domain, endpoint, index) {
497
+ if (!domain) return `Imported Service ${index + 1}`;
498
+ const pathPart = endpointPath(endpoint).split('/').filter(Boolean).slice(-1)[0];
499
+ return `${domain}${pathPart ? ` ${pathPart}` : ''}`;
500
+ }
501
+
502
+ function endpointPath(endpoint) {
503
+ try { return new URL(endpoint).pathname || ''; } catch { return ''; }
504
+ }
505
+
506
+ function URL_SAFE_PATH(endpoint) {
507
+ return endpointPath(endpoint).replace(/\//g, '-');
508
+ }
509
+
510
+ function normalizeCategory(category) {
511
+ const raw = String(category || '')
512
+ .toLowerCase()
513
+ .replace(/[^a-z0-9]+/g, '_');
514
+
515
+ const value = trimBoundaryCharacters(raw, '_');
516
+
517
+ if (!value) return 'general';
518
+ if (SERVICE_CATEGORIES[value]) return value;
519
+ for (const [id, meta] of Object.entries(SERVICE_CATEGORIES)) {
520
+ if ((meta.aliases || []).includes(value)) return id;
521
+ }
522
+ return value;
523
+ }
524
+
525
+ function trimBoundaryCharacters(value, character) {
526
+ const input = String(value || '');
527
+ const target = String(character || '');
528
+ if (!input || target.length !== 1) return input;
529
+
530
+ let start = 0;
531
+ let end = input.length;
532
+
533
+ while (start < end && input[start] === target) start += 1;
534
+ while (end > start && input[end - 1] === target) end -= 1;
535
+
536
+ return input.slice(start, end);
537
+ }
538
+
539
+ function inferCategory(text) {
540
+ const value = String(text || '').toLowerCase();
541
+ if (/search|research|serp|query|answer engine/.test(value)) return 'web_search';
542
+ if (/content|extract|readability|article|url text|document parse|pdf/.test(value)) return 'content_extraction';
543
+ if (/browser|session|navigate|headless|page/.test(value)) return 'browser';
544
+ if (/scrap|crawl|spider|site map/.test(value)) return 'scraping';
545
+ if (/price|market|quote|ticker|ohlc|chart|news|token price/.test(value)) return 'market_data';
546
+ if (/wallet|address|holder|risk|intel|portfolio|transaction history/.test(value)) return 'wallet_intel';
547
+ if (/rpc|chain|contract|token|web3|base|nft|block/.test(value)) return 'web3_data';
548
+ if (/image|vision|generate|thumbnail|creative|logo/.test(value)) return 'image_generation';
549
+ if (/model|llm|inference|chat|completion|prompt|embedding/.test(value)) return 'inference';
550
+ if (/code|sandbox|execute|python|javascript|runtime/.test(value)) return 'code_execution';
551
+ if (/storage|file|blob|ipfs|pinning/.test(value)) return 'storage';
552
+ if (/identity|auth|attestation|profile|did/.test(value)) return 'identity';
553
+ if (/email|sms|message|notify|notification|telegram|discord/.test(value)) return 'messaging';
554
+ if (/payment|settle|checkout|invoice|wallet/.test(value)) return 'payments';
555
+ return 'general';
556
+ }
557
+
558
+ function categoryTags(category) {
559
+ return String(category || 'general').split('_').filter(Boolean);
560
+ }
561
+
562
+ function normalizeNetwork(network) {
563
+ const value = String(network || '').toLowerCase();
564
+ if (value === 'eip155:8453') return 'base';
565
+ if (value === 'eip155:84532') return 'base-sepolia';
566
+ return network || 'base';
567
+ }
568
+
569
+ function inferPriceUSDC(accepts = []) {
570
+ const exact = accepts.find((item) => item && item.amount !== undefined);
571
+ if (!exact) return 0.01;
572
+ const amount = Number(exact.amount);
573
+ if (!Number.isFinite(amount)) return 0.01;
574
+ if (amount >= 1000) return Number((amount / 1_000_000).toFixed(6));
575
+ return amount;
576
+ }
@@ -0,0 +1,35 @@
1
+ import path from 'node:path';
2
+ import { walkFiles, sha256File } from './fs.js';
3
+
4
+ export async function createSnapshot(rootDir = '.', options = {}) {
5
+ const root = path.resolve(rootDir);
6
+ const files = await walkFiles(root, { ignore: options.ignore || [] });
7
+ const snapshotFiles = [];
8
+ for (const file of files) {
9
+ snapshotFiles.push({ path: file.rel, size: file.size, sha256: await sha256File(file.absPath) });
10
+ }
11
+ return {
12
+ schema: 'https://mythos.dev/schemas/snapshot.v0.json',
13
+ createdAt: new Date().toISOString(),
14
+ root,
15
+ files: snapshotFiles
16
+ };
17
+ }
18
+
19
+ export function diffSnapshots(before, after) {
20
+ const b = new Map((before.files || []).map((file) => [file.path, file]));
21
+ const a = new Map((after.files || []).map((file) => [file.path, file]));
22
+ const added = [];
23
+ const modified = [];
24
+ const deleted = [];
25
+
26
+ for (const [filePath, afterFile] of a.entries()) {
27
+ const beforeFile = b.get(filePath);
28
+ if (!beforeFile) added.push(afterFile);
29
+ else if (beforeFile.sha256 !== afterFile.sha256 || beforeFile.size !== afterFile.size) modified.push({ before: beforeFile, after: afterFile });
30
+ }
31
+ for (const [filePath, beforeFile] of b.entries()) {
32
+ if (!a.has(filePath)) deleted.push(beforeFile);
33
+ }
34
+ return { added, modified, deleted, changedCount: added.length + modified.length + deleted.length };
35
+ }