@barivia/barsom-mcp 0.22.4 → 0.23.2

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/shared.js CHANGED
@@ -20,12 +20,16 @@ export const API_URL = process.env.BARIVIA_API_URL ??
20
20
  "https://api.barivia.se";
21
21
  export const API_KEY = process.env.BARIVIA_API_KEY ?? process.env.BARSOM_API_KEY ?? "";
22
22
  export const FETCH_TIMEOUT_MS = parseInt(process.env.BARIVIA_FETCH_TIMEOUT_MS ?? "60000", 10);
23
- export const MAX_RETRIES = 2;
24
- export const RETRY_BASE_MS = parseInt(process.env.BARIVIA_RETRY_BASE_MS ?? "1000", 10);
23
+ /** Retries after the first attempt; default 2 → 3 total tries. Override with BARIVIA_MAX_RETRIES. */
24
+ export const MAX_RETRIES = parseInt(process.env.BARIVIA_MAX_RETRIES ?? "2", 10);
25
+ /** Delay between burst retries (ms). Default 2000 → ~2s between each of the 3 attempts. */
26
+ export const RETRY_BASE_MS = parseInt(process.env.BARIVIA_RETRY_BASE_MS ?? "2000", 10);
25
27
  export const RETRYABLE_STATUS = new Set([502, 503, 504]);
