@saadnvd1/agent-os 0.3.558 → 0.3.561

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 (51) hide show
  1. package/README.md +1 -0
  2. package/app/api/sessions/route.ts +3 -3
  3. package/app/api/shares/[id]/route.ts +23 -0
  4. package/app/api/shares/preview/route.ts +30 -0
  5. package/app/api/shares/route.test.ts +455 -0
  6. package/app/api/shares/route.ts +101 -0
  7. package/app/page.tsx +5 -0
  8. package/components/Chat/Artifact.tsx +17 -1
  9. package/components/Pane/MobileTabBar.tsx +19 -0
  10. package/components/Pane/PaneMenu.tsx +16 -0
  11. package/components/SessionList/SessionRowMenu.tsx +16 -0
  12. package/components/Settings/SettingsDialog.test.ts +3 -0
  13. package/components/Settings/SettingsDialog.tsx +9 -0
  14. package/components/Settings/commands.ts +9 -0
  15. package/components/Settings/entry-points.test.ts +1 -0
  16. package/components/Share/ShareDialog.tsx +336 -0
  17. package/components/Share/format.test.ts +20 -0
  18. package/components/Share/format.ts +33 -0
  19. package/components/Shares/SharedLinksPanel.tsx +147 -0
  20. package/data/shares/index.ts +2 -0
  21. package/data/shares/keys.ts +4 -0
  22. package/data/shares/queries.ts +111 -0
  23. package/lib/chat/drivers/claude.ts +5 -4
  24. package/lib/connect/control-api.ts +84 -0
  25. package/lib/db/migrations.ts +50 -0
  26. package/lib/db/queries.ts +4 -3
  27. package/lib/models/plan-step.ts +107 -15
  28. package/lib/orchestrator/conditions.ts +12 -4
  29. package/lib/orchestrator/events.ts +23 -0
  30. package/lib/orchestrator/reconcile.ts +201 -0
  31. package/lib/orchestrator/untrusted.ts +12 -2
  32. package/lib/plans/features.ts +69 -13
  33. package/lib/plans/pieces.ts +70 -15
  34. package/lib/plans/release.ts +46 -4
  35. package/lib/recipes/settle.ts +3 -0
  36. package/lib/security/demo.ts +1 -0
  37. package/lib/session-audit/normalize.ts +1 -1
  38. package/lib/settings-url.ts +1 -0
  39. package/lib/share/connect.ts +86 -0
  40. package/lib/share/context.ts +50 -0
  41. package/lib/share/redact.ts +190 -0
  42. package/lib/share/render.tsx +161 -0
  43. package/lib/share/route.ts +33 -0
  44. package/lib/share/service.ts +159 -0
  45. package/lib/share/snapshot.ts +261 -0
  46. package/lib/share/static-markup.ts +16 -0
  47. package/lib/share/store.ts +39 -0
  48. package/lib/share/styles.ts +83 -0
  49. package/lib/sidebar/shelves.ts +5 -6
  50. package/package.json +1 -1
  51. package/stores/shareUi.ts +17 -0
