feat: add plugin text transforms

This commit is contained in:
Peter Steinberger
2026-04-11 02:03:11 +01:00
parent a2dbc1b63c
commit 202f80792e
32 changed files with 866 additions and 50 deletions

View File

@@ -1,4 +1,4 @@
995d3ffac3a4a982f9a97d7583c081eb8b48e2d1ed80fa4db5633f2b5c0f543a config-baseline.json
44b2f2fc7fe0092346d33a16936c576e8767b83d13808491e0cb418cd69ecf1b config-baseline.core.json
228031f16ad06580bfd137f092d70d03f2796515e723b8b6618ed69d285465fa config-baseline.json
bad0a5bb247a62b8fb9ed9fc2b2720eacf3e0913077ac351b5d26ae2723335ad config-baseline.core.json
e1f94346a8507ce3dec763b598e79f3bb89ff2e33189ce977cc87d3b05e71c1d config-baseline.channel.json
6c19997f1fb2aff4315f2cb9c7d9e299b403fbc0f9e78e3412cc7fe1c655f222 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
2256ba1237c3608ca981bce3a7c66b6880b12d05025f260d5c086b69038f408b plugin-sdk-api-baseline.json
6360529513280140c122020466f0821a9acc83aba64612cf90656c2af0261ab3 plugin-sdk-api-baseline.jsonl
4d6ea7dac2bcb51c3e99d36ac974bc417dd65117316492a605f2fc9762d1e722 plugin-sdk-api-baseline.json
836ee5cd995a2fd4e4dbcab702f59d749c0c142767fa40d736d095cba4a83749 plugin-sdk-api-baseline.jsonl

View File

@@ -263,6 +263,31 @@ CLI backend defaults are now part of the plugin surface:
- Backend-specific config cleanup stays plugin-owned through the optional
`normalizeConfig` hook.
Plugins that need tiny prompt/message compatibility shims can declare
bidirectional text transforms without replacing a provider or CLI backend:
```typescript
api.registerTextTransforms({
input: [
{ from: /red basket/g, to: "blue basket" },
{ from: /paper ticket/g, to: "digital ticket" },
{ from: /left shelf/g, to: "right shelf" },
],
output: [
{ from: /blue basket/g, to: "red basket" },
{ from: /digital ticket/g, to: "paper ticket" },
{ from: /right shelf/g, to: "left shelf" },
],
});
```
`input` rewrites the system prompt and user prompt passed to the CLI. `output`
rewrites streamed assistant deltas and parsed final text before OpenClaw handles
its own control markers and channel delivery.
For CLIs that emit Claude Code stream-json compatible JSONL, set
`jsonlDialect: "claude-stream-json"` on that backend's config.
## Bundle MCP overlays
CLI backends do **not** receive OpenClaw tool calls directly, but a backend can

View File

@@ -175,6 +175,28 @@ API key auth, and dynamic model resolution.
`openclaw onboard --acme-ai-api-key <key>` and select
`acme-ai/acme-large` as their model.
If the upstream provider uses different control tokens than OpenClaw, add a
small bidirectional text transform instead of replacing the stream path:
```typescript
api.registerTextTransforms({
input: [
{ from: /red basket/g, to: "blue basket" },
{ from: /paper ticket/g, to: "digital ticket" },
{ from: /left shelf/g, to: "right shelf" },
],
output: [
{ from: /blue basket/g, to: "red basket" },
{ from: /digital ticket/g, to: "paper ticket" },
{ from: /right shelf/g, to: "left shelf" },
],
});
```
`input` rewrites the final system prompt and text message content before
transport. `output` rewrites assistant text deltas and final text before
OpenClaw parses its own control markers or channel delivery.
For bundled providers that only register one text provider with API-key
auth plus a single catalog-backed runtime, prefer the narrower
`defineSingleProviderPluginEntry(...)` helper:

View File

@@ -2,9 +2,11 @@ import type { OpenClawConfig } from "../config/config.js";
import type { CliBackendConfig } from "../config/types.js";
import { resolveRuntimeCliBackends } from "../plugins/cli-backends.runtime.js";
import { resolvePluginSetupCliBackend } from "../plugins/setup-registry.js";
import type { CliBundleMcpMode } from "../plugins/types.js";
import { resolveRuntimeTextTransforms } from "../plugins/text-transforms.runtime.js";
import type { CliBundleMcpMode, CliBackendPlugin, PluginTextTransforms } from "../plugins/types.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import { normalizeProviderId } from "./model-selection.js";
import { mergePluginTextTransforms } from "./plugin-text-transforms.js";
type CliBackendsDeps = {
resolvePluginSetupCliBackend: typeof resolvePluginSetupCliBackend;
@@ -24,6 +26,8 @@ export type ResolvedCliBackend = {
bundleMcp: boolean;
bundleMcpMode?: CliBundleMcpMode;
pluginId?: string;
transformSystemPrompt?: CliBackendPlugin["transformSystemPrompt"];
textTransforms?: PluginTextTransforms;
};
export type ResolvedCliBackendLiveTest = {
@@ -44,6 +48,8 @@ type FallbackCliBackendPolicy = {
bundleMcpMode?: CliBundleMcpMode;
baseConfig?: CliBackendConfig;
normalizeConfig?: (config: CliBackendConfig) => CliBackendConfig;
transformSystemPrompt?: CliBackendPlugin["transformSystemPrompt"];
textTransforms?: PluginTextTransforms;
};
const FALLBACK_CLI_BACKEND_POLICIES: Record<string, FallbackCliBackendPolicy> = {};
@@ -75,6 +81,8 @@ function resolveSetupCliBackendPolicy(provider: string): FallbackCliBackendPolic
),
baseConfig: entry.backend.config,
normalizeConfig: entry.backend.normalizeConfig,
transformSystemPrompt: entry.backend.transformSystemPrompt,
textTransforms: entry.backend.textTransforms,
};
}
@@ -185,6 +193,7 @@ export function resolveCliBackendConfig(
cfg?: OpenClawConfig,
): ResolvedCliBackend | null {
const normalized = normalizeBackendKey(provider);
const runtimeTextTransforms = resolveRuntimeTextTransforms();
const configured = cfg?.agents?.defaults?.cliBackends ?? {};
const override = pickBackendConfig(configured, normalized);
const registered = resolveRegisteredBackend(normalized);
@@ -204,6 +213,8 @@ export function resolveCliBackendConfig(
registered.bundleMcp === true,
),
pluginId: registered.pluginId,
transformSystemPrompt: registered.transformSystemPrompt,
textTransforms: mergePluginTextTransforms(runtimeTextTransforms, registered.textTransforms),
};
}
@@ -224,6 +235,11 @@ export function resolveCliBackendConfig(
config: { ...baseConfig, command },
bundleMcp: fallbackPolicy.bundleMcp,
bundleMcpMode: fallbackPolicy.bundleMcpMode,
transformSystemPrompt: fallbackPolicy.transformSystemPrompt,
textTransforms: mergePluginTextTransforms(
runtimeTextTransforms,
fallbackPolicy.textTransforms,
),
};
}
const mergedFallback = fallbackPolicy?.baseConfig
@@ -241,6 +257,11 @@ export function resolveCliBackendConfig(
config: { ...config, command },
bundleMcp: fallbackPolicy?.bundleMcp === true,
bundleMcpMode: fallbackPolicy?.bundleMcpMode,
transformSystemPrompt: fallbackPolicy?.transformSystemPrompt,
textTransforms: mergePluginTextTransforms(
runtimeTextTransforms,
fallbackPolicy?.textTransforms,
),
};
}

