@oharness/server 0.9.2 → 0.10.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 (61) hide show
  1. package/dist/i18n.js +356 -62
  2. package/dist/i18n.js.map +1 -1
  3. package/dist/main.js +4 -0
  4. package/dist/main.js.map +1 -1
  5. package/dist/page/client/account.js +430 -23
  6. package/dist/page/client/account.js.map +1 -1
  7. package/dist/page/client/base.js +20 -1
  8. package/dist/page/client/base.js.map +1 -1
  9. package/dist/page/client/composer.js +709 -58
  10. package/dist/page/client/composer.js.map +1 -1
  11. package/dist/page/client/computer.d.ts +27 -0
  12. package/dist/page/client/computer.js +358 -0
  13. package/dist/page/client/computer.js.map +1 -0
  14. package/dist/page/client/dialogs.js +93 -6
  15. package/dist/page/client/dialogs.js.map +1 -1
  16. package/dist/page/client/events.js +94 -7
  17. package/dist/page/client/events.js.map +1 -1
  18. package/dist/page/client/files.js +86 -1
  19. package/dist/page/client/files.js.map +1 -1
  20. package/dist/page/client/index.js +4 -0
  21. package/dist/page/client/index.js.map +1 -1
  22. package/dist/page/client/panels.js +12 -6
  23. package/dist/page/client/panels.js.map +1 -1
  24. package/dist/page/client/routines.js +296 -41
  25. package/dist/page/client/routines.js.map +1 -1
  26. package/dist/page/client/shell.js +229 -51
  27. package/dist/page/client/shell.js.map +1 -1
  28. package/dist/page/client/state.js +273 -19
  29. package/dist/page/client/state.js.map +1 -1
  30. package/dist/page/client/tasks.d.ts +8 -0
  31. package/dist/page/client/tasks.js +293 -0
  32. package/dist/page/client/tasks.js.map +1 -0
  33. package/dist/page/client/transcript.js +174 -6
  34. package/dist/page/client/transcript.js.map +1 -1
  35. package/dist/page/client/usage.js +1 -11
  36. package/dist/page/client/usage.js.map +1 -1
  37. package/dist/page/style.js +706 -104
  38. package/dist/page/style.js.map +1 -1
  39. package/dist/page.js +161 -54
  40. package/dist/page.js.map +1 -1
  41. package/dist/prefs.d.ts +14 -0
  42. package/dist/prefs.js +8 -1
  43. package/dist/prefs.js.map +1 -1
  44. package/dist/remote-crypto.d.ts +78 -0
  45. package/dist/remote-crypto.js +216 -0
  46. package/dist/remote-crypto.js.map +1 -0
  47. package/dist/remote.d.ts +103 -0
  48. package/dist/remote.js +498 -0
  49. package/dist/remote.js.map +1 -0
  50. package/dist/server.d.ts +70 -0
  51. package/dist/server.js +1291 -49
  52. package/dist/server.js.map +1 -1
  53. package/dist/starters.d.ts +92 -0
  54. package/dist/starters.js +213 -0
  55. package/dist/starters.js.map +1 -0
  56. package/dist/theme.js +5 -0
  57. package/dist/theme.js.map +1 -1
  58. package/dist/turn-shots.d.ts +27 -0
  59. package/dist/turn-shots.js +71 -0
  60. package/dist/turn-shots.js.map +1 -0
  61. package/package.json +2 -2
