@edda-business/mcp 0.69.2 → 0.71.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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/server.js +235 -29
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edda-business/mcp",
3
- "version": "0.69.2",
3
+ "version": "0.71.0",
4
4
  "description": "Edda — the company data layer for AI agents.",
5
5
  "license": "Apache-2.0",
6
6
  "publishConfig": {
package/src/server.js CHANGED
@@ -137,10 +137,27 @@ export function createServer() {
137
137
  if (want === "documents") docs = docs.filter((d) => d.kind === "declared");
138
138
  if (want === "distilled") docs = docs.filter((d) => d.kind === "derived");
139
139
  docs = docs.slice(0, perLimit);
140
- if (!docs.length) {
140
+ // Claims are what the company currently BELIEVES, as opposed to what a document says. They
141
+ // lead the answer: a settled current-state fact beats a page that merely mentions the words,
142
+ // and each carries its standing so a non-binding figure cannot be read as a commitment.
143
+ const claims = want === "distilled" ? [] : (r?.claims ?? []);
144
+
145
+ if (!docs.length && !claims.length) {
141
146
  return text(`Nothing matched "${question}"${want !== "all" ? ` in ${want}` : ""} in the company knowledge.`);
142
147
  }
143
148
  const lines = [`Company knowledge for "${question}"${want !== "all" ? ` (${want})` : ""}:`, ""];
149
+ if (claims.length) {
150
+ lines.push("CURRENT STATE — what this company believes right now:");
151
+ for (const c of claims) {
152
+ const mark = c.standing && c.standing !== "committed" && c.standing !== "reported" ? ` (${c.standing})` : "";
153
+ const infer = c.confidence === "inferred" ? " (inferred)" : "";
154
+ const label = String(c.predicate ?? "").replace(/[_:]/g, " ");
155
+ lines.push(`[claim] ${c.subject ? `${c.subject} · ` : ""}${label}${mark}${infer}: ${c.object}`);
156
+ lines.push(` as of ${String(c.as_of ?? "").slice(0, 10)} · evidence ${c.source?.kind}:${c.source?.id}`);
157
+ }
158
+ lines.push("");
159
+ }
160
+ if (docs.length) lines.push("DOCUMENTS:", "");
144
161
  for (const d of docs) {
145
162
  const declared = d.kind === "declared";
146
163
  const tag = declared ? "[company]" : "[company·distilled]";
@@ -258,39 +275,192 @@ export function createServer() {
258
275
  // ===========================================================================
259
276
  server.tool(
260
277
  "submit_company_candidate",
261
- "SHARE a file up into the COMPANY knowledge — stage one user-approved item into the company " +
262
- "Edda's Inbox. You do NOT pick where it lands: the company routes it into the right folder " +
263
- "itself (on the next reconcile). You may optionally `suggest` a destination as a hint. Use " +
264
- "this to promote a finished note from your own work into the shared company brain. Only stage " +
265
- "what the person has approved sharing this leaves their private vault. (Their private files " +
266
- "stay on their disk; this is the shared company layer.) This stages PAGES (content) only " +
267
- "NEVER folders: if the person asks to create a folder, call `create` instead; folder creation " +
268
- "is direct and immediate, it does not go through the Inbox.",
278
+ "SHARE something up into the COMPANY knowledge — stage one user-approved item into the company " +
279
+ "Edda's Inbox. Takes EITHER markdown you wrote (`content`) OR a real file (`file_base64`): a " +
280
+ "proposal, invoice, report, financial model or scan. The server extracts the text from PDF, " +
281
+ "Word, Excel, images, CSV and plain text, so send the file itself rather than your summary of " +
282
+ "it — a summary loses the numbers. You do NOT pick where it lands: the company routes it into " +
283
+ "the right folder on the next reconcile, and you may `suggest` a destination as a hint. " +
284
+ "Optionally attach `claims` for what this CHANGES about what the company believes an update " +
285
+ "to existing truth is not a new document. Only stage what the person has approved sharing: " +
286
+ "this leaves their private vault. This stages PAGES and FILES, never folders — for a folder " +
287
+ "call `create`, which is direct and does not go through the Inbox.",
269
288
  {
270
- name: z.string().describe("File/page name, e.g. 'MCP Overview EFB'. A '.md' suffix is added if missing."),
271
- content: z.string().describe("The full markdown content of the item to share."),
289
+ name: z.string().describe("File/page name, e.g. 'MCP Overview EFB' or 'Q3 invoices.xlsx'. A '.md' suffix is added to markdown items if missing."),
290
+ content: z.string().optional().describe("The full markdown content, when you are sharing text you wrote. Omit when sending a FILE via file_base64."),
291
+ file_base64: z.string().optional().describe(
292
+ "A real FILE, base64-encoded — a proposal, invoice, report, financial model, scan. The server extracts the text: " +
293
+ "PDF (scans are OCR'd), Word, Excel and legacy .xls (one heading per sheet), images (OCR'd), CSV and plain text. " +
294
+ "A format with no readable text (.zip, .dwg, an installer) is refused with a reason rather than filed as an empty page. " +
295
+ "Send the file itself rather than pasting your own summary of it: the extracted text is what makes it searchable, " +
296
+ "and a summary loses the numbers."
297
+ ),
298
+ mime: z.string().optional().describe("The file's MIME type when known, e.g. application/pdf. Helps route the extractor; the filename extension is used as a fallback."),
272
299
  suggested: z.string().optional().describe("Optional destination HINT, e.g. 'Projects/Data Centre/Working Documents' or 'General Edda'. The company still resolves/creates and places it — this only nudges."),
273
300
  grade: z.enum(["fact", "summary", "full"]).optional().describe("How much of the source this carries: a single fact, a summary, or the full document."),
274
- audience: z.enum(["company-wide", "department", "owner", "filing-agent-decides"]).optional().describe("The MOST this may be seen by (an upper bound the company may narrow, never broaden). Default: filing-agent-decides."),
301
+ audience: z.enum(["company-wide", "department", "owner", "filing-agent-decides"]).optional().describe(
302
+ "The MOST this may be seen by — an upper bound the company may narrow, never broaden. " +
303
+ "LEAVE THIS UNSET unless the person asked you to restrict it: the default lets the company decide, which is almost always right for something being shared UP into the shared brain. " +
304
+ "'owner' means nobody but the submitter can ever see it, including the admin reviewing the Inbox — it does not mean 'be careful', it means the contribution is invisible and has to be promoted by hand. " +
305
+ "Choose it only when the person explicitly said this must stay private to them."
306
+ ),
275
307
  idempotency_key: z.string().optional().describe("Optional stable key so re-submitting the same approved item files it at most once."),
308
+ claims: z.array(z.object({
309
+ predicate: z.string().describe("What is being claimed. Tier 1 (bare): status | next_step | owner | risk | blocker | decision. Tier 2 (root:qualifier): sentiment:<topic> | doc_status:<name> | position:<topic> | availability:<topic>."),
310
+ object: z.string().describe("The value, in plain words, e.g. 'received — Tom is unhappy with the covenant'."),
311
+ standing: z.enum(["committed", "reported", "indicative", "contested"]).optional().describe("How FIRM the fact is, independent of who said it. committed = signed/agreed/done · reported = a source states it, unverified · indicative = explicitly non-binding, draft, conditional · contested = disputed. Default reported. Get this right: an indicative figure recorded as fact reads as a commitment."),
312
+ as_of: z.string().optional().describe("When it became TRUE (YYYY-MM-DD), not when you are writing it down. A late note about an old fact must not overwrite newer knowledge."),
313
+ subject_kind: z.enum(["project", "person", "company", "page"]).optional().describe("Default project — the project this is filed into."),
314
+ subject_id: z.string().optional().describe("Only when the subject is NOT the destination project."),
315
+ confidence: z.enum(["stated", "inferred"]).optional().describe("stated = the source says it outright; inferred = you read it between the lines."),
316
+ })).optional().describe(
317
+ "What this material CHANGES about what the company believes — the point of sharing an update rather than a document. " +
318
+ "'The term sheet arrived and Tom is unhappy with it' is not a document, it is two claims. " +
319
+ "These are PROPOSALS: they appear in the review gate as a belief diff (what we believed → what you are asserting) " +
320
+ "and are applied only when a person approves the item. Cite nothing yourself — the filed document becomes the evidence."
321
+ ),
276
322
  },
277
- async ({ name, content, suggested, grade, audience, idempotency_key }) => {
323
+ async ({ name, content, file_base64, mime, suggested, grade, audience, idempotency_key, claims }) => {
278
324
  try {
279
- const r = await post("/v2/company/river/stage", { name, content, suggested, grade, audience, idempotency_key });
325
+ if (!content && !file_base64) return text(`Give "${name}" either markdown content or a file (file_base64) to stage.`);
326
+ const r = await post("/v2/company/river/stage", { name, content, file_base64, mime, suggested, grade, audience, idempotency_key, claims });
280
327
  const rc = r?.receipt ?? r;
281
328
  if (rc?.idempotent) return text(`Already staged — "${name}" is in the company Inbox (${rc.location}). Nothing duplicated.`);
282
329
  if (rc?.rejected === "hash_mismatch") return text(`Couldn't stage "${name}": the content didn't match its declared checksum (it may have changed after approval). Re-capture and try again.`);
283
330
  if (rc?.staged === false) return text(`Couldn't stage "${name}": ${rc?.reason ?? "unknown error"}.`);
284
- return text(`Staged "${name}" into the company Inbox (${rc?.location ?? "Inbox"}). The company will route it to the right folder on the next reconcile.`);
331
+ const n = Array.isArray(claims) ? claims.length : 0;
332
+ // Say what is waiting, including the belief change — the person needs to know a claim is
333
+ // pending a human decision, not quietly applied.
334
+ return text(
335
+ `Staged "${name}" into the company Inbox (${rc?.location ?? "Inbox"}). The company will route it to the right folder on the next reconcile.`
336
+ + (n ? `\n${n} proposed knowledge update${n === 1 ? "" : "s"} rides with it — shown in the review gate as a belief change and applied only on approval.` : ""),
337
+ );
285
338
  } catch (e) {
286
339
  const msg = String(e?.message || e);
287
- if (msg.includes("admins_only") || msg.includes("(403)")) return text("You don't have a role that can share into this company wiki (members are read-only).");
340
+ if (msg.includes("read_only") || msg.includes("admins_only") || msg.includes("(403)")) return text("This seat is read-only on the company Edda, so it can search and read but not contribute. Ask an admin for a write role.");
341
+ if (msg.includes("no_readable_text")) return text("That file has no text to extract \u2014 it is a binary format. The River reads PDF, Word, Excel, images and scans (OCR\u2019d), CSV and plain text. Filing it as a page would create something that looks like knowledge and answers nothing.");
288
342
  if (msg.includes("name_required")) return text("Give the item a name and content to stage.");
289
343
  throw e;
290
344
  }
291
345
  },
292
346
  );
293
347
 
348
+ // ===========================================================================
349
+ // TOOL: submit_company_batch — POST /v2/company/river/stage-batch
350
+ // One periodic ingest run = one submission. A seat's ingest sweeps the last few hours of email,
351
+ // transcripts, notes and attachments, and produces a handful of documents plus the belief
352
+ // changes they imply. That is one arrival, not fifteen.
353
+ // ===========================================================================
354
+ server.tool(
355
+ "submit_company_batch",
356
+ "SHARE SEVERAL things up into the COMPANY knowledge in one call — the batch form of " +
357
+ "submit_company_candidate, for a periodic ingest run rather than a single share. Use this when " +
358
+ "a sweep produced multiple documents and belief changes at once: the contracts and invoices " +
359
+ "that arrived, plus what they change about what the company believes. Everything lands in the " +
360
+ "company Inbox; you never choose the final destination. A per-item failure does not stop the " +
361
+ "rest — each item gets its own receipt saying what happened. Up to 100 items. " +
362
+ "IMPORTANT: attach `claims` to the item they came from rather than writing a separate " +
363
+ "'here is what changed' document — a belief change carried as structured claims is applied by " +
364
+ "the reviewer's single approval, whereas one buried in prose has to be read and re-derived by " +
365
+ "whoever files it.",
366
+ {
367
+ items: z.array(z.object({
368
+ name: z.string().describe("File/page name, e.g. 'Cook Street construction proposal.pdf'."),
369
+ content: z.string().optional().describe("Markdown content, when sharing text you wrote. Omit when sending a file."),
370
+ file_base64: z.string().optional().describe("The file itself, base64-encoded — PDF, Word, Excel, image, CSV. Send the file rather than a summary of it."),
371
+ mime: z.string().optional().describe("MIME type when known."),
372
+ suggested: z.string().optional().describe("Optional destination HINT. A path you cannot see is dropped, and the item still lands in the Inbox."),
373
+ grade: z.enum(["fact", "summary", "full"]).optional(),
374
+ audience: z.enum(["company-wide", "department", "owner", "filing-agent-decides"]).optional().describe("Leave unset unless the person asked to restrict this item. 'owner' makes it invisible to everyone including the reviewer."),
375
+ idempotency_key: z.string().optional().describe("Stable key so a re-run of the same ingest files each item at most once. Strongly recommended for a periodic sweep."),
376
+ claims: z.array(z.object({
377
+ predicate: z.string().describe("status | next_step | owner | risk | blocker | decision, or a qualified root: sentiment:<topic> | doc_status:<name> | position:<topic> | availability:<topic>."),
378
+ object: z.string().describe("The value in plain words."),
379
+ standing: z.enum(["committed", "reported", "indicative", "contested"]).optional().describe("How firm the fact is. committed = signed/agreed/done · reported = a source states it · indicative = non-binding, draft, conditional · contested = disputed."),
380
+ as_of: z.string().optional().describe("When it became TRUE (YYYY-MM-DD), not when you wrote it down."),
381
+ subject_kind: z.enum(["project", "person", "company", "page"]).optional(),
382
+ subject_id: z.string().optional(),
383
+ confidence: z.enum(["stated", "inferred"]).optional(),
384
+ })).optional().describe("What THIS item changes about what the company believes."),
385
+ })).max(100).describe("The items from one ingest run."),
386
+ },
387
+ async ({ items }) => {
388
+ try {
389
+ const r = await post("/v2/company/river/stage-batch", { candidates: items });
390
+ const staged = r?.staged ?? 0;
391
+ const failed = r?.failed ?? 0;
392
+ const lines = [`Staged ${staged} item(s) into the company Inbox${failed ? `, ${failed} rejected` : ""}.`];
393
+ for (const rec of r?.receipts ?? []) {
394
+ if (rec.staged === false) lines.push(` ✗ ${rec.name ?? "(unnamed)"} — ${rec.reason ?? rec.rejected ?? "unknown"}`);
395
+ else if (rec.suggestion_dropped) lines.push(` • ${rec.name} — staged; your suggested destination was dropped (not visible to you)`);
396
+ }
397
+ lines.push("", "They wait for a person to review and file them. Nothing has been placed in a project yet.");
398
+ return text(lines.join("\n"));
399
+ } catch (e) {
400
+ const msg = String(e?.message || e);
401
+ if (msg.includes("read_only") || msg.includes("(403)")) return text("This seat is read-only on the company Edda, so it cannot contribute.");
402
+ if (msg.includes("too_many")) return text("Too many items — send at most 100 per call.");
403
+ throw e;
404
+ }
405
+ },
406
+ );
407
+
408
+ // ===========================================================================
409
+ // TOOL: submit_belief_update — POST /v2/company/river/stage-claims
410
+ // A correction with no document. The last piece of "updates to existing truth, not necessarily
411
+ // specific documents": until this existed, an agent holding a pure correction had to invent a
412
+ // document to carry it.
413
+ // ===========================================================================
414
+ server.tool(
415
+ "submit_belief_update",
416
+ "CORRECT what the company believes about a project, when NO new document arrived. Use this " +
417
+ "when something already filed is now out of date: the term sheet you filed last week has been " +
418
+ "rejected, the deadline moved, the owner changed. It creates NO page — approving it updates " +
419
+ "the project's current state and nothing else. " +
420
+ "Use submit_company_candidate instead whenever actual material arrived (a file, a note, a " +
421
+ "transcript); the claims ride along with it and one approval applies both. " +
422
+ "NEVER write a document whose only purpose is to describe a change — that is what this tool " +
423
+ "replaces. " +
424
+ "`source_refs` is required and must point at evidence ALREADY filed and live in the company " +
425
+ "brain: a correction has to point at something. If you have nothing to point at, what you are " +
426
+ "holding is new material — submit it as a document. Like every contribution this waits in the " +
427
+ "company Inbox for a person to approve.",
428
+ {
429
+ folder_id: z.string().describe("The project whose current state changes. Get it from `orientation` or `search`."),
430
+ claims: z.array(z.object({
431
+ predicate: z.string().describe("status | next_step | owner | risk | blocker | decision, or a qualified root: sentiment:<topic> | doc_status:<name> | position:<topic> | availability:<topic>."),
432
+ object: z.string().describe("What is true now, in plain words."),
433
+ standing: z.enum(["committed", "reported", "indicative", "contested"]).optional().describe("committed = signed/agreed/done · reported = a source states it · indicative = non-binding, draft, conditional · contested = disputed. A weaker claim never retires a firmer one."),
434
+ as_of: z.string().optional().describe("When it became TRUE (YYYY-MM-DD), not when you learned it."),
435
+ confidence: z.enum(["stated", "inferred"]).optional(),
436
+ })).max(25).describe("The beliefs that changed. One per fact — do not pack two facts into one sentence."),
437
+ source_refs: z.array(z.object({
438
+ kind: z.enum(["company_file", "river_original", "river_receipt"]),
439
+ id: z.string(),
440
+ })).describe("Evidence already live in the brain that this correction rests on."),
441
+ note: z.string().optional().describe("One line of context for the reviewer, if the claims alone would not explain themselves."),
442
+ idempotency_key: z.string().optional().describe("Stable key so a re-run of the same ingest proposes this at most once."),
443
+ },
444
+ async ({ folder_id, claims, source_refs, note, idempotency_key }) => {
445
+ try {
446
+ const r = await post("/v2/company/river/stage-claims", { folder_id, claims, source_refs, note, idempotency_key });
447
+ const lines = claims.map((c) => ` · ${String(c.predicate).replace(/[_:]/g, " ")}: ${c.object}${c.standing ? ` (${c.standing})` : ""}`);
448
+ return text(
449
+ `Belief update waiting in the company Inbox${r?.idempotent ? " (already submitted earlier — not duplicated)" : ""}:\n`
450
+ + lines.join("\n")
451
+ + "\n\nA person reviews it. Approving updates the project's current state; no document is created."
452
+ );
453
+ } catch (e) {
454
+ const msg = String(e?.message || e);
455
+ if (msg.includes("source_refs_required")) return text("A correction has to cite evidence already filed in the brain. If you have none, submit the material as a document with submit_company_candidate instead.");
456
+ if (msg.includes("bad_source_refs")) return text(`That evidence could not be verified: ${msg}. Cite only documents that are already filed and live.`);
457
+ if (msg.includes("folder_not_visible")) return text("You do not have access to that project, so you cannot change what the company believes about it.");
458
+ if (msg.includes("read_only") || msg.includes("(403)")) return text("This seat is read-only on the company Edda, so it cannot contribute.");
459
+ throw e;
460
+ }
461
+ },
462
+ );
463
+
294
464
  // ===========================================================================
295
465
  // TOOL: inbox — GET /v2/company/inbox
296
466
  // The waiting room the agent-run ingest reads (Decision Log 2026-09-04: the agent is
@@ -298,24 +468,60 @@ export function createServer() {
298
468
  // ===========================================================================
299
469
  server.tool(
300
470
  "inbox",
301
- "LIST what is waiting in the company Inbox each staged item with its id, submitter, the " +
302
- "submitter's suggested destination (if any), and a content preview. This is the start of the " +
303
- "company ingest: read each item (use `read` for the full text if the preview is not enough), " +
304
- "decide where it belongs among the real projects, then file it with " +
305
- "reconcile { stagingId, path }. Admin/ambassador only.",
306
- {},
307
- async () => {
471
+ "LIST what is waiting in the company Inbox, AND what was decided about the things you sent. " +
472
+ "Each waiting item comes with its id, submitter, suggested destination and a content preview. " +
473
+ "This is the start of the company ingest: read each item (use `read` for the full text if the " +
474
+ "preview is not enough), decide where it belongs among the real projects, then file it with " +
475
+ "reconcile { stagingId, path }. An ADMIN sees everything waiting; a MEMBER sees only their own " +
476
+ "submissions. Pass `since` (ISO timestamp) to see only decisions made after your last run — " +
477
+ "that is the cursor a periodic ingest should keep, so it can tell its user what was filed and " +
478
+ "what was rejected, and stop re-sending things that were turned down.",
479
+ {
480
+ since: z.string().optional().describe("ISO timestamp of your last ingest run. Decisions after it are reported back; omit for the last 7 days."),
481
+ },
482
+ async ({ since }) => {
308
483
  try {
484
+ // Two questions in one call, because a periodic ingest asks both every time: what still
485
+ // needs filing, and what happened to what I already sent. The second half is the only
486
+ // signal a submitting agent ever gets — without it, it re-sends rejected items forever.
487
+ const cursor = since || new Date(Date.now() - 7 * 24 * 3600 * 1000).toISOString();
488
+ const decided = await get(`/v2/company/river/receipts?limit=50&since=${encodeURIComponent(cursor)}`)
489
+ .then((d) => d?.receipts ?? [])
490
+ .catch(() => []);
491
+ const outcomes = decided.length
492
+ ? "\n\n" + `Decided since ${cursor.slice(0, 16).replace("T", " ")}:\n`
493
+ + decided.map((d) => {
494
+ const head = d.status === "rejected" ? `✗ ${d.name} — rejected` : `✓ ${d.name} → ${d.location}`;
495
+ const why = d.detail ? ` — ${d.detail}` : (d.status === "rejected" ? " (no reason given)" : "");
496
+ const cl = d.claims_asserted ? ` · updated ${d.claims_asserted} belief(s)` : "";
497
+ return ` ${head}${why}${cl}`;
498
+ }).join("\n")
499
+ : "";
500
+
309
501
  const r = await get("/v2/company/inbox");
310
502
  const pending = r?.pending ?? [];
311
- if (!pending.length) return text("The company Inbox is empty — nothing waiting.");
312
- const lines = pending.map((f) =>
313
- `• ${f.name} (stagingId: ${f.id})\n staged ${String(f.created_at).slice(0, 10)}` +
314
- `${f.suggested ? ` · suggested: ${f.suggested}` : ""}\n ${String(f.preview || "").replace(/\s+/g, " ").slice(0, 240)}`);
315
- return text(`${pending.length} item(s) waiting in the company Inbox:\n\n${lines.join("\n\n")}`);
503
+ if (!pending.length) return text(`The company Inbox is empty — nothing waiting.${outcomes}`);
504
+ const lines = pending.map((f) => {
505
+ // The belief diff, beside the destination. An admin running the ingest is deciding on
506
+ // BOTH where the document goes and what it changes about what the company believes —
507
+ // and the second one is invisible unless it is written down here.
508
+ const changes = (f.changes ?? []).map((c) => {
509
+ if (c.invalid) return ` ! ${c.predicate}: ${c.invalid}`;
510
+ const mark = c.standing && c.standing !== "committed" && c.standing !== "reported" ? ` (${c.standing})` : "";
511
+ const label = String(c.predicate).replace(/[_:]/g, " ");
512
+ return c.from
513
+ ? ` ${label}${mark}: ${c.from} → ${c.to}`
514
+ : ` ${label}${mark}: (new) ${c.to}`;
515
+ });
516
+ return `• ${f.name} (stagingId: ${f.id})\n staged ${String(f.created_at).slice(0, 10)}`
517
+ + `${f.suggested ? ` · suggested: ${f.suggested}` : ""}`
518
+ + `\n ${String(f.preview || "").replace(/\s+/g, " ").slice(0, 240)}`
519
+ + (changes.length ? `\n changes what we believe:\n${changes.join("\n")}` : "");
520
+ });
521
+ return text(`${pending.length} item(s) waiting in the company Inbox:\n\n${lines.join("\n\n")}${outcomes}`);
316
522
  } catch (e) {
317
523
  const msg = String(e?.message || e);
318
- if (msg.includes("admins_only") || msg.includes("(403)")) return text("Only an admin or ambassador can read the company Inbox.");
524
+ if (msg.includes("read_only") || msg.includes("(403)")) return text("This seat is read-only on the company Edda, so it has nothing waiting in the Inbox to show.");
319
525
  throw e;
320
526
  }
321
527
  },