@ganglion/xacpx-relay 0.5.2 → 0.8.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.
@@ -0,0 +1,9 @@
1
+ export interface RelayUpdateDeps {
2
+ readCurrentVersion: () => string;
3
+ getLatestVersion: () => Promise<string | null>;
4
+ updateSelf: () => Promise<void>;
5
+ print: (line: string) => void;
6
+ }
7
+ /** `xacpx-relay update [--check]` — self-update the hub package. `--check` only
8
+ * reports current vs latest. Returns the process exit code. */
9
+ export declare function handleRelayUpdate(args: string[], deps?: Partial<RelayUpdateDeps>): Promise<number>;
package/dist/cli.js CHANGED
@@ -6,8 +6,8 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
6
6
  import { randomUUID as randomUUID3 } from "node:crypto";
7
7
  import { existsSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
- import { dirname as dirname2, join, resolve } from "node:path";
10
- import { fileURLToPath } from "node:url";
9
+ import { dirname as dirname3, join as join2, resolve } from "node:path";
10
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
11
11
 
12
12
  // packages/relay/src/server.ts
13
13
  import { serve } from "@hono/node-server";
@@ -101,10 +101,15 @@ function initSchema(db) {
101
101
  direction TEXT NOT NULL CHECK (direction IN ('in','out')),
102
102
  text TEXT NOT NULL,
103
103
  created_at TEXT NOT NULL,
104
- structured TEXT
104
+ structured TEXT,
105
+ attachments TEXT
105
106
  );
106
107
  CREATE INDEX IF NOT EXISTS idx_messages_session ON messages (instance_id, session_alias, id);
107
108
  `);
109
+ const messageCols = db.all("PRAGMA table_info(messages)");
110
+ if (!messageCols.some((c) => c.name === "attachments")) {
111
+ db.exec("ALTER TABLE messages ADD COLUMN attachments TEXT");
112
+ }
108
113
  }
109
114
 
110
115
  // packages/relay/src/stores/accounts.ts
@@ -363,13 +368,21 @@ class MessageStore {
363
368
  this.db = db;
364
369
  this.now = now;
365
370
  }
366
- append(instanceId, sessionAlias, direction, text, structured) {
367
- this.db.run("INSERT INTO messages (instance_id, session_alias, direction, text, created_at, structured) VALUES (?,?,?,?,?,?)", [instanceId, sessionAlias, direction, text, this.now().toISOString(), structured ? JSON.stringify(structured) : null]);
371
+ append(instanceId, sessionAlias, direction, text, structured, attachments) {
372
+ this.db.run("INSERT INTO messages (instance_id, session_alias, direction, text, created_at, structured, attachments) VALUES (?,?,?,?,?,?,?)", [
373
+ instanceId,
374
+ sessionAlias,
375
+ direction,
376
+ text,
377
+ this.now().toISOString(),
378
+ structured ? JSON.stringify(structured) : null,
379
+ attachments && attachments.length > 0 ? JSON.stringify(attachments) : null
380
+ ]);
368
381
  }
369
382
  listBySession(accountId, instanceId, sessionAlias, opts = {}) {
370
383
  const limit = opts.limit ?? 100;
371
384
  const before = opts.before ?? null;
372
- const rows = this.db.all(`SELECT m.id, m.instance_id, m.session_alias, m.direction, m.text, m.created_at, m.structured
385
+ const rows = this.db.all(`SELECT m.id, m.instance_id, m.session_alias, m.direction, m.text, m.created_at, m.structured, m.attachments
373
386
  FROM messages m JOIN instances i ON i.id = m.instance_id
374
387
  WHERE i.account_id = ? AND m.instance_id = ? AND m.session_alias = ?
375
388
  AND (? IS NULL OR m.id < ?)
@@ -385,7 +398,8 @@ class MessageStore {
385
398
  direction: r.direction,
386
399
  text: r.text,
387
400
  createdAt: r.created_at,
388
- ...r.structured ? { structured: JSON.parse(r.structured) } : {}
401
+ ...r.structured ? { structured: JSON.parse(r.structured) } : {},
402
+ ...r.attachments ? { attachments: JSON.parse(r.attachments) } : {}
389
403
  }))
390
404
  };
391
405
  }
@@ -602,6 +616,124 @@ function clientIp(c, trustProxy) {
602
616
  }
603
617
  }
604
618
 
619
+ // packages/relay/src/version.ts
620
+ import { readFileSync } from "node:fs";
621
+ import { dirname as dirname2, join } from "node:path";
622
+ import { fileURLToPath } from "node:url";
623
+
624
+ // packages/relay/src/proc.ts
625
+ import { spawn } from "node:child_process";
626
+ var spawnUsesShell = () => process.platform === "win32";
627
+ async function runCapture(command, args, opts = {}) {
628
+ return await new Promise((resolve, reject) => {
629
+ const child = spawn(command, args, {
630
+ stdio: ["ignore", "pipe", "pipe"],
631
+ shell: spawnUsesShell(),
632
+ timeout: opts.timeoutMs
633
+ });
634
+ let stdout = "";
635
+ let stderr = "";
636
+ child.stdout.setEncoding("utf8");
637
+ child.stderr.setEncoding("utf8");
638
+ child.stdout.on("data", (chunk) => {
639
+ stdout += chunk;
640
+ });
641
+ child.stderr.on("data", (chunk) => {
642
+ stderr += chunk;
643
+ });
644
+ child.on("error", reject);
645
+ child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
646
+ });
647
+ }
648
+ async function runInherit(command, args) {
649
+ await new Promise((resolve, reject) => {
650
+ const child = spawn(command, args, { stdio: "inherit", shell: spawnUsesShell() });
651
+ child.on("error", reject);
652
+ child.on("exit", (code) => {
653
+ if (code === 0)
654
+ resolve();
655
+ else
656
+ reject(new Error(`${command} ${args.join(" ")} exited with code ${code}`));
657
+ });
658
+ });
659
+ }
660
+
661
+ // packages/relay/src/version.ts
662
+ var RELAY_PACKAGE_NAME = "@ganglion/xacpx-relay";
663
+ function readRelayVersion(moduleUrl = import.meta.url) {
664
+ const here = dirname2(fileURLToPath(moduleUrl));
665
+ for (const candidate of [
666
+ join(here, "package.json"),
667
+ join(here, "..", "package.json"),
668
+ join(here, "..", "..", "package.json")
669
+ ]) {
670
+ try {
671
+ const parsed = JSON.parse(readFileSync(candidate, "utf8"));
672
+ if (parsed.name === RELAY_PACKAGE_NAME && typeof parsed.version === "string")
673
+ return parsed.version;
674
+ } catch {}
675
+ }
676
+ return "unknown";
677
+ }
678
+ async function getLatestNpmVersion(packageName) {
679
+ let result;
680
+ try {
681
+ result = await runCapture("npm", ["view", packageName, "version", "--json"], { timeoutMs: 8000 });
682
+ } catch {
683
+ return null;
684
+ }
685
+ if (result.code !== 0)
686
+ return null;
687
+ const raw = result.stdout.trim();
688
+ if (!raw)
689
+ return null;
690
+ try {
691
+ const parsed = JSON.parse(raw);
692
+ return typeof parsed === "string" ? parsed : null;
693
+ } catch {
694
+ return raw.replace(/^"|"$/g, "") || null;
695
+ }
696
+ }
697
+ function isNewer(candidate, current) {
698
+ if (!parseSemver(candidate) || !parseSemver(current))
699
+ return false;
700
+ return compareSemver(candidate, current) > 0;
701
+ }
702
+ function parseSemver(value) {
703
+ const match = /^\s*v?(\d+)\.(\d+)\.(\d+)(-[^\s]*)?/.exec(value);
704
+ if (!match)
705
+ return null;
706
+ return { nums: [Number(match[1]), Number(match[2]), Number(match[3])], prerelease: Boolean(match[4]) };
707
+ }
708
+ function compareSemver(a, b) {
709
+ const left = parseSemver(a) ?? { nums: [0, 0, 0], prerelease: false };
710
+ const right = parseSemver(b) ?? { nums: [0, 0, 0], prerelease: false };
711
+ for (let i = 0;i < 3; i += 1) {
712
+ if (left.nums[i] !== right.nums[i])
713
+ return left.nums[i] < right.nums[i] ? -1 : 1;
714
+ }
715
+ if (left.prerelease === right.prerelease)
716
+ return 0;
717
+ return left.prerelease ? -1 : 1;
718
+ }
719
+ function createRelayUpdateChecker(opts) {
720
+ const getLatest = opts.getLatest ?? (() => getLatestNpmVersion(RELAY_PACKAGE_NAME));
721
+ const now = opts.now ?? (() => Date.now());
722
+ const ttlMs = opts.ttlMs ?? 60 * 60 * 1000;
723
+ let cache = null;
724
+ return async () => {
725
+ if (!cache || now() - cache.at >= ttlMs) {
726
+ try {
727
+ const latest2 = await getLatest();
728
+ if (latest2 != null)
729
+ cache = { latest: latest2, at: now() };
730
+ } catch {}
731
+ }
732
+ const latest = cache?.latest ?? null;
733
+ return { current: opts.current, latest, updateAvailable: latest != null && isNewer(latest, opts.current) };
734
+ };
735
+ }
736
+
605
737
  // packages/relay/src/http/app.ts
606
738
  var SESSION_COOKIE = "xrelay_session";
607
739
  var LOGIN_WINDOW_MS = 10 * 60 * 1000;
@@ -620,12 +752,26 @@ var CHAT_SCOPED_TYPES = new Set([
620
752
  MSG2.sessionsCreate,
621
753
  MSG2.sessionsNativeList,
622
754
  MSG2.sessionsRemove,
755
+ MSG2.sessionsArchive,
756
+ MSG2.sessionsUnarchive,
623
757
  MSG2.sessionModelGet,
624
758
  MSG2.sessionModelSet
625
759
  ]);
626
760
  function requireJson(contentType) {
627
761
  return (contentType ?? "").toLowerCase().includes("application/json");
628
762
  }
763
+ function safePreviewUrl(v) {
764
+ if (typeof v === "string" && v.startsWith("data:image/") && v.length <= 256 * 1024) {
765
+ return v;
766
+ }
767
+ return;
768
+ }
769
+ var RPC_MAX_BODY_BYTES = 16 * 1024 * 1024;
770
+ var MAX_PERSISTED_ATTACHMENTS = 5;
771
+ var MAX_ATTACHMENT_FIELD_LEN = 256;
772
+ function boundField(v) {
773
+ return typeof v === "string" ? v.slice(0, MAX_ATTACHMENT_FIELD_LEN) : v;
774
+ }
629
775
  function createApp(deps) {
630
776
  const sessionTtlMs = deps.sessionTtlMs ?? 7 * 24 * 60 * 60 * 1000;
631
777
  const pairingTtlMs = deps.pairingTtlMs ?? 10 * 60 * 1000;
@@ -722,6 +868,10 @@ function createApp(deps) {
722
868
  }
723
869
  });
724
870
  });
871
+ app.get("/api/version", async (c) => {
872
+ const check = deps.checkUpdate ?? (async () => ({ current: readRelayVersion(), latest: null, updateAvailable: false }));
873
+ return c.json(await check());
874
+ });
725
875
  app.get("/api/instances", (c) => {
726
876
  const account = c.get("account");
727
877
  const rows = deps.instances.listByAccount(account.id).map((row) => ({
@@ -785,6 +935,10 @@ function createApp(deps) {
785
935
  const instance = deps.instances.getOwned(c.req.param("id"), account.id);
786
936
  if (!instance)
787
937
  return c.json({ error: "not-found" }, 404);
938
+ const contentLength = Number(c.req.header("content-length"));
939
+ if (Number.isFinite(contentLength) && contentLength > RPC_MAX_BODY_BYTES) {
940
+ return c.json({ error: "payload-too-large" }, 413);
941
+ }
788
942
  const body = await c.req.json().catch(() => ({}));
789
943
  if (!body.type || !body.type.startsWith("control."))
790
944
  return c.json({ error: "invalid-rpc-type" }, 400);
@@ -798,10 +952,28 @@ function createApp(deps) {
798
952
  };
799
953
  }
800
954
  try {
955
+ if (body.type === MSG2.upload) {
956
+ const up = payload;
957
+ const approxBytes = up.content ? Math.floor(up.content.length * 3 / 4) : 0;
958
+ if (approxBytes > 10 * 1024 * 1024)
959
+ return c.json({ error: "file-too-large" }, 413);
960
+ }
801
961
  if (body.type === MSG2.prompt || body.type === MSG2.commandExecute) {
802
962
  const p = payload;
803
- if (p.sessionAlias && p.text)
804
- deps.messages.append(instance.id, p.sessionAlias, "in", p.text);
963
+ if (p.sessionAlias && p.text !== undefined) {
964
+ const attachments = (p.media ?? []).slice(0, MAX_PERSISTED_ATTACHMENTS).map((m) => {
965
+ const previewUrl = safePreviewUrl(m.previewUrl);
966
+ return {
967
+ id: m.id,
968
+ filename: boundField(m.fileName),
969
+ mimeType: boundField(m.mimeType),
970
+ size: m.size,
971
+ kind: m.kind,
972
+ ...previewUrl ? { previewUrl } : {}
973
+ };
974
+ });
975
+ deps.messages.append(instance.id, p.sessionAlias, "in", p.text, undefined, attachments);
976
+ }
805
977
  }
806
978
  const result = await deps.gateway.sendRequest(instance.id, body.type, payload);
807
979
  if (body.type === MSG2.commandExecute) {
@@ -976,7 +1148,8 @@ async function createRelayRuntime(dbPath, options = {}) {
976
1148
  historyRetentionDays: options.historyRetentionDays ?? 30,
977
1149
  maxMessagesPerSession: MAX_MESSAGES_PER_SESSION,
978
1150
  activeTurns: listActiveTurns,
979
- trustProxy: options.trustProxy
1151
+ trustProxy: options.trustProxy,
1152
+ checkUpdate: createRelayUpdateChecker({ current: readRelayVersion() })
980
1153
  });
981
1154
  return { db, accounts, instances, messages, gateway, webGateway, app, close: () => db.close() };
982
1155
  }
@@ -1056,17 +1229,62 @@ function parseCookie(header) {
1056
1229
  return out;
1057
1230
  }
1058
1231
 
1232
+ // packages/relay/src/cli-update.ts
1233
+ async function handleRelayUpdate(args, deps = {}) {
1234
+ const readCurrent = deps.readCurrentVersion ?? (() => readRelayVersion());
1235
+ const getLatest = deps.getLatestVersion ?? (() => getLatestNpmVersion(RELAY_PACKAGE_NAME));
1236
+ const updateSelf = deps.updateSelf ?? defaultUpdateSelf;
1237
+ const print = deps.print ?? ((l) => console.log(l));
1238
+ const checkOnly = args.includes("--check");
1239
+ const current = readCurrent();
1240
+ const latest = await getLatest();
1241
+ if (latest == null) {
1242
+ if (checkOnly) {
1243
+ print(`current: v${current}; latest: unknown (could not reach npm)`);
1244
+ return 0;
1245
+ }
1246
+ print(`update failed: could not determine the latest ${RELAY_PACKAGE_NAME} version (is npm reachable?)`);
1247
+ return 1;
1248
+ }
1249
+ if (!isNewer(latest, current)) {
1250
+ print(`already up to date (v${current})`);
1251
+ return 0;
1252
+ }
1253
+ if (checkOnly) {
1254
+ print(`update available: v${current} → v${latest} (run: xacpx-relay update)`);
1255
+ return 0;
1256
+ }
1257
+ print(`updating ${RELAY_PACKAGE_NAME}: v${current} → v${latest} …`);
1258
+ try {
1259
+ await updateSelf();
1260
+ } catch (error) {
1261
+ print(`update failed: ${error instanceof Error ? error.message : String(error)}`);
1262
+ return 1;
1263
+ }
1264
+ print(`updated to v${latest}`);
1265
+ return 0;
1266
+ }
1267
+ async function defaultUpdateSelf() {
1268
+ const spec = `${RELAY_PACKAGE_NAME}@latest`;
1269
+ const useBun = (process.env.PACKAGE_MANAGER ?? "").trim().toLowerCase() === "bun";
1270
+ if (useBun) {
1271
+ await runInherit("bun", ["add", "-g", spec]);
1272
+ return;
1273
+ }
1274
+ await runInherit("npm", ["install", "-g", spec]);
1275
+ }
1276
+
1059
1277
  // packages/relay/src/cli.ts
1060
1278
  function defaultDbPath() {
1061
- return join(homedir(), ".xacpx-relay", "relay.db");
1279
+ return join2(homedir(), ".xacpx-relay", "relay.db");
1062
1280
  }
1063
- function resolveBundledWebRoot(cliJsPath = fileURLToPath(import.meta.url)) {
1064
- const here = dirname2(cliJsPath);
1281
+ function resolveBundledWebRoot(cliJsPath = fileURLToPath2(import.meta.url)) {
1282
+ const here = dirname3(cliJsPath);
1065
1283
  const embedded = resolve(here, "relay-web");
1066
- if (existsSync(join(embedded, "index.html")))
1284
+ if (existsSync(join2(embedded, "index.html")))
1067
1285
  return embedded;
1068
1286
  const sibling = resolve(here, "../../relay-web/dist");
1069
- if (existsSync(join(sibling, "index.html")))
1287
+ if (existsSync(join2(sibling, "index.html")))
1070
1288
  return sibling;
1071
1289
  return;
1072
1290
  }
@@ -1077,6 +1295,7 @@ var USAGE = [
1077
1295
  " add token [--label <note>] [--db <path>]",
1078
1296
  " ls [--db <path>]",
1079
1297
  " rm token <value-or-id> [--db <path>]",
1298
+ " update [--check] (self-update @ganglion/xacpx-relay; --check only reports)",
1080
1299
  "",
1081
1300
  " Defaults: --db ~/.xacpx-relay/relay.db --web-root auto-detects the bundled dashboard"
1082
1301
  ].join(`
@@ -1180,6 +1399,9 @@ async function runRelayCli(args, io) {
1180
1399
  runtime.close();
1181
1400
  }
1182
1401
  }
1402
+ if (args[0] === "update") {
1403
+ return await handleRelayUpdate(args.slice(1), { print: io.print });
1404
+ }
1183
1405
  io.print(USAGE);
1184
1406
  return 1;
1185
1407
  }
