@alfe.ai/openclaw-voice 0.0.1
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.
- package/README.md +49 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.js +266 -0
- package/package.json +31 -0
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# packages/openclaw-voice (`@alfe.ai/openclaw-voice`)
|
|
2
|
+
|
|
3
|
+
OpenClaw voice plugin for Alfe — Discord audio, Twilio phone calls, and meeting bots.
|
|
4
|
+
|
|
5
|
+
## What It Does
|
|
6
|
+
|
|
7
|
+
OpenClaw plugin that integrates voice capabilities into the agent runtime. All heavy voice logic lives in `voice-service` — this package is the OpenClaw integration layer.
|
|
8
|
+
|
|
9
|
+
On activation, the plugin:
|
|
10
|
+
|
|
11
|
+
- **Registers voice tools** — `voice_hangup`, `voice_transfer` (Twilio), `voice_dtmf` (Twilio)
|
|
12
|
+
- **Registers HTTP routes** — `/twilio/inbound` and `/twilio/status` webhook handlers
|
|
13
|
+
- **Registers gateway RPC** — `voice.speak` for TTS and direct audio output
|
|
14
|
+
- **Hooks into OpenClaw events** — auto-joins/leaves Discord voice on session start/end, auto-injects session IDs into tool calls
|
|
15
|
+
- **Connects to Alfe daemon IPC** — registers voice capabilities; gracefully degrades if unavailable
|
|
16
|
+
|
|
17
|
+
Follows the standard OpenClaw plugin pattern: exports `id`, `name`, `activate`, `deactivate`.
|
|
18
|
+
|
|
19
|
+
## Key Files
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
src/
|
|
23
|
+
├── plugin.ts # Plugin entry point (activate/deactivate lifecycle, tools, routes, hooks)
|
|
24
|
+
└── index.ts # Public re-exports
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Session Management
|
|
28
|
+
|
|
29
|
+
The plugin maintains bidirectional maps between OpenClaw session keys and voice session IDs. On `before_tool_call`, it auto-injects `sessionId` into voice tool parameters so the agent doesn't need to track sessions explicitly.
|
|
30
|
+
|
|
31
|
+
Discord sessions are detected via the pattern `voice-discord:{guildId}:{channelId}`.
|
|
32
|
+
|
|
33
|
+
## Development
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pnpm install
|
|
37
|
+
pnpm --filter @alfe.ai/openclaw-voice build
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Testing
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pnpm --filter @alfe.ai/openclaw-voice test
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Dependencies
|
|
47
|
+
|
|
48
|
+
- **voice-service** — core voice logic (TTS, audio pipeline, Discord/Twilio adapters)
|
|
49
|
+
- **@alfe.ai/openclaw** — OpenClaw runtime plugin API
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { TSchema } from "@sinclair/typebox";
|
|
2
|
+
|
|
3
|
+
//#region src/plugin.d.ts
|
|
4
|
+
|
|
5
|
+
interface Logger {
|
|
6
|
+
info(msg: string, ...args: unknown[]): void;
|
|
7
|
+
warn(msg: string, ...args: unknown[]): void;
|
|
8
|
+
error(msg: string, ...args: unknown[]): void;
|
|
9
|
+
debug(msg: string, ...args: unknown[]): void;
|
|
10
|
+
}
|
|
11
|
+
interface VoicePluginConfig {
|
|
12
|
+
voiceServiceUrl?: string;
|
|
13
|
+
voiceServicePort?: string | number;
|
|
14
|
+
voiceServiceApiKey?: string;
|
|
15
|
+
daemonSocket?: string;
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
interface OpenClawConfig {
|
|
19
|
+
plugins?: {
|
|
20
|
+
entries?: Record<string, {
|
|
21
|
+
config?: VoicePluginConfig;
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
}>;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
};
|
|
26
|
+
[key: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
interface OpenClawPluginApi {
|
|
29
|
+
logger: Logger;
|
|
30
|
+
config?: OpenClawConfig;
|
|
31
|
+
registerTool(tool: ToolDef): void;
|
|
32
|
+
registerGatewayMethod(name: string, handler: (...args: unknown[]) => Promise<unknown>): void;
|
|
33
|
+
on(event: string, handler: (...args: unknown[]) => void | Promise<void>, options?: {
|
|
34
|
+
priority?: number;
|
|
35
|
+
}): void;
|
|
36
|
+
}
|
|
37
|
+
interface ToolDef {
|
|
38
|
+
name: string;
|
|
39
|
+
description: string;
|
|
40
|
+
label: string;
|
|
41
|
+
parameters: TSchema;
|
|
42
|
+
execute: (toolCallId: string, params: Record<string, unknown>) => Promise<{
|
|
43
|
+
content: {
|
|
44
|
+
type: 'text';
|
|
45
|
+
text: string;
|
|
46
|
+
}[];
|
|
47
|
+
details: unknown;
|
|
48
|
+
}>;
|
|
49
|
+
}
|
|
50
|
+
declare const plugin: {
|
|
51
|
+
id: string;
|
|
52
|
+
name: string;
|
|
53
|
+
description: string;
|
|
54
|
+
version: string;
|
|
55
|
+
activate(api: OpenClawPluginApi): Promise<void>;
|
|
56
|
+
deactivate(api: OpenClawPluginApi): void;
|
|
57
|
+
};
|
|
58
|
+
//#endregion
|
|
59
|
+
export { plugin as default };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
//#region src/plugin.ts
|
|
5
|
+
/**
|
|
6
|
+
* @alfe/voice-plugin — OpenClaw native plugin
|
|
7
|
+
*
|
|
8
|
+
* Thin client that registers voice tools with OpenClaw and forwards
|
|
9
|
+
* all operations to the voice service over HTTP. The plugin holds no
|
|
10
|
+
* credentials and imports nothing from the voice service.
|
|
11
|
+
*/
|
|
12
|
+
const DEFAULT_SOCKET_PATH = join(homedir(), ".alfe", "gateway.sock");
|
|
13
|
+
const VOICE_CAPABILITIES = [
|
|
14
|
+
"voice.call",
|
|
15
|
+
"voice.answer",
|
|
16
|
+
"voice.dtmf",
|
|
17
|
+
"voice.hangup"
|
|
18
|
+
];
|
|
19
|
+
let voiceServiceUrl = "";
|
|
20
|
+
let voiceServiceApiKey = "";
|
|
21
|
+
async function voiceApi(method, path, body) {
|
|
22
|
+
const url = `${voiceServiceUrl}${path}`;
|
|
23
|
+
const headers = { "Content-Type": "application/json" };
|
|
24
|
+
if (voiceServiceApiKey) headers["x-api-key"] = voiceServiceApiKey;
|
|
25
|
+
const res = await fetch(url, {
|
|
26
|
+
method,
|
|
27
|
+
headers,
|
|
28
|
+
body: body ? JSON.stringify(body) : void 0
|
|
29
|
+
});
|
|
30
|
+
const json = await res.json();
|
|
31
|
+
if (!res.ok) {
|
|
32
|
+
const errorMsg = typeof json.error === "string" ? json.error : `Voice service returned ${String(res.status)}`;
|
|
33
|
+
throw new Error(errorMsg);
|
|
34
|
+
}
|
|
35
|
+
return json;
|
|
36
|
+
}
|
|
37
|
+
let daemonIpcClient = null;
|
|
38
|
+
async function connectToDaemon(socketPath, log) {
|
|
39
|
+
try {
|
|
40
|
+
const IPCClientCtor = (await import("@alfe.ai/openclaw")).IPCClient;
|
|
41
|
+
const client = new IPCClientCtor(socketPath, log);
|
|
42
|
+
client.on("connected", async () => {
|
|
43
|
+
log.info("Connected to Alfe daemon — registering voice capabilities...");
|
|
44
|
+
const response = await client.request("capability.register", {
|
|
45
|
+
plugin: "@alfe.ai/openclaw-voice",
|
|
46
|
+
capabilities: [...VOICE_CAPABILITIES]
|
|
47
|
+
});
|
|
48
|
+
if (response.ok) log.info("Voice capabilities registered with daemon");
|
|
49
|
+
else log.warn(`Failed to register voice capabilities: ${response.error?.message ?? "unknown"}`);
|
|
50
|
+
});
|
|
51
|
+
client.on("disconnected", (reason) => {
|
|
52
|
+
log.warn(`Disconnected from Alfe daemon: ${String(reason)}`);
|
|
53
|
+
});
|
|
54
|
+
client.on("error", (err) => {
|
|
55
|
+
log.debug(`Daemon IPC error: ${err.message}`);
|
|
56
|
+
});
|
|
57
|
+
client.start();
|
|
58
|
+
return client;
|
|
59
|
+
} catch {
|
|
60
|
+
log.info("Alfe daemon not available — voice plugin running standalone");
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function ok(data) {
|
|
65
|
+
return {
|
|
66
|
+
content: [{
|
|
67
|
+
type: "text",
|
|
68
|
+
text: JSON.stringify(data)
|
|
69
|
+
}],
|
|
70
|
+
details: data
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function errResult(message) {
|
|
74
|
+
return {
|
|
75
|
+
content: [{
|
|
76
|
+
type: "text",
|
|
77
|
+
text: JSON.stringify({ error: message })
|
|
78
|
+
}],
|
|
79
|
+
details: { error: message }
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function defineTool(def) {
|
|
83
|
+
return {
|
|
84
|
+
name: def.name,
|
|
85
|
+
description: def.description,
|
|
86
|
+
label: def.name,
|
|
87
|
+
parameters: def.parameters,
|
|
88
|
+
execute: async (_toolCallId, params) => {
|
|
89
|
+
try {
|
|
90
|
+
return ok(await def.handler(params));
|
|
91
|
+
} catch (e) {
|
|
92
|
+
return errResult(e.message);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** Maps OpenClaw session keys to voice session IDs */
|
|
98
|
+
const sessionKeyToVoiceId = /* @__PURE__ */ new Map();
|
|
99
|
+
/** Maps voice session IDs back to OpenClaw session keys */
|
|
100
|
+
const voiceIdToSessionKey = /* @__PURE__ */ new Map();
|
|
101
|
+
function linkSession(sessionKey, voiceSessionId) {
|
|
102
|
+
sessionKeyToVoiceId.set(sessionKey, voiceSessionId);
|
|
103
|
+
voiceIdToSessionKey.set(voiceSessionId, sessionKey);
|
|
104
|
+
}
|
|
105
|
+
function unlinkSession(sessionKey) {
|
|
106
|
+
const voiceId = sessionKeyToVoiceId.get(sessionKey);
|
|
107
|
+
if (voiceId) {
|
|
108
|
+
voiceIdToSessionKey.delete(voiceId);
|
|
109
|
+
sessionKeyToVoiceId.delete(sessionKey);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function findVoiceSessionForAgent(sessionKey) {
|
|
113
|
+
const direct = sessionKeyToVoiceId.get(sessionKey);
|
|
114
|
+
if (direct) return direct;
|
|
115
|
+
const discordMatch = /voice-discord[:-](\S+)/.exec(sessionKey);
|
|
116
|
+
if (discordMatch) try {
|
|
117
|
+
const sessions = (await voiceApi("GET", "/api/discord/sessions")).sessions ?? [];
|
|
118
|
+
for (const s of sessions) if (s.sessionId.includes(discordMatch[1]) || sessionKey.includes(s.sessionId)) return s.sessionId;
|
|
119
|
+
} catch {}
|
|
120
|
+
}
|
|
121
|
+
async function findVoiceSessionForCurrentContext() {
|
|
122
|
+
try {
|
|
123
|
+
const sessions = (await voiceApi("GET", "/api/discord/sessions")).sessions ?? [];
|
|
124
|
+
if (sessions.length === 1) return sessions[0].sessionId;
|
|
125
|
+
} catch {}
|
|
126
|
+
}
|
|
127
|
+
const voiceTools = [
|
|
128
|
+
defineTool({
|
|
129
|
+
name: "voice_hangup",
|
|
130
|
+
description: "Hang up the current voice call or leave the voice channel. Use when the conversation is done or the user asks you to leave.",
|
|
131
|
+
parameters: Type.Object({ sessionId: Type.Optional(Type.String({ description: "Voice session ID. If omitted, uses the current session." })) }),
|
|
132
|
+
handler: async (params) => {
|
|
133
|
+
const sid = params.sessionId ?? await findVoiceSessionForCurrentContext();
|
|
134
|
+
if (!sid) throw new Error("No active voice session found");
|
|
135
|
+
try {
|
|
136
|
+
if ((await voiceApi("GET", `/api/status/${encodeURIComponent(sid)}`)).sessionType === "discord") return await voiceApi("POST", `/api/discord/leave/${encodeURIComponent(sid)}`);
|
|
137
|
+
} catch {}
|
|
138
|
+
return await voiceApi("POST", `/api/leave/${encodeURIComponent(sid)}`);
|
|
139
|
+
}
|
|
140
|
+
}),
|
|
141
|
+
defineTool({
|
|
142
|
+
name: "voice_transfer",
|
|
143
|
+
description: "Transfer the current phone call to another number. Only works for Twilio calls.",
|
|
144
|
+
parameters: Type.Object({
|
|
145
|
+
targetNumber: Type.String({ description: "Phone number to transfer to (E.164 format)" }),
|
|
146
|
+
sessionId: Type.Optional(Type.String({ description: "Voice session ID. If omitted, uses the current session." }))
|
|
147
|
+
}),
|
|
148
|
+
handler: async (params) => {
|
|
149
|
+
const targetNumber = params.targetNumber;
|
|
150
|
+
const sid = params.sessionId ?? await findVoiceSessionForCurrentContext();
|
|
151
|
+
if (!sid) throw new Error("No active voice session found");
|
|
152
|
+
return await voiceApi("POST", `/api/transfer/${encodeURIComponent(sid)}`, { targetNumber });
|
|
153
|
+
}
|
|
154
|
+
}),
|
|
155
|
+
defineTool({
|
|
156
|
+
name: "voice_dtmf",
|
|
157
|
+
description: "Send DTMF tones (dial pad digits) on the current phone call. Only works for Twilio calls.",
|
|
158
|
+
parameters: Type.Object({
|
|
159
|
+
digits: Type.String({ description: "DTMF digits to send (0-9, *, #, w for pause)" }),
|
|
160
|
+
sessionId: Type.Optional(Type.String({ description: "Voice session ID. If omitted, uses the current session." }))
|
|
161
|
+
}),
|
|
162
|
+
handler: async (params) => {
|
|
163
|
+
const digits = params.digits;
|
|
164
|
+
const sid = params.sessionId ?? await findVoiceSessionForCurrentContext();
|
|
165
|
+
if (!sid) throw new Error("No active voice session found");
|
|
166
|
+
return await voiceApi("POST", `/api/dtmf/${encodeURIComponent(sid)}`, { digits });
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
];
|
|
170
|
+
const voiceToolNames = new Set(voiceTools.map((t) => t.name));
|
|
171
|
+
const plugin = {
|
|
172
|
+
id: "@alfe.ai/openclaw-voice",
|
|
173
|
+
name: "Alfe Voice Plugin",
|
|
174
|
+
description: "Real-time voice integration — Twilio phone calls, Discord voice channels, and meeting bots",
|
|
175
|
+
version: "0.1.0",
|
|
176
|
+
async activate(api) {
|
|
177
|
+
if (globalThis.__voiceGatewayActivated) {
|
|
178
|
+
api.logger.debug("Alfe Voice plugin already activated, skipping re-init");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
globalThis.__voiceGatewayActivated = true;
|
|
182
|
+
const log = api.logger;
|
|
183
|
+
log.info("Alfe Voice plugin activating...");
|
|
184
|
+
const fullConfig = api.config ?? {};
|
|
185
|
+
const pluginConfig = fullConfig.plugins?.entries?.["@alfe.ai/openclaw-voice"]?.config ?? fullConfig.plugins?.entries?.["voice-gateway"]?.config ?? {};
|
|
186
|
+
voiceServiceUrl = pluginConfig.voiceServiceUrl ?? process.env.VOICE_SERVICE_URL ?? `http://localhost:${String(pluginConfig.voiceServicePort ?? process.env.VOICE_SERVICE_PORT ?? "3100")}`;
|
|
187
|
+
voiceServiceApiKey = pluginConfig.voiceServiceApiKey ?? process.env.VOICE_SERVICE_API_KEY ?? "";
|
|
188
|
+
log.info(`Voice service: ${voiceServiceUrl}`);
|
|
189
|
+
daemonIpcClient = await connectToDaemon(pluginConfig.daemonSocket ?? process.env.ALFE_GATEWAY_SOCKET ?? DEFAULT_SOCKET_PATH, log);
|
|
190
|
+
for (const tool of voiceTools) api.registerTool(tool);
|
|
191
|
+
log.info(`Registered ${String(voiceTools.length)} voice tools: ${voiceTools.map((t) => t.name).join(", ")}`);
|
|
192
|
+
api.registerGatewayMethod("voice.speak", async (...args) => {
|
|
193
|
+
const { sessionId, text } = args[0];
|
|
194
|
+
log.info(`voice.speak RPC → session=${sessionId}, text=${text?.slice(0, 50) ?? "(audio)"}...`);
|
|
195
|
+
if (!text) throw new Error("voice.speak requires text");
|
|
196
|
+
return await voiceApi("POST", "/api/speak", {
|
|
197
|
+
botId: sessionId,
|
|
198
|
+
text
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
log.info("Registered gateway RPC method: voice.speak");
|
|
202
|
+
api.on("session_start", async (...eventArgs) => {
|
|
203
|
+
const key = eventArgs[0].sessionKey;
|
|
204
|
+
if (!key) return;
|
|
205
|
+
if (!(key.includes("voice-discord") || key.includes("voice-twilio") || key.includes("voice-recall"))) return;
|
|
206
|
+
log.info(`Voice session starting: ${key}`);
|
|
207
|
+
const discordMatch = /voice-discord:(\d+):(\d+)/.exec(key);
|
|
208
|
+
if (discordMatch) {
|
|
209
|
+
const [, guildId, channelId] = discordMatch;
|
|
210
|
+
try {
|
|
211
|
+
const result = await voiceApi("POST", "/api/discord/join", {
|
|
212
|
+
guildId,
|
|
213
|
+
channelId
|
|
214
|
+
});
|
|
215
|
+
linkSession(key, result.sessionId);
|
|
216
|
+
log.info(`Auto-joined Discord voice for session ${key} → ${String(result.sessionId)}`);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
log.error(`Failed to auto-join Discord voice: ${err.message}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}, { priority: 50 });
|
|
222
|
+
api.on("session_end", async (...eventArgs) => {
|
|
223
|
+
const key = eventArgs[0].sessionKey;
|
|
224
|
+
if (!key) return;
|
|
225
|
+
const voiceId = sessionKeyToVoiceId.get(key);
|
|
226
|
+
if (!voiceId) return;
|
|
227
|
+
log.info(`Voice session ending: ${key} → ${voiceId}`);
|
|
228
|
+
try {
|
|
229
|
+
await voiceApi("POST", `/api/leave/${encodeURIComponent(voiceId)}`);
|
|
230
|
+
} catch (err) {
|
|
231
|
+
log.error(`Error leaving voice on session end: ${err.message}`);
|
|
232
|
+
}
|
|
233
|
+
unlinkSession(key);
|
|
234
|
+
});
|
|
235
|
+
api.on("message_received", (...eventArgs) => {
|
|
236
|
+
const event = eventArgs[0];
|
|
237
|
+
if (eventArgs[1].channelId.includes("voice")) log.debug(`Voice-related message from ${event.from}: ${event.content.slice(0, 100)}`);
|
|
238
|
+
});
|
|
239
|
+
api.on("before_tool_call", async (...eventArgs) => {
|
|
240
|
+
const event = eventArgs[0];
|
|
241
|
+
const ctx = eventArgs[1];
|
|
242
|
+
if (voiceToolNames.has(event.toolName) && !event.params.sessionId && ctx.sessionKey) {
|
|
243
|
+
const voiceId = await findVoiceSessionForAgent(ctx.sessionKey);
|
|
244
|
+
if (voiceId) event.params.sessionId = voiceId;
|
|
245
|
+
}
|
|
246
|
+
}, { priority: 50 });
|
|
247
|
+
log.info("Alfe Voice plugin activated");
|
|
248
|
+
},
|
|
249
|
+
deactivate(api) {
|
|
250
|
+
globalThis.__voiceGatewayActivated = false;
|
|
251
|
+
const log = api.logger;
|
|
252
|
+
log.info("Alfe Voice plugin deactivating...");
|
|
253
|
+
if (daemonIpcClient) {
|
|
254
|
+
try {
|
|
255
|
+
daemonIpcClient.stop();
|
|
256
|
+
log.info("Disconnected from Alfe daemon");
|
|
257
|
+
} catch (err) {
|
|
258
|
+
log.debug(`Error disconnecting from daemon: ${err.message}`);
|
|
259
|
+
}
|
|
260
|
+
daemonIpcClient = null;
|
|
261
|
+
}
|
|
262
|
+
log.info("Alfe Voice plugin deactivated");
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
//#endregion
|
|
266
|
+
export { plugin as default };
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alfe.ai/openclaw-voice",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "OpenClaw voice plugin for Alfe — Discord audio, Twilio, Recall.ai",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"openclaw": {
|
|
15
|
+
"extensions": [
|
|
16
|
+
"./dist/plugin.js"
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@sinclair/typebox": "^0.34.48"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsdown",
|
|
27
|
+
"dev": "tsdown --watch",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"lint": "eslint ."
|
|
30
|
+
}
|
|
31
|
+
}
|