26
- /** Exponential backoff for transient API retries (attempt 0 base, 1 2×, …). */
27
- export function retryDelayMs(attempt) {
28
- return RETRY_BASE_MS * 2 ** attempt;
28
+ /** Cool-down hint after the burst is exhausted agents should wait before another round. */
29
+ export const UNAVAILABLE_RETRY_AFTER_SEC = 30;
30
+ /** Fixed delay between burst retries (not exponential). */
31
+ export function retryDelayMs(_attempt) {
32
+ return RETRY_BASE_MS;
29
33
  }
30
34
  function newRequestId() {
31
35
  return randomUUID();
@@ -35,7 +39,7 @@ function newRequestId() {
35
39
  * X-Barsom-Client-Version so the server can annotate tool guidance with the
36
40
  * wrapper version each action requires. Keep in sync with package.json on bump.
37
41
  */
38
- export const CLIENT_VERSION = "0.22.4";
42
+ export const CLIENT_VERSION = "0.23.2";
39
43
  /** User-facing links; keep aligned with barivia.se / api.barivia.se. */
40
44
  export const PUBLIC_SITE_ORIGIN = "https://barivia.se";
41
45
  /** Self-serve account dashboard (manage plan, billing, and API keys). */
@@ -374,52 +378,237 @@ export async function resolveFilePathForUpload(filePath, mcpServer) {
374
378
  `"C:\\\\Users\\\\you\\\\data.csv") or a file:// URI. For relative paths, set BARIVIA_WORKSPACE_ROOT in your MCP config.`);
375
379
  }
376
380
  }
377
- /** User-visible API error line (includes request id for support). Exported for tests. */
378
- export function formatApiErrorMessage(status, bodyText, requestId) {
381
+ function looksLikeHtml(bodyText) {
382
+ const t = bodyText.trim().slice(0, 200).toLowerCase();
383
+ return t.startsWith("<!doctype") || t.startsWith("<html") || /<\s*html[\s>]/.test(t);
384
+ }
385
+ function isStorageErrorCode(code) {
386
+ if (!code)
387
+ return false;
388
+ const c = code.toLowerCase();
389
+ return c === "storage_upload_failed" || (c.includes("storage") && !c.includes("unavailable"));
390
+ }
391
+ /**
392
+ * Classify an HTTP API failure into a compact structured error.
393
+ * Gateway HTML 502/503 bodies are never echoed — they become api_unreachable.
394
+ */
395
+ export function classifyApiError(status, bodyText, requestId) {
379
396
  let parsed = null;
380
- try {
381
- const j = JSON.parse(bodyText);
382
- if (j && typeof j === "object")
383
- parsed = j;
384
- }
385
- catch {
386
- /* ignore */
397
+ const html = looksLikeHtml(bodyText);
398
+ if (!html && bodyText.trim()) {
399
+ try {
400
+ const j = JSON.parse(bodyText);
401
+ if (j && typeof j === "object")
402
+ parsed = j;
403
+ }
404
+ catch {
405
+ /* non-JSON */
406
+ }
387
407
  }
388
- const detail = (parsed?.error != null && String(parsed.error)) ||
389
- (bodyText.trim() ? bodyText.trim() : `HTTP ${status}`);
390
- const code = parsed?.error_code != null ? ` (error_code: ${parsed.error_code})` : "";
408
+ const apiCode = parsed?.error_code != null ? String(parsed.error_code) : "";
409
+ const apiMsg = parsed?.error != null && String(parsed.error).trim()
410
+ ? String(parsed.error).trim()
411
+ : "";
412
+ let cause = "server";
413
+ let error_code = apiCode || `http_${status}`;
414
+ let message = apiMsg || `HTTP ${status}`;
415
+ let hint = "";
416
+ let retryable = status === 502 || status === 503 || status === 504;
391
417
  const accountHint = ` Check your email or manage your key at ${PUBLIC_DASHBOARD_URL} if needed.`;
392
- const hint = status === 400
393
- ? " Check parameter types and required fields."
394
- : status === 401
395
- ? ` Check BARIVIA_API_KEY in your MCP config.${accountHint}`
396
- : status === 403
397
- ? ` Access denied for this operation (plan or permissions).${accountHint}`
398
- : status === 402
399
- ? ` Credits or subscription may be insufficient — upgrade at ${PUBLIC_DASHBOARD_URL}.`
400
- : status === 404
401
- ? " The resource may not exist or may have been deleted."
402
- : status === 409
403
- ? " The job may not be in the expected state."
404
- : status === 429
405
- ? " Plan limit (e.g. dataset cap) or rate limit — read the error above; delete unused datasets or wait and retry."
406
- : status === 502
407
- ? " Object storage (R2/S3) error from API — retry later."
408
- : status === 503
409
- ? parsed?.error_code === "system_info_unavailable"
410
- ? " System info (plan/queue) temporarily unavailable — retry later."
411
- : " API or database temporarily unavailableretry later."
412
- : status >= 500
413
- ? " Server error — retry later."
414
- : "";
415
- const rid = ` (request id: ${requestId} — include if contacting support)`;
416
- return `${detail}${code}${hint}${rid}`;
418
+ if (status === 400) {
419
+ cause = "client";
420
+ hint = "Check parameter types and required fields.";
421
+ retryable = false;
422
+ }
423
+ else if (status === 401) {
424
+ cause = "auth";
425
+ error_code = apiCode || "unauthorized";
426
+ hint = `Check BARIVIA_API_KEY in your MCP config.${accountHint}`;
427
+ retryable = false;
428
+ }
429
+ else if (status === 403) {
430
+ cause = "auth";
431
+ error_code = apiCode || "forbidden";
432
+ hint = `Access denied for this operation (plan or permissions).${accountHint}`;
433
+ retryable = false;
434
+ }
435
+ else if (status === 402) {
436
+ cause = "client";
437
+ hint = `Credits or subscription may be insufficient upgrade at ${PUBLIC_DASHBOARD_URL}.`;
438
+ retryable = false;
439
+ }
440
+ else if (status === 404) {
441
+ cause = "client";
442
+ hint = "The resource may not exist or may have been deleted.";
443
+ retryable = false;
444
+ }
445
+ else if (status === 409) {
446
+ cause = "client";
447
+ hint = "The job may not be in the expected state.";
448
+ retryable = false;
449
+ }
450
+ else if (status === 429) {
451
+ cause = "rate_limit";
452
+ hint = "Plan limit (e.g. dataset cap) or rate limit — delete unused datasets or wait and retry.";
453
+ retryable = true;
454
+ }
455
+ else if (status === 502 || status === 503 || status === 504) {
456
+ if (isStorageErrorCode(apiCode) || /object storage|r2\/s3|storage upload/i.test(apiMsg)) {
457
+ cause = "storage";
458
+ error_code = apiCode || "storage_upload_failed";
459
+ message = apiMsg || "Object storage (R2/S3) error from API.";
460
+ hint = "Retry later; feedback may still succeed via DB when storage is degraded.";
461
+ }
462
+ else if (html || !parsed) {
463
+ cause = "api_unreachable";
464
+ error_code = apiCode || "api_unreachable";
465
+ message = "API temporarily unavailable (gateway/tunnel returned a non-JSON error).";
466
+ hint =
467
+ `Proxy already tried 3 times (~2s apart). Wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s, then retry. ` +
468
+ "Use account(action=health) — /health does not depend on R2. Pause — do not paste HTML.";
469
+ }
470
+ else if (apiCode === "system_info_unavailable") {
471
+ cause = "server";
472
+ message = apiMsg || "System info (plan/queue) temporarily unavailable.";
473
+ hint = `Wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s, then retry.`;
474
+ }
475
+ else if (apiCode === "feedback_unavailable" || apiCode === "database_error") {
476
+ cause = "server";
477
+ error_code = apiCode;
478
+ message = apiMsg || "API or database temporarily unavailable.";
479
+ hint =
480
+ `Wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s; send_feedback queues locally if the API stays down.`;
481
+ }
482
+ else {
483
+ cause = "server";
484
+ message = apiMsg || `HTTP ${status}`;
485
+ hint = `API or database temporarily unavailable — wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s, then retry.`;
486
+ }
487
+ }
488
+ else if (status >= 500) {
489
+ cause = "server";
490
+ hint = `Server error — wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s, then retry.`;
491
+ retryable = true;
492
+ }
493
+ else if (!apiMsg && html) {
494
+ cause = "api_unreachable";
495
+ error_code = "api_unreachable";
496
+ message = `HTTP ${status} (non-JSON gateway body)`;
497
+ hint = `Wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s, then retry. Do not paste HTML.`;
498
+ retryable = status >= 500;
499
+ }
500
+ const retry_after_sec = retryable ? UNAVAILABLE_RETRY_AFTER_SEC : undefined;
501
+ return {
502
+ status,
503
+ error_code,
504
+ message,
505
+ hint,
506
+ request_id: requestId,
507
+ retryable,
508
+ cause,
509
+ ...(retry_after_sec !== undefined ? { retry_after_sec } : {}),
510
+ };
511
+ }
512
+ /** User-visible API error line (slim; includes request id). Exported for tests. */
513
+ export function formatApiErrorMessage(status, bodyText, requestId) {
514
+ const c = classifyApiError(status, bodyText, requestId);
515
+ const parts = [
516
+ c.message,
517
+ `error_code=${c.error_code}`,
518
+ `cause=${c.cause}`,
519
+ c.retry_after_sec != null ? `retry_after_sec=${c.retry_after_sec}` : "",
520
+ c.hint,
521
+ `request_id=${c.request_id}`,
522
+ ].filter((p) => p && String(p).trim());
523
+ return parts.join(" | ");
417
524
  }
418
525
  function throwApiError(status, bodyText, requestId) {
526
+ const classified = classifyApiError(status, bodyText, requestId);
419
527
  const err = new Error(formatApiErrorMessage(status, bodyText, requestId));
420
528
  err.httpStatus = status;
529
+ err.errorCode = classified.error_code;
530
+ err.requestId = requestId;
531
+ err.retryable = classified.retryable;
532
+ err.cause = classified.cause;
533
+ err.retryAfterSec = classified.retry_after_sec;
534
+ err.classified = classified;
421
535
  throw err;
422
536
  }
537
+ /** Final network failure after the burst — same calm shape as gateway HTML 502. */
538
+ function throwNetworkUnreachable(requestId, err) {
539
+ const detail = err instanceof Error ? err.message : String(err);
540
+ const classified = {
541
+ status: 0,
542
+ error_code: "api_unreachable",
543
+ message: "API temporarily unavailable (network error).",
544
+ hint: `Proxy already tried 3 times (~2s apart). Wait ~${UNAVAILABLE_RETRY_AFTER_SEC}s, then retry. ` +
545
+ `Detail: ${detail.slice(0, 120)}`,
546
+ request_id: requestId,
547
+ retryable: true,
548
+ cause: "api_unreachable",
549
+ retry_after_sec: UNAVAILABLE_RETRY_AFTER_SEC,
550
+ };
551
+ const msg = [
552
+ classified.message,
553
+ `error_code=${classified.error_code}`,
554
+ `cause=${classified.cause}`,
555
+ `retry_after_sec=${classified.retry_after_sec}`,
556
+ classified.hint,
557
+ `request_id=${classified.request_id}`,
558
+ ].join(" | ");
559
+ const out = new Error(msg);
560
+ out.httpStatus = 0;
561
+ out.errorCode = classified.error_code;
562
+ out.requestId = requestId;
563
+ out.retryable = true;
564
+ out.cause = "api_unreachable";
565
+ out.retryAfterSec = UNAVAILABLE_RETRY_AFTER_SEC;
566
+ out.classified = classified;
567
+ throw out;
568
+ }
569
+ /**
570
+ * Liveness (+ optional readiness) probe against the API origin.
571
+ * Uses GET /health which never touches R2 — safe during storage outages.
572
+ */
573
+ export async function probeApiHealth() {
574
+ const base = { storage: "not_probed" };
575
+ try {
576
+ const live = await fetchWithTimeout(`${API_URL}/health`, { method: "GET" }, 8_000);
577
+ const liveText = await live.text();
578
+ if (!live.ok) {
579
+ return { ...base, liveness: "down", readiness: "unknown", detail: `health HTTP ${live.status}` };
580
+ }
581
+ let readiness = "unknown";
582
+ let db;
583
+ let redis;
584
+ try {
585
+ const ready = await fetchWithTimeout(`${API_URL}/ready`, { method: "GET" }, 8_000);
586
+ const readyText = await ready.text();
587
+ try {
588
+ const j = JSON.parse(readyText);
589
+ readiness = ready.ok && j.status === "ready" ? "ready" : "not_ready";
590
+ db = j.db;
591
+ redis = j.redis;
592
+ }
593
+ catch {
594
+ readiness = ready.ok ? "ready" : "not_ready";
595
+ }
596
+ }
597
+ catch {
598
+ readiness = "unknown";
599
+ }
600
+ void liveText;
601
+ return { ...base, liveness: "ok", readiness, db, redis };
602
+ }
603
+ catch (err) {
604
+ return {
605
+ ...base,
606
+ liveness: "down",
607
+ readiness: "unknown",
608
+ detail: err instanceof Error ? err.message : String(err),
609
+ };
610
+ }
611
+ }
423
612
  /**
424
613
  * @param requestTimeoutMs Optional per-request timeout (default `BARIVIA_FETCH_TIMEOUT_MS`).
425
614
  */
@@ -532,9 +721,13 @@ export async function apiCall(method, path, body, extraHeaders, requestTimeoutMs
532
721
  !err.httpStatus) {
533
722
  throw new Error(`Request timed out after ${effectiveTimeout}ms. Increase BARIVIA_FETCH_TIMEOUT_MS in your MCP env (e.g. 120000) for slow or large requests. (request id: ${requestId})`);
534
723
  }
724
+ if (err instanceof TypeError)
725
+ throwNetworkUnreachable(requestId, err);
535
726
  throw err;
536
727
  }
537
728
  }
729
+ if (lastError instanceof TypeError)
730
+ throwNetworkUnreachable(requestId, lastError);
538
731
  throw lastError;
539
732
  }
