@openclaw/voice-call 2026.1.29

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 (44) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +135 -0
  3. package/index.ts +497 -0
  4. package/openclaw.plugin.json +601 -0
  5. package/package.json +16 -0
  6. package/src/cli.ts +312 -0
  7. package/src/config.test.ts +204 -0
  8. package/src/config.ts +502 -0
  9. package/src/core-bridge.ts +198 -0
  10. package/src/manager/context.ts +21 -0
  11. package/src/manager/events.ts +177 -0
  12. package/src/manager/lookup.ts +33 -0
  13. package/src/manager/outbound.ts +248 -0
  14. package/src/manager/state.ts +50 -0
  15. package/src/manager/store.ts +88 -0
  16. package/src/manager/timers.ts +86 -0
  17. package/src/manager/twiml.ts +9 -0
  18. package/src/manager.test.ts +108 -0
  19. package/src/manager.ts +888 -0
  20. package/src/media-stream.test.ts +97 -0
  21. package/src/media-stream.ts +393 -0
  22. package/src/providers/base.ts +67 -0
  23. package/src/providers/index.ts +10 -0
  24. package/src/providers/mock.ts +168 -0
  25. package/src/providers/plivo.test.ts +28 -0
  26. package/src/providers/plivo.ts +504 -0
  27. package/src/providers/stt-openai-realtime.ts +311 -0
  28. package/src/providers/telnyx.ts +364 -0
  29. package/src/providers/tts-openai.ts +264 -0
  30. package/src/providers/twilio/api.ts +45 -0
  31. package/src/providers/twilio/webhook.ts +30 -0
  32. package/src/providers/twilio.test.ts +64 -0
  33. package/src/providers/twilio.ts +595 -0
  34. package/src/response-generator.ts +171 -0
  35. package/src/runtime.ts +217 -0
  36. package/src/telephony-audio.ts +88 -0
  37. package/src/telephony-tts.ts +95 -0
  38. package/src/tunnel.ts +331 -0
  39. package/src/types.ts +273 -0
  40. package/src/utils.ts +12 -0
  41. package/src/voice-mapping.ts +65 -0
  42. package/src/webhook-security.test.ts +260 -0
  43. package/src/webhook-security.ts +469 -0
  44. package/src/webhook.ts +491 -0
