mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-07 06:21:32 +08:00
Compare commits
4 Commits
fix/cli-ru
...
codex/plug
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53ac244ec6 | ||
|
|
c94f10b915 | ||
|
|
70b43319ff | ||
|
|
487f752754 |
@@ -21,7 +21,6 @@ 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
|
||||
|
||||
@@ -38,7 +37,6 @@ 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.
|
||||
|
||||
@@ -244,7 +244,7 @@
|
||||
"exportName": "CliBackendPlugin",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1316,
|
||||
"line": 1346,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -406,7 +406,7 @@
|
||||
"exportName": "OpenClawPluginApi",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1360,
|
||||
"line": 1390,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -3423,7 +3423,7 @@
|
||||
"exportName": "OpenClawPluginApi",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1360,
|
||||
"line": 1390,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -3432,7 +3432,7 @@
|
||||
"exportName": "OpenClawPluginCommandDefinition",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1086,
|
||||
"line": 1108,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -3450,7 +3450,7 @@
|
||||
"exportName": "OpenClawPluginDefinition",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1342,
|
||||
"line": 1372,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -3459,7 +3459,7 @@
|
||||
"exportName": "OpenClawPluginService",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1309,
|
||||
"line": 1339,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -3468,7 +3468,7 @@
|
||||
"exportName": "OpenClawPluginServiceContext",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1301,
|
||||
"line": 1331,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -3504,7 +3504,7 @@
|
||||
"exportName": "PluginInteractiveTelegramHandlerContext",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1115,
|
||||
"line": 1145,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -3929,7 +3929,7 @@
|
||||
"exportName": "OpenClawPluginApi",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1360,
|
||||
"line": 1390,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -3938,7 +3938,7 @@
|
||||
"exportName": "OpenClawPluginCommandDefinition",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1086,
|
||||
"line": 1108,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -3956,7 +3956,7 @@
|
||||
"exportName": "OpenClawPluginDefinition",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1342,
|
||||
"line": 1372,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -3965,7 +3965,7 @@
|
||||
"exportName": "OpenClawPluginService",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1309,
|
||||
"line": 1339,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -3974,7 +3974,7 @@
|
||||
"exportName": "OpenClawPluginServiceContext",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1301,
|
||||
"line": 1331,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
@@ -4010,7 +4010,7 @@
|
||||
"exportName": "PluginInteractiveTelegramHandlerContext",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 1115,
|
||||
"line": 1145,
|
||||
"path": "src/plugins/types.ts"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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":1316,"sourcePath":"src/plugins/types.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 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":1360,"sourcePath":"src/plugins/types.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 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":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 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 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":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 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 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":1115,"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 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":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 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 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":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 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 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":1115,"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 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"}
|
||||
|
||||
@@ -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`)
|
||||
- **Microsoft** (primary or fallback provider; current bundled implementation uses `node-edge-tts`, default when no API keys)
|
||||
- **OpenAI** (primary or fallback provider; also used for summaries)
|
||||
|
||||
### Microsoft speech notes
|
||||
@@ -38,7 +38,9 @@ If you want OpenAI or ElevenLabs:
|
||||
- `ELEVENLABS_API_KEY` (or `XI_API_KEY`)
|
||||
- `OPENAI_API_KEY`
|
||||
|
||||
Microsoft speech does **not** require an 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`).
|
||||
|
||||
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`),
|
||||
@@ -58,8 +60,8 @@ so that provider must also be authenticated if you enable summaries.
|
||||
No. Auto‑TTS is **off** by default. Enable it in config with
|
||||
`messages.tts.auto` or per session with `/tts always` (alias: `/tts on`).
|
||||
|
||||
When `messages.tts.provider` is unset, OpenClaw picks the first configured
|
||||
speech provider in registry auto-select order.
|
||||
Microsoft speech **is** enabled by default once TTS is on, and is used automatically
|
||||
when no OpenAI or ElevenLabs API keys are available.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -91,28 +93,26 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
modelOverrides: {
|
||||
enabled: true,
|
||||
},
|
||||
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,
|
||||
},
|
||||
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,15 +128,13 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
tts: {
|
||||
auto: "always",
|
||||
provider: "microsoft",
|
||||
providers: {
|
||||
microsoft: {
|
||||
enabled: true,
|
||||
voice: "en-US-MichelleNeural",
|
||||
lang: "en-US",
|
||||
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
|
||||
rate: "+10%",
|
||||
pitch: "-5%",
|
||||
},
|
||||
microsoft: {
|
||||
enabled: true,
|
||||
voice: "en-US-MichelleNeural",
|
||||
lang: "en-US",
|
||||
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
|
||||
rate: "+10%",
|
||||
pitch: "-5%",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -149,10 +147,8 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
providers: {
|
||||
microsoft: {
|
||||
enabled: false,
|
||||
},
|
||||
microsoft: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -212,37 +208,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 uses the first configured speech provider in registry auto-select order.
|
||||
- If `provider` is **unset**, OpenClaw prefers `openai` (if key), then `elevenlabs` (if key),
|
||||
otherwise `microsoft`.
|
||||
- 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`).
|
||||
- `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`
|
||||
- `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`
|
||||
- Non-default values are treated as OpenAI-compatible TTS endpoints, so custom model and voice names are accepted.
|
||||
- `providers.elevenlabs.voiceSettings`:
|
||||
- `elevenlabs.voiceSettings`:
|
||||
- `stability`, `similarityBoost`, `style`: `0..1`
|
||||
- `useSpeakerBoost`: `true|false`
|
||||
- `speed`: `0.5..2.0` (1.0 = normal)
|
||||
- `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`).
|
||||
- `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`).
|
||||
- See Microsoft Speech output formats for valid values; not all formats are supported by the bundled Edge-backed transport.
|
||||
- `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).
|
||||
- `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).
|
||||
- `edge.*`: legacy alias for the same Microsoft settings.
|
||||
|
||||
## Model-driven overrides (default on)
|
||||
|
||||
108
docs/tts.md
108
docs/tts.md
@@ -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`)
|
||||
- **Microsoft** (primary or fallback provider; current bundled implementation uses `node-edge-tts`, default when no API keys)
|
||||
- **OpenAI** (primary or fallback provider; also used for summaries)
|
||||
|
||||
### Microsoft speech notes
|
||||
@@ -38,7 +38,9 @@ If you want OpenAI or ElevenLabs:
|
||||
- `ELEVENLABS_API_KEY` (or `XI_API_KEY`)
|
||||
- `OPENAI_API_KEY`
|
||||
|
||||
Microsoft speech does **not** require an 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`).
|
||||
|
||||
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`),
|
||||
@@ -58,8 +60,8 @@ so that provider must also be authenticated if you enable summaries.
|
||||
No. Auto‑TTS is **off** by default. Enable it in config with
|
||||
`messages.tts.auto` or per session with `/tts always` (alias: `/tts on`).
|
||||
|
||||
When `messages.tts.provider` is unset, OpenClaw picks the first configured
|
||||
speech provider in registry auto-select order.
|
||||
Microsoft speech **is** enabled by default once TTS is on, and is used automatically
|
||||
when no OpenAI or ElevenLabs API keys are available.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -91,28 +93,26 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
modelOverrides: {
|
||||
enabled: true,
|
||||
},
|
||||
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,
|
||||
},
|
||||
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,15 +128,13 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
tts: {
|
||||
auto: "always",
|
||||
provider: "microsoft",
|
||||
providers: {
|
||||
microsoft: {
|
||||
enabled: true,
|
||||
voice: "en-US-MichelleNeural",
|
||||
lang: "en-US",
|
||||
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
|
||||
rate: "+10%",
|
||||
pitch: "-5%",
|
||||
},
|
||||
microsoft: {
|
||||
enabled: true,
|
||||
voice: "en-US-MichelleNeural",
|
||||
lang: "en-US",
|
||||
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
|
||||
rate: "+10%",
|
||||
pitch: "-5%",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -149,10 +147,8 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
providers: {
|
||||
microsoft: {
|
||||
enabled: false,
|
||||
},
|
||||
microsoft: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -212,37 +208,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 uses the first configured speech provider in registry auto-select order.
|
||||
- If `provider` is **unset**, OpenClaw prefers `openai` (if key), then `elevenlabs` (if key),
|
||||
otherwise `microsoft`.
|
||||
- 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`).
|
||||
- `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`
|
||||
- `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`
|
||||
- Non-default values are treated as OpenAI-compatible TTS endpoints, so custom model and voice names are accepted.
|
||||
- `providers.elevenlabs.voiceSettings`:
|
||||
- `elevenlabs.voiceSettings`:
|
||||
- `stability`, `similarityBoost`, `style`: `0..1`
|
||||
- `useSpeakerBoost`: `true|false`
|
||||
- `speed`: `0.5..2.0` (1.0 = normal)
|
||||
- `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`).
|
||||
- `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`).
|
||||
- See Microsoft Speech output formats for valid values; not all formats are supported by the bundled Edge-backed transport.
|
||||
- `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).
|
||||
- `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).
|
||||
- `edge.*`: legacy alias for the same Microsoft settings.
|
||||
|
||||
## Model-driven overrides (default on)
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -120,9 +121,12 @@ function createChannelRuntime(
|
||||
}
|
||||
|
||||
function createCommandContext(params?: Partial<PluginCommandContext>): PluginCommandContext {
|
||||
const surface = params?.surface ?? params?.channel ?? "webchat";
|
||||
return {
|
||||
channel: "webchat",
|
||||
surface,
|
||||
channel: surface,
|
||||
isAuthorizedSender: true,
|
||||
senderIsOwner: false,
|
||||
commandBody: "/pair qr",
|
||||
args: "qr",
|
||||
config: {},
|
||||
@@ -446,14 +450,20 @@ describe("device-pair /pair approve", () => {
|
||||
});
|
||||
|
||||
const command = registerPairCommand();
|
||||
const result = await command.handler(
|
||||
createCommandContext({
|
||||
channel: "webchat",
|
||||
args: "approve latest",
|
||||
commandBody: "/pair approve latest",
|
||||
gatewayClientScopes: ["operator.write"],
|
||||
}),
|
||||
);
|
||||
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,
|
||||
});
|
||||
|
||||
expect(vi.mocked(approveDevicePairing)).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
@@ -501,14 +511,20 @@ describe("device-pair /pair approve", () => {
|
||||
});
|
||||
|
||||
const command = registerPairCommand();
|
||||
const result = await command.handler(
|
||||
createCommandContext({
|
||||
channel: "webchat",
|
||||
args: "approve latest",
|
||||
commandBody: "/pair approve latest",
|
||||
gatewayClientScopes: ["operator.write", "operator.pairing"],
|
||||
}),
|
||||
);
|
||||
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,
|
||||
});
|
||||
|
||||
expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1");
|
||||
expect(result).toEqual({ text: "✅ Paired Victim Phone (ios)." });
|
||||
|
||||
@@ -546,13 +546,14 @@ 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"
|
||||
@@ -574,15 +575,6 @@ 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) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import {
|
||||
createResolvedDirectoryEntriesLister,
|
||||
listResolvedDirectoryEntriesFromSources,
|
||||
type DirectoryConfigParams,
|
||||
} from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { mergeDiscordAccountConfig, resolveDefaultDiscordAccountId } from "./accounts.js";
|
||||
@@ -18,36 +18,38 @@ function resolveDiscordDirectoryConfigAccount(
|
||||
};
|
||||
}
|
||||
|
||||
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 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 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;
|
||||
},
|
||||
});
|
||||
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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -40,7 +40,9 @@ describe("createDiscordGatewaySupervisor", () => {
|
||||
error: vi.fn(),
|
||||
};
|
||||
const supervisor = createDiscordGatewaySupervisor({
|
||||
gateway: { emitter },
|
||||
client: {
|
||||
getPlugin: vi.fn(() => ({ emitter })),
|
||||
} as never,
|
||||
isDisallowedIntentsError: (err) => String(err).includes("4014"),
|
||||
runtime: runtime as never,
|
||||
});
|
||||
@@ -70,7 +72,9 @@ describe("createDiscordGatewaySupervisor", () => {
|
||||
|
||||
it("is idempotent on dispose and noops without an emitter", () => {
|
||||
const supervisor = createDiscordGatewaySupervisor({
|
||||
gateway: undefined,
|
||||
client: {
|
||||
getPlugin: vi.fn(() => undefined),
|
||||
} as never,
|
||||
isDisallowedIntentsError: () => false,
|
||||
runtime: { error: vi.fn() } as never,
|
||||
});
|
||||
@@ -86,7 +90,9 @@ describe("createDiscordGatewaySupervisor", () => {
|
||||
const emitter = new EventEmitter();
|
||||
const runtime = { error: vi.fn() };
|
||||
const supervisor = createDiscordGatewaySupervisor({
|
||||
gateway: { emitter },
|
||||
client: {
|
||||
getPlugin: vi.fn(() => ({ emitter })),
|
||||
} as never,
|
||||
isDisallowedIntentsError: () => false,
|
||||
runtime: runtime as never,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -66,11 +67,12 @@ export function classifyDiscordGatewayEvent(params: {
|
||||
}
|
||||
|
||||
export function createDiscordGatewaySupervisor(params: {
|
||||
gateway?: unknown;
|
||||
client: Client;
|
||||
isDisallowedIntentsError: (err: unknown) => boolean;
|
||||
runtime: RuntimeEnv;
|
||||
}): DiscordGatewaySupervisor {
|
||||
const emitter = getDiscordGatewayEmitter(params.gateway);
|
||||
const gateway = params.client.getPlugin("gateway");
|
||||
const emitter = getDiscordGatewayEmitter(gateway);
|
||||
const pending: DiscordGatewayEvent[] = [];
|
||||
if (!emitter) {
|
||||
return {
|
||||
@@ -84,36 +86,39 @@ export function createDiscordGatewaySupervisor(params: {
|
||||
|
||||
let lifecycleHandler: ((event: DiscordGatewayEvent) => void) | undefined;
|
||||
let phase: GatewaySupervisorPhase = "buffering";
|
||||
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}`,
|
||||
),
|
||||
);
|
||||
};
|
||||
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 onGatewayError = (err: unknown) => {
|
||||
const event = classifyDiscordGatewayEvent({
|
||||
err,
|
||||
isDisallowedIntentsError: params.isDisallowedIntentsError,
|
||||
});
|
||||
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 === "disposed") {
|
||||
logLateDisposedEvent(event);
|
||||
return;
|
||||
}
|
||||
if (phase === "active" && lifecycleHandler) {
|
||||
lifecycleHandler(event);
|
||||
return;
|
||||
}
|
||||
if (phase === "teardown") {
|
||||
logLateTeardownEvent(event);
|
||||
return;
|
||||
}
|
||||
pending.push(event);
|
||||
};
|
||||
emitter.on("error", onGatewayError);
|
||||
|
||||
@@ -141,9 +146,10 @@ export function createDiscordGatewaySupervisor(params: {
|
||||
return "continue";
|
||||
},
|
||||
dispose: () => {
|
||||
if (phase === "disposed") {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
disposed = true;
|
||||
lifecycleHandler = undefined;
|
||||
phase = "disposed";
|
||||
pending.length = 0;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -42,7 +43,6 @@ vi.mock("./gateway-registry.js", () => ({
|
||||
|
||||
describe("runDiscordGatewayLifecycle", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
attachDiscordGatewayLoggingMock.mockClear();
|
||||
getDiscordGatewayEmitterMock.mockClear();
|
||||
waitForDiscordGatewayStopMock.mockClear();
|
||||
@@ -72,13 +72,6 @@ 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();
|
||||
@@ -103,7 +96,7 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
return "continue";
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
emitter: gateway.emitter,
|
||||
emitter: params?.gateway?.emitter,
|
||||
};
|
||||
const statusSink = vi.fn();
|
||||
const runtime: RuntimeEnv = {
|
||||
@@ -121,7 +114,9 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
statusSink,
|
||||
lifecycleParams: {
|
||||
accountId: params?.accountId ?? "default",
|
||||
gateway,
|
||||
client: {
|
||||
getPlugin: vi.fn((name: string) => (name === "gateway" ? params?.gateway : undefined)),
|
||||
} as unknown as Client,
|
||||
runtime,
|
||||
isDisallowedIntentsError: params?.isDisallowedIntentsError ?? (() => false),
|
||||
voiceManager: null,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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";
|
||||
@@ -69,7 +70,7 @@ async function waitForDiscordGatewayReady(params: {
|
||||
|
||||
export async function runDiscordGatewayLifecycle(params: {
|
||||
accountId: string;
|
||||
gateway?: GatewayPlugin;
|
||||
client: Client;
|
||||
runtime: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
isDisallowedIntentsError: (err: unknown) => boolean;
|
||||
@@ -84,7 +85,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.gateway;
|
||||
const gateway = params.client.getPlugin<GatewayPlugin>("gateway");
|
||||
if (gateway) {
|
||||
registerGateway(params.accountId, gateway);
|
||||
}
|
||||
|
||||
@@ -947,15 +947,15 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
},
|
||||
clientPlugins,
|
||||
);
|
||||
lifecycleGateway = client.getPlugin<GatewayPlugin>("gateway");
|
||||
gatewaySupervisor = (
|
||||
createDiscordGatewaySupervisorForTesting ?? createDiscordGatewaySupervisor
|
||||
)({
|
||||
gateway: lifecycleGateway,
|
||||
client,
|
||||
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,
|
||||
gateway: lifecycleGateway,
|
||||
client,
|
||||
runtime,
|
||||
abortSignal: opts.abortSignal,
|
||||
statusSink: opts.setStatus,
|
||||
|
||||
@@ -3,16 +3,17 @@ 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";
|
||||
@@ -105,14 +106,20 @@ export async function promptIMessageAllowFrom(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export const imessageDmPolicy = createTopLevelChannelDmPolicy({
|
||||
export const imessageDmPolicy: ChannelSetupDmPolicy = {
|
||||
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";
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
|
||||
import {
|
||||
createChannelDirectoryAdapter,
|
||||
createResolvedDirectoryEntriesLister,
|
||||
listResolvedDirectoryEntriesFromSources,
|
||||
} from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { runStoppablePassiveMonitor } from "openclaw/plugin-sdk/extension-shared";
|
||||
import {
|
||||
@@ -61,30 +61,6 @@ 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,
|
||||
@@ -251,9 +227,32 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = createChat
|
||||
},
|
||||
},
|
||||
directory: createChannelDirectoryAdapter({
|
||||
listPeers: async (params) => listIrcDirectoryPeersFromConfig(params),
|
||||
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,
|
||||
}),
|
||||
listGroups: async (params) => {
|
||||
const entries = await listIrcDirectoryGroupsFromConfig(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;
|
||||
},
|
||||
});
|
||||
return entries.map((entry) => ({ ...entry, name: entry.id }));
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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,46 +78,6 @@ 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>,
|
||||
@@ -286,14 +246,53 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount, MatrixProbe> =
|
||||
},
|
||||
directory: createChannelDirectoryAdapter({
|
||||
listPeers: async (params) => {
|
||||
const entries = await listMatrixDirectoryPeersFromConfig(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;
|
||||
},
|
||||
});
|
||||
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) => await listMatrixDirectoryGroupsFromConfig(params),
|
||||
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;
|
||||
},
|
||||
}),
|
||||
...createRuntimeDirectoryLiveAdapter({
|
||||
getRuntime: loadMatrixChannelRuntime,
|
||||
listPeersLive: (runtime) => runtime.listMatrixDirectoryPeersLive,
|
||||
|
||||
@@ -28,7 +28,6 @@ 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", () => {
|
||||
@@ -40,8 +39,7 @@ describe("matrix client storage paths", () => {
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ maybeMigrateLegacyStorage, resolveMatrixStateFilePath, resolveMatrixStoragePaths } =
|
||||
await import("./storage.js"));
|
||||
({ maybeMigrateLegacyStorage, resolveMatrixStoragePaths } = await import("./storage.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -113,26 +111,6 @@ 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: {
|
||||
|
||||
@@ -11,7 +11,6 @@ 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";
|
||||
@@ -294,25 +293,6 @@ 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;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from "node:path";
|
||||
import { readJsonFileWithFallback, writeJsonFileAtomically } from "../../runtime-api.js";
|
||||
import { createAsyncLock } from "../async-lock.js";
|
||||
import { resolveMatrixStateFilePath } from "../client/storage.js";
|
||||
import { resolveMatrixStoragePaths } from "../client/storage.js";
|
||||
import type { MatrixAuth } from "../client/types.js";
|
||||
import { LogService } from "../sdk/logger.js";
|
||||
|
||||
@@ -43,12 +44,16 @@ function resolveInboundDedupeStatePath(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
}): string {
|
||||
return resolveMatrixStateFilePath({
|
||||
auth: params.auth,
|
||||
const storagePaths = resolveMatrixStoragePaths({
|
||||
homeserver: params.auth.homeserver,
|
||||
userId: params.auth.userId,
|
||||
accessToken: params.auth.accessToken,
|
||||
accountId: params.auth.accountId,
|
||||
deviceId: params.auth.deviceId,
|
||||
env: params.env,
|
||||
stateDir: params.stateDir,
|
||||
filename: INBOUND_DEDUPE_FILENAME,
|
||||
});
|
||||
return path.join(storagePaths.rootDir, INBOUND_DEDUPE_FILENAME);
|
||||
}
|
||||
|
||||
function normalizeTimestamp(raw: unknown): number | null {
|
||||
|
||||
@@ -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 { resolveMatrixStateFilePath, resolveMatrixStoragePaths } from "./client/storage.js";
|
||||
import { resolveMatrixStoragePaths } from "./client/storage.js";
|
||||
import {
|
||||
createMatrixThreadBindingManager,
|
||||
resetMatrixThreadBindingsForTests,
|
||||
@@ -87,12 +87,14 @@ describe("matrix thread bindings", () => {
|
||||
}
|
||||
|
||||
function resolveBindingsFilePath(customStateDir?: string) {
|
||||
return resolveMatrixStateFilePath({
|
||||
auth,
|
||||
env: process.env,
|
||||
...(customStateDir ? { stateDir: customStateDir } : {}),
|
||||
filename: "thread-bindings.json",
|
||||
});
|
||||
return path.join(
|
||||
resolveMatrixStoragePaths({
|
||||
...auth,
|
||||
env: process.env,
|
||||
...(customStateDir ? { stateDir: customStateDir } : {}),
|
||||
}).rootDir,
|
||||
"thread-bindings.json",
|
||||
);
|
||||
}
|
||||
|
||||
async function readPersistedLastActivityAt(bindingsPath: string) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import path from "node:path";
|
||||
import type { SessionBindingAdapter } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import {
|
||||
readJsonFileWithFallback,
|
||||
@@ -7,7 +8,7 @@ import {
|
||||
unregisterSessionBindingAdapter,
|
||||
writeJsonFileAtomically,
|
||||
} from "../runtime-api.js";
|
||||
import { resolveMatrixStateFilePath } from "./client/storage.js";
|
||||
import { resolveMatrixStoragePaths } from "./client/storage.js";
|
||||
import type { MatrixAuth } from "./client/types.js";
|
||||
import type { MatrixClient } from "./sdk.js";
|
||||
import { sendMessageMatrix } from "./send.js";
|
||||
@@ -61,13 +62,16 @@ function resolveBindingsPath(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
}): string {
|
||||
return resolveMatrixStateFilePath({
|
||||
auth: params.auth,
|
||||
const storagePaths = resolveMatrixStoragePaths({
|
||||
homeserver: params.auth.homeserver,
|
||||
userId: params.auth.userId,
|
||||
accessToken: params.auth.accessToken,
|
||||
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) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
colorize,
|
||||
defaultRuntime,
|
||||
formatErrorMessage,
|
||||
getMemorySearchManager,
|
||||
isRich,
|
||||
listMemoryFiles,
|
||||
loadConfig,
|
||||
@@ -24,7 +25,6 @@ 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"];
|
||||
|
||||
@@ -24,20 +24,13 @@ 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;
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./api.js";
|
||||
export * from "./memory/index.js";
|
||||
@@ -1,5 +1,8 @@
|
||||
import { resolveMemoryBackendConfig, type MemoryPluginRuntime } from "./api.js";
|
||||
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./runtime-api.js";
|
||||
import {
|
||||
getMemorySearchManager,
|
||||
resolveMemoryBackendConfig,
|
||||
type MemoryPluginRuntime,
|
||||
} from "./api.js";
|
||||
|
||||
export const memoryRuntime: MemoryPluginRuntime = {
|
||||
async getMemorySearchManager(params) {
|
||||
@@ -12,7 +15,4 @@ export const memoryRuntime: MemoryPluginRuntime = {
|
||||
resolveMemoryBackendConfig(params) {
|
||||
return resolveMemoryBackendConfig(params);
|
||||
},
|
||||
async closeAllMemorySearchManagers() {
|
||||
await closeAllMemorySearchManagers();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export { readAgentMemoryFile, resolveMemoryBackendConfig } from "./api.js";
|
||||
export { getMemorySearchManager } from "./runtime-api.js";
|
||||
export { getMemorySearchManager, readAgentMemoryFile, resolveMemoryBackendConfig } from "./api.js";
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
|
||||
type MemoryToolRuntime = typeof import("./tools.runtime.js");
|
||||
type MemorySearchManagerResult = Awaited<
|
||||
ReturnType<(typeof import("./runtime-api.js"))["getMemorySearchManager"]>
|
||||
ReturnType<(typeof import("./api.js"))["getMemorySearchManager"]>
|
||||
>;
|
||||
|
||||
let memoryToolRuntimePromise: Promise<MemoryToolRuntime> | null = null;
|
||||
|
||||
@@ -40,8 +40,10 @@ function createApi(params: {
|
||||
|
||||
function createCommandContext(args: string): PluginCommandContext {
|
||||
return {
|
||||
surface: "test",
|
||||
channel: "test",
|
||||
isAuthorizedSender: true,
|
||||
senderIsOwner: false,
|
||||
commandBody: `/phone ${args}`,
|
||||
args,
|
||||
config: {},
|
||||
|
||||
@@ -3,17 +3,18 @@ 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";
|
||||
@@ -121,14 +122,20 @@ export async function promptSignalAllowFrom(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export const signalDmPolicy = createTopLevelChannelDmPolicy({
|
||||
export const signalDmPolicy: ChannelSetupDmPolicy = {
|
||||
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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/account-resolution";
|
||||
import {
|
||||
createResolvedDirectoryEntriesLister,
|
||||
listResolvedDirectoryEntriesFromSources,
|
||||
type DirectoryConfigParams,
|
||||
} from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { mergeSlackAccountConfig, resolveDefaultSlackAccountId } from "./accounts.js";
|
||||
@@ -19,38 +19,40 @@ function resolveSlackDirectoryConfigAccount(
|
||||
};
|
||||
}
|
||||
|
||||
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 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 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;
|
||||
},
|
||||
});
|
||||
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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -30,13 +31,15 @@ function createHarness(config: Record<string, unknown>) {
|
||||
function createCommandContext(
|
||||
args: string,
|
||||
channel: string = "discord",
|
||||
gatewayClientScopes?: string[],
|
||||
gatewayClientScopes?: OperatorScope[],
|
||||
) {
|
||||
return {
|
||||
args,
|
||||
surface: channel,
|
||||
channel,
|
||||
channelId: channel,
|
||||
isAuthorizedSender: true,
|
||||
senderIsOwner: false,
|
||||
gatewayClientScopes,
|
||||
commandBody: args ? `/voice ${args}` : "/voice",
|
||||
config: {},
|
||||
@@ -47,7 +50,7 @@ function createCommandContext(
|
||||
}
|
||||
|
||||
describe("talk-voice plugin", () => {
|
||||
function createElevenlabsVoiceSetHarness(channel = "webchat", scopes?: string[]) {
|
||||
function createElevenlabsVoiceSetHarness(channel = "webchat", scopes?: OperatorScope[]) {
|
||||
const { command, runtime } = createHarness({
|
||||
talk: {
|
||||
provider: "elevenlabs",
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import { createInspectedDirectoryEntriesLister } from "openclaw/plugin-sdk/directory-runtime";
|
||||
import {
|
||||
listInspectedDirectoryEntriesFromSources,
|
||||
type DirectoryConfigParams,
|
||||
} from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { inspectTelegramAccount, type InspectedTelegramAccount } from "./account-inspect.js";
|
||||
|
||||
export const listTelegramDirectoryPeersFromConfig =
|
||||
createInspectedDirectoryEntriesLister<InspectedTelegramAccount>({
|
||||
export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConfigParams) {
|
||||
return listInspectedDirectoryEntriesFromSources({
|
||||
...params,
|
||||
kind: "user",
|
||||
inspectAccount: (cfg, accountId) =>
|
||||
inspectTelegramAccount({ cfg, accountId }) as InspectedTelegramAccount | null,
|
||||
@@ -22,12 +26,15 @@ export const listTelegramDirectoryPeersFromConfig =
|
||||
return trimmed.startsWith("@") ? trimmed : `@${trimmed}`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const listTelegramDirectoryGroupsFromConfig =
|
||||
createInspectedDirectoryEntriesLister<InspectedTelegramAccount>({
|
||||
export async function listTelegramDirectoryGroupsFromConfig(params: DirectoryConfigParams) {
|
||||
return listInspectedDirectoryEntriesFromSources({
|
||||
...params,
|
||||
kind: "group",
|
||||
inspectAccount: (cfg, accountId) =>
|
||||
inspectTelegramAccount({ cfg, accountId }) as InspectedTelegramAccount | null,
|
||||
resolveSources: (account) => [Object.keys(account.config.groups ?? {})],
|
||||
normalizeId: (entry) => entry.trim() || null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 { ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
|
||||
import type { ChannelSetupDmPolicy, ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
|
||||
import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
|
||||
import { inspectTelegramAccount } from "./account-inspect.js";
|
||||
import {
|
||||
@@ -76,14 +76,20 @@ function buildTelegramDmAccessWarningLines(accountId: string): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
const dmPolicy = createTopLevelChannelDmPolicy({
|
||||
const dmPolicy: ChannelSetupDmPolicy = {
|
||||
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,
|
||||
|
||||
@@ -5,21 +5,27 @@
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { ensureDirectory, logVerboseCopy, resolveBuildCopyContext } from "./lib/copy-assets.ts";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const context = resolveBuildCopyContext(import.meta.url);
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
const verbose = process.env.OPENCLAW_BUILD_VERBOSE === "1";
|
||||
|
||||
const srcDir = path.join(context.projectRoot, "src", "auto-reply", "reply", "export-html");
|
||||
const distDir = path.join(context.projectRoot, "dist", "export-html");
|
||||
const srcDir = path.join(projectRoot, "src", "auto-reply", "reply", "export-html");
|
||||
const distDir = path.join(projectRoot, "dist", "export-html");
|
||||
|
||||
function copyExportHtmlTemplates() {
|
||||
if (!fs.existsSync(srcDir)) {
|
||||
console.warn(`${context.prefix} Source directory not found:`, srcDir);
|
||||
console.warn("[copy-export-html-templates] Source directory not found:", srcDir);
|
||||
return;
|
||||
}
|
||||
|
||||
ensureDirectory(distDir);
|
||||
// Create dist directory
|
||||
if (!fs.existsSync(distDir)) {
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Copy main template files
|
||||
const templateFiles = ["template.html", "template.css", "template.js"];
|
||||
let copiedCount = 0;
|
||||
for (const file of templateFiles) {
|
||||
@@ -28,14 +34,19 @@ function copyExportHtmlTemplates() {
|
||||
if (fs.existsSync(srcFile)) {
|
||||
fs.copyFileSync(srcFile, distFile);
|
||||
copiedCount += 1;
|
||||
logVerboseCopy(context, `Copied ${file}`);
|
||||
if (verbose) {
|
||||
console.log(`[copy-export-html-templates] Copied ${file}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy vendor files
|
||||
const srcVendor = path.join(srcDir, "vendor");
|
||||
const distVendor = path.join(distDir, "vendor");
|
||||
if (fs.existsSync(srcVendor)) {
|
||||
ensureDirectory(distVendor);
|
||||
if (!fs.existsSync(distVendor)) {
|
||||
fs.mkdirSync(distVendor, { recursive: true });
|
||||
}
|
||||
const vendorFiles = fs.readdirSync(srcVendor);
|
||||
for (const file of vendorFiles) {
|
||||
const srcFile = path.join(srcVendor, file);
|
||||
@@ -43,12 +54,14 @@ function copyExportHtmlTemplates() {
|
||||
if (fs.statSync(srcFile).isFile()) {
|
||||
fs.copyFileSync(srcFile, distFile);
|
||||
copiedCount += 1;
|
||||
logVerboseCopy(context, `Copied vendor/${file}`);
|
||||
if (verbose) {
|
||||
console.log(`[copy-export-html-templates] Copied vendor/${file}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`${context.prefix} Copied ${copiedCount} export-html assets.`);
|
||||
console.log(`[copy-export-html-templates] Copied ${copiedCount} export-html assets.`);
|
||||
}
|
||||
|
||||
copyExportHtmlTemplates();
|
||||
|
||||
@@ -5,20 +5,24 @@
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { ensureDirectory, logVerboseCopy, resolveBuildCopyContext } from "./lib/copy-assets.ts";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const context = resolveBuildCopyContext(import.meta.url);
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
const verbose = process.env.OPENCLAW_BUILD_VERBOSE === "1";
|
||||
|
||||
const srcBundled = path.join(context.projectRoot, "src", "hooks", "bundled");
|
||||
const distBundled = path.join(context.projectRoot, "dist", "bundled");
|
||||
const srcBundled = path.join(projectRoot, "src", "hooks", "bundled");
|
||||
const distBundled = path.join(projectRoot, "dist", "bundled");
|
||||
|
||||
function copyHookMetadata() {
|
||||
if (!fs.existsSync(srcBundled)) {
|
||||
console.warn(`${context.prefix} Source directory not found:`, srcBundled);
|
||||
console.warn("[copy-hook-metadata] Source directory not found:", srcBundled);
|
||||
return;
|
||||
}
|
||||
|
||||
ensureDirectory(distBundled);
|
||||
if (!fs.existsSync(distBundled)) {
|
||||
fs.mkdirSync(distBundled, { recursive: true });
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(srcBundled, { withFileTypes: true });
|
||||
let copiedCount = 0;
|
||||
@@ -35,18 +39,22 @@ function copyHookMetadata() {
|
||||
const distHookMd = path.join(distHookDir, "HOOK.md");
|
||||
|
||||
if (!fs.existsSync(srcHookMd)) {
|
||||
console.warn(`${context.prefix} No HOOK.md found for ${hookName}`);
|
||||
console.warn(`[copy-hook-metadata] No HOOK.md found for ${hookName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
ensureDirectory(distHookDir);
|
||||
if (!fs.existsSync(distHookDir)) {
|
||||
fs.mkdirSync(distHookDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.copyFileSync(srcHookMd, distHookMd);
|
||||
copiedCount += 1;
|
||||
logVerboseCopy(context, `Copied ${hookName}/HOOK.md`);
|
||||
if (verbose) {
|
||||
console.log(`[copy-hook-metadata] Copied ${hookName}/HOOK.md`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`${context.prefix} Copied ${copiedCount} hook metadata files.`);
|
||||
console.log(`[copy-hook-metadata] Copied ${copiedCount} hook metadata files.`);
|
||||
}
|
||||
|
||||
copyHookMetadata();
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -231,47 +231,6 @@ 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({
|
||||
|
||||
@@ -128,7 +128,12 @@ export async function runCliAgent(params: {
|
||||
const normalizedModel = normalizeCliModel(modelId, backend);
|
||||
const modelDisplay = `${params.provider}/${modelId}`;
|
||||
|
||||
const extraSystemPrompt = params.extraSystemPrompt?.trim() ?? "";
|
||||
const extraSystemPrompt = [
|
||||
params.extraSystemPrompt?.trim(),
|
||||
"Tools are disabled in this session. Do not call tools.",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
const sessionLabel = params.sessionKey ?? params.sessionId;
|
||||
const { bootstrapFiles, contextFiles } = await resolveBootstrapContextForRun({
|
||||
|
||||
@@ -5,9 +5,24 @@
|
||||
* 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,
|
||||
@@ -34,10 +49,12 @@ 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,
|
||||
gatewayClientScopes: params.ctx.GatewayClientScopes,
|
||||
senderIsOwner: command.senderIsOwner,
|
||||
gatewayClientScopes: narrowGatewayClientScopes(params.ctx.GatewayClientScopes),
|
||||
commandBody: command.commandBodyNormalized,
|
||||
config: cfg,
|
||||
from: command.from,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createInspectedDirectoryEntriesLister,
|
||||
createResolvedDirectoryEntriesLister,
|
||||
listDirectoryEntriesFromSources,
|
||||
listInspectedDirectoryEntriesFromSources,
|
||||
listDirectoryGroupEntriesFromMapKeysAndAllowFrom,
|
||||
@@ -130,21 +128,6 @@ 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 = () => ({
|
||||
@@ -191,18 +174,4 @@ 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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,22 +121,6 @@ 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";
|
||||
@@ -155,19 +139,6 @@ 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;
|
||||
|
||||
@@ -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 closeActiveMemorySearchManagersMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const closeAllMemorySearchManagersMock = 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("../plugins/memory-runtime.js", () => ({
|
||||
closeActiveMemorySearchManagers: closeActiveMemorySearchManagersMock,
|
||||
vi.mock("../memory/search-manager.js", () => ({
|
||||
closeAllMemorySearchManagers: closeAllMemorySearchManagersMock,
|
||||
}));
|
||||
|
||||
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(closeActiveMemorySearchManagersMock).toHaveBeenCalledTimes(1);
|
||||
expect(closeAllMemorySearchManagersMock).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(closeActiveMemorySearchManagersMock).toHaveBeenCalledTimes(1);
|
||||
expect(closeAllMemorySearchManagersMock).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(closeActiveMemorySearchManagersMock).not.toHaveBeenCalled();
|
||||
expect(closeAllMemorySearchManagersMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates a handled container-target exit code", async () => {
|
||||
|
||||
@@ -20,8 +20,8 @@ import { normalizeWindowsArgv } from "./windows-argv.js";
|
||||
|
||||
async function closeCliMemoryManagers(): Promise<void> {
|
||||
try {
|
||||
const { closeActiveMemorySearchManagers } = await import("../plugins/memory-runtime.js");
|
||||
await closeActiveMemorySearchManagers();
|
||||
const { closeAllMemorySearchManagers } = await import("../memory/search-manager.js");
|
||||
await closeAllMemorySearchManagers();
|
||||
} catch {
|
||||
// Best-effort teardown for short-lived CLI processes.
|
||||
}
|
||||
|
||||
@@ -523,6 +523,7 @@ describe("normalizeCompatibilityConfigValues", () => {
|
||||
});
|
||||
|
||||
expect(res.config.talk).toEqual({
|
||||
provider: "elevenlabs",
|
||||
providers: {
|
||||
elevenlabs: {
|
||||
voiceId: "voice-123",
|
||||
@@ -544,7 +545,9 @@ describe("normalizeCompatibilityConfigValues", () => {
|
||||
interruptOnSpeech: false,
|
||||
silenceTimeoutMs: 1500,
|
||||
});
|
||||
expect(res.changes).toEqual(["Moved legacy talk flat fields → talk.providers.elevenlabs."]);
|
||||
expect(res.changes).toEqual([
|
||||
"Moved legacy talk flat fields → talk.provider/talk.providers.elevenlabs.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes talk provider ids without overriding explicit provider config", () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
resolveTelegramPreviewStreamMode,
|
||||
} from "../config/discord-preview-streaming.js";
|
||||
import { migrateLegacyWebSearchConfig } from "../config/legacy-web-search.js";
|
||||
import { LEGACY_TALK_PROVIDER_ID, normalizeTalkSection } from "../config/talk.js";
|
||||
import { DEFAULT_TALK_PROVIDER, 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,7 +651,9 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
|
||||
return;
|
||||
}
|
||||
|
||||
changes.push(`Moved legacy talk flat fields → talk.providers.${LEGACY_TALK_PROVIDER_ID}.`);
|
||||
changes.push(
|
||||
`Moved legacy talk flat fields → talk.provider/talk.providers.${DEFAULT_TALK_PROVIDER}.`,
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeLegacyCrossContextMessageConfig = () => {
|
||||
|
||||
@@ -482,7 +482,7 @@ describe("config strict validation", () => {
|
||||
|
||||
const snap = await readConfigFileSnapshot();
|
||||
|
||||
expect(snap.valid).toBe(true);
|
||||
expect(snap.valid).toBe(false);
|
||||
expect(snap.legacyIssues).not.toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -517,7 +517,7 @@ describe("config strict validation", () => {
|
||||
});
|
||||
|
||||
const snap = await readConfigFileSnapshot();
|
||||
expect(snap.valid).toBe(true);
|
||||
expect(snap.valid).toBe(false);
|
||||
expect(snap.legacyIssues.some((issue) => issue.path === "gateway.bind")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,7 +68,7 @@ function expectRoutingAllowFromLegacySnapshot(
|
||||
ctx: { snapshot: ConfigSnapshot; parsed: unknown },
|
||||
expectedAllowFrom: string[],
|
||||
) {
|
||||
expect(ctx.snapshot.valid).toBe(true);
|
||||
expect(ctx.snapshot.valid).toBe(false);
|
||||
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(true);
|
||||
expect(ctx.snapshot.valid).toBe(false);
|
||||
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(true);
|
||||
expect(ctx.snapshot.valid).toBe(false);
|
||||
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(true);
|
||||
expect(ctx.snapshot.valid).toBe(false);
|
||||
expect(ctx.snapshot.legacyIssues.some((issue) => issue.path === "whatsapp")).toBe(true);
|
||||
|
||||
const parsed = ctx.parsed as {
|
||||
|
||||
@@ -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 {
|
||||
LEGACY_TALK_PROVIDER_ID,
|
||||
DEFAULT_TALK_PROVIDER,
|
||||
normalizeTalkConfig,
|
||||
resolveActiveTalkProviderConfig,
|
||||
resolveTalkApiKey,
|
||||
@@ -204,7 +204,7 @@ export function applyTalkApiKey(config: OpenClawConfig): OpenClawConfig {
|
||||
|
||||
const talk = normalized.talk;
|
||||
const active = resolveActiveTalkProviderConfig(talk);
|
||||
if (!active || active.provider !== LEGACY_TALK_PROVIDER_ID) {
|
||||
if (active?.provider && active.provider !== DEFAULT_TALK_PROVIDER) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ export function applyTalkApiKey(config: OpenClawConfig): OpenClawConfig {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const providerId = active.provider;
|
||||
const providerId = active?.provider ?? DEFAULT_TALK_PROVIDER;
|
||||
const providers = { ...talk?.providers };
|
||||
const providerConfig = { ...providers[providerId], apiKey: resolved };
|
||||
providers[providerId] = providerConfig;
|
||||
@@ -222,6 +222,7 @@ export function applyTalkApiKey(config: OpenClawConfig): OpenClawConfig {
|
||||
const nextTalk = {
|
||||
...talk,
|
||||
apiKey: resolved,
|
||||
provider: talk?.provider ?? providerId,
|
||||
providers,
|
||||
};
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ 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";
|
||||
@@ -1186,11 +1185,6 @@ type ConfigReadResolution = {
|
||||
envWarnings: EnvSubstitutionWarning[];
|
||||
};
|
||||
|
||||
type LegacyMigrationResolution = {
|
||||
effectiveConfigRaw: unknown;
|
||||
sourceLegacyIssues: LegacyConfigIssue[];
|
||||
};
|
||||
|
||||
function resolveConfigIncludesForRead(
|
||||
parsed: unknown,
|
||||
configPath: string,
|
||||
@@ -1231,21 +1225,6 @@ 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>;
|
||||
@@ -1296,15 +1275,13 @@ 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(effectiveConfigRaw, deps.logger);
|
||||
if (typeof effectiveConfigRaw !== "object" || effectiveConfigRaw === null) {
|
||||
warnOnConfigMiskeys(resolvedConfig, deps.logger);
|
||||
if (typeof resolvedConfig !== "object" || resolvedConfig === null) {
|
||||
observeLoadConfigSnapshot({
|
||||
path: configPath,
|
||||
exists: true,
|
||||
@@ -1316,31 +1293,31 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
hash,
|
||||
issues: [],
|
||||
warnings: [],
|
||||
legacyIssues: legacyResolution.sourceLegacyIssues,
|
||||
legacyIssues: [],
|
||||
});
|
||||
return {};
|
||||
}
|
||||
const preValidationDuplicates = findDuplicateAgentDirs(effectiveConfigRaw as OpenClawConfig, {
|
||||
const preValidationDuplicates = findDuplicateAgentDirs(resolvedConfig as OpenClawConfig, {
|
||||
env: deps.env,
|
||||
homedir: deps.homedir,
|
||||
});
|
||||
if (preValidationDuplicates.length > 0) {
|
||||
throw new DuplicateAgentDirError(preValidationDuplicates);
|
||||
}
|
||||
const validated = validateConfigObjectWithPlugins(effectiveConfigRaw, { env: deps.env });
|
||||
const validated = validateConfigObjectWithPlugins(resolvedConfig, { env: deps.env });
|
||||
if (!validated.ok) {
|
||||
observeLoadConfigSnapshot({
|
||||
path: configPath,
|
||||
exists: true,
|
||||
raw,
|
||||
parsed,
|
||||
resolved: coerceConfig(effectiveConfigRaw),
|
||||
resolved: coerceConfig(resolvedConfig),
|
||||
valid: false,
|
||||
config: coerceConfig(effectiveConfigRaw),
|
||||
config: coerceConfig(resolvedConfig),
|
||||
hash,
|
||||
issues: validated.issues,
|
||||
warnings: validated.warnings,
|
||||
legacyIssues: legacyResolution.sourceLegacyIssues,
|
||||
legacyIssues: findLegacyConfigIssues(resolvedConfig, parsed),
|
||||
});
|
||||
const details = validated.issues
|
||||
.map(
|
||||
@@ -1385,13 +1362,13 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
exists: true,
|
||||
raw,
|
||||
parsed,
|
||||
resolved: coerceConfig(effectiveConfigRaw),
|
||||
resolved: coerceConfig(resolvedConfig),
|
||||
valid: true,
|
||||
config: cfg,
|
||||
hash,
|
||||
issues: [],
|
||||
warnings: validated.warnings,
|
||||
legacyIssues: legacyResolution.sourceLegacyIssues,
|
||||
legacyIssues: findLegacyConfigIssues(resolvedConfig, parsed),
|
||||
});
|
||||
|
||||
const duplicates = findDuplicateAgentDirs(cfg, {
|
||||
@@ -1559,10 +1536,11 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
}));
|
||||
|
||||
const resolvedConfigRaw = readResolution.resolvedConfigRaw;
|
||||
const legacyResolution = resolveLegacyConfigForRead(resolvedConfigRaw, parsedRes.parsed);
|
||||
const effectiveConfigRaw = legacyResolution.effectiveConfigRaw;
|
||||
// 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 validated = validateConfigObjectWithPlugins(effectiveConfigRaw, { env: deps.env });
|
||||
const validated = validateConfigObjectWithPlugins(resolvedConfigRaw, { env: deps.env });
|
||||
if (!validated.ok) {
|
||||
return await finalizeReadConfigSnapshotInternalResult(deps, {
|
||||
snapshot: {
|
||||
@@ -1570,13 +1548,13 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
exists: true,
|
||||
raw,
|
||||
parsed: parsedRes.parsed,
|
||||
resolved: coerceConfig(effectiveConfigRaw),
|
||||
resolved: coerceConfig(resolvedConfigRaw),
|
||||
valid: false,
|
||||
config: coerceConfig(effectiveConfigRaw),
|
||||
config: coerceConfig(resolvedConfigRaw),
|
||||
hash,
|
||||
issues: validated.issues,
|
||||
warnings: [...validated.warnings, ...envVarWarnings],
|
||||
legacyIssues: legacyResolution.sourceLegacyIssues,
|
||||
legacyIssues,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1602,13 +1580,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(effectiveConfigRaw),
|
||||
resolved: coerceConfig(resolvedConfigRaw),
|
||||
valid: true,
|
||||
config: snapshotConfig,
|
||||
hash,
|
||||
issues: [],
|
||||
warnings: [...validated.warnings, ...envVarWarnings],
|
||||
legacyIssues: legacyResolution.sourceLegacyIssues,
|
||||
legacyIssues,
|
||||
},
|
||||
envSnapshotForRestore: readResolution.envSnapshotForRestore,
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ describe("talk normalization", () => {
|
||||
});
|
||||
|
||||
expect(normalized).toEqual({
|
||||
provider: "elevenlabs",
|
||||
providers: {
|
||||
elevenlabs: {
|
||||
voiceId: "voice-123",
|
||||
@@ -148,7 +149,7 @@ describe("talk normalization", () => {
|
||||
async (configPath) => {
|
||||
const io = createConfigIO({ configPath });
|
||||
const snapshot = await io.readConfigFileSnapshot();
|
||||
expect(snapshot.config.talk?.provider).toBeUndefined();
|
||||
expect(snapshot.config.talk?.provider).toBe("elevenlabs");
|
||||
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);
|
||||
|
||||
@@ -16,7 +16,7 @@ type TalkApiKeyDeps = {
|
||||
path?: typeof path;
|
||||
};
|
||||
|
||||
export const LEGACY_TALK_PROVIDER_ID = "elevenlabs";
|
||||
export const DEFAULT_TALK_PROVIDER = "elevenlabs";
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
@@ -225,13 +225,19 @@ 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.providers = { [LEGACY_TALK_PROVIDER_ID]: legacyProviderConfig };
|
||||
normalized.provider = DEFAULT_TALK_PROVIDER;
|
||||
normalized.providers = { [DEFAULT_TALK_PROVIDER]: legacyProviderConfig };
|
||||
}
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
@@ -27,8 +27,22 @@ export type TtsModelOverrideConfig = {
|
||||
|
||||
export type TtsProviderConfigMap = Record<string, Record<string, unknown>>;
|
||||
|
||||
export type LegacyTtsConfigCompat = {
|
||||
/** Legacy ElevenLabs configuration. Prefer providers.elevenlabs. */
|
||||
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. */
|
||||
elevenlabs?: {
|
||||
apiKey?: SecretInput;
|
||||
baseUrl?: string;
|
||||
@@ -45,7 +59,7 @@ export type LegacyTtsConfigCompat = {
|
||||
speed?: number;
|
||||
};
|
||||
};
|
||||
/** Legacy OpenAI configuration. Prefer providers.openai. */
|
||||
/** OpenAI configuration. */
|
||||
openai?: {
|
||||
apiKey?: SecretInput;
|
||||
baseUrl?: string;
|
||||
@@ -56,7 +70,7 @@ export type LegacyTtsConfigCompat = {
|
||||
/** System-level instructions for the TTS model (gpt-4o-mini-tts only). */
|
||||
instructions?: string;
|
||||
};
|
||||
/** Legacy alias for Microsoft speech configuration. Prefer providers.microsoft. */
|
||||
/** Legacy alias for Microsoft speech configuration. */
|
||||
edge?: {
|
||||
/** Explicitly allow Microsoft speech usage (no API key required). */
|
||||
enabled?: boolean;
|
||||
@@ -70,7 +84,7 @@ export type LegacyTtsConfigCompat = {
|
||||
proxy?: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
/** Legacy Microsoft speech configuration. Prefer providers.microsoft. */
|
||||
/** Preferred alias for Microsoft speech configuration. */
|
||||
microsoft?: {
|
||||
enabled?: boolean;
|
||||
voice?: string;
|
||||
@@ -83,23 +97,6 @@ export type LegacyTtsConfigCompat = {
|
||||
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). */
|
||||
|
||||
@@ -6,6 +6,14 @@ 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
|
||||
@@ -13,13 +21,14 @@ export type OperatorScope =
|
||||
| typeof APPROVALS_SCOPE
|
||||
| typeof PAIRING_SCOPE;
|
||||
|
||||
export const CLI_DEFAULT_OPERATOR_SCOPES: OperatorScope[] = [
|
||||
ADMIN_SCOPE,
|
||||
READ_SCOPE,
|
||||
WRITE_SCOPE,
|
||||
APPROVALS_SCOPE,
|
||||
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);
|
||||
}
|
||||
|
||||
const NODE_ROLE_METHODS = new Set([
|
||||
"node.invoke.result",
|
||||
|
||||
@@ -2,14 +2,11 @@ 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("../../extensions/memory-core/src/memory/index.js");
|
||||
type MemoryIndexModule = typeof import("./index.js");
|
||||
|
||||
export function installEmbeddingManagerFixture(opts: {
|
||||
fixturePrefix: string;
|
||||
@@ -64,7 +61,7 @@ export function installEmbeddingManagerFixture(opts: {
|
||||
const embeddingMocks = await import("./embedding.test-mocks.js");
|
||||
embedBatch = embeddingMocks.getEmbedBatchMock();
|
||||
resetEmbeddingMocks = embeddingMocks.resetEmbeddingMocks;
|
||||
({ getMemorySearchManager } = await import("../../extensions/memory-core/src/memory/index.js"));
|
||||
({ getMemorySearchManager } = await import("./index.js"));
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), opts.fixturePrefix));
|
||||
workspaceDir = path.join(fixtureRoot, "workspace");
|
||||
memoryDir = path.join(workspaceDir, "memory");
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
bm25RankToScore,
|
||||
buildFtsQuery,
|
||||
mergeHybridResults,
|
||||
} from "../../extensions/memory-core/src/memory/hybrid.js";
|
||||
import { bm25RankToScore, buildFtsQuery, mergeHybridResults } from "./hybrid.js";
|
||||
|
||||
describe("memory hybrid helpers", () => {
|
||||
it("buildFtsQuery tokenizes and AND-joins", () => {
|
||||
|
||||
@@ -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 "../../extensions/memory-core/src/memory/index.js";
|
||||
import type { MemoryIndexManager } from "./index.js";
|
||||
|
||||
type MemoryIndexModule = typeof import("../../extensions/memory-core/src/memory/index.js");
|
||||
type MemoryIndexModule = typeof import("./index.js");
|
||||
|
||||
let getMemorySearchManager: MemoryIndexModule["getMemorySearchManager"];
|
||||
let closeAllMemorySearchManagers: MemoryIndexModule["closeAllMemorySearchManagers"];
|
||||
@@ -131,8 +131,7 @@ describe("memory index", () => {
|
||||
beforeAll(async () => {
|
||||
vi.resetModules();
|
||||
await import("./test-runtime-mocks.js");
|
||||
({ getMemorySearchManager, closeAllMemorySearchManagers } =
|
||||
await import("../../extensions/memory-core/src/memory/index.js"));
|
||||
({ getMemorySearchManager, closeAllMemorySearchManagers } = await import("./index.js"));
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-fixtures-"));
|
||||
workspaceDir = path.join(fixtureRoot, "workspace");
|
||||
memoryDir = path.join(workspaceDir, "memory");
|
||||
|
||||
@@ -3,7 +3,7 @@ export type {
|
||||
MemoryEmbeddingProbeResult,
|
||||
MemorySearchManager,
|
||||
MemorySearchResult,
|
||||
} from "../api.js";
|
||||
} from "./types.js";
|
||||
export {
|
||||
closeAllMemorySearchManagers,
|
||||
getMemorySearchManager,
|
||||
@@ -1,30 +1,31 @@
|
||||
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 {
|
||||
buildGeminiEmbeddingRequest,
|
||||
buildMultimodalChunkForIndexing,
|
||||
chunkMarkdown,
|
||||
createSubsystemLogger,
|
||||
enforceEmbeddingMaxInputTokens,
|
||||
estimateStructuredEmbeddingInputBytes,
|
||||
estimateUtf8Bytes,
|
||||
hasNonTextEmbeddingParts,
|
||||
} from "./embedding-input-limits.js";
|
||||
import { type EmbeddingInput, hasNonTextEmbeddingParts } from "./embedding-inputs.js";
|
||||
import { buildGeminiEmbeddingRequest } from "./embeddings-gemini.js";
|
||||
import {
|
||||
buildMultimodalChunkForIndexing,
|
||||
chunkMarkdown,
|
||||
hashText,
|
||||
parseEmbedding,
|
||||
remapChunkLines,
|
||||
runGeminiEmbeddingBatches,
|
||||
runOpenAiEmbeddingBatches,
|
||||
runVoyageEmbeddingBatches,
|
||||
type EmbeddingInput,
|
||||
type GeminiBatchRequest,
|
||||
type MemoryChunk,
|
||||
type MemoryFileEntry,
|
||||
type MemorySource,
|
||||
type OpenAiBatchRequest,
|
||||
type SessionFileEntry,
|
||||
type VoyageBatchRequest,
|
||||
OPENAI_BATCH_ENDPOINT,
|
||||
} from "../api.js";
|
||||
} from "./internal.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";
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { cosineSimilarity, parseEmbedding, truncateUtf16Safe } from "../api.js";
|
||||
import { truncateUtf16Safe } from "../utils.js";
|
||||
import { cosineSimilarity, parseEmbedding } from "./internal.js";
|
||||
|
||||
const vectorToBlob = (embedding: number[]): Buffer =>
|
||||
Buffer.from(new Float32Array(embedding).buffer);
|
||||
@@ -4,47 +4,52 @@ 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,
|
||||
} from "../api.js";
|
||||
listSessionFilesForAgent,
|
||||
sessionPathForFile,
|
||||
} from "./session-files.js";
|
||||
import { loadSqliteVecExtension } from "./sqlite-vec.js";
|
||||
import { requireNodeSqlite } from "./sqlite.js";
|
||||
import type { MemorySource, MemorySyncProgressUpdate } from "./types.js";
|
||||
|
||||
type MemoryIndexMeta = {
|
||||
model: string;
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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("../../extensions/memory-core/src/memory/index.js");
|
||||
type MemoryIndexModule = typeof import("./index.js");
|
||||
|
||||
describe("memory manager atomic reindex", () => {
|
||||
let fixtureRoot = "";
|
||||
@@ -32,8 +32,7 @@ describe("memory manager atomic reindex", () => {
|
||||
embedBatch = embeddingMocks.getEmbedBatchMock();
|
||||
resetEmbeddingMocks = embeddingMocks.resetEmbeddingMocks;
|
||||
({ getRequiredMemoryIndexManager } = await import("./test-manager-helpers.js"));
|
||||
({ closeAllMemorySearchManagers } =
|
||||
await import("../../extensions/memory-core/src/memory/index.js"));
|
||||
({ closeAllMemorySearchManagers } = await import("./index.js"));
|
||||
vi.stubEnv("OPENCLAW_TEST_MEMORY_UNSAFE_REINDEX", "0");
|
||||
resetEmbeddingMocks();
|
||||
shouldFail = false;
|
||||
|
||||
@@ -7,9 +7,8 @@ import type { OpenClawConfig } from "../config/config.js";
|
||||
import { createOpenAIEmbeddingProviderMock } from "./test-embeddings-mock.js";
|
||||
import { mockPublicPinnedHostname } from "./test-helpers/ssrf.js";
|
||||
|
||||
type MemoryIndexManager =
|
||||
import("../../extensions/memory-core/src/memory/index.js").MemoryIndexManager;
|
||||
type MemoryIndexModule = typeof import("../../extensions/memory-core/src/memory/index.js");
|
||||
type MemoryIndexManager = import("./index.js").MemoryIndexManager;
|
||||
type MemoryIndexModule = typeof import("./index.js");
|
||||
|
||||
const embedBatch = vi.fn(async (_texts: string[]) => [] as number[][]);
|
||||
const embedQuery = vi.fn(async () => [0.5, 0.5, 0.5]);
|
||||
@@ -122,7 +121,7 @@ describe("memory indexing with OpenAI batches", () => {
|
||||
}),
|
||||
}));
|
||||
await import("./test-runtime-mocks.js");
|
||||
({ getMemorySearchManager } = await import("../../extensions/memory-core/src/memory/index.js"));
|
||||
({ getMemorySearchManager } = await import("./index.js"));
|
||||
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-batch-"));
|
||||
workspaceDir = path.join(fixtureRoot, "workspace");
|
||||
|
||||
@@ -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 { MemoryIndexManager } from "../../extensions/memory-core/src/memory/index.js";
|
||||
import "./test-runtime-mocks.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import "./test-runtime-mocks.js";
|
||||
import type { MemoryIndexManager } from "./index.js";
|
||||
|
||||
type MemoryIndexModule = typeof import("../../extensions/memory-core/src/memory/index.js");
|
||||
type ManagerModule = typeof import("../../extensions/memory-core/src/memory/manager.js");
|
||||
type MemoryIndexModule = typeof import("./index.js");
|
||||
type ManagerModule = typeof import("./manager.js");
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
providerCreateCalls: 0,
|
||||
@@ -43,10 +43,9 @@ describe("memory manager cache hydration", () => {
|
||||
let workspaceDir = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
({ getMemorySearchManager, closeAllMemorySearchManagers } =
|
||||
await import("../../extensions/memory-core/src/memory/index.js"));
|
||||
({ getMemorySearchManager, closeAllMemorySearchManagers } = await import("./index.js"));
|
||||
({ closeAllMemoryIndexManagers, MemoryIndexManager: RawMemoryIndexManager } =
|
||||
await import("../../extensions/memory-core/src/memory/manager.js"));
|
||||
await import("./manager.js"));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
|
||||
@@ -2,7 +2,6 @@ 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 {
|
||||
@@ -12,6 +11,7 @@ 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("../../extensions/memory-core/src/memory/index.js");
|
||||
type MemoryIndexModule = typeof import("./index.js");
|
||||
|
||||
let getMemorySearchManager: MemoryIndexModule["getMemorySearchManager"];
|
||||
let closeAllMemorySearchManagers: MemoryIndexModule["closeAllMemorySearchManagers"];
|
||||
@@ -78,8 +78,7 @@ describe("memory manager mistral provider wiring", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ getMemorySearchManager, closeAllMemorySearchManagers } =
|
||||
await import("../../extensions/memory-core/src/memory/index.js"));
|
||||
({ getMemorySearchManager, closeAllMemorySearchManagers } = await import("./index.js"));
|
||||
vi.clearAllMocks();
|
||||
createEmbeddingProviderMock.mockReset();
|
||||
workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-mistral-"));
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runDetachedMemorySync } from "../../extensions/memory-core/src/memory/manager-sync-ops.js";
|
||||
import { runDetachedMemorySync } from "./manager-sync-ops.js";
|
||||
|
||||
describe("memory manager sync failures", () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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,
|
||||
@@ -10,25 +16,20 @@ import {
|
||||
type OllamaEmbeddingClient,
|
||||
type OpenAiEmbeddingClient,
|
||||
type VoyageEmbeddingClient,
|
||||
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";
|
||||
} from "./embeddings.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";
|
||||
@@ -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("../../extensions/memory-core/src/memory/index.js");
|
||||
type MemoryIndexModule = typeof import("./index.js");
|
||||
|
||||
let buildFileEntry: MemoryInternalModule["buildFileEntry"];
|
||||
let createMemoryManagerOrThrow: TestManagerModule["createMemoryManagerOrThrow"];
|
||||
@@ -57,8 +57,7 @@ describe("memory vector dedupe", () => {
|
||||
vi.resetModules();
|
||||
({ buildFileEntry } = await import("./internal.js"));
|
||||
({ createMemoryManagerOrThrow } = await import("./test-manager.js"));
|
||||
({ closeAllMemorySearchManagers } =
|
||||
await import("../../extensions/memory-core/src/memory/index.js"));
|
||||
({ closeAllMemorySearchManagers } = await import("./index.js"));
|
||||
workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-"));
|
||||
indexPath = path.join(workspaceDir, "index.sqlite");
|
||||
await seedMemoryWorkspace(workspaceDir);
|
||||
|
||||
@@ -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("../../extensions/memory-core/src/memory/index.js");
|
||||
type MemoryIndexModule = typeof import("./index.js");
|
||||
|
||||
let getMemorySearchManager: MemoryIndexModule["getMemorySearchManager"];
|
||||
let closeAllMemorySearchManagers: MemoryIndexModule["closeAllMemorySearchManagers"];
|
||||
@@ -46,8 +46,7 @@ describe("memory watcher config", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ getMemorySearchManager, closeAllMemorySearchManagers } =
|
||||
await import("../../extensions/memory-core/src/memory/index.js"));
|
||||
({ getMemorySearchManager, closeAllMemorySearchManagers } = await import("./index.js"));
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
applyMMRToHybridResults,
|
||||
DEFAULT_MMR_CONFIG,
|
||||
type MMRItem,
|
||||
} from "../../extensions/memory-core/src/memory/mmr.js";
|
||||
} from "./mmr.js";
|
||||
|
||||
describe("tokenize", () => {
|
||||
it("normalizes, filters, and deduplicates token sets", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,39 +2,38 @@ 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 {
|
||||
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,
|
||||
buildSessionEntry,
|
||||
type SessionFileEntry,
|
||||
writeFileWithinRoot,
|
||||
} from "../api.js";
|
||||
} from "./session-files.js";
|
||||
import { requireNodeSqlite } from "./sqlite.js";
|
||||
import type {
|
||||
MemoryEmbeddingProbeResult,
|
||||
MemoryProviderStatus,
|
||||
MemorySearchManager,
|
||||
MemorySearchResult,
|
||||
MemorySource,
|
||||
MemorySyncProgressUpdate,
|
||||
} from "./types.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");
|
||||
|
||||
@@ -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("../../extensions/memory-core/src/memory/qmd-manager.js", () => ({
|
||||
vi.mock("./qmd-manager.js", () => ({
|
||||
QmdMemoryManager: {
|
||||
create: vi.fn(async () => mockPrimary),
|
||||
},
|
||||
@@ -109,11 +109,8 @@ vi.mock("./manager-runtime.js", () => ({
|
||||
closeAllMemoryIndexManagers: mockCloseAllMemoryIndexManagers,
|
||||
}));
|
||||
|
||||
import { QmdMemoryManager } from "../../extensions/memory-core/src/memory/qmd-manager.js";
|
||||
import {
|
||||
closeAllMemorySearchManagers,
|
||||
getMemorySearchManager,
|
||||
} from "../../extensions/memory-core/src/memory/search-manager.js";
|
||||
import { QmdMemoryManager } from "./qmd-manager.js";
|
||||
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./search-manager.js";
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- mocked static function
|
||||
const createQmdManagerMock = vi.mocked(QmdMemoryManager.create);
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {
|
||||
createSubsystemLogger,
|
||||
resolveGlobalSingleton,
|
||||
resolveMemoryBackendConfig,
|
||||
type MemoryEmbeddingProbeResult,
|
||||
type MemorySearchManager,
|
||||
type MemorySyncProgressUpdate,
|
||||
type OpenClawConfig,
|
||||
type ResolvedQmdConfig,
|
||||
} from "../api.js";
|
||||
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";
|
||||
|
||||
const MEMORY_SEARCH_MANAGER_CACHE_KEY = Symbol.for("openclaw.memorySearchManagerCache");
|
||||
type MemorySearchManagerCacheStore = {
|
||||
@@ -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 "../../extensions/memory-core/src/memory/hybrid.js";
|
||||
import { mergeHybridResults } from "./hybrid.js";
|
||||
import {
|
||||
applyTemporalDecayToHybridResults,
|
||||
applyTemporalDecayToScore,
|
||||
calculateTemporalDecayMultiplier,
|
||||
} from "../../extensions/memory-core/src/memory/temporal-decay.js";
|
||||
} from "./temporal-decay.js";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const NOW_MS = Date.UTC(2026, 1, 10, 0, 0, 0);
|
||||
|
||||
@@ -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,8 +7,7 @@ export async function getRequiredMemoryIndexManager(params: {
|
||||
purpose?: "default" | "status";
|
||||
}): Promise<MemoryIndexManager> {
|
||||
await import("./embedding.test-mocks.js");
|
||||
const { getMemorySearchManager } =
|
||||
await import("../../extensions/memory-core/src/memory/index.js");
|
||||
const { getMemorySearchManager } = await import("./index.js");
|
||||
const result = await getMemorySearchManager({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId ?? "main",
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
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,
|
||||
|
||||
@@ -14,8 +14,6 @@ export {
|
||||
export {
|
||||
applyDirectoryQueryAndLimit,
|
||||
collectNormalizedDirectoryIds,
|
||||
createInspectedDirectoryEntriesLister,
|
||||
createResolvedDirectoryEntriesLister,
|
||||
listDirectoryEntriesFromSources,
|
||||
listDirectoryGroupEntriesFromMapKeys,
|
||||
listDirectoryGroupEntriesFromMapKeysAndAllowFrom,
|
||||
|
||||
@@ -5,15 +5,11 @@ 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,
|
||||
type ResolvedMemorySearchConfig,
|
||||
} from "../agents/memory-search.js";
|
||||
export { resolveMemorySearchConfig } 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";
|
||||
@@ -25,112 +21,22 @@ 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 { 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 { listMemoryFiles, normalizeExtraMemoryPaths } from "../memory/internal.js";
|
||||
export { readAgentMemoryFile } 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,
|
||||
truncateUtf16Safe,
|
||||
} from "../utils.js";
|
||||
export { shortenHomeInString, shortenHomePath, resolveUserPath } 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";
|
||||
@@ -144,6 +50,7 @@ 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,
|
||||
|
||||
@@ -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 "../../extensions/memory-core/src/memory/index.js";
|
||||
export { getMemorySearchManager } from "../memory/index.js";
|
||||
export { listMemoryFiles, normalizeExtraMemoryPaths } from "../memory/internal.js";
|
||||
export { readAgentMemoryFile } from "../memory/read-file.js";
|
||||
export { resolveMemoryBackendConfig } from "../memory/backend-config.js";
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import {
|
||||
ADMIN_SCOPE,
|
||||
APPROVALS_SCOPE,
|
||||
PAIRING_SCOPE,
|
||||
READ_SCOPE,
|
||||
WRITE_SCOPE,
|
||||
} from "../gateway/method-scopes.js";
|
||||
import { logVerbose } from "../globals.js";
|
||||
import {
|
||||
clearPluginCommands,
|
||||
@@ -87,6 +94,13 @@ 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";
|
||||
}
|
||||
@@ -112,6 +126,23 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import {
|
||||
__testing,
|
||||
@@ -52,6 +52,17 @@ 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", () => {
|
||||
@@ -331,3 +342,171 @@ 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." });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
|
||||
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,
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
requestPluginConversationBinding,
|
||||
} from "./conversation-binding.js";
|
||||
import type {
|
||||
PluginCommandAuthorizationContext,
|
||||
OpenClawPluginCommandDefinition,
|
||||
PluginCommandContext,
|
||||
PluginCommandResult,
|
||||
@@ -36,6 +39,25 @@ 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,
|
||||
@@ -181,9 +203,11 @@ 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;
|
||||
@@ -192,7 +216,17 @@ export async function executePluginCommand(params: {
|
||||
accountId?: PluginCommandContext["accountId"];
|
||||
messageThreadId?: PluginCommandContext["messageThreadId"];
|
||||
}): Promise<PluginCommandResult> {
|
||||
const { command, args, senderId, channel, isAuthorizedSender, commandBody, config } = params;
|
||||
const {
|
||||
command,
|
||||
args,
|
||||
senderId,
|
||||
channel,
|
||||
isAuthorizedSender,
|
||||
commandBody,
|
||||
config,
|
||||
senderIsOwner = false,
|
||||
} = params;
|
||||
const surface = params.surface ?? channel;
|
||||
|
||||
// Check authorization
|
||||
const requireAuth = command.requireAuth !== false; // Default to true
|
||||
@@ -202,9 +236,67 @@ export async function executePluginCommand(params: {
|
||||
);
|
||||
return { text: "⚠️ This command requires authorization." };
|
||||
}
|
||||
|
||||
// Sanitize args before passing to handler
|
||||
if (command.requireOwner && !senderIsOwner) {
|
||||
logVerbose(
|
||||
`Plugin command /${command.name} blocked: non-owner sender ${senderId || "<unknown>"}`,
|
||||
);
|
||||
return { text: "⚠️ This command requires owner authorization." };
|
||||
}
|
||||
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,
|
||||
@@ -215,9 +307,11 @@ export async function executePluginCommand(params: {
|
||||
|
||||
const ctx: PluginCommandContext = {
|
||||
senderId,
|
||||
surface,
|
||||
channel,
|
||||
channelId: params.channelId,
|
||||
isAuthorizedSender,
|
||||
senderIsOwner,
|
||||
gatewayClientScopes: params.gatewayClientScopes,
|
||||
args: sanitizedArgs,
|
||||
commandBody,
|
||||
|
||||
@@ -26,8 +26,3 @@ 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?.();
|
||||
}
|
||||
|
||||
@@ -56,7 +56,6 @@ export type MemoryPluginRuntime = {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
}): MemoryRuntimeBackendConfig;
|
||||
closeAllMemorySearchManagers?(): Promise<void>;
|
||||
};
|
||||
|
||||
type MemoryPluginState = {
|
||||
|
||||
@@ -984,14 +984,18 @@ 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?: string[];
|
||||
gatewayClientScopes?: OperatorScope[];
|
||||
/** Raw command arguments after the command name */
|
||||
args?: string;
|
||||
/** The full normalized command body */
|
||||
@@ -1068,6 +1072,24 @@ 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.
|
||||
*/
|
||||
@@ -1098,6 +1120,14 @@ 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;
|
||||
};
|
||||
|
||||
@@ -54,4 +54,25 @@ 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1949,22 +1949,20 @@ describe("secrets runtime snapshot", () => {
|
||||
loadAuthStore: () => ({ version: 1, profiles: {} }),
|
||||
});
|
||||
|
||||
expect(snapshot.config.channels?.discord?.voice?.tts?.providers?.openai?.apiKey).toEqual({
|
||||
expect(snapshot.config.channels?.discord?.voice?.tts?.openai?.apiKey).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "MISSING_DISCORD_VOICE_TTS_OPENAI",
|
||||
});
|
||||
expect(
|
||||
snapshot.config.channels?.discord?.accounts?.work?.voice?.tts?.providers?.openai?.apiKey,
|
||||
).toEqual({
|
||||
expect(snapshot.config.channels?.discord?.accounts?.work?.voice?.tts?.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.providers.openai.apiKey",
|
||||
"channels.discord.accounts.work.voice.tts.providers.openai.apiKey",
|
||||
"channels.discord.voice.tts.openai.apiKey",
|
||||
"channels.discord.accounts.work.voice.tts.openai.apiKey",
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -1976,10 +1974,8 @@ describe("secrets runtime snapshot", () => {
|
||||
discord: {
|
||||
voice: {
|
||||
tts: {
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: { source: "env", provider: "default", id: "DISCORD_BASE_TTS_OPENAI" },
|
||||
},
|
||||
openai: {
|
||||
apiKey: { source: "env", provider: "default", id: "DISCORD_BASE_TTS_OPENAI" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1994,13 +1990,11 @@ describe("secrets runtime snapshot", () => {
|
||||
enabled: true,
|
||||
voice: {
|
||||
tts: {
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "DISCORD_ENABLED_OVERRIDE_TTS_OPENAI",
|
||||
},
|
||||
openai: {
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "DISCORD_ENABLED_OVERRIDE_TTS_OPENAI",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2010,13 +2004,11 @@ describe("secrets runtime snapshot", () => {
|
||||
enabled: false,
|
||||
voice: {
|
||||
tts: {
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "DISCORD_DISABLED_OVERRIDE_TTS_OPENAI",
|
||||
},
|
||||
openai: {
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "DISCORD_DISABLED_OVERRIDE_TTS_OPENAI",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2042,17 +2034,13 @@ describe("secrets runtime snapshot", () => {
|
||||
loadAuthStore: () => ({ version: 1, profiles: {} }),
|
||||
});
|
||||
|
||||
expect(snapshot.config.channels?.discord?.voice?.tts?.providers?.openai?.apiKey).toBe(
|
||||
"base-tts-openai",
|
||||
);
|
||||
expect(snapshot.config.channels?.discord?.voice?.tts?.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?.providers?.openai
|
||||
?.apiKey,
|
||||
snapshot.config.channels?.discord?.accounts?.enabledOverride?.voice?.tts?.openai?.apiKey,
|
||||
).toBe("enabled-override-tts-openai");
|
||||
expect(
|
||||
snapshot.config.channels?.discord?.accounts?.disabledOverride?.voice?.tts?.providers?.openai
|
||||
?.apiKey,
|
||||
snapshot.config.channels?.discord?.accounts?.disabledOverride?.voice?.tts?.openai?.apiKey,
|
||||
).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
@@ -2067,7 +2055,7 @@ describe("secrets runtime snapshot", () => {
|
||||
);
|
||||
expect(snapshot.warnings.map((warning) => warning.path)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"channels.discord.accounts.disabledOverride.voice.tts.providers.openai.apiKey",
|
||||
"channels.discord.accounts.disabledOverride.voice.tts.openai.apiKey",
|
||||
"channels.discord.accounts.disabledOverride.pluralkit.token",
|
||||
]),
|
||||
);
|
||||
@@ -2080,13 +2068,11 @@ describe("secrets runtime snapshot", () => {
|
||||
discord: {
|
||||
voice: {
|
||||
tts: {
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "DISCORD_UNUSED_BASE_TTS_OPENAI",
|
||||
},
|
||||
openai: {
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "DISCORD_UNUSED_BASE_TTS_OPENAI",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2096,13 +2082,11 @@ describe("secrets runtime snapshot", () => {
|
||||
enabled: true,
|
||||
voice: {
|
||||
tts: {
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "DISCORD_ENABLED_ONLY_TTS_OPENAI",
|
||||
},
|
||||
openai: {
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "DISCORD_ENABLED_ONLY_TTS_OPENAI",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2123,16 +2107,15 @@ describe("secrets runtime snapshot", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
snapshot.config.channels?.discord?.accounts?.enabledOverride?.voice?.tts?.providers?.openai
|
||||
?.apiKey,
|
||||
snapshot.config.channels?.discord?.accounts?.enabledOverride?.voice?.tts?.openai?.apiKey,
|
||||
).toBe("enabled-only-tts-openai");
|
||||
expect(snapshot.config.channels?.discord?.voice?.tts?.providers?.openai?.apiKey).toEqual({
|
||||
expect(snapshot.config.channels?.discord?.voice?.tts?.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.providers.openai.apiKey",
|
||||
"channels.discord.voice.tts.openai.apiKey",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2144,10 +2127,8 @@ describe("secrets runtime snapshot", () => {
|
||||
discord: {
|
||||
voice: {
|
||||
tts: {
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: { source: "env", provider: "default", id: "DISCORD_BASE_TTS_OK" },
|
||||
},
|
||||
openai: {
|
||||
apiKey: { source: "env", provider: "default", id: "DISCORD_BASE_TTS_OK" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2156,13 +2137,11 @@ describe("secrets runtime snapshot", () => {
|
||||
enabled: true,
|
||||
voice: {
|
||||
tts: {
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "DISCORD_ENABLED_OVERRIDE_TTS_MISSING",
|
||||
},
|
||||
openai: {
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "DISCORD_ENABLED_OVERRIDE_TTS_MISSING",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
setRuntimeConfigSnapshot,
|
||||
type OpenClawConfig,
|
||||
} from "../config/config.js";
|
||||
import { migrateLegacyConfig } from "../config/legacy-migrate.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import {
|
||||
collectCommandSecretAssignmentsFromSnapshot,
|
||||
@@ -140,9 +139,7 @@ export async function prepareSecretsRuntimeSnapshot(params: {
|
||||
}): Promise<PreparedSecretsRuntimeSnapshot> {
|
||||
const runtimeEnv = mergeSecretsRuntimeEnv(params.env);
|
||||
const sourceConfig = structuredClone(params.config);
|
||||
const resolvedConfig = structuredClone(
|
||||
migrateLegacyConfig(params.config).config ?? params.config,
|
||||
);
|
||||
const resolvedConfig = structuredClone(params.config);
|
||||
const context = createResolverContext({
|
||||
sourceConfig,
|
||||
env: runtimeEnv,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { listSpeechProviders } from "./provider-registry.js";
|
||||
import type {
|
||||
SpeechModelOverridePolicy,
|
||||
SpeechProviderConfig,
|
||||
SpeechProviderOverrides,
|
||||
TtsDirectiveOverrides,
|
||||
TtsDirectiveParseResult,
|
||||
} from "./provider-types.js";
|
||||
@@ -12,6 +13,7 @@ type ParseTtsDirectiveOptions = {
|
||||
cfg?: OpenClawConfig;
|
||||
providers?: readonly SpeechProviderPlugin[];
|
||||
providerConfigs?: Record<string, SpeechProviderConfig>;
|
||||
openaiBaseUrl?: string;
|
||||
};
|
||||
|
||||
function buildProviderOrder(left: SpeechProviderPlugin, right: SpeechProviderPlugin): number {
|
||||
@@ -34,18 +36,49 @@ function resolveDirectiveProviderConfig(
|
||||
provider: SpeechProviderPlugin,
|
||||
options?: ParseTtsDirectiveOptions,
|
||||
): SpeechProviderConfig | undefined {
|
||||
return options?.providerConfigs?.[provider.id];
|
||||
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;
|
||||
}
|
||||
|
||||
export function parseTtsDirectives(
|
||||
text: string,
|
||||
policy: SpeechModelOverridePolicy,
|
||||
options?: ParseTtsDirectiveOptions,
|
||||
optionsOrOpenaiBaseUrl?: ParseTtsDirectiveOptions | string,
|
||||
): 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[] = [];
|
||||
@@ -102,13 +135,7 @@ export function parseTtsDirectives(
|
||||
}
|
||||
handled = true;
|
||||
if (parsed.overrides) {
|
||||
overrides.providerOverrides = {
|
||||
...overrides.providerOverrides,
|
||||
[provider.id]: {
|
||||
...overrides.providerOverrides?.[provider.id],
|
||||
...parsed.overrides,
|
||||
},
|
||||
};
|
||||
mergeProviderOverrides(overrides, provider.id, parsed.overrides);
|
||||
}
|
||||
if (parsed.warnings?.length) {
|
||||
warnings.push(...parsed.warnings);
|
||||
|
||||
@@ -86,6 +86,7 @@ const {
|
||||
parseTtsDirectives,
|
||||
resolveModelOverridePolicy,
|
||||
summarizeText,
|
||||
resolveOutputFormat,
|
||||
getResolvedSpeechProviderConfig,
|
||||
} = _test;
|
||||
|
||||
@@ -233,6 +234,65 @@ 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" } } },
|
||||
@@ -323,11 +383,9 @@ 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 result = parseTtsDirectives(input, policy, {
|
||||
providerConfigs: {
|
||||
openai: { baseUrl: "http://localhost:8880/v1" },
|
||||
},
|
||||
});
|
||||
const customBaseUrl = "http://localhost:8880/v1";
|
||||
|
||||
const result = parseTtsDirectives(input, policy, customBaseUrl);
|
||||
const openaiOverrides = result.overrides.providerOverrides?.openai as
|
||||
| { voice?: string; model?: string }
|
||||
| undefined;
|
||||
@@ -340,11 +398,9 @@ 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 result = parseTtsDirectives(input, policy, {
|
||||
providerConfigs: {
|
||||
openai: { baseUrl: "https://api.openai.com/v1" },
|
||||
},
|
||||
});
|
||||
const defaultBaseUrl = "https://api.openai.com/v1";
|
||||
|
||||
const result = parseTtsDirectives(input, policy, defaultBaseUrl);
|
||||
const openaiOverrides = result.overrides.providerOverrides?.openai as
|
||||
| { voice?: string }
|
||||
| undefined;
|
||||
|
||||
@@ -48,6 +48,22 @@ 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;
|
||||
@@ -402,9 +418,16 @@ export function setLastTtsAttempt(entry: TtsStatusEntry | undefined): void {
|
||||
lastTtsAttempt = entry;
|
||||
}
|
||||
|
||||
/** Channels that require voice-note-compatible audio */
|
||||
/** Channels that require opus 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;
|
||||
}
|
||||
@@ -853,5 +876,6 @@ export const _test = {
|
||||
parseTtsDirectives,
|
||||
resolveModelOverridePolicy,
|
||||
summarizeText,
|
||||
resolveOutputFormat,
|
||||
getResolvedSpeechProviderConfig,
|
||||
};
|
||||
|
||||
@@ -67,26 +67,10 @@ const targetedUnitProxyFiles = [
|
||||
|
||||
const REPO_ROOT = path.resolve(import.meta.dirname, "../..");
|
||||
|
||||
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 {
|
||||
function runPlannerPlan(args: string[], env: NodeJS.ProcessEnv): string {
|
||||
return execFileSync("node", ["scripts/test-parallel.mjs", ...args], {
|
||||
cwd: REPO_ROOT,
|
||||
env: createPlannerEnv(envOverrides),
|
||||
env,
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
@@ -94,11 +78,15 @@ function runPlannerPlan(args: string[], envOverrides: NodeJS.ProcessEnv = {}): s
|
||||
function runHighMemoryLocalMultiSurfacePlan(): string {
|
||||
return runPlannerPlan(
|
||||
["--plan", "--surface", "unit", "--surface", "extensions", "--surface", "channels"],
|
||||
createLocalPlannerEnv({
|
||||
{
|
||||
...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",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -203,8 +191,14 @@ 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 output = runPlannerPlan(["--plan"], {
|
||||
OPENCLAW_TEST_PROFILE: "serial",
|
||||
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",
|
||||
});
|
||||
|
||||
expect(output).toContain("unit-fast");
|
||||
@@ -212,10 +206,16 @@ describe("scripts/test-parallel lane planning", () => {
|
||||
});
|
||||
|
||||
it("recycles default local unit-fast runs into bounded batches", () => {
|
||||
const output = runPlannerPlan(["--plan"], {
|
||||
CI: "",
|
||||
OPENCLAW_TEST_UNIT_FAST_LANES: "1",
|
||||
OPENCLAW_TEST_UNIT_FAST_BATCH_TARGET_MS: "1",
|
||||
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",
|
||||
});
|
||||
|
||||
expect(output).toContain("unit-fast-batch-");
|
||||
@@ -223,24 +223,44 @@ describe("scripts/test-parallel lane planning", () => {
|
||||
});
|
||||
|
||||
it("keeps legacy base-pinned targeted reruns on dedicated forks lanes", () => {
|
||||
const output = runPlannerPlan([
|
||||
"--plan",
|
||||
"--files",
|
||||
"src/auto-reply/reply/followup-runner.test.ts",
|
||||
]);
|
||||
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",
|
||||
},
|
||||
);
|
||||
|
||||
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 output = runPlannerPlan(
|
||||
["--plan", "--surface", "unit", "--surface", "extensions"],
|
||||
createLocalPlannerEnv({
|
||||
RUNNER_OS: "macOS",
|
||||
OPENCLAW_TEST_HOST_CPU_COUNT: "10",
|
||||
OPENCLAW_TEST_HOST_MEMORY_GIB: "64",
|
||||
}),
|
||||
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",
|
||||
},
|
||||
);
|
||||
|
||||
expect(output).toContain("mode=local intent=normal memoryBand=mid");
|
||||
@@ -249,29 +269,47 @@ describe("scripts/test-parallel lane planning", () => {
|
||||
});
|
||||
|
||||
it("uses higher shared extension worker counts on high-memory local hosts", () => {
|
||||
const highMemoryOutput = runPlannerPlan(
|
||||
["--plan", "--surface", "extensions"],
|
||||
createLocalPlannerEnv({
|
||||
RUNNER_OS: "macOS",
|
||||
OPENCLAW_TEST_HOST_CPU_COUNT: "12",
|
||||
OPENCLAW_TEST_HOST_MEMORY_GIB: "128",
|
||||
}),
|
||||
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 midMemoryOutput = runPlannerPlan(
|
||||
["--plan", "--surface", "extensions"],
|
||||
createLocalPlannerEnv({
|
||||
RUNNER_OS: "macOS",
|
||||
OPENCLAW_TEST_HOST_CPU_COUNT: "10",
|
||||
OPENCLAW_TEST_HOST_MEMORY_GIB: "64",
|
||||
}),
|
||||
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",
|
||||
},
|
||||
);
|
||||
|
||||
expect(midMemoryOutput).toContain("extensions-batch-1 filters=all maxWorkers=3");
|
||||
expect(midMemoryOutput).toContain("extensions-batch-2 filters=all maxWorkers=3");
|
||||
expect(midMemoryOutput).not.toContain("extensions-batch-3");
|
||||
expect(midMemoryOutput).toContain("extensions-batch-3 filters=all maxWorkers=3");
|
||||
expect(highMemoryOutput).toContain("extensions-batch-1 filters=all maxWorkers=5");
|
||||
expect(highMemoryOutput).toContain("extensions-batch-2 filters=all maxWorkers=5");
|
||||
expect(highMemoryOutput).not.toContain("extensions-batch-3");
|
||||
expect(highMemoryOutput).toContain("extensions-batch-3 filters=all maxWorkers=5");
|
||||
expect(highMemoryOutput).not.toContain("extensions-batch-4");
|
||||
});
|
||||
|
||||
it("starts isolated channel lanes before shared extension batches on high-memory local hosts", () => {
|
||||
@@ -296,18 +334,29 @@ describe("scripts/test-parallel lane planning", () => {
|
||||
});
|
||||
|
||||
it("uses earlier targeted channel batching on high-memory local hosts", () => {
|
||||
const output = runPlannerPlan(
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../..");
|
||||
const output = execFileSync(
|
||||
"node",
|
||||
[
|
||||
"scripts/test-parallel.mjs",
|
||||
"--plan",
|
||||
"--surface",
|
||||
"channels",
|
||||
...targetedChannelProxyFiles.flatMap((file) => ["--files", file]),
|
||||
],
|
||||
createLocalPlannerEnv({
|
||||
RUNNER_OS: "macOS",
|
||||
OPENCLAW_TEST_HOST_CPU_COUNT: "12",
|
||||
OPENCLAW_TEST_HOST_MEMORY_GIB: "128",
|
||||
}),
|
||||
{
|
||||
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",
|
||||
},
|
||||
);
|
||||
|
||||
expect(output).toContain("channels-batch-1 filters=33");
|
||||
@@ -316,18 +365,29 @@ describe("scripts/test-parallel lane planning", () => {
|
||||
});
|
||||
|
||||
it("uses targeted unit batching on high-memory local hosts", () => {
|
||||
const output = runPlannerPlan(
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../..");
|
||||
const output = execFileSync(
|
||||
"node",
|
||||
[
|
||||
"scripts/test-parallel.mjs",
|
||||
"--plan",
|
||||
"--surface",
|
||||
"unit",
|
||||
...targetedUnitProxyFiles.flatMap((file) => ["--files", file]),
|
||||
],
|
||||
createLocalPlannerEnv({
|
||||
RUNNER_OS: "macOS",
|
||||
OPENCLAW_TEST_HOST_CPU_COUNT: "12",
|
||||
OPENCLAW_TEST_HOST_MEMORY_GIB: "128",
|
||||
}),
|
||||
{
|
||||
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",
|
||||
},
|
||||
);
|
||||
|
||||
expect(output).toContain("unit-batch-1 filters=50");
|
||||
@@ -336,7 +396,16 @@ describe("scripts/test-parallel lane planning", () => {
|
||||
});
|
||||
|
||||
it("explains targeted file ownership and execution policy", () => {
|
||||
const output = runPlannerPlan(["--explain", "src/auto-reply/reply/followup-runner.test.ts"]);
|
||||
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",
|
||||
},
|
||||
);
|
||||
|
||||
expect(output).toContain("surface=base");
|
||||
expect(output).toContain("reasons=base-surface,base-pinned-manifest");
|
||||
@@ -344,17 +413,23 @@ describe("scripts/test-parallel lane planning", () => {
|
||||
});
|
||||
|
||||
it("prints the planner-backed CI manifest as JSON", () => {
|
||||
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 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 manifest = JSON.parse(output);
|
||||
@@ -438,13 +513,28 @@ describe("scripts/test-parallel lane planning", () => {
|
||||
});
|
||||
|
||||
it("passes through vitest --mode values that are not wrapper runtime overrides", () => {
|
||||
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",
|
||||
}),
|
||||
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",
|
||||
},
|
||||
);
|
||||
|
||||
expect(output).toContain("mode=local intent=normal memoryBand=high");
|
||||
@@ -452,43 +542,90 @@ describe("scripts/test-parallel lane planning", () => {
|
||||
});
|
||||
|
||||
it("rejects removed machine-name profiles", () => {
|
||||
expect(() => runPlannerPlan(["--plan", "--profile", "macmini"])).toThrowError(
|
||||
/Unsupported test profile "macmini"/u,
|
||||
);
|
||||
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);
|
||||
});
|
||||
|
||||
it("rejects unknown explicit surface names", () => {
|
||||
expect(() => runPlannerPlan(["--plan", "--surface", "channel"])).toThrowError(
|
||||
/Unsupported --surface value\(s\): channel/u,
|
||||
);
|
||||
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);
|
||||
});
|
||||
|
||||
it("rejects wrapper --files values that look like options", () => {
|
||||
expect(() => runPlannerPlan(["--plan", "--files", "--config"])).toThrowError(
|
||||
/Invalid --files value/u,
|
||||
);
|
||||
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);
|
||||
});
|
||||
|
||||
it("rejects missing --profile values", () => {
|
||||
expect(() => runPlannerPlan(["--plan", "--profile"])).toThrowError(/Invalid --profile value/u);
|
||||
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);
|
||||
});
|
||||
|
||||
it("rejects missing --surface values", () => {
|
||||
expect(() => runPlannerPlan(["--plan", "--surface"])).toThrowError(/Invalid --surface value/u);
|
||||
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);
|
||||
});
|
||||
|
||||
it("rejects missing --explain values", () => {
|
||||
expect(() => runPlannerPlan(["--explain"])).toThrowError(/Invalid --explain value/u);
|
||||
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);
|
||||
});
|
||||
|
||||
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(() => runPlannerPlan(["--plan", "--files", tempFilePath])).toThrowError(
|
||||
/is not a known test file/u,
|
||||
);
|
||||
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);
|
||||
} finally {
|
||||
fs.rmSync(tempFilePath, { force: true });
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user