540
733
  /** Fetch raw bytes from the API (for image downloads). */
@@ -596,9 +789,13 @@ export async function apiRawCall(path, requestTimeoutMs) {
596
789
  !err.httpStatus) {
597
790
  throw new Error(`Request timed out after ${effectiveTimeout}ms. Increase BARIVIA_FETCH_TIMEOUT_MS (e.g. 120000) for large images. (request id: ${requestId})`);
598
791
  }
792
+ if (err instanceof TypeError)
793
+ throwNetworkUnreachable(requestId, err);
599
794
  throw err;
600
795
  }
601
796
  }
797
+ if (lastError instanceof TypeError)
798
+ throwNetworkUnreachable(requestId, lastError);
602
799
  throw lastError;
603
800
  }
604
801
  // ---------------------------------------------------------------------------
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Format the SIOM occupation / coverage block for results(get) text.
3
+ * Surfaces effective hyperparams + util/Gini/entropy/siom_qe alongside grid
4
+ * dead-node counts (which come from hit_stats elsewhere).
5
+ */
6
+ export function formatSiomOccupationSummary(siom, opts) {
7
+ if (!siom || typeof siom !== "object")
8
+ return [];
9
+ const fmt4 = (v) => v !== null && v !== undefined && v !== "" && Number.isFinite(Number(v))
10
+ ? Number(v).toFixed(4)
11
+ : "N/A";
12
+ const fmt3 = (v) => v !== null && v !== undefined && v !== "" && Number.isFinite(Number(v))
13
+ ? Number(v).toFixed(3)
14
+ : "N/A";
15
+ const fmtAny = (v) => v === null || v === undefined || v === "" ? "N/A" : String(v);
16
+ const hasHyper = siom.gamma !== undefined ||
17
+ siom.gamma_f !== undefined ||
18
+ siom.decay !== undefined ||
19
+ siom.siom_decay !== undefined ||
20
+ siom.penalty !== undefined ||
21
+ siom.siom_penalty !== undefined ||
22
+ siom.reset_per_epoch !== undefined;
23
+ const decay = siom.decay ?? siom.siom_decay;
24
+ const penalty = siom.penalty ?? siom.siom_penalty;
25
+ const files = opts?.files ?? [];
26
+ const hasOccFile = files.some((f) => String(f).includes("siom_occupation"));
27
+ if (opts?.compact) {
28
+ const parts = [
29
+ `utilization: ${fmt4(siom.utilization)}`,
30
+ `dead_fraction: ${fmt4(siom.dead_fraction)}`,
31
+ `gini: ${fmt4(siom.gini)}`,
32
+ `entropy: ${fmt4(siom.entropy)}`,
33
+ siom.siom_qe !== undefined ? `siom_qe: ${fmt4(siom.siom_qe)}` : "",
34
+ hasHyper
35
+ ? `γ=${fmt3(siom.gamma)}${siom.gamma_f !== undefined && Number(siom.gamma_f) > 0 ? `→${fmt3(siom.gamma_f)}` : ""} penalty=${fmtAny(penalty)} decay=${fmt4(decay)}`
36
+ : "",
37
+ ].filter(Boolean);
38
+ return [`SIOM occupation summary — ${parts.join(" | ")}`];
39
+ }
40
+ const lines = [
41
+ "SIOM occupation summary (layer coverage; distinct from grid BMU dead nodes):",
42
+ ` Utilization: ${fmt4(siom.utilization)} | dead_fraction: ${fmt4(siom.dead_fraction)}`,
43
+ ` Gini: ${fmt4(siom.gini)} | Entropy: ${fmt4(siom.entropy)}${siom.max_entropy !== undefined ? ` / max ${fmt4(siom.max_entropy)}` : ""}`,
44
+ siom.siom_qe !== undefined ? ` SIOM QE: ${fmt4(siom.siom_qe)}` : "",
45
+ ];
46
+ if (hasHyper) {
47
+ lines.push(` Knobs: gamma=${fmt3(siom.gamma)}` +
48
+ (siom.gamma_f !== undefined ? ` gamma_f=${fmt3(siom.gamma_f)}` : "") +
49
+ ` siom_decay=${fmt4(decay)} siom_penalty=${fmtAny(penalty)}` +
50
+ (siom.penalty_alpha !== undefined ? ` α=${fmt3(siom.penalty_alpha)}` : "") +
51
+ (siom.reset_per_epoch !== undefined ? ` reset_per_epoch=${fmtAny(siom.reset_per_epoch)}` : ""));
52
+ }
53
+ if (hasOccFile) {
54
+ lines.push(" Artifact: siom_occupation.json (per-node μ) — results(action=download, include_json=true).");
55
+ }
56
+ return lines.filter((l) => l !== "");
57
+ }
@@ -1,27 +1,95 @@
1
1
  import { z } from "zod";
