Compare commits

..

14 Commits

Author SHA1 Message Date
Peter Steinberger
3917b1d0e0 fix: preserve native Claude CLI tools (#46214) (thanks @Dhi13man) 2026-03-26 23:30:11 +00:00
dhi13man
1ce1a28a8e fix(agents): use claude-cli backend in tools-disabled regression test
codex-cli lacks systemPromptArg, so the system prompt is never
serialized into argv — making the not-toContain assertion pass
vacuously even on pre-fix code. Switch to claude-cli which defines
systemPromptArg ("--append-system-prompt") and add a positive
assertion that the user-supplied prompt IS present in argv.

Co-Authored-By: Dhiman's Agentic Suite <dhiman.seal@hotmail.com>
2026-03-26 23:26:42 +00:00
dhi13man
0c342992c5 ci: retrigger checks
Previous run had a flaky Telegram extension test failure (429 rate
limit in extensions/telegram/src/send.test.ts) unrelated to this
change.

Co-Authored-By: Dhiman's Agentic Suite <dhiman.seal@hotmail.com>
2026-03-26 23:26:42 +00:00
dhi13man
f1fb44761a fix(agents): remove unconditional "Tools are disabled" prompt injection in CLI runner
`runCliAgent()` unconditionally appended "Tools are disabled in this
session. Do not call tools." to `extraSystemPrompt` for every CLI
backend session. The intent was to prevent the LLM from calling
OpenClaw's embedded API tools (since CLI backends manage their own
tools natively). However, CLI agents like Claude Code interpret this
text as a blanket prohibition on ALL tools, including their own native
Bash, Read, and Write tools.

This caused silent failures across cron jobs, group chats, and DM
sessions when using any CLI backend: the agent would see the injected
text in the system prompt and refuse to execute tools, returning text
responses instead. Cron jobs reported `lastStatus: "ok"` despite the
agent failing to run scripts.

The fix removes the hardcoded string entirely. CLI backends already
receive `tools: []` (no OpenClaw embedded tools in the API call), so
the text was redundant at best.

Closes #44135

Co-Authored-By: Dhiman's Agentic Suite <dhiman.seal@hotmail.com>
2026-03-26 23:26:42 +00:00
Peter Steinberger
22348914cf refactor: centralize discord gateway ownership 2026-03-26 23:25:27 +00:00
Peter Steinberger
01bcbcf8d5 refactor: require legacy config migration on read 2026-03-26 23:23:47 +00:00
Peter Steinberger
cad83db8b2 refactor: move memory engine into memory plugin 2026-03-26 23:20:35 +00:00
Peter Steinberger
0e182dd3e1 refactor: share top-level setup dm policies 2026-03-26 23:20:26 +00:00
Peter Steinberger
abf95c5f99 refactor: share build copy script helpers 2026-03-26 23:20:26 +00:00
Peter Steinberger
0106b0488a refactor: share config section card rendering 2026-03-26 23:20:26 +00:00
Peter Steinberger
4890656d9d refactor: share matrix state file path helper 2026-03-26 23:20:26 +00:00
Peter Steinberger
bfad32aa16 refactor: share directory config listers 2026-03-26 23:20:26 +00:00
Peter Steinberger
4151b48d6c style(browser): format profiles service test 2026-03-26 23:18:57 +00:00
Peter Steinberger
8eeccb116d test(planner): refresh extension batch expectations 2026-03-26 23:16:22 +00:00
101 changed files with 1222 additions and 1595 deletions

View File

@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
- Agents/compaction: surface safeguard-specific cancel reasons and relabel benign manual `/compact` no-op cases as skipped instead of failed. (#51072) Thanks @afurm.
- Plugins/CLI backends: move bundled Claude CLI, Codex CLI, and Gemini CLI inference defaults onto the plugin surface, add bundled Gemini CLI backend support, and replace `gateway run --claude-cli-logs` with generic `--cli-backend-logs` while keeping the old flag as a compatibility alias.
- Plugins/startup: auto-load bundled provider and CLI-backend plugins from explicit config refs, so bundled Claude CLI, Codex CLI, and Gemini CLI message-provider setups no longer need manual `plugins.allow` entries.
- Config/TTS: auto-migrate legacy speech config on normal reads and secret resolution, keep legacy diagnostics for Doctor, and remove regular-mode runtime fallback for old bundled `tts.<provider>` API-key shapes.
### Fixes
@@ -37,6 +38,7 @@ Docs: https://docs.openclaw.ai
- CLI/onboarding: show the Kimi Code API key option again in the Moonshot setup menu so the interactive picker includes all Kimi setup paths together. Fixes #54412 Thanks @sparkyrider
- Agents/status: use provider-aware context window lookup for fresh Anthropic 4.6 model overrides so `/status` shows the correct 1.0m window instead of an underreported shared-cache minimum. (#54796) Thanks @neeravmakwana.
- Agents/errors: surface provider quota/reset details when available, but keep HTML/Cloudflare rate-limit pages on the generic fallback so raw error pages are not shown to users. (#54512) Thanks @bugkill3r.
- CLI/agents: stop injecting a fake "Tools are disabled" system-prompt line into CLI backend runs, so Claude CLI native tools can decide tool usage themselves. (#46214) Thanks @Dhi13man.
- Agents/embedded replies: surface mid-turn 429 and overload failures when embedded runs end without a user-visible reply, while preserving successful media-only replies that still use legacy `mediaUrl`. (#50930) Thanks @infichen.
- WhatsApp/allowFrom: show a specific allowFrom policy error for valid blocked targets instead of the misleading `<E.164|group JID>` format hint. Thanks @mcaxtr.
- Agents/cooldowns: scope rate-limit cooldowns per model so one 429 no longer blocks every model on the same auth profile, replace the exponential 1 min -> 1 h escalation with a stepped 30 s / 1 min / 5 min ladder, and surface a user-facing countdown message when all models are rate-limited. (#49834) Thanks @kiranvk-2011.

View File

@@ -244,7 +244,7 @@
"exportName": "CliBackendPlugin",
"kind": "type",
"source": {
"line": 1346,
"line": 1316,
"path": "src/plugins/types.ts"
}
},
@@ -406,7 +406,7 @@
"exportName": "OpenClawPluginApi",
"kind": "type",
"source": {
"line": 1390,
"line": 1360,
"path": "src/plugins/types.ts"
}
},
@@ -3423,7 +3423,7 @@
"exportName": "OpenClawPluginApi",
"kind": "type",
"source": {
"line": 1390,
"line": 1360,
"path": "src/plugins/types.ts"
}
},
@@ -3432,7 +3432,7 @@
"exportName": "OpenClawPluginCommandDefinition",
"kind": "type",
"source": {
"line": 1108,
"line": 1086,
"path": "src/plugins/types.ts"
}
},
@@ -3450,7 +3450,7 @@
"exportName": "OpenClawPluginDefinition",
"kind": "type",
"source": {
"line": 1372,
"line": 1342,
"path": "src/plugins/types.ts"
}
},
@@ -3459,7 +3459,7 @@
"exportName": "OpenClawPluginService",
"kind": "type",
"source": {
"line": 1339,
"line": 1309,
"path": "src/plugins/types.ts"
}
},
@@ -3468,7 +3468,7 @@
"exportName": "OpenClawPluginServiceContext",
"kind": "type",
"source": {
"line": 1331,
"line": 1301,
"path": "src/plugins/types.ts"
}
},
@@ -3504,7 +3504,7 @@
"exportName": "PluginInteractiveTelegramHandlerContext",
"kind": "type",
"source": {
"line": 1145,
"line": 1115,
"path": "src/plugins/types.ts"
}
},
@@ -3929,7 +3929,7 @@
"exportName": "OpenClawPluginApi",
"kind": "type",
"source": {
"line": 1390,
"line": 1360,
"path": "src/plugins/types.ts"
}
},
@@ -3938,7 +3938,7 @@
"exportName": "OpenClawPluginCommandDefinition",
"kind": "type",
"source": {
"line": 1108,
"line": 1086,
"path": "src/plugins/types.ts"
}
},
@@ -3956,7 +3956,7 @@
"exportName": "OpenClawPluginDefinition",
"kind": "type",
"source": {
"line": 1372,
"line": 1342,
"path": "src/plugins/types.ts"
}
},
@@ -3965,7 +3965,7 @@
"exportName": "OpenClawPluginService",
"kind": "type",
"source": {
"line": 1339,
"line": 1309,
"path": "src/plugins/types.ts"
}
},
@@ -3974,7 +3974,7 @@
"exportName": "OpenClawPluginServiceContext",
"kind": "type",
"source": {
"line": 1331,
"line": 1301,
"path": "src/plugins/types.ts"
}
},
@@ -4010,7 +4010,7 @@
"exportName": "PluginInteractiveTelegramHandlerContext",
"kind": "type",
"source": {
"line": 1145,
"line": 1115,
"path": "src/plugins/types.ts"
}
},

View File

@@ -25,7 +25,7 @@
{"declaration":"export type ChannelStatusIssue = ChannelStatusIssue;","entrypoint":"index","exportName":"ChannelStatusIssue","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":100,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"index","exportName":"ClawdbotConfig","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/config/types.openclaw.ts"}
{"declaration":"export type CliBackendConfig = CliBackendConfig;","entrypoint":"index","exportName":"CliBackendConfig","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":47,"sourcePath":"src/config/types.agent-defaults.ts"}
{"declaration":"export type CliBackendPlugin = CliBackendPlugin;","entrypoint":"index","exportName":"CliBackendPlugin","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":1346,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type CliBackendPlugin = CliBackendPlugin;","entrypoint":"index","exportName":"CliBackendPlugin","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":1316,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type CompiledConfiguredBinding = CompiledConfiguredBinding;","entrypoint":"index","exportName":"CompiledConfiguredBinding","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":38,"sourcePath":"src/channels/plugins/binding-types.ts"}
{"declaration":"export type ConfiguredBindingConversation = ConversationRef;","entrypoint":"index","exportName":"ConfiguredBindingConversation","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":13,"sourcePath":"src/channels/plugins/binding-types.ts"}
{"declaration":"export type ConfiguredBindingResolution = ConfiguredBindingResolution;","entrypoint":"index","exportName":"ConfiguredBindingResolution","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":49,"sourcePath":"src/channels/plugins/binding-types.ts"}
@@ -43,7 +43,7 @@
{"declaration":"export type ImageGenerationSourceImage = ImageGenerationSourceImage;","entrypoint":"index","exportName":"ImageGenerationSourceImage","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":14,"sourcePath":"src/image-generation/types.ts"}
{"declaration":"export type MediaUnderstandingProviderPlugin = MediaUnderstandingProvider;","entrypoint":"index","exportName":"MediaUnderstandingProviderPlugin","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":969,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"index","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/config/types.openclaw.ts"}
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"index","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":1390,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"index","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":1360,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginConfigSchema = OpenClawPluginConfigSchema;","entrypoint":"index","exportName":"OpenClawPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":95,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginLogger = PluginLogger;","entrypoint":"index","exportName":"PluginLogger","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":66,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginRuntime = PluginRuntime;","entrypoint":"index","exportName":"PluginRuntime","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":54,"sourcePath":"src/plugins/runtime/types.ts"}
@@ -376,16 +376,16 @@
{"declaration":"export type GatewayRequestHandlerOptions = GatewayRequestHandlerOptions;","entrypoint":"core","exportName":"GatewayRequestHandlerOptions","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":112,"sourcePath":"src/gateway/server-methods/types.ts"}
{"declaration":"export type MediaUnderstandingProviderPlugin = MediaUnderstandingProvider;","entrypoint":"core","exportName":"MediaUnderstandingProviderPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":969,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"core","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/config/types.openclaw.ts"}
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"core","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1390,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"core","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1108,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"core","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1360,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"core","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1086,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginConfigSchema = OpenClawPluginConfigSchema;","entrypoint":"core","exportName":"OpenClawPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":95,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"core","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1372,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginService = OpenClawPluginService;","entrypoint":"core","exportName":"OpenClawPluginService","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1339,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginServiceContext = OpenClawPluginServiceContext;","entrypoint":"core","exportName":"OpenClawPluginServiceContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1331,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"core","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1342,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginService = OpenClawPluginService;","entrypoint":"core","exportName":"OpenClawPluginService","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1309,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginServiceContext = OpenClawPluginServiceContext;","entrypoint":"core","exportName":"OpenClawPluginServiceContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1301,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginToolContext = OpenClawPluginToolContext;","entrypoint":"core","exportName":"OpenClawPluginToolContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":110,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginToolFactory = OpenClawPluginToolFactory;","entrypoint":"core","exportName":"OpenClawPluginToolFactory","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":131,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginCommandContext = PluginCommandContext;","entrypoint":"core","exportName":"PluginCommandContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":984,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginInteractiveTelegramHandlerContext = PluginInteractiveTelegramHandlerContext;","entrypoint":"core","exportName":"PluginInteractiveTelegramHandlerContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1145,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginInteractiveTelegramHandlerContext = PluginInteractiveTelegramHandlerContext;","entrypoint":"core","exportName":"PluginInteractiveTelegramHandlerContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1115,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginLogger = PluginLogger;","entrypoint":"core","exportName":"PluginLogger","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":66,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginRuntime = PluginRuntime;","entrypoint":"core","exportName":"PluginRuntime","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":54,"sourcePath":"src/plugins/runtime/types.ts"}
{"declaration":"export type ProviderAugmentModelCatalogContext = ProviderAugmentModelCatalogContext;","entrypoint":"core","exportName":"ProviderAugmentModelCatalogContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":582,"sourcePath":"src/plugins/types.ts"}
@@ -432,16 +432,16 @@
{"declaration":"export type AnyAgentTool = AnyAgentTool;","entrypoint":"plugin-entry","exportName":"AnyAgentTool","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":9,"sourcePath":"src/agents/tools/common.ts"}
{"declaration":"export type MediaUnderstandingProviderPlugin = MediaUnderstandingProvider;","entrypoint":"plugin-entry","exportName":"MediaUnderstandingProviderPlugin","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":969,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"plugin-entry","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/config/types.openclaw.ts"}
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"plugin-entry","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1390,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1108,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"plugin-entry","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1360,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1086,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginConfigSchema = OpenClawPluginConfigSchema;","entrypoint":"plugin-entry","exportName":"OpenClawPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":95,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1372,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginService = OpenClawPluginService;","entrypoint":"plugin-entry","exportName":"OpenClawPluginService","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1339,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginServiceContext = OpenClawPluginServiceContext;","entrypoint":"plugin-entry","exportName":"OpenClawPluginServiceContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1331,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1342,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginService = OpenClawPluginService;","entrypoint":"plugin-entry","exportName":"OpenClawPluginService","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1309,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginServiceContext = OpenClawPluginServiceContext;","entrypoint":"plugin-entry","exportName":"OpenClawPluginServiceContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1301,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginToolContext = OpenClawPluginToolContext;","entrypoint":"plugin-entry","exportName":"OpenClawPluginToolContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":110,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginToolFactory = OpenClawPluginToolFactory;","entrypoint":"plugin-entry","exportName":"OpenClawPluginToolFactory","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":131,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginCommandContext = PluginCommandContext;","entrypoint":"plugin-entry","exportName":"PluginCommandContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":984,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginInteractiveTelegramHandlerContext = PluginInteractiveTelegramHandlerContext;","entrypoint":"plugin-entry","exportName":"PluginInteractiveTelegramHandlerContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1145,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginInteractiveTelegramHandlerContext = PluginInteractiveTelegramHandlerContext;","entrypoint":"plugin-entry","exportName":"PluginInteractiveTelegramHandlerContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1115,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginLogger = PluginLogger;","entrypoint":"plugin-entry","exportName":"PluginLogger","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":66,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type ProviderAugmentModelCatalogContext = ProviderAugmentModelCatalogContext;","entrypoint":"plugin-entry","exportName":"ProviderAugmentModelCatalogContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":582,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type ProviderAuthContext = ProviderAuthContext;","entrypoint":"plugin-entry","exportName":"ProviderAuthContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":166,"sourcePath":"src/plugins/types.ts"}

View File