View File

@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { extractCliErrorMessage, parseCliJson, parseCliJsonl } from "./cli-output.js";
import {
createCliJsonlStreamingParser,
extractCliErrorMessage,
parseCliJson,
parseCliJsonl,
} from "./cli-output.js";
describe("parseCliJson", () => {
it("recovers mixed-output Claude session metadata from embedded JSON objects", () => {
@@ -157,6 +162,33 @@ describe("parseCliJsonl", () => {
});
});
it("parses Claude stream-json result events for an explicit backend dialect", () => {
const result = parseCliJsonl(
[
JSON.stringify({ type: "init", session_id: "session-dialect" }),
JSON.stringify({
type: "result",
session_id: "session-dialect",
result: "dialect says hello",
usage: { input_tokens: 5, output_tokens: 2 },
}),
].join("\n"),
{
command: "local-cli",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
"local-cli",
);
expect(result).toMatchObject({
text: "dialect says hello",
sessionId: "session-dialect",
usage: { input: 5, output: 2 },
});
});
it("preserves Claude cache creation tokens instead of flattening them to zero", () => {
const result = parseCliJsonl(
[
@@ -284,3 +316,37 @@ describe("parseCliJsonl", () => {
expect(result).toBe(message);
});
});
describe("createCliJsonlStreamingParser", () => {
it("streams Claude stream-json deltas for an explicit backend dialect", () => {
const deltas: Array<{ text: string; delta: string; sessionId?: string }> = [];
const parser = createCliJsonlStreamingParser({
backend: {
command: "local-cli",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
providerId: "local-cli",
onAssistantDelta: (delta) => deltas.push(delta),
});
parser.push(
[
JSON.stringify({ type: "init", session_id: "session-stream" }),
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
delta: { type: "text_delta", text: "hello" },
},
}),
].join("\n"),
);
parser.finish();
expect(deltas).toEqual([
{ text: "hello", delta: "hello", sessionId: "session-stream", usage: undefined },
]);
});
});

View File

@@ -27,6 +27,15 @@ function isClaudeCliProvider(providerId: string): boolean {
return normalizeLowercaseStringOrEmpty(providerId) === "claude-cli";
}
function usesClaudeStreamJsonDialect(params: {
backend: CliBackendConfig;
providerId: string;
}): boolean {
return (
params.backend.jsonlDialect === "claude-stream-json" || isClaudeCliProvider(params.providerId)
);
}
function extractJsonObjectCandidates(raw: string): string[] {
const candidates: string[] = [];
let depth = 0;
@@ -295,12 +304,13 @@ export function parseCliJson(raw: string, backend: CliBackendConfig): CliOutput
}
function parseClaudeCliJsonlResult(params: {
backend: CliBackendConfig;
providerId: string;
parsed: Record<string, unknown>;
sessionId?: string;
usage?: CliUsage;
}): CliOutput | null {
if (!isClaudeCliProvider(params.providerId)) {
if (!usesClaudeStreamJsonDialect(params)) {
return null;
}
if (
@@ -320,13 +330,14 @@ function parseClaudeCliJsonlResult(params: {
}
function parseClaudeCliStreamingDelta(params: {
backend: CliBackendConfig;
providerId: string;
parsed: Record<string, unknown>;
textSoFar: string;
sessionId?: string;
usage?: CliUsage;
}): CliStreamingDelta | null {
if (!isClaudeCliProvider(params.providerId)) {
if (!usesClaudeStreamJsonDialect(params)) {
return null;
}
if (params.parsed.type !== "stream_event" || !isRecord(params.parsed.event)) {
@@ -371,6 +382,7 @@ export function createCliJsonlStreamingParser(params: {
}
const delta = parseClaudeCliStreamingDelta({
backend: params.backend,
providerId: params.providerId,
parsed,
textSoFar: assistantText,
@@ -452,6 +464,7 @@ export function parseCliJsonl(
usage = readCliUsage(parsed) ?? usage;
const claudeResult = parseClaudeCliJsonlResult({
backend,
providerId,
parsed,
sessionId,

View File

@@ -15,6 +15,7 @@ import {
} from "../cli-output.js";
import { FailoverError, resolveFailoverStatus } from "../failover-error.js";
import { classifyFailoverReason } from "../pi-embedded-helpers.js";
import { applyPluginTextReplacements } from "../plugin-text-transforms.js";
import { applySkillEnvOverridesFromSnapshot } from "../skills.js";
import { prepareClaudeCliSkillsPlugin } from "./claude-skills-plugin.js";
import {
@@ -192,9 +193,12 @@ export async function executePreparedCliRun(
})
: undefined;
let prompt = prependBootstrapPromptWarning(params.prompt, context.bootstrapPromptWarningLines, {
preserveExactPrompt: context.heartbeatPrompt,
});
let prompt = applyPluginTextReplacements(
prependBootstrapPromptWarning(params.prompt, context.bootstrapPromptWarningLines, {
preserveExactPrompt: context.heartbeatPrompt,
}),
context.backendResolved.textTransforms?.input,
);
const {
prompt: promptWithImages,
imagePaths,
@@ -317,8 +321,14 @@ export async function executePreparedCliRun(
runId: params.runId,
stream: "assistant",
data: {
text,
delta,
text: applyPluginTextReplacements(
text,
context.backendResolved.textTransforms?.output,
),
delta: applyPluginTextReplacements(
delta,
context.backendResolved.textTransforms?.output,
),
},
});
},
@@ -445,13 +455,20 @@ export async function executePreparedCliRun(
});
}
return parseCliOutput({
const parsed = parseCliOutput({
raw: stdout,
backend,
providerId: context.backendResolved.id,
outputMode: useResume ? (backend.resumeOutput ?? backend.output) : backend.output,
fallbackSessionId: resolvedSessionId,
});
return {
...parsed,
text: applyPluginTextReplacements(
parsed.text,
context.backendResolved.textTransforms?.output,
),
};
} finally {
restoreSkillEnv?.();
}

View File

@@ -22,6 +22,7 @@ import {
resolveBootstrapPromptTruncationWarningMode,
resolveBootstrapTotalMaxChars,
} from "../pi-embedded-helpers.js";
import { applyPluginTextReplacements } from "../plugin-text-transforms.js";
import { resolveSkillsPromptForRun } from "../skills.js";
import { resolveSystemPromptOverride } from "../system-prompt-override.js";
import { buildSystemPromptReport } from "../system-prompt-report.js";
@@ -170,7 +171,7 @@ export async function prepareCliRunContext(
config: params.config,
agentId: sessionAgentId,
});
const systemPrompt =
const builtSystemPrompt =
resolveSystemPromptOverride({
config: params.config,
agentId: sessionAgentId,
@@ -189,6 +190,20 @@ export async function prepareCliRunContext(
modelDisplay,
agentId: sessionAgentId,
});
const transformedSystemPrompt =
backendResolved.transformSystemPrompt?.({
config: params.config,
workspaceDir,
provider: params.provider,
modelId,
modelDisplay,
agentId: sessionAgentId,
systemPrompt: builtSystemPrompt,
}) ?? builtSystemPrompt;
const systemPrompt = applyPluginTextReplacements(
transformedSystemPrompt,
backendResolved.textTransforms?.input,
);
const systemPromptReport = buildSystemPromptReport({
source: "run",
generatedAt: Date.now(),

View File

@@ -29,6 +29,8 @@ import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import {
prepareProviderRuntimeAuth,
resolveProviderSystemPromptContribution,
resolveProviderTextTransforms,
transformProviderSystemPrompt,
} from "../../plugins/provider-runtime.js";
import type { ProviderRuntimeModel } from "../../plugins/types.js";
import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js";
@@ -77,6 +79,7 @@ import {
} from "../pi-hooks/compaction-safeguard-runtime.js";
import { createPreparedEmbeddedPiSettingsManager } from "../pi-project-settings.js";
import { createOpenClawCodingTools } from "../pi-tools.js";
import { wrapStreamFnTextTransforms } from "../plugin-text-transforms.js";
import { registerProviderStreamForModel } from "../provider-stream.js";
import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js";
import { resolveSandboxContext } from "../sandbox.js";
@@ -252,6 +255,19 @@ function prepareCompactionSessionAgent(params: {
resolvedApiKey: params.resolvedApiKey,
authStorage: params.authStorage as never,
});
const providerTextTransforms = resolveProviderTextTransforms({
provider: params.provider,
config: params.config,
workspaceDir: params.effectiveWorkspace,
});
if (providerTextTransforms) {
params.session.agent.streamFn = wrapStreamFnTextTransforms({
streamFn: params.session.agent.streamFn as never,
input: providerTextTransforms.input,
output: providerTextTransforms.output,
transformSystemPrompt: false,
}) as never;
}
return applyExtraParamsToAgent(
params.session.agent as never,
params.config,
@@ -746,45 +762,64 @@ export async function compactEmbeddedPiSessionDirect(
agentId: sessionAgentId,
},
});
const buildSystemPromptOverride = (defaultThinkLevel: ThinkLevel) =>
createSystemPromptOverride(
const buildSystemPromptOverride = (defaultThinkLevel: ThinkLevel) => {
const builtSystemPrompt =
resolveSystemPromptOverride({
config: params.config,
agentId: sessionAgentId,
}) ??
buildEmbeddedSystemPrompt({
workspaceDir: effectiveWorkspace,
defaultThinkLevel,
reasoningLevel: params.reasoningLevel ?? "off",
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
ownerDisplay: ownerDisplay.ownerDisplay,
ownerDisplaySecret: ownerDisplay.ownerDisplaySecret,
reasoningTagHint,
heartbeatPrompt: resolveHeartbeatPromptForSystemPrompt({
config: params.config,
agentId: sessionAgentId,
defaultAgentId,
}),
skillsPrompt,
docsPath: docsPath ?? undefined,
ttsHint,
promptMode,
acpEnabled: params.config?.acp?.enabled !== false,
runtimeInfo,
reactionGuidance,
messageToolHints,
sandboxInfo,
tools: effectiveTools,
modelAliasLines: buildModelAliasLines(params.config),
userTimezone,
userTime,
userTimeFormat,
contextFiles,
memoryCitationsMode: params.config?.memory?.citations,
promptContribution,
buildEmbeddedSystemPrompt({
workspaceDir: effectiveWorkspace,
defaultThinkLevel,
reasoningLevel: params.reasoningLevel ?? "off",
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
ownerDisplay: ownerDisplay.ownerDisplay,
ownerDisplaySecret: ownerDisplay.ownerDisplaySecret,
reasoningTagHint,
heartbeatPrompt: resolveHeartbeatPromptForSystemPrompt({
config: params.config,
agentId: sessionAgentId,
defaultAgentId,
}),
skillsPrompt,
docsPath: docsPath ?? undefined,
ttsHint,
promptMode,
acpEnabled: params.config?.acp?.enabled !== false,
runtimeInfo,
reactionGuidance,
messageToolHints,
sandboxInfo,
tools: effectiveTools,
modelAliasLines: buildModelAliasLines(params.config),
userTimezone,
userTime,
userTimeFormat,
contextFiles,
memoryCitationsMode: params.config?.memory?.citations,
promptContribution,
});
return createSystemPromptOverride(
transformProviderSystemPrompt({
provider,
config: params.config,
workspaceDir: effectiveWorkspace,
context: {
config: params.config,
agentDir,
workspaceDir: effectiveWorkspace,
provider,
modelId,
promptMode,
runtimeChannel,
runtimeCapabilities,
agentId: sessionAgentId,
systemPrompt: builtSystemPrompt,
},
}),
);
};
const compactionTimeoutMs = resolveCompactionTimeoutMs(params.config);
const sessionLock = await acquireSessionWriteLock({

View File

@@ -24,7 +24,11 @@ import {
} from "../../../plugin-sdk/ollama-runtime.js";
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
import { resolveToolCallArgumentsEncoding } from "../../../plugins/provider-model-compat.js";
import { resolveProviderSystemPromptContribution } from "../../../plugins/provider-runtime.js";
import {
resolveProviderSystemPromptContribution,
resolveProviderTextTransforms,
transformProviderSystemPrompt,
} from "../../../plugins/provider-runtime.js";
import { isSubagentSessionKey } from "../../../routing/session-key.js";
import { normalizeOptionalLowercaseString } from "../../../shared/string-coerce.js";
import { normalizeOptionalString } from "../../../shared/string-coerce.js";
@@ -84,6 +88,7 @@ import { createPreparedEmbeddedPiSettingsManager } from "../../pi-project-settin
import { applyPiAutoCompactionGuard } from "../../pi-settings.js";
import { toClientToolDefinitions } from "../../pi-tool-definition-adapter.js";
import { createOpenClawCodingTools, resolveToolLoopDetectionConfig } from "../../pi-tools.js";
import { wrapStreamFnTextTransforms } from "../../plugin-text-transforms.js";
import { registerProviderStreamForModel } from "../../provider-stream.js";
import { resolveSandboxContext } from "../../sandbox.js";
import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js";
@@ -733,7 +738,7 @@ export async function runEmbeddedAttempt(
},
});
const appendPrompt =
const builtAppendPrompt =
resolveSystemPromptOverride({
config: params.config,
agentId: sessionAgentId,
@@ -768,6 +773,23 @@ export async function runEmbeddedAttempt(
memoryCitationsMode: params.config?.memory?.citations,
promptContribution,
});
const appendPrompt = transformProviderSystemPrompt({
provider: params.provider,
config: params.config,
workspaceDir: effectiveWorkspace,
context: {
config: params.config,
agentDir: params.agentDir,
workspaceDir: effectiveWorkspace,
provider: params.provider,
modelId: params.modelId,
promptMode: effectivePromptMode,
runtimeChannel,
runtimeCapabilities,
agentId: sessionAgentId,
systemPrompt: builtAppendPrompt,
},
});
const systemPromptReport = buildSystemPromptReport({
source: "run",
generatedAt: Date.now(),
@@ -1029,6 +1051,19 @@ export async function runEmbeddedAttempt(
resolvedApiKey: params.resolvedApiKey,
authStorage: params.authStorage,
});
const providerTextTransforms = resolveProviderTextTransforms({
provider: params.provider,
config: params.config,
workspaceDir: effectiveWorkspace,
});
if (providerTextTransforms) {
activeSession.agent.streamFn = wrapStreamFnTextTransforms({
streamFn: activeSession.agent.streamFn,
input: providerTextTransforms.input,
output: providerTextTransforms.output,
transformSystemPrompt: false,
});
}
const { effectiveExtraParams } = applyExtraParamsToAgent(
activeSession.agent,

View File

@@ -0,0 +1,159 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import {
createAssistantMessageEventStream,
type AssistantMessage,
type Context,
type Model,
} from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import {
applyPluginTextReplacements,
mergePluginTextTransforms,
transformStreamContextText,
wrapStreamFnTextTransforms,
} from "./plugin-text-transforms.js";
const model = {
api: "openai-responses",
provider: "test",
id: "test-model",
} as Model<"openai-responses">;
function makeAssistantMessage(text: string): AssistantMessage {
return {
role: "assistant",
content: [{ type: "text", text }],
stopReason: "stop",
api: "openai-responses",
provider: "test",
model: "test-model",
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: 0,
};
}
describe("plugin text transforms", () => {
it("merges registered transform groups in order", () => {
const merged = mergePluginTextTransforms(
{ input: [{ from: /red basket/g, to: "blue basket" }] },
{ output: [{ from: /blue basket/g, to: "red basket" }] },
{ input: [{ from: /paper ticket/g, to: "digital ticket" }] },
);
expect(merged?.input).toHaveLength(2);
expect(merged?.output).toHaveLength(1);
expect(applyPluginTextReplacements("red basket paper ticket", merged?.input)).toBe(
"blue basket digital ticket",
);
});
it("applies ordered string and regexp replacements", () => {
expect(
applyPluginTextReplacements("paper ticket on the left shelf", [
{ from: /paper ticket/g, to: "digital ticket" },
{ from: /left shelf/g, to: "right shelf" },
{ from: "digital ticket", to: "counter receipt" },
]),
).toBe("counter receipt on the right shelf");
});
it("rewrites system prompt and message text content before transport", () => {
const context = transformStreamContextText(
{
systemPrompt: "Use orchid mailbox inside north tower",
messages: [
{
role: "user",
content: [
{ type: "text", text: "Please use the red basket" },
{ type: "image", url: "data:image/png;base64,abc" },
],
},
],
} as Context,
[
{
from: /orchid mailbox/g,
to: "pine mailbox",
},
{ from: /red basket/g, to: "blue basket" },
],
) as unknown as { systemPrompt: string; messages: Array<{ content: unknown[] }> };
expect(context.systemPrompt).toBe("Use pine mailbox inside north tower");
expect(context.messages[0]?.content[0]).toMatchObject({
type: "text",
text: "Please use the blue basket",
});
expect(context.messages[0]?.content[1]).toMatchObject({
type: "image",
url: "data:image/png;base64,abc",
});
});
it("wraps stream functions with inbound and outbound replacements", async () => {
let capturedContext: Context | undefined;
const baseStreamFn: StreamFn = (_model, context) => {
capturedContext = context;
const stream = createAssistantMessageEventStream();
queueMicrotask(() => {
const partial = makeAssistantMessage("blue basket on the right shelf");
stream.push({
type: "text_delta",
contentIndex: 0,
delta: "blue basket on the right shelf",
partial,
});
stream.push({
type: "done",
reason: "stop",
message: makeAssistantMessage("final blue basket on the right shelf"),
});
stream.end();
});
return stream;
};
const wrapped = wrapStreamFnTextTransforms({
streamFn: baseStreamFn,
input: [{ from: /red basket/g, to: "blue basket" }],
output: [
{ from: /blue basket/g, to: "red basket" },
{ from: /right shelf/g, to: "left shelf" },
],
transformSystemPrompt: false,
});
const stream = await Promise.resolve(
wrapped(
model,
{
systemPrompt: "Keep red basket untouched here",
messages: [{ role: "user", content: "Use red basket" }],
} as Context,
undefined,
),
);
const events = [];
for await (const event of stream) {
events.push(event);
}
const result = await stream.result();
expect(capturedContext?.systemPrompt).toBe("Keep red basket untouched here");
expect(capturedContext?.messages).toMatchObject([{ role: "user", content: "Use blue basket" }]);
expect(events[0]).toMatchObject({
type: "text_delta",
delta: "red basket on the left shelf",
});
expect(result.content).toMatchObject([
{ type: "text", text: "final red basket on the left shelf" },
]);
});
});

View File

@@ -0,0 +1,173 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { streamSimple, type AssistantMessageEvent } from "@mariozechner/pi-ai";
import type { PluginTextReplacement, PluginTextTransforms } from "../plugins/types.js";
export function mergePluginTextTransforms(
...transforms: Array<PluginTextTransforms | undefined>
): PluginTextTransforms | undefined {
const input = transforms.flatMap((entry) => entry?.input ?? []);
const output = transforms.flatMap((entry) => entry?.output ?? []);
if (input.length === 0 && output.length === 0) {
return undefined;
}
return {
...(input.length > 0 ? { input } : {}),
...(output.length > 0 ? { output } : {}),
};
}
export function applyPluginTextReplacements(
text: string,
replacements?: PluginTextReplacement[],
): string {
if (!replacements || replacements.length === 0 || !text) {
return text;
}
let next = text;
for (const replacement of replacements) {
next = next.replace(replacement.from, replacement.to);
}
return next;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function transformContentText(content: unknown, replacements?: PluginTextReplacement[]): unknown {
if (typeof content === "string") {
return applyPluginTextReplacements(content, replacements);
}
if (Array.isArray(content)) {
return content.map((entry) => transformContentText(entry, replacements));
}
if (!isRecord(content)) {
return content;
}
const next = { ...content };
if (typeof next.text === "string") {
next.text = applyPluginTextReplacements(next.text, replacements);
}
if (Object.hasOwn(next, "content")) {
next.content = transformContentText(next.content, replacements);
}
return next;
}
function transformMessageText(message: unknown, replacements?: PluginTextReplacement[]): unknown {
if (!isRecord(message)) {
return message;
}
const next = { ...message };
if (Object.hasOwn(next, "content")) {
next.content = transformContentText(next.content, replacements);
}
if (typeof next.errorMessage === "string") {
next.errorMessage = applyPluginTextReplacements(next.errorMessage, replacements);
}
return next;
}
export function transformStreamContextText(
context: Parameters<StreamFn>[1],
replacements?: PluginTextReplacement[],
options?: { systemPrompt?: boolean },
): Parameters<StreamFn>[1] {
if (!replacements || replacements.length === 0) {
return context;
}
return {
...context,
systemPrompt:
options?.systemPrompt !== false && typeof context.systemPrompt === "string"
? applyPluginTextReplacements(context.systemPrompt, replacements)
: context.systemPrompt,
messages: Array.isArray(context.messages)
? context.messages.map((message) => transformMessageText(message, replacements))
: context.messages,
} as Parameters<StreamFn>[1];
}
function transformAssistantEventText(
event: unknown,
replacements?: PluginTextReplacement[],
): AssistantMessageEvent {
if (!isRecord(event) || !replacements || replacements.length === 0) {
return event as AssistantMessageEvent;
}
const next = { ...event };
if (next.type === "text_delta" && typeof next.delta === "string") {
next.delta = applyPluginTextReplacements(next.delta, replacements);
}
if (next.type === "text_end" && typeof next.content === "string") {
next.content = applyPluginTextReplacements(next.content, replacements);
}
if (Object.hasOwn(next, "partial")) {
next.partial = transformMessageText(next.partial, replacements);
}
if (Object.hasOwn(next, "message")) {
next.message = transformMessageText(next.message, replacements);
}
if (Object.hasOwn(next, "error")) {
next.error = transformMessageText(next.error, replacements);
}
return next as AssistantMessageEvent;
}
function wrapStreamTextTransforms(
stream: ReturnType<typeof streamSimple>,
replacements?: PluginTextReplacement[],
): ReturnType<typeof streamSimple> {
if (!replacements || replacements.length === 0) {
return stream;
}
const originalResult = stream.result.bind(stream);
stream.result = async () => transformMessageText(await originalResult(), replacements) as never;
const originalAsyncIterator = stream[Symbol.asyncIterator].bind(stream);
(stream as { [Symbol.asyncIterator]: typeof originalAsyncIterator })[Symbol.asyncIterator] =
function () {
const iterator = originalAsyncIterator();
return {
async next() {
const result = await iterator.next();
return result.done
? result
: {
done: false as const,
value: transformAssistantEventText(result.value, replacements),
};
},
async return(value?: unknown) {
return iterator.return?.(value) ?? { done: true as const, value: undefined };
},
async throw(error?: unknown) {
return iterator.throw?.(error) ?? { done: true as const, value: undefined };
},
[Symbol.asyncIterator]() {
return this;
},
};
};
return stream;
}
export function wrapStreamFnTextTransforms(params: {
streamFn: StreamFn;
input?: PluginTextReplacement[];
output?: PluginTextReplacement[];
transformSystemPrompt?: boolean;
}): StreamFn {
return (model, context, options) => {
const nextContext = transformStreamContextText(context, params.input, {
systemPrompt: params.transformSystemPrompt,
});
const maybeStream = params.streamFn(model, nextContext, options);
if (maybeStream && typeof maybeStream === "object" && "then" in maybeStream) {
return Promise.resolve(maybeStream).then((stream) =>
wrapStreamTextTransforms(stream, params.output),
);
}
return wrapStreamTextTransforms(maybeStream, params.output);
};
}

View File

@@ -3369,6 +3369,10 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
},
],
},
jsonlDialect: {
type: "string",
const: "claude-stream-json",
},
input: {
anyOf: [
{

View File

@@ -59,6 +59,8 @@ export type CliBackendConfig = {
output?: "json" | "text" | "jsonl";
/** Output parsing mode when resuming a CLI session. */
resumeOutput?: "json" | "text" | "jsonl";
/** JSONL event dialect for CLIs with provider-specific stream formats. */
jsonlDialect?: "claude-stream-json";
/** Prompt input mode (default: arg). */
input?: "arg" | "stdin";
/** Max prompt length for arg mode (if exceeded, stdin is used). */

View File

@@ -529,6 +529,7 @@ export const CliBackendSchema = z
args: z.array(z.string()).optional(),
output: z.union([z.literal("json"), z.literal("text"), z.literal("jsonl")]).optional(),
resumeOutput: z.union([z.literal("json"), z.literal("text"), z.literal("jsonl")]).optional(),
jsonlDialect: z.literal("claude-stream-json").optional(),
input: z.union([z.literal("arg"), z.literal("stdin")]).optional(),
maxPromptArgChars: z.number().int().positive().optional(),
env: z.record(z.string(), z.string()).optional(),

View File

@@ -78,6 +78,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
webFetchProviders: [],
webSearchProviders: [],
memoryEmbeddingProviders: [],
textTransforms: [],
agentHarnesses: [],
gatewayHandlers: {},
httpRoutes: [],

View File

@@ -23,6 +23,7 @@ function createStubPluginRegistry(): PluginRegistry {
webFetchProviders: [],
webSearchProviders: [],
memoryEmbeddingProviders: [],
textTransforms: [],
agentHarnesses: [],
gatewayHandlers: {},
httpRoutes: [],

View File

@@ -29,6 +29,7 @@ export type BuildPluginApiParams = {
| "registerSecurityAuditCollector"
| "registerService"
| "registerCliBackend"
| "registerTextTransforms"
| "registerConfigMigration"
| "registerAutoEnableProbe"
| "registerProvider"
@@ -71,6 +72,7 @@ const noopRegisterSecurityAuditCollector: OpenClawPluginApi["registerSecurityAud
() => {};
const noopRegisterService: OpenClawPluginApi["registerService"] = () => {};
const noopRegisterCliBackend: OpenClawPluginApi["registerCliBackend"] = () => {};
const noopRegisterTextTransforms: OpenClawPluginApi["registerTextTransforms"] = () => {};
const noopRegisterConfigMigration: OpenClawPluginApi["registerConfigMigration"] = () => {};
const noopRegisterAutoEnableProbe: OpenClawPluginApi["registerAutoEnableProbe"] = () => {};
const noopRegisterProvider: OpenClawPluginApi["registerProvider"] = () => {};
@@ -134,6 +136,7 @@ export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi
handlers.registerSecurityAuditCollector ?? noopRegisterSecurityAuditCollector,
registerService: handlers.registerService ?? noopRegisterService,
registerCliBackend: handlers.registerCliBackend ?? noopRegisterCliBackend,
registerTextTransforms: handlers.registerTextTransforms ?? noopRegisterTextTransforms,
registerConfigMigration: handlers.registerConfigMigration ?? noopRegisterConfigMigration,
registerAutoEnableProbe: handlers.registerAutoEnableProbe ?? noopRegisterAutoEnableProbe,
registerProvider: handlers.registerProvider ?? noopRegisterProvider,

View File

@@ -341,6 +341,15 @@ export function loadBundledCapabilityRuntimeRegistry(params: {
rootDir: record.rootDir,
})),
);
registry.textTransforms.push(
...captured.textTransforms.map((transforms) => ({
pluginId: record.id,
pluginName: record.name,
transforms,
source: record.source,
rootDir: record.rootDir,
})),
);
registry.providers.push(
...captured.providers.map((provider) => ({
pluginId: record.id,

View File

@@ -18,6 +18,10 @@ describe("captured plugin registration", () => {
label: "Captured Provider",
auth: [],
});
api.registerTextTransforms({
input: [{ from: /red basket/g, to: "blue basket" }],
output: [{ from: /blue basket/g, to: "red basket" }],
});
api.registerChannel({
plugin: {
id: "captured-channel",
@@ -47,6 +51,8 @@ describe("captured plugin registration", () => {
expect(captured.tools.map((tool) => tool.name)).toEqual(["captured-tool"]);
expect(captured.providers.map((provider) => provider.id)).toEqual(["captured-provider"]);
expect(captured.textTransforms).toHaveLength(1);
expect(captured.textTransforms[0]?.input).toHaveLength(1);
expect(captured.api.registerMemoryEmbeddingProvider).toBeTypeOf("function");
});
});

View File

@@ -12,6 +12,7 @@ import type {
OpenClawPluginApi,
OpenClawPluginCliCommandDescriptor,
OpenClawPluginCliRegistrar,
PluginTextTransformRegistration,
ProviderPlugin,
RealtimeTranscriptionProviderPlugin,
RealtimeVoiceProviderPlugin,
@@ -33,6 +34,7 @@ export type CapturedPluginRegistration = {
agentHarnesses: AgentHarness[];
cliRegistrars: CapturedPluginCliRegistration[];
cliBackends: CliBackendPlugin[];
textTransforms: PluginTextTransformRegistration[];
speechProviders: SpeechProviderPlugin[];
realtimeTranscriptionProviders: RealtimeTranscriptionProviderPlugin[];
realtimeVoiceProviders: RealtimeVoiceProviderPlugin[];
@@ -54,6 +56,7 @@ export function createCapturedPluginRegistration(params?: {
const agentHarnesses: AgentHarness[] = [];
const cliRegistrars: CapturedPluginCliRegistration[] = [];
const cliBackends: CliBackendPlugin[] = [];
const textTransforms: PluginTextTransformRegistration[] = [];
const speechProviders: SpeechProviderPlugin[] = [];
const realtimeTranscriptionProviders: RealtimeTranscriptionProviderPlugin[] = [];
const realtimeVoiceProviders: RealtimeVoiceProviderPlugin[] = [];
@@ -77,6 +80,7 @@ export function createCapturedPluginRegistration(params?: {
agentHarnesses,
cliRegistrars,
cliBackends,
textTransforms,
speechProviders,
realtimeTranscriptionProviders,
realtimeVoiceProviders,
@@ -130,6 +134,9 @@ export function createCapturedPluginRegistration(params?: {
registerCliBackend(backend: CliBackendPlugin) {
cliBackends.push(backend);
},
registerTextTransforms(transforms: PluginTextTransformRegistration) {
textTransforms.push(transforms);
},
registerSpeechProvider(provider: SpeechProviderPlugin) {
speechProviders.push(provider);
},

View File

@@ -719,6 +719,37 @@ describe("loadOpenClawPlugins", () => {
expect(bundled?.status).toBe("disabled");
});
it("registers standalone text transforms", () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "text-shim",
filename: "text-shim.cjs",
body: `module.exports = {
id: "text-shim",
register(api) {
api.registerTextTransforms({
input: [{ from: /red basket/g, to: "blue basket" }],
output: [{ from: /blue basket/g, to: "red basket" }],
});
},
};`,
});
const registry = loadRegistryFromSinglePlugin({
plugin,
pluginConfig: { allow: ["text-shim"] },
});
expect(registry.textTransforms).toHaveLength(1);
expect(registry.textTransforms[0]).toMatchObject({
pluginId: "text-shim",
transforms: {
input: expect.any(Array),
output: expect.any(Array),
},
});
});
it.each([
{
name: "loads bundled telegram plugin when enabled",

View File

@@ -1,4 +1,8 @@
import type { AuthProfileCredential, OAuthCredential } from "../agents/auth-profiles/types.js";
import {
applyPluginTextReplacements,
mergePluginTextTransforms,
} from "../agents/plugin-text-transforms.js";
import { normalizeProviderId } from "../agents/provider-id.js";
import type { ProviderSystemPromptContribution } from "../agents/system-prompt-contribution.js";
import type { OpenClawConfig } from "../config/config.js";
@@ -9,6 +13,7 @@ import { resolveCatalogHookProviderPluginIds } from "./providers.js";
import { isPluginProvidersLoadInFlight, resolvePluginProviders } from "./providers.runtime.js";
import { resolvePluginCacheInputs } from "./roots.js";
import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-state.js";
import { resolveRuntimeTextTransforms } from "./text-transforms.runtime.js";
import type {
ProviderAuthDoctorHintContext,
ProviderAugmentModelCatalogContext,
@@ -49,12 +54,14 @@ import type {
ProviderResolveTransportTurnStateContext,
ProviderResolveWebSocketSessionPolicyContext,
ProviderSystemPromptContributionContext,
ProviderTransformSystemPromptContext,
ProviderRuntimeModel,
ProviderThinkingPolicyContext,
ProviderTransportTurnState,
ProviderValidateReplayTurnsContext,
ProviderWebSocketSessionPolicy,
ProviderWrapStreamFnContext,
PluginTextTransforms,
} from "./types.js";
function matchesProviderId(provider: ProviderPlugin, providerId: string): boolean {
@@ -242,6 +249,35 @@ export function resolveProviderSystemPromptContribution(params: {
);
}
export function transformProviderSystemPrompt(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
context: ProviderTransformSystemPromptContext;
}): string {
const plugin = resolveProviderRuntimePlugin(params);
const textTransforms = mergePluginTextTransforms(
resolveRuntimeTextTransforms(),
plugin?.textTransforms,
);
const transformed =
plugin?.transformSystemPrompt?.(params.context) ?? params.context.systemPrompt;
return applyPluginTextReplacements(transformed, textTransforms?.input);
}
export function resolveProviderTextTransforms(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): PluginTextTransforms | undefined {
return mergePluginTextTransforms(
resolveRuntimeTextTransforms(),
resolveProviderRuntimePlugin(params)?.textTransforms,
);
}
export async function prepareProviderDynamicModel(params: {
provider: string;
config?: OpenClawConfig;

View File

@@ -10,6 +10,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
channelSetups: [],
providers: [],
cliBackends: [],
textTransforms: [],
speechProviders: [],
realtimeTranscriptionProviders: [],
realtimeVoiceProviders: [],

View File

@@ -33,6 +33,7 @@ import type {
PluginKind,
PluginLogger,
PluginOrigin,
PluginTextTransformRegistration,
ProviderPlugin,
RealtimeTranscriptionProviderPlugin,
RealtimeVoiceProviderPlugin,
@@ -105,6 +106,14 @@ export type PluginCliBackendRegistration = {
rootDir?: string;
};
export type PluginTextTransformsRegistration = {
pluginId: string;
pluginName?: string;
transforms: PluginTextTransformRegistration;
source: string;
rootDir?: string;
};
type PluginOwnedProviderRegistration<T extends { id: string }> = {
pluginId: string;
pluginName?: string;
@@ -259,6 +268,7 @@ export type PluginRegistry = {
channelSetups: PluginChannelSetupRegistration[];
providers: PluginProviderRegistration[];
cliBackends?: PluginCliBackendRegistration[];
textTransforms: PluginTextTransformsRegistration[];
speechProviders: PluginSpeechProviderRegistration[];
realtimeTranscriptionProviders: PluginRealtimeTranscriptionProviderRegistration[];
realtimeVoiceProviders: PluginRealtimeVoiceProviderRegistration[];

View File

@@ -63,6 +63,7 @@ import type {
PluginReloadRegistration,
PluginSecurityAuditCollectorRegistration,
PluginServiceRegistration,
PluginTextTransformsRegistration,
} from "./registry-types.js";
import { withPluginRuntimePluginIdScope } from "./runtime/gateway-request-scope.js";
import type { PluginRuntime } from "./runtime/types.js";
@@ -136,6 +137,7 @@ export type {
PluginReloadRegistration,
PluginSecurityAuditCollectorRegistration,
PluginServiceRegistration,
PluginTextTransformsRegistration,
PluginToolRegistration,
PluginSpeechProviderRegistration,
PluginRealtimeTranscriptionProviderRegistration,
@@ -614,6 +616,31 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
record.cliBackendIds.push(id);
};
const registerTextTransforms = (
record: PluginRecord,
transforms: PluginTextTransformsRegistration["transforms"],
) => {
if (
(!transforms.input || transforms.input.length === 0) &&
(!transforms.output || transforms.output.length === 0)
) {
pushDiagnostic({
level: "warn",
pluginId: record.id,
source: record.source,
message: "text transform registration has no input or output replacements",
});
return;
}
registry.textTransforms.push({
pluginId: record.id,
pluginName: record.name,
transforms,
source: record.source,
rootDir: record.rootDir,
});
};
const registerUniqueProviderLike = <
T extends { id: string },
R extends PluginOwnedProviderRegistration<T>,
@@ -1151,6 +1178,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerGatewayMethod(record, method, handler, opts),
registerService: (service) => registerService(record, service),
registerCliBackend: (backend) => registerCliBackend(record, backend),
registerTextTransforms: (transforms) => registerTextTransforms(record, transforms),
registerReload: (registration) => registerReload(record, registration),
registerNodeHostCommand: (command) => registerNodeHostCommand(record, command),
registerSecurityAuditCollector: (collector) =>
@@ -1394,6 +1422,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerProvider,
registerAgentHarness,
registerCliBackend,
registerTextTransforms,
registerSpeechProvider,
registerRealtimeTranscriptionProvider,
registerRealtimeVoiceProvider,

View File

@@ -128,6 +128,7 @@ export function createPluginLoadResult(
webFetchProviders: [],
webSearchProviders: [],
memoryEmbeddingProviders: [],
textTransforms: [],
agentHarnesses: [],
tools: [],
hooks: [],

View File

@@ -0,0 +1,33 @@
import { createRequire } from "node:module";
import { mergePluginTextTransforms } from "../agents/plugin-text-transforms.js";
import type { PluginTextTransforms } from "./types.js";
type PluginRuntimeModule = Pick<typeof import("./runtime.js"), "getActivePluginRegistry">;
const require = createRequire(import.meta.url);
const RUNTIME_MODULE_CANDIDATES = ["./runtime.js", "./runtime.ts"] as const;
let pluginRuntimeModule: PluginRuntimeModule | undefined;
function loadPluginRuntime(): PluginRuntimeModule | null {
if (pluginRuntimeModule) {
return pluginRuntimeModule;
}
for (const candidate of RUNTIME_MODULE_CANDIDATES) {
try {
pluginRuntimeModule = require(candidate) as PluginRuntimeModule;
return pluginRuntimeModule;
} catch {
// Try source/runtime candidates in order.
}
}
return null;
}
export function resolveRuntimeTextTransforms(): PluginTextTransforms | undefined {
return mergePluginTextTransforms(
...(loadPluginRuntime()
?.getActivePluginRegistry()
?.textTransforms.map((entry) => entry.transforms) ?? []),
);
}

View File

@@ -1095,6 +1095,24 @@ export type ProviderSystemPromptContributionContext = {
agentId?: string;
};
export type ProviderTransformSystemPromptContext = ProviderSystemPromptContributionContext & {
systemPrompt: string;
};
export type PluginTextReplacement = {
from: string | RegExp;
to: string;
};
export type PluginTextTransforms = {
/** Rewrites applied to outbound prompt text before provider/CLI transport. */
input?: PluginTextReplacement[];
/** Rewrites applied to inbound assistant text before OpenClaw consumes it. */
output?: PluginTextReplacement[];
};
export type PluginTextTransformRegistration = PluginTextTransforms;
/** Text-inference provider capability registered by a plugin. */
export type ProviderPlugin = {
id: string;
@@ -1467,6 +1485,22 @@ export type ProviderPlugin = {
resolveSystemPromptContribution?: (
ctx: ProviderSystemPromptContributionContext,
) => ProviderSystemPromptContribution | null | undefined;
/**
* Provider-owned final system-prompt transform.
*
* Use this sparingly when a provider transport needs small compatibility
* rewrites after OpenClaw has assembled the complete prompt. Return
* `undefined`/`null` to leave the prompt unchanged.
*/
transformSystemPrompt?: (ctx: ProviderTransformSystemPromptContext) => string | null | undefined;
/**
* Provider-owned bidirectional text replacements.
*
* `input` applies to system prompts and text message content before transport.
* `output` applies to assistant text deltas/final text before OpenClaw handles
* its own control markers or channel delivery.
*/
textTransforms?: PluginTextTransforms;
/**
* Provider-owned global config defaults.
*
@@ -2091,6 +2125,28 @@ export type CliBackendPlugin = {
* shapes need to stay working.
*/
normalizeConfig?: (config: CliBackendConfig) => CliBackendConfig;
/**
* Backend-owned final system-prompt transform.
*
* Use this for tiny CLI-specific compatibility rewrites without replacing
* the generic CLI runner or prompt builder.
*/
transformSystemPrompt?: (ctx: {
config?: OpenClawConfig;
workspaceDir?: string;
provider: string;
modelId: string;
modelDisplay: string;
agentId?: string;
systemPrompt: string;
}) => string | null | undefined;
/**
* Backend-owned bidirectional text replacements.
*
* `input` applies to the system prompt and user prompt passed to the CLI.
* `output` applies to parsed/streamed assistant text from the CLI.
*/
textTransforms?: PluginTextTransforms;
};
export type OpenClawPluginChannelRegistration = {
@@ -2199,6 +2255,8 @@ export type OpenClawPluginApi = {
registerService: (service: OpenClawPluginService) => void;
/** Register a text-only CLI backend used by the local CLI runner. */
registerCliBackend: (backend: CliBackendPlugin) => void;
/** Register plugin-owned prompt/message compatibility text transforms. */
registerTextTransforms: (transforms: PluginTextTransformRegistration) => void;
/** Register a lightweight config migration that can run before plugin runtime loads. */
registerConfigMigration: (migrate: PluginConfigMigration) => void;
/** Register a lightweight config probe that can auto-enable this plugin generically. */

View File

@@ -36,6 +36,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl
webFetchProviders: [],
webSearchProviders: [],
memoryEmbeddingProviders: [],
textTransforms: [],
agentHarnesses: [],
gatewayHandlers: {},
gatewayMethodScopes: {},

View File

@@ -18,6 +18,7 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi
registerGatewayMethod() {},
registerCli() {},
registerCliBackend() {},
registerTextTransforms() {},
registerService() {},
registerReload() {},
registerNodeHostCommand() {},