@aghents/gateway 0.2.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,198 @@
1
+ import { Result } from 'neverthrow';
2
+ import { z } from 'zod';
3
+ import { PulseServiceClient, PulseGame, GameStore, PulseError } from '@aghents/pulse';
4
+ export { SCHEDULER_CADENCE_MS } from '@aghents/pulse';
5
+
6
+ /**
7
+ * The persona contract. Personas decide from a seat's View ONLY, never GameState; no wall clock
8
+ * and no global RNG anywhere in this package: `nowMs`, `rng` and `fraction` are parameters.
9
+ */
10
+
11
+ type GatewayError = {
12
+ code: "llm" | "invalid_slug" | "handle_taken" | "db";
13
+ message: string;
14
+ };
15
+ interface PersonaTiming {
16
+ /** Earliest action time as a fraction of the open window (0..1). */
17
+ minFraction: number;
18
+ /** Latest action time as a fraction of the open window (0..1). */
19
+ maxFraction: number;
20
+ }
21
+ /**
22
+ * What a persona decided, and (when it differs from the persona's own tier/model) what produced
23
+ * it: an llm persona's heuristic fallback discloses as heuristic, never as the model it skipped.
24
+ */
25
+ type Decision<E> = {
26
+ events: E[];
27
+ produced?: {
28
+ tier: "heuristic" | "llm";
29
+ model: string;
30
+ };
31
+ };
32
+ interface Persona<V, E> {
33
+ personaId: string;
34
+ tier: "heuristic" | "llm";
35
+ model: string;
36
+ timing: PersonaTiming;
37
+ decide(input: {
38
+ view: V;
39
+ rng: () => number;
40
+ fraction: number;
41
+ }): Promise<Decision<E>>;
42
+ }
43
+ declare const LLM_MAX_TOKENS = 400;
44
+ /**
45
+ * An llm-tier persona: prompt from the view, complete, parse the text into events. A completer
46
+ * failure rejects, and the scheduler turns that into a `skipped` seat.
47
+ */
48
+ declare function llmPersona<V, E>(opts: {
49
+ personaId: string;
50
+ model: string;
51
+ completer: Completer;
52
+ prompt: (view: V) => {
53
+ system: string;
54
+ user: string;
55
+ };
56
+ parse: (text: string, view: V) => E[];
57
+ timing?: PersonaTiming;
58
+ /**
59
+ * Used when the completer errs or `parse` yields nothing, so an llm persona backed by a
60
+ * heuristic never spends a second call on the same pulse. Without it the scheduler marks the
61
+ * seat skipped and asks again next tick.
62
+ */
63
+ fallback?: (input: {
64
+ view: V;
65
+ rng: () => number;
66
+ fraction: number;
67
+ }) => E[];
68
+ }): Persona<V, E>;
69
+
70
+ interface Completer {
71
+ complete(input: {
72
+ system: string;
73
+ user: string;
74
+ maxTokens: number;
75
+ }): Promise<Result<string, GatewayError>>;
76
+ }
77
+
78
+ declare const schema: z.ZodObject<{
79
+ ANTHROPIC_API_KEY: z.ZodString;
80
+ AGHENTS_LLM_MODEL: z.ZodDefault<z.ZodString>;
81
+ }, "strip", z.ZodTypeAny, {
82
+ ANTHROPIC_API_KEY: string;
83
+ AGHENTS_LLM_MODEL: string;
84
+ }, {
85
+ ANTHROPIC_API_KEY: string;
86
+ AGHENTS_LLM_MODEL?: string | undefined;
87
+ }>;
88
+ type LlmEnv = z.infer<typeof schema>;
89
+ declare function readLlmEnv(raw: Record<string, string | undefined>): LlmEnv;
90
+
91
+ /** One call's worst case: the SDK timeout times the attempts (one retry). The scheduler reserves this much of its deadline before an llm decision. */
92
+ declare const LLM_TIMEOUT_MS = 20000;
93
+ declare const LLM_MAX_RETRIES = 1;
94
+ declare const LLM_WORST_CASE_MS: number;
95
+ /** Pad for the awaits between the reserve check and the call itself (the budget spend). */
96
+ declare const LLM_PREFLIGHT_MS = 2000;
97
+ declare class AnthropicCompleter implements Completer {
98
+ private readonly client;
99
+ private readonly model;
100
+ constructor(opts: {
101
+ env: LlmEnv;
102
+ fetch?: typeof globalThis.fetch;
103
+ });
104
+ complete(input: {
105
+ system: string;
106
+ user: string;
107
+ maxTokens: number;
108
+ }): Promise<Result<string, GatewayError>>;
109
+ }
110
+
111
+ /** Scripted replies for tests, consumed in order; records every call. */
112
+ declare class FakeCompleter implements Completer {
113
+ private readonly replies;
114
+ readonly calls: {
115
+ system: string;
116
+ user: string;
117
+ maxTokens: number;
118
+ }[];
119
+ constructor(replies: string[]);
120
+ complete(input: {
121
+ system: string;
122
+ user: string;
123
+ maxTokens: number;
124
+ }): Promise<Result<string, GatewayError>>;
125
+ }
126
+
127
+ /**
128
+ * Mint an identity.profiles row for a persona. profiles.id references auth.users, so the persona
129
+ * gets an auth user first: no password and an .invalid address, so nothing can ever sign in as
130
+ * it. Idempotent: an existing persona profile row wins; an existing auth user without one is
131
+ * looked up by email and reused. The `persona-` handle namespace is reserved to kind='persona'
132
+ * (0004_persona_handles.sql), so a human row under this handle can only predate 0004; it is
133
+ * never returned as the persona.
134
+ */
135
+ declare function mintPersonaProfile(serviceClient: PulseServiceClient, input: {
136
+ slug: string;
137
+ displayName: string;
138
+ }): Promise<Result<{
139
+ id: string;
140
+ }, GatewayError>>;
141
+
142
+ type Log = (message: string, context: Record<string, unknown>) => void;
143
+
144
+ /**
145
+ * What must remain of a window after a persona's due time, per tier: one scheduler cadence
146
+ * (so a cron tick lands after it) plus, for an llm seat, the worst case of its call (so the
147
+ * tick that finds it due can still afford the decision before the window closes).
148
+ */
149
+ declare const LLM_RESERVE_MS: number;
150
+ declare const dueReserveMs: (tier: Persona<unknown, unknown>["tier"]) => number;
151
+ /** The shortest window in which every tier can still act: cadence + the llm worst case. */
152
+ declare const MIN_WINDOW_MS: number;
153
+ /**
154
+ * When a persona is due: its offset into the window, clamped so `dueReserveMs(tier)` remains
155
+ * before the window ends. Otherwise an offset in the window's tail can fall between two cron
156
+ * ticks, the next of which advances the phase (or refuses the llm reserve) before the persona
157
+ * acts. A window shorter than the reserve is due at its start.
158
+ */
159
+ declare function dueAtMs(windowStartMs: number, windowMs: number, offsetMs: number, tier?: Persona<unknown, unknown>["tier"]): number;
160
+ /**
161
+ * One scheduler pass over one game: catch the clock up, then let every due persona act once per
162
+ * phase, in both the open and the lock window (which events each phase takes is the game's
163
+ * `advance`: Circle claims in open and votes in lock). "Acted this phase" is read off the store:
164
+ * the persona ids on events after the last system (clock) event. Idempotent: a second tick in the
165
+ * same phase acts nobody. A game-level failure (the clock could not be caught up, or the record
166
+ * or log could not be read afterwards) is an err with the pulse code, logged, so a pass never
167
+ * reports an outage as an empty success. `nowMs` is the pass start (offsets, catchUp); `now()`
168
+ * is the live clock for the pre-decide budget, the post-decide window check and the submit
169
+ * time (pure: callers inject it, tests pass a fake). With a `deadline`, due seats not reached
170
+ * before `now() >= deadline.atMs` are returned as `deferred` (the next tick asks them).
171
+ */
172
+ type TickResult = {
173
+ acted: string[];
174
+ skipped: string[];
175
+ deferred: string[];
176
+ };
177
+ declare function tick<S, E, V>(input: {
178
+ game: PulseGame<S, E, V>;
179
+ store: GameStore<E>;
180
+ personas: Map<string, Persona<V, E>>;
181
+ gameId: string;
182
+ nowMs: number;
183
+ now: () => number;
184
+ deadline?: {
185
+ atMs: number;
186
+ };
187
+ log?: Log;
188
+ }): Promise<Result<TickResult, PulseError>>;
189
+
190
+ /**
191
+ * Millisecond offsets within a pulse window at which a persona acts. Ported unchanged from
192
+ * Circle packages/personas/src/timing.ts. Pure: the scheduler owns wall-clock time.
193
+ */
194
+ declare function actionOffsets(count: number, windowMs: number, profile: {
195
+ timing: PersonaTiming;
196
+ }, rng: () => number): number[];
197
+
198
+ export { AnthropicCompleter, type Completer, type Decision, FakeCompleter, type GatewayError, LLM_MAX_RETRIES, LLM_MAX_TOKENS, LLM_PREFLIGHT_MS, LLM_RESERVE_MS, LLM_TIMEOUT_MS, LLM_WORST_CASE_MS, type LlmEnv, type Log, MIN_WINDOW_MS, type Persona, type PersonaTiming, type TickResult, actionOffsets, dueAtMs, dueReserveMs, llmPersona, mintPersonaProfile, readLlmEnv, tick };
package/dist/index.js ADDED
@@ -0,0 +1,305 @@
1
+ // src/completer.anthropic.ts
2
+ import Anthropic from "@anthropic-ai/sdk";
3
+ import { err, ok } from "neverthrow";
4
+
5
+ // src/persona.ts
6
+ var LLM_MAX_TOKENS = 400;
7
+ var DEFAULT_TIMING = { minFraction: 0.1, maxFraction: 0.8 };
8
+ function llmPersona(opts) {
9
+ return {
10
+ personaId: opts.personaId,
11
+ tier: "llm",
12
+ model: opts.model,
13
+ timing: opts.timing ?? DEFAULT_TIMING,
14
+ async decide(input) {
15
+ const { system, user } = opts.prompt(input.view);
16
+ const r = await opts.completer.complete({ system, user, maxTokens: LLM_MAX_TOKENS });
17
+ let events = [];
18
+ if (r.isOk()) {
19
+ try {
20
+ events = opts.parse(r.value, input.view);
21
+ } catch {
22
+ events = [];
23
+ }
24
+ }
25
+ if (events.length > 0) return { events };
26
+ if (opts.fallback) {
27
+ return {
28
+ events: opts.fallback(input),
29
+ produced: { tier: "heuristic", model: "heuristic:fallback" }
30
+ };
31
+ }
32
+ if (r.isErr()) throw new Error(`${r.error.code}: ${r.error.message}`);
33
+ return { events: [] };
34
+ }
35
+ };
36
+ }
37
+
38
+ // src/completer.anthropic.ts
39
+ var LLM_TIMEOUT_MS = 2e4;
40
+ var LLM_MAX_RETRIES = 1;
41
+ var LLM_WORST_CASE_MS = LLM_TIMEOUT_MS * (LLM_MAX_RETRIES + 1);
42
+ var LLM_PREFLIGHT_MS = 2e3;
43
+ var AnthropicCompleter = class {
44
+ client;
45
+ model;
46
+ constructor(opts) {
47
+ this.model = opts.env.AGHENTS_LLM_MODEL;
48
+ this.client = new Anthropic({
49
+ apiKey: opts.env.ANTHROPIC_API_KEY,
50
+ timeout: LLM_TIMEOUT_MS,
51
+ maxRetries: LLM_MAX_RETRIES,
52
+ ...opts.fetch ? { fetch: opts.fetch } : {}
53
+ });
54
+ }
55
+ async complete(input) {
56
+ try {
57
+ const res = await this.client.messages.create({
58
+ model: this.model,
59
+ max_tokens: Math.min(input.maxTokens, LLM_MAX_TOKENS),
60
+ system: input.system,
61
+ messages: [{ role: "user", content: input.user }]
62
+ });
63
+ const text = res.content.filter((b) => b.type === "text").map((b) => b.text).join("");
64
+ return ok(text);
65
+ } catch (e) {
66
+ return err({ code: "llm", message: e instanceof Error ? e.message : String(e) });
67
+ }
68
+ }
69
+ };
70
+
71
+ // src/completer.fake.ts
72
+ import { err as err2, ok as ok2 } from "neverthrow";
73
+ var FakeCompleter = class {
74
+ constructor(replies) {
75
+ this.replies = replies;
76
+ }
77
+ replies;
78
+ calls = [];
79
+ async complete(input) {
80
+ this.calls.push(input);
81
+ const reply = this.replies.shift();
82
+ return reply === void 0 ? err2({ code: "llm", message: `no scripted reply for call ${this.calls.length}` }) : ok2(reply);
83
+ }
84
+ };
85
+
86
+ // src/env.ts
87
+ import { z } from "zod";
88
+ var schema = z.object({
89
+ ANTHROPIC_API_KEY: z.string().min(1),
90
+ AGHENTS_LLM_MODEL: z.string().min(1).default("claude-sonnet-5")
91
+ });
92
+ function readLlmEnv(raw) {
93
+ return schema.parse(raw);
94
+ }
95
+
96
+ // src/profile.ts
97
+ import { err as err3, ok as ok3 } from "neverthrow";
98
+ import { z as z2 } from "zod";
99
+ var slug = z2.string().regex(/^[a-z0-9-]{1,16}$/);
100
+ var personaEmail = (s) => `persona-${s}@personas.aghents.invalid`;
101
+ async function mintPersonaProfile(serviceClient, input) {
102
+ const parsed = slug.safeParse(input.slug);
103
+ if (!parsed.success) {
104
+ return err3({ code: "invalid_slug", message: "slug must match ^[a-z0-9-]{1,16}$" });
105
+ }
106
+ const handle = `persona-${parsed.data}`;
107
+ const identity = serviceClient.schema("identity");
108
+ const existing = await identity.from("profiles").select("id, kind").eq("handle", handle).maybeSingle();
109
+ if (existing.error) return err3({ code: "db", message: existing.error.message });
110
+ if (existing.data?.kind === "persona") return ok3({ id: existing.data.id });
111
+ if (existing.data) {
112
+ return err3({ code: "handle_taken", message: `${handle} belongs to a ${existing.data.kind}` });
113
+ }
114
+ const user = await userId(serviceClient, personaEmail(parsed.data));
115
+ if (user.isErr()) return err3(user.error);
116
+ const { error } = await identity.from("profiles").upsert(
117
+ { id: user.value, handle, display_name: input.displayName, kind: "persona" },
118
+ { onConflict: "id" }
119
+ );
120
+ return error ? err3({ code: "db", message: error.message }) : ok3({ id: user.value });
121
+ }
122
+ async function userId(client, email) {
123
+ const created = await client.auth.admin.createUser({
124
+ email,
125
+ email_confirm: true,
126
+ user_metadata: { kind: "persona" },
127
+ app_metadata: { kind: "persona" }
128
+ });
129
+ if (!created.error) return ok3(created.data.user.id);
130
+ if (created.error.code !== "email_exists") {
131
+ return err3({ code: "db", message: created.error.message });
132
+ }
133
+ for (let page = 1; ; page++) {
134
+ const list = await client.auth.admin.listUsers({ page, perPage: 1e3 });
135
+ if (list.error) return err3({ code: "db", message: list.error.message });
136
+ const hit = list.data.users.find((u) => u.email === email);
137
+ if (hit) {
138
+ return hit.app_metadata?.kind === "persona" ? ok3(hit.id) : err3({ code: "handle_taken", message: `${email} is a client-created account` });
139
+ }
140
+ if (list.data.users.length < 1e3) return err3({ code: "db", message: `no user ${email}` });
141
+ }
142
+ }
143
+
144
+ // src/scheduler.ts
145
+ import {
146
+ SCHEDULER_CADENCE_MS,
147
+ catchUp,
148
+ createRng,
149
+ getView,
150
+ pulseTimes,
151
+ submit
152
+ } from "@aghents/pulse";
153
+ import { err as err4, ok as ok4 } from "neverthrow";
154
+
155
+ // src/timing.ts
156
+ function actionOffsets(count, windowMs, profile, rng) {
157
+ const lo = profile.timing.minFraction * windowMs;
158
+ const hi = profile.timing.maxFraction * windowMs;
159
+ return Array.from({ length: count }, () => lo + rng() * (hi - lo)).sort((a, b) => a - b);
160
+ }
161
+
162
+ // src/scheduler.ts
163
+ var noop = () => {
164
+ };
165
+ var LLM_RESERVE_MS = LLM_WORST_CASE_MS + LLM_PREFLIGHT_MS;
166
+ var dueReserveMs = (tier) => SCHEDULER_CADENCE_MS + (tier === "llm" ? LLM_RESERVE_MS : 0);
167
+ var MIN_WINDOW_MS = dueReserveMs("llm");
168
+ function dueAtMs(windowStartMs, windowMs, offsetMs, tier = "heuristic") {
169
+ const latest = Math.max(windowStartMs, windowStartMs + windowMs - dueReserveMs(tier));
170
+ return Math.min(windowStartMs + offsetMs, latest);
171
+ }
172
+ function actedIds(events) {
173
+ const lastClock = events.map((e) => e.actor.actorKind).lastIndexOf("system");
174
+ return new Set(
175
+ events.slice(lastClock + 1).flatMap((e) => e.actor.actorKind === "persona" ? [e.actor.personaId] : [])
176
+ );
177
+ }
178
+ async function tick(input) {
179
+ const { game, store, personas, gameId, nowMs, now, deadline } = input;
180
+ const log = input.log ?? noop;
181
+ const acted = [];
182
+ const skipped = [];
183
+ const deferred = [];
184
+ const skip = (seatId, message, context = {}) => {
185
+ skipped.push(seatId);
186
+ log(message, { gameId, seatId, ...context });
187
+ };
188
+ const fail = (what, e) => {
189
+ log(`${what}: ${e.message}`, { gameId, code: e.code });
190
+ return err4(e);
191
+ };
192
+ const state = await catchUp(game, store, gameId, nowMs);
193
+ if (state.isErr()) return fail("catchUp", state.error);
194
+ if (!state.value) return ok4({ acted, skipped, deferred });
195
+ const phase = game.phaseOf(state.value);
196
+ if (phase === "complete") return ok4({ acted, skipped, deferred });
197
+ const [record, events] = await Promise.all([store.getGame(gameId), store.listEvents(gameId)]);
198
+ if (record.isErr()) return fail("getGame", record.error);
199
+ if (events.isErr()) return fail("listEvents", events.error);
200
+ if (!record.value) return fail("getGame", { code: "store", message: "game vanished" });
201
+ const pulse = game.pulseOf(state.value);
202
+ const t = pulseTimes(record.value.startedAtMs, record.value.timescale, pulse);
203
+ const windowStartMs = phase === "open" ? t.openAtMs : t.lockAtMs;
204
+ const windowEndMs = phase === "open" ? t.lockAtMs : t.resolveAtMs;
205
+ const windowMs = windowEndMs - windowStartMs;
206
+ const actedThisPhase = actedIds(events.value);
207
+ const actedMeanwhile = async (personaId) => (await store.listEvents(gameId)).map((es) => actedIds(es).has(personaId)).unwrapOr(true);
208
+ for (const [seatId, persona] of personas) {
209
+ const seat = record.value.seats.find((s) => s.seatId === seatId);
210
+ if (seat?.kind !== "persona") {
211
+ skip(seatId, "not a persona seat", { reason: "not_persona_seat" });
212
+ continue;
213
+ }
214
+ if (seat.personaProfileId !== persona.personaId) {
215
+ skip(seatId, "persona does not match the seat", { reason: "persona_mismatch" });
216
+ continue;
217
+ }
218
+ if (actedThisPhase.has(persona.personaId)) continue;
219
+ const seed = `${record.value.seed}:${seatId}:${pulse}:${phase}`;
220
+ const [offset = 0] = actionOffsets(1, windowMs, persona, createRng(`${seed}:when`));
221
+ if (nowMs < dueAtMs(windowStartMs, windowMs, offset, persona.tier)) continue;
222
+ const budgetEndMs = Math.min(deadline?.atMs ?? Number.POSITIVE_INFINITY, windowEndMs);
223
+ const view = await getView(game, store, { gameId, seatId, nowMs });
224
+ if (view.isErr()) {
225
+ skip(seatId, `getView: ${view.error.message}`);
226
+ continue;
227
+ }
228
+ const reserveMs = persona.tier === "llm" ? LLM_RESERVE_MS : 0;
229
+ if (budgetEndMs - now() < Math.max(reserveMs, 1)) {
230
+ deferred.push(seatId);
231
+ log("no budget for the decision; deferring the seat", { gameId, seatId, reserveMs });
232
+ continue;
233
+ }
234
+ if (persona.tier === "llm") {
235
+ const spent = await store.spendLlmCall(gameId);
236
+ if (spent.isErr()) {
237
+ skip(seatId, `spendLlmCall: ${spent.error.code}`, { code: spent.error.code });
238
+ continue;
239
+ }
240
+ }
241
+ let decided;
242
+ try {
243
+ decided = await persona.decide({
244
+ view: view.value.view,
245
+ rng: createRng(`${seed}:decide`),
246
+ fraction: (nowMs - windowStartMs) / windowMs
247
+ });
248
+ } catch (e) {
249
+ skip(seatId, `decide: ${e instanceof Error ? e.message : String(e)}`);
250
+ continue;
251
+ }
252
+ if (decided.events.length === 0) {
253
+ skip(seatId, "decide returned no events", { reason: "no_events" });
254
+ continue;
255
+ }
256
+ const actor = {
257
+ actorKind: "persona",
258
+ personaId: persona.personaId,
259
+ tier: decided.produced?.tier ?? persona.tier,
260
+ model: decided.produced?.model ?? persona.model
261
+ };
262
+ let landed = 0;
263
+ for (const event of decided.events) {
264
+ if (landed === 0 && await actedMeanwhile(persona.personaId)) {
265
+ skip(seatId, "acted meanwhile", { reason: "acted_meanwhile" });
266
+ break;
267
+ }
268
+ const at = now();
269
+ if (at >= budgetEndMs) {
270
+ skip(seatId, at >= windowEndMs ? "window closed" : "deadline passed", {
271
+ reason: at >= windowEndMs ? "window_closed" : "deadline",
272
+ landed
273
+ });
274
+ break;
275
+ }
276
+ const r = await submit(game, store, { gameId, seatId, event, nowMs: at, actor });
277
+ if (r.isErr()) {
278
+ skip(seatId, `submit: ${r.error.code}`, { code: r.error.code, message: r.error.message });
279
+ break;
280
+ }
281
+ landed++;
282
+ }
283
+ if (landed > 0 && landed === decided.events.length) acted.push(seatId);
284
+ }
285
+ return ok4({ acted, skipped, deferred });
286
+ }
287
+ export {
288
+ AnthropicCompleter,
289
+ FakeCompleter,
290
+ LLM_MAX_RETRIES,
291
+ LLM_MAX_TOKENS,
292
+ LLM_PREFLIGHT_MS,
293
+ LLM_RESERVE_MS,
294
+ LLM_TIMEOUT_MS,
295
+ LLM_WORST_CASE_MS,
296
+ MIN_WINDOW_MS,
297
+ SCHEDULER_CADENCE_MS,
298
+ actionOffsets,
299
+ dueAtMs,
300
+ dueReserveMs,
301
+ llmPersona,
302
+ mintPersonaProfile,
303
+ readLlmEnv,
304
+ tick
305
+ };
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@aghents/gateway",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "dependencies": {
21
+ "@anthropic-ai/sdk": "^0.125.0",
22
+ "neverthrow": "^8.1.1",
23
+ "zod": "^3.24.1",
24
+ "@aghents/pulse": "0.2.0"
25
+ },
26
+ "devDependencies": {
27
+ "typescript": "^5.9.3",
28
+ "vitest": "^2.1.9"
29
+ },
30
+ "scripts": {
31
+ "build": "tsup src/index.ts --format esm --dts --clean",
32
+ "typecheck": "tsc --noEmit -p tsconfig.json",
33
+ "test": "vitest run"
34
+ }
35
+ }