@@ -15,7 +15,7 @@ It works anywhere OpenClaw can send audio.
## Supported services
- **ElevenLabs** (primary or fallback provider)
- **Microsoft** (primary or fallback provider; current bundled implementation uses `node-edge-tts`, default when no API keys)
- **Microsoft** (primary or fallback provider; current bundled implementation uses `node-edge-tts`)
- **OpenAI** (primary or fallback provider; also used for summaries)
### Microsoft speech notes
@@ -38,9 +38,7 @@ If you want OpenAI or ElevenLabs:
- `ELEVENLABS_API_KEY` (or `XI_API_KEY`)
- `OPENAI_API_KEY`
Microsoft speech does **not** require an API key. If no API keys are found,
OpenClaw defaults to Microsoft (unless disabled via
`messages.tts.microsoft.enabled=false` or `messages.tts.edge.enabled=false`).
Microsoft speech does **not** require an API key.
If multiple providers are configured, the selected provider is used first and the others are fallback options.
Auto-summary uses the configured `summaryModel` (or `agents.defaults.model.primary`),
@@ -60,8 +58,8 @@ so that provider must also be authenticated if you enable summaries.
No. AutoTTS is **off** by default. Enable it in config with
`messages.tts.auto` or per session with `/tts always` (alias: `/tts on`).
Microsoft speech **is** enabled by default once TTS is on, and is used automatically
when no OpenAI or ElevenLabs API keys are available.
When `messages.tts.provider` is unset, OpenClaw picks the first configured
speech provider in registry auto-select order.
## Config
@@ -93,26 +91,28 @@ Full schema is in [Gateway configuration](/gateway/configuration).
modelOverrides: {
enabled: true,
},
openai: {
apiKey: "openai_api_key",
baseUrl: "https://api.openai.com/v1",
model: "gpt-4o-mini-tts",
voice: "alloy",
},
elevenlabs: {
apiKey: "elevenlabs_api_key",
baseUrl: "https://api.elevenlabs.io",
voiceId: "voice_id",
modelId: "eleven_multilingual_v2",
seed: 42,
applyTextNormalization: "auto",
languageCode: "en",
voiceSettings: {
stability: 0.5,
similarityBoost: 0.75,
style: 0.0,
useSpeakerBoost: true,
speed: 1.0,
providers: {
openai: {
apiKey: "openai_api_key",
baseUrl: "https://api.openai.com/v1",
model: "gpt-4o-mini-tts",
voice: "alloy",
},
elevenlabs: {
apiKey: "elevenlabs_api_key",
baseUrl: "https://api.elevenlabs.io",
voiceId: "voice_id",
modelId: "eleven_multilingual_v2",
seed: 42,
applyTextNormalization: "auto",
languageCode: "en",
voiceSettings: {
stability: 0.5,
similarityBoost: 0.75,
style: 0.0,
useSpeakerBoost: true,
speed: 1.0,
},
},
},
},
@@ -128,13 +128,15 @@ Full schema is in [Gateway configuration](/gateway/configuration).
tts: {
auto: "always",
provider: "microsoft",
microsoft: {
enabled: true,
voice: "en-US-MichelleNeural",
lang: "en-US",
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
rate: "+10%",
pitch: "-5%",
providers: {
microsoft: {
enabled: true,
voice: "en-US-MichelleNeural",
lang: "en-US",
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
rate: "+10%",
pitch: "-5%",
},
},
},
},
@@ -147,8 +149,10 @@ Full schema is in [Gateway configuration](/gateway/configuration).
{
messages: {
tts: {
microsoft: {
enabled: false,
providers: {
microsoft: {
enabled: false,
},
},
},
},
@@ -208,37 +212,37 @@ Then run:
- `enabled`: legacy toggle (doctor migrates this to `auto`).
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
- `provider`: speech provider id such as `"elevenlabs"`, `"microsoft"`, or `"openai"` (fallback is automatic).
- If `provider` is **unset**, OpenClaw prefers `openai` (if key), then `elevenlabs` (if key),
otherwise `microsoft`.
- If `provider` is **unset**, OpenClaw uses the first configured speech provider in registry auto-select order.
- Legacy `provider: "edge"` still works and is normalized to `microsoft`.
- `summaryModel`: optional cheap model for auto-summary; defaults to `agents.defaults.model.primary`.
- Accepts `provider/model` or a configured model alias.
- `modelOverrides`: allow the model to emit TTS directives (on by default).
- `allowProvider` defaults to `false` (provider switching is opt-in).
- `providers.<id>`: provider-owned settings keyed by speech provider id.
- `maxTextLength`: hard cap for TTS input (chars). `/tts audio` fails if exceeded.
- `timeoutMs`: request timeout (ms).
- `prefsPath`: override the local prefs JSON path (provider/limit/summary).
- `apiKey` values fall back to env vars (`ELEVENLABS_API_KEY`/`XI_API_KEY`, `OPENAI_API_KEY`).
- `elevenlabs.baseUrl`: override ElevenLabs API base URL.
- `openai.baseUrl`: override the OpenAI TTS endpoint.
- Resolution order: `messages.tts.openai.baseUrl` -> `OPENAI_TTS_BASE_URL` -> `https://api.openai.com/v1`
- `providers.elevenlabs.baseUrl`: override ElevenLabs API base URL.
- `providers.openai.baseUrl`: override the OpenAI TTS endpoint.
- Resolution order: `messages.tts.providers.openai.baseUrl` -> `OPENAI_TTS_BASE_URL` -> `https://api.openai.com/v1`
- Non-default values are treated as OpenAI-compatible TTS endpoints, so custom model and voice names are accepted.
- `elevenlabs.voiceSettings`:
- `providers.elevenlabs.voiceSettings`:
- `stability`, `similarityBoost`, `style`: `0..1`
- `useSpeakerBoost`: `true|false`
- `speed`: `0.5..2.0` (1.0 = normal)
- `elevenlabs.applyTextNormalization`: `auto|on|off`
- `elevenlabs.languageCode`: 2-letter ISO 639-1 (e.g. `en`, `de`)
- `elevenlabs.seed`: integer `0..4294967295` (best-effort determinism)
- `microsoft.enabled`: allow Microsoft speech usage (default `true`; no API key).
- `microsoft.voice`: Microsoft neural voice name (e.g. `en-US-MichelleNeural`).
- `microsoft.lang`: language code (e.g. `en-US`).
- `microsoft.outputFormat`: Microsoft output format (e.g. `audio-24khz-48kbitrate-mono-mp3`).
- `providers.elevenlabs.applyTextNormalization`: `auto|on|off`
- `providers.elevenlabs.languageCode`: 2-letter ISO 639-1 (e.g. `en`, `de`)
- `providers.elevenlabs.seed`: integer `0..4294967295` (best-effort determinism)
- `providers.microsoft.enabled`: allow Microsoft speech usage (default `true`; no API key).
- `providers.microsoft.voice`: Microsoft neural voice name (e.g. `en-US-MichelleNeural`).
- `providers.microsoft.lang`: language code (e.g. `en-US`).
- `providers.microsoft.outputFormat`: Microsoft output format (e.g. `audio-24khz-48kbitrate-mono-mp3`).
- See Microsoft Speech output formats for valid values; not all formats are supported by the bundled Edge-backed transport.
- `microsoft.rate` / `microsoft.pitch` / `microsoft.volume`: percent strings (e.g. `+10%`, `-5%`).
- `microsoft.saveSubtitles`: write JSON subtitles alongside the audio file.
- `microsoft.proxy`: proxy URL for Microsoft speech requests.
- `microsoft.timeoutMs`: request timeout override (ms).
- `providers.microsoft.rate` / `providers.microsoft.pitch` / `providers.microsoft.volume`: percent strings (e.g. `+10%`, `-5%`).
- `providers.microsoft.saveSubtitles`: write JSON subtitles alongside the audio file.
- `providers.microsoft.proxy`: proxy URL for Microsoft speech requests.
- `providers.microsoft.timeoutMs`: request timeout override (ms).
- `edge.*`: legacy alias for the same Microsoft settings.
## Model-driven overrides (default on)

View File

@@ -15,7 +15,7 @@ It works anywhere OpenClaw can send audio.
## Supported services
- **ElevenLabs** (primary or fallback provider)
- **Microsoft** (primary or fallback provider; current bundled implementation uses `node-edge-tts`, default when no API keys)
- **Microsoft** (primary or fallback provider; current bundled implementation uses `node-edge-tts`)
- **OpenAI** (primary or fallback provider; also used for summaries)
### Microsoft speech notes
@@ -38,9 +38,7 @@ If you want OpenAI or ElevenLabs:
- `ELEVENLABS_API_KEY` (or `XI_API_KEY`)
- `OPENAI_API_KEY`
Microsoft speech does **not** require an API key. If no API keys are found,
OpenClaw defaults to Microsoft (unless disabled via
`messages.tts.microsoft.enabled=false` or `messages.tts.edge.enabled=false`).
Microsoft speech does **not** require an API key.
If multiple providers are configured, the selected provider is used first and the others are fallback options.
Auto-summary uses the configured `summaryModel` (or `agents.defaults.model.primary`),
@@ -60,8 +58,8 @@ so that provider must also be authenticated if you enable summaries.
No. AutoTTS is **off** by default. Enable it in config with
`messages.tts.auto` or per session with `/tts always` (alias: `/tts on`).
Microsoft speech **is** enabled by default once TTS is on, and is used automatically
when no OpenAI or ElevenLabs API keys are available.
When `messages.tts.provider` is unset, OpenClaw picks the first configured
speech provider in registry auto-select order.
## Config
@@ -93,26 +91,28 @@ Full schema is in [Gateway configuration](/gateway/configuration).
modelOverrides: {
enabled: true,
},
openai: {
apiKey: "openai_api_key",
baseUrl: "https://api.openai.com/v1",
model: "gpt-4o-mini-tts",
voice: "alloy",
},
elevenlabs: {
apiKey: "elevenlabs_api_key",
baseUrl: "https://api.elevenlabs.io",
voiceId: "voice_id",
modelId: "eleven_multilingual_v2",
seed: 42,
applyTextNormalization: "auto",
languageCode: "en",
voiceSettings: {
stability: 0.5,
similarityBoost: 0.75,
style: 0.0,
useSpeakerBoost: true,
speed: 1.0,
providers: {
openai: {
apiKey: "openai_api_key",
baseUrl: "https://api.openai.com/v1",
model: "gpt-4o-mini-tts",
voice: "alloy",
},
elevenlabs: {
apiKey: "elevenlabs_api_key",
baseUrl: "https://api.elevenlabs.io",
voiceId: "voice_id",
modelId: "eleven_multilingual_v2",
seed: 42,
applyTextNormalization: "auto",
languageCode: "en",
voiceSettings: {
stability: 0.5,
similarityBoost: 0.75,
style: 0.0,
useSpeakerBoost: true,
speed: 1.0,
},
},
},
},
@@ -128,13 +128,15 @@ Full schema is in [Gateway configuration](/gateway/configuration).
tts: {
auto: "always",
provider: "microsoft",
microsoft: {
enabled: true,
voice: "en-US-MichelleNeural",
lang: "en-US",
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
rate: "+10%",
pitch: "-5%",
providers: {
microsoft: {
enabled: true,
voice: "en-US-MichelleNeural",
lang: "en-US",
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
rate: "+10%",
pitch: "-5%",
},
},
},
},
@@ -147,8 +149,10 @@ Full schema is in [Gateway configuration](/gateway/configuration).
{
messages: {
tts: {
microsoft: {
enabled: false,
providers: {
microsoft: {
enabled: false,
},
},
},
},
@@ -208,37 +212,37 @@ Then run:
- `enabled`: legacy toggle (doctor migrates this to `auto`).
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
- `provider`: speech provider id such as `"elevenlabs"`, `"microsoft"`, or `"openai"` (fallback is automatic).
- If `provider` is **unset**, OpenClaw prefers `openai` (if key), then `elevenlabs` (if key),
otherwise `microsoft`.
- If `provider` is **unset**, OpenClaw uses the first configured speech provider in registry auto-select order.
- Legacy `provider: "edge"` still works and is normalized to `microsoft`.
- `summaryModel`: optional cheap model for auto-summary; defaults to `agents.defaults.model.primary`.
- Accepts `provider/model` or a configured model alias.
- `modelOverrides`: allow the model to emit TTS directives (on by default).
- `allowProvider` defaults to `false` (provider switching is opt-in).
- `providers.<id>`: provider-owned settings keyed by speech provider id.
- `maxTextLength`: hard cap for TTS input (chars). `/tts audio` fails if exceeded.
- `timeoutMs`: request timeout (ms).
- `prefsPath`: override the local prefs JSON path (provider/limit/summary).
- `apiKey` values fall back to env vars (`ELEVENLABS_API_KEY`/`XI_API_KEY`, `OPENAI_API_KEY`).
- `elevenlabs.baseUrl`: override ElevenLabs API base URL.
- `openai.baseUrl`: override the OpenAI TTS endpoint.
- Resolution order: `messages.tts.openai.baseUrl` -> `OPENAI_TTS_BASE_URL` -> `https://api.openai.com/v1`
- `providers.elevenlabs.baseUrl`: override ElevenLabs API base URL.
- `providers.openai.baseUrl`: override the OpenAI TTS endpoint.
- Resolution order: `messages.tts.providers.openai.baseUrl` -> `OPENAI_TTS_BASE_URL` -> `https://api.openai.com/v1`
- Non-default values are treated as OpenAI-compatible TTS endpoints, so custom model and voice names are accepted.
- `elevenlabs.voiceSettings`:
- `providers.elevenlabs.voiceSettings`:
- `stability`, `similarityBoost`, `style`: `0..1`
- `useSpeakerBoost`: `true|false`
- `speed`: `0.5..2.0` (1.0 = normal)
- `elevenlabs.applyTextNormalization`: `auto|on|off`
- `elevenlabs.languageCode`: 2-letter ISO 639-1 (e.g. `en`, `de`)
- `elevenlabs.seed`: integer `0..4294967295` (best-effort determinism)
- `microsoft.enabled`: allow Microsoft speech usage (default `true`; no API key).
- `microsoft.voice`: Microsoft neural voice name (e.g. `en-US-MichelleNeural`).
- `microsoft.lang`: language code (e.g. `en-US`).
- `microsoft.outputFormat`: Microsoft output format (e.g. `audio-24khz-48kbitrate-mono-mp3`).
- `providers.elevenlabs.applyTextNormalization`: `auto|on|off`
- `providers.elevenlabs.languageCode`: 2-letter ISO 639-1 (e.g. `en`, `de`)
- `providers.elevenlabs.seed`: integer `0..4294967295` (best-effort determinism)
- `providers.microsoft.enabled`: allow Microsoft speech usage (default `true`; no API key).
- `providers.microsoft.voice`: Microsoft neural voice name (e.g. `en-US-MichelleNeural`).
- `providers.microsoft.lang`: language code (e.g. `en-US`).
- `providers.microsoft.outputFormat`: Microsoft output format (e.g. `audio-24khz-48kbitrate-mono-mp3`).
- See Microsoft Speech output formats for valid values; not all formats are supported by the bundled Edge-backed transport.
- `microsoft.rate` / `microsoft.pitch` / `microsoft.volume`: percent strings (e.g. `+10%`, `-5%`).
- `microsoft.saveSubtitles`: write JSON subtitles alongside the audio file.
- `microsoft.proxy`: proxy URL for Microsoft speech requests.
- `microsoft.timeoutMs`: request timeout override (ms).
- `providers.microsoft.rate` / `providers.microsoft.pitch` / `providers.microsoft.volume`: percent strings (e.g. `+10%`, `-5%`).
- `providers.microsoft.saveSubtitles`: write JSON subtitles alongside the audio file.
- `providers.microsoft.proxy`: proxy URL for Microsoft speech requests.
- `providers.microsoft.timeoutMs`: request timeout override (ms).
- `edge.*`: legacy alias for the same Microsoft settings.
## Model-driven overrides (default on)

View File

@@ -6,7 +6,6 @@ import type {
PluginCommandContext,
} from "openclaw/plugin-sdk/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { executePluginCommand } from "../../src/plugins/commands.js";
import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js";
import type { OpenClawPluginApi } from "./api.js";
import type { PendingPairingRequest } from "./notify.ts";
@@ -121,12 +120,9 @@ function createChannelRuntime(
}
function createCommandContext(params?: Partial<PluginCommandContext>): PluginCommandContext {
const surface = params?.surface ?? params?.channel ?? "webchat";
return {
surface,
channel: surface,
channel: "webchat",
isAuthorizedSender: true,
senderIsOwner: false,
commandBody: "/pair qr",
args: "qr",
config: {},
@@ -450,20 +446,14 @@ describe("device-pair /pair approve", () => {
});
const command = registerPairCommand();
const result = await executePluginCommand({
command: {
...command,
pluginId: "device-pair",
},
senderId: "writer-1",
surface: "webchat",
channel: "webchat",
isAuthorizedSender: true,
gatewayClientScopes: ["operator.write"],
args: "approve latest",
commandBody: "/pair approve latest",
config: {} as never,
});
const result = await command.handler(
createCommandContext({
channel: "webchat",
args: "approve latest",
commandBody: "/pair approve latest",
gatewayClientScopes: ["operator.write"],
}),
);
expect(vi.mocked(approveDevicePairing)).not.toHaveBeenCalled();
expect(result).toEqual({
@@ -511,20 +501,14 @@ describe("device-pair /pair approve", () => {
});
const command = registerPairCommand();
const result = await executePluginCommand({
command: {
...command,
pluginId: "device-pair",
},
senderId: "pairing-1",
surface: "webchat",
channel: "webchat",
isAuthorizedSender: true,
gatewayClientScopes: ["operator.write", "operator.pairing"],
args: "approve latest",
commandBody: "/pair approve latest",
config: {} as never,
});
const result = await command.handler(
createCommandContext({
channel: "webchat",
args: "approve latest",
commandBody: "/pair approve latest",
gatewayClientScopes: ["operator.write", "operator.pairing"],
}),
);
expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1");
expect(result).toEqual({ text: "✅ Paired Victim Phone (ios)." });

View File

@@ -546,14 +546,13 @@ export default definePluginEntry({
name: "pair",
description: "Generate setup codes and approve device pairing requests.",
acceptsArgs: true,
resolveRequiredGatewayScopes: (ctx) => {
const action = ctx.args?.trim().split(/\s+/, 1)[0]?.toLowerCase();
return action === "approve" ? ["operator.pairing"] : undefined;
},
handler: async (ctx) => {
const args = ctx.args?.trim() ?? "";
const tokens = args.split(/\s+/).filter(Boolean);
const action = tokens[0]?.toLowerCase() ?? "";
const gatewayClientScopes = Array.isArray(ctx.gatewayClientScopes)
? ctx.gatewayClientScopes
: null;
api.logger.info?.(
`device-pair: /pair invoked channel=${ctx.channel} sender=${ctx.senderId ?? "unknown"} action=${
action || "new"
@@ -575,6 +574,15 @@ export default definePluginEntry({
}
if (action === "approve") {
if (
gatewayClientScopes &&
!gatewayClientScopes.includes("operator.pairing") &&
!gatewayClientScopes.includes("operator.admin")
) {
return {
text: "⚠️ This command requires operator.pairing for internal gateway callers.",
};
}
const requested = tokens[1]?.trim();
const list = await listDevicePairing();
if (list.pending.length === 0) {

View File

@@ -1,6 +1,6 @@
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import {
listResolvedDirectoryEntriesFromSources,
createResolvedDirectoryEntriesLister,
type DirectoryConfigParams,
} from "openclaw/plugin-sdk/directory-runtime";
import { mergeDiscordAccountConfig, resolveDefaultDiscordAccountId } from "./accounts.js";
@@ -18,38 +18,36 @@ function resolveDiscordDirectoryConfigAccount(
};
}
export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfigParams) {
return listResolvedDirectoryEntriesFromSources({
...params,
kind: "user",
resolveAccount: (cfg, accountId) => resolveDiscordDirectoryConfigAccount(cfg, accountId),
resolveSources: (account) => {
const allowFrom = account.config.allowFrom ?? account.config.dm?.allowFrom ?? [];
const guildUsers = Object.values(account.config.guilds ?? {}).flatMap((guild) => [
...(guild.users ?? []),
...Object.values(guild.channels ?? {}).flatMap((channel) => channel.users ?? []),
]);
return [allowFrom, Object.keys(account.config.dms ?? {}), guildUsers];
},
normalizeId: (raw) => {
const mention = raw.match(/^<@!?(\d+)>$/);
const cleaned = (mention?.[1] ?? raw).replace(/^(discord|user):/i, "").trim();
return /^\d+$/.test(cleaned) ? `user:${cleaned}` : null;
},
});
}
export const listDiscordDirectoryPeersFromConfig = createResolvedDirectoryEntriesLister<
ReturnType<typeof resolveDiscordDirectoryConfigAccount>
>({
kind: "user",
resolveAccount: (cfg, accountId) => resolveDiscordDirectoryConfigAccount(cfg, accountId),
resolveSources: (account) => {
const allowFrom = account.config.allowFrom ?? account.config.dm?.allowFrom ?? [];
const guildUsers = Object.values(account.config.guilds ?? {}).flatMap((guild) => [
...(guild.users ?? []),
...Object.values(guild.channels ?? {}).flatMap((channel) => channel.users ?? []),
]);
return [allowFrom, Object.keys(account.config.dms ?? {}), guildUsers];
},
normalizeId: (raw) => {
const mention = raw.match(/^<@!?(\d+)>$/);
const cleaned = (mention?.[1] ?? raw).replace(/^(discord|user):/i, "").trim();
return /^\d+$/.test(cleaned) ? `user:${cleaned}` : null;
},
});
export async function listDiscordDirectoryGroupsFromConfig(params: DirectoryConfigParams) {
return listResolvedDirectoryEntriesFromSources({
...params,
kind: "group",
resolveAccount: (cfg, accountId) => resolveDiscordDirectoryConfigAccount(cfg, accountId),
resolveSources: (account) =>
Object.values(account.config.guilds ?? {}).map((guild) => Object.keys(guild.channels ?? {})),
normalizeId: (raw) => {
const mention = raw.match(/^<#(\d+)>$/);
const cleaned = (mention?.[1] ?? raw).replace(/^(discord|channel|group):/i, "").trim();
return /^\d+$/.test(cleaned) ? `channel:${cleaned}` : null;
},
});
}
export const listDiscordDirectoryGroupsFromConfig = createResolvedDirectoryEntriesLister<
ReturnType<typeof resolveDiscordDirectoryConfigAccount>
>({
kind: "group",
resolveAccount: (cfg, accountId) => resolveDiscordDirectoryConfigAccount(cfg, accountId),
resolveSources: (account) =>
Object.values(account.config.guilds ?? {}).map((guild) => Object.keys(guild.channels ?? {})),
normalizeId: (raw) => {
const mention = raw.match(/^<#(\d+)>$/);
const cleaned = (mention?.[1] ?? raw).replace(/^(discord|channel|group):/i, "").trim();
return /^\d+$/.test(cleaned) ? `channel:${cleaned}` : null;
},
});

View File

@@ -40,9 +40,7 @@ describe("createDiscordGatewaySupervisor", () => {
error: vi.fn(),
};
const supervisor = createDiscordGatewaySupervisor({
client: {
getPlugin: vi.fn(() => ({ emitter })),
} as never,
gateway: { emitter },
isDisallowedIntentsError: (err) => String(err).includes("4014"),
runtime: runtime as never,
});
@@ -72,9 +70,7 @@ describe("createDiscordGatewaySupervisor", () => {
it("is idempotent on dispose and noops without an emitter", () => {
const supervisor = createDiscordGatewaySupervisor({
client: {
getPlugin: vi.fn(() => undefined),
} as never,
gateway: undefined,
isDisallowedIntentsError: () => false,
runtime: { error: vi.fn() } as never,
});
@@ -90,9 +86,7 @@ describe("createDiscordGatewaySupervisor", () => {
const emitter = new EventEmitter();
const runtime = { error: vi.fn() };
const supervisor = createDiscordGatewaySupervisor({
client: {
getPlugin: vi.fn(() => ({ emitter })),
} as never,
gateway: { emitter },
isDisallowedIntentsError: () => false,
runtime: runtime as never,
});

View File

@@ -1,5 +1,4 @@
import type { EventEmitter } from "node:events";
import type { Client } from "@buape/carbon";
import { danger } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { getDiscordGatewayEmitter } from "../monitor.gateway.js";
@@ -67,12 +66,11 @@ export function classifyDiscordGatewayEvent(params: {
}
export function createDiscordGatewaySupervisor(params: {
client: Client;
gateway?: unknown;
isDisallowedIntentsError: (err: unknown) => boolean;
runtime: RuntimeEnv;
}): DiscordGatewaySupervisor {
const gateway = params.client.getPlugin("gateway");
const emitter = getDiscordGatewayEmitter(gateway);
const emitter = getDiscordGatewayEmitter(params.gateway);
const pending: DiscordGatewayEvent[] = [];
if (!emitter) {
return {
@@ -86,39 +84,36 @@ export function createDiscordGatewaySupervisor(params: {
let lifecycleHandler: ((event: DiscordGatewayEvent) => void) | undefined;
let phase: GatewaySupervisorPhase = "buffering";
let disposed = false;
const logLateTeardownEvent = (event: DiscordGatewayEvent) => {
params.runtime.error?.(
danger(
`discord: suppressed late gateway ${event.type} error during teardown: ${event.message}`,
),
);
};
const logLateDisposedEvent = (event: DiscordGatewayEvent) => {
params.runtime.error?.(
danger(
`discord: suppressed late gateway ${event.type} error after dispose: ${event.message}`,
),
);
};
const logLateEvent =
(state: Extract<GatewaySupervisorPhase, "disposed" | "teardown">) =>
(event: DiscordGatewayEvent) => {
params.runtime.error?.(
danger(
`discord: suppressed late gateway ${event.type} error ${
state === "disposed" ? "after dispose" : "during teardown"
}: ${event.message}`,
),
);
};
const onGatewayError = (err: unknown) => {
const event = classifyDiscordGatewayEvent({
err,
isDisallowedIntentsError: params.isDisallowedIntentsError,
});
if (phase === "disposed") {
logLateDisposedEvent(event);
return;
switch (phase) {
case "disposed":
logLateEvent("disposed")(event);
return;
case "active":
lifecycleHandler?.(event);
return;
case "teardown":
logLateEvent("teardown")(event);
return;
case "buffering":
pending.push(event);
return;
}
if (phase === "active" && lifecycleHandler) {
lifecycleHandler(event);
return;
}
if (phase === "teardown") {
logLateTeardownEvent(event);
return;
}
pending.push(event);
};
emitter.on("error", onGatewayError);
@@ -146,10 +141,9 @@ export function createDiscordGatewaySupervisor(params: {
return "continue";
},
dispose: () => {
if (disposed) {
if (phase === "disposed") {
return;
}
disposed = true;
lifecycleHandler = undefined;
phase = "disposed";
pending.length = 0;

View File

@@ -1,5 +1,4 @@
import { EventEmitter } from "node:events";
import type { Client } from "@buape/carbon";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../../../../src/runtime.js";
import type { WaitForDiscordGatewayStopParams } from "../monitor.gateway.js";
@@ -43,6 +42,7 @@ vi.mock("./gateway-registry.js", () => ({
describe("runDiscordGatewayLifecycle", () => {
beforeEach(() => {
vi.resetModules();
attachDiscordGatewayLoggingMock.mockClear();
getDiscordGatewayEmitterMock.mockClear();
waitForDiscordGatewayStopMock.mockClear();
@@ -72,6 +72,13 @@ describe("runDiscordGatewayLifecycle", () => {
ws?: EventEmitter & { terminate?: () => void };
};
}) => {
const gateway =
params?.gateway ??
(() => {
const defaultGateway = createGatewayHarness().gateway;
defaultGateway.isConnected = true;
return defaultGateway;
})();
const start = vi.fn(params?.start ?? (async () => undefined));
const stop = vi.fn(params?.stop ?? (async () => undefined));
const threadStop = vi.fn();
@@ -96,7 +103,7 @@ describe("runDiscordGatewayLifecycle", () => {
return "continue";
}),
dispose: vi.fn(),
emitter: params?.gateway?.emitter,
emitter: gateway.emitter,
};
const statusSink = vi.fn();
const runtime: RuntimeEnv = {
@@ -114,9 +121,7 @@ describe("runDiscordGatewayLifecycle", () => {
statusSink,
lifecycleParams: {
accountId: params?.accountId ?? "default",
client: {
getPlugin: vi.fn((name: string) => (name === "gateway" ? params?.gateway : undefined)),
} as unknown as Client,
gateway,
runtime,
isDisallowedIntentsError: params?.isDisallowedIntentsError ?? (() => false),
voiceManager: null,

View File

@@ -1,4 +1,3 @@
import type { Client } from "@buape/carbon";
import type { GatewayPlugin } from "@buape/carbon/gateway";
import { createArmableStallWatchdog } from "openclaw/plugin-sdk/channel-lifecycle";
import { createConnectedChannelStatusPatch } from "openclaw/plugin-sdk/gateway-runtime";
@@ -70,7 +69,7 @@ async function waitForDiscordGatewayReady(params: {
export async function runDiscordGatewayLifecycle(params: {
accountId: string;
client: Client;
gateway?: GatewayPlugin;
runtime: RuntimeEnv;
abortSignal?: AbortSignal;
isDisallowedIntentsError: (err: unknown) => boolean;
@@ -85,7 +84,7 @@ export async function runDiscordGatewayLifecycle(params: {
const HELLO_CONNECTED_POLL_MS = 250;
const MAX_CONSECUTIVE_HELLO_STALLS = 3;
const RECONNECT_STALL_TIMEOUT_MS = 5 * 60_000;
const gateway = params.client.getPlugin<GatewayPlugin>("gateway");
const gateway = params.gateway;
if (gateway) {
registerGateway(params.accountId, gateway);
}

View File

@@ -947,15 +947,15 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
},
clientPlugins,
);
lifecycleGateway = client.getPlugin<GatewayPlugin>("gateway");
gatewaySupervisor = (
createDiscordGatewaySupervisorForTesting ?? createDiscordGatewaySupervisor
)({
client,
gateway: lifecycleGateway,
isDisallowedIntentsError: isDiscordDisallowedIntentsError,
runtime,
});
lifecycleGateway = client.getPlugin<GatewayPlugin>("gateway");
earlyGatewayEmitter = gatewaySupervisor.emitter;
onEarlyGatewayDebug = (msg: unknown) => {
if (!(isVerboseForTesting ?? isVerbose)()) {
@@ -1164,7 +1164,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
onEarlyGatewayDebug = undefined;
await (runDiscordGatewayLifecycleForTesting ?? runDiscordGatewayLifecycle)({
accountId: account.accountId,
client,
gateway: lifecycleGateway,
runtime,
abortSignal: opts.abortSignal,
statusSink: opts.setStatus,

View File

@@ -3,17 +3,16 @@ import {
createDelegatedSetupWizardProxy,
createDelegatedTextInputShouldPrompt,
createPatchedAccountSetupAdapter,
createTopLevelChannelDmPolicy,
parseSetupEntriesAllowingWildcard,
promptParsedAllowFromForAccount,
setAccountAllowFromForChannel,
setChannelDmPolicyWithAllowFrom,
setSetupChannelEnabled,
type OpenClawConfig,
type WizardPrompter,
} from "openclaw/plugin-sdk/setup";
import type {
ChannelSetupAdapter,
ChannelSetupDmPolicy,
ChannelSetupWizard,
ChannelSetupWizardTextInput,
} from "openclaw/plugin-sdk/setup";
@@ -106,20 +105,14 @@ export async function promptIMessageAllowFrom(params: {
});
}
export const imessageDmPolicy: ChannelSetupDmPolicy = {
export const imessageDmPolicy = createTopLevelChannelDmPolicy({
label: "iMessage",
channel,
policyKey: "channels.imessage.dmPolicy",
allowFromKey: "channels.imessage.allowFrom",
getCurrent: (cfg: OpenClawConfig) => cfg.channels?.imessage?.dmPolicy ?? "pairing",
setPolicy: (cfg: OpenClawConfig, policy) =>
setChannelDmPolicyWithAllowFrom({
cfg,
channel,
dmPolicy: policy,
}),
promptAllowFrom: promptIMessageAllowFrom,
};
});
function resolveIMessageCliPath(params: { cfg: OpenClawConfig; accountId: string }) {
return resolveIMessageAccount(params).config.cliPath ?? "imsg";

View File

@@ -13,7 +13,7 @@ import {
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
import {
createChannelDirectoryAdapter,
listResolvedDirectoryEntriesFromSources,
createResolvedDirectoryEntriesLister,
} from "openclaw/plugin-sdk/directory-runtime";
import { runStoppablePassiveMonitor } from "openclaw/plugin-sdk/extension-shared";
import {
@@ -61,6 +61,30 @@ function normalizePairingTarget(raw: string): string {
return normalized.split(/[!@]/, 1)[0]?.trim() ?? "";
}
const listIrcDirectoryPeersFromConfig = createResolvedDirectoryEntriesLister<ResolvedIrcAccount>({
kind: "user",
resolveAccount: adaptScopedAccountAccessor(resolveIrcAccount),
resolveSources: (account) => [
account.config.allowFrom ?? [],
account.config.groupAllowFrom ?? [],
...Object.values(account.config.groups ?? {}).map((group) => group.allowFrom ?? []),
],
normalizeId: (entry) => normalizePairingTarget(entry) || null,
});
const listIrcDirectoryGroupsFromConfig = createResolvedDirectoryEntriesLister<ResolvedIrcAccount>({
kind: "group",
resolveAccount: adaptScopedAccountAccessor(resolveIrcAccount),
resolveSources: (account) => [
account.config.channels ?? [],
Object.keys(account.config.groups ?? {}),
],
normalizeId: (entry) => {
const normalized = normalizeIrcMessagingTarget(entry);
return normalized && isChannelTarget(normalized) ? normalized : null;
},
});
const ircConfigAdapter = createScopedChannelConfigAdapter<
ResolvedIrcAccount,
ResolvedIrcAccount,
@@ -227,32 +251,9 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = createChat
},
},
directory: createChannelDirectoryAdapter({
listPeers: async (params) =>
listResolvedDirectoryEntriesFromSources<ResolvedIrcAccount>({
...params,
kind: "user",
resolveAccount: adaptScopedAccountAccessor(resolveIrcAccount),
resolveSources: (account) => [
account.config.allowFrom ?? [],
account.config.groupAllowFrom ?? [],
...Object.values(account.config.groups ?? {}).map((group) => group.allowFrom ?? []),
],
normalizeId: (entry) => normalizePairingTarget(entry) || null,
}),
listPeers: async (params) => listIrcDirectoryPeersFromConfig(params),
listGroups: async (params) => {
const entries = listResolvedDirectoryEntriesFromSources<ResolvedIrcAccount>({
...params,
kind: "group",
resolveAccount: adaptScopedAccountAccessor(resolveIrcAccount),
resolveSources: (account) => [
account.config.channels ?? [],
Object.keys(account.config.groups ?? {}),
],
normalizeId: (entry) => {
const normalized = normalizeIrcMessagingTarget(entry);
return normalized && isChannelTarget(normalized) ? normalized : null;
},
});
const entries = await listIrcDirectoryGroupsFromConfig(params);
return entries.map((entry) => ({ ...entry, name: entry.id }));
},
}),

View File

@@ -16,8 +16,8 @@ import { createScopedAccountReplyToModeResolver } from "openclaw/plugin-sdk/conv
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
import {
createChannelDirectoryAdapter,
createResolvedDirectoryEntriesLister,
createRuntimeDirectoryLiveAdapter,
listResolvedDirectoryEntriesFromSources,
} from "openclaw/plugin-sdk/directory-runtime";
import { buildTrafficStatusSummary } from "openclaw/plugin-sdk/extension-shared";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
@@ -78,6 +78,46 @@ const meta = {
quickstartAllowFrom: true,
};
const listMatrixDirectoryPeersFromConfig =
createResolvedDirectoryEntriesLister<ResolvedMatrixAccount>({
kind: "user",
resolveAccount: adaptScopedAccountAccessor(resolveMatrixAccount),
resolveSources: (account) => [
account.config.dm?.allowFrom ?? [],
account.config.groupAllowFrom ?? [],
...Object.values(account.config.groups ?? account.config.rooms ?? {}).map(
(room) => room.users ?? [],
),
],
normalizeId: (entry) => {
const raw = entry.replace(/^matrix:/i, "").trim();
if (!raw || raw === "*") {
return null;
}
const lowered = raw.toLowerCase();
const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw;
return cleaned.startsWith("@") ? `user:${cleaned}` : cleaned;
},
});
const listMatrixDirectoryGroupsFromConfig =
createResolvedDirectoryEntriesLister<ResolvedMatrixAccount>({
kind: "group",
resolveAccount: adaptScopedAccountAccessor(resolveMatrixAccount),
resolveSources: (account) => [Object.keys(account.config.groups ?? account.config.rooms ?? {})],
normalizeId: (entry) => {
const raw = entry.replace(/^matrix:/i, "").trim();
if (!raw || raw === "*") {
return null;
}
const lowered = raw.toLowerCase();
if (lowered.startsWith("room:") || lowered.startsWith("channel:")) {
return raw;
}
return raw.startsWith("!") ? `room:${raw}` : raw;
},
});
const matrixConfigAdapter = createScopedChannelConfigAdapter<
ResolvedMatrixAccount,
ReturnType<typeof resolveMatrixAccountConfig>,
@@ -246,53 +286,14 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount, MatrixProbe> =
},
directory: createChannelDirectoryAdapter({
listPeers: async (params) => {
const entries = listResolvedDirectoryEntriesFromSources<ResolvedMatrixAccount>({
...params,
kind: "user",
resolveAccount: adaptScopedAccountAccessor(resolveMatrixAccount),
resolveSources: (account) => [
account.config.dm?.allowFrom ?? [],
account.config.groupAllowFrom ?? [],
...Object.values(account.config.groups ?? account.config.rooms ?? {}).map(
(room) => room.users ?? [],
),
],
normalizeId: (entry) => {
const raw = entry.replace(/^matrix:/i, "").trim();
if (!raw || raw === "*") {
return null;
}
const lowered = raw.toLowerCase();
const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw;
return cleaned.startsWith("@") ? `user:${cleaned}` : cleaned;
},
});
const entries = await listMatrixDirectoryPeersFromConfig(params);
return entries.map((entry) => {
const raw = entry.id.startsWith("user:") ? entry.id.slice("user:".length) : entry.id;
const incomplete = !raw.startsWith("@") || !raw.includes(":");
return incomplete ? { ...entry, name: "incomplete id; expected @user:server" } : entry;
});
},
listGroups: async (params) =>
listResolvedDirectoryEntriesFromSources<ResolvedMatrixAccount>({
...params,
kind: "group",
resolveAccount: adaptScopedAccountAccessor(resolveMatrixAccount),
resolveSources: (account) => [
Object.keys(account.config.groups ?? account.config.rooms ?? {}),
],
normalizeId: (entry) => {
const raw = entry.replace(/^matrix:/i, "").trim();
if (!raw || raw === "*") {
return null;
}
const lowered = raw.toLowerCase();
if (lowered.startsWith("room:") || lowered.startsWith("channel:")) {
return raw;
}
return raw.startsWith("!") ? `room:${raw}` : raw;
},
}),
listGroups: async (params) => await listMatrixDirectoryGroupsFromConfig(params),
...createRuntimeDirectoryLiveAdapter({
getRuntime: loadMatrixChannelRuntime,
listPeersLive: (runtime) => runtime.listMatrixDirectoryPeersLive,

View File

@@ -28,6 +28,7 @@ vi.mock("../../../../../src/infra/backup-create.js", async (importOriginal) => {
});
let maybeMigrateLegacyStorage: typeof import("./storage.js").maybeMigrateLegacyStorage;
let resolveMatrixStateFilePath: typeof import("./storage.js").resolveMatrixStateFilePath;
let resolveMatrixStoragePaths: typeof import("./storage.js").resolveMatrixStoragePaths;
describe("matrix client storage paths", () => {
@@ -39,7 +40,8 @@ describe("matrix client storage paths", () => {
};
beforeAll(async () => {
({ maybeMigrateLegacyStorage, resolveMatrixStoragePaths } = await import("./storage.js"));
({ maybeMigrateLegacyStorage, resolveMatrixStateFilePath, resolveMatrixStoragePaths } =
await import("./storage.js"));
});
afterEach(() => {
@@ -111,6 +113,26 @@ describe("matrix client storage paths", () => {
});
}
it("resolves state file paths inside the selected storage root", () => {
setupStateDir();
const filePath = resolveMatrixStateFilePath({
auth: {
...defaultStorageAuth,
accountId: "ops",
deviceId: "DEVICE1",
},
filename: "thread-bindings.json",
env: {},
});
expect(filePath).toBe(
path.join(
resolveDefaultStoragePaths({ accountId: "ops", deviceId: "DEVICE1" }).rootDir,
"thread-bindings.json",
),
);
});
function writeLegacyMatrixStorage(
stateDir: string,
params: {

View File

@@ -11,6 +11,7 @@ import {
resolveMatrixAccountStorageRoot,
resolveMatrixLegacyFlatStoragePaths,
} from "../../storage-paths.js";
import type { MatrixAuth } from "./types.js";
import type { MatrixStoragePaths } from "./types.js";
export const DEFAULT_ACCOUNT_KEY = "default";
@@ -293,6 +294,25 @@ export function resolveMatrixStoragePaths(params: {
};
}
export function resolveMatrixStateFilePath(params: {
auth: MatrixAuth;
filename: string;
accountId?: string | null;
env?: NodeJS.ProcessEnv;
stateDir?: string;
}): string {
const storagePaths = resolveMatrixStoragePaths({
homeserver: params.auth.homeserver,
userId: params.auth.userId,
accessToken: params.auth.accessToken,
accountId: params.accountId ?? params.auth.accountId,
deviceId: params.auth.deviceId,
env: params.env,
stateDir: params.stateDir,
});
return path.join(storagePaths.rootDir, params.filename);
}
export async function maybeMigrateLegacyStorage(params: {
storagePaths: MatrixStoragePaths;
env?: NodeJS.ProcessEnv;

View File

@@ -1,7 +1,6 @@
import path from "node:path";
import { readJsonFileWithFallback, writeJsonFileAtomically } from "../../runtime-api.js";
import { createAsyncLock } from "../async-lock.js";
import { resolveMatrixStoragePaths } from "../client/storage.js";
import { resolveMatrixStateFilePath } from "../client/storage.js";
import type { MatrixAuth } from "../client/types.js";
import { LogService } from "../sdk/logger.js";
@@ -44,16 +43,12 @@ function resolveInboundDedupeStatePath(params: {
env?: NodeJS.ProcessEnv;
stateDir?: string;
}): string {
const storagePaths = resolveMatrixStoragePaths({
homeserver: params.auth.homeserver,
userId: params.auth.userId,
accessToken: params.auth.accessToken,
accountId: params.auth.accountId,
deviceId: params.auth.deviceId,
return resolveMatrixStateFilePath({
auth: params.auth,
env: params.env,
stateDir: params.stateDir,
filename: INBOUND_DEDUPE_FILENAME,
});
return path.join(storagePaths.rootDir, INBOUND_DEDUPE_FILENAME);
}
function normalizeTimestamp(raw: unknown): number | null {

View File

@@ -8,7 +8,7 @@ import {
} from "../../../../src/infra/outbound/session-binding-service.js";
import type { PluginRuntime } from "../../runtime-api.js";
import { setMatrixRuntime } from "../runtime.js";
import { resolveMatrixStoragePaths } from "./client/storage.js";
import { resolveMatrixStateFilePath, resolveMatrixStoragePaths } from "./client/storage.js";
import {
createMatrixThreadBindingManager,
resetMatrixThreadBindingsForTests,
@@ -87,14 +87,12 @@ describe("matrix thread bindings", () => {
}
function resolveBindingsFilePath(customStateDir?: string) {
return path.join(
resolveMatrixStoragePaths({
...auth,
env: process.env,
...(customStateDir ? { stateDir: customStateDir } : {}),
}).rootDir,
"thread-bindings.json",
);
return resolveMatrixStateFilePath({
auth,
env: process.env,
...(customStateDir ? { stateDir: customStateDir } : {}),
filename: "thread-bindings.json",
});
}
async function readPersistedLastActivityAt(bindingsPath: string) {

View File

@@ -1,4 +1,3 @@
import path from "node:path";
import type { SessionBindingAdapter } from "openclaw/plugin-sdk/conversation-runtime";
import {
readJsonFileWithFallback,
@@ -8,7 +7,7 @@ import {
unregisterSessionBindingAdapter,
writeJsonFileAtomically,
} from "../runtime-api.js";
import { resolveMatrixStoragePaths } from "./client/storage.js";
import { resolveMatrixStateFilePath } from "./client/storage.js";
import type { MatrixAuth } from "./client/types.js";
import type { MatrixClient } from "./sdk.js";
import { sendMessageMatrix } from "./send.js";
@@ -62,16 +61,13 @@ function resolveBindingsPath(params: {
env?: NodeJS.ProcessEnv;
stateDir?: string;
}): string {
const storagePaths = resolveMatrixStoragePaths({
homeserver: params.auth.homeserver,
userId: params.auth.userId,
accessToken: params.auth.accessToken,
return resolveMatrixStateFilePath({
auth: params.auth,
accountId: params.accountId,
deviceId: params.auth.deviceId,
env: params.env,
stateDir: params.stateDir,
filename: "thread-bindings.json",
});
return path.join(storagePaths.rootDir, "thread-bindings.json");
}
async function loadBindingsFromDisk(filePath: string, accountId: string) {

View File

@@ -6,7 +6,6 @@ import {
colorize,
defaultRuntime,
formatErrorMessage,
getMemorySearchManager,
isRich,
listMemoryFiles,
loadConfig,
@@ -25,6 +24,7 @@ import {
type OpenClawConfig,
} from "./api.js";
import type { MemoryCommandOptions, MemorySearchCommandOptions } from "./cli.types.js";
import { getMemorySearchManager } from "./runtime-api.js";
type MemoryManager = NonNullable<Awaited<ReturnType<typeof getMemorySearchManager>>["manager"]>;
type MemoryManagerPurpose = Parameters<typeof getMemorySearchManager>[0]["purpose"];

View File

@@ -24,13 +24,20 @@ vi.mock("./api.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./api.js")>();
return {
...actual,
getMemorySearchManager,
loadConfig,
resolveDefaultAgentId,
resolveCommandSecretRefsViaGateway,
};
});
vi.mock("./runtime-api.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./runtime-api.js")>();
return {
...actual,
getMemorySearchManager,
};
});
let registerMemoryCli: typeof import("./cli.js").registerMemoryCli;
let defaultRuntime: typeof import("./api.js").defaultRuntime;
let isVerbose: typeof import("./api.js").isVerbose;

View File

@@ -3,7 +3,7 @@ export type {
MemoryEmbeddingProbeResult,
MemorySearchManager,
MemorySearchResult,
} from "./types.js";
} from "../api.js";
export {
closeAllMemorySearchManagers,
getMemorySearchManager,

View File

@@ -1,31 +1,30 @@
import fs from "node:fs/promises";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { runGeminiEmbeddingBatches, type GeminiBatchRequest } from "./batch-gemini.js";
import {
OPENAI_BATCH_ENDPOINT,
type OpenAiBatchRequest,
runOpenAiEmbeddingBatches,
} from "./batch-openai.js";
import { type VoyageBatchRequest, runVoyageEmbeddingBatches } from "./batch-voyage.js";
import { enforceEmbeddingMaxInputTokens } from "./embedding-chunk-limits.js";
import {
estimateStructuredEmbeddingInputBytes,
estimateUtf8Bytes,
} from "./embedding-input-limits.js";
import { type EmbeddingInput, hasNonTextEmbeddingParts } from "./embedding-inputs.js";
import { buildGeminiEmbeddingRequest } from "./embeddings-gemini.js";
import {
buildGeminiEmbeddingRequest,
buildMultimodalChunkForIndexing,
chunkMarkdown,
createSubsystemLogger,
enforceEmbeddingMaxInputTokens,
estimateStructuredEmbeddingInputBytes,
estimateUtf8Bytes,
hasNonTextEmbeddingParts,
hashText,
parseEmbedding,
remapChunkLines,
runGeminiEmbeddingBatches,
runOpenAiEmbeddingBatches,
runVoyageEmbeddingBatches,
type EmbeddingInput,
type GeminiBatchRequest,
type MemoryChunk,
type MemoryFileEntry,
} from "./internal.js";
type MemorySource,
type OpenAiBatchRequest,
type SessionFileEntry,
type VoyageBatchRequest,
OPENAI_BATCH_ENDPOINT,
} from "../api.js";
import { MemoryManagerSyncOps } from "./manager-sync-ops.js";
import type { SessionFileEntry } from "./session-files.js";
import type { MemorySource } from "./types.js";
const VECTOR_TABLE = "chunks_vec";
const FTS_TABLE = "chunks_fts";

View File

@@ -1,6 +1,5 @@
import type { DatabaseSync } from "node:sqlite";
import { truncateUtf16Safe } from "../utils.js";
import { cosineSimilarity, parseEmbedding } from "./internal.js";
import { cosineSimilarity, parseEmbedding, truncateUtf16Safe } from "../api.js";
const vectorToBlob = (embedding: number[]): Buffer =>
Buffer.from(new Float32Array(embedding).buffer);

View File

@@ -4,52 +4,47 @@ import fs from "node:fs/promises";
import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import chokidar, { FSWatcher } from "chokidar";
import { resolveAgentDir } from "../agents/agent-scope.js";
import { ResolvedMemorySearchConfig } from "../agents/memory-search.js";
import { type OpenClawConfig } from "../config/config.js";
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { onSessionTranscriptUpdate } from "../sessions/transcript-events.js";
import { resolveUserPath } from "../utils.js";
import { DEFAULT_GEMINI_EMBEDDING_MODEL } from "./embeddings-gemini.js";
import { DEFAULT_MISTRAL_EMBEDDING_MODEL } from "./embeddings-mistral.js";
import { DEFAULT_OLLAMA_EMBEDDING_MODEL } from "./embeddings-ollama.js";
import { DEFAULT_OPENAI_EMBEDDING_MODEL } from "./embeddings-openai.js";
import { DEFAULT_VOYAGE_EMBEDDING_MODEL } from "./embeddings-voyage.js";
import {
DEFAULT_GEMINI_EMBEDDING_MODEL,
DEFAULT_MISTRAL_EMBEDDING_MODEL,
DEFAULT_OLLAMA_EMBEDDING_MODEL,
DEFAULT_OPENAI_EMBEDDING_MODEL,
DEFAULT_VOYAGE_EMBEDDING_MODEL,
buildCaseInsensitiveExtensionGlob,
buildFileEntry,
classifyMemoryMultimodalPath,
createEmbeddingProvider,
createSubsystemLogger,
ensureDir,
ensureMemoryIndexSchema,
getMemoryMultimodalExtensions,
hashText,
isFileMissingError,
listMemoryFiles,
listSessionFilesForAgent,
loadSqliteVecExtension,
normalizeExtraMemoryPaths,
onSessionTranscriptUpdate,
requireNodeSqlite,
resolveAgentDir,
resolveSessionTranscriptsDirForAgent,
resolveUserPath,
runWithConcurrency,
sessionPathForFile,
type MemoryFileEntry,
type MemorySource,
type MemorySyncProgressUpdate,
type OpenClawConfig,
type ResolvedMemorySearchConfig,
type SessionFileEntry,
type EmbeddingProvider,
type GeminiEmbeddingClient,
type MistralEmbeddingClient,
type OllamaEmbeddingClient,
type OpenAiEmbeddingClient,
type VoyageEmbeddingClient,
} from "./embeddings.js";
import { isFileMissingError } from "./fs-utils.js";
import {
buildFileEntry,
ensureDir,
hashText,
listMemoryFiles,
normalizeExtraMemoryPaths,
runWithConcurrency,
} from "./internal.js";
import { type MemoryFileEntry } from "./internal.js";
import { ensureMemoryIndexSchema } from "./memory-schema.js";
import {
buildCaseInsensitiveExtensionGlob,
classifyMemoryMultimodalPath,
getMemoryMultimodalExtensions,
} from "./multimodal.js";
import type { SessionFileEntry } from "./session-files.js";
import {
buildSessionEntry,
listSessionFilesForAgent,
sessionPathForFile,
} from "./session-files.js";
import { loadSqliteVecExtension } from "./sqlite-vec.js";
import { requireNodeSqlite } from "./sqlite.js";
import type { MemorySource, MemorySyncProgressUpdate } from "./types.js";
} from "../api.js";
type MemoryIndexMeta = {
model: string;

View File

@@ -1,11 +1,5 @@
import type { DatabaseSync } from "node:sqlite";
import { type FSWatcher } from "chokidar";
import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import type { ResolvedMemorySearchConfig } from "../agents/memory-search.js";
import { resolveMemorySearchConfig } from "../agents/memory-search.js";
import type { OpenClawConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import {
createEmbeddingProvider,
type EmbeddingProvider,
@@ -16,20 +10,25 @@ import {
type OllamaEmbeddingClient,
type OpenAiEmbeddingClient,
type VoyageEmbeddingClient,
} from "./embeddings.js";
extractKeywords,
readMemoryFile,
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveGlobalSingleton,
resolveMemorySearchConfig,
type MemoryEmbeddingProbeResult,
type MemoryProviderStatus,
type MemorySearchManager,
type MemorySearchResult,
type MemorySource,
type MemorySyncProgressUpdate,
type OpenClawConfig,
type ResolvedMemorySearchConfig,
createSubsystemLogger,
} from "../api.js";
import { bm25RankToScore, buildFtsQuery, mergeHybridResults } from "./hybrid.js";
import { MemoryManagerEmbeddingOps } from "./manager-embedding-ops.js";
import { searchKeyword, searchVector } from "./manager-search.js";
import { extractKeywords } from "./query-expansion.js";
import { readMemoryFile } from "./read-file.js";
import type {
MemoryEmbeddingProbeResult,
MemoryProviderStatus,
MemorySearchManager,
MemorySearchResult,
MemorySource,
MemorySyncProgressUpdate,
} from "./types.js";
const SNIPPET_MAX_CHARS = 700;
const VECTOR_TABLE = "chunks_vec";
const FTS_TABLE = "chunks_fts";

View File

@@ -2,38 +2,39 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import readline from "node:readline";
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { writeFileWithinRoot } from "../infra/fs-safe.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import { isFileMissingError, statRegularFile } from "./fs-utils.js";
import { resolveCliSpawnInvocation, runCliCommand } from "./qmd-process.js";
import { deriveQmdScopeChannel, deriveQmdScopeChatType, isQmdScopeAllowed } from "./qmd-scope.js";
import {
listSessionFilesForAgent,
buildSessionEntry,
createSubsystemLogger,
deriveQmdScopeChannel,
deriveQmdScopeChatType,
extractKeywords,
isFileMissingError,
isQmdScopeAllowed,
listSessionFilesForAgent,
parseQmdQueryJson,
requireNodeSqlite,
resolveAgentWorkspaceDir,
resolveCliSpawnInvocation,
resolveGlobalSingleton,
resolveStateDir,
runCliCommand,
statRegularFile,
type MemoryEmbeddingProbeResult,
type MemoryProviderStatus,
type MemorySearchManager,
type MemorySearchResult,
type MemorySource,
type MemorySyncProgressUpdate,
type OpenClawConfig,
type QmdQueryResult,
type ResolvedMemoryBackendConfig,
type ResolvedQmdConfig,
type ResolvedQmdMcporterConfig,
type SessionFileEntry,
} from "./session-files.js";
import { requireNodeSqlite } from "./sqlite.js";
import type {
MemoryEmbeddingProbeResult,
MemoryProviderStatus,
MemorySearchManager,
MemorySearchResult,
MemorySource,
MemorySyncProgressUpdate,
} from "./types.js";
writeFileWithinRoot,
} from "../api.js";
type SqliteDatabase = import("node:sqlite").DatabaseSync;
import type {
ResolvedMemoryBackendConfig,
ResolvedQmdConfig,
ResolvedQmdMcporterConfig,
} from "./backend-config.js";
import { parseQmdQueryJson, type QmdQueryResult } from "./qmd-query-parser.js";
import { extractKeywords } from "./query-expansion.js";
const log = createSubsystemLogger("memory");

View File

@@ -1,13 +1,13 @@
import type { OpenClawConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import type { ResolvedQmdConfig } from "./backend-config.js";
import { resolveMemoryBackendConfig } from "./backend-config.js";
import type {
MemoryEmbeddingProbeResult,
MemorySearchManager,
MemorySyncProgressUpdate,
} from "./types.js";
import {
createSubsystemLogger,
resolveGlobalSingleton,
resolveMemoryBackendConfig,
type MemoryEmbeddingProbeResult,
type MemorySearchManager,
type MemorySyncProgressUpdate,
type OpenClawConfig,
type ResolvedQmdConfig,
} from "../api.js";
const MEMORY_SEARCH_MANAGER_CACHE_KEY = Symbol.for("openclaw.memorySearchManagerCache");
type MemorySearchManagerCacheStore = {

View File

@@ -0,0 +1,2 @@
export * from "./api.js";
export * from "./memory/index.js";

View File

@@ -1,8 +1,5 @@
import {
getMemorySearchManager,
resolveMemoryBackendConfig,
type MemoryPluginRuntime,
} from "./api.js";
import { resolveMemoryBackendConfig, type MemoryPluginRuntime } from "./api.js";
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./runtime-api.js";
export const memoryRuntime: MemoryPluginRuntime = {
async getMemorySearchManager(params) {
@@ -15,4 +12,7 @@ export const memoryRuntime: MemoryPluginRuntime = {
resolveMemoryBackendConfig(params) {
return resolveMemoryBackendConfig(params);
},
async closeAllMemorySearchManagers() {
await closeAllMemorySearchManagers();
},
};

View File

@@ -1 +1,2 @@
export { getMemorySearchManager, readAgentMemoryFile, resolveMemoryBackendConfig } from "./api.js";
export { readAgentMemoryFile, resolveMemoryBackendConfig } from "./api.js";
export { getMemorySearchManager } from "./runtime-api.js";

View File

@@ -8,7 +8,7 @@ import {
type MemoryToolRuntime = typeof import("./tools.runtime.js");
type MemorySearchManagerResult = Awaited<
ReturnType<(typeof import("./api.js"))["getMemorySearchManager"]>
ReturnType<(typeof import("./runtime-api.js"))["getMemorySearchManager"]>
>;
let memoryToolRuntimePromise: Promise<MemoryToolRuntime> | null = null;

View File

@@ -40,10 +40,8 @@ function createApi(params: {
function createCommandContext(args: string): PluginCommandContext {
return {
surface: "test",
channel: "test",
isAuthorizedSender: true,
senderIsOwner: false,
commandBody: `/phone ${args}`,
args,
config: {},

View File

@@ -3,18 +3,17 @@ import {
createDelegatedSetupWizardProxy,
createDelegatedTextInputShouldPrompt,
createPatchedAccountSetupAdapter,
createTopLevelChannelDmPolicy,
normalizeE164,
parseSetupEntriesAllowingWildcard,
promptParsedAllowFromForAccount,
setAccountAllowFromForChannel,
setChannelDmPolicyWithAllowFrom,
setSetupChannelEnabled,
type OpenClawConfig,
type WizardPrompter,
} from "openclaw/plugin-sdk/setup";
import type {
ChannelSetupAdapter,
ChannelSetupDmPolicy,
ChannelSetupWizard,
ChannelSetupWizardTextInput,
} from "openclaw/plugin-sdk/setup";
@@ -122,20 +121,14 @@ export async function promptSignalAllowFrom(params: {
});
}
export const signalDmPolicy: ChannelSetupDmPolicy = {
export const signalDmPolicy = createTopLevelChannelDmPolicy({
label: "Signal",
channel,
policyKey: "channels.signal.dmPolicy",
allowFromKey: "channels.signal.allowFrom",
getCurrent: (cfg: OpenClawConfig) => cfg.channels?.signal?.dmPolicy ?? "pairing",
setPolicy: (cfg: OpenClawConfig, policy) =>
setChannelDmPolicyWithAllowFrom({
cfg,
channel,
dmPolicy: policy,
}),
promptAllowFrom: promptSignalAllowFrom,
};
});
function resolveSignalCliPath(params: {
cfg: OpenClawConfig;

View File

@@ -1,6 +1,6 @@
import { normalizeAccountId } from "openclaw/plugin-sdk/account-resolution";
import {
listResolvedDirectoryEntriesFromSources,
createResolvedDirectoryEntriesLister,
type DirectoryConfigParams,
} from "openclaw/plugin-sdk/directory-runtime";
import { mergeSlackAccountConfig, resolveDefaultSlackAccountId } from "./accounts.js";
@@ -19,40 +19,38 @@ function resolveSlackDirectoryConfigAccount(
};
}
export async function listSlackDirectoryPeersFromConfig(params: DirectoryConfigParams) {
return listResolvedDirectoryEntriesFromSources({
...params,
kind: "user",
resolveAccount: (cfg, accountId) => resolveSlackDirectoryConfigAccount(cfg, accountId),
resolveSources: (account) => {
const allowFrom = account.config.allowFrom ?? account.dm?.allowFrom ?? [];
const channelUsers = Object.values(account.config.channels ?? {}).flatMap(
(channel) => channel.users ?? [],
);
return [allowFrom, Object.keys(account.config.dms ?? {}), channelUsers];
},
normalizeId: (raw) => {
const mention = raw.match(/^<@([A-Z0-9]+)>$/i);
const normalizedUserId = (mention?.[1] ?? raw).replace(/^(slack|user):/i, "").trim();
if (!normalizedUserId) {
return null;
}
const target = `user:${normalizedUserId}`;
const normalized = parseSlackTarget(target, { defaultKind: "user" });
return normalized?.kind === "user" ? `user:${normalized.id.toLowerCase()}` : null;
},
});
}
export const listSlackDirectoryPeersFromConfig = createResolvedDirectoryEntriesLister<
ReturnType<typeof resolveSlackDirectoryConfigAccount>
>({
kind: "user",
resolveAccount: (cfg, accountId) => resolveSlackDirectoryConfigAccount(cfg, accountId),
resolveSources: (account) => {
const allowFrom = account.config.allowFrom ?? account.dm?.allowFrom ?? [];
const channelUsers = Object.values(account.config.channels ?? {}).flatMap(
(channel) => channel.users ?? [],
);
return [allowFrom, Object.keys(account.config.dms ?? {}), channelUsers];
},
normalizeId: (raw) => {
const mention = raw.match(/^<@([A-Z0-9]+)>$/i);
const normalizedUserId = (mention?.[1] ?? raw).replace(/^(slack|user):/i, "").trim();
if (!normalizedUserId) {
return null;
}
const target = `user:${normalizedUserId}`;
const normalized = parseSlackTarget(target, { defaultKind: "user" });
return normalized?.kind === "user" ? `user:${normalized.id.toLowerCase()}` : null;
},
});
export async function listSlackDirectoryGroupsFromConfig(params: DirectoryConfigParams) {
return listResolvedDirectoryEntriesFromSources({
...params,
kind: "group",
resolveAccount: (cfg, accountId) => resolveSlackDirectoryConfigAccount(cfg, accountId),
resolveSources: (account) => [Object.keys(account.config.channels ?? {})],
normalizeId: (raw) => {
const normalized = parseSlackTarget(raw, { defaultKind: "channel" });
return normalized?.kind === "channel" ? `channel:${normalized.id.toLowerCase()}` : null;
},
});
}
export const listSlackDirectoryGroupsFromConfig = createResolvedDirectoryEntriesLister<
ReturnType<typeof resolveSlackDirectoryConfigAccount>
>({
kind: "group",
resolveAccount: (cfg, accountId) => resolveSlackDirectoryConfigAccount(cfg, accountId),
resolveSources: (account) => [Object.keys(account.config.channels ?? {})],
normalizeId: (raw) => {
const normalized = parseSlackTarget(raw, { defaultKind: "channel" });
return normalized?.kind === "channel" ? `channel:${normalized.id.toLowerCase()}` : null;
},
});

View File

@@ -1,5 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import type { OperatorScope } from "../../src/gateway/method-scopes.js";
import type { OpenClawPluginCommandDefinition } from "../../test/helpers/extensions/plugin-command.js";
import { createPluginRuntimeMock } from "../../test/helpers/extensions/plugin-runtime-mock.js";
import register from "./index.js";
@@ -31,15 +30,13 @@ function createHarness(config: Record<string, unknown>) {
function createCommandContext(
args: string,
channel: string = "discord",
gatewayClientScopes?: OperatorScope[],
gatewayClientScopes?: string[],
) {
return {
args,
surface: channel,
channel,
channelId: channel,
isAuthorizedSender: true,
senderIsOwner: false,
gatewayClientScopes,
commandBody: args ? `/voice ${args}` : "/voice",
config: {},
@@ -50,7 +47,7 @@ function createCommandContext(
}
describe("talk-voice plugin", () => {
function createElevenlabsVoiceSetHarness(channel = "webchat", scopes?: OperatorScope[]) {
function createElevenlabsVoiceSetHarness(channel = "webchat", scopes?: string[]) {
const { command, runtime } = createHarness({
talk: {
provider: "elevenlabs",

View File

@@ -1,13 +1,9 @@
import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
import {
listInspectedDirectoryEntriesFromSources,
type DirectoryConfigParams,
} from "openclaw/plugin-sdk/directory-runtime";
import { createInspectedDirectoryEntriesLister } from "openclaw/plugin-sdk/directory-runtime";
import { inspectTelegramAccount, type InspectedTelegramAccount } from "./account-inspect.js";
export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConfigParams) {
return listInspectedDirectoryEntriesFromSources({
...params,
export const listTelegramDirectoryPeersFromConfig =
createInspectedDirectoryEntriesLister<InspectedTelegramAccount>({
kind: "user",
inspectAccount: (cfg, accountId) =>
inspectTelegramAccount({ cfg, accountId }) as InspectedTelegramAccount | null,
@@ -26,15 +22,12 @@ export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConf
return trimmed.startsWith("@") ? trimmed : `@${trimmed}`;
},
});
}
export async function listTelegramDirectoryGroupsFromConfig(params: DirectoryConfigParams) {
return listInspectedDirectoryEntriesFromSources({
...params,
export const listTelegramDirectoryGroupsFromConfig =
createInspectedDirectoryEntriesLister<InspectedTelegramAccount>({
kind: "group",
inspectAccount: (cfg, accountId) =>
inspectTelegramAccount({ cfg, accountId }) as InspectedTelegramAccount | null,
resolveSources: (account) => [Object.keys(account.config.groups ?? {})],
normalizeId: (entry) => entry.trim() || null,
});
}

View File

@@ -1,15 +1,15 @@
import {
createAllowFromSection,
createTopLevelChannelDmPolicy,
createStandardChannelSetupStatus,
DEFAULT_ACCOUNT_ID,
hasConfiguredSecretInput,
type OpenClawConfig,
patchChannelConfigForAccount,
setChannelDmPolicyWithAllowFrom,
setSetupChannelEnabled,
splitSetupEntries,
} from "openclaw/plugin-sdk/setup";
import type { ChannelSetupDmPolicy, ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
import { inspectTelegramAccount } from "./account-inspect.js";
import {
@@ -76,20 +76,14 @@ function buildTelegramDmAccessWarningLines(accountId: string): string[] {
];
}
const dmPolicy: ChannelSetupDmPolicy = {
const dmPolicy = createTopLevelChannelDmPolicy({
label: "Telegram",
channel,
policyKey: "channels.telegram.dmPolicy",
allowFromKey: "channels.telegram.allowFrom",
getCurrent: (cfg) => cfg.channels?.telegram?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) =>
setChannelDmPolicyWithAllowFrom({
cfg,
channel,
dmPolicy: policy,
}),
promptAllowFrom: promptTelegramAllowFromForAccount,
};
});
export const telegramSetupWizard: ChannelSetupWizard = {
channel,

View File

@@ -5,27 +5,21 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { ensureDirectory, logVerboseCopy, resolveBuildCopyContext } from "./lib/copy-assets.ts";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const verbose = process.env.OPENCLAW_BUILD_VERBOSE === "1";
const context = resolveBuildCopyContext(import.meta.url);
const srcDir = path.join(projectRoot, "src", "auto-reply", "reply", "export-html");
const distDir = path.join(projectRoot, "dist", "export-html");
const srcDir = path.join(context.projectRoot, "src", "auto-reply", "reply", "export-html");
const distDir = path.join(context.projectRoot, "dist", "export-html");
function copyExportHtmlTemplates() {
if (!fs.existsSync(srcDir)) {
console.warn("[copy-export-html-templates] Source directory not found:", srcDir);
console.warn(`${context.prefix} Source directory not found:`, srcDir);
return;
}
// Create dist directory
if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir, { recursive: true });
}
ensureDirectory(distDir);
// Copy main template files
const templateFiles = ["template.html", "template.css", "template.js"];
let copiedCount = 0;
for (const file of templateFiles) {
@@ -34,19 +28,14 @@ function copyExportHtmlTemplates() {
if (fs.existsSync(srcFile)) {
fs.copyFileSync(srcFile, distFile);
copiedCount += 1;
if (verbose) {
console.log(`[copy-export-html-templates] Copied ${file}`);
}
logVerboseCopy(context, `Copied ${file}`);
}
}
// Copy vendor files
const srcVendor = path.join(srcDir, "vendor");
const distVendor = path.join(distDir, "vendor");
if (fs.existsSync(srcVendor)) {
if (!fs.existsSync(distVendor)) {
fs.mkdirSync(distVendor, { recursive: true });
}
ensureDirectory(distVendor);
const vendorFiles = fs.readdirSync(srcVendor);
for (const file of vendorFiles) {
const srcFile = path.join(srcVendor, file);
@@ -54,14 +43,12 @@ function copyExportHtmlTemplates() {
if (fs.statSync(srcFile).isFile()) {
fs.copyFileSync(srcFile, distFile);
copiedCount += 1;
if (verbose) {
console.log(`[copy-export-html-templates] Copied vendor/${file}`);
}
logVerboseCopy(context, `Copied vendor/${file}`);
}
}
}
console.log(`[copy-export-html-templates] Copied ${copiedCount} export-html assets.`);
console.log(`${context.prefix} Copied ${copiedCount} export-html assets.`);
}
copyExportHtmlTemplates();

View File

@@ -5,24 +5,20 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { ensureDirectory, logVerboseCopy, resolveBuildCopyContext } from "./lib/copy-assets.ts";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const verbose = process.env.OPENCLAW_BUILD_VERBOSE === "1";
const context = resolveBuildCopyContext(import.meta.url);
const srcBundled = path.join(projectRoot, "src", "hooks", "bundled");
const distBundled = path.join(projectRoot, "dist", "bundled");
const srcBundled = path.join(context.projectRoot, "src", "hooks", "bundled");
const distBundled = path.join(context.projectRoot, "dist", "bundled");
function copyHookMetadata() {
if (!fs.existsSync(srcBundled)) {
console.warn("[copy-hook-metadata] Source directory not found:", srcBundled);
console.warn(`${context.prefix} Source directory not found:`, srcBundled);
return;
}
if (!fs.existsSync(distBundled)) {
fs.mkdirSync(distBundled, { recursive: true });
}
ensureDirectory(distBundled);
const entries = fs.readdirSync(srcBundled, { withFileTypes: true });
let copiedCount = 0;
@@ -39,22 +35,18 @@ function copyHookMetadata() {
const distHookMd = path.join(distHookDir, "HOOK.md");
if (!fs.existsSync(srcHookMd)) {
console.warn(`[copy-hook-metadata] No HOOK.md found for ${hookName}`);
console.warn(`${context.prefix} No HOOK.md found for ${hookName}`);
continue;
}
if (!fs.existsSync(distHookDir)) {
fs.mkdirSync(distHookDir, { recursive: true });
}
ensureDirectory(distHookDir);
fs.copyFileSync(srcHookMd, distHookMd);
copiedCount += 1;
if (verbose) {
console.log(`[copy-hook-metadata] Copied ${hookName}/HOOK.md`);
}
logVerboseCopy(context, `Copied ${hookName}/HOOK.md`);
}
console.log(`[copy-hook-metadata] Copied ${copiedCount} hook metadata files.`);
console.log(`${context.prefix} Copied ${copiedCount} hook metadata files.`);
}
copyHookMetadata();

View File

@@ -0,0 +1,30 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
export type BuildCopyContext = {
prefix: string;
projectRoot: string;
verbose: boolean;
};
export function resolveBuildCopyContext(importMetaUrl: string): BuildCopyContext {
const filePath = fileURLToPath(importMetaUrl);
return {
prefix: `[${path.basename(filePath, path.extname(filePath))}]`,
projectRoot: path.resolve(path.dirname(filePath), ".."),
verbose: process.env.OPENCLAW_BUILD_VERBOSE === "1",
};
}
export function ensureDirectory(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
export function logVerboseCopy(context: BuildCopyContext, message: string): void {
if (context.verbose) {
console.log(`${context.prefix} ${message}`);
}
}

View File

@@ -231,6 +231,47 @@ describe("runCliAgent with process supervisor", () => {
await loadFreshCliRunnerModuleForTest();
});
it("does not inject hardcoded 'Tools are disabled' text into CLI arguments", async () => {
supervisorSpawnMock.mockResolvedValueOnce(
createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout: "ok",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
}),
);
await runCliAgent({
sessionId: "s1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
prompt: "Run: node script.mjs",
provider: "claude-cli",
model: "sonnet",
timeoutMs: 1_000,
runId: "run-no-tools-disabled",
extraSystemPrompt: "You are a helpful assistant.",
});
expect(supervisorSpawnMock).toHaveBeenCalledTimes(1);
const input = supervisorSpawnMock.mock.calls[0]?.[0] as {
argv?: string[];
input?: string;
};
// Use claude-cli because it defines systemPromptArg ("--append-system-prompt"),
// so the system prompt is serialized into argv. The codex-cli backend lacks
// systemPromptArg, meaning the prompt is dropped before reaching argv —
// making the assertion vacuous. See: openclaw/openclaw#44135
const allArgs = (input.argv ?? []).join("\n");
expect(allArgs).not.toContain("Tools are disabled in this session");
// Verify the user-supplied system prompt IS present (proves the arg path works)
expect(allArgs).toContain("You are a helpful assistant.");
});
it("runs CLI through supervisor and returns payload", async () => {
supervisorSpawnMock.mockResolvedValueOnce(
createManagedRun({

View File

@@ -128,12 +128,7 @@ export async function runCliAgent(params: {
const normalizedModel = normalizeCliModel(modelId, backend);
const modelDisplay = `${params.provider}/${modelId}`;
const extraSystemPrompt = [
params.extraSystemPrompt?.trim(),
"Tools are disabled in this session. Do not call tools.",
]
.filter(Boolean)
.join("\n");
const extraSystemPrompt = params.extraSystemPrompt?.trim() ?? "";
const sessionLabel = params.sessionKey ?? params.sessionId;
const { bootstrapFiles, contextFiles } = await resolveBootstrapContextForRun({

View File

@@ -5,24 +5,9 @@
* This handler is called before built-in command handlers.
*/
import { isOperatorScope, type OperatorScope } from "../../gateway/method-scopes.js";
import { logVerbose } from "../../globals.js";
import { matchPluginCommand, executePluginCommand } from "../../plugins/commands.js";
import type { CommandHandler, CommandHandlerResult } from "./commands-types.js";
function narrowGatewayClientScopes(
scopes: readonly string[] | undefined,
): OperatorScope[] | undefined {
if (!scopes) {
return undefined;
}
const narrowed = scopes.filter((scope) => isOperatorScope(scope));
if (narrowed.length !== scopes.length) {
logVerbose("Plugin command handler ignored unknown gateway scope values");
}
return narrowed.length > 0 ? narrowed : undefined;
}
/**
* Handle plugin-registered commands.
* Returns a result if a plugin command was matched and executed,
@@ -49,12 +34,10 @@ export const handlePluginCommand: CommandHandler = async (
command: match.command,
args: match.args,
senderId: command.senderId,
surface: command.surface,
channel: command.channel,
channelId: command.channelId,
isAuthorizedSender: command.isAuthorizedSender,
senderIsOwner: command.senderIsOwner,
gatewayClientScopes: narrowGatewayClientScopes(params.ctx.GatewayClientScopes),
gatewayClientScopes: params.ctx.GatewayClientScopes,
commandBody: command.commandBodyNormalized,
config: cfg,
from: command.from,

View File

@@ -1,11 +1,11 @@
import fs from "node:fs";
import path from "node:path";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveOpenClawUserDataDir } from "../../extensions/browser/src/browser/chrome.js";
import type {
BrowserRouteContext,
BrowserServerState,
} from "../../extensions/browser/src/browser/server-context.js";
import { resolveOpenClawUserDataDir } from "../../extensions/browser/src/browser/chrome.js";
import { movePathToTrash } from "../../extensions/browser/src/browser/trash.js";
import { loadConfig, writeConfigFile } from "../../extensions/browser/src/config/config.js";

View File

@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import {
createInspectedDirectoryEntriesLister,
createResolvedDirectoryEntriesLister,
listDirectoryEntriesFromSources,
listInspectedDirectoryEntriesFromSources,
listDirectoryGroupEntriesFromMapKeysAndAllowFrom,
@@ -128,6 +130,21 @@ describe("listInspectedDirectoryEntriesFromSources", () => {
});
});
describe("createInspectedDirectoryEntriesLister", () => {
it("builds a reusable inspected-account lister", async () => {
const listGroups = createInspectedDirectoryEntriesLister({
kind: "group",
inspectAccount: () => ({ ids: [["room:a"], ["room:b", "room:a"]] }),
resolveSources: (account) => account.ids,
normalizeId: (entry) => entry.replace(/^room:/i, ""),
});
await expect(listGroups({ cfg: {} as never, query: "a" })).resolves.toEqual([
{ kind: "group", id: "a" },
]);
});
});
describe("resolved account directory helpers", () => {
const cfg = {} as never;
const resolveAccount = () => ({
@@ -174,4 +191,18 @@ describe("resolved account directory helpers", () => {
expectUserDirectoryEntries(entries);
});
it("builds a reusable resolved-account lister", async () => {
const listUsers = createResolvedDirectoryEntriesLister({
kind: "user",
resolveAccount,
resolveSources: (account) => [account.allowFrom, ["user:carla", "user:alice"]],
normalizeId: (entry) => entry.replace(/^user:/i, ""),
});
await expect(listUsers({ cfg, query: "a", limit: 2 })).resolves.toEqual([
{ kind: "user", id: "alice" },
{ kind: "user", id: "carla" },
]);
});
});

View File

@@ -121,6 +121,22 @@ export function listInspectedDirectoryEntriesFromSources<InspectedAccount>(
});
}
export function createInspectedDirectoryEntriesLister<InspectedAccount>(params: {
kind: "user" | "group";
inspectAccount: (
cfg: OpenClawConfig,
accountId?: string | null,
) => InspectedAccount | null | undefined;
resolveSources: (account: InspectedAccount) => Iterable<unknown>[];
normalizeId: (entry: string) => string | null | undefined;
}) {
return async (configParams: DirectoryConfigParams): Promise<ChannelDirectoryEntry[]> =>
listInspectedDirectoryEntriesFromSources({
...configParams,
...params,
});
}
export function listResolvedDirectoryEntriesFromSources<ResolvedAccount>(
params: DirectoryConfigParams & {
kind: "user" | "group";
@@ -139,6 +155,19 @@ export function listResolvedDirectoryEntriesFromSources<ResolvedAccount>(
});
}
export function createResolvedDirectoryEntriesLister<ResolvedAccount>(params: {
kind: "user" | "group";
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => ResolvedAccount;
resolveSources: (account: ResolvedAccount) => Iterable<unknown>[];
normalizeId: (entry: string) => string | null | undefined;
}) {
return async (configParams: DirectoryConfigParams): Promise<ChannelDirectoryEntry[]> =>
listResolvedDirectoryEntriesFromSources({
...configParams,
...params,
});
}
export function listDirectoryUserEntriesFromAllowFrom(params: {
allowFrom?: readonly unknown[];
query?: string | null;

View File

@@ -6,7 +6,7 @@ const loadDotEnvMock = vi.hoisted(() => vi.fn());
const normalizeEnvMock = vi.hoisted(() => vi.fn());
const ensurePathMock = vi.hoisted(() => vi.fn());
const assertRuntimeMock = vi.hoisted(() => vi.fn());
const closeAllMemorySearchManagersMock = vi.hoisted(() => vi.fn(async () => {}));
const closeActiveMemorySearchManagersMock = vi.hoisted(() => vi.fn(async () => {}));
const outputRootHelpMock = vi.hoisted(() => vi.fn());
const buildProgramMock = vi.hoisted(() => vi.fn());
const maybeRunCliInContainerMock = vi.hoisted(() =>
@@ -40,8 +40,8 @@ vi.mock("../infra/runtime-guard.js", () => ({
assertSupportedRuntime: assertRuntimeMock,
}));
vi.mock("../memory/search-manager.js", () => ({
closeAllMemorySearchManagers: closeAllMemorySearchManagersMock,
vi.mock("../plugins/memory-runtime.js", () => ({
closeActiveMemorySearchManagers: closeActiveMemorySearchManagersMock,
}));
vi.mock("./program/root-help.js", () => ({
@@ -69,7 +69,7 @@ describe("runCli exit behavior", () => {
expect(maybeRunCliInContainerMock).toHaveBeenCalledWith(["node", "openclaw", "status"]);
expect(tryRouteCliMock).toHaveBeenCalledWith(["node", "openclaw", "status"]);
expect(closeAllMemorySearchManagersMock).toHaveBeenCalledTimes(1);
expect(closeActiveMemorySearchManagersMock).toHaveBeenCalledTimes(1);
expect(exitSpy).not.toHaveBeenCalled();
exitSpy.mockRestore();
});
@@ -85,7 +85,7 @@ describe("runCli exit behavior", () => {
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(outputRootHelpMock).toHaveBeenCalledTimes(1);
expect(buildProgramMock).not.toHaveBeenCalled();
expect(closeAllMemorySearchManagersMock).toHaveBeenCalledTimes(1);
expect(closeActiveMemorySearchManagersMock).toHaveBeenCalledTimes(1);
expect(exitSpy).not.toHaveBeenCalled();
exitSpy.mockRestore();
});
@@ -104,7 +104,7 @@ describe("runCli exit behavior", () => {
]);
expect(loadDotEnvMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(closeAllMemorySearchManagersMock).not.toHaveBeenCalled();
expect(closeActiveMemorySearchManagersMock).not.toHaveBeenCalled();
});
it("propagates a handled container-target exit code", async () => {

View File

@@ -20,8 +20,8 @@ import { normalizeWindowsArgv } from "./windows-argv.js";
async function closeCliMemoryManagers(): Promise<void> {
try {
const { closeAllMemorySearchManagers } = await import("../memory/search-manager.js");
await closeAllMemorySearchManagers();
const { closeActiveMemorySearchManagers } = await import("../plugins/memory-runtime.js");
await closeActiveMemorySearchManagers();
} catch {
// Best-effort teardown for short-lived CLI processes.
}

View File

@@ -523,7 +523,6 @@ describe("normalizeCompatibilityConfigValues", () => {
});
expect(res.config.talk).toEqual({
provider: "elevenlabs",
providers: {
elevenlabs: {
voiceId: "voice-123",
@@ -545,9 +544,7 @@ describe("normalizeCompatibilityConfigValues", () => {
interruptOnSpeech: false,
silenceTimeoutMs: 1500,
});
expect(res.changes).toEqual([
"Moved legacy talk flat fields → talk.provider/talk.providers.elevenlabs.",
]);
expect(res.changes).toEqual(["Moved legacy talk flat fields → talk.providers.elevenlabs."]);
});
it("normalizes talk provider ids without overriding explicit provider config", () => {

View File

@@ -11,7 +11,7 @@ import {
resolveTelegramPreviewStreamMode,
} from "../config/discord-preview-streaming.js";
import { migrateLegacyWebSearchConfig } from "../config/legacy-web-search.js";
import { DEFAULT_TALK_PROVIDER, normalizeTalkSection } from "../config/talk.js";
import { LEGACY_TALK_PROVIDER_ID, normalizeTalkSection } from "../config/talk.js";
import { DEFAULT_GOOGLE_API_BASE_URL } from "../infra/google-api-base-url.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
@@ -651,9 +651,7 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
return;
}
changes.push(
`Moved legacy talk flat fields → talk.provider/talk.providers.${DEFAULT_TALK_PROVIDER}.`,
);
changes.push(`Moved legacy talk flat fields → talk.providers.${LEGACY_TALK_PROVIDER_ID}.`);
};
const normalizeLegacyCrossContextMessageConfig = () => {

View File

@@ -482,7 +482,7 @@ describe("config strict validation", () => {
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(false);
expect(snap.valid).toBe(true);
expect(snap.legacyIssues).not.toHaveLength(0);
});
});
@@ -517,7 +517,7 @@ describe("config strict validation", () => {
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(false);
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "gateway.bind")).toBe(true);
});
});

View File

@@ -68,7 +68,7 @@ function expectRoutingAllowFromLegacySnapshot(
ctx: { snapshot: ConfigSnapshot; parsed: unknown },
expectedAllowFrom: string[],
) {
expect(ctx.snapshot.valid).toBe(false);
expect(ctx.snapshot.valid).toBe(true);
expect(ctx.snapshot.legacyIssues.some((issue) => issue.path === "routing.allowFrom")).toBe(true);
const parsed = ctx.parsed as {
routing?: { allowFrom?: string[] };
@@ -269,7 +269,7 @@ describe("legacy config detection", () => {
await withSnapshotForConfig(
{ memorySearch: { provider: "local", fallback: "none" } },
async (ctx) => {
expect(ctx.snapshot.valid).toBe(false);
expect(ctx.snapshot.valid).toBe(true);
expect(ctx.snapshot.legacyIssues.some((issue) => issue.path === "memorySearch")).toBe(true);
},
);
@@ -278,14 +278,14 @@ describe("legacy config detection", () => {
await withSnapshotForConfig(
{ heartbeat: { model: "anthropic/claude-3-5-haiku-20241022", every: "30m" } },
async (ctx) => {
expect(ctx.snapshot.valid).toBe(false);
expect(ctx.snapshot.valid).toBe(true);
expect(ctx.snapshot.legacyIssues.some((issue) => issue.path === "heartbeat")).toBe(true);
},
);
});
it("flags legacy provider sections in snapshot", async () => {
await withSnapshotForConfig({ whatsapp: { allowFrom: ["+1555"] } }, async (ctx) => {
expect(ctx.snapshot.valid).toBe(false);
expect(ctx.snapshot.valid).toBe(true);
expect(ctx.snapshot.legacyIssues.some((issue) => issue.path === "whatsapp")).toBe(true);
const parsed = ctx.parsed as {

View File

@@ -3,7 +3,7 @@ import { normalizeProviderId, parseModelRef } from "../agents/model-selection.js
import { DEFAULT_AGENT_MAX_CONCURRENT, DEFAULT_SUBAGENT_MAX_CONCURRENT } from "./agent-limits.js";
import { resolveAgentModelPrimaryValue } from "./model-input.js";
import {
DEFAULT_TALK_PROVIDER,
LEGACY_TALK_PROVIDER_ID,
normalizeTalkConfig,
resolveActiveTalkProviderConfig,
resolveTalkApiKey,
@@ -204,7 +204,7 @@ export function applyTalkApiKey(config: OpenClawConfig): OpenClawConfig {
const talk = normalized.talk;
const active = resolveActiveTalkProviderConfig(talk);
if (active?.provider && active.provider !== DEFAULT_TALK_PROVIDER) {
if (!active || active.provider !== LEGACY_TALK_PROVIDER_ID) {
return normalized;
}
@@ -214,7 +214,7 @@ export function applyTalkApiKey(config: OpenClawConfig): OpenClawConfig {
return normalized;
}
const providerId = active?.provider ?? DEFAULT_TALK_PROVIDER;
const providerId = active.provider;
const providers = { ...talk?.providers };
const providerConfig = { ...providers[providerId], apiKey: resolved };
providers[providerId] = providerConfig;
@@ -222,7 +222,6 @@ export function applyTalkApiKey(config: OpenClawConfig): OpenClawConfig {
const nextTalk = {
...talk,
apiKey: resolved,
provider: talk?.provider ?? providerId,
providers,
};

View File

@@ -41,6 +41,7 @@ import {
readConfigIncludeFileWithGuards,
resolveConfigIncludes,
} from "./includes.js";
import { migrateLegacyConfig } from "./legacy-migrate.js";
import { findLegacyConfigIssues } from "./legacy.js";
import { applyMergePatch } from "./merge-patch.js";
import { normalizeExecSafeBinProfilesInConfig } from "./normalize-exec-safe-bin.js";
@@ -1185,6 +1186,11 @@ type ConfigReadResolution = {
envWarnings: EnvSubstitutionWarning[];
};
type LegacyMigrationResolution = {
effectiveConfigRaw: unknown;
sourceLegacyIssues: LegacyConfigIssue[];
};
function resolveConfigIncludesForRead(
parsed: unknown,
configPath: string,
@@ -1225,6 +1231,21 @@ function resolveConfigForRead(
};
}
function resolveLegacyConfigForRead(
resolvedConfigRaw: unknown,
sourceRaw: unknown,
): LegacyMigrationResolution {
const sourceLegacyIssues = findLegacyConfigIssues(resolvedConfigRaw, sourceRaw);
if (sourceLegacyIssues.length === 0) {
return { effectiveConfigRaw: resolvedConfigRaw, sourceLegacyIssues };
}
const migrated = migrateLegacyConfig(resolvedConfigRaw);
return {
effectiveConfigRaw: migrated.config ?? resolvedConfigRaw,
sourceLegacyIssues,
};
}
type ReadConfigFileSnapshotInternalResult = {
snapshot: ConfigFileSnapshot;
envSnapshotForRestore?: Record<string, string | undefined>;
@@ -1275,13 +1296,15 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
deps.env,
);
const resolvedConfig = readResolution.resolvedConfigRaw;
const legacyResolution = resolveLegacyConfigForRead(resolvedConfig, parsed);
const effectiveConfigRaw = legacyResolution.effectiveConfigRaw;
for (const w of readResolution.envWarnings) {
deps.logger.warn(
`Config (${configPath}): missing env var "${w.varName}" at ${w.configPath} — feature using this value will be unavailable`,
);
}
warnOnConfigMiskeys(resolvedConfig, deps.logger);
if (typeof resolvedConfig !== "object" || resolvedConfig === null) {
warnOnConfigMiskeys(effectiveConfigRaw, deps.logger);
if (typeof effectiveConfigRaw !== "object" || effectiveConfigRaw === null) {
observeLoadConfigSnapshot({
path: configPath,
exists: true,
@@ -1293,31 +1316,31 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
hash,
issues: [],
warnings: [],
legacyIssues: [],
legacyIssues: legacyResolution.sourceLegacyIssues,
});
return {};
}
const preValidationDuplicates = findDuplicateAgentDirs(resolvedConfig as OpenClawConfig, {
const preValidationDuplicates = findDuplicateAgentDirs(effectiveConfigRaw as OpenClawConfig, {
env: deps.env,
homedir: deps.homedir,
});
if (preValidationDuplicates.length > 0) {
throw new DuplicateAgentDirError(preValidationDuplicates);
}
const validated = validateConfigObjectWithPlugins(resolvedConfig, { env: deps.env });
const validated = validateConfigObjectWithPlugins(effectiveConfigRaw, { env: deps.env });
if (!validated.ok) {
observeLoadConfigSnapshot({
path: configPath,
exists: true,
raw,
parsed,
resolved: coerceConfig(resolvedConfig),
resolved: coerceConfig(effectiveConfigRaw),
valid: false,
config: coerceConfig(resolvedConfig),
config: coerceConfig(effectiveConfigRaw),
hash,
issues: validated.issues,
warnings: validated.warnings,
legacyIssues: findLegacyConfigIssues(resolvedConfig, parsed),
legacyIssues: legacyResolution.sourceLegacyIssues,
});
const details = validated.issues
.map(
@@ -1362,13 +1385,13 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
exists: true,
raw,
parsed,
resolved: coerceConfig(resolvedConfig),
resolved: coerceConfig(effectiveConfigRaw),
valid: true,
config: cfg,
hash,
issues: [],
warnings: validated.warnings,
legacyIssues: findLegacyConfigIssues(resolvedConfig, parsed),
legacyIssues: legacyResolution.sourceLegacyIssues,
});
const duplicates = findDuplicateAgentDirs(cfg, {
@@ -1536,11 +1559,10 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
}));
const resolvedConfigRaw = readResolution.resolvedConfigRaw;
// Detect legacy keys on resolved config, but only mark source-literal legacy
// entries (for auto-migration) when they are present in the parsed source.
const legacyIssues = findLegacyConfigIssues(resolvedConfigRaw, parsedRes.parsed);
const legacyResolution = resolveLegacyConfigForRead(resolvedConfigRaw, parsedRes.parsed);
const effectiveConfigRaw = legacyResolution.effectiveConfigRaw;
const validated = validateConfigObjectWithPlugins(resolvedConfigRaw, { env: deps.env });
const validated = validateConfigObjectWithPlugins(effectiveConfigRaw, { env: deps.env });
if (!validated.ok) {
return await finalizeReadConfigSnapshotInternalResult(deps, {
snapshot: {
@@ -1548,13 +1570,13 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
exists: true,
raw,
parsed: parsedRes.parsed,
resolved: coerceConfig(resolvedConfigRaw),
resolved: coerceConfig(effectiveConfigRaw),
valid: false,
config: coerceConfig(resolvedConfigRaw),
config: coerceConfig(effectiveConfigRaw),
hash,
issues: validated.issues,
warnings: [...validated.warnings, ...envVarWarnings],
legacyIssues,
legacyIssues: legacyResolution.sourceLegacyIssues,
},
});
}
@@ -1580,13 +1602,13 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
parsed: parsedRes.parsed,
// Use resolvedConfigRaw (after $include and ${ENV} substitution but BEFORE runtime defaults)
// for config set/unset operations (issue #6070)
resolved: coerceConfig(resolvedConfigRaw),
resolved: coerceConfig(effectiveConfigRaw),
valid: true,
config: snapshotConfig,
hash,
issues: [],
warnings: [...validated.warnings, ...envVarWarnings],
legacyIssues,
legacyIssues: legacyResolution.sourceLegacyIssues,
},
envSnapshotForRestore: readResolution.envSnapshotForRestore,
});

View File

@@ -36,7 +36,6 @@ describe("talk normalization", () => {
});
expect(normalized).toEqual({
provider: "elevenlabs",
providers: {
elevenlabs: {
voiceId: "voice-123",
@@ -149,7 +148,7 @@ describe("talk normalization", () => {
async (configPath) => {
const io = createConfigIO({ configPath });
const snapshot = await io.readConfigFileSnapshot();
expect(snapshot.config.talk?.provider).toBe("elevenlabs");
expect(snapshot.config.talk?.provider).toBeUndefined();
expect(snapshot.config.talk?.providers?.elevenlabs?.voiceId).toBe("voice-123");
expect(snapshot.config.talk?.providers?.elevenlabs?.apiKey).toBe(elevenLabsApiKey);
expect(snapshot.config.talk?.apiKey).toBe(elevenLabsApiKey);

View File

@@ -16,7 +16,7 @@ type TalkApiKeyDeps = {
path?: typeof path;
};
export const DEFAULT_TALK_PROVIDER = "elevenlabs";
export const LEGACY_TALK_PROVIDER_ID = "elevenlabs";
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -225,19 +225,13 @@ export function normalizeTalkSection(value: TalkConfig | undefined): TalkConfig
}
if (provider) {
normalized.provider = provider;
} else if (providers) {
const ids = Object.keys(providers);
if (ids.length === 1) {
normalized.provider = ids[0];
}
}
return Object.keys(normalized).length > 0 ? normalized : undefined;
}
const legacyProviderConfig = legacyProviderConfigFromTalk(source);
if (legacyProviderConfig) {
normalized.provider = DEFAULT_TALK_PROVIDER;
normalized.providers = { [DEFAULT_TALK_PROVIDER]: legacyProviderConfig };
normalized.providers = { [LEGACY_TALK_PROVIDER_ID]: legacyProviderConfig };
}
return Object.keys(normalized).length > 0 ? normalized : undefined;
}

View File

@@ -27,22 +27,8 @@ export type TtsModelOverrideConfig = {
export type TtsProviderConfigMap = Record<string, Record<string, unknown>>;
export type TtsConfig = {
/** Auto-TTS mode (preferred). */
auto?: TtsAutoMode;
/** Legacy: enable auto-TTS when `auto` is not set. */
enabled?: boolean;
/** Apply TTS to final replies only or to all replies (tool/block/final). */
mode?: TtsMode;
/** Primary TTS provider (fallbacks are automatic). */
provider?: TtsProvider;
/** Optional model override for TTS auto-summary (provider/model or alias). */
summaryModel?: string;
/** Allow the model to override TTS parameters. */
modelOverrides?: TtsModelOverrideConfig;
/** Provider-specific TTS settings keyed by speech provider id. */
providers?: TtsProviderConfigMap;
/** ElevenLabs configuration. */
export type LegacyTtsConfigCompat = {
/** Legacy ElevenLabs configuration. Prefer providers.elevenlabs. */
elevenlabs?: {
apiKey?: SecretInput;
baseUrl?: string;
@@ -59,7 +45,7 @@ export type TtsConfig = {
speed?: number;
};
};
/** OpenAI configuration. */
/** Legacy OpenAI configuration. Prefer providers.openai. */
openai?: {
apiKey?: SecretInput;
baseUrl?: string;
@@ -70,7 +56,7 @@ export type TtsConfig = {
/** System-level instructions for the TTS model (gpt-4o-mini-tts only). */
instructions?: string;
};
/** Legacy alias for Microsoft speech configuration. */
/** Legacy alias for Microsoft speech configuration. Prefer providers.microsoft. */
edge?: {
/** Explicitly allow Microsoft speech usage (no API key required). */
enabled?: boolean;
@@ -84,7 +70,7 @@ export type TtsConfig = {
proxy?: string;
timeoutMs?: number;
};
/** Preferred alias for Microsoft speech configuration. */
/** Legacy Microsoft speech configuration. Prefer providers.microsoft. */
microsoft?: {
enabled?: boolean;
voice?: string;
@@ -97,6 +83,23 @@ export type TtsConfig = {
proxy?: string;
timeoutMs?: number;
};
};
export type TtsConfig = LegacyTtsConfigCompat & {
/** Auto-TTS mode (preferred). */
auto?: TtsAutoMode;
/** Legacy: enable auto-TTS when `auto` is not set. */
enabled?: boolean;
/** Apply TTS to final replies only or to all replies (tool/block/final). */
mode?: TtsMode;
/** Primary TTS provider (fallbacks are automatic). */
provider?: TtsProvider;
/** Optional model override for TTS auto-summary (provider/model or alias). */
summaryModel?: string;
/** Allow the model to override TTS parameters. */
modelOverrides?: TtsModelOverrideConfig;
/** Provider-specific TTS settings keyed by speech provider id. */
providers?: TtsProviderConfigMap;
/** Optional path for local TTS user preferences JSON. */
prefsPath?: string;
/** Hard cap for text sent to TTS (chars). */

View File

@@ -6,14 +6,6 @@ export const WRITE_SCOPE = "operator.write" as const;
export const APPROVALS_SCOPE = "operator.approvals" as const;
export const PAIRING_SCOPE = "operator.pairing" as const;
const ALL_OPERATOR_SCOPES = [
ADMIN_SCOPE,
READ_SCOPE,
WRITE_SCOPE,
APPROVALS_SCOPE,
PAIRING_SCOPE,
] as const;
export type OperatorScope =
| typeof ADMIN_SCOPE
| typeof READ_SCOPE
@@ -21,14 +13,13 @@ export type OperatorScope =
| typeof APPROVALS_SCOPE
| typeof PAIRING_SCOPE;
const OPERATOR_SCOPE_SET = new Set<string>(ALL_OPERATOR_SCOPES);
export const CLI_DEFAULT_OPERATOR_SCOPES: OperatorScope[] = [...ALL_OPERATOR_SCOPES];
/** Narrow an arbitrary scope string to a known operator scope. */
export function isOperatorScope(scope: string): scope is OperatorScope {
return OPERATOR_SCOPE_SET.has(scope);
}
export const CLI_DEFAULT_OPERATOR_SCOPES: OperatorScope[] = [
ADMIN_SCOPE,
READ_SCOPE,
WRITE_SCOPE,
APPROVALS_SCOPE,
PAIRING_SCOPE,
];
const NODE_ROLE_METHODS = new Set([
"node.invoke.result",

View File

@@ -2,11 +2,14 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, expect, vi, type Mock } from "vitest";
import type {
MemoryIndexManager,
MemorySearchManager,
} from "../../extensions/memory-core/src/memory/index.js";
import type { OpenClawConfig } from "../config/config.js";
import type { MemoryIndexManager, MemorySearchManager } from "./index.js";
type EmbeddingTestMocksModule = typeof import("./embedding.test-mocks.js");
type MemoryIndexModule = typeof import("./index.js");
type MemoryIndexModule = typeof import("../../extensions/memory-core/src/memory/index.js");
export function installEmbeddingManagerFixture(opts: {
fixturePrefix: string;
@@ -61,7 +64,7 @@ export function installEmbeddingManagerFixture(opts: {
const embeddingMocks = await import("./embedding.test-mocks.js");
embedBatch = embeddingMocks.getEmbedBatchMock();
resetEmbeddingMocks = embeddingMocks.resetEmbeddingMocks;
({ getMemorySearchManager } = await import("./index.js"));
({ getMemorySearchManager } = await import("../../extensions/memory-core/src/memory/index.js"));
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), opts.fixturePrefix));
workspaceDir = path.join(fixtureRoot, "workspace");
memoryDir = path.join(workspaceDir, "memory");

View File

@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { bm25RankToScore, buildFtsQuery, mergeHybridResults } from "./hybrid.js";
import {
bm25RankToScore,
buildFtsQuery,
mergeHybridResults,
} from "../../extensions/memory-core/src/memory/hybrid.js";
describe("memory hybrid helpers", () => {
it("buildFtsQuery tokenizes and AND-joins", () => {

View File

@@ -5,9 +5,9 @@ import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import "./test-runtime-mocks.js";
import type { MemoryIndexManager } from "./index.js";
import type { MemoryIndexManager } from "../../extensions/memory-core/src/memory/index.js";
type MemoryIndexModule = typeof import("./index.js");
type MemoryIndexModule = typeof import("../../extensions/memory-core/src/memory/index.js");
let getMemorySearchManager: MemoryIndexModule["getMemorySearchManager"];
let closeAllMemorySearchManagers: MemoryIndexModule["closeAllMemorySearchManagers"];
@@ -131,7 +131,8 @@ describe("memory index", () => {
beforeAll(async () => {
vi.resetModules();
await import("./test-runtime-mocks.js");
({ getMemorySearchManager, closeAllMemorySearchManagers } = await import("./index.js"));
({ getMemorySearchManager, closeAllMemorySearchManagers } =
await import("../../extensions/memory-core/src/memory/index.js"));
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-fixtures-"));
workspaceDir = path.join(fixtureRoot, "workspace");
memoryDir = path.join(workspaceDir, "memory");

View File

@@ -2,9 +2,9 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MemoryIndexManager } from "../../extensions/memory-core/src/memory/index.js";
import { closeAllMemorySearchManagers } from "../../extensions/memory-core/src/memory/index.js";
import type { OpenClawConfig } from "../config/config.js";
import type { MemoryIndexManager } from "./index.js";
import { closeAllMemorySearchManagers } from "./index.js";
import { createOpenAIEmbeddingProviderMock } from "./test-embeddings-mock.js";
import { createMemoryManagerOrThrow } from "./test-manager.js";

View File

@@ -2,14 +2,14 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { MemoryIndexManager } from "../../extensions/memory-core/src/memory/index.js";
import type { OpenClawConfig } from "../config/config.js";
import type { MemoryIndexManager } from "./index.js";
let shouldFail = false;
type EmbeddingTestMocksModule = typeof import("./embedding.test-mocks.js");
type TestManagerHelpersModule = typeof import("./test-manager-helpers.js");
type MemoryIndexModule = typeof import("./index.js");
type MemoryIndexModule = typeof import("../../extensions/memory-core/src/memory/index.js");
describe("memory manager atomic reindex", () => {
let fixtureRoot = "";
@@ -32,7 +32,8 @@ describe("memory manager atomic reindex", () => {
embedBatch = embeddingMocks.getEmbedBatchMock();
resetEmbeddingMocks = embeddingMocks.resetEmbeddingMocks;
({ getRequiredMemoryIndexManager } = await import("./test-manager-helpers.js"));
({ closeAllMemorySearchManagers } = await import("./index.js"));
({ closeAllMemorySearchManagers } =
await import("../../extensions/memory-core/src/memory/index.js"));
vi.stubEnv("OPENCLAW_TEST_MEMORY_UNSAFE_REINDEX", "0");
resetEmbeddingMocks();
shouldFail = false;

View File

@@ -7,8 +7,9 @@ import type { OpenClawConfig } from "../config/config.js";
import { createOpenAIEmbeddingProviderMock } from "./test-embeddings-mock.js";
import { mockPublicPinnedHostname } from "./test-helpers/ssrf.js";
type MemoryIndexManager = import("./index.js").MemoryIndexManager;
type MemoryIndexModule = typeof import("./index.js");
type MemoryIndexManager =
import("../../extensions/memory-core/src/memory/index.js").MemoryIndexManager;
type MemoryIndexModule = typeof import("../../extensions/memory-core/src/memory/index.js");
const embedBatch = vi.fn(async (_texts: string[]) => [] as number[][]);
const embedQuery = vi.fn(async () => [0.5, 0.5, 0.5]);
@@ -121,7 +122,7 @@ describe("memory indexing with OpenAI batches", () => {
}),
}));
await import("./test-runtime-mocks.js");
({ getMemorySearchManager } = await import("./index.js"));
({ getMemorySearchManager } = await import("../../extensions/memory-core/src/memory/index.js"));
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-batch-"));
workspaceDir = path.join(fixtureRoot, "workspace");

View File

@@ -3,12 +3,12 @@ import os from "node:os";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { MemoryIndexManager } from "../../extensions/memory-core/src/memory/index.js";
import "./test-runtime-mocks.js";
import type { MemoryIndexManager } from "./index.js";
import type { OpenClawConfig } from "../config/config.js";
type MemoryIndexModule = typeof import("./index.js");
type ManagerModule = typeof import("./manager.js");
type MemoryIndexModule = typeof import("../../extensions/memory-core/src/memory/index.js");
type ManagerModule = typeof import("../../extensions/memory-core/src/memory/manager.js");
const hoisted = vi.hoisted(() => ({
providerCreateCalls: 0,
@@ -43,9 +43,10 @@ describe("memory manager cache hydration", () => {
let workspaceDir = "";
beforeAll(async () => {
({ getMemorySearchManager, closeAllMemorySearchManagers } = await import("./index.js"));
({ getMemorySearchManager, closeAllMemorySearchManagers } =
await import("../../extensions/memory-core/src/memory/index.js"));
({ closeAllMemoryIndexManagers, MemoryIndexManager: RawMemoryIndexManager } =
await import("./manager.js"));
await import("../../extensions/memory-core/src/memory/manager.js"));
});
beforeEach(async () => {

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MemoryIndexManager } from "../../extensions/memory-core/src/memory/index.js";
import type { OpenClawConfig } from "../config/config.js";
import { DEFAULT_OLLAMA_EMBEDDING_MODEL } from "./embeddings-ollama.js";
import type {
@@ -11,7 +12,6 @@ import type {
OllamaEmbeddingClient,
OpenAiEmbeddingClient,
} from "./embeddings.js";
import type { MemoryIndexManager } from "./index.js";
const { createEmbeddingProviderMock } = vi.hoisted(() => ({
createEmbeddingProviderMock: vi.fn(),
@@ -25,7 +25,7 @@ vi.mock("./sqlite-vec.js", () => ({
loadSqliteVecExtension: async () => ({ ok: false, error: "sqlite-vec disabled in tests" }),
}));
type MemoryIndexModule = typeof import("./index.js");
type MemoryIndexModule = typeof import("../../extensions/memory-core/src/memory/index.js");
let getMemorySearchManager: MemoryIndexModule["getMemorySearchManager"];
let closeAllMemorySearchManagers: MemoryIndexModule["closeAllMemorySearchManagers"];
@@ -78,7 +78,8 @@ describe("memory manager mistral provider wiring", () => {
beforeEach(async () => {
vi.resetModules();
({ getMemorySearchManager, closeAllMemorySearchManagers } = await import("./index.js"));
({ getMemorySearchManager, closeAllMemorySearchManagers } =
await import("../../extensions/memory-core/src/memory/index.js"));
vi.clearAllMocks();
createEmbeddingProviderMock.mockReset();
workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-mistral-"));

View File

@@ -2,9 +2,9 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import type { MemoryIndexManager } from "../../extensions/memory-core/src/memory/index.js";
import type { OpenClawConfig } from "../config/config.js";
import { resetEmbeddingMocks } from "./embedding.test-mocks.js";
import type { MemoryIndexManager } from "./index.js";
import { getRequiredMemoryIndexManager } from "./test-manager-helpers.js";
function createMemorySearchCfg(options: {

View File

@@ -3,9 +3,9 @@ import os from "node:os";
import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MemoryIndexManager } from "../../extensions/memory-core/src/memory/manager.js";
import type { OpenClawConfig } from "../config/config.js";
import { resetEmbeddingMocks } from "./embedding.test-mocks.js";
import { MemoryIndexManager } from "./manager.js";
import { getRequiredMemoryIndexManager } from "./test-manager-helpers.js";
type ReadonlyRecoveryHarness = {

View File

@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { runDetachedMemorySync } from "./manager-sync-ops.js";
import { runDetachedMemorySync } from "../../extensions/memory-core/src/memory/manager-sync-ops.js";
describe("memory manager sync failures", () => {
beforeEach(() => {

View File

@@ -2,8 +2,8 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MemoryIndexManager } from "../../extensions/memory-core/src/memory/index.js";
import type { OpenClawConfig } from "../config/config.js";
import type { MemoryIndexManager } from "./index.js";
vi.mock("./embeddings.js", () => {
return {
@@ -21,7 +21,7 @@ vi.mock("./embeddings.js", () => {
type MemoryInternalModule = typeof import("./internal.js");
type TestManagerModule = typeof import("./test-manager.js");
type MemoryIndexModule = typeof import("./index.js");
type MemoryIndexModule = typeof import("../../extensions/memory-core/src/memory/index.js");
let buildFileEntry: MemoryInternalModule["buildFileEntry"];
let createMemoryManagerOrThrow: TestManagerModule["createMemoryManagerOrThrow"];
@@ -57,7 +57,8 @@ describe("memory vector dedupe", () => {
vi.resetModules();
({ buildFileEntry } = await import("./internal.js"));
({ createMemoryManagerOrThrow } = await import("./test-manager.js"));
({ closeAllMemorySearchManagers } = await import("./index.js"));
({ closeAllMemorySearchManagers } =
await import("../../extensions/memory-core/src/memory/index.js"));
workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-"));
indexPath = path.join(workspaceDir, "index.sqlite");
await seedMemoryWorkspace(workspaceDir);

View File

@@ -2,9 +2,9 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MemoryIndexManager } from "../../extensions/memory-core/src/memory/index.js";
import type { OpenClawConfig } from "../config/config.js";
import type { MemorySearchConfig } from "../config/types.tools.js";
import type { MemoryIndexManager } from "./index.js";
const { watchMock } = vi.hoisted(() => ({
watchMock: vi.fn(() => ({
@@ -34,7 +34,7 @@ vi.mock("./embeddings.js", () => ({
}),
}));
type MemoryIndexModule = typeof import("./index.js");
type MemoryIndexModule = typeof import("../../extensions/memory-core/src/memory/index.js");
let getMemorySearchManager: MemoryIndexModule["getMemorySearchManager"];
let closeAllMemorySearchManagers: MemoryIndexModule["closeAllMemorySearchManagers"];
@@ -46,7 +46,8 @@ describe("memory watcher config", () => {
beforeEach(async () => {
vi.resetModules();
({ getMemorySearchManager, closeAllMemorySearchManagers } = await import("./index.js"));
({ getMemorySearchManager, closeAllMemorySearchManagers } =
await import("../../extensions/memory-core/src/memory/index.js"));
vi.clearAllMocks();
});

View File

@@ -8,7 +8,7 @@ import {
applyMMRToHybridResults,
DEFAULT_MMR_CONFIG,
type MMRItem,
} from "./mmr.js";
} from "../../extensions/memory-core/src/memory/mmr.js";
describe("tokenize", () => {
it("normalizes, filters, and deduplicates token sets", () => {

View File

@@ -88,9 +88,9 @@ vi.mock("node:child_process", async (importOriginal) => {
});
import { spawn as mockedSpawn } from "node:child_process";
import { QmdMemoryManager } from "../../extensions/memory-core/src/memory/qmd-manager.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveMemoryBackendConfig } from "./backend-config.js";
import { QmdMemoryManager } from "./qmd-manager.js";
import { requireNodeSqlite } from "./sqlite.js";
const spawnMock = mockedSpawn as unknown as Mock;

View File

@@ -96,7 +96,7 @@ const fallbackSearch = fallbackManager.search;
const mockMemoryIndexGet = vi.hoisted(() => vi.fn(async () => fallbackManager));
const mockCloseAllMemoryIndexManagers = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("./qmd-manager.js", () => ({
vi.mock("../../extensions/memory-core/src/memory/qmd-manager.js", () => ({
QmdMemoryManager: {
create: vi.fn(async () => mockPrimary),
},
@@ -109,8 +109,11 @@ vi.mock("./manager-runtime.js", () => ({
closeAllMemoryIndexManagers: mockCloseAllMemoryIndexManagers,
}));
import { QmdMemoryManager } from "./qmd-manager.js";
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./search-manager.js";
import { QmdMemoryManager } from "../../extensions/memory-core/src/memory/qmd-manager.js";
import {
closeAllMemorySearchManagers,
getMemorySearchManager,
} from "../../extensions/memory-core/src/memory/search-manager.js";
// eslint-disable-next-line @typescript-eslint/unbound-method -- mocked static function
const createQmdManagerMock = vi.mocked(QmdMemoryManager.create);

View File

@@ -2,12 +2,12 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { mergeHybridResults } from "./hybrid.js";
import { mergeHybridResults } from "../../extensions/memory-core/src/memory/hybrid.js";
import {
applyTemporalDecayToHybridResults,
applyTemporalDecayToScore,
calculateTemporalDecayMultiplier,
} from "./temporal-decay.js";
} from "../../extensions/memory-core/src/memory/temporal-decay.js";
const DAY_MS = 24 * 60 * 60 * 1000;
const NOW_MS = Date.UTC(2026, 1, 10, 0, 0, 0);

View File

@@ -1,5 +1,5 @@
import type { MemoryIndexManager } from "../../extensions/memory-core/src/memory/index.js";
import type { OpenClawConfig } from "../config/config.js";
import type { MemoryIndexManager } from "./index.js";
export async function getRequiredMemoryIndexManager(params: {
cfg: OpenClawConfig;
@@ -7,7 +7,8 @@ export async function getRequiredMemoryIndexManager(params: {
purpose?: "default" | "status";
}): Promise<MemoryIndexManager> {
await import("./embedding.test-mocks.js");
const { getMemorySearchManager } = await import("./index.js");
const { getMemorySearchManager } =
await import("../../extensions/memory-core/src/memory/index.js");
const result = await getMemorySearchManager({
cfg: params.cfg,
agentId: params.agentId ?? "main",

View File

@@ -1,5 +1,8 @@
import {
getMemorySearchManager,
type MemoryIndexManager,
} from "../../extensions/memory-core/src/memory/index.js";
import type { OpenClawConfig } from "../config/config.js";
import { getMemorySearchManager, type MemoryIndexManager } from "./index.js";
export async function createMemoryManagerOrThrow(
cfg: OpenClawConfig,

View File

@@ -14,6 +14,8 @@ export {
export {
applyDirectoryQueryAndLimit,
collectNormalizedDirectoryIds,
createInspectedDirectoryEntriesLister,
createResolvedDirectoryEntriesLister,
listDirectoryEntriesFromSources,
listDirectoryGroupEntriesFromMapKeys,
listDirectoryGroupEntriesFromMapKeysAndAllowFrom,

View File

@@ -5,11 +5,15 @@ export type { AnyAgentTool } from "../agents/tools/common.js";
export { resolveCronStyleNow } from "../agents/current-time.js";
export { DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR } from "../agents/pi-settings.js";
export {
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
resolveSessionAgentId,
} from "../agents/agent-scope.js";
export { resolveMemorySearchConfig } from "../agents/memory-search.js";
export {
resolveMemorySearchConfig,
type ResolvedMemorySearchConfig,
} from "../agents/memory-search.js";
export { jsonResult, readNumberParam, readStringParam } from "../agents/tools/common.js";
export { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
export { formatErrorMessage, withManager } from "../cli/cli-utils.js";
@@ -21,22 +25,112 @@ export { parseNonNegativeByteSize } from "../config/byte-size.js";
export { loadConfig } from "../config/config.js";
export { resolveStateDir } from "../config/paths.js";
export { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js";
export { listMemoryFiles, normalizeExtraMemoryPaths } from "../memory/internal.js";
export { readAgentMemoryFile } from "../memory/read-file.js";
export { writeFileWithinRoot } from "../infra/fs-safe.js";
export { createSubsystemLogger } from "../logging/subsystem.js";
export { resolveGlobalSingleton } from "../shared/global-singleton.js";
export { onSessionTranscriptUpdate } from "../sessions/transcript-events.js";
export {
buildFileEntry,
buildMultimodalChunkForIndexing,
chunkMarkdown,
cosineSimilarity,
ensureDir,
hashText,
listMemoryFiles,
normalizeExtraMemoryPaths,
parseEmbedding,
remapChunkLines,
runWithConcurrency,
type MemoryChunk,
type MemoryFileEntry,
} from "../memory/internal.js";
export { readAgentMemoryFile, readMemoryFile } from "../memory/read-file.js";
export { resolveMemoryBackendConfig } from "../memory/backend-config.js";
export type {
ResolvedMemoryBackendConfig,
ResolvedQmdConfig,
ResolvedQmdMcporterConfig,
} from "../memory/backend-config.js";
export type {
MemoryEmbeddingProbeResult,
MemoryProviderStatus,
MemorySearchManager,
MemorySearchResult,
MemorySource,
MemorySyncProgressUpdate,
} from "../memory/types.js";
export {
createEmbeddingProvider,
type EmbeddingProvider,
type EmbeddingProviderRequest,
type EmbeddingProviderResult,
type GeminiEmbeddingClient,
type MistralEmbeddingClient,
type OllamaEmbeddingClient,
type OpenAiEmbeddingClient,
type VoyageEmbeddingClient,
} from "../memory/embeddings.js";
export {
DEFAULT_GEMINI_EMBEDDING_MODEL,
buildGeminiEmbeddingRequest,
} from "../memory/embeddings-gemini.js";
export { DEFAULT_MISTRAL_EMBEDDING_MODEL } from "../memory/embeddings-mistral.js";
export { DEFAULT_OLLAMA_EMBEDDING_MODEL } from "../memory/embeddings-ollama.js";
export { DEFAULT_OPENAI_EMBEDDING_MODEL } from "../memory/embeddings-openai.js";
export { DEFAULT_VOYAGE_EMBEDDING_MODEL } from "../memory/embeddings-voyage.js";
export { runGeminiEmbeddingBatches, type GeminiBatchRequest } from "../memory/batch-gemini.js";
export {
OPENAI_BATCH_ENDPOINT,
runOpenAiEmbeddingBatches,
type OpenAiBatchRequest,
} from "../memory/batch-openai.js";
export { runVoyageEmbeddingBatches, type VoyageBatchRequest } from "../memory/batch-voyage.js";
export { enforceEmbeddingMaxInputTokens } from "../memory/embedding-chunk-limits.js";
export {
estimateStructuredEmbeddingInputBytes,
estimateUtf8Bytes,
} from "../memory/embedding-input-limits.js";
export { hasNonTextEmbeddingParts, type EmbeddingInput } from "../memory/embedding-inputs.js";
export {
buildCaseInsensitiveExtensionGlob,
classifyMemoryMultimodalPath,
getMemoryMultimodalExtensions,
} from "../memory/multimodal.js";
export { ensureMemoryIndexSchema } from "../memory/memory-schema.js";
export { loadSqliteVecExtension } from "../memory/sqlite-vec.js";
export { requireNodeSqlite } from "../memory/sqlite.js";
export { extractKeywords, isQueryStopWordToken } from "../memory/query-expansion.js";
export {
buildSessionEntry,
listSessionFilesForAgent,
sessionPathForFile,
type SessionFileEntry,
} from "../memory/session-files.js";
export { parseQmdQueryJson, type QmdQueryResult } from "../memory/qmd-query-parser.js";
export {
deriveQmdScopeChannel,
deriveQmdScopeChatType,
isQmdScopeAllowed,
} from "../memory/qmd-scope.js";
export { isFileMissingError, statRegularFile } from "../memory/fs-utils.js";
export { resolveCliSpawnInvocation, runCliCommand } from "../memory/qmd-process.js";
export {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
} from "../config/types.secrets.js";
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
export { getMemorySearchManager } from "../memory/index.js";
export { parseAgentSessionKey } from "../routing/session-key.js";
export { defaultRuntime } from "../runtime.js";
export { colorize, isRich, theme } from "../terminal/theme.js";
export { formatDocsLink } from "../terminal/links.js";
export { detectMime } from "../media/mime.js";
export { setVerbose, isVerbose } from "../globals.js";
export { shortenHomeInString, shortenHomePath, resolveUserPath } from "../utils.js";
export {
shortenHomeInString,
shortenHomePath,
resolveUserPath,
truncateUtf16Safe,
} from "../utils.js";
export { splitShellArgs } from "../utils/shell-argv.js";
export { runTasksWithConcurrency } from "../utils/run-with-concurrency.js";
export type { OpenClawConfig } from "../config/config.js";
@@ -50,7 +144,6 @@ export type {
MemoryQmdSearchMode,
} from "../config/types.memory.js";
export type { SecretInput } from "../config/types.secrets.js";
export type { MemorySearchResult } from "../memory/types.js";
export type {
MemoryFlushPlan,
MemoryFlushPlanResolver,

View File

@@ -14,7 +14,7 @@ export { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
export { loadConfig } from "../config/config.js";
export { resolveStateDir } from "../config/paths.js";
export { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js";
export { getMemorySearchManager } from "../memory/index.js";
export { getMemorySearchManager } from "../../extensions/memory-core/src/memory/index.js";
export { listMemoryFiles, normalizeExtraMemoryPaths } from "../memory/internal.js";
export { readAgentMemoryFile } from "../memory/read-file.js";
export { resolveMemoryBackendConfig } from "../memory/backend-config.js";

View File

@@ -1,10 +1,3 @@
import {
ADMIN_SCOPE,
APPROVALS_SCOPE,
PAIRING_SCOPE,
READ_SCOPE,
WRITE_SCOPE,
} from "../gateway/method-scopes.js";
import { logVerbose } from "../globals.js";
import {
clearPluginCommands,
@@ -94,13 +87,6 @@ export function validateCommandName(name: string): string | null {
export function validatePluginCommandDefinition(
command: OpenClawPluginCommandDefinition,
): string | null {
const knownOperatorScopes = new Set([
ADMIN_SCOPE,
APPROVALS_SCOPE,
PAIRING_SCOPE,
READ_SCOPE,
WRITE_SCOPE,
]);
if (typeof command.handler !== "function") {
return "Command handler must be a function";
}
@@ -126,23 +112,6 @@ export function validatePluginCommandDefinition(
return `Native command alias "${label}" invalid: ${aliasError}`;
}
}
if (
command.requiredGatewayScopes !== undefined &&
!Array.isArray(command.requiredGatewayScopes)
) {
return "Command requiredGatewayScopes must be an array";
}
if (
command.resolveRequiredGatewayScopes !== undefined &&
typeof command.resolveRequiredGatewayScopes !== "function"
) {
return "Command resolveRequiredGatewayScopes must be a function";
}
for (const scope of command.requiredGatewayScopes ?? []) {
if (!knownOperatorScopes.has(scope)) {
return `Command requiredGatewayScopes contains unknown scope "${scope}"`;
}
}
return null;
}

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import {
__testing,
@@ -52,17 +52,6 @@ describe("registerPluginCommand", () => {
ok: false,
error: "Command description must be a string",
});
const invalidRequiredGatewayScopes = registerPluginCommand("demo-plugin", {
name: "secure",
description: "Secure command",
requiredGatewayScopes: ["operator.nope" as never],
handler: async () => ({ text: "ok" }),
});
expect(invalidRequiredGatewayScopes).toEqual({
ok: false,
error: 'Command requiredGatewayScopes contains unknown scope "operator.nope"',
});
});
it("normalizes command metadata for downstream consumers", () => {
@@ -342,171 +331,3 @@ describe("registerPluginCommand", () => {
);
});
});
describe("executePluginCommand", () => {
it("enforces owner requirements before running the handler", async () => {
const handler = vi.fn(async () => ({ text: "ok" }));
const result = await executePluginCommand({
command: {
name: "owneronly",
description: "Owner-only command",
requireOwner: true,
handler,
pluginId: "demo-plugin",
},
channel: "discord",
senderId: "U123",
isAuthorizedSender: true,
senderIsOwner: false,
commandBody: "/owneronly",
config: {} as never,
});
expect(handler).not.toHaveBeenCalled();
expect(result).toEqual({ text: "⚠️ This command requires owner authorization." });
});
it("blocks internal callers missing required gateway scopes", async () => {
const handler = vi.fn(async () => ({ text: "ok" }));
const result = await executePluginCommand({
command: {
name: "pairing",
description: "Pairing command",
requiredGatewayScopes: ["operator.pairing"],
handler,
pluginId: "demo-plugin",
},
surface: "webchat",
channel: "webchat",
senderId: "writer-1",
isAuthorizedSender: true,
gatewayClientScopes: ["operator.write"],
commandBody: "/pairing",
config: {} as never,
});
expect(handler).not.toHaveBeenCalled();
expect(result).toEqual({
text: "⚠️ This command requires operator.pairing for internal gateway callers.",
});
});
it("allows admin-scoped internal callers to bypass narrower scope requirements", async () => {
const handler = vi.fn(async () => ({ text: "ok" }));
const result = await executePluginCommand({
command: {
name: "pairing",
description: "Pairing command",
requiredGatewayScopes: ["operator.pairing"],
handler,
pluginId: "demo-plugin",
},
surface: "webchat",
channel: "webchat",
senderId: "admin-1",
isAuthorizedSender: true,
gatewayClientScopes: ["operator.admin"],
commandBody: "/pairing",
config: {} as never,
});
expect(handler).toHaveBeenCalledTimes(1);
expect(result).toEqual({ text: "ok" });
});
it("allows external callers to bypass gateway scope requirements", async () => {
const handler = vi.fn(async () => ({ text: "ok" }));
const result = await executePluginCommand({
command: {
name: "pairing",
description: "Pairing command",
requiredGatewayScopes: ["operator.pairing"],
handler,
pluginId: "demo-plugin",
},
surface: "telegram",
channel: "telegram",
senderId: "123",
isAuthorizedSender: true,
commandBody: "/pairing",
config: {} as never,
});
expect(handler).toHaveBeenCalledTimes(1);
expect(result).toEqual({ text: "ok" });
});
it("supports context-sensitive gateway scope requirements", async () => {
const handler = vi.fn(async () => ({ text: "ok" }));
const command: Parameters<typeof executePluginCommand>[0]["command"] = {
name: "pair",
description: "Pair command",
resolveRequiredGatewayScopes: (ctx) => {
const action = ctx.args?.trim().split(/\s+/, 1)[0]?.toLowerCase();
return action === "approve" ? ["operator.pairing"] : undefined;
},
handler,
pluginId: "demo-plugin",
};
const denied = await executePluginCommand({
command,
surface: "webchat",
channel: "webchat",
senderId: "writer-1",
isAuthorizedSender: true,
gatewayClientScopes: ["operator.write"],
args: "approve latest",
commandBody: "/pair approve latest",
config: {} as never,
});
const allowed = await executePluginCommand({
command,
surface: "webchat",
channel: "webchat",
senderId: "writer-1",
isAuthorizedSender: true,
gatewayClientScopes: ["operator.write"],
args: "qr",
commandBody: "/pair qr",
config: {} as never,
});
expect(handler).toHaveBeenCalledTimes(1);
expect(denied).toEqual({
text: "⚠️ This command requires operator.pairing for internal gateway callers.",
});
expect(allowed).toEqual({ text: "ok" });
});
it("returns a safe error reply when dynamic scope resolution throws", async () => {
const handler = vi.fn(async () => ({ text: "ok" }));
const result = await executePluginCommand({
command: {
name: "pair",
description: "Pair command",
resolveRequiredGatewayScopes: () => {
throw new Error("resolver exploded");
},
handler,
pluginId: "demo-plugin",
},
surface: "webchat",
channel: "webchat",
senderId: "writer-1",
isAuthorizedSender: true,
gatewayClientScopes: ["operator.write"],
commandBody: "/pair",
config: {} as never,
});
expect(handler).not.toHaveBeenCalled();
expect(result).toEqual({ text: "⚠️ Command failed. Please try again later." });
});
});

View File

@@ -7,9 +7,7 @@
import { parseExplicitTargetForChannel } from "../channels/plugins/target-parsing.js";
import type { OpenClawConfig } from "../config/config.js";
import { ADMIN_SCOPE, isOperatorScope, type OperatorScope } from "../gateway/method-scopes.js";
import { logVerbose } from "../globals.js";
import { isInternalMessageChannel } from "../utils/message-channel.js";
import {
clearPluginCommands,
clearPluginCommandsForPlugin,
@@ -30,7 +28,6 @@ import {
requestPluginConversationBinding,
} from "./conversation-binding.js";
import type {
PluginCommandAuthorizationContext,
OpenClawPluginCommandDefinition,
PluginCommandContext,
PluginCommandResult,
@@ -39,25 +36,6 @@ import type {
// Maximum allowed length for command arguments (defense in depth)
const MAX_ARGS_LENGTH = 4096;
function formatRequiredGatewayScopes(scopes: readonly string[]): string {
if (scopes.length === 0) {
return "gateway authorization";
}
if (scopes.length === 1) {
return scopes[0];
}
if (scopes.length === 2) {
return `${scopes[0]} and ${scopes[1]}`;
}
return `${scopes.slice(0, -1).join(", ")}, and ${scopes[scopes.length - 1]}`;
}
function buildMissingGatewayScopeReply(scopes: readonly string[]): PluginCommandResult {
return {
text: `⚠️ This command requires ${formatRequiredGatewayScopes(scopes)} for internal gateway callers.`,
};
}
export {
clearPluginCommands,
clearPluginCommandsForPlugin,
@@ -203,11 +181,9 @@ export async function executePluginCommand(params: {
command: RegisteredPluginCommand;
args?: string;
senderId?: string;
surface?: PluginCommandContext["surface"];
channel: string;
channelId?: PluginCommandContext["channelId"];
isAuthorizedSender: boolean;
senderIsOwner?: PluginCommandContext["senderIsOwner"];
gatewayClientScopes?: PluginCommandContext["gatewayClientScopes"];
commandBody: string;
config: OpenClawConfig;
@@ -216,17 +192,7 @@ export async function executePluginCommand(params: {
accountId?: PluginCommandContext["accountId"];
messageThreadId?: PluginCommandContext["messageThreadId"];
}): Promise<PluginCommandResult> {
const {
command,
args,
senderId,
channel,
isAuthorizedSender,
commandBody,
config,
senderIsOwner = false,
} = params;
const surface = params.surface ?? channel;
const { command, args, senderId, channel, isAuthorizedSender, commandBody, config } = params;
// Check authorization
const requireAuth = command.requireAuth !== false; // Default to true
@@ -236,67 +202,9 @@ export async function executePluginCommand(params: {
);
return { text: "⚠️ This command requires authorization." };
}
if (command.requireOwner && !senderIsOwner) {
logVerbose(
`Plugin command /${command.name} blocked: non-owner sender ${senderId || "<unknown>"}`,
);
return { text: "⚠️ This command requires owner authorization." };
}
// Sanitize args before passing to handler
const sanitizedArgs = sanitizeArgs(args);
const authContext: PluginCommandAuthorizationContext = {
senderId,
surface,
channel,
channelId: params.channelId,
isAuthorizedSender,
senderIsOwner,
gatewayClientScopes: params.gatewayClientScopes,
args: sanitizedArgs,
commandBody,
config,
from: params.from,
to: params.to,
accountId: params.accountId,
messageThreadId: params.messageThreadId,
};
let dynamicRequiredGatewayScopes: OperatorScope[] = [];
if (command.resolveRequiredGatewayScopes) {
try {
const resolvedScopes = command.resolveRequiredGatewayScopes(authContext) as
| readonly string[]
| undefined;
dynamicRequiredGatewayScopes = (resolvedScopes ?? []).filter(
(scope): scope is OperatorScope => {
if (isOperatorScope(scope)) {
return true;
}
logVerbose(`Plugin command /${command.name} ignored unknown dynamic scope "${scope}"`);
return false;
},
);
} catch (err) {
const error = err as Error;
logVerbose(`Plugin command /${command.name} scope resolver error: ${error.message}`);
return { text: "⚠️ Command failed. Please try again later." };
}
}
const requiredGatewayScopes = Array.from(
new Set([...(command.requiredGatewayScopes ?? []), ...dynamicRequiredGatewayScopes]),
);
if (
requiredGatewayScopes.length > 0 &&
isInternalMessageChannel(surface) &&
!requiredGatewayScopes.every(
(scope) =>
params.gatewayClientScopes?.includes(scope) ||
params.gatewayClientScopes?.includes(ADMIN_SCOPE),
)
) {
logVerbose(
`Plugin command /${command.name} blocked: gateway caller missing scope ${requiredGatewayScopes.join(", ")}`,
);
return buildMissingGatewayScopeReply(requiredGatewayScopes);
}
const bindingConversation = resolveBindingConversationFromCommand({
channel,
from: params.from,
@@ -307,11 +215,9 @@ export async function executePluginCommand(params: {
const ctx: PluginCommandContext = {
senderId,
surface,
channel,
channelId: params.channelId,
isAuthorizedSender,
senderIsOwner,
gatewayClientScopes: params.gatewayClientScopes,
args: sanitizedArgs,
commandBody,

View File

@@ -26,3 +26,8 @@ export async function getActiveMemorySearchManager(params: {
export function resolveActiveMemoryBackendConfig(params: { cfg: OpenClawConfig; agentId: string }) {
return ensureMemoryRuntime(params.cfg)?.resolveMemoryBackendConfig(params) ?? null;
}
export async function closeActiveMemorySearchManagers(cfg?: OpenClawConfig): Promise<void> {
const runtime = ensureMemoryRuntime(cfg);
await runtime?.closeAllMemorySearchManagers?.();
}

View File

@@ -56,6 +56,7 @@ export type MemoryPluginRuntime = {
cfg: OpenClawConfig;
agentId: string;
}): MemoryRuntimeBackendConfig;
closeAllMemorySearchManagers?(): Promise<void>;
};
type MemoryPluginState = {

View File

@@ -984,18 +984,14 @@ export type OpenClawPluginGatewayMethod = {
export type PluginCommandContext = {
/** The sender's identifier (e.g., Telegram user ID) */
senderId?: string;
/** The inbound command surface (e.g., "webchat", "telegram") */
surface: string;
/** The channel/surface (e.g., "telegram", "discord") */
channel: string;
/** Provider channel id (e.g., "telegram") */
channelId?: ChannelId;
/** Whether the sender is on the allowlist */
isAuthorizedSender: boolean;
/** Whether the sender is treated as an owner-level caller */
senderIsOwner: boolean;
/** Gateway client scopes for internal control-plane callers */
gatewayClientScopes?: OperatorScope[];
gatewayClientScopes?: string[];
/** Raw command arguments after the command name */
args?: string;
/** The full normalized command body */
@@ -1072,24 +1068,6 @@ export type PluginConversationBindingResolvedEvent = {
};
};
export type PluginCommandAuthorizationContext = Pick<
PluginCommandContext,
| "senderId"
| "surface"
| "channel"
| "channelId"
| "isAuthorizedSender"
| "senderIsOwner"
| "gatewayClientScopes"
| "args"
| "commandBody"
| "config"
| "from"
| "to"
| "accountId"
| "messageThreadId"
>;
/**
* Result returned by a plugin command handler.
*/
@@ -1120,14 +1098,6 @@ export type OpenClawPluginCommandDefinition = {
acceptsArgs?: boolean;
/** Whether only authorized senders can use this command (default: true) */
requireAuth?: boolean;
/** Whether only owner-level callers can use this command (default: false) */
requireOwner?: boolean;
/** Gateway scopes required for internal control-plane callers */
requiredGatewayScopes?: readonly OperatorScope[];
/** Context-sensitive gateway scopes required for internal control-plane callers */
resolveRequiredGatewayScopes?: (
ctx: PluginCommandAuthorizationContext,
) => readonly OperatorScope[] | undefined;
/** The handler function */
handler: PluginCommandHandler;
};

View File

@@ -54,25 +54,4 @@ export function collectTtsApiKeyAssignments(params: {
}
return;
}
// Legacy compatibility until migrated configs have been rewritten on disk.
const legacyProviders = ["elevenlabs", "openai"] as const;
for (const providerId of legacyProviders) {
const providerConfig = params.tts[providerId];
if (!isRecord(providerConfig)) {
continue;
}
collectSecretInputAssignment({
value: providerConfig.apiKey,
path: `${params.pathPrefix}.${providerId}.apiKey`,
expected: "string",
defaults: params.defaults,
context: params.context,
active: params.active,
inactiveReason: params.inactiveReason,
apply: (value) => {
providerConfig.apiKey = value;
},
});
}
}

View File

@@ -1949,20 +1949,22 @@ describe("secrets runtime snapshot", () => {
loadAuthStore: () => ({ version: 1, profiles: {} }),
});
expect(snapshot.config.channels?.discord?.voice?.tts?.openai?.apiKey).toEqual({
expect(snapshot.config.channels?.discord?.voice?.tts?.providers?.openai?.apiKey).toEqual({
source: "env",
provider: "default",
id: "MISSING_DISCORD_VOICE_TTS_OPENAI",
});
expect(snapshot.config.channels?.discord?.accounts?.work?.voice?.tts?.openai?.apiKey).toEqual({
expect(
snapshot.config.channels?.discord?.accounts?.work?.voice?.tts?.providers?.openai?.apiKey,
).toEqual({
source: "env",
provider: "default",
id: "MISSING_DISCORD_WORK_VOICE_TTS_OPENAI",
});
expect(snapshot.warnings.map((warning) => warning.path)).toEqual(
expect.arrayContaining([
"channels.discord.voice.tts.openai.apiKey",
"channels.discord.accounts.work.voice.tts.openai.apiKey",
"channels.discord.voice.tts.providers.openai.apiKey",
"channels.discord.accounts.work.voice.tts.providers.openai.apiKey",
]),
);
});
@@ -1974,8 +1976,10 @@ describe("secrets runtime snapshot", () => {
discord: {
voice: {
tts: {
openai: {
apiKey: { source: "env", provider: "default", id: "DISCORD_BASE_TTS_OPENAI" },
providers: {
openai: {
apiKey: { source: "env", provider: "default", id: "DISCORD_BASE_TTS_OPENAI" },
},
},
},
},
@@ -1990,11 +1994,13 @@ describe("secrets runtime snapshot", () => {
enabled: true,
voice: {
tts: {
openai: {
apiKey: {
source: "env",
provider: "default",
id: "DISCORD_ENABLED_OVERRIDE_TTS_OPENAI",
providers: {
openai: {
apiKey: {
source: "env",
provider: "default",
id: "DISCORD_ENABLED_OVERRIDE_TTS_OPENAI",
},
},
},
},
@@ -2004,11 +2010,13 @@ describe("secrets runtime snapshot", () => {
enabled: false,
voice: {
tts: {
openai: {
apiKey: {
source: "env",
provider: "default",
id: "DISCORD_DISABLED_OVERRIDE_TTS_OPENAI",
providers: {
openai: {
apiKey: {
source: "env",
provider: "default",
id: "DISCORD_DISABLED_OVERRIDE_TTS_OPENAI",
},
},
},
},
@@ -2034,13 +2042,17 @@ describe("secrets runtime snapshot", () => {
loadAuthStore: () => ({ version: 1, profiles: {} }),
});
expect(snapshot.config.channels?.discord?.voice?.tts?.openai?.apiKey).toBe("base-tts-openai");
expect(snapshot.config.channels?.discord?.voice?.tts?.providers?.openai?.apiKey).toBe(
"base-tts-openai",
);
expect(snapshot.config.channels?.discord?.pluralkit?.token).toBe("base-pk-token");
expect(
snapshot.config.channels?.discord?.accounts?.enabledOverride?.voice?.tts?.openai?.apiKey,
snapshot.config.channels?.discord?.accounts?.enabledOverride?.voice?.tts?.providers?.openai
?.apiKey,
).toBe("enabled-override-tts-openai");
expect(
snapshot.config.channels?.discord?.accounts?.disabledOverride?.voice?.tts?.openai?.apiKey,
snapshot.config.channels?.discord?.accounts?.disabledOverride?.voice?.tts?.providers?.openai
?.apiKey,
).toEqual({
source: "env",
provider: "default",
@@ -2055,7 +2067,7 @@ describe("secrets runtime snapshot", () => {
);
expect(snapshot.warnings.map((warning) => warning.path)).toEqual(
expect.arrayContaining([
"channels.discord.accounts.disabledOverride.voice.tts.openai.apiKey",
"channels.discord.accounts.disabledOverride.voice.tts.providers.openai.apiKey",
"channels.discord.accounts.disabledOverride.pluralkit.token",
]),
);
@@ -2068,11 +2080,13 @@ describe("secrets runtime snapshot", () => {
discord: {
voice: {
tts: {
openai: {
apiKey: {
source: "env",
provider: "default",
id: "DISCORD_UNUSED_BASE_TTS_OPENAI",
providers: {
openai: {
apiKey: {
source: "env",
provider: "default",
id: "DISCORD_UNUSED_BASE_TTS_OPENAI",
},
},
},
},
@@ -2082,11 +2096,13 @@ describe("secrets runtime snapshot", () => {
enabled: true,
voice: {
tts: {
openai: {
apiKey: {
source: "env",
provider: "default",
id: "DISCORD_ENABLED_ONLY_TTS_OPENAI",
providers: {
openai: {
apiKey: {
source: "env",
provider: "default",
id: "DISCORD_ENABLED_ONLY_TTS_OPENAI",
},
},
},
},
@@ -2107,15 +2123,16 @@ describe("secrets runtime snapshot", () => {
});
expect(
snapshot.config.channels?.discord?.accounts?.enabledOverride?.voice?.tts?.openai?.apiKey,
snapshot.config.channels?.discord?.accounts?.enabledOverride?.voice?.tts?.providers?.openai
?.apiKey,
).toBe("enabled-only-tts-openai");
expect(snapshot.config.channels?.discord?.voice?.tts?.openai?.apiKey).toEqual({
expect(snapshot.config.channels?.discord?.voice?.tts?.providers?.openai?.apiKey).toEqual({
source: "env",
provider: "default",
id: "DISCORD_UNUSED_BASE_TTS_OPENAI",
});
expect(snapshot.warnings.map((warning) => warning.path)).toContain(
"channels.discord.voice.tts.openai.apiKey",
"channels.discord.voice.tts.providers.openai.apiKey",
);
});
@@ -2127,8 +2144,10 @@ describe("secrets runtime snapshot", () => {
discord: {
voice: {
tts: {
openai: {
apiKey: { source: "env", provider: "default", id: "DISCORD_BASE_TTS_OK" },
providers: {
openai: {
apiKey: { source: "env", provider: "default", id: "DISCORD_BASE_TTS_OK" },
},
},
},
},
@@ -2137,11 +2156,13 @@ describe("secrets runtime snapshot", () => {
enabled: true,
voice: {
tts: {
openai: {
apiKey: {
source: "env",
provider: "default",
id: "DISCORD_ENABLED_OVERRIDE_TTS_MISSING",
providers: {
openai: {
apiKey: {
source: "env",
provider: "default",
id: "DISCORD_ENABLED_OVERRIDE_TTS_MISSING",
},
},
},
},

View File

@@ -12,6 +12,7 @@ import {
setRuntimeConfigSnapshot,
type OpenClawConfig,
} from "../config/config.js";
import { migrateLegacyConfig } from "../config/legacy-migrate.js";
import { resolveUserPath } from "../utils.js";
import {
collectCommandSecretAssignmentsFromSnapshot,
@@ -139,7 +140,9 @@ export async function prepareSecretsRuntimeSnapshot(params: {
}): Promise<PreparedSecretsRuntimeSnapshot> {
const runtimeEnv = mergeSecretsRuntimeEnv(params.env);
const sourceConfig = structuredClone(params.config);
const resolvedConfig = structuredClone(params.config);
const resolvedConfig = structuredClone(
migrateLegacyConfig(params.config).config ?? params.config,
);
const context = createResolverContext({
sourceConfig,
env: runtimeEnv,

View File

@@ -4,7 +4,6 @@ import { listSpeechProviders } from "./provider-registry.js";
import type {
SpeechModelOverridePolicy,
SpeechProviderConfig,
SpeechProviderOverrides,
TtsDirectiveOverrides,
TtsDirectiveParseResult,
} from "./provider-types.js";
@@ -13,7 +12,6 @@ type ParseTtsDirectiveOptions = {
cfg?: OpenClawConfig;
providers?: readonly SpeechProviderPlugin[];
providerConfigs?: Record<string, SpeechProviderConfig>;
openaiBaseUrl?: string;
};
function buildProviderOrder(left: SpeechProviderPlugin, right: SpeechProviderPlugin): number {
@@ -36,49 +34,18 @@ function resolveDirectiveProviderConfig(
provider: SpeechProviderPlugin,
options?: ParseTtsDirectiveOptions,
): SpeechProviderConfig | undefined {
const explicit = options?.providerConfigs?.[provider.id];
if (explicit) {
return explicit;
}
if (provider.id === "openai" && options?.openaiBaseUrl) {
return { baseUrl: options.openaiBaseUrl };
}
return undefined;
}
function mergeProviderOverrides(
target: TtsDirectiveOverrides,
providerId: string,
next: SpeechProviderOverrides,
): void {
target.providerOverrides = {
...target.providerOverrides,
[providerId]: {
...target.providerOverrides?.[providerId],
...next,
},
};
}
function resolveLegacyOptions(
optionsOrOpenaiBaseUrl?: ParseTtsDirectiveOptions | string,
): ParseTtsDirectiveOptions | undefined {
if (typeof optionsOrOpenaiBaseUrl === "string") {
return { openaiBaseUrl: optionsOrOpenaiBaseUrl };
}
return optionsOrOpenaiBaseUrl;
return options?.providerConfigs?.[provider.id];
}
export function parseTtsDirectives(
text: string,
policy: SpeechModelOverridePolicy,
optionsOrOpenaiBaseUrl?: ParseTtsDirectiveOptions | string,
options?: ParseTtsDirectiveOptions,
): TtsDirectiveParseResult {
if (!policy.enabled) {
return { cleanedText: text, overrides: {}, warnings: [], hasDirective: false };
}
const options = resolveLegacyOptions(optionsOrOpenaiBaseUrl);
const providers = resolveDirectiveProviders(options);
const overrides: TtsDirectiveOverrides = {};
const warnings: string[] = [];
@@ -135,7 +102,13 @@ export function parseTtsDirectives(
}
handled = true;
if (parsed.overrides) {
mergeProviderOverrides(overrides, provider.id, parsed.overrides);
overrides.providerOverrides = {
...overrides.providerOverrides,
[provider.id]: {
...overrides.providerOverrides?.[provider.id],
...parsed.overrides,
},
};
}
if (parsed.warnings?.length) {
warnings.push(...parsed.warnings);

View File

@@ -86,7 +86,6 @@ const {
parseTtsDirectives,
resolveModelOverridePolicy,
summarizeText,
resolveOutputFormat,
getResolvedSpeechProviderConfig,
} = _test;
@@ -234,65 +233,6 @@ describe("tts", () => {
});
});
describe("resolveOutputFormat", () => {
it("selects opus for opus channels (telegram/feishu/whatsapp/matrix) and mp3 for others", () => {
const cases = [
{
channel: "telegram",
expected: {
openai: "opus",
elevenlabs: "opus_48000_64",
extension: ".opus",
voiceCompatible: true,
},
},
{
channel: "feishu",
expected: {
openai: "opus",
elevenlabs: "opus_48000_64",
extension: ".opus",
voiceCompatible: true,
},
},
{
channel: "whatsapp",
expected: {
openai: "opus",
elevenlabs: "opus_48000_64",
extension: ".opus",
voiceCompatible: true,
},
},
{
channel: "matrix",
expected: {
openai: "opus",
elevenlabs: "opus_48000_64",
extension: ".opus",
voiceCompatible: true,
},
},
{
channel: "discord",
expected: {
openai: "mp3",
elevenlabs: "mp3_44100_128",
extension: ".mp3",
voiceCompatible: false,
},
},
] as const;
for (const testCase of cases) {
const output = resolveOutputFormat(testCase.channel);
expect(output.openai, testCase.channel).toBe(testCase.expected.openai);
expect(output.elevenlabs, testCase.channel).toBe(testCase.expected.elevenlabs);
expect(output.extension, testCase.channel).toBe(testCase.expected.extension);
expect(output.voiceCompatible, testCase.channel).toBe(testCase.expected.voiceCompatible);
}
});
});
describe("resolveEdgeOutputFormat", () => {
const baseCfg: OpenClawConfig = {
agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } },
@@ -383,9 +323,11 @@ describe("tts", () => {
it("accepts custom voices and models when openaiBaseUrl is a non-default endpoint", () => {
const policy = resolveModelOverridePolicy({ enabled: true });
const input = "Hello [[tts:voice=kokoro-chinese model=kokoro-v1]] world";
const customBaseUrl = "http://localhost:8880/v1";
const result = parseTtsDirectives(input, policy, customBaseUrl);
const result = parseTtsDirectives(input, policy, {
providerConfigs: {
openai: { baseUrl: "http://localhost:8880/v1" },
},
});
const openaiOverrides = result.overrides.providerOverrides?.openai as
| { voice?: string; model?: string }
| undefined;
@@ -398,9 +340,11 @@ describe("tts", () => {
it("rejects unknown voices and models when openaiBaseUrl is the default OpenAI endpoint", () => {
const policy = resolveModelOverridePolicy({ enabled: true });
const input = "Hello [[tts:voice=kokoro-chinese model=kokoro-v1]] world";
const defaultBaseUrl = "https://api.openai.com/v1";
const result = parseTtsDirectives(input, policy, defaultBaseUrl);
const result = parseTtsDirectives(input, policy, {
providerConfigs: {
openai: { baseUrl: "https://api.openai.com/v1" },
},
});
const openaiOverrides = result.overrides.providerOverrides?.openai as
| { voice?: string }
| undefined;

View File

@@ -48,22 +48,6 @@ const DEFAULT_TTS_MAX_LENGTH = 1500;
const DEFAULT_TTS_SUMMARIZE = true;
const DEFAULT_MAX_TEXT_LENGTH = 4096;
const OPUS_OUTPUT = {
openai: "opus" as const,
// ElevenLabs output formats use codec_sample_rate_bitrate naming.
// Opus @ 48kHz/64kbps is a good voice message tradeoff.
elevenlabs: "opus_48000_64",
extension: ".opus",
voiceCompatible: true,
};
const DEFAULT_OUTPUT = {
openai: "mp3" as const,
elevenlabs: "mp3_44100_128",
extension: ".mp3",
voiceCompatible: false,
};
export type ResolvedTtsConfig = {
auto: TtsAutoMode;
mode: TtsMode;
@@ -418,16 +402,9 @@ export function setLastTtsAttempt(entry: TtsStatusEntry | undefined): void {
lastTtsAttempt = entry;
}
/** Channels that require opus audio */
/** Channels that require voice-note-compatible audio */
const OPUS_CHANNELS = new Set(["telegram", "feishu", "whatsapp", "matrix"]);
function resolveOutputFormat(channelId?: string | null) {
if (channelId && OPUS_CHANNELS.has(channelId)) {
return OPUS_OUTPUT;
}
return DEFAULT_OUTPUT;
}
function resolveChannelId(channel: string | undefined): ChannelId | null {
return channel ? normalizeChannelId(channel) : null;
}
@@ -876,6 +853,5 @@ export const _test = {
parseTtsDirectives,
resolveModelOverridePolicy,
summarizeText,
resolveOutputFormat,
getResolvedSpeechProviderConfig,
};

View File

@@ -67,10 +67,26 @@ const targetedUnitProxyFiles = [
const REPO_ROOT = path.resolve(import.meta.dirname, "../..");
function runPlannerPlan(args: string[], env: NodeJS.ProcessEnv): string {
function createPlannerEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
return {
...clearPlannerShardEnv(process.env),
...overrides,
};
}
function createLocalPlannerEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
return createPlannerEnv({
CI: "",
GITHUB_ACTIONS: "",
OPENCLAW_TEST_LOAD_AWARE: "0",
...overrides,
});
}
function runPlannerPlan(args: string[], envOverrides: NodeJS.ProcessEnv = {}): string {
return execFileSync("node", ["scripts/test-parallel.mjs", ...args], {
cwd: REPO_ROOT,
env,
env: createPlannerEnv(envOverrides),
encoding: "utf8",
});
}
@@ -78,15 +94,11 @@ function runPlannerPlan(args: string[], env: NodeJS.ProcessEnv): string {
function runHighMemoryLocalMultiSurfacePlan(): string {
return runPlannerPlan(
["--plan", "--surface", "unit", "--surface", "extensions", "--surface", "channels"],
{
...clearPlannerShardEnv(process.env),
CI: "",
GITHUB_ACTIONS: "",
createLocalPlannerEnv({
RUNNER_OS: "macOS",
OPENCLAW_TEST_HOST_CPU_COUNT: "12",
OPENCLAW_TEST_HOST_MEMORY_GIB: "128",
OPENCLAW_TEST_LOAD_AWARE: "0",
},
}),
);
}
@@ -191,14 +203,8 @@ describe("scripts/test-parallel memory trace parsing", () => {
describe("scripts/test-parallel lane planning", () => {
it("keeps serial profile on split unit lanes instead of one giant unit worker", () => {
const repoRoot = path.resolve(import.meta.dirname, "../..");
const output = execFileSync("node", ["scripts/test-parallel.mjs", "--plan"], {
cwd: repoRoot,
env: {
...clearPlannerShardEnv(process.env),
OPENCLAW_TEST_PROFILE: "serial",
},
encoding: "utf8",
const output = runPlannerPlan(["--plan"], {
OPENCLAW_TEST_PROFILE: "serial",
});
expect(output).toContain("unit-fast");
@@ -206,16 +212,10 @@ describe("scripts/test-parallel lane planning", () => {
});
it("recycles default local unit-fast runs into bounded batches", () => {
const repoRoot = path.resolve(import.meta.dirname, "../..");
const output = execFileSync("node", ["scripts/test-parallel.mjs", "--plan"], {
cwd: repoRoot,
env: {
...clearPlannerShardEnv(process.env),
CI: "",
OPENCLAW_TEST_UNIT_FAST_LANES: "1",
OPENCLAW_TEST_UNIT_FAST_BATCH_TARGET_MS: "1",
},
encoding: "utf8",
const output = runPlannerPlan(["--plan"], {
CI: "",
OPENCLAW_TEST_UNIT_FAST_LANES: "1",
OPENCLAW_TEST_UNIT_FAST_BATCH_TARGET_MS: "1",
});
expect(output).toContain("unit-fast-batch-");
@@ -223,44 +223,24 @@ describe("scripts/test-parallel lane planning", () => {
});
it("keeps legacy base-pinned targeted reruns on dedicated forks lanes", () => {
const repoRoot = path.resolve(import.meta.dirname, "../..");
const output = execFileSync(
"node",
[
"scripts/test-parallel.mjs",
"--plan",
"--files",
"src/auto-reply/reply/followup-runner.test.ts",
],
{
cwd: repoRoot,
env: clearPlannerShardEnv(process.env),
encoding: "utf8",
},
);
const output = runPlannerPlan([
"--plan",
"--files",
"src/auto-reply/reply/followup-runner.test.ts",
]);
expect(output).toContain("base-pinned-followup-runner");
expect(output).not.toContain("base-followup-runner");
});
it("reports capability-derived output for mid-memory local macOS hosts", () => {
const repoRoot = path.resolve(import.meta.dirname, "../..");
const output = execFileSync(
"node",
["scripts/test-parallel.mjs", "--plan", "--surface", "unit", "--surface", "extensions"],
{
cwd: repoRoot,
env: {
...clearPlannerShardEnv(process.env),
CI: "",
GITHUB_ACTIONS: "",
RUNNER_OS: "macOS",
OPENCLAW_TEST_HOST_CPU_COUNT: "10",
OPENCLAW_TEST_HOST_MEMORY_GIB: "64",
OPENCLAW_TEST_LOAD_AWARE: "0",
},
encoding: "utf8",
},
const output = runPlannerPlan(
["--plan", "--surface", "unit", "--surface", "extensions"],
createLocalPlannerEnv({
RUNNER_OS: "macOS",
OPENCLAW_TEST_HOST_CPU_COUNT: "10",
OPENCLAW_TEST_HOST_MEMORY_GIB: "64",
}),
);
expect(output).toContain("mode=local intent=normal memoryBand=mid");
@@ -269,47 +249,29 @@ describe("scripts/test-parallel lane planning", () => {
});
it("uses higher shared extension worker counts on high-memory local hosts", () => {
const repoRoot = path.resolve(import.meta.dirname, "../..");
const highMemoryOutput = execFileSync(
"node",
["scripts/test-parallel.mjs", "--plan", "--surface", "extensions"],
{
cwd: repoRoot,
env: {
...clearPlannerShardEnv(process.env),
CI: "",
GITHUB_ACTIONS: "",
RUNNER_OS: "macOS",
OPENCLAW_TEST_HOST_CPU_COUNT: "12",
OPENCLAW_TEST_HOST_MEMORY_GIB: "128",
OPENCLAW_TEST_LOAD_AWARE: "0",
},
encoding: "utf8",
},
const highMemoryOutput = runPlannerPlan(
["--plan", "--surface", "extensions"],
createLocalPlannerEnv({
RUNNER_OS: "macOS",
OPENCLAW_TEST_HOST_CPU_COUNT: "12",
OPENCLAW_TEST_HOST_MEMORY_GIB: "128",
}),
);
const midMemoryOutput = execFileSync(
"node",
["scripts/test-parallel.mjs", "--plan", "--surface", "extensions"],
{
cwd: repoRoot,
env: {
...clearPlannerShardEnv(process.env),
CI: "",
GITHUB_ACTIONS: "",
RUNNER_OS: "macOS",
OPENCLAW_TEST_HOST_CPU_COUNT: "10",
OPENCLAW_TEST_HOST_MEMORY_GIB: "64",
OPENCLAW_TEST_LOAD_AWARE: "0",
},
encoding: "utf8",
},
const midMemoryOutput = runPlannerPlan(
["--plan", "--surface", "extensions"],
createLocalPlannerEnv({
RUNNER_OS: "macOS",
OPENCLAW_TEST_HOST_CPU_COUNT: "10",
OPENCLAW_TEST_HOST_MEMORY_GIB: "64",
}),
);
expect(midMemoryOutput).toContain("extensions-batch-1 filters=all maxWorkers=3");
expect(midMemoryOutput).toContain("extensions-batch-3 filters=all maxWorkers=3");
expect(midMemoryOutput).toContain("extensions-batch-2 filters=all maxWorkers=3");
expect(midMemoryOutput).not.toContain("extensions-batch-3");
expect(highMemoryOutput).toContain("extensions-batch-1 filters=all maxWorkers=5");
expect(highMemoryOutput).toContain("extensions-batch-3 filters=all maxWorkers=5");
expect(highMemoryOutput).not.toContain("extensions-batch-4");
expect(highMemoryOutput).toContain("extensions-batch-2 filters=all maxWorkers=5");
expect(highMemoryOutput).not.toContain("extensions-batch-3");
});
it("starts isolated channel lanes before shared extension batches on high-memory local hosts", () => {
@@ -334,29 +296,18 @@ describe("scripts/test-parallel lane planning", () => {
});
it("uses earlier targeted channel batching on high-memory local hosts", () => {
const repoRoot = path.resolve(import.meta.dirname, "../..");
const output = execFileSync(
"node",
const output = runPlannerPlan(
[
"scripts/test-parallel.mjs",
"--plan",
"--surface",
"channels",
...targetedChannelProxyFiles.flatMap((file) => ["--files", file]),
],
{
cwd: repoRoot,
env: {
...clearPlannerShardEnv(process.env),
CI: "",
GITHUB_ACTIONS: "",
RUNNER_OS: "macOS",
OPENCLAW_TEST_HOST_CPU_COUNT: "12",
OPENCLAW_TEST_HOST_MEMORY_GIB: "128",
OPENCLAW_TEST_LOAD_AWARE: "0",
},
encoding: "utf8",
},
createLocalPlannerEnv({
RUNNER_OS: "macOS",
OPENCLAW_TEST_HOST_CPU_COUNT: "12",
OPENCLAW_TEST_HOST_MEMORY_GIB: "128",
}),
);
expect(output).toContain("channels-batch-1 filters=33");
@@ -365,29 +316,18 @@ describe("scripts/test-parallel lane planning", () => {
});
it("uses targeted unit batching on high-memory local hosts", () => {
const repoRoot = path.resolve(import.meta.dirname, "../..");
const output = execFileSync(
"node",
const output = runPlannerPlan(
[
"scripts/test-parallel.mjs",
"--plan",
"--surface",
"unit",
...targetedUnitProxyFiles.flatMap((file) => ["--files", file]),
],
{
cwd: repoRoot,
env: {
...clearPlannerShardEnv(process.env),
CI: "",
GITHUB_ACTIONS: "",
RUNNER_OS: "macOS",
OPENCLAW_TEST_HOST_CPU_COUNT: "12",
OPENCLAW_TEST_HOST_MEMORY_GIB: "128",
OPENCLAW_TEST_LOAD_AWARE: "0",
},
encoding: "utf8",
},
createLocalPlannerEnv({
RUNNER_OS: "macOS",
OPENCLAW_TEST_HOST_CPU_COUNT: "12",
OPENCLAW_TEST_HOST_MEMORY_GIB: "128",
}),
);
expect(output).toContain("unit-batch-1 filters=50");
@@ -396,16 +336,7 @@ describe("scripts/test-parallel lane planning", () => {
});
it("explains targeted file ownership and execution policy", () => {
const repoRoot = path.resolve(import.meta.dirname, "../..");
const output = execFileSync(
"node",
["scripts/test-parallel.mjs", "--explain", "src/auto-reply/reply/followup-runner.test.ts"],
{
cwd: repoRoot,
env: clearPlannerShardEnv(process.env),
encoding: "utf8",
},
);
const output = runPlannerPlan(["--explain", "src/auto-reply/reply/followup-runner.test.ts"]);
expect(output).toContain("surface=base");
expect(output).toContain("reasons=base-surface,base-pinned-manifest");
@@ -413,23 +344,17 @@ describe("scripts/test-parallel lane planning", () => {
});
it("prints the planner-backed CI manifest as JSON", () => {
const repoRoot = path.resolve(import.meta.dirname, "../..");
const output = execFileSync("node", ["scripts/test-parallel.mjs", "--ci-manifest"], {
cwd: repoRoot,
env: {
...clearPlannerShardEnv(process.env),
GITHUB_EVENT_NAME: "pull_request",
OPENCLAW_CI_DOCS_ONLY: "false",
OPENCLAW_CI_DOCS_CHANGED: "false",
OPENCLAW_CI_RUN_NODE: "true",
OPENCLAW_CI_RUN_MACOS: "true",
OPENCLAW_CI_RUN_ANDROID: "false",
OPENCLAW_CI_RUN_WINDOWS: "true",
OPENCLAW_CI_RUN_SKILLS_PYTHON: "false",
OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: "false",
OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX: '{"include":[]}',
},
encoding: "utf8",
const output = runPlannerPlan(["--ci-manifest"], {
GITHUB_EVENT_NAME: "pull_request",
OPENCLAW_CI_DOCS_ONLY: "false",
OPENCLAW_CI_DOCS_CHANGED: "false",
OPENCLAW_CI_RUN_NODE: "true",
OPENCLAW_CI_RUN_MACOS: "true",
OPENCLAW_CI_RUN_ANDROID: "false",
OPENCLAW_CI_RUN_WINDOWS: "true",
OPENCLAW_CI_RUN_SKILLS_PYTHON: "false",
OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: "false",
OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX: '{"include":[]}',
});
const manifest = JSON.parse(output);
@@ -513,28 +438,13 @@ describe("scripts/test-parallel lane planning", () => {
});
it("passes through vitest --mode values that are not wrapper runtime overrides", () => {
const repoRoot = path.resolve(import.meta.dirname, "../..");
const output = execFileSync(
"node",
[
"scripts/test-parallel.mjs",
"--plan",
"--mode",
"development",
"src/infra/outbound/deliver.test.ts",
],
{
cwd: repoRoot,
env: {
...clearPlannerShardEnv(process.env),
CI: "",
GITHUB_ACTIONS: "",
RUNNER_OS: "Linux",
OPENCLAW_TEST_HOST_CPU_COUNT: "16",
OPENCLAW_TEST_HOST_MEMORY_GIB: "128",
},
encoding: "utf8",
},
const output = runPlannerPlan(
["--plan", "--mode", "development", "src/infra/outbound/deliver.test.ts"],
createLocalPlannerEnv({
RUNNER_OS: "Linux",
OPENCLAW_TEST_HOST_CPU_COUNT: "16",
OPENCLAW_TEST_HOST_MEMORY_GIB: "128",
}),
);
expect(output).toContain("mode=local intent=normal memoryBand=high");
@@ -542,90 +452,43 @@ describe("scripts/test-parallel lane planning", () => {
});
it("rejects removed machine-name profiles", () => {
const repoRoot = path.resolve(import.meta.dirname, "../..");
expect(() =>
execFileSync("node", ["scripts/test-parallel.mjs", "--plan", "--profile", "macmini"], {
cwd: repoRoot,
env: clearPlannerShardEnv(process.env),
encoding: "utf8",
}),
).toThrowError(/Unsupported test profile "macmini"/u);
expect(() => runPlannerPlan(["--plan", "--profile", "macmini"])).toThrowError(
/Unsupported test profile "macmini"/u,
);
});
it("rejects unknown explicit surface names", () => {
const repoRoot = path.resolve(import.meta.dirname, "../..");
expect(() =>
execFileSync("node", ["scripts/test-parallel.mjs", "--plan", "--surface", "channel"], {
cwd: repoRoot,
env: clearPlannerShardEnv(process.env),
encoding: "utf8",
}),
).toThrowError(/Unsupported --surface value\(s\): channel/u);
expect(() => runPlannerPlan(["--plan", "--surface", "channel"])).toThrowError(
/Unsupported --surface value\(s\): channel/u,
);
});
it("rejects wrapper --files values that look like options", () => {
const repoRoot = path.resolve(import.meta.dirname, "../..");
expect(() =>
execFileSync("node", ["scripts/test-parallel.mjs", "--plan", "--files", "--config"], {
cwd: repoRoot,
env: clearPlannerShardEnv(process.env),
encoding: "utf8",
}),
).toThrowError(/Invalid --files value/u);
expect(() => runPlannerPlan(["--plan", "--files", "--config"])).toThrowError(
/Invalid --files value/u,
);
});
it("rejects missing --profile values", () => {
const repoRoot = path.resolve(import.meta.dirname, "../..");
expect(() =>
execFileSync("node", ["scripts/test-parallel.mjs", "--plan", "--profile"], {
cwd: repoRoot,
env: clearPlannerShardEnv(process.env),
encoding: "utf8",
}),
).toThrowError(/Invalid --profile value/u);
expect(() => runPlannerPlan(["--plan", "--profile"])).toThrowError(/Invalid --profile value/u);
});
it("rejects missing --surface values", () => {
const repoRoot = path.resolve(import.meta.dirname, "../..");
expect(() =>
execFileSync("node", ["scripts/test-parallel.mjs", "--plan", "--surface"], {
cwd: repoRoot,
env: clearPlannerShardEnv(process.env),
encoding: "utf8",
}),
).toThrowError(/Invalid --surface value/u);
expect(() => runPlannerPlan(["--plan", "--surface"])).toThrowError(/Invalid --surface value/u);
});
it("rejects missing --explain values", () => {
const repoRoot = path.resolve(import.meta.dirname, "../..");
expect(() =>
execFileSync("node", ["scripts/test-parallel.mjs", "--explain"], {
cwd: repoRoot,
env: clearPlannerShardEnv(process.env),
encoding: "utf8",
}),
).toThrowError(/Invalid --explain value/u);
expect(() => runPlannerPlan(["--explain"])).toThrowError(/Invalid --explain value/u);
});
it("rejects explicit existing files that are not known test files", () => {
const repoRoot = path.resolve(import.meta.dirname, "../..");
const tempFilePath = path.join(os.tmpdir(), `openclaw-non-test-${Date.now()}.ts`);
fs.writeFileSync(tempFilePath, "export const notATest = true;\n", "utf8");
try {
expect(() =>
execFileSync("node", ["scripts/test-parallel.mjs", "--plan", "--files", tempFilePath], {
cwd: repoRoot,
env: clearPlannerShardEnv(process.env),
encoding: "utf8",
}),
).toThrowError(/is not a known test file/u);
expect(() => runPlannerPlan(["--plan", "--files", tempFilePath])).toThrowError(
/is not a known test file/u,
);
} finally {
fs.rmSync(tempFilePath, { force: true });
}

Some files were not shown because too many files have changed in this diff Show More