@saadnvd1/agent-os 0.3.611 → 0.3.613

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,92 @@
1
+ import "@/components/Chat/composer/test-dom";
2
+ import { act, createElement } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { MobileViewPicker, mobileViews } from "./MobileViewPicker";
6
+
7
+ // Radix dispatches CustomEvents; the node global is not jsdom's.
8
+ Object.defineProperty(globalThis, "CustomEvent", {
9
+ value: (window as unknown as { CustomEvent: unknown }).CustomEvent,
10
+ configurable: true,
11
+ });
12
+
13
+ const all = {
14
+ isLocalSession: true,
15
+ hasLog: true,
16
+ isConductor: true,
17
+ workerCount: 3,
18
+ };
19
+
20
+ describe("mobileViews", () => {
21
+ it("lists every view, Artifacts included", () => {
22
+ expect(mobileViews(all).map((v) => v.label)).toEqual([
23
+ "Chat",
24
+ "Files",
25
+ "Git",
26
+ "Artifacts",
27
+ "Log",
28
+ "Workers",
29
+ ]);
30
+ expect(mobileViews(all).at(-1)?.count).toBe(3);
31
+ });
32
+
33
+ it("hides views that do not apply", () => {
34
+ expect(
35
+ mobileViews({
36
+ isLocalSession: false,
37
+ isConductor: false,
38
+ workerCount: 0,
39
+ }).map((v) => v.label)
40
+ ).toEqual(["Chat", "Artifacts"]);
41
+ });
42
+ });
43
+
44
+ describe("MobileViewPicker", () => {
45
+ async function open(viewMode: "terminal" | "artifacts", onChange = vi.fn()) {
46
+ const el = document.createElement("div");
47
+ document.body.appendChild(el);
48
+ const root = createRoot(el);
49
+ await act(async () => {
50
+ root.render(
51
+ createElement(MobileViewPicker, {
52
+ ...all,
53
+ viewMode,
54
+ onViewModeChange: onChange,
55
+ })
56
+ );
57
+ });
58
+ const trigger = el.querySelector("button") as HTMLButtonElement;
59
+ await act(async () => {
60
+ trigger.dispatchEvent(
61
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
62
+ );
63
+ });
64
+ return { trigger, onChange };
65
+ }
66
+
67
+ it("shows the current view and lists all views in the menu", async () => {
68
+ const { trigger } = await open("artifacts");
69
+ expect(trigger.textContent).toContain("Artifacts");
70
+ const items = [...document.querySelectorAll('[role="menuitem"]')];
71
+ expect(items.map((i) => i.textContent)).toEqual([
72
+ "Chat",
73
+ "Files",
74
+ "Git",
75
+ "Artifacts",
76
+ "Log",
77
+ "Workers3",
78
+ ]);
79
+ expect(items[3].getAttribute("aria-current")).toBe("true");
80
+ });
81
+
82
+ it("selecting an item calls the view-change handler", async () => {
83
+ const { onChange } = await open("terminal");
84
+ const log = [...document.querySelectorAll('[role="menuitem"]')].find((i) =>
85
+ i.textContent?.startsWith("Log")
86
+ ) as HTMLElement;
87
+ await act(async () => {
88
+ log.click();
89
+ });
90
+ expect(onChange).toHaveBeenCalledWith("log");
91
+ });
92
+ });
@@ -0,0 +1,106 @@
1
+ "use client";
2
+
3
+ import {
4
+ Check,
5
+ ChevronDown,
6
+ FolderOpen,
7
+ GitBranch,
8
+ Package,
9
+ ScrollText,
10
+ Terminal as TerminalIcon,
11
+ Users,
12
+ type LucideIcon,
13
+ } from "lucide-react";
14
+ import {
15
+ DropdownMenu,
16
+ DropdownMenuContent,
17
+ DropdownMenuItem,
18
+ DropdownMenuTrigger,
19
+ } from "@/components/ui/dropdown-menu";
20
+ import type { ViewMode } from "@/contexts/PaneContext";
21
+
22
+ export interface MobileView {
23
+ mode: ViewMode;
24
+ label: string;
25
+ icon: LucideIcon;
26
+ count?: number;
27
+ }
28
+
29
+ export interface MobileViewAvailability {
30
+ isLocalSession: boolean;
31
+ hasLog?: boolean;
32
+ isConductor: boolean;
33
+ workerCount: number;
34
+ }
35
+
36
+ // The one list of views on a phone; the picker renders it.
37
+ export function mobileViews({
38
+ isLocalSession,
39
+ hasLog,
40
+ isConductor,
41
+ workerCount,
42
+ }: MobileViewAvailability): MobileView[] {
43
+ const views: (MobileView | false)[] = [
44
+ { mode: "terminal", label: "Chat", icon: TerminalIcon },
45
+ isLocalSession && { mode: "files", label: "Files", icon: FolderOpen },
46
+ isLocalSession && { mode: "git", label: "Git", icon: GitBranch },
47
+ { mode: "artifacts", label: "Artifacts", icon: Package },
48
+ !!hasLog && { mode: "log", label: "Log", icon: ScrollText },
49
+ isConductor && {
50
+ mode: "workers",
51
+ label: "Workers",
52
+ icon: Users,
53
+ count: workerCount,
54
+ },
55
+ ];
56
+ return views.filter((v): v is MobileView => !!v);
57
+ }
58
+
59
+ export function MobileViewPicker({
60
+ viewMode,
61
+ onViewModeChange,
62
+ ...availability
63
+ }: MobileViewAvailability & {
64
+ viewMode: ViewMode;
65
+ onViewModeChange: (mode: ViewMode) => void;
66
+ }) {
67
+ const views = mobileViews(availability);
68
+ const current = views.find((v) => v.mode === viewMode) ?? views[0];
69
+ const CurrentIcon = current.icon;
70
+
71
+ return (
72
+ <DropdownMenu modal={false}>
73
+ <DropdownMenuTrigger asChild>
74
+ <button
75
+ type="button"
76
+ aria-label={`View: ${current.label}`}
77
+ onClick={(e) => e.stopPropagation()}
78
+ className="bg-accent/50 relative flex h-8 shrink-0 items-center gap-1 rounded-md px-2 text-xs after:absolute after:inset-x-0 after:-inset-y-1.5 after:content-['']"
79
+ >
80
+ <CurrentIcon className="h-4 w-4" />
81
+ <span className="max-[359px]:hidden">{current.label}</span>
82
+ <ChevronDown className="text-muted-foreground h-3 w-3" />
83
+ </button>
84
+ </DropdownMenuTrigger>
85
+ <DropdownMenuContent align="end" className="min-w-40">
86
+ {views.map(({ mode, label, icon: Icon, count }) => (
87
+ <DropdownMenuItem
88
+ key={mode}
89
+ aria-current={mode === current.mode}
90
+ onSelect={() => onViewModeChange(mode)}
91
+ className="h-11 gap-2"
92
+ >
93
+ <Icon className="h-4 w-4" />
94
+ <span className="flex-1">{label}</span>
95
+ {count !== undefined && (
96
+ <span className="bg-primary/20 text-primary rounded px-1 text-[10px]">
97
+ {count}
98
+ </span>
99
+ )}
100
+ {mode === current.mode && <Check className="h-4 w-4" />}
101
+ </DropdownMenuItem>
102
+ ))}
103
+ </DropdownMenuContent>
104
+ </DropdownMenu>
105
+ );
106
+ }
@@ -68,7 +68,9 @@ export type ChatOriginKind =
68
68
  // AgentOS itself: the load monitor, stacks.
