@covia/covia-sdk 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -6,18 +6,18 @@ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { en
6
6
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
7
7
 
8
8
  // src/types.ts
9
- var RunStatus = /* @__PURE__ */ ((RunStatus2) => {
10
- RunStatus2["COMPLETE"] = "COMPLETE";
11
- RunStatus2["FAILED"] = "FAILED";
12
- RunStatus2["PENDING"] = "PENDING";
13
- RunStatus2["STARTED"] = "STARTED";
14
- RunStatus2["CANCELLED"] = "CANCELLED";
15
- RunStatus2["TIMEOUT"] = "TIMEOUT";
16
- RunStatus2["REJECTED"] = "REJECTED";
17
- RunStatus2["INPUT_REQUIRED"] = "INPUT_REQUIRED";
18
- RunStatus2["AUTH_REQUIRED"] = "AUTH_REQUIRED";
19
- RunStatus2["PAUSED"] = "PAUSED";
20
- return RunStatus2;
9
+ var RunStatus = /* @__PURE__ */ ((RunStatus3) => {
10
+ RunStatus3["COMPLETE"] = "COMPLETE";
11
+ RunStatus3["FAILED"] = "FAILED";
12
+ RunStatus3["PENDING"] = "PENDING";
13
+ RunStatus3["STARTED"] = "STARTED";
14
+ RunStatus3["CANCELLED"] = "CANCELLED";
15
+ RunStatus3["TIMEOUT"] = "TIMEOUT";
16
+ RunStatus3["REJECTED"] = "REJECTED";
17
+ RunStatus3["INPUT_REQUIRED"] = "INPUT_REQUIRED";
18
+ RunStatus3["AUTH_REQUIRED"] = "AUTH_REQUIRED";
19
+ RunStatus3["PAUSED"] = "PAUSED";
20
+ return RunStatus3;
21
21
  })(RunStatus || {});
22
22
  var JobStatus = RunStatus;
23
23
  var AgentStatus = /* @__PURE__ */ ((AgentStatus2) => {
@@ -70,6 +70,13 @@ var JobFailedError = class extends CoviaError {
70
70
  this.jobData = jobData;
71
71
  }
72
72
  };
73
+ var RateLimitError = class extends GridError {
74
+ constructor(message, retryAfterSeconds, body) {
75
+ super(429, message, body);
76
+ this.retryAfterSeconds = retryAfterSeconds;
77
+ this.name = "RateLimitError";
78
+ }
79
+ };
73
80
  var NotFoundError = class extends GridError {
74
81
  constructor(message) {
75
82
  super(404, message);
@@ -1307,22 +1314,55 @@ function wrapError(error) {
1307
1314
  }
1308
1315
  return new CoviaError(`Request failed: ${msg}`);
1309
1316
  }
1317
+ var RETRY_MAX_ATTEMPTS = 4;
1318
+ var RETRY_BASE_MS = 200;
1319
+ var RETRY_MAX_DELAY_MS = 1e4;
1320
+ var RETRY_BUDGET_MS = 3e4;
1321
+ function parseRetryAfterMs(header, nowMs) {
1322
+ if (!header) return 0;
1323
+ const secs = Number(header);
1324
+ if (Number.isFinite(secs)) return Math.max(0, secs * 1e3);
1325
+ const date = Date.parse(header);
1326
+ return Number.isFinite(date) ? Math.max(0, date - nowMs) : 0;
1327
+ }
1328
+ function retryDelayMs(attempt, retryAfterMs, remainingBudgetMs, random) {
1329
+ if (attempt >= RETRY_MAX_ATTEMPTS) return -1;
1330
+ const backoff = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_MS * 2 ** (attempt - 1));
1331
+ const delay = Math.max(retryAfterMs, Math.floor(random * backoff));
1332
+ return delay > remainingBudgetMs ? -1 : delay;
1333
+ }
1334
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1310
1335
  async function fetchWithError(url, options) {
1311
1336
  const method = options?.method ?? "GET";
1312
- logger.debug(`${method} ${url}`);
1313
- let response;
1314
- try {
1315
- response = await fetch(url, options);
1316
- } catch (error) {
1317
- const msg = error.message ?? String(error);
1318
- logger.debug(`Connection failed: ${method} ${url} \u2014 ${msg}`);
1319
- throw wrapError(error);
1320
- }
1321
- logger.debug(`${method} ${url} \u2192 ${response.status}`);
1322
- if (!response.ok) {
1323
- await throwHttpError(response);
1337
+ const deadline = Date.now() + RETRY_BUDGET_MS;
1338
+ for (let attempt = 1; ; attempt++) {
1339
+ logger.debug(`${method} ${url}`);
1340
+ let response;
1341
+ try {
1342
+ response = await fetch(url, options);
1343
+ } catch (error) {
1344
+ const msg = error.message ?? String(error);
1345
+ logger.debug(`Connection failed: ${method} ${url} \u2014 ${msg}`);
1346
+ throw wrapError(error);
1347
+ }
1348
+ logger.debug(`${method} ${url} \u2192 ${response.status}`);
1349
+ if (response.status === 429) {
1350
+ const now = Date.now();
1351
+ const retryAfterMs = parseRetryAfterMs(response.headers?.get?.("Retry-After") ?? null, now);
1352
+ const delay = retryDelayMs(attempt, retryAfterMs, deadline - now, Math.random());
1353
+ if (delay < 0) {
1354
+ const { message, body } = await parseErrorBody(response);
1355
+ throw new RateLimitError(message, Math.max(1, Math.ceil(retryAfterMs / 1e3)), body);
1356
+ }
1357
+ logger.debug(`429 from ${url}; retrying in ${delay}ms (attempt ${attempt})`);
1358
+ await sleep(delay);
1359
+ continue;
1360
+ }
1361
+ if (!response.ok) {
1362
+ await throwHttpError(response);
1363
+ }
1364
+ return response.json();
1324
1365
  }
1325
- return response.json();
1326
1366
  }
1327
1367
  async function fetchStreamWithError(url, options) {
1328
1368
  const method = options?.method ?? "GET";
@@ -1457,10 +1497,96 @@ async function* parseSSEStream(response) {
1457
1497
  }
1458
1498
  }
1459
1499
 
1500
+ // src/AdapterManager.ts
1501
+ var ADAPTERS_PATH = "v/info/adapters";
1502
+ var LIST_LIMIT = 1e3;
1503
+ function toAdapterInfo(name, value) {
1504
+ return {
1505
+ name: value?.name ?? name ?? "",
1506
+ description: value?.description,
1507
+ operations: Array.isArray(value?.operations) ? value.operations : []
1508
+ };
1509
+ }
1510
+ var AdapterManager = class {
1511
+ constructor(venue) {
1512
+ this.venue = venue;
1513
+ }
1514
+ /**
1515
+ * List the adapters registered on the venue, with their descriptions and
1516
+ * invocable operation catalog paths. Registered-but-inactive or zero-op
1517
+ * adapters are included — this is the true registry, not an inference
1518
+ * from the operations catalog.
1519
+ */
1520
+ async list() {
1521
+ const r = await this.venue.workspace.slice(ADAPTERS_PATH, 0, LIST_LIMIT);
1522
+ if (!r.exists || !Array.isArray(r.values)) return [];
1523
+ const out = [];
1524
+ for (const entry of r.values) {
1525
+ if (!entry || typeof entry !== "object") continue;
1526
+ const key = "key" in entry && typeof entry.key === "string" ? entry.key : void 0;
1527
+ const value = "value" in entry ? entry.value : entry;
1528
+ out.push(toAdapterInfo(key, value));
1529
+ }
1530
+ return out;
1531
+ }
1532
+ /**
1533
+ * Get a single adapter's summary by name.
1534
+ * @param name - Adapter name, e.g. "mcp"
1535
+ * @returns {Promise<AdapterInfo | null>} `null` if no adapter with that name is registered
1536
+ */
1537
+ async get(name) {
1538
+ const r = await this.venue.workspace.read(`${ADAPTERS_PATH}/${encodeURIComponent(name)}`);
1539
+ if (!r.exists) return null;
1540
+ return toAdapterInfo(name, r.value);
1541
+ }
1542
+ };
1543
+
1460
1544
  // src/AgentManager.ts
1545
+ function versionAtLeast(version, major, minor) {
1546
+ const m = version?.match(/^(\d+)\.(\d+)/);
1547
+ if (!m) return true;
1548
+ const [maj, min] = [Number(m[1]), Number(m[2])];
1549
+ return maj > major || maj === major && min >= minor;
1550
+ }
1461
1551
  var AgentManager = class {
1462
1552
  constructor(venue) {
1463
1553
  this.venue = venue;
1554
+ // Whether this venue serves GET /api/v1/agents — flipped on the first 404 so
1555
+ // pre-0.4 venues pay the probe once, not one failed GET per read (covia#180).
1556
+ this.agentsGetSupported = true;
1557
+ }
1558
+ _headers() {
1559
+ const headers = { "Content-Type": "application/json" };
1560
+ this.venue.auth.apply(headers, this.venue.venueId);
1561
+ return headers;
1562
+ }
1563
+ /** Whether the venue serves `GET /api/v1/agents`: no if a probe already
1564
+ * 404'd, or if the venue's last known status identifies it as pre-0.4. */
1565
+ supportsAgentsGet() {
1566
+ if (!this.agentsGetSupported) return false;
1567
+ const status = this.venue.lastKnownStatus;
1568
+ if (status && (!status.version || !versionAtLeast(status.version, 0, 4))) {
1569
+ this.agentsGetSupported = false;
1570
+ }
1571
+ return this.agentsGetSupported;
1572
+ }
1573
+ /** A job-free agents GET, falling back to the invoke path on pre-0.4 venues. */
1574
+ async _agentsOr(path, params, fallback) {
1575
+ if (this.supportsAgentsGet()) {
1576
+ try {
1577
+ const qs = new URLSearchParams();
1578
+ for (const [k, v] of Object.entries(params)) if (v !== void 0) qs.set(k, String(v));
1579
+ const q = qs.toString();
1580
+ return await fetchWithError(
1581
+ `${this.venue.baseUrl}/api/v1/agents${path}${q ? `?${q}` : ""}`,
1582
+ { headers: this._headers() }
1583
+ );
1584
+ } catch (e) {
1585
+ if (!(e instanceof NotFoundError)) throw e;
1586
+ this.agentsGetSupported = false;
1587
+ }
1588
+ }
1589
+ return fallback();
1464
1590
  }
1465
1591
  async create(input) {
1466
1592
  return this.venue.operations.run("v/ops/agent/create", input);
@@ -1493,8 +1619,13 @@ var AgentManager = class {
1493
1619
  async trigger(agentId) {
1494
1620
  return this.venue.operations.run("v/ops/agent/trigger", { agentId });
1495
1621
  }
1622
+ /**
1623
+ * List the caller's agents. **Job-free** on covia ≥ 0.4: goes through
1624
+ * `GET /api/v1/agents` (covia#180) — synchronous, no Job persisted. Older
1625
+ * venues transparently fall back to the invoke path (one probe, remembered).
1626
+ */
1496
1627
  async list(includeTerminated) {
1497
- return this.venue.operations.run("v/ops/agent/list", { includeTerminated });
1628
+ return this._agentsOr("", { includeTerminated }, () => this.venue.operations.run("v/ops/agent/list", { includeTerminated }));
1498
1629
  }
1499
1630
  async delete(agentId, remove) {
1500
1631
  return this.venue.operations.run("v/ops/agent/delete", { agentId, remove });
@@ -1511,8 +1642,10 @@ var AgentManager = class {
1511
1642
  async cancelTask(agentId, taskId) {
1512
1643
  return this.venue.operations.run("v/ops/agent/cancel-task", { agentId, taskId });
1513
1644
  }
1645
+ /** Agent info. **Job-free** on covia ≥ 0.4 (`GET /api/v1/agents/{id}`,
1646
+ * covia#180); older venues fall back to the invoke path. */
1514
1647
  async info(agentId) {
1515
- return this.venue.operations.run("v/ops/agent/info", { agentId });
1648
+ return this._agentsOr(`/${encodeURIComponent(agentId)}`, {}, () => this.venue.operations.run("v/ops/agent/info", { agentId }));
1516
1649
  }
1517
1650
  async fork(input) {
1518
1651
  return this.venue.operations.run("v/ops/agent/fork", input);
@@ -2013,9 +2146,47 @@ var OperationManager = class {
2013
2146
  * @param options - Invoke options (e.g., ucans)
2014
2147
  */
2015
2148
  async run(assetId, input, options) {
2149
+ if (this.venue.privateJobs) {
2150
+ return this._runPrivate(assetId, input, options);
2151
+ }
2016
2152
  const job = await this.invoke(assetId, input, options);
2017
2153
  return await job.result();
2018
2154
  }
2155
+ /**
2156
+ * Private-mode execution (covia #192): a memory-only job whose result is
2157
+ * collected through the invoke `wait` window — a completed private job is
2158
+ * immediately forgotten by the venue, so polling cannot be used. If the
2159
+ * operation outlives the venue's wait cap, polling continues while the job
2160
+ * runs; a job that completes between polls is unobservable and surfaces as
2161
+ * a clear error rather than a confusing 404.
2162
+ */
2163
+ async _runPrivate(assetId, input, options) {
2164
+ const payload = {
2165
+ operation: assetId,
2166
+ input,
2167
+ private: true,
2168
+ wait: true
2169
+ };
2170
+ if (options?.ucans) payload.ucans = options.ucans;
2171
+ const rec = await fetchWithError(`${this.venue.baseUrl}/api/v1/invoke`, {
2172
+ method: "POST",
2173
+ headers: this._buildHeaders(),
2174
+ body: JSON.stringify(payload)
2175
+ });
2176
+ const status = rec.status;
2177
+ if (isJobComplete(status)) return rec.output;
2178
+ if (isJobFinished(status)) {
2179
+ throw new JobFailedError(rec);
2180
+ }
2181
+ try {
2182
+ const job = new Job(rec.id ?? "", this.venue, rec);
2183
+ return await job.result();
2184
+ } catch (e) {
2185
+ throw new CoviaError(
2186
+ `Private job completed while unobserved \u2014 its record is already forgotten, so the result could not be collected. Private jobs that outlive the venue wait window cannot be reliably collected; consider a non-private run for long operations. (${e.message})`
2187
+ );
2188
+ }
2189
+ }
2019
2190
  /**
2020
2191
  * Execute an operation and return a Job for tracking
2021
2192
  * @param assetId - Operation asset ID or named operation
@@ -2023,6 +2194,11 @@ var OperationManager = class {
2023
2194
  * @param options - Invoke options (e.g., ucans)
2024
2195
  */
2025
2196
  async invoke(assetId, input, options) {
2197
+ if (this.venue.privateJobs) {
2198
+ throw new CoviaError(
2199
+ "Private-jobs mode requires run(): a completed private job is immediately forgotten by the venue, so a poll-style Job cannot collect its result."
2200
+ );
2201
+ }
2026
2202
  const payload = {
2027
2203
  operation: assetId,
2028
2204
  input
@@ -2045,7 +2221,7 @@ var OperationManager = class {
2045
2221
  };
2046
2222
 
2047
2223
  // src/WorkspaceManager.ts
2048
- function versionAtLeast(version, major, minor) {
2224
+ function versionAtLeast2(version, major, minor) {
2049
2225
  const m = version?.match(/^(\d+)\.(\d+)/);
2050
2226
  if (!m) return true;
2051
2227
  const [maj, min] = [Number(m[1]), Number(m[2])];
@@ -2078,7 +2254,7 @@ var WorkspaceManager = class {
2078
2254
  supportsValues() {
2079
2255
  if (!this.valuesSupported) return false;
2080
2256
  const status = this.venue.lastKnownStatus;
2081
- if (status && (!status.version || !versionAtLeast(status.version, 0, 3))) {
2257
+ if (status && (!status.version || !versionAtLeast2(status.version, 0, 3))) {
2082
2258
  this.valuesSupported = false;
2083
2259
  }
2084
2260
  return this.valuesSupported;
@@ -2161,6 +2337,16 @@ var UCANManager = class {
2161
2337
  async issue(aud, att, exp) {
2162
2338
  return this.venue.operations.run("v/ops/ucan/issue", { aud, att, exp });
2163
2339
  }
2340
+ /**
2341
+ * Verify a UCAN against the venue's trust policy and get the verdict
2342
+ * explained — validity with a diagnosable reason, chain depth and root
2343
+ * issuer, per-capability root-authority (owner / venue / refused), and,
2344
+ * when `check` is supplied, whether the token would authorise that request
2345
+ * here. The diagnostic counterpart to an enforcement "Access denied".
2346
+ */
2347
+ async verify(token, check) {
2348
+ return this.venue.operations.run("v/ops/ucan/verify", { token, ...check });
2349
+ }
2164
2350
  };
2165
2351
 
2166
2352
  // src/SecretManager.ts
@@ -2297,6 +2483,34 @@ function venueBaseUrlCandidates(venueId) {
2297
2483
  return schemelessVenueCandidates(venueId);
2298
2484
  }
2299
2485
  var Venue = class _Venue {
2486
+ constructor(options = {}) {
2487
+ /** Connection-level private-jobs mode — see {@link setPrivate}. */
2488
+ this.privateJobs = false;
2489
+ this.baseUrl = options.baseUrl || "";
2490
+ this.venueId = options.venueId || "";
2491
+ this.auth = options.auth || new NoAuth();
2492
+ this.lastKnownStatus = options.status;
2493
+ this.metadata = {
2494
+ name: options.name || "default",
2495
+ description: options.description || ""
2496
+ };
2497
+ }
2498
+ /**
2499
+ * Puts this connection in **private-jobs mode**: every subsequent
2500
+ * `operations.run(...)` executes as a memory-only job (covia #192) — never
2501
+ * persisted to the venue's job index, no durable record, gone on venue
2502
+ * restart. Requires `enablePrivateJobs` on the venue.
2503
+ *
2504
+ * Because a completed private job is immediately forgotten, results are
2505
+ * collected through the invoke `wait` window rather than polling — so
2506
+ * private mode works with `run()`; a poll-style `invoke()` throws.
2507
+ */
2508
+ setPrivate(enabled) {
2509
+ this.privateJobs = enabled;
2510
+ }
2511
+ get adapters() {
2512
+ return this._adapters ?? (this._adapters = new AdapterManager(this));
2513
+ }
2300
2514
  get agents() {
2301
2515
  return this._agents ?? (this._agents = new AgentManager(this));
2302
2516
  }
@@ -2318,16 +2532,6 @@ var Venue = class _Venue {
2318
2532
  get secrets() {
2319
2533
  return this._secrets ?? (this._secrets = new SecretManager(this));
2320
2534
  }
2321
- constructor(options = {}) {
2322
- this.baseUrl = options.baseUrl || "";
2323
- this.venueId = options.venueId || "";
2324
- this.auth = options.auth || new NoAuth();
2325
- this.lastKnownStatus = options.status;
2326
- this.metadata = {
2327
- name: options.name || "default",
2328
- description: options.description || ""
2329
- };
2330
- }
2331
2535
  /**
2332
2536
  * Connect to a venue, validating it with `GET {base}/api/v1/status`. If the
2333
2537
  * venue is auth-gated (status answers 401/403 — public access disabled), the
@@ -2559,10 +2763,42 @@ var Grid = class {
2559
2763
  return connectedVenue;
2560
2764
  }
2561
2765
  };
2766
+
2767
+ // src/crypto/ucan.ts
2768
+ var encoder2 = new TextEncoder();
2769
+ var VENUE_RELAY = "venue/relay";
2770
+ function didFor(privateKey) {
2771
+ return `did:key:${encodePublicKey(getPublicKey2(privateKey))}`;
2772
+ }
2773
+ function createUCANJWT(privateKey, audienceDID, att, lifetimeSeconds, proofs = []) {
2774
+ const multikey = encodePublicKey(getPublicKey2(privateKey));
2775
+ const nowSecs = Math.floor(Date.now() / 1e3);
2776
+ const header = JSON.stringify({ alg: "EdDSA", typ: "JWT", kid: multikey });
2777
+ const claims = {
2778
+ iss: `did:key:${multikey}`,
2779
+ aud: audienceDID,
2780
+ att,
2781
+ exp: nowSecs + lifetimeSeconds
2782
+ };
2783
+ if (proofs.length) claims.prf = proofs;
2784
+ const signingInput = `${base64UrlEncode(encoder2.encode(header))}.${base64UrlEncode(encoder2.encode(JSON.stringify(claims)))}`;
2785
+ const signature = sign(encoder2.encode(signingInput), privateKey);
2786
+ return `${signingInput}.${base64UrlEncode(signature)}`;
2787
+ }
2788
+ function identityToken(privateKey, venueDID, lifetimeSeconds = 300) {
2789
+ return createUCANJWT(privateKey, venueDID, [], lifetimeSeconds);
2790
+ }
2791
+ function grant(ownerPrivateKey, audienceDID, withResource, can, lifetimeSeconds) {
2792
+ return createUCANJWT(ownerPrivateKey, audienceDID, [{ with: withResource, can }], lifetimeSeconds);
2793
+ }
2794
+ function relayDelegation(privateKey, venueDID, lifetimeSeconds, caps = []) {
2795
+ const att = [{ with: didFor(privateKey), can: VENUE_RELAY }, ...caps];
2796
+ return createUCANJWT(privateKey, venueDID, att, lifetimeSeconds);
2797
+ }
2562
2798
  /*! Bundled license information:
2563
2799
 
2564
2800
  @noble/ed25519/index.js:
2565
2801
  (*! noble-ed25519 - MIT License (c) 2019 Paul Miller (paulmillr.com) *)
2566
2802
  */
2567
2803
 
2568
- export { Agent, AgentManager, AgentStatus, Asset, AssetManager, AssetNotFoundError, Auth, BasicAuth, BearerAuth, ChatSession, CoviaConnectionError, CoviaError, CoviaTimeoutError, CredentialsHTTP, DataAsset, Ed25519Auth, Grid, GridError, Job, JobFailedError, JobManager, JobNotFoundError, JobStatus, KeyPairAuth, Namespace, NoAuth, NotFoundError, Operation, OperationManager, RunStatus, SecretManager, UCANManager, Venue, WorkspaceManager, assetHash, createSSEEvent, decodePublicKey, didFromPublicKey, didMethod, didUrl, encodePublicKey, fetchStreamWithError, fetchWithError, generateKeyPair, getAssetIdFromPath, getAssetIdFromVenueId, getParsedAssetId, hexToPrivateKey, isDid, isJobComplete, isJobFinished, isJobPaused, logger, parseDidUrl, parseSSEStream, privateKeyToHex, venueBaseUrlCandidates };
2804
+ export { AdapterManager, Agent, AgentManager, AgentStatus, Asset, AssetManager, AssetNotFoundError, Auth, BasicAuth, BearerAuth, ChatSession, CoviaConnectionError, CoviaError, CoviaTimeoutError, CredentialsHTTP, DataAsset, Ed25519Auth, Grid, GridError, Job, JobFailedError, JobManager, JobNotFoundError, JobStatus, KeyPairAuth, Namespace, NoAuth, NotFoundError, Operation, OperationManager, RateLimitError, RunStatus, SecretManager, UCANManager, VENUE_RELAY, Venue, WorkspaceManager, assetHash, createSSEEvent, createUCANJWT, decodePublicKey, didFor, didFromPublicKey, didMethod, didUrl, encodePublicKey, fetchStreamWithError, fetchWithError, generateKeyPair, getAssetIdFromPath, getAssetIdFromVenueId, getParsedAssetId, grant, hexToPrivateKey, identityToken, isDid, isJobComplete, isJobFinished, isJobPaused, logger, parseDidUrl, parseRetryAfterMs, parseSSEStream, privateKeyToHex, relayDelegation, retryDelayMs, venueBaseUrlCandidates };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@covia/covia-sdk",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Typescript library for covia-ai",
5
5
  "packageManager": "pnpm@11.9.0",
6
6
  "main": "./dist/index.js",
@@ -69,4 +69,4 @@
69
69
  "did-resolver": "^4.1.0",
70
70
  "web-did-resolver": "^2.0.32"
71
71
  }
72
- }
72
+ }