@@ -23,6 +23,7 @@ import {
23
23
  Menu,
24
24
  Plus,
25
25
  Search,
26
+ Share2,
26
27
  Terminal as TerminalIcon,
27
28
  TextSearch,
28
29
  Users,
@@ -34,6 +35,7 @@ import { awayUntil } from "@/components/Away/time";
34
35
  import type { Session, Project } from "@/lib/db";
35
36
  import type { LucideIcon } from "lucide-react";
36
37
  import { HostBadge } from "@/components/Hosts/HostBadge";
38
+ import { shareUiActions } from "@/stores/shareUi";
37
39
  import { MoveMenuItems } from "@/components/Tasks/MoveMenuItems";
38
40
 
39
41
  type ViewMode = "terminal" | "files" | "git" | "workers" | "log";
@@ -234,6 +236,23 @@ export function MobileTabBar({
234
236
  iconClassName="h-4 w-4"
235
237
  />
236
238
  )}
239
+ {session?.view === "chat" && (
240
+ <>
241
+ <DropdownMenuItem
242
+ onSelect={() =>
243
+ shareUiActions.open({
244
+ kind: "chat",
245
+ sessionId: session.id,
246
+ title: session.name,
247
+ })
248
+ }
249
+ >
250
+ <Share2 className="h-4 w-4" />
251
+ Share…
252
+ </DropdownMenuItem>
253
+ <DropdownMenuSeparator />
254
+ </>
255
+ )}
237
256
  {sessions
238
257
  .filter((s) => !s.conductor_session_id)
239
258
  .map((s) => {
@@ -5,6 +5,7 @@ import {
5
5
  Copy,
6
6
  Link,
7
7
  MoreHorizontal,
8
+ Share2,
8
9
  SplitSquareHorizontal,
9
10
  SplitSquareVertical,
10
11
  Unplug,
@@ -21,6 +22,7 @@ import {
21
22
  } from "@/components/ui/dropdown-menu";
22
23
  import { copySessionLink, writeClipboard } from "@/hooks/useCopyToClipboard";
23
24
  import type { Session } from "@/lib/db";
25
+ import { shareUiActions } from "@/stores/shareUi";
24
26
  import { MoveMenuItems } from "@/components/Tasks/MoveMenuItems";
25
27
 
26
28
  interface PaneMenuProps {
@@ -87,6 +89,20 @@ export function PaneMenu({
87
89
  <Copy className="h-4 w-4" />
88
90
  Copy session ID
89
91
  </DropdownMenuItem>
92
+ {session.view === "chat" && (
93
+ <DropdownMenuItem
94
+ onSelect={() =>
95
+ shareUiActions.open({
96
+ kind: "chat",
97
+ sessionId: session.id,
98
+ title: session.name,
99
+ })
100
+ }
101
+ >
102
+ <Share2 className="h-4 w-4" />
103
+ Share…
104
+ </DropdownMenuItem>
105
+ )}
90
106
  <DropdownMenuSeparator />
91
107
  <MoveMenuItems
92
108
  session={session}
@@ -13,6 +13,7 @@ import {
13
13
  GitPullRequest,
14
14
  KanbanSquare,
15
15
  Link,
16
+ Share2,
16
17
  Loader2,
17
18
  Pencil,
18
19
  Pin,
@@ -38,6 +39,7 @@ import { SNOOZE_PRESETS, snoozePreset, wakeLabel } from "@/lib/sidebar/snooze";
38
39
  import type { SidebarRow } from "@/lib/sidebar/shelves";
39
40
  import { useRowContext } from "./RowContext";
40
41
  import { MoveMenuItems } from "@/components/Tasks/MoveMenuItems";
42
+ import { shareUiActions } from "@/stores/shareUi";
41
43
  import { copySessionLink } from "@/hooks/useCopyToClipboard";
42
44
  import { useLinkedHostNames } from "@/data/hosts";
43
45
  import { remoteBlocks } from "@/lib/hosts/remote-menu";
@@ -206,6 +208,20 @@ export function SessionRowMenu({
206
208
  <Link className={icon} />
207
209
  Copy link
208
210
  </Item>
211
+ {session.view === "chat" && (
212
+ <Item
213
+ onClick={() =>
214
+ shareUiActions.open({
215
+ kind: "chat",
216
+ sessionId: session.id,
217
+ title: session.name,
218
+ })
219
+ }
220
+ >
221
+ <Share2 className={icon} />
222
+ Share…
223
+ </Item>
224
+ )}
209
225
  <Item onClick={onRename}>
210
226
  <Pencil className={icon} />
211
227
  Rename
@@ -38,6 +38,9 @@ vi.mock("@/components/Models/ModelsSection", () => ({
38
38
  ModelsSection: stub("models"),
39
39
  }));
40
40
  vi.mock("@/components/Devices", () => ({ DevicesPanel: stub("devices") }));
41
+ vi.mock("@/components/Shares/SharedLinksPanel", () => ({
42
+ SharedLinksPanel: stub("shares"),
43
+ }));
41
44
  vi.mock("@/components/Schedules", () => ({
42
45
  SchedulesPanel: stub("schedules"),
43
46
  }));
@@ -12,6 +12,7 @@ import {
12
12
  Cpu,
13
13
  GitMerge,
14
14
  LayoutGrid,
15
+ Link2,
15
16
  ListChecks,
16
17
  ShieldOff,
17
18
  FlaskConical,
@@ -32,6 +33,7 @@ import { AuditsPanel } from "@/components/Audits/AuditsPanel";
32
33
  import { DevicesPanel } from "@/components/Devices";
33
34
  import { GlobalMergeSection } from "@/components/Merge/GlobalMergeSection";
34
35
  import { ModelsSection } from "@/components/Models/ModelsSection";
36
+ import { SharedLinksPanel } from "@/components/Shares/SharedLinksPanel";
35
37
  import { SchedulesPanel } from "@/components/Schedules";
36
38
  import { UpdatesSection } from "@/components/Updates/UpdatesSection";
37
39
  import { ExperimentsSection } from "./ExperimentsSection";
@@ -73,6 +75,11 @@ export const SECTION_INFO: Record<
73
75
  hint: "Pairing, passkeys, network",
74
76
  icon: Smartphone,
75
77
  },
78
+ shares: {
79
+ label: "Shared links",
80
+ hint: "Snapshot links you made, with views, and revoke",
81
+ icon: Link2,
82
+ },
76
83
  models: {
77
84
  label: "Models",
78
85
  hint: "Which model each kind of work runs on, and providers",
@@ -133,6 +140,8 @@ function SectionBody({
133
140
  return <ModelsSection />;
134
141
  case "devices":
135
142
  return <DevicesPanel />;
143
+ case "shares":
144
+ return <SharedLinksPanel />;
136
145
  case "notifications":
137
146
  return <NotificationsSection browser={browser} />;
138
147
  case "recipes":
@@ -6,6 +6,7 @@ import {
6
6
  FlaskConical,
7
7
  GitMerge,
8
8
  LayoutGrid,
9
+ Link2,
9
10
  RefreshCw,
10
11
  Settings,
11
12
  Smartphone,
@@ -72,6 +73,14 @@ export function settingsCommands(workspaceId: string | null): PaletteCommand[] {
72
73
  icon: Smartphone,
73
74
  run: () => settingsUiActions.open("devices"),
74
75
  },
76
+ {
77
+ id: "app.shares",
78
+ title: "Shared links",
79
+ group,
80
+ keywords: ["share", "snapshot", "revoke", "link", "connect"],
81
+ icon: Link2,
82
+ run: () => settingsUiActions.open("shares"),
83
+ },
75
84
  {
76
85
  id: "app.notifications",
77
86
  title: "Notification settings",
@@ -23,6 +23,7 @@ describe("⌘K entry points", () => {
23
23
  ["app.workspace-settings", "workspaces"],
24
24
  ["app.models", "models"],
25
25
  ["app.devices", "devices"],
26
+ ["app.shares", "shares"],
26
27
  ["app.notifications", "notifications"],
27
28
  ["app.phone-notifications", "notifications"],
28
29
  ["app.schedules", "schedules"],
@@ -0,0 +1,336 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { useSnapshot } from "valtio";
5
+ import { toast } from "sonner";
6
+ import { Check, Copy, Download, ExternalLink, Link2 } from "lucide-react";
7
+ import { Button } from "@/components/ui/button";
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogDescription,
12
+ DialogTitle,
13
+ } from "@/components/ui/dialog";
14
+ import {
15
+ Select,
16
+ SelectContent,
17
+ SelectItem,
18
+ SelectTrigger,
19
+ SelectValue,
20
+ } from "@/components/ui/select";
21
+ import { Switch } from "@/components/ui/switch";
22
+ import { writeClipboard } from "@/hooks/useCopyToClipboard";
23
+ import {
24
+ useCreateShare,
25
+ useSharePreview,
26
+ type ShareExpiry,
27
+ type SharePreview,
28
+ } from "@/data/shares";
29
+ import { shareUi, shareUiActions } from "@/stores/shareUi";
30
+ import { redactionSummary, formatBytes, fileName } from "./format";
31
+
32
+ const EXPIRIES: { value: ShareExpiry; label: string }[] = [
33
+ { value: "1d", label: "1 day" },
34
+ { value: "7d", label: "7 days" },
35
+ { value: "30d", label: "30 days" },
36
+ { value: "never", label: "Never" },
37
+ ];
38
+
39
+ const TYPE_LABEL: Record<string, string> = {
40
+ user: "You",
41
+ assistant: "Agent",
42
+ plan: "Plan",
43
+ error: "Error",
44
+ divider: "Divider",
45
+ artifact: "Page",
46
+ detail: "Tool",
47
+ };
48
+
49
+ // Opened from a menu or an artifact: the redacted page exactly as it would be
50
+ // uploaded, with what to leave out, then Create link or Download HTML.
51
+ export function ShareDialog() {
52
+ const { target } = useSnapshot(shareUi);
53
+ return (
54
+ <Dialog
55
+ open={!!target}
56
+ onOpenChange={(open) => !open && shareUiActions.close()}
57
+ >
58
+ {target && (
59
+ <ShareBody
60
+ key={target.sessionId ?? target.artifactId}
61
+ target={{ ...target }}
62
+ />
63
+ )}
64
+ </Dialog>
65
+ );
66
+ }
67
+
68
+ function ShareBody({
69
+ target,
70
+ }: {
71
+ target: {
72
+ kind: "chat" | "artifact";
73
+ sessionId?: string;
74
+ artifactId?: string;
75
+ title: string;
76
+ };
77
+ }) {
78
+ const [tools, setTools] = useState(false);
79
+ const [dropped, setDropped] = useState<string[]>([]);
80
+ const [expiresIn, setExpiresIn] = useState<ShareExpiry>("7d");
81
+ const [preview, setPreview] = useState<SharePreview | null>(null);
82
+ const [error, setError] = useState<string | null>(null);
83
+ const [created, setCreated] = useState<string | null>(null);
84
+ const [copied, setCopied] = useState(false);
85
+ const previewReq = useSharePreview();
86
+ const create = useCreateShare();
87
+ const seq = useRef(0);
88
+
89
+ const { kind, sessionId, artifactId } = target;
90
+ useEffect(() => {
91
+ const mine = ++seq.current;
92
+ const timer = setTimeout(async () => {
93
+ try {
94
+ const p = await previewReq.mutateAsync({
95
+ kind,
96
+ sessionId,
97
+ artifactId,
98
+ includeTools: tools,
99
+ dropIds: dropped,
100
+ });
101
+ if (mine === seq.current) {
102
+ setPreview(p);
103
+ setError(null);
104
+ }
105
+ } catch (err) {
106
+ if (mine === seq.current) setError((err as Error).message);
107
+ }
108
+ }, 250);
109
+ return () => clearTimeout(timer);
110
+ // previewReq is stable enough; re-running on it would loop.
111
+ // eslint-disable-next-line react-hooks/exhaustive-deps
112
+ }, [kind, sessionId, artifactId, tools, dropped]);
113
+
114
+ const toggle = (id: string) =>
115
+ setDropped((d) =>
116
+ d.includes(id) ? d.filter((x) => x !== id) : [...d, id]
117
+ );
118
+
119
+ const makeLink = async () => {
120
+ if (!preview) return;
121
+ try {
122
+ const res = await create.mutateAsync({
123
+ kind,
124
+ sessionId,
125
+ artifactId,
126
+ includeTools: tools,
127
+ dropIds: dropped,
128
+ sharedAt: preview.sharedAt,
129
+ hash: preview.hash,
130
+ expiresIn,
131
+ });
132
+ setCreated(res.url);
133
+ try {
134
+ await writeClipboard(res.url);
135
+ toast.success("Link copied");
136
+ } catch {
137
+ toast.message("Link created. Copy it below.");
138
+ }
139
+ } catch (err) {
140
+ setError((err as Error).message);
141
+ }
142
+ };
143
+
144
+ const download = () => {
145
+ if (!preview) return;
146
+ const url = URL.createObjectURL(
147
+ new Blob([preview.html], { type: "text/html" })
148
+ );
149
+ const a = document.createElement("a");
150
+ a.href = url;
151
+ a.download = fileName(target.title);
152
+ a.click();
153
+ URL.revokeObjectURL(url);
154
+ };
155
+
156
+ const copyAgain = async () => {
157
+ if (!created) return;
158
+ try {
159
+ await writeClipboard(created);
160
+ setCopied(true);
161
+ setTimeout(() => setCopied(false), 1500);
162
+ } catch {
163
+ toast.error("Could not copy to clipboard");
164
+ }
165
+ };
166
+
167
+ const linked = preview?.connectLinked ?? false;
168
+ const empty =
169
+ kind === "chat" && !!preview && preview.items.length === dropped.length;
170
+ const summary = preview ? redactionSummary(preview.hits) : "";
171
+
172
+ return (
173
+ <DialogContent className="flex h-[100dvh] w-screen max-w-none flex-col gap-3 rounded-none p-4 sm:h-[90vh] sm:max-w-5xl sm:rounded-lg">
174
+ <div>
175
+ <DialogTitle>
176
+ Share {kind === "chat" ? "this chat" : "this page"}
177
+ </DialogTitle>
178
+ <DialogDescription>
179
+ A read-only snapshot, with secrets, home paths and machine names
180
+ masked. Nothing is uploaded until you create the link.
181
+ </DialogDescription>
182
+ </div>
183
+
184
+ <div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto md:flex-row md:overflow-hidden">
185
+ <div className="bg-background min-h-[45vh] flex-1 overflow-hidden rounded-lg border md:min-h-0">
186
+ {preview ? (
187
+ <iframe
188
+ title="Preview of what will be shared"
189
+ sandbox="allow-scripts"
190
+ referrerPolicy="no-referrer"
191
+ srcDoc={preview.html}
192
+ className="h-full min-h-[45vh] w-full md:min-h-0"
193
+ />
194
+ ) : (
195
+ <p className="text-muted-foreground p-4 text-sm">
196
+ {error ?? "Preparing the preview…"}
197
+ </p>
198
+ )}
199
+ </div>
200
+
201
+ <div className="flex shrink-0 flex-col gap-4 md:w-80 md:overflow-y-auto">
202
+ {kind === "chat" && (
203
+ <>
204
+ <label className="flex min-h-11 items-center justify-between gap-3 text-sm">
205
+ <span>Include tool calls and thinking</span>
206
+ <Switch checked={tools} onCheckedChange={setTools} />
207
+ </label>
208
+ <div className="min-h-0">
209
+ <div className="text-muted-foreground mb-1 text-xs font-medium">
210
+ Messages
211
+ </div>
212
+ <ul className="max-h-64 divide-y overflow-y-auto rounded-lg border">
213
+ {preview?.items.map((item) => (
214
+ <li key={item.id}>
215
+ <label className="flex min-h-11 cursor-pointer items-center gap-3 px-3 py-1.5 text-sm">
216
+ <input
217
+ type="checkbox"
218
+ className="h-4 w-4 shrink-0"
219
+ checked={!dropped.includes(item.id)}
220
+ onChange={() => toggle(item.id)}
221
+ />
222
+ <span className="text-muted-foreground w-12 shrink-0 text-xs">
223
+ {TYPE_LABEL[item.type] ?? item.type}
224
+ </span>
225
+ <span className="min-w-0 flex-1 truncate">
226
+ {item.excerpt || "(empty)"}
227
+ </span>
228
+ </label>
229
+ </li>
230
+ ))}
231
+ </ul>
232
+ </div>
233
+ </>
234
+ )}
235
+
236
+ {linked && (
237
+ <label className="flex min-h-11 items-center justify-between gap-3 text-sm">
238
+ <span>Link expires after</span>
239
+ <Select
240
+ value={expiresIn}
241
+ onValueChange={(v) => setExpiresIn(v as ShareExpiry)}
242
+ >
243
+ <SelectTrigger className="w-32">
244
+ <SelectValue />
245
+ </SelectTrigger>
246
+ <SelectContent>
247
+ {EXPIRIES.map((e) => (
248
+ <SelectItem key={e.value} value={e.value}>
249
+ {e.label}
250
+ </SelectItem>
251
+ ))}
252
+ </SelectContent>
253
+ </Select>
254
+ </label>
255
+ )}
256
+
257
+ {preview && (
258
+ <div className="text-muted-foreground space-y-1 text-xs">
259
+ <p>{summary || "Nothing needed masking."}</p>
260
+ <p>
261
+ Your own machine and network names are masked; public sites like
262
+ github.com are not.
263
+ </p>
264
+ <p>{formatBytes(preview.bytes)}</p>
265
+ </div>
266
+ )}
267
+ {preview?.tooBig && (
268
+ <p className="text-destructive text-sm">
269
+ Too big to share; drop some messages or turn tool calls off.
270
+ </p>
271
+ )}
272
+ {error && preview && (
273
+ <p className="text-destructive text-sm">{error}</p>
274
+ )}
275
+ </div>
276
+ </div>
277
+
278
+ {created ? (
279
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
280
+ <input
281
+ readOnly
282
+ value={created}
283
+ onFocus={(e) => e.currentTarget.select()}
284
+ aria-label="Shared link"
285
+ className="bg-muted min-h-11 min-w-0 flex-1 rounded-lg px-3 font-mono text-xs"
286
+ />
287
+ <Button variant="outline" className="min-h-11" onClick={copyAgain}>
288
+ {copied ? (
289
+ <Check className="h-4 w-4" />
290
+ ) : (
291
+ <Copy className="h-4 w-4" />
292
+ )}
293
+ Copy
294
+ </Button>
295
+ <Button variant="outline" className="min-h-11" asChild>
296
+ <a href={created} target="_blank" rel="noopener noreferrer">
297
+ <ExternalLink className="h-4 w-4" />
298
+ Open
299
+ </a>
300
+ </Button>
301
+ <Button className="min-h-11" onClick={shareUiActions.close}>
302
+ Done
303
+ </Button>
304
+ </div>
305
+ ) : (
306
+ <div className="flex flex-col-reverse items-stretch gap-2 sm:flex-row sm:items-center sm:justify-end">
307
+ {preview && !linked && (
308
+ <p className="text-muted-foreground flex-1 text-xs">
309
+ Link this machine in Settings → Devices &amp; access to share a
310
+ link.
311
+ </p>
312
+ )}
313
+ <Button
314
+ variant="outline"
315
+ className="min-h-11"
316
+ disabled={!preview || preview.tooBig || empty}
317
+ onClick={download}
318
+ >
319
+ <Download className="h-4 w-4" />
320
+ Download HTML
321
+ </Button>
322
+ {linked && (
323
+ <Button
324
+ className="min-h-11"
325
+ disabled={!preview || preview.tooBig || empty || create.isPending}
326
+ onClick={makeLink}
327
+ >
328
+ <Link2 className="h-4 w-4" />
329
+ {create.isPending ? "Creating…" : "Create link"}
330
+ </Button>
331
+ )}
332
+ </div>
333
+ )}
334
+ </DialogContent>
335
+ );
336
+ }
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { fileName, formatBytes, redactionSummary } from "./format";
3
+
4
+ describe("share format", () => {
5
+ it("summarises what was masked, singular and plural", () => {
6
+ const none = { secret: 0, path: 0, ip: 0, host: 0, email: 0 };
7
+ expect(redactionSummary(none)).toBe("");
8
+ expect(redactionSummary({ ...none, secret: 1, path: 12 })).toBe(
9
+ "Masked: 1 secret, 12 paths"
10
+ );
11
+ });
12
+ it("names the download after the title", () => {
13
+ expect(fileName("My chat: part 2!")).toBe("my-chat-part-2.html");
14
+ expect(fileName("???")).toBe("shared.html");
15
+ });
16
+ it("sizes", () => {
17
+ expect(formatBytes(500)).toBe("500 bytes");
18
+ expect(formatBytes(2048)).toBe("2.0 KB");
19
+ });
20
+ });
@@ -0,0 +1,33 @@
1
+ import type { SharePreview } from "@/data/shares";
2
+
3
+ const PLURAL: Record<keyof SharePreview["hits"], [string, string]> = {
4
+ secret: ["secret", "secrets"],
5
+ path: ["path", "paths"],
6
+ ip: ["address", "addresses"],
7
+ host: ["host", "hosts"],
8
+ email: ["email", "emails"],
9
+ };
10
+
11
+ /** "Masked: 3 secrets, 12 paths", or "" when nothing was. */
12
+ export function redactionSummary(hits: SharePreview["hits"]): string {
13
+ const parts = (Object.keys(PLURAL) as (keyof typeof PLURAL)[])
14
+ .filter((k) => hits[k] > 0)
15
+ .map((k) => `${hits[k]} ${PLURAL[k][hits[k] === 1 ? 0 : 1]}`);
16
+ return parts.length ? `Masked: ${parts.join(", ")}` : "";
17
+ }
18
+
19
+ export function formatBytes(n: number): string {
20
+ if (n < 1024) return `${n} bytes`;
21
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
22
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
23
+ }
24
+
25
+ /** A file name for a download: the title, plain, with .html. */
26
+ export function fileName(title: string): string {
27
+ const slug = title
28
+ .toLowerCase()
29
+ .replace(/[^a-z0-9]+/g, "-")
30
+ .replace(/^-+|-+$/g, "")
31
+ .slice(0, 60);
32
+ return `${slug || "shared"}.html`;
33
+ }