69
69
  | "system"
70
70
  // The reader's own decision, made outside the composer (a plan carried out).
71
- | "decision";
71
+ | "decision"
72
+ // The reader's phone: a shake report or something shared to AgentOS.
73
+ | "device";
72
74
 
73
75
  export interface ChatOrigin {
74
76
  kind: ChatOriginKind;
@@ -24,7 +24,8 @@ function fromText(text: string): ChatOrigin | null {
24
24
  return null;
25
25
  }
26
26
 
27
- // What sent a user item, or null when the reader typed it.
27
+ // What sent a user item, or null when the reader typed it. A "device" origin
28
+ // is only ever set by the server (lib/intake), so it is never read from text.
28
29
  export function originOf(item: UserItem): ChatOrigin | null {
29
30
  if (item.origin) return item.origin;
30
31
  if (item.peer)
@@ -66,6 +66,12 @@ function visibleCommands(caps: Capabilities): ChatCommand[] {
66
66
  );
67
67
  }
68
68
 
69
+ // The commands already loaded for a session's folder; never loads them.
70
+ export function cachedCommands(session: Session): ChatCommand[] {
71
+ const caps = registry.caps.get(capsKey(session));
72
+ return caps ? visibleCommands(caps) : [];
73
+ }
74
+
69
75
  export function emitCapabilities(session: Session, listener?: Listener): void {
70
76
  const caps = registry.caps.get(capsKey(session));
71
77
  if (!caps) return;
@@ -26,4 +26,51 @@ export interface ClientConfig {
26
26
  taskStates: Record<string, LabelTone>;
27
27
  runStatuses: Record<string, LabelTone>;
28
28
  runTriggers: Record<string, string>;
29
+ features: ClientFeatures;
30
+ intake: IntakeLimits;
31
+ attachments: { max: number };
32
+ voice: VoiceLimits;
29
33
  }
34
+
35
+ // Server kill switches for the phone's new sends.
36
+ export interface ClientFeatures {
37
+ shakeToReport: boolean;
38
+ shareIntake: boolean;
39
+ voice: boolean;
40
+ }
41
+
42
+ export interface IntakeLimits {
43
+ maxBytes: number;
44
+ maxFiles: number;
45
+ maxInlineImages: number;
46
+ maxNoteChars: number;
47
+ screenshotMaxEdge: number;
48
+ logSeconds: number;
49
+ logMaxLines: number;
50
+ }
51
+
52
+ export interface VoiceLimits {
53
+ maxPhrases: number;
54
+ holdMinMs: number;
55
+ cancelSlidePx: number;
56
+ speakMaxChars: number;
57
+ }
58
+
59
+ export const INTAKE_LIMITS: IntakeLimits = {
60
+ maxBytes: 25_000_000,
61
+ maxFiles: 8,
62
+ maxInlineImages: 2,
63
+ maxNoteChars: 280,
64
+ screenshotMaxEdge: 1600,
65
+ logSeconds: 60,
66
+ logMaxLines: 200,
67
+ };
68
+
69
+ export const ATTACHMENT_LIMITS = { max: 8 };
70
+
71
+ export const VOICE_LIMITS: VoiceLimits = {
72
+ maxPhrases: 100,
73
+ holdMinMs: 250,
74
+ cancelSlidePx: 80,
75
+ speakMaxChars: 300,
76
+ };
@@ -7,12 +7,26 @@ import {
7
7
  import { TASK_STATE_LABELS } from "../tasks/state-labels";
8
8
  import { cachedCheck } from "../updates/check";
9
9
  import {
10
+ ATTACHMENT_LIMITS,
10
11
  CLIENT_CONFIG_VERSION,
12
+ INTAKE_LIMITS,
13
+ VOICE_LIMITS,
14
+ type ClientFeatures,
11
15
  type ClientConfig,
12
16
  } from "./client-config-types";
13
17
 
14
18
  const startedAt = new Date(Date.now() - process.uptime() * 1000).toISOString();
15
19
 
20
+ // Off when its env switch says so, for a server that wants it dark.
21
+ export function featuresNow(): ClientFeatures {
22
+ const intake = process.env.AGENTOS_INTAKE !== "off";
23
+ return {
24
+ shakeToReport: intake,
25
+ shareIntake: intake,
26
+ voice: process.env.AGENTOS_VOICE !== "off",
27
+ };
28
+ }
29
+
16
30
  export function clientConfig(): ClientConfig {
17
31
  return {
18
32
  v: CLIENT_CONFIG_VERSION,
@@ -24,5 +38,9 @@ export function clientConfig(): ClientConfig {
24
38
  taskStates: TASK_STATE_LABELS,
25
39
  runStatuses: RUN_STATUS_LABELS,
26
40
  runTriggers: RUN_TRIGGER_LABELS,
41
+ features: featuresNow(),
42
+ intake: INTAKE_LIMITS,
43
+ attachments: ATTACHMENT_LIMITS,
44
+ voice: VOICE_LIMITS,
27
45
  };
28
46
  }
package/lib/db/index.ts CHANGED
@@ -3,6 +3,7 @@ import path from "path";
3
3
  import fs from "fs";
4
4
  import { createSchema } from "./schema";
5
5
  import { runMigrations } from "./migrations";
6
+ import { dbPath } from "./path";
6
7
 
7
8
  // Re-export types and queries
8
9
  export * from "./types";
@@ -11,7 +12,7 @@ export * from "./lumifyhub";
11
12
  export * from "./lumifyhub-docs";
12
13
  export * from "./stacks";
13
14
 
14
- const DB_PATH = process.env.DB_PATH || path.join(process.cwd(), "agent-os.db");
15
+ const DB_PATH = dbPath();
15
16
  const LOCK_PATH = DB_PATH + ".init-lock";
16
17
 
17
18
  // Simple file-based lock for initialization
package/lib/db/path.ts ADDED
@@ -0,0 +1,7 @@
1
+ import path from "path";
2
+
3
+ export const dbPath = (): string =>
4
+ process.env.DB_PATH || path.join(process.cwd(), "agent-os.db");
5
+
6
+ // Where AgentOS keeps its own files beside the database.
7
+ export const dataRoot = (): string => path.dirname(path.resolve(dbPath()));
@@ -0,0 +1,250 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { dataRoot } from "../db/path";
4
+ import type { ChatImage } from "../chat/events";
5
+ import { chatState, sendChat } from "../chat/runner";
6
+ import { ensureOrchestrator, getOrchestrator } from "../orchestrator/home";
7
+ import { getWorkspace } from "../workspaces";
8
+ import { INTAKE_LIMITS, MAX_INLINE_IMAGE_BYTES } from "./limits";
9
+ import { buildIntakeMessage, type IntakeMeta } from "./message";
10
+ import { sniff } from "./sniff";
11
+
12
+ export class IntakeError extends Error {
13
+ constructor(
14
+ public status: number,
15
+ message: string
16
+ ) {
17
+ super(message);
18
+ }
19
+ }
20
+
21
+ export interface IntakeResult {
22
+ id: string;
23
+ sessionId: string | null;
24
+ delivered: "sent" | "queued";
25
+ dir: string;
26
+ }
27
+
28
+ export interface IntakeFile {
29
+ name: string;
30
+ data: Buffer;
31
+ }
32
+
33
+ const ID = /^[A-Za-z0-9-]{8,64}$/;
34
+ const DAY = 86_400_000;
35
+
36
+ const segment = (s: string) => s.replace(/[^\w-]/g, "_");
37
+ const day = (t: number) => new Date(t).toISOString().slice(0, 10);
38
+
39
+ const workspaceRoot = (workspaceId: string) =>
40
+ path.join(dataRoot(), "intake", segment(workspaceId));
41
+
42
+ /** A phone's filename as one safe path segment. */
43
+ export function safeName(name: string, taken: Set<string>, n: number): string {
44
+ let base = path.basename(name.replace(/\\/g, "/"));
45
+ base = base
46
+ .replace(/[^a-zA-Z0-9.-]/g, "_")
47
+ .replace(/^\.+/, "")
48
+ .slice(0, 80);
49
+ if (!base) base = `file-${n}`;
50
+ let out = base;
51
+ for (let i = 2; taken.has(out); i++) {
52
+ const dot = base.lastIndexOf(".");
53
+ out =
54
+ dot > 0 ? `${base.slice(0, dot)}-${i}${base.slice(dot)}` : `${base}-${i}`;
55
+ }
56
+ taken.add(out);
57
+ return out;
58
+ }
59
+
60
+ function checkMeta(meta: IntakeMeta) {
61
+ if (!meta || typeof meta !== "object") throw new IntakeError(400, "Bad meta");
62
+ if (typeof meta.clientId !== "string" || !ID.test(meta.clientId))
63
+ throw new IntakeError(400, "clientId must be a uuid");
64
+ if (meta.kind !== "report" && meta.kind !== "share")
65
+ throw new IntakeError(400, "kind must be report or share");
66
+ if (meta.note !== undefined) {
67
+ if (typeof meta.note !== "string") throw new IntakeError(400, "Bad note");
68
+ if (meta.note.length > INTAKE_LIMITS.maxNoteChars)
69
+ throw new IntakeError(413, "Note is too long");
70
+ }
71
+ }
72
+
73
+ // An earlier request with this clientId, today or just before midnight.
74
+ function existing(
75
+ workspaceId: string,
76
+ clientId: string,
77
+ now: number
78
+ ): string | null {
79
+ for (const t of [now, now - DAY]) {
80
+ const dir = path.join(workspaceRoot(workspaceId), day(t), clientId);
81
+ if (fs.existsSync(dir)) return dir;
82
+ }
83
+ return null;
84
+ }
85
+
86
+ function earlier(dir: string, clientId: string, workspaceId: string) {
87
+ try {
88
+ return JSON.parse(
89
+ fs.readFileSync(path.join(dir, "result.json"), "utf8")
90
+ ) as IntakeResult;
91
+ } catch {
92
+ // The first request is still delivering.
93
+ return {
94
+ id: clientId,
95
+ sessionId: getOrchestrator(workspaceId)?.id ?? null,
96
+ delivered: "queued",
97
+ dir,
98
+ } satisfies IntakeResult;
99
+ }
100
+ }
101
+
102
+ /** Saves what a phone sent and hands it to the workspace's orchestrator. */
103
+ export async function receiveIntake(input: {
104
+ workspaceId: string;
105
+ deviceName: string;
106
+ meta: IntakeMeta;
107
+ files: IntakeFile[];
108
+ now?: number;
109
+ }): Promise<IntakeResult & { duplicate: boolean }> {
110
+ const { workspaceId, deviceName, meta, files } = input;
111
+ const now = input.now ?? Date.now();
112
+ if (!getWorkspace(workspaceId))
113
+ throw new IntakeError(404, "Unknown workspace");
114
+ checkMeta(meta);
115
+ if (files.length > INTAKE_LIMITS.maxFiles)
116
+ throw new IntakeError(413, "Too many files");
117
+ const total = files.reduce((n, f) => n + f.data.length, 0);
118
+ if (total > INTAKE_LIMITS.maxBytes) throw new IntakeError(413, "Too large");
119
+ const sniffed = files.map((f) => {
120
+ const kind = sniff(f.data, f.name);
121
+ if (!kind)
122
+ throw new IntakeError(415, `Can't take ${path.basename(f.name)}`);
123
+ return kind;
124
+ });
125
+
126
+ const prior = existing(workspaceId, meta.clientId, now);
127
+ if (prior)
128
+ return { ...earlier(prior, meta.clientId, workspaceId), duplicate: true };
129
+
130
+ const parent = path.join(workspaceRoot(workspaceId), day(now));
131
+ const dir = path.join(parent, meta.clientId);
132
+ fs.mkdirSync(parent, { recursive: true, mode: 0o700 });
133
+ try {
134
+ fs.mkdirSync(dir, { mode: 0o700 });
135
+ } catch (e) {
136
+ if ((e as NodeJS.ErrnoException).code === "EEXIST")
137
+ return { ...earlier(dir, meta.clientId, workspaceId), duplicate: true };
138
+ throw e;
139
+ }
140
+
141
+ try {
142
+ const write = (name: string, data: string | Buffer) =>
143
+ fs.writeFileSync(path.join(dir, name), data, { mode: 0o600 });
144
+ write(
145
+ "meta.json",
146
+ JSON.stringify({ ...meta, deviceName, receivedAt: now }, null, 2)
147
+ );
148
+ if (meta.kind === "report")
149
+ write("report.json", JSON.stringify(meta.report ?? {}, null, 2));
150
+ const taken = new Set([
151
+ "meta.json",
152
+ "report.json",
153
+ "logs.txt",
154
+ "result.json",
155
+ ]);
156
+ let logs: string | undefined;
157
+ const saved: { path: string; name: string; i: number }[] = [];
158
+ files.forEach((f, i) => {
159
+ if (f.name === "logs.txt" && logs === undefined) {
160
+ logs = f.data
161
+ .toString("utf8")
162
+ .split(/\r?\n/)
163
+ .slice(-INTAKE_LIMITS.logMaxLines)
164
+ .join("\n");
165
+ write("logs.txt", logs);
166
+ return;
167
+ }
168
+ const name = safeName(f.name, taken, i + 1);
169
+ write(name, f.data);
170
+ saved.push({ path: path.join(dir, name), name, i });
171
+ });
172
+
173
+ const images: ChatImage[] = saved
174
+ .filter(
175
+ (s) =>
176
+ sniffed[s.i].kind === "image" &&
177
+ sniffed[s.i].mediaType !== "image/heic" &&
178
+ files[s.i].data.length <= MAX_INLINE_IMAGE_BYTES
179
+ )
180
+ .sort(
181
+ (a, b) =>
182
+ Number(/^screenshot/.test(b.name)) -
183
+ Number(/^screenshot/.test(a.name))
184
+ )
185
+ .slice(0, INTAKE_LIMITS.maxInlineImages)
186
+ .map((s) => ({
187
+ mediaType: sniffed[s.i].mediaType,
188
+ data: files[s.i].data.toString("base64"),
189
+ }));
190
+
191
+ const { text, body } = buildIntakeMessage({
192
+ meta,
193
+ deviceName,
194
+ dir,
195
+ files: saved.map((s) => s.path),
196
+ logs,
197
+ });
198
+ const orchestrator = ensureOrchestrator(workspaceId);
199
+ const state = chatState(orchestrator.id);
200
+ const delivered =
201
+ state === "running" || state === "waiting" ? "queued" : "sent";
202
+ await sendChat(orchestrator.id, {
203
+ id: `intake-${meta.clientId}`,
204
+ text,
205
+ images: images.length ? images : undefined,
206
+ queue: true,
207
+ origin: { kind: "device", label: deviceName, body },
208
+ });
209
+ const result: IntakeResult = {
210
+ id: meta.clientId,
211
+ sessionId: orchestrator.id,
212
+ delivered,
213
+ dir,
214
+ };
215
+ write("result.json", JSON.stringify(result));
216
+ return { ...result, duplicate: false };
217
+ } catch (e) {
218
+ // So a retry can deliver it.
219
+ fs.rmSync(dir, { recursive: true, force: true });
220
+ throw e;
221
+ }
222
+ }
223
+
224
+ /** The request's form, refused once its body passes `max` bytes. */
225
+ export async function cappedFormData(
226
+ request: Request,
227
+ max: number
228
+ ): Promise<FormData> {
229
+ const declared = Number(request.headers.get("content-length") ?? 0);
230
+ if (declared > max) throw new IntakeError(413, "Too large");
231
+ if (!request.body) throw new IntakeError(400, "No body");
232
+ let size = 0;
233
+ const counted = request.body.pipeThrough(
234
+ new TransformStream<Uint8Array, Uint8Array>({
235
+ transform(chunk, controller) {
236
+ size += chunk.byteLength;
237
+ if (size > max) controller.error(new IntakeError(413, "Too large"));
238
+ else controller.enqueue(chunk);
239
+ },
240
+ })
241
+ );
242
+ try {
243
+ return await new Response(counted, {
244
+ headers: { "content-type": request.headers.get("content-type") ?? "" },
245
+ }).formData();
246
+ } catch (e) {
247
+ if (e instanceof IntakeError) throw e;
248
+ throw new IntakeError(size > max ? 413 : 400, "Bad upload");
249
+ }
250
+ }
@@ -0,0 +1,30 @@
1
+ import { INTAKE_LIMITS } from "../config/client-config-types";
2
+
3
+ export { INTAKE_LIMITS };
4
+
5
+ export const RATE = { max: 20, windowMs: 10 * 60_000 };
6
+ // Under the 5 MB an image may be once base64'd.
7
+ export const MAX_INLINE_IMAGE_BYTES = 3_750_000;
8
+ export const SHARE_EXCERPT_BYTES = 2048;
9
+ export const MAX_META_BYTES = 64 * 1024;
10
+ export const REPORT_INLINE_MAX = 1500;
11
+ // Multipart framing on top of the files themselves.
12
+ export const BODY_SLACK_BYTES = 1_000_000;
13
+
14
+ const hits = new Map<string, number[]>();
15
+
16
+ /** Counts a request against the device; false once it is over the cap. */
17
+ export function takeIntakeSlot(deviceId: string, now = Date.now()): boolean {
18
+ const recent = (hits.get(deviceId) ?? []).filter(
19
+ (t) => t > now - RATE.windowMs
20
+ );
21
+ if (recent.length >= RATE.max) {
22
+ hits.set(deviceId, recent);
23
+ return false;
24
+ }
25
+ recent.push(now);
26
+ hits.set(deviceId, recent);
27
+ return true;
28
+ }
29
+
30
+ export const resetIntakeRate = () => hits.clear();