@@ -3,6 +3,7 @@ import { type LiveTurnSnapshotDto } from "@ganglion/xacpx-relay-protocol";
3
3
  import type { AccountRow, AccountStore } from "../stores/accounts.js";
4
4
  import type { InstanceStore } from "../stores/instances.js";
5
5
  import type { MessageStore } from "../stores/messages.js";
6
+ import { type UpdateCheck } from "../version.js";
6
7
  export interface GatewayForApp {
7
8
  isOnline(instanceId: string): boolean;
8
9
  sendRequest(instanceId: string, type: string, payload: unknown): Promise<unknown>;
@@ -19,6 +20,9 @@ export interface AppDeps {
19
20
  pairingTtlMs?: number;
20
21
  historyRetentionDays?: number;
21
22
  maxMessagesPerSession?: number;
23
+ /** Returns the hub's current version + whether a newer one is published. Injected
24
+ * by server.ts (cached). When omitted, /api/version reports current-only. */
25
+ checkUpdate?: () => Promise<UpdateCheck>;
22
26
  trustProxy?: boolean;
23
27
  now?: () => Date;
24
28
  }
package/dist/proc.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ export declare function runCapture(command: string, args: string[], opts?: {
2
+ timeoutMs?: number;
3
+ }): Promise<{
4
+ code: number;
5
+ stdout: string;
6
+ stderr: string;
7
+ }>;
8
+ export declare function runInherit(command: string, args: string[]): Promise<void>;
@@ -1 +1 @@
1
- import{d as e,c as s,Q as a,o as n}from"./index-B_zwxZen.js";const o={class:"flex items-center gap-2 select-none"},d=e({__name:"BrandLogo",setup(r){return(p,t)=>(n(),s("div",o,[...t[0]||(t[0]=[a('<svg data-test="brand-x" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><defs><linearGradient id="xacpxBrand" x1="3" y1="3" x2="21" y2="21" gradientUnits="userSpaceOnUse"><stop stop-color="#4F9BF5"></stop><stop offset="1" stop-color="#69D689"></stop></linearGradient></defs><path d="M5 5 L19 19 M19 5 L5 19" stroke="url(#xacpxBrand)" stroke-width="3.2" stroke-linecap="round"></path></svg><span class="text-[15px] font-semibold tracking-tight text-fg">xacpx</span><span class="text-fg-muted text-xs">· relay</span>',3)])]))}});export{d as _};
1
+ import{d as e,c as s,S as a,o as n}from"./index-DcwLeItv.js";const o={class:"flex items-center gap-2 select-none"},d=e({__name:"BrandLogo",setup(r){return(p,t)=>(n(),s("div",o,[...t[0]||(t[0]=[a('<svg data-test="brand-x" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><defs><linearGradient id="xacpxBrand" x1="3" y1="3" x2="21" y2="21" gradientUnits="userSpaceOnUse"><stop stop-color="#4F9BF5"></stop><stop offset="1" stop-color="#69D689"></stop></linearGradient></defs><path d="M5 5 L19 19 M19 5 L5 19" stroke="url(#xacpxBrand)" stroke-width="3.2" stroke-linecap="round"></path></svg><span class="text-[15px] font-semibold tracking-tight text-fg">xacpx</span><span class="text-fg-muted text-xs">· relay</span>',3)])]))}});export{d as _};
@@ -1 +1 @@
1
- .stream-md>:first-child{margin-top:0}.stream-md>:last-child{margin-bottom:0}.stream-md p{margin:.5em 0;line-height:1.5}.stream-md h1,.stream-md h2,.stream-md h3,.stream-md h4{margin:.8em 0 .4em;font-weight:600;line-height:1.3}.stream-md h1{font-size:1.3em}.stream-md h2{font-size:1.2em}.stream-md h3{font-size:1.1em}.stream-md ul,.stream-md ol{margin:.5em 0;padding-left:1.4em}.stream-md ul{list-style:disc}.stream-md ol{list-style:decimal}.stream-md li{margin:.2em 0}.stream-md a{color:rgb(var(--c-accent));text-decoration:underline}.stream-md code{background:rgb(var(--c-surface));color:rgb(var(--c-fg));border-radius:4px;padding:.1em .3em;font-family:JetBrains Mono,ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px}.stream-md pre{background:rgb(var(--c-bg));color:rgb(var(--c-fg));border:1px solid rgb(var(--c-border));border-radius:8px;padding:.65em .85em;overflow-x:auto;margin:.6em 0;box-shadow:var(--shadow-e1)}.stream-md pre::-webkit-scrollbar{height:8px}.stream-md pre::-webkit-scrollbar-thumb{background:rgb(var(--c-fg-muted) / .28);border-radius:8px}.stream-md pre::-webkit-scrollbar-track{background:transparent}.stream-md pre code{background:transparent;padding:0;color:inherit;font-size:13px;line-height:1.6}.stream-md .hljs-keyword,.stream-md .hljs-built_in,.stream-md .hljs-name,.stream-md .hljs-tag{color:rgb(var(--c-accent))}.stream-md .hljs-string,.stream-md .hljs-attr,.stream-md .hljs-symbol{color:rgb(var(--c-run))}.stream-md .hljs-comment,.stream-md .hljs-quote{color:rgb(var(--c-fg-muted))}.stream-md .hljs-number,.stream-md .hljs-literal{color:rgb(var(--c-info))}.stream-md blockquote{border-left:3px solid rgb(var(--c-border));margin:.6em 0;padding-left:.8em;color:rgb(var(--c-fg-muted))}.stream-md .md-table-wrap{max-width:100%;overflow-x:auto;margin:.6em 0}.stream-md .md-table-wrap::-webkit-scrollbar{height:8px}.stream-md .md-table-wrap::-webkit-scrollbar-thumb{background:rgb(var(--c-fg-muted) / .28);border-radius:8px}.stream-md .md-table-wrap::-webkit-scrollbar-track{background:transparent}.stream-md table{border-collapse:collapse}.stream-md img{max-width:100%;height:auto}.stream-md th,.stream-md td{border:1px solid rgb(var(--c-border));padding:.3em .6em}.stream-md hr{border:none;border-top:1px solid rgb(var(--c-border));margin:.8em 0}.cv-row[data-v-e4c8869b]{content-visibility:auto;contain-intrinsic-size:auto 88px}
1
+ .stream-md>:first-child{margin-top:0}.stream-md>:last-child{margin-bottom:0}.stream-md p{margin:.5em 0;line-height:1.5}.stream-md h1,.stream-md h2,.stream-md h3,.stream-md h4{margin:.8em 0 .4em;font-weight:600;line-height:1.3}.stream-md h1{font-size:1.3em}.stream-md h2{font-size:1.2em}.stream-md h3{font-size:1.1em}.stream-md ul,.stream-md ol{margin:.5em 0;padding-left:1.4em}.stream-md ul{list-style:disc}.stream-md ol{list-style:decimal}.stream-md li{margin:.2em 0}.stream-md a{color:rgb(var(--c-accent));text-decoration:underline}.stream-md code{background:rgb(var(--c-surface));color:rgb(var(--c-fg));border-radius:4px;padding:.1em .3em;font-family:JetBrains Mono,ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px}.stream-md pre{background:rgb(var(--c-bg));color:rgb(var(--c-fg));border:1px solid rgb(var(--c-border));border-radius:8px;padding:.65em .85em;overflow-x:auto;margin:.6em 0;box-shadow:var(--shadow-e1)}.stream-md pre::-webkit-scrollbar{height:8px}.stream-md pre::-webkit-scrollbar-thumb{background:rgb(var(--c-fg-muted) / .28);border-radius:8px}.stream-md pre::-webkit-scrollbar-track{background:transparent}.stream-md pre code{background:transparent;padding:0;color:inherit;font-size:13px;line-height:1.6}.stream-md .hljs-keyword,.stream-md .hljs-built_in,.stream-md .hljs-name,.stream-md .hljs-tag{color:rgb(var(--c-accent))}.stream-md .hljs-string,.stream-md .hljs-attr,.stream-md .hljs-symbol{color:rgb(var(--c-run))}.stream-md .hljs-comment,.stream-md .hljs-quote{color:rgb(var(--c-fg-muted))}.stream-md .hljs-number,.stream-md .hljs-literal{color:rgb(var(--c-info))}.stream-md blockquote{border-left:3px solid rgb(var(--c-border));margin:.6em 0;padding-left:.8em;color:rgb(var(--c-fg-muted))}.stream-md .md-table-wrap{max-width:100%;overflow-x:auto;margin:.6em 0}.stream-md .md-table-wrap::-webkit-scrollbar{height:8px}.stream-md .md-table-wrap::-webkit-scrollbar-thumb{background:rgb(var(--c-fg-muted) / .28);border-radius:8px}.stream-md .md-table-wrap::-webkit-scrollbar-track{background:transparent}.stream-md table{border-collapse:collapse}.stream-md img{max-width:100%;height:auto}.stream-md th,.stream-md td{border:1px solid rgb(var(--c-border));padding:.3em .6em}.stream-md hr{border:none;border-top:1px solid rgb(var(--c-border));margin:.8em 0}.cv-row[data-v-c68894eb]{content-visibility:auto;contain-intrinsic-size:auto 88px}