2
2
  import { registerAuditedTool } from "../audit.js";
3
- import { apiCall, CLIENT_VERSION } from "../shared.js";
3
+ import { apiCall, CLIENT_VERSION, probeApiHealth, structuredTextResult, UNAVAILABLE_RETRY_AFTER_SEC, } from "../shared.js";
4
4
  import { getMcpClientDiagnostics } from "../ui-delivery.js";
5
+ import { listDeferredFeedback } from "../deferred_feedback.js";
6
+ import { buildJobsListQuery, extractJobsList, formatDatasetInventoryLine, formatJobInventoryLine, } from "../inventory_format.js";
7
+ function degradedStatusResult(opts) {
8
+ const retry_after_sec = UNAVAILABLE_RETRY_AFTER_SEC;
9
+ const payload = {
10
+ ok: false,
11
+ retry_after_sec,
12
+ api_reachable: false,
13
+ message: "Barivia is temporarily unavailable (we could not reach the API). " +
14
+ "Your local files and prior downloads are still fine. Try again in about 30 seconds.",
15
+ offline_ok: [
16
+ "already-downloaded results",
17
+ "local CSVs",
18
+ "draft notes / deferred feedback queue",
19
+ ],
20
+ needs_api: ["upload", "train", "feedback flush", "list/status", "other cloud tools"],
21
+ request_id: opts.requestId ?? null,
22
+ hint_for_host: "Barivia: reconnecting…",
23
+ detail: opts.detail ?? null,
24
+ };
25
+ const rid = opts.requestId
26
+ ? ` (request id: ${opts.requestId} — include if contacting support)`
27
+ : "";
28
+ const text = [
29
+ payload.message + rid,
30
+ `retry_after_sec=${retry_after_sec} | api_reachable=false`,
31
+ `AGENT: Calm banner ("${payload.hint_for_host}"); do not dump HTML. Proxy already burst-retried; wait ~${retry_after_sec}s before another round. Do not start large uploads/trains until ok.`,
32
+ ].join("\n");
33
+ return structuredTextResult(payload, text);
34
+ }
5
35
  export function registerAccountTool(server) {
6
- registerAuditedTool(server, "account", `Manage your Barivia account — check plan/license info, view usage history, add funds.
36
+ registerAuditedTool(server, "account", `Manage your Barivia account — check plan/license info, view usage history, rebuild an org inventory, add funds, or probe API health.
7
37
 
8
38
  | Action | Use when |
9
39
  |--------|----------|
10
- | status | Before large jobs — see plan tier, GPU availability, queue depth, training time estimates, credit balance |
40
+ | status | Before large jobs — see plan tier, GPU availability, queue depth, training time estimates, credit balance. When the API is down, returns calm \`{ok:false, retry_after_sec}\` instead of HTML. |
11
41
  | history | Viewing recent compute usage and credit spend |
42
+ | inventory | Rebuild a durable markdown overview of datasets + recent jobs (agent-friendly org catalog) |
12
43
  | add_funds | Getting instructions to add credits |
44
+ | health | Diagnose API reachability during outages — GET /health + /ready; does **not** probe R2/object storage |
13
45
 
14
46
  action=status: Returns plan tier, compute class (CPU/GPU), usage limits, live queue state, training time estimates, and credit balance.
15
47
  Use BEFORE large jobs to check GPU availability and estimate wait time.
48
+ When the API/gateway is down, returns structured \`{ok:false, retry_after_sec, …}\` — treat as a pause, not a crash.
49
+ action=inventory: Lists datasets (name/id/rows/cols/description/tags) and slim recent jobs (label/id/status/job_type/dataset_id/created_at/result). Prefer this over hand-assembling datasets(list)+jobs(list). Page jobs with jobs(action=list, cursor=…).
50
+ action=health: Lightweight liveness/readiness (no R2). Use when tools return api_unreachable or before retrying deferred send_feedback.
16
51
  Capacity is shared LOCAL/GKE worker pools — there is no per-key cloud burst lease.
17
52
  NOT FOR: Training itself — use train(action=map). This tool only manages the account.`, {
18
53
  action: z
19
- .enum(["status", "history", "add_funds"])
20
- .describe("status: plan/license/queue info; history: recent compute usage; add_funds: instructions"),
21
- limit: z.number().optional().describe("action=history: number of records to return (default: 10)"),
54
+ .enum(["status", "history", "inventory", "add_funds", "health"])
55
+ .describe("status: plan/license/queue info; history: recent compute usage; inventory: datasets+jobs overview; add_funds: instructions; health: API liveness without R2"),
56
+ limit: z.number().optional().describe("action=history or inventory: number of job records to return (default: 10 history / 50 inventory)"),
22
57
  }, async ({ action, limit }) => {
58
+ if (action === "health") {
59
+ const health = await probeApiHealth();
60
+ const deferred = await listDeferredFeedback("barsom").catch(() => []);
61
+ const lines = [
62
+ `API liveness: ${health.liveness}`,
63
+ `API readiness: ${health.readiness}`,
64
+ health.db ? `DB: ${health.db}` : null,
65
+ health.redis ? `Redis: ${health.redis}` : null,
66
+ `Object storage (R2): ${health.storage} (intentionally not checked)`,
67
+ health.detail ? `Detail: ${health.detail}` : null,
68
+ `Deferred feedback drafts (barsom): ${deferred.length}`,
69
+ health.liveness === "down"
70
+ ? `retry_after_sec=${UNAVAILABLE_RETRY_AFTER_SEC} (wait before another tool round)`
71
+ : null,
72
+ ].filter(Boolean);
73
+ return structuredTextResult({
74
+ ...health,
75
+ deferred_feedback_pending: deferred.length,
76
+ ...(health.liveness === "down"
77
+ ? { ok: false, retry_after_sec: UNAVAILABLE_RETRY_AFTER_SEC }
78
+ : { ok: true }),
79
+ }, lines.join("\n"));
80
+ }
23
81
  if (action === "status") {
24
- const data = (await apiCall("GET", "/v1/system/info"));
82
+ let data;
83
+ try {
84
+ data = (await apiCall("GET", "/v1/system/info"));
85
+ }
86
+ catch (err) {
87
+ const e = err;
88
+ return degradedStatusResult({
89
+ requestId: e.requestId,
90
+ detail: e.message,
91
+ });
92
+ }
25
93
  const plan = data.plan ?? {};
26
94
  const backend = data.backend ?? {};
27
95
  const status = data.status ?? {};
@@ -95,6 +163,42 @@ NOT FOR: Training itself — use train(action=map). This tool only manages the a
95
163
  content: [{ type: "text", text: `Credit Balance: $${(data.credit_balance_cents / 100).toFixed(2)}\n\nRecent Usage:\n${history}` }]
96
164
  };
97
165
  }
166
+ if (action === "inventory") {
167
+ const jobLimit = limit && limit > 0 ? Math.min(Math.floor(limit), 100) : 50;
168
+ const [datasetsRaw, jobsRaw] = await Promise.all([
169
+ apiCall("GET", "/v1/datasets"),
170
+ apiCall("GET", buildJobsListQuery({ limit: jobLimit })),
171
+ ]);
172
+ const datasets = Array.isArray(datasetsRaw) ? datasetsRaw : [];
173
+ const { jobs, next_cursor } = extractJobsList(jobsRaw);
174
+ const lines = [
175
+ `# Org inventory`,
176
+ ``,
177
+ `## Datasets (${datasets.length})`,
178
+ ];
179
+ if (datasets.length === 0) {
180
+ lines.push("(none)");
181
+ }
182
+ else {
183
+ for (const ds of datasets)
184
+ lines.push(`- ${formatDatasetInventoryLine(ds)}`);
185
+ }
186
+ lines.push(``, `## Jobs (latest ${jobs.length}${next_cursor ? ", more available" : ""})`);
187
+ if (jobs.length === 0) {
188
+ lines.push("(none)");
189
+ }
190
+ else {
191
+ for (const job of jobs)
192
+ lines.push(`- ${formatJobInventoryLine(job)}`);
193
+ }
194
+ if (next_cursor) {
195
+ lines.push(``);
196
+ lines.push(`More jobs: jobs(action=list, cursor="${next_cursor}", limit=${jobLimit})`);
197
+ }
198
+ lines.push(``);
199
+ lines.push(`Tips: set description/tags on datasets(upload|update) and train(...)/jobs(update). Filter jobs with status/job_type/has_results.`);
200
+ return { content: [{ type: "text", text: lines.join("\n") }] };
201
+ }
98
202
  if (action === "add_funds") {
99
203
  return {
100
204
  content: [{ type: "text", text: `To add funds to your account, please visit the Barivia Billing Portal (integration pending) or ask your administrator to use the CLI tool:
@@ -102,7 +206,7 @@ bash scripts/billing/manage-credits.sh add <org_id> <amount_usd>` }]
102
206
  };
103
207
  }
104
208
  return {
105
- content: [{ type: "text", text: `Unknown action: ${action}. Valid: status, history, add_funds.` }]
209
+ content: [{ type: "text", text: `Unknown action: ${action}. Valid: status, history, inventory, add_funds, health.` }]
106
210
  };
107
211
  });
108
212
  }