@@ -8,6 +8,9 @@ export const SHELL = String.raw `
8
8
 
9
9
  const projectBtn = document.getElementById("project");
10
10
  let projects = [];
11
+ /** Session directories that are worktrees, each naming the project it came from. */
12
+ let worktrees = [];
13
+ const worktreeAt = (cwd) => worktrees.find((one) => one.path === cwd);
11
14
 
12
15
  /** The last path segment, which is what the directory is called. */
13
16
  function projectName(dir) {
@@ -25,6 +28,7 @@ function paintProject() {
25
28
  // it too, or it goes on advertising the tree the user just left.
26
29
  const cwdEl = document.getElementById("empty-cwd");
27
30
  if (cwdEl && active) cwdEl.textContent = active.name;
31
+ if (active) void paintStarters(active.path);
28
32
  // A directory added while the chip was open belongs on its list.
29
33
  refreshWorkspace();
30
34
  }
@@ -50,6 +54,7 @@ function repaintPanel() {
50
54
  async function loadProjects() {
51
55
  const body = await getJson("/projects");
52
56
  projects = body.projects || [];
57
+ worktrees = body.worktrees || [];
53
58
  projectOrder = body.order || [];
54
59
  paintProject();
55
60
  // A project's pin and its place decide how the session list is grouped, so
@@ -294,6 +299,12 @@ const IS_WIN = /win/i.test(navigator.platform || navigator.userAgent || "");
294
299
  /** The rows the current query left on screen, in the order they are drawn. */
295
300
  let paletteRows = [];
296
301
  let paletteCursor = 0;
302
+ /**
303
+ * Whether the palette was opened from the sidebar's "Search sessions": then it
304
+ * lists sessions and nothing else, and every one of them rather than the
305
+ * recent eight — the question asked was "where is that conversation".
306
+ */
307
+ let paletteSessionsOnly = false;
297
308
 
298
309
  /** A nav item's own icon, so the palette names things the way the sidebar does. */
299
310
  function navIcon(id) {
@@ -315,6 +326,7 @@ const enLabel = (key) => (STRINGS.en && STRINGS.en[key]) || "";
315
326
  function paletteItems() {
316
327
  const items = [];
317
328
  const query = paletteField.value.trim();
329
+ if (paletteSessionsOnly) return sessionItems(sessionRows);
318
330
  const actions = t("palette.actions");
319
331
  const here = (projects.find((project) => project.current) || {}).path || currentCwd;
320
332
 
@@ -342,6 +354,7 @@ function paletteItems() {
342
354
  for (const [id, key] of [
343
355
  ["nav-artifacts", "nav.artifacts"],
344
356
  ["nav-sessions", "nav.sessions"],
357
+ ["nav-usage", "nav.usage"],
345
358
  ["nav-models", "nav.models"],
346
359
  ["nav-routines", "nav.routines"],
347
360
  ["nav-settings", "nav.settings"],
@@ -359,21 +372,7 @@ function paletteItems() {
359
372
  // Unfiltered, the list is a preview of the most recent work and the actions
360
373
  // above it still have to be visible. Once there is a query, every session in
361
374
  // every tree is fair game — that is what the field is for.
362
- const sessions = t("palette.sessions");
363
- for (const session of query ? sessionRows : sessionRows.slice(0, 8)) {
364
- items.push({
365
- kind: "session",
366
- group: sessions,
367
- icon: () => svgIcon("chat"),
368
- label: session.title || t("session.untitled"),
369
- sub: [projectName(session.cwd || ""), ago(session.updatedAt)].filter(Boolean).join(" · "),
370
- keywords: session.cwd || "",
371
- run: () => {
372
- if (session.current) return void setNav(null);
373
- void openSession(session.id, session.cwd);
374
- },
375
- });
376
- }
375
+ items.push(...sessionItems(query ? sessionRows : sessionRows.slice(0, 8)));
377
376
 
378
377
  const named = t("palette.projects");
379
378
  for (const project of query ? projects : projects.slice(0, 5)) {
@@ -394,6 +393,23 @@ function paletteItems() {
394
393
  return items;
395
394
  }
396
395
 
396
+ /** Palette rows for sessions, each reopening its own transcript. */
397
+ function sessionItems(rows) {
398
+ const group = t("palette.sessions");
399
+ return rows.map((session) => ({
400
+ kind: "session",
401
+ group,
402
+ icon: () => svgIcon("chat"),
403
+ label: session.title || t("session.untitled"),
404
+ sub: [projectName(session.cwd || ""), ago(session.updatedAt)].filter(Boolean).join(" · "),
405
+ keywords: session.cwd || "",
406
+ run: () => {
407
+ if (session.current) return void setNav(null);
408
+ void openSession(session.id, session.cwd);
409
+ },
410
+ }));
411
+ }
412
+
397
413
  /** Same word-by-word match the pickers use, over the row's hidden aliases too. */
398
414
  const paletteMatch = (item, query) =>
399
415
  matchesQuery(
@@ -454,12 +470,14 @@ function movePalette(step) {
454
470
  if (active) active.scrollIntoView({ block: "nearest" });
455
471
  }
456
472
 
457
- function openPalette() {
473
+ function openPalette(sessionsOnly = false) {
458
474
  // One modal at a time. A permission request is a question the agent is
459
475
  // blocked on; a palette over it would answer neither.
460
476
  if (document.querySelector("dialog[open]")) return;
461
477
  closeAttachMenu();
462
478
  closeRowMenu();
479
+ paletteSessionsOnly = sessionsOnly === true;
480
+ paletteField.placeholder = t(paletteSessionsOnly ? "palette.sessionsPlaceholder" : "palette.placeholder");
463
481
  paletteField.value = "";
464
482
  paletteCursor = 0;
465
483
  paintPalette();
@@ -471,7 +489,7 @@ function openPalette() {
471
489
  // The desktop shell reaches the palette through this: a menu accelerator is
472
490
  // handled before the page ever sees the keystroke, so the menu item calls the
473
491
  // same function the key would have.
474
- window.openCommandPalette = openPalette;
492
+ window.openCommandPalette = () => openPalette();
475
493
 
476
494
  paletteField.addEventListener("input", () => {
477
495
  paletteCursor = 0;
@@ -520,7 +538,8 @@ document.addEventListener("keydown", (e) => {
520
538
  openPalette();
521
539
  });
522
540
 
523
- document.getElementById("nav-search").addEventListener("click", openPalette);
541
+ document.getElementById("nav-search").addEventListener("click", () => openPalette());
542
+ document.getElementById("side-search").addEventListener("click", () => openPalette(true));
524
543
  // The chord as this machine actually spells it, in the one place a mouse user
525
544
  // will see it.
526
545
  document.getElementById("palette-hint").textContent = IS_MAC ? "⌘K" : "Ctrl K";
@@ -540,43 +559,151 @@ function applyStrings() {
540
559
  for (const node of document.querySelectorAll("[data-t-aria-label]")) {
541
560
  node.setAttribute("aria-label", ts(node.dataset.tAriaLabel));
542
561
  }
562
+ // The rail shows icons only, so each names itself to a screen reader in the
563
+ // words its hidden label carries; the hover label below reads the same.
564
+ for (const item of document.querySelectorAll("#rail .nav-item")) {
565
+ const label = item.querySelector("[data-t]");
566
+ if (label) item.setAttribute("aria-label", label.textContent);
567
+ }
543
568
  document.documentElement.lang = LOCALE;
544
569
  }
545
570
 
571
+ /*
572
+ * The rail's names, beside the icon the pointer is on.
573
+ *
574
+ * Not the title attribute: the browser shows that after a second's pause, in
575
+ * its own style, and an icon that only says what it is if you wait is one
576
+ * people click to find out. One label for the whole rail, placed against the
577
+ * button, because the rail clips anything that tries to hang out of it.
578
+ */
579
+ const railTip = document.createElement("div");
580
+ railTip.className = "rail-tip";
581
+ railTip.setAttribute("aria-hidden", "true");
582
+ document.body.appendChild(railTip);
583
+ function showRailTip(item) {
584
+ const name = item.getAttribute("aria-label");
585
+ if (!name) return;
586
+ const box = item.getBoundingClientRect();
587
+ railTip.textContent = name;
588
+ railTip.style.left = box.right + 8 + "px";
589
+ railTip.style.top = box.top + box.height / 2 + "px";
590
+ railTip.classList.add("on");
591
+ }
592
+ const hideRailTip = () => railTip.classList.remove("on");
593
+ const rail = document.getElementById("rail");
594
+ rail.addEventListener("mouseover", (event) => {
595
+ const item = event.target.closest(".nav-item");
596
+ if (item) showRailTip(item);
597
+ else hideRailTip();
598
+ });
599
+ rail.addEventListener("mouseleave", hideRailTip);
600
+ rail.addEventListener("focusin", (event) => {
601
+ const item = event.target.closest(".nav-item");
602
+ if (item && item.matches(":focus-visible")) showRailTip(item);
603
+ });
604
+ rail.addEventListener("focusout", hideRailTip);
605
+ rail.addEventListener("click", hideRailTip);
606
+
546
607
  /**
547
- * What the empty state offers to do, grouped by the kind of work it is.
608
+ * What the empty state offers to do.
548
609
  *
549
- * One chip is drawn from each group, so the three on screen always span
550
- * writing code, getting through the day's work, and taking the thing to
551
- * market — three variations on "read my repo" would teach a new person that
552
- * this is a code-reading tool and nothing else. Each id names a label and a
553
- * "…prompt" string; the label is what fits on
554
- * a chip, the prompt is the sentence the chip actually sends.
610
+ * The server decides which kind: a project with no memory gets a rotating pool
611
+ * of everyday offers, and one the agent has notes on gets three written from
612
+ * those notes (see ../../starters.ts). Each chip's label is what fits on it,
613
+ * and the sentence it actually sends rides along in data-p.
555
614
  */
556
- const STARTERS = [
557
- ["explain", "bugs", "tests", "review", "faster"],
558
- ["plan", "docs", "digest", "automate", "backlog"],
559
- ["landing", "launch", "pitch", "position", "changelog"],
560
- ];
615
+ const FIXED_STARTERS = ["trip", "email", "research", "savings", "japanTrip"];
616
+ const VISIBLE_STARTERS = 3;
617
+ const STARTER_ROTATION_MS = 6000;
618
+ /** Which project the chips on screen were drawn for, and the newest ask. */
619
+ let startersFor = "";
620
+ let startersAsk = 0;
621
+ let starterOffset = 0;
622
+ let starterTimer;
623
+
624
+ function starterChip(label, prompt) {
625
+ const button = el("button", "", label);
626
+ button.type = "button";
627
+ button.dataset.p = prompt;
628
+ // The label is a chip; the title is the sentence it will actually send.
629
+ button.title = prompt;
630
+ return button;
631
+ }
632
+
633
+ function animateStarters(box) {
634
+ box.classList.remove("fresh");
635
+ void box.offsetWidth;
636
+ box.classList.add("fresh");
637
+ }
638
+
639
+ function stopStarterRotation() {
640
+ clearInterval(starterTimer);
641
+ starterTimer = undefined;
642
+ }
643
+
644
+ function drawFixedStarters(box, animate = false) {
645
+ const ids = Array.from({ length: VISIBLE_STARTERS }, (_, index) =>
646
+ FIXED_STARTERS[(starterOffset + index) % FIXED_STARTERS.length]);
647
+ box.replaceChildren(...ids.map((id) => starterChip(t("starter." + id), t("starter." + id + ".prompt"))));
648
+ if (animate) animateStarters(box);
649
+ }
650
+
651
+ function startStarterRotation(box) {
652
+ stopStarterRotation();
653
+ if (FIXED_STARTERS.length <= VISIBLE_STARTERS) return;
654
+ starterTimer = setInterval(() => {
655
+ // Do not move a choice out from under a mouse pointer or keyboard focus.
656
+ if (!box.offsetParent || box.matches(":hover") || box.matches(":focus-within")) return;
657
+ starterOffset = (starterOffset + 1) % FIXED_STARTERS.length;
658
+ drawFixedStarters(box, true);
659
+ }, STARTER_ROTATION_MS);
660
+ }
561
661
 
562
662
  /**
563
- * Draw one chip per group. The sentence rides along in data-p, and the click
564
- * is wired at the end of boot with the rest of them: a chip that submits before
565
- * the session it would submit to has loaded is worse than one that waits.
663
+ * Ask for the project's chips and draw them. Skipped when the project on
664
+ * screen has not changed, so a repaint of the project button does not pay for
665
+ * another round trip. The answer never waits on a model: a project whose set
666
+ * is not written yet gets the fixed three now, and the written set arrives
667
+ * later as a "starters" event (see startersArrived).
566
668
  */
567
- function paintStarters() {
669
+ async function paintStarters(cwd) {
568
670
  const box = document.getElementById("starters");
569
- if (!box) return;
671
+ if (!box || !cwd || cwd === startersFor) return;
672
+ stopStarterRotation();
673
+ startersFor = cwd;
674
+ starterOffset = 0;
675
+ const ask = ++startersAsk;
570
676
  box.replaceChildren();
571
- for (const group of STARTERS) {
572
- const id = group[Math.floor(Math.random() * group.length)];
573
- const button = el("button", "", t("starter." + id));
574
- button.type = "button";
575
- button.dataset.p = t("starter." + id + ".prompt");
576
- // The label is a chip; the title is the sentence it will actually send.
577
- button.title = button.dataset.p;
578
- box.appendChild(button);
677
+ let body;
678
+ try {
679
+ body = await getJson("/starters?cwd=" + encodeURIComponent(cwd) + "&locale=" + encodeURIComponent(LOCALE));
680
+ } catch {
681
+ body = { kind: "fixed" };
579
682
  }
683
+ // A switch made while this was being written owns the box now.
684
+ if (ask !== startersAsk) return;
685
+ if (body && body.kind === "written" && Array.isArray(body.starters)) {
686
+ box.replaceChildren(...body.starters.map((item) => starterChip(item.label, item.prompt)));
687
+ } else {
688
+ drawFixedStarters(box);
689
+ startStarterRotation(box);
690
+ }
691
+ }
692
+
693
+ /**
694
+ * A set written in the background for a project, pushed when it lands.
695
+ *
696
+ * Swapped in only where it still fits: the same project and language on
697
+ * screen, and the empty state still showing — nobody's chip is replaced while
698
+ * they are reading a conversation, and the next visit reads it from disk.
699
+ */
700
+ function startersArrived(event) {
701
+ const box = document.getElementById("starters");
702
+ if (!box || !event || event.cwd !== startersFor || event.locale !== LOCALE || !Array.isArray(event.starters)) return;
703
+ if (!box.offsetParent) return;
704
+ stopStarterRotation();
705
+ box.replaceChildren(...event.starters.map((item) => starterChip(item.label, item.prompt)));
706
+ animateStarters(box);
580
707
  }
581
708
 
582
709
  /**
@@ -628,9 +755,52 @@ if (IS_DESKTOP && window.oharness.updates) {
628
755
  window.oharness.updates.state().then(paintUpdateHint, () => {});
629
756
  }
630
757
 
758
+ /**
759
+ * A prompt from a link on the website — oharness://new?prompt=… — opened as a
760
+ * new session with the text in the composer, and nothing more.
761
+ *
762
+ * The main process checked and cleaned the text (desktop/deep-link.js), but
763
+ * it came from whatever page the person was on, so this page treats it as
764
+ * someone else's suggestion: it is never sent, no permission is changed and
765
+ * no card is answered. It sits in the box until the person reads it and
766
+ * presses send, or deletes it. A new session rather than the open one, so the
767
+ * suggestion never lands in the middle of a conversation as if it belonged
768
+ * there; and a draft already in the box is only replaced when the person says
769
+ * so, because the box is shared by every tab and the draft may be the only
770
+ * copy of what they were writing.
771
+ *
772
+ * Not before boot has finished: switching sessions while the first one is
773
+ * still being opened leaves the two fighting over the page. Boot takes the
774
+ * one that launched the app at the end; the nudge covers every later click.
775
+ */
776
+ let deepLinkReady = false;
777
+
778
+ async function takeDeepLink() {
779
+ if (!deepLinkReady || !IS_DESKTOP || !window.oharness.deepLink) return;
780
+ let text;
781
+ try {
782
+ text = await window.oharness.deepLink.take();
783
+ } catch {
784
+ return;
785
+ }
786
+ if (typeof text !== "string" || !text) return;
787
+ const draft = input.value.trim();
788
+ if (draft && draft !== text && !confirm(t("deepLink.replaceDraft"))) return;
789
+ await switchTo({ action: "new" });
790
+ input.value = text;
791
+ // Called rather than an input event, which would also open the command
792
+ // menu for a prompt that starts with "/" — one Enter from running it.
793
+ autosize();
794
+ paintMirror();
795
+ input.focus();
796
+ input.setSelectionRange(input.value.length, input.value.length);
797
+ }
798
+ if (IS_DESKTOP && window.oharness.deepLink) {
799
+ window.oharness.deepLink.onArrived(() => void takeDeepLink());
800
+ }
801
+
631
802
  void (async () => {
632
803
  applyStrings();
633
- paintStarters();
634
804
  // The language is here because it is the one thing that decides what to
635
805
  // translate next, and the shell because a desktop window and a browser tab
636
806
  // are different products to the person using them.
@@ -685,18 +855,26 @@ void (async () => {
685
855
  await loadSidePanels();
686
856
  await loadProjects();
687
857
 
688
- for (const button of document.querySelectorAll("#starters button")) {
689
- button.addEventListener("click", () => {
690
- input.value = button.dataset.p;
691
- paintMirror();
692
- document.getElementById("composer").requestSubmit();
693
- });
694
- }
858
+ // Wired here, after the session has loaded: a chip that submits before the
859
+ // session it would submit to exists is worse than one that waits. Delegated,
860
+ // because the chips are redrawn on every project switch. Absent when the tab
861
+ // opened on a conversation: its first entry took the invitation off the
862
+ // page, and a throw here stopped everything after it, the session list too.
863
+ document.getElementById("starters")?.addEventListener("click", (event) => {
864
+ const button = event.target.closest("#starters button");
865
+ if (!button) return;
866
+ stopStarterRotation();
867
+ input.value = button.dataset.p;
868
+ paintMirror();
869
+ document.getElementById("composer").requestSubmit();
870
+ });
695
871
  await loadSessions();
696
872
  await refreshRoutineCount();
697
873
  await refreshPluginPages();
698
874
  await refreshModelCount();
699
875
  await loadAccount();
700
876
  input.focus();
877
+ deepLinkReady = true;
878
+ await takeDeepLink();
701
879
  })();`;
702
880
  //# sourceMappingURL=shell.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"shell.js","sourceRoot":"","sources":["../../../src/page/client/shell.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;MAurBzB,CAAC"}
1
+ {"version":3,"file":"shell.js","sourceRoot":"","sources":["../../../src/page/client/shell.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;MAy2BzB,CAAC"}