package/src/config.ts ADDED
@@ -0,0 +1,502 @@
1
+ import { z } from "zod";
2
+
3
+ // -----------------------------------------------------------------------------
4
+ // Phone Number Validation
5
+ // -----------------------------------------------------------------------------
6
+
7
+ /**
8
+ * E.164 phone number format: +[country code][number]
9
+ * Examples use 555 prefix (reserved for fictional numbers)
10
+ */
11
+ export const E164Schema = z
12
+ .string()
13
+ .regex(/^\+[1-9]\d{1,14}$/, "Expected E.164 format, e.g. +15550001234");
14
+
15
+ // -----------------------------------------------------------------------------
16
+ // Inbound Policy
17
+ // -----------------------------------------------------------------------------
18
+
19
+ /**
20
+ * Controls how inbound calls are handled:
21
+ * - "disabled": Block all inbound calls (outbound only)
22
+ * - "allowlist": Only accept calls from numbers in allowFrom
23
+ * - "pairing": Unknown callers can request pairing (future)
24
+ * - "open": Accept all inbound calls (dangerous!)
25
+ */
26
+ export const InboundPolicySchema = z.enum([
27
+ "disabled",
28
+ "allowlist",
29
+ "pairing",
30
+ "open",
31
+ ]);
32
+ export type InboundPolicy = z.infer<typeof InboundPolicySchema>;
33
+
34
+ // -----------------------------------------------------------------------------
35
+ // Provider-Specific Configuration
36
+ // -----------------------------------------------------------------------------
37
+
38
+ export const TelnyxConfigSchema = z
39
+ .object({
40
+ /** Telnyx API v2 key */
41
+ apiKey: z.string().min(1).optional(),
42
+ /** Telnyx connection ID (from Call Control app) */
43
+ connectionId: z.string().min(1).optional(),
44
+ /** Public key for webhook signature verification */
45
+ publicKey: z.string().min(1).optional(),
46
+ })
47
+ .strict();
48
+ export type TelnyxConfig = z.infer<typeof TelnyxConfigSchema>;
49
+
50
+ export const TwilioConfigSchema = z
51
+ .object({
52
+ /** Twilio Account SID */
53
+ accountSid: z.string().min(1).optional(),
54
+ /** Twilio Auth Token */
55
+ authToken: z.string().min(1).optional(),
56
+ })
57
+ .strict();
58
+ export type TwilioConfig = z.infer<typeof TwilioConfigSchema>;
59
+
60
+ export const PlivoConfigSchema = z
61
+ .object({
62
+ /** Plivo Auth ID (starts with MA/SA) */
63
+ authId: z.string().min(1).optional(),
64
+ /** Plivo Auth Token */
65
+ authToken: z.string().min(1).optional(),
66
+ })
67
+ .strict();
68
+ export type PlivoConfig = z.infer<typeof PlivoConfigSchema>;
69
+
70
+ // -----------------------------------------------------------------------------
71
+ // STT/TTS Configuration
72
+ // -----------------------------------------------------------------------------
73
+
74
+ export const SttConfigSchema = z
75
+ .object({
76
+ /** STT provider (currently only OpenAI supported) */
77
+ provider: z.literal("openai").default("openai"),
78
+ /** Whisper model to use */
79
+ model: z.string().min(1).default("whisper-1"),
80
+ })
81
+ .strict()
82
+ .default({ provider: "openai", model: "whisper-1" });
83
+ export type SttConfig = z.infer<typeof SttConfigSchema>;
84
+
85
+ export const TtsProviderSchema = z.enum(["openai", "elevenlabs", "edge"]);
86
+ export const TtsModeSchema = z.enum(["final", "all"]);
87
+ export const TtsAutoSchema = z.enum(["off", "always", "inbound", "tagged"]);
88
+
89
+ export const TtsConfigSchema = z
90
+ .object({
91
+ auto: TtsAutoSchema.optional(),
92
+ enabled: z.boolean().optional(),
93
+ mode: TtsModeSchema.optional(),
94
+ provider: TtsProviderSchema.optional(),
95
+ summaryModel: z.string().optional(),
96
+ modelOverrides: z
97
+ .object({
98
+ enabled: z.boolean().optional(),
99
+ allowText: z.boolean().optional(),
100
+ allowProvider: z.boolean().optional(),
101
+ allowVoice: z.boolean().optional(),
102
+ allowModelId: z.boolean().optional(),
103
+ allowVoiceSettings: z.boolean().optional(),
104
+ allowNormalization: z.boolean().optional(),
105
+ allowSeed: z.boolean().optional(),
106
+ })
107
+ .strict()
108
+ .optional(),
109
+ elevenlabs: z
110
+ .object({
111
+ apiKey: z.string().optional(),
112
+ baseUrl: z.string().optional(),
113
+ voiceId: z.string().optional(),
114
+ modelId: z.string().optional(),
115
+ seed: z.number().int().min(0).max(4294967295).optional(),
116
+ applyTextNormalization: z.enum(["auto", "on", "off"]).optional(),
117
+ languageCode: z.string().optional(),
118
+ voiceSettings: z
119
+ .object({
120
+ stability: z.number().min(0).max(1).optional(),
121
+ similarityBoost: z.number().min(0).max(1).optional(),
122
+ style: z.number().min(0).max(1).optional(),
123
+ useSpeakerBoost: z.boolean().optional(),
124
+ speed: z.number().min(0.5).max(2).optional(),
125
+ })
126
+ .strict()
127
+ .optional(),
128
+ })
129
+ .strict()
130
+ .optional(),
131
+ openai: z
132
+ .object({
133
+ apiKey: z.string().optional(),
134
+ model: z.string().optional(),
135
+ voice: z.string().optional(),
136
+ })
137
+ .strict()
138
+ .optional(),
139
+ edge: z
140
+ .object({
141
+ enabled: z.boolean().optional(),
142
+ voice: z.string().optional(),
143
+ lang: z.string().optional(),
144
+ outputFormat: z.string().optional(),
145
+ pitch: z.string().optional(),
146
+ rate: z.string().optional(),
147
+ volume: z.string().optional(),
148
+ saveSubtitles: z.boolean().optional(),
149
+ proxy: z.string().optional(),
150
+ timeoutMs: z.number().int().min(1000).max(120000).optional(),
151
+ })
152
+ .strict()
153
+ .optional(),
154
+ prefsPath: z.string().optional(),
155
+ maxTextLength: z.number().int().min(1).optional(),
156
+ timeoutMs: z.number().int().min(1000).max(120000).optional(),
157
+ })
158
+ .strict()
159
+ .optional();
160
+ export type VoiceCallTtsConfig = z.infer<typeof TtsConfigSchema>;
161
+
162
+ // -----------------------------------------------------------------------------
163
+ // Webhook Server Configuration
164
+ // -----------------------------------------------------------------------------
165
+
166
+ export const VoiceCallServeConfigSchema = z
167
+ .object({
168
+ /** Port to listen on */
169
+ port: z.number().int().positive().default(3334),
170
+ /** Bind address */
171
+ bind: z.string().default("127.0.0.1"),
172
+ /** Webhook path */
173
+ path: z.string().min(1).default("/voice/webhook"),
174
+ })
175
+ .strict()
176
+ .default({ port: 3334, bind: "127.0.0.1", path: "/voice/webhook" });
177
+ export type VoiceCallServeConfig = z.infer<typeof VoiceCallServeConfigSchema>;
178
+
179
+ export const VoiceCallTailscaleConfigSchema = z
180
+ .object({
181
+ /**
182
+ * Tailscale exposure mode:
183
+ * - "off": No Tailscale exposure
184
+ * - "serve": Tailscale serve (private to tailnet)
185
+ * - "funnel": Tailscale funnel (public HTTPS)
186
+ */
187
+ mode: z.enum(["off", "serve", "funnel"]).default("off"),
188
+ /** Path for Tailscale serve/funnel (should usually match serve.path) */
189
+ path: z.string().min(1).default("/voice/webhook"),
190
+ })
191
+ .strict()
192
+ .default({ mode: "off", path: "/voice/webhook" });
193
+ export type VoiceCallTailscaleConfig = z.infer<
194
+ typeof VoiceCallTailscaleConfigSchema
195
+ >;
196
+
197
+ // -----------------------------------------------------------------------------
198
+ // Tunnel Configuration (unified ngrok/tailscale)
199
+ // -----------------------------------------------------------------------------
200
+
201
+ export const VoiceCallTunnelConfigSchema = z
202
+ .object({
203
+ /**
204
+ * Tunnel provider:
205
+ * - "none": No tunnel (use publicUrl if set, or manual setup)
206
+ * - "ngrok": Use ngrok for public HTTPS tunnel
207
+ * - "tailscale-serve": Tailscale serve (private to tailnet)
208
+ * - "tailscale-funnel": Tailscale funnel (public HTTPS)
209
+ */
210
+ provider: z
211
+ .enum(["none", "ngrok", "tailscale-serve", "tailscale-funnel"])
212
+ .default("none"),
213
+ /** ngrok auth token (optional, enables longer sessions and more features) */
214
+ ngrokAuthToken: z.string().min(1).optional(),
215
+ /** ngrok custom domain (paid feature, e.g., "myapp.ngrok.io") */
216
+ ngrokDomain: z.string().min(1).optional(),
217
+ /**
218
+ * Allow ngrok free tier compatibility mode.
219
+ * When true, signature verification failures on ngrok-free.app URLs
220
+ * will be allowed only for loopback requests (ngrok local agent).
221
+ */
222
+ allowNgrokFreeTierLoopbackBypass: z.boolean().default(false),
223
+ /**
224
+ * Legacy ngrok free tier compatibility mode (deprecated).
225
+ * Use allowNgrokFreeTierLoopbackBypass instead.
226
+ */
227
+ allowNgrokFreeTier: z.boolean().optional(),
228
+ })
229
+ .strict()
230
+ .default({ provider: "none", allowNgrokFreeTierLoopbackBypass: false });
231
+ export type VoiceCallTunnelConfig = z.infer<typeof VoiceCallTunnelConfigSchema>;
232
+
233
+ // -----------------------------------------------------------------------------
234
+ // Outbound Call Configuration
235
+ // -----------------------------------------------------------------------------
236
+
237
+ /**
238
+ * Call mode determines how outbound calls behave:
239
+ * - "notify": Deliver message and auto-hangup after delay (one-way notification)
240
+ * - "conversation": Stay open for back-and-forth until explicit end or timeout
241
+ */
242
+ export const CallModeSchema = z.enum(["notify", "conversation"]);
243
+ export type CallMode = z.infer<typeof CallModeSchema>;
244
+
245
+ export const OutboundConfigSchema = z
246
+ .object({
247
+ /** Default call mode for outbound calls */
248
+ defaultMode: CallModeSchema.default("notify"),
249
+ /** Seconds to wait after TTS before auto-hangup in notify mode */
250
+ notifyHangupDelaySec: z.number().int().nonnegative().default(3),
251
+ })
252
+ .strict()
253
+ .default({ defaultMode: "notify", notifyHangupDelaySec: 3 });
254
+ export type OutboundConfig = z.infer<typeof OutboundConfigSchema>;
255
+
256
+ // -----------------------------------------------------------------------------
257
+ // Streaming Configuration (OpenAI Realtime STT)
258
+ // -----------------------------------------------------------------------------
259
+
260
+ export const VoiceCallStreamingConfigSchema = z
261
+ .object({
262
+ /** Enable real-time audio streaming (requires WebSocket support) */
263
+ enabled: z.boolean().default(false),
264
+ /** STT provider for real-time transcription */
265
+ sttProvider: z.enum(["openai-realtime"]).default("openai-realtime"),
266
+ /** OpenAI API key for Realtime API (uses OPENAI_API_KEY env if not set) */
267
+ openaiApiKey: z.string().min(1).optional(),
268
+ /** OpenAI transcription model (default: gpt-4o-transcribe) */
269
+ sttModel: z.string().min(1).default("gpt-4o-transcribe"),
270
+ /** VAD silence duration in ms before considering speech ended */
271
+ silenceDurationMs: z.number().int().positive().default(800),
272
+ /** VAD threshold 0-1 (higher = less sensitive) */
273
+ vadThreshold: z.number().min(0).max(1).default(0.5),
274
+ /** WebSocket path for media stream connections */
275
+ streamPath: z.string().min(1).default("/voice/stream"),
276
+ })
277
+ .strict()
278
+ .default({
279
+ enabled: false,
280
+ sttProvider: "openai-realtime",
281
+ sttModel: "gpt-4o-transcribe",
282
+ silenceDurationMs: 800,
283
+ vadThreshold: 0.5,
284
+ streamPath: "/voice/stream",
285
+ });
286
+ export type VoiceCallStreamingConfig = z.infer<
287
+ typeof VoiceCallStreamingConfigSchema
288
+ >;
289
+
290
+ // -----------------------------------------------------------------------------
291
+ // Main Voice Call Configuration
292
+ // -----------------------------------------------------------------------------
293
+
294
+ export const VoiceCallConfigSchema = z
295
+ .object({
296
+ /** Enable voice call functionality */
297
+ enabled: z.boolean().default(false),
298
+
299
+ /** Active provider (telnyx, twilio, plivo, or mock) */
300
+ provider: z.enum(["telnyx", "twilio", "plivo", "mock"]).optional(),
301
+
302
+ /** Telnyx-specific configuration */
303
+ telnyx: TelnyxConfigSchema.optional(),
304
+
305
+ /** Twilio-specific configuration */
306
+ twilio: TwilioConfigSchema.optional(),
307
+
308
+ /** Plivo-specific configuration */
309
+ plivo: PlivoConfigSchema.optional(),
310
+
311
+ /** Phone number to call from (E.164) */
312
+ fromNumber: E164Schema.optional(),
313
+
314
+ /** Default phone number to call (E.164) */
315
+ toNumber: E164Schema.optional(),
316
+
317
+ /** Inbound call policy */
318
+ inboundPolicy: InboundPolicySchema.default("disabled"),
319
+
320
+ /** Allowlist of phone numbers for inbound calls (E.164) */
321
+ allowFrom: z.array(E164Schema).default([]),
322
+
323
+ /** Greeting message for inbound calls */
324
+ inboundGreeting: z.string().optional(),
325
+
326
+ /** Outbound call configuration */
327
+ outbound: OutboundConfigSchema,
328
+
329
+ /** Maximum call duration in seconds */
330
+ maxDurationSeconds: z.number().int().positive().default(300),
331
+
332
+ /** Silence timeout for end-of-speech detection (ms) */
333
+ silenceTimeoutMs: z.number().int().positive().default(800),
334
+
335
+ /** Timeout for user transcript (ms) */
336
+ transcriptTimeoutMs: z.number().int().positive().default(180000),
337
+
338
+ /** Ring timeout for outbound calls (ms) */
339
+ ringTimeoutMs: z.number().int().positive().default(30000),
340
+
341
+ /** Maximum concurrent calls */
342
+ maxConcurrentCalls: z.number().int().positive().default(1),
343
+
344
+ /** Webhook server configuration */
345
+ serve: VoiceCallServeConfigSchema,
346
+
347
+ /** Tailscale exposure configuration (legacy, prefer tunnel config) */
348
+ tailscale: VoiceCallTailscaleConfigSchema,
349
+
350
+ /** Tunnel configuration (unified ngrok/tailscale) */
351
+ tunnel: VoiceCallTunnelConfigSchema,
352
+
353
+ /** Real-time audio streaming configuration */
354
+ streaming: VoiceCallStreamingConfigSchema,
355
+
356
+ /** Public webhook URL override (if set, bypasses tunnel auto-detection) */
357
+ publicUrl: z.string().url().optional(),
358
+
359
+ /** Skip webhook signature verification (development only, NOT for production) */
360
+ skipSignatureVerification: z.boolean().default(false),
361
+
362
+ /** STT configuration */
363
+ stt: SttConfigSchema,
364
+
365
+ /** TTS override (deep-merges with core messages.tts) */
366
+ tts: TtsConfigSchema,
367
+
368
+ /** Store path for call logs */
369
+ store: z.string().optional(),
370
+
371
+ /** Model for generating voice responses (e.g., "anthropic/claude-sonnet-4", "openai/gpt-4o") */
372
+ responseModel: z.string().default("openai/gpt-4o-mini"),
373
+
374
+ /** System prompt for voice responses */
375
+ responseSystemPrompt: z.string().optional(),
376
+
377
+ /** Timeout for response generation in ms (default 30s) */
378
+ responseTimeoutMs: z.number().int().positive().default(30000),
379
+ })
380
+ .strict();
381
+
382
+ export type VoiceCallConfig = z.infer<typeof VoiceCallConfigSchema>;
383
+
384
+ // -----------------------------------------------------------------------------
385
+ // Configuration Helpers
386
+ // -----------------------------------------------------------------------------
387
+
388
+ /**
389
+ * Resolves the configuration by merging environment variables into missing fields.
390
+ * Returns a new configuration object with environment variables applied.
391
+ */
392
+ export function resolveVoiceCallConfig(config: VoiceCallConfig): VoiceCallConfig {
393
+ const resolved = JSON.parse(JSON.stringify(config)) as VoiceCallConfig;
394
+
395
+ // Telnyx
396
+ if (resolved.provider === "telnyx") {
397
+ resolved.telnyx = resolved.telnyx ?? {};
398
+ resolved.telnyx.apiKey =
399
+ resolved.telnyx.apiKey ?? process.env.TELNYX_API_KEY;
400
+ resolved.telnyx.connectionId =
401
+ resolved.telnyx.connectionId ?? process.env.TELNYX_CONNECTION_ID;
402
+ resolved.telnyx.publicKey =
403
+ resolved.telnyx.publicKey ?? process.env.TELNYX_PUBLIC_KEY;
404
+ }
405
+
406
+ // Twilio
407
+ if (resolved.provider === "twilio") {
408
+ resolved.twilio = resolved.twilio ?? {};
409
+ resolved.twilio.accountSid =
410
+ resolved.twilio.accountSid ?? process.env.TWILIO_ACCOUNT_SID;
411
+ resolved.twilio.authToken =
412
+ resolved.twilio.authToken ?? process.env.TWILIO_AUTH_TOKEN;
413
+ }
414
+
415
+ // Plivo
416
+ if (resolved.provider === "plivo") {
417
+ resolved.plivo = resolved.plivo ?? {};
418
+ resolved.plivo.authId =
419
+ resolved.plivo.authId ?? process.env.PLIVO_AUTH_ID;
420
+ resolved.plivo.authToken =
421
+ resolved.plivo.authToken ?? process.env.PLIVO_AUTH_TOKEN;
422
+ }
423
+
424
+ // Tunnel Config
425
+ resolved.tunnel = resolved.tunnel ?? {
426
+ provider: "none",
427
+ allowNgrokFreeTierLoopbackBypass: false,
428
+ };
429
+ resolved.tunnel.allowNgrokFreeTierLoopbackBypass =
430
+ resolved.tunnel.allowNgrokFreeTierLoopbackBypass ||
431
+ resolved.tunnel.allowNgrokFreeTier ||
432
+ false;
433
+ resolved.tunnel.ngrokAuthToken =
434
+ resolved.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN;
435
+ resolved.tunnel.ngrokDomain =
436
+ resolved.tunnel.ngrokDomain ?? process.env.NGROK_DOMAIN;
437
+
438
+ return resolved;
439
+ }
440
+
441
+ /**
442
+ * Validate that the configuration has all required fields for the selected provider.
443
+ */
444
+ export function validateProviderConfig(config: VoiceCallConfig): {
445
+ valid: boolean;
446
+ errors: string[];
447
+ } {
448
+ const errors: string[] = [];
449
+
450
+ if (!config.enabled) {
451
+ return { valid: true, errors: [] };
452
+ }
453
+
454
+ if (!config.provider) {
455
+ errors.push("plugins.entries.voice-call.config.provider is required");
456
+ }
457
+
458
+ if (!config.fromNumber && config.provider !== "mock") {
459
+ errors.push("plugins.entries.voice-call.config.fromNumber is required");
460
+ }
461
+
462
+ if (config.provider === "telnyx") {
463
+ if (!config.telnyx?.apiKey) {
464
+ errors.push(
465
+ "plugins.entries.voice-call.config.telnyx.apiKey is required (or set TELNYX_API_KEY env)",
466
+ );
467
+ }
468
+ if (!config.telnyx?.connectionId) {
469
+ errors.push(
470
+ "plugins.entries.voice-call.config.telnyx.connectionId is required (or set TELNYX_CONNECTION_ID env)",
471
+ );
472
+ }
473
+ }
474
+
475
+ if (config.provider === "twilio") {
476
+ if (!config.twilio?.accountSid) {
477
+ errors.push(
478
+ "plugins.entries.voice-call.config.twilio.accountSid is required (or set TWILIO_ACCOUNT_SID env)",
479
+ );
480
+ }
481
+ if (!config.twilio?.authToken) {
482
+ errors.push(
483
+ "plugins.entries.voice-call.config.twilio.authToken is required (or set TWILIO_AUTH_TOKEN env)",
484
+ );
485
+ }
486
+ }
487
+
488
+ if (config.provider === "plivo") {
489
+ if (!config.plivo?.authId) {
490
+ errors.push(
491
+ "plugins.entries.voice-call.config.plivo.authId is required (or set PLIVO_AUTH_ID env)",
492
+ );
493
+ }
494
+ if (!config.plivo?.authToken) {
495
+ errors.push(
496
+ "plugins.entries.voice-call.config.plivo.authToken is required (or set PLIVO_AUTH_TOKEN env)",
497
+ );
498
+ }
499
+ }
500
+
501
+ return { valid: errors.length === 0, errors };
502
+ }
@@ -0,0 +1,198 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+
5
+ import type { VoiceCallTtsConfig } from "./config.js";
6
+
7
+ export type CoreConfig = {
8
+ session?: {
9
+ store?: string;
10
+ };
11
+ messages?: {
12
+ tts?: VoiceCallTtsConfig;
13
+ };
14
+ [key: string]: unknown;
15
+ };
16
+
17
+ type CoreAgentDeps = {
18
+ resolveAgentDir: (cfg: CoreConfig, agentId: string) => string;
19
+ resolveAgentWorkspaceDir: (cfg: CoreConfig, agentId: string) => string;
20
+ resolveAgentIdentity: (
21
+ cfg: CoreConfig,
22
+ agentId: string,
23
+ ) => { name?: string | null } | null | undefined;
24
+ resolveThinkingDefault: (params: {
25
+ cfg: CoreConfig;
26
+ provider?: string;
27
+ model?: string;
28
+ }) => string;
29
+ runEmbeddedPiAgent: (params: {
30
+ sessionId: string;
31
+ sessionKey?: string;
32
+ messageProvider?: string;
33
+ sessionFile: string;
34
+ workspaceDir: string;
35
+ config?: CoreConfig;
36
+ prompt: string;
37
+ provider?: string;
38
+ model?: string;
39
+ thinkLevel?: string;
40
+ verboseLevel?: string;
41
+ timeoutMs: number;
42
+ runId: string;
43
+ lane?: string;
44
+ extraSystemPrompt?: string;
45
+ agentDir?: string;
46
+ }) => Promise<{
47
+ payloads?: Array<{ text?: string; isError?: boolean }>;
48
+ meta?: { aborted?: boolean };
49
+ }>;
50
+ resolveAgentTimeoutMs: (opts: { cfg: CoreConfig }) => number;
51
+ ensureAgentWorkspace: (params?: { dir: string }) => Promise<void>;
52
+ resolveStorePath: (store?: string, opts?: { agentId?: string }) => string;
53
+ loadSessionStore: (storePath: string) => Record<string, unknown>;
54
+ saveSessionStore: (
55
+ storePath: string,
56
+ store: Record<string, unknown>,
57
+ ) => Promise<void>;
58
+ resolveSessionFilePath: (
59
+ sessionId: string,
60
+ entry: unknown,
61
+ opts?: { agentId?: string },
62
+ ) => string;
63
+ DEFAULT_MODEL: string;
64
+ DEFAULT_PROVIDER: string;
65
+ };
66
+
67
+ let coreRootCache: string | null = null;
68
+ let coreDepsPromise: Promise<CoreAgentDeps> | null = null;
69
+
70
+ function findPackageRoot(startDir: string, name: string): string | null {
71
+ let dir = startDir;
72
+ for (;;) {
73
+ const pkgPath = path.join(dir, "package.json");
74
+ try {
75
+ if (fs.existsSync(pkgPath)) {
76
+ const raw = fs.readFileSync(pkgPath, "utf8");
77
+ const pkg = JSON.parse(raw) as { name?: string };
78
+ if (pkg.name === name) return dir;
79
+ }
80
+ } catch {
81
+ // ignore parse errors and keep walking
82
+ }
83
+ const parent = path.dirname(dir);
84
+ if (parent === dir) return null;
85
+ dir = parent;
86
+ }
87
+ }
88
+
89
+ function resolveOpenClawRoot(): string {
90
+ if (coreRootCache) return coreRootCache;
91
+ const override = process.env.OPENCLAW_ROOT?.trim();
92
+ if (override) {
93
+ coreRootCache = override;
94
+ return override;
95
+ }
96
+
97
+ const candidates = new Set<string>();
98
+ if (process.argv[1]) {
99
+ candidates.add(path.dirname(process.argv[1]));
100
+ }
101
+ candidates.add(process.cwd());
102
+ try {
103
+ const urlPath = fileURLToPath(import.meta.url);
104
+ candidates.add(path.dirname(urlPath));
105
+ } catch {
106
+ // ignore
107
+ }
108
+
109
+ for (const start of candidates) {
110
+ for (const name of ["openclaw"]) {
111
+ const found = findPackageRoot(start, name);
112
+ if (found) {
113
+ coreRootCache = found;
114
+ return found;
115
+ }
116
+ }
117
+ }
118
+
119
+ throw new Error(
120
+ "Unable to resolve core root. Set OPENCLAW_ROOT to the package root.",
121
+ );
122
+ }
123
+
124
+ async function importCoreModule<T>(relativePath: string): Promise<T> {
125
+ const root = resolveOpenClawRoot();
126
+ const distPath = path.join(root, "dist", relativePath);
127
+ if (!fs.existsSync(distPath)) {
128
+ throw new Error(
129
+ `Missing core module at ${distPath}. Run \`pnpm build\` or install the official package.`,
130
+ );
131
+ }
132
+ return (await import(pathToFileURL(distPath).href)) as T;
133
+ }
134
+
135
+ export async function loadCoreAgentDeps(): Promise<CoreAgentDeps> {
136
+ if (coreDepsPromise) return coreDepsPromise;
137
+
138
+ coreDepsPromise = (async () => {
139
+ const [
140
+ agentScope,
141
+ defaults,
142
+ identity,
143
+ modelSelection,
144
+ piEmbedded,
145
+ timeout,
146
+ workspace,
147
+ sessions,
148
+ ] = await Promise.all([
149
+ importCoreModule<{
150
+ resolveAgentDir: CoreAgentDeps["resolveAgentDir"];
151
+ resolveAgentWorkspaceDir: CoreAgentDeps["resolveAgentWorkspaceDir"];
152
+ }>("agents/agent-scope.js"),
153
+ importCoreModule<{
154
+ DEFAULT_MODEL: string;
155
+ DEFAULT_PROVIDER: string;
156
+ }>("agents/defaults.js"),
157
+ importCoreModule<{
158
+ resolveAgentIdentity: CoreAgentDeps["resolveAgentIdentity"];
159
+ }>("agents/identity.js"),
160
+ importCoreModule<{
161
+ resolveThinkingDefault: CoreAgentDeps["resolveThinkingDefault"];
162
+ }>("agents/model-selection.js"),
163
+ importCoreModule<{
164
+ runEmbeddedPiAgent: CoreAgentDeps["runEmbeddedPiAgent"];
165
+ }>("agents/pi-embedded.js"),
166
+ importCoreModule<{
167
+ resolveAgentTimeoutMs: CoreAgentDeps["resolveAgentTimeoutMs"];
168
+ }>("agents/timeout.js"),
169
+ importCoreModule<{
170
+ ensureAgentWorkspace: CoreAgentDeps["ensureAgentWorkspace"];
171
+ }>("agents/workspace.js"),
172
+ importCoreModule<{
173
+ resolveStorePath: CoreAgentDeps["resolveStorePath"];
174
+ loadSessionStore: CoreAgentDeps["loadSessionStore"];
175
+ saveSessionStore: CoreAgentDeps["saveSessionStore"];
176
+ resolveSessionFilePath: CoreAgentDeps["resolveSessionFilePath"];
177
+ }>("config/sessions.js"),
178
+ ]);
179
+
180
+ return {
181
+ resolveAgentDir: agentScope.resolveAgentDir,
182
+ resolveAgentWorkspaceDir: agentScope.resolveAgentWorkspaceDir,
183
+ resolveAgentIdentity: identity.resolveAgentIdentity,
184
+ resolveThinkingDefault: modelSelection.resolveThinkingDefault,
185
+ runEmbeddedPiAgent: piEmbedded.runEmbeddedPiAgent,
186
+ resolveAgentTimeoutMs: timeout.resolveAgentTimeoutMs,
187
+ ensureAgentWorkspace: workspace.ensureAgentWorkspace,
188
+ resolveStorePath: sessions.resolveStorePath,
189
+ loadSessionStore: sessions.loadSessionStore,
190
+ saveSessionStore: sessions.saveSessionStore,
191
+ resolveSessionFilePath: sessions.resolveSessionFilePath,
192
+ DEFAULT_MODEL: defaults.DEFAULT_MODEL,
193
+ DEFAULT_PROVIDER: defaults.DEFAULT_PROVIDER,
194
+ };
195
+ })();
196
+
197
+ return coreDepsPromise;
198
+ }