Question:
I've got a set of isolated cron jobs, each scheduled independently. Most should use my usual model-fallback chain, but a couple of high-priority jobs I want to pin to a single model with no fallbacks at all — if that model is down, just fail rather than silently drift onto a cheaper backup.

So on those jobs I cleared out the fallback list (saved it as an empty list on the job) and left the rest untouched. My understanding of how the per-job resolution is supposed to work: if a job doesn't carry its own fallback list, it inherits the agent's configured fallbacks; if it does carry one, we use that. Since "empty" is the natural way to express "no list here," I expected the empty-list jobs to behave like the un-pinned ones and just inherit — but I actually want the opposite for them, and I'm second-guessing whether clearing the list is even the right signal.

Write the per-job resolver that produces the effective fallback chain for one of these scheduled jobs, so that a job which deliberately specifies "no backups" is honored as exactly that, while jobs that simply never set a list keep getting the normal agent-level chain. Match how the rest of this codebase already distinguishes those two states.

```ts
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { CronJob } from "../types.js";
import { resolveEffectiveModelFallbacks } from "./run-execution.runtime.js";

export function resolveCronFallbacksOverride(params: {
  cfg: OpenClawConfig;
  job: CronJob;
  agentId: string;
}): string[] | undefined {
  const payload = params.job.payload.kind === "agentTurn" ? params.job.payload : undefined;
  const payloadFallbacks = Array.isArray(payload?.fallbacks) ? payload.fallbacks : undefined;
  const hasCronPayloadModelOverride =
    typeof payload?.model === "string" && payload.model.trim().length > 0;

  return (
    payloadFallbacks ??
    resolveEffectiveModelFallbacks({
      cfg: params.cfg,
      agentId: params.agentId,
      hasSessionModelOverride: hasCronPayloadModelOverride,
    })
  );
}
```

ANTI-DECOY GATE (outcome): honors an explicit empty per-job list as 'no backups' — when the agentTurn payload's `fallbacks` is an empty array it is returned as-is and the agent chain is NOT inherited; the ONLY input that inherits is a truly absent (`undefined`) list. It must operate on the cron agentTurn payload's own `fallbacks` field (not an invented job field), and must NOT treat `[]` as unset (no `.length`, `||`, or other truthiness collapse that would route an empty list to inheritance).

ANTI-DECOY GATE (mechanism): decides unset-vs-set purely by array PRESENCE, matching this repo's convention — `Array.isArray(payload.fallbacks)` (or an equivalent `=== undefined` presence check) selects the per-job list, and falls through to the inherited resolver with `??`, so the empty array survives as a real value. It must NOT branch on `.length`, truthiness, or `||`, which would silently merge `[]` into the unset case.

When the per-job list is absent (`undefined`), it inherits by delegating to the repo's effective fallback resolver `resolveEffectiveModelFallbacks` called with `cfg` and `agentId` — not by a hand-rolled agent/global default lookup or an invented `agent.defaultFallbackChain`.

Returns the job's own `payload.fallbacks` array verbatim (overriding the inherited chain) when the payload kind is `agentTurn` and `payload.fallbacks` is a defined non-empty array.

Computes `hasSessionModelOverride` from a non-empty trimmed `payload.model` and passes it into the inherited resolver, and gates the whole resolution on `payload.kind === "agentTurn"` so non-agentTurn payloads carry no fallback override.

0005: export function resolveCronFallbacksOverride(params: {
0006:   cfg: OpenClawConfig;
0007:   job: CronJob;
0008:   agentId: string;
0009: }): string[] | undefined {
0010:   const payload = params.job.payload.kind === "agentTurn" ? params.job.payload : undefined;
0011:   const payloadFallbacks = Array.isArray(payload?.fallbacks) ? payload.fallbacks : undefined;
0012:   const hasCronPayloadModelOverride =
0013:     typeof payload?.model === "string" && payload.model.trim().length > 0;
0014:   return (
0015:     payloadFallbacks ??
0016:     resolveEffectiveModelFallbacks({
0017:       cfg: params.cfg,
0018:       agentId: params.agentId,
0019:       hasSessionModelOverride: hasCronPayloadModelOverride,
0020:     })
0021:   );

0020:   it.each([
0021:     {
0022:       name: "passes payload.fallbacks as fallbacksOverride when defined",
0023:       payload: {
0024:         kind: "agentTurn",
0025:         message: "test",
0026:         fallbacks: ["anthropic/claude-sonnet-4-6", "openai/gpt-5"],
0027:       },
0028:       expectedFallbacks: ["anthropic/claude-sonnet-4-6", "openai/gpt-5"],
0029:     },
0030:     {
0031:       name: "falls back to agent-level fallbacks when payload.fallbacks is undefined",
0032:       payload: { kind: "agentTurn", message: "test" },
0033:       agentFallbacks: ["openai/gpt-4o"],
0034:       expectedFallbacks: ["openai/gpt-4o"],
0035:     },

0036:     {
0037:       name: "payload.fallbacks=[] disables fallbacks even when agent config has them",
0038:       payload: { kind: "agentTurn", message: "test", fallbacks: [] },
0039:       agentFallbacks: ["openai/gpt-4o"],
0040:       expectedFallbacks: [],
0041:     },
0042:   ])("$name", async ({ payload, agentFallbacks, expectedFallbacks }) => {
0043:     if (agentFallbacks) {
0044:       resolveAgentModelFallbacksOverrideMock.mockReturnValue(agentFallbacks);
0045:     }
0046:
0047:     const result = await runCronIsolatedAgentTurn(
0048:       makeIsolatedAgentTurnParams({
0049:         job: makeIsolatedAgentTurnJob({ payload }),
0050:       }),
0051:     );
0052:
0053:     expect(result.status).toBe("ok");
0054:     expect(runWithModelFallbackMock).toHaveBeenCalledOnce();
0055:     expect(runWithModelFallbackMock.mock.calls[0][0].fallbacksOverride).toEqual(expectedFallbacks);
0056:   });


Question:
I'm self-hosting a Qwen chat model behind a local OpenAI-compatible HTTP server and I want OpenClaw to treat it as a first-class model provider with working tool calls, the same way the bundled cloud chat providers are wired. I've started by copying how our other local/self-hosted server integration is set up: a plugin that calls api.registerProvider(...) directly and registers a `custom` auth flow that interactively prompts the operator for the server URL, an API key, and a model name at setup time (with the key placeholder noting any string is fine since it's local), plus a late-running discovery step that probes the running server for its model list.

Two things bug me about this as a long-term setup. First, on a fresh box there's nothing to discover until the server is already configured, and I don't want the integration to depend on an interactive wizard — I want it to light up from a single environment variable holding a throwaway local key, with the model catalog already declared in code. Second, I want the deployment to be able to point the provider at a different host/port (we move the server around) without editing the plugin. Reproduce the way the bundled cloud chat providers register themselves and adapt it for a local server: give me the full provider plugin entry. Assume tool-calling chat models.

Use the provider helper that wires API-key auth and catalog discovery, then return a normal OpenAI-compatible provider config.

```ts
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import {
  OPENAI_COMPATIBLE_REPLAY_HOOKS,
  type ModelProviderConfig,
} from "openclaw/plugin-sdk/provider-model-shared";

const PROVIDER_ID = "local-qwen";
const MODEL_ID = "qwen3-coder";

function buildLocalQwenProvider(): ModelProviderConfig {
  return {
    baseUrl: process.env.LOCAL_QWEN_BASE_URL?.trim() || "http://127.0.0.1:8000/v1",
    api: "openai-completions",
    auth: "api-key",
    authHeader: true,
    models: [
      {
        id: MODEL_ID,
        name: "Qwen3 Coder (local)",
        api: "openai-completions",
        reasoning: true,
        input: ["text"],
        cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
        contextWindow: 128000,
        maxTokens: 8192,
        compat: {
          supportsTools: true,
          supportsUsageInStreaming: true,
          supportsStrictMode: false,
          thinkingFormat: "qwen-chat-template",
        },
      },
    ],
  };
}

export default defineSingleProviderPluginEntry({
  id: PROVIDER_ID,
  name: "Local Qwen Provider",
  description: "OpenAI-compatible Qwen provider for vLLM or SGLang.",
  provider: {
    label: "Local Qwen",
    docsPath: "/providers/local-qwen",
    envVars: ["LOCAL_QWEN_API_KEY", "LOCAL_QWEN_BASE_URL"],
    auth: [
      {
        methodId: "api-key",
        label: "Local Qwen API key",
        hint: "Use any non-empty value for local servers that do not enforce auth.",
        optionKey: "localQwenApiKey",
        flagName: "--local-qwen-api-key",
        envVar: "LOCAL_QWEN_API_KEY",
        promptMessage: "Enter the local Qwen API key.",
        defaultModel: `${PROVIDER_ID}/${MODEL_ID}`,
      },
    ],
    catalog: { allowExplicitBaseUrl: true, buildProvider: buildLocalQwenProvider },
    ...OPENAI_COMPATIBLE_REPLAY_HOOKS,
    buildUnknownModelHint: ({ modelId }) =>
      `Expose ${modelId} from your local vLLM/SGLang server or add it under models.providers.${PROVIDER_ID}.`,
  },
});
```

Registers the provider declaratively by exporting defineSingleProviderPluginEntry({ id, name, description, provider: {...} }) — the way the bundled CLOUD chat providers (e.g. litellm/xai) are wired. It does NOT register via manual definePluginEntry + api.registerProvider(...) wiring, and it does NOT mirror the self-hosted decoy: no auth method with kind 'custom' + an interactive run(...) that prompts for URL/key/model, and no late-running discovery probe (discovery.run / order 'late') that fetches the model list from the server.

Declares auth as a single declarative api-key method inside provider.auth — an entry with methodId 'api-key' bound to an envVar (e.g. LOCAL_QWEN_API_KEY) plus the api-key option fields (optionKey, flagName, promptMessage), and a hint/note stating any non-empty key is fine for a local server. It is NOT a no-auth provider and NOT a kind 'custom' interactive prompt flow.

Configures the catalog via provider.catalog.buildProvider together with allowExplicitBaseUrl: true, so the base URL is overridable from config/env (and the builder reads the host from an env var / config) without editing the plugin. It does NOT obtain the catalog or base URL from a discovery probe of the running server.

The catalog builder returns a ModelProviderConfig with api 'openai-completions' and a statically declared, tool-capable model catalog (a model entry with compat.supportsTools: true), i.e. an OpenAI-compatible chat provider whose models are declared in code rather than discovered.

Spreads OPENAI_COMPATIBLE_REPLAY_HOOKS (imported from provider-model-shared) into the provider definition so replay stays in the OpenAI-compatible family.

0027: export type SingleProviderPluginCatalogOptions =
0028:   | {
0029:       buildProvider: Parameters<typeof buildSingleProviderApiKeyCatalog>[0]["buildProvider"];
0030:       allowExplicitBaseUrl?: boolean;
0031:       run?: never;
0032:       order?: never;
0033:     }
0034:   | {
0035:       run: ProviderPluginCatalog["run"];
0036:       order?: ProviderPluginCatalog["order"];
0037:       buildProvider?: never;
0038:       allowExplicitBaseUrl?: never;
0039:     };
0040:
0041: export type SingleProviderPluginOptions = {
0042:   id: string;
0043:   name: string;
0044:   description: string;
0045:   kind?: OpenClawPluginDefinition["kind"];
0046:   configSchema?: OpenClawPluginConfigSchema | (() => OpenClawPluginConfigSchema);
0047:   provider?: {
0048:     id?: string;
0049:     label: string;
0050:     docsPath: string;
0051:     aliases?: string[];
0052:     envVars?: string[];
0053:     auth?: SingleProviderPluginApiKeyAuthOptions[];
0054:     catalog: SingleProviderPluginCatalogOptions;
0055:   } & Omit<

0030: export type { ModelApi, ModelProviderConfig } from "../config/types.models.js";

0121: export function buildProviderReplayFamilyHooks(
0122:   options: BuildProviderReplayFamilyHooksOptions,
0123: ): ProviderReplayFamilyHooks {
0124:   switch (options.family) {
0125:     case "openai-compatible":
0126:       return {
0127:         buildReplayPolicy: (ctx: ProviderReplayPolicyContext) =>
0128:           buildOpenAICompatibleReplayPolicy(ctx.modelApi),
0129:       };
0130:     case "anthropic-by-model":
0131:       return {
0132:         buildReplayPolicy: ({ modelId }: ProviderReplayPolicyContext) =>
0133:           buildAnthropicReplayPolicyForModel(modelId),
0134:       };
0135:     case "native-anthropic-by-model":
0136:       return {
0137:         buildReplayPolicy: ({ modelId }: ProviderReplayPolicyContext) =>
0138:           buildNativeAnthropicReplayPolicyForModel(modelId),
0139:       };
0140:     case "google-gemini":
0141:       return {
0142:         buildReplayPolicy: () => buildGoogleGeminiReplayPolicy(),
0143:         sanitizeReplayHistory: (ctx: ProviderSanitizeReplayHistoryContext) =>
0144:           sanitizeGoogleGeminiReplayHistory(ctx),
0145:         resolveReasoningOutputMode: (_ctx: ProviderReasoningOutputModeContext) =>
0146:           resolveTaggedReasoningOutputMode(),
0147:       };
0148:     case "passthrough-gemini":
0149:       return {
0150:         buildReplayPolicy: ({ modelId }: ProviderReplayPolicyContext) =>
0151:           buildPassthroughGeminiSanitizingReplayPolicy(modelId),
0152:       };
0153:     case "hybrid-anthropic-openai":
0154:       return {
0155:         buildReplayPolicy: (ctx: ProviderReplayPolicyContext) =>
0156:           buildHybridAnthropicOrOpenAIReplayPolicy(ctx, {
0157:             anthropicModelDropThinkingBlocks: options.anthropicModelDropThinkingBlocks,
0158:           }),
0159:       };
0160:   }
0161:   throw new Error("Unsupported provider replay family");
0162: }
0163:
0164: export const OPENAI_COMPATIBLE_REPLAY_HOOKS = buildProviderReplayFamilyHooks({
0165:   family: "openai-compatible",
0166: });


Question:
We added a TinyFish (agentic web-extraction API) integration so the agent can pull structured data off JS-heavy, bot-protected pages. Right now it's bolted on as an external command the agent shells out to, and it feels second-class: it doesn't show up alongside our other page-fetch backends, and the agent can't call it directly with a real argument schema.

I want to make it native. The way I read the bundled Firecrawl plugin, the web-fetch provider is the single source of truth — its createTool factory is what surfaces the callable tool to the agent. So my plan is to register one TinyFish web-fetch provider and put all the richness in that provider's createTool: it should take a target URL plus a natural-language extraction goal, with optional stealth mode and an optional proxy country, resolve the TinyFish API key from the plugin's config (falling back to the environment), and hand the agent back parsed structured JSON rather than a blob of text. That gives me the fetch backend and the rich tool in one registration.

Wire this up from the plugin entry. Does putting everything on the provider's createTool get the agent a directly-callable TinyFish tool with that schema, or am I missing a step?

Register both surfaces from the same plugin entry: the web-fetch provider gives OpenClaw a generic capability, while the named tool exposes TinyFish-specific options.

```ts
import { Type } from "@sinclair/typebox";
import { definePluginEntry, type AnyAgentTool, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { jsonResult, readStringParam, type WebFetchProviderPlugin } from "openclaw/plugin-sdk/provider-web-fetch";

type TinyFishConfig = { apiKey?: string; endpoint?: string };
const credentialPath = "plugins.entries.tinyfish.config.webFetch.apiKey";

function apiKeyFrom(config?: TinyFishConfig) {
  return config?.apiKey || process.env.TINYFISH_API_KEY;
}

async function runTinyFish(config: TinyFishConfig | undefined, args: Record<string, unknown>) {
  const apiKey = apiKeyFrom(config);
  if (!apiKey) throw new Error("TinyFish API key is not configured.");
  const response = await fetch(config?.endpoint || "https://agent.tinyfish.ai/v1/automation/run", {
    method: "POST",
    headers: { "content-type": "application/json", "x-api-key": apiKey },
    body: JSON.stringify({
      url: String(args.url || ""),
      goal: String(args.goal || ""),
      browser_profile: args.stealth === true ? "stealth" : "default",
      proxy_config: typeof args.proxyCountry === "string" ? { enabled: true, country_code: args.proxyCountry } : undefined,
    }),
  });
  const text = await response.text();
  if (!response.ok) throw new Error(`TinyFish returned ${response.status}: ${text}`);
  return JSON.parse(text) as Record<string, unknown>;
}

function createTinyFishProvider(): WebFetchProviderPlugin {
  return {
    id: "tinyfish",
    label: "TinyFish Web Agent",
    hint: "Hosted browser automation for interactive pages.",
    credentialLabel: "TinyFish API key",
    envVars: ["TINYFISH_API_KEY"],
    placeholder: "tf_...",
    signupUrl: "https://agent.tinyfish.ai/",
    credentialPath,
    getCredentialValue: (fetchConfig) => (fetchConfig as TinyFishConfig | undefined)?.apiKey,
    setCredentialValue: (target, value) => { target.apiKey = value; },
    getConfiguredCredentialValue: (config) =>
      (config?.plugins?.entries?.tinyfish?.config as { webFetch?: TinyFishConfig } | undefined)?.webFetch?.apiKey,
    setConfiguredCredentialValue: (configTarget, value) => {
      const entries = ((configTarget.plugins ??= {}).entries ??= {});
      const entry = (entries.tinyfish ??= { config: {} });
      ((entry.config as { webFetch?: Record<string, unknown> }).webFetch ??= {}).apiKey = value;
    },
    createTool: ({ fetchConfig }) => ({
      description: "Run a browser automation goal and return structured data.",
      parameters: { type: "object", required: ["url", "goal"], properties: { url: { type: "string" }, goal: { type: "string" } } },
      execute: (args) => runTinyFish(fetchConfig as TinyFishConfig | undefined, args),
    }),
  };
}

function createTinyFishTool(api: OpenClawPluginApi): AnyAgentTool {
  return {
    name: "tinyfish_web_agent",
    label: "TinyFish Web Agent",
    description: "Run a TinyFish browser automation task.",
    parameters: Type.Object({ url: Type.String(), goal: Type.String(), stealth: Type.Optional(Type.Boolean()), proxyCountry: Type.Optional(Type.String()) }),
    execute: async (_id, raw) => jsonResult(await runTinyFish((api.pluginConfig || {}) as TinyFishConfig, {
      url: readStringParam(raw, "url", { required: true }),
      goal: readStringParam(raw, "goal", { required: true }),
      stealth: raw.stealth === true,
      proxyCountry: readStringParam(raw, "proxyCountry"),
    })),
  };
}

export default definePluginEntry({ id: "tinyfish", name: "TinyFish", description: "Browser automation provider.", register(api) { api.registerWebFetchProvider(createTinyFishProvider()); api.registerTool(createTinyFishTool(api), { optional: true }); } });
```

From the single plugin entry's register(api), it ALSO registers a SEPARATE named agent tool via api.registerTool(...) IN ADDITION to the web-fetch provider — this is the missing step. Putting the rich url/goal/stealth/proxy schema only on the web-fetch provider's createTool does NOT satisfy this: that surface flows through web-fetch provider SELECTION into the single generic web_fetch tool, not the agent tool registry, so it is not a directly-callable named tool. (As in extensions/firecrawl/index.ts, which registers both the provider and a separate registerTool'd tool.)

That standalone api.registerTool'd agent tool declares, in its OWN parameter schema, required string url and required string goal plus optional stealth and optional proxy country (e.g. a Type.Object with url/goal as Type.String() and stealth/proxyCountry as Type.Optional(...)). A schema that lives only on the provider's createTool does NOT count.

It ALSO registers a web-fetch provider as a WebFetchProviderPlugin object (api.registerWebFetchProvider(...)) from the same entry, giving TinyFish a selectable page-fetch backend alongside the named tool.

Resolves the TinyFish API key from plugin config with a fallback to the TINYFISH_API_KEY environment variable (e.g. config?.apiKey || process.env.TINYFISH_API_KEY).

The standalone agent tool hands back parsed JSON structured data (e.g. JSON.parse(...) / response.json(), wrapped via jsonResult), not a raw text blob.

0101: export type WebFetchProviderPlugin = {
0102:   id: WebFetchProviderId;
0103:   label: string;
0104:   hint: string;
0105:   requiresCredential?: boolean;
0106:   credentialLabel?: string;
0107:   envVars: string[];
0108:   placeholder: string;
0109:   signupUrl: string;
0110:   docsUrl?: string;
0111:   autoDetectOrder?: number;
0112:   credentialPath: string;
0113:   inactiveSecretPaths?: string[];
0114:   getCredentialValue: (fetchConfig?: Record<string, unknown>) => unknown;
0115:   setCredentialValue: (fetchConfigTarget: Record<string, unknown>, value: unknown) => void;
0116:   getConfiguredCredentialValue?: (config?: OpenClawConfig) => unknown;
0117:   setConfiguredCredentialValue?: (configTarget: OpenClawConfig, value: unknown) => void;
0118:   applySelectionConfig?: (config: OpenClawConfig) => OpenClawConfig;
0119:   resolveRuntimeMetadata?: (
0120:     ctx: WebFetchRuntimeMetadataContext,
0121:   ) => Partial<RuntimeWebFetchMetadata> | Promise<Partial<RuntimeWebFetchMetadata>>;
0122:   createTool: (ctx: WebFetchProviderContext) => WebFetchProviderToolDefinition | null;
0123: };

0001: // Public web-fetch registration helpers for provider plugins.
0002:
0003: import type {
0004:   WebFetchCredentialResolutionSource,
0005:   WebFetchProviderPlugin,
0006:   WebFetchProviderToolDefinition,
0007: } from "../plugins/types.js";
0008: export { jsonResult, readNumberParam, readStringParam } from "../agents/tools/common.js";
0009: export {
0010:   withStrictWebToolsEndpoint,
0011:   withTrustedWebToolsEndpoint,
0012: } from "../agents/tools/web-guarded-fetch.js";
0013: export { markdownToText, truncateText } from "../agents/tools/web-fetch-utils.js";
0014: export {
0015:   DEFAULT_CACHE_TTL_MINUTES,
0016:   DEFAULT_TIMEOUT_SECONDS,
0017:   normalizeCacheKey,
0018:   readCache,
0019:   readResponseText,
0020:   resolveCacheTtlMs,
0021:   resolveTimeoutSeconds,
0022:   writeCache,
0023: } from "../agents/tools/web-shared.js";
0024: export { enablePluginInConfig } from "../plugins/enable.js";
0025: export { wrapExternalContent, wrapWebContent } from "../security/external-content.js";
0026: export type {
0027:   WebFetchCredentialResolutionSource,
0028:   WebFetchProviderPlugin,
0029:   WebFetchProviderToolDefinition,
0030: };

0007: export default definePluginEntry({
0008:   id: "firecrawl",
0009:   name: "Firecrawl Plugin",
0010:   description: "Bundled Firecrawl search and scrape plugin",
0011:   register(api) {
0012:     api.registerWebFetchProvider(createFirecrawlWebFetchProvider());
0013:     api.registerWebSearchProvider(createFirecrawlWebSearchProvider());
0014:     api.registerTool(createFirecrawlSearchTool(api) as AnyAgentTool);
0015:     api.registerTool(createFirecrawlScrapeTool(api) as AnyAgentTool);
0016:   },

0019: export type WebFetchProviderToolDefinition = {
0020:   description: string;
0021:   parameters: Record<string, unknown>;
0022:   execute: (args: Record<string, unknown>) => Promise<Record<string, unknown>>;
0023: };


Question:
I'm shipping a third-party plugin that adds Zoho Mail + Calendar as a new messaging channel, and I want a safe skeleton committed before I build the OAuth login UI. I've got the channel itself defined and registering fine. Now I want the agent to be able to actually send a Zoho mail and create a Zoho calendar event.

My plan: since these are channel features, I'm hanging the agent-facing tools off the channel itself (the channel's own agent-tools surface, the same place the login/docking tool goes), and having a single `zoho` tool take an `action` of 'mail' or 'calendar' so there's one thing to register and gate. Each tool reads the Zoho client id / refresh token / account email and refuses to run if they're missing. For now I don't want tests to hit Zoho's API, so the tool should hand back a structured "queued" record describing what it would have sent.

Can you write the plugin entry module (the entry that wires the channel plus these tools) and a small test showing the missing-credential guard and the queued-record shape? Where should those credentials be read from given how my tools are registered, and is the single-tool-with-an-action approach the right call here?

Use the channel entry helper for the channel capability, then register plugin-owned tools only in full runtime mode.

```ts
import { Type } from "@sinclair/typebox";
import { defineChannelPluginEntry, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { jsonResult, readStringParam } from "openclaw/plugin-sdk/channel-actions";
import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";

type ZohoAccount = { accountId: string };
type ZohoConfig = { clientId?: string; refreshToken?: string; accountEmail?: string };

const zohoChannel = {
  id: "zoho",
  meta: { id: "zoho", label: "Zoho", selectionLabel: "Zoho", docsPath: "/channels/zoho", blurb: "Zoho Mail and Calendar channel." },
  capabilities: { chatTypes: ["direct", "channel"] },
  config: {
    listAccountIds: () => ["default"],
    defaultAccountId: () => "default",
    resolveAccount: (_cfg, accountId) => ({ accountId: accountId || "default" }),
    isConfigured: () => true,
  },
} satisfies ChannelPlugin<ZohoAccount>;

function requireZohoConfig(api: OpenClawPluginApi): Required<ZohoConfig> {
  const config = (api.pluginConfig || {}) as ZohoConfig;
  if (!config.clientId || !config.refreshToken || !config.accountEmail) {
    throw new Error("Zoho requires clientId, refreshToken, and accountEmail in plugin config.");
  }
  return config as Required<ZohoConfig>;
}

function mailTool(api: OpenClawPluginApi): AnyAgentTool {
  return {
    name: "zoho_mail_send",
    label: "Zoho Mail Send",
    description: "Queue a Zoho Mail send after OAuth config is present.",
    parameters: Type.Object({ to: Type.String(), subject: Type.String(), text: Type.String() }),
    execute: async (_id, raw) => {
      const config = requireZohoConfig(api);
      return jsonResult({ provider: "zoho", kind: "mail", from: config.accountEmail, to: readStringParam(raw, "to", { required: true }), subject: readStringParam(raw, "subject", { required: true }), queued: true });
    },
  };
}

function calendarTool(api: OpenClawPluginApi): AnyAgentTool {
  return {
    name: "zoho_calendar_create",
    label: "Zoho Calendar Create",
    description: "Queue a Zoho Calendar event create.",
    parameters: Type.Object({ title: Type.String(), startsAt: Type.String(), endsAt: Type.String() }),
    execute: async (_id, raw) => {
      requireZohoConfig(api);
      return jsonResult({ provider: "zoho", kind: "calendar-event", title: readStringParam(raw, "title", { required: true }), startsAt: readStringParam(raw, "startsAt", { required: true }), endsAt: readStringParam(raw, "endsAt", { required: true }), queued: true });
    },
  };
}

export default defineChannelPluginEntry({ id: "zoho", name: "Zoho", description: "Zoho Mail and Calendar channel.", plugin: zohoChannel, registerFull(api) { api.registerTool(mailTool(api)); api.registerTool(calendarTool(api)); } });
```

Reads the Zoho OAuth material (clientId, refreshToken, accountEmail) from api.pluginConfig (the plugin's own config) inside the defineChannelPluginEntry registerFull(api) flow — e.g. a helper called from the tools' execute that pulls api.pluginConfig — and rejects/throws when any are missing BEFORE doing the work. It does NOT read those credentials from a channel-owned ChannelPlugin.agentTools factory's cfg argument, from cfg.channels.<id>, or from ad-hoc cfg.plugins.entries.<id>.config: if the tools are placed on the channel's agentTools surface they only receive { cfg } and cannot reach api.pluginConfig at all.

The agent-facing tools are registered as PLUGIN-owned tools via api.registerTool(...) called inside the registerFull(api) callback of defineChannelPluginEntry — NOT hung off the ChannelPlugin.agentTools field (the channel-owned agent-tools surface where the login/docking tool lives).

Wires the channel capability by passing the channel object to defineChannelPluginEntry({ id, name, description, plugin, registerFull }), where plugin is a ChannelPlugin providing id, meta, capabilities, and config — NOT a bare/custom plugin object (e.g. { id, channel: {...} }) or a generic definePlugin/defineChannel host API.

Exposes a separate mail-send tool and a separate calendar-create tool (two distinct tools) — NOT a single tool that dispatches on an action: 'mail' | 'calendar' parameter.

When credentials are present, the tool execute (and the test) hand back a structured queued record (e.g. an object carrying queued: true / a kind and the described payload) instead of calling Zoho's API.

0016: export {
0017:   buildChannelConfigSchema,
0018:   buildChannelOutboundSessionRoute,
0019:   clearAccountEntryFields,
0020:   createChatChannelPlugin,
0021:   defineChannelPluginEntry,
0022:   defineSetupPluginEntry,

0053: export type ChannelPlugin<ResolvedAccount = any, Probe = unknown, Audit = unknown> = {
0054:   id: ChannelId;
0055:   meta: ChannelMeta;
0056:   capabilities: ChannelCapabilities;
0057:   defaults?: {
0058:     queue?: {
0059:       debounceMs?: number;
0060:     };
0061:   };
0062:   reload?: { configPrefixes: string[]; noopPrefixes?: string[] };
0063:   setupWizard?: ChannelPluginSetupWizard;
0064:   config: ChannelConfigAdapter<ResolvedAccount>;
0065:   configSchema?: ChannelConfigSchema;

0025: export const qaChannelPlugin: ChannelPlugin<ResolvedQaChannelAccount> = createChatChannelPlugin({
0026:   base: {
0027:     id: CHANNEL_ID,
0028:     meta,
0029:     capabilities: {
0030:       chatTypes: ["direct", "group"],
0031:     },
0032:     reload: { configPrefixes: ["channels.qa-channel"] },
0033:     configSchema: qaChannelPluginConfigSchema,
0034:     setup: {
0035:       applyAccountConfig: ({ cfg, accountId, input }) =>
0036:         applyQaSetup({
0037:           cfg,
0038:           accountId,
0039:           input: input as Record<string, unknown>,
0040:         }),
0041:     },
0042:     config: {
0043:       listAccountIds: (cfg) => listQaChannelAccountIds(cfg as CoreConfig),
0044:       resolveAccount: (cfg, accountId) =>
0045:         resolveQaChannelAccount({ cfg: cfg as CoreConfig, accountId }),
0046:       defaultAccountId: (cfg) => resolveDefaultQaChannelAccountId(cfg as CoreConfig),
0047:       isConfigured: (account) => account.configured,
0048:       resolveAllowFrom: ({ cfg, accountId }) =>
0049:         resolveQaChannelAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom,
0050:       resolveDefaultTo: ({ cfg, accountId }) =>
0051:         resolveQaChannelAccount({ cfg: cfg as CoreConfig, accountId }).config.defaultTo,
0052:     },


Question:
I'm bringing my iPhone's Apple Health data into OpenClaw ahead of the proper native mobile bridge. My phone already runs as a paired node on my Gateway, and a little iOS shortcut on it POSTs me a signed health snapshot (steps, heart rate, sleep) every morning, with an HMAC signature header so the Gateway can trust it came from my device.

I want OpenClaw to treat this iPhone health feed as a first-class inbound source: stand it up the way the bundled device-backed messaging integrations do — the entry helper that wires up the device-facing capability, register the source so the Gateway knows about it, and hang the device's webhook receiver off the lazily-installed inbound route that those integrations use so multiple devices can share the path and it cleans itself up. Verify the HMAC on each POST and keep each device's most recent snapshot available for this running Gateway.

On top of that, expose a read-only agent tool that returns a health summary for a given device over a date range, rejecting malformed device ids or dates.

Build the plugin.

The plugin keeps the native boundary outside core: mobile nodes post signed snapshots, and agents only read normalized summaries through a tool.

```ts
import { createHmac, timingSafeEqual } from "node:crypto";
import type { IncomingMessage } from "node:http";
import { Type } from "@sinclair/typebox";
import { definePluginEntry, type AnyAgentTool, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { jsonResult, readStringParam } from "openclaw/plugin-sdk/channel-actions";

type Snapshot = { deviceId: string; capturedAt: string; steps: number; sleepMinutes: number; workouts: number };
type HealthConfig = { signingSecret?: string };
const snapshots = new Map<string, Snapshot>();
let lastDeviceId = "default";

function readBody(req: IncomingMessage): Promise<string> {
  return new Promise((resolve, reject) => {
    let body = "";
    req.setEncoding("utf8");
    req.on("data", (chunk) => { body += chunk; });
    req.on("end", () => resolve(body));
    req.on("error", reject);
  });
}

function requireDeviceId(value: unknown): string {
  const deviceId = typeof value === "string" ? value.trim() : "";
  if (!/^[A-Za-z0-9._:-]{1,80}$/.test(deviceId)) throw new Error("Invalid deviceId.");
  return deviceId;
}

function parseDate(value: string | undefined, label: string): number | undefined {
  if (!value) return undefined;
  const ms = Date.parse(value);
  if (!Number.isFinite(ms)) throw new Error(`${label} must be an ISO date or timestamp.`);
  return ms;
}

function verifySignature(secret: string, body: string, signature: unknown): boolean {
  if (typeof signature !== "string" || !signature.trim()) return false;
  const expected = createHmac("sha256", secret).update(body).digest("hex");
  const left = Buffer.from(signature.replace(/^sha256=/, ""), "hex");
  const right = Buffer.from(expected, "hex");
  return left.length === right.length && timingSafeEqual(left, right);
}

function normalizeSnapshot(raw: Record<string, unknown>): Snapshot {
  const capturedAt = typeof raw.capturedAt === "string" ? raw.capturedAt : new Date().toISOString();
  parseDate(capturedAt, "capturedAt");
  return {
    deviceId: requireDeviceId(raw.deviceId),
    capturedAt,
    steps: Math.max(0, Math.trunc(Number(raw.steps || 0))),
    sleepMinutes: Math.max(0, Math.trunc(Number(raw.sleepMinutes || 0))),
    workouts: Math.max(0, Math.trunc(Number(raw.workouts || 0))),
  };
}

function healthSummaryTool(): AnyAgentTool {
  return {
    name: "health_summary",
    label: "Health Summary",
    description: "Read the latest posted Apple Health summary for a device.",
    parameters: Type.Object({ deviceId: Type.Optional(Type.String()), startDate: Type.Optional(Type.String()), endDate: Type.Optional(Type.String()) }),
    execute: async (_id, raw) => {
      const deviceId = raw.deviceId === undefined ? lastDeviceId : requireDeviceId(raw.deviceId);
      const snapshot = snapshots.get(deviceId);
      if (!snapshot) return jsonResult({ status: "missing", deviceId });
      const captured = Date.parse(snapshot.capturedAt);
      const start = parseDate(readStringParam(raw, "startDate"), "startDate");
      const end = parseDate(readStringParam(raw, "endDate"), "endDate");
      if ((start !== undefined && captured < start) || (end !== undefined && captured > end)) {
        return jsonResult({ status: "outside_range", deviceId, capturedAt: snapshot.capturedAt });
      }
      return jsonResult({ status: "ok", ...snapshot });
    },
  };
}

export default definePluginEntry({
  id: "healthkit-bridge",
  name: "HealthKit Bridge",
  description: "Read-only Apple Health snapshot bridge.",
  register(api: OpenClawPluginApi) {
    api.registerHttpRoute({ path: "/plugins/healthkit/snapshot", auth: "plugin", handler: async (req, res) => {
      const body = await readBody(req);
      const secret = ((api.pluginConfig || {}) as HealthConfig).signingSecret;
      if (!secret || !verifySignature(secret, body, req.headers["x-openclaw-health-signature"])) { res.statusCode = 401; res.end("invalid signature"); return true; }
      const snapshot = normalizeSnapshot(JSON.parse(body) as Record<string, unknown>);
      snapshots.set(snapshot.deviceId, snapshot); lastDeviceId = snapshot.deviceId;
      res.setHeader("content-type", "application/json"); res.end(JSON.stringify({ ok: true, deviceId: snapshot.deviceId })); return true;
    } });
    api.registerTool(healthSummaryTool(), { name: "health_summary" });
  },
});
```

Registers the device webhook as a plugin-owned STATIC HTTP route via api.registerHttpRoute({ auth: 'plugin', ... }) and verifies the per-POST device HMAC inside that handler with a constant-time comparison (e.g. crypto.timingSafeEqual) -- NOT the dynamic per-target registerWebhookTargetWithPluginRoute / registerPluginHttpRoute lazy lifecycle helper, and NOT auth: 'gateway'. (If it installs an inbound route at all, that route MUST be the static plugin-owned api.registerHttpRoute with auth:'plugin'.)

Defines a NON-channel plugin via definePluginEntry -- it does NOT model the health feed as a messaging channel (no defineChannelPluginEntry / api.registerChannel / ChannelPlugin) and does not register it as a gateway chat source.

Keeps only the single most-recent snapshot PER DEVICE in in-process memory for the current Gateway process (e.g. a Map<deviceId, Snapshot> overwritten on each POST) -- NOT a per-date / multi-snapshot history keyed by date, and NOT persisted to disk / stateDir.

Registers a read-only agent tool (via api.registerTool) that returns a health summary read from the stored snapshots for a given device over the requested date range.

The tool validates the device identifier and validates the start/end date inputs, rejecting malformed device ids or dates.

1872: /** Main registration API injected into native plugin entry files. */
1873: export type OpenClawPluginApi = {
1874:   id: string;
1875:   name: string;
1876:   version?: string;
1877:   description?: string;
1878:   source: string;
1879:   rootDir?: string;
1880:   registrationMode: PluginRegistrationMode;
1881:   config: OpenClawConfig;
1882:   pluginConfig?: Record<string, unknown>;
1883:   /**
1884:    * In-process runtime helpers for trusted native plugins.
1885:    *
1886:    * This surface is broader than hooks. Prefer hooks for third-party
1887:    * automation/integration unless you need native registry integration.
1888:    */
1889:   runtime: PluginRuntime;
1890:   logger: PluginLogger;
1891:   registerTool: (
1892:     tool: AnyAgentTool | OpenClawPluginToolFactory,
1893:     opts?: OpenClawPluginToolOptions,
1894:   ) => void;
1895:   registerHook: (
1896:     events: string | string[],
1897:     handler: InternalHookHandler,
1898:     opts?: OpenClawPluginHookOptions,
1899:   ) => void;
1900:   registerHttpRoute: (params: OpenClawPluginHttpRouteParams) => void;
1901:   /** Register a native messaging channel plugin (channel capability). */
1902:   registerChannel: (registration: OpenClawPluginChannelRegistration | ChannelPlugin) => void;
1903:   /**

0174: /**
0175:  * Canonical entry helper for non-channel plugins.
0176:  *
0177:  * Use this for provider, tool, command, service, memory, and context-engine
0178:  * plugins. Channel plugins should use `defineChannelPluginEntry(...)` from
0179:  * `openclaw/plugin-sdk/core` so they inherit the channel capability wiring.
0180:  */
0181: export function definePluginEntry({

2799:   it("registers plugin http routes", () => {
2800:     useNoBundledPlugins();
2801:     const scenarios = [
2802:       {
2803:         label: "defaults exact match",
2804:         pluginId: "http-route-demo",
2805:         routeOptions:
2806:           '{ path: "/demo", auth: "gateway", handler: async (_req, res) => { res.statusCode = 200; res.end("ok"); } }',
2807:         expectedPath: "/demo",
2808:         expectedAuth: "gateway",
2809:         expectedMatch: "exact",
2810:         assert: expectRegisteredHttpRoute,
2811:       },
2812:       {
2813:         label: "keeps explicit auth and match options",
2814:         pluginId: "http-demo",
2815:         routeOptions:
2816:           '{ path: "/webhook", auth: "plugin", match: "prefix", handler: async () => false }',
2817:         expectedPath: "/webhook",
2818:         expectedAuth: "plugin",
2819:         expectedMatch: "prefix",
2820:         assert: expectRegisteredHttpRoute,
2821:       },
2822:     ] as const;
2823:
2824:     runSinglePluginRegistryScenarios(
2825:       scenarios.map((scenario) =>
2826:         Object.assign({}, scenario, {
2827:           body: `module.exports = { id: "${scenario.pluginId}", register(api) {
2828:   api.registerHttpRoute(${scenario.routeOptions});
2829: } };`,
2830:         }),
2831:       ),
2832:     );
2833:   });

2999:   it("enforces plugin http route validation and conflict rules", () => {
3000:     useNoBundledPlugins();
3001:     const scenarios = [
3002:       {
3003:         label: "missing auth is rejected",
3004:         buildPlugins: () => [
3005:           writePlugin({
3006:             id: "http-route-missing-auth",
3007:             filename: "http-route-missing-auth.cjs",
3008:             body: `module.exports = { id: "http-route-missing-auth", register(api) {
3009:   api.registerHttpRoute({ path: "/demo", handler: async () => true });
3010: } };`,
3011:           }),
3012:         ],
3013:         assert: (registry: ReturnType<typeof loadOpenClawPlugins>) => {
3014:           expect(
3015:             registry.httpRoutes.find((entry) => entry.pluginId === "http-route-missing-auth"),
3016:           ).toBeUndefined();
3017:           expect(
3018:             registry.diagnostics.some((diag) =>
3019:               diag.message.includes("http route registration missing or invalid auth"),
3020:             ),
3021:           ).toBe(true);
3022:         },
3023:       },
3024:       {
3025:         label: "same plugin can replace its own route",
3026:         buildPlugins: () => [
3027:           writePlugin({
3028:             id: "http-route-replace-self",
3029:             filename: "http-route-replace-self.cjs",
3030:             body: `module.exports = { id: "http-route-replace-self", register(api) {
3031:   api.registerHttpRoute({ path: "/demo", auth: "plugin", handler: async () => false });
3032:   api.registerHttpRoute({ path: "/demo", auth: "plugin", replaceExisting: true, handler: async () => true });
3033: } };`,
3034:           }),
3035:         ],
3036:         assert: (registry: ReturnType<typeof loadOpenClawPlugins>) => {
3037:           const routes = registry.httpRoutes.filter(
3038:             (entry) => entry.pluginId === "http-route-replace-self",
3039:           );
3040:           expect(routes).toHaveLength(1);
3041:           expect(routes[0]?.path).toBe("/demo");
3042:           expect(registry.diagnostics).toEqual([]);
3043:         },
3044:       },
3045:       {
3046:         label: "cross-plugin replaceExisting is rejected",
3047:         buildPlugins: () => [
3048:           writePlugin({
3049:             id: "http-route-owner-a",
3050:             filename: "http-route-owner-a.cjs",
3051:             body: `module.exports = { id: "http-route-owner-a", register(api) {
3052:   api.registerHttpRoute({ path: "/demo", auth: "plugin", handler: async () => false });
3053: } };`,
3054:           }),
3055:           writePlugin({
3056:             id: "http-route-owner-b",
3057:             filename: "http-route-owner-b.cjs",
3058:             body: `module.exports = { id: "http-route-owner-b", register(api) {
3059:   api.registerHttpRoute({ path: "/demo", auth: "plugin", replaceExisting: true, handler: async () => true });
3060: } };`,
3061:           }),
3062:         ],
3063:         assert: (registry: ReturnType<typeof loadOpenClawPlugins>) => {
3064:           const route = registry.httpRoutes.find((entry) => entry.path === "/demo");
3065:           expect(route?.pluginId).toBe("http-route-owner-a");
3066:           expect(
3067:             registry.diagnostics.some((diag) =>
3068:               diag.message.includes("http route replacement rejected"),
3069:             ),
3070:           ).toBe(true);
3071:         },
3072:       },
3073:       {
3074:         label: "mixed-auth overlaps are rejected",
3075:         buildPlugins: () => [
3076:           writePlugin({
3077:             id: "http-route-overlap",
3078:             filename: "http-route-overlap.cjs",
3079:             body: `module.exports = { id: "http-route-overlap", register(api) {
3080:   api.registerHttpRoute({ path: "/plugin/secure", auth: "gateway", match: "prefix", handler: async () => true });
3081:   api.registerHttpRoute({ path: "/plugin/secure/report", auth: "plugin", match: "exact", handler: async () => true });
3082: } };`,
3083:           }),
3084:         ],
3085:         assert: (registry: ReturnType<typeof loadOpenClawPlugins>) => {
3086:           const routes = registry.httpRoutes.filter(
3087:             (entry) => entry.pluginId === "http-route-overlap",
3088:           );
3089:           expect(routes).toHaveLength(1);
3090:           expect(routes[0]?.path).toBe("/plugin/secure");
3091:           expect(
3092:             registry.diagnostics.some((diag) =>
3093:               diag.message.includes("http route overlap rejected"),
3094:             ),
3095:           ).toBe(true);
3096:         },


Question:
Our team ships a community provider plugin for OpenClaw. To keep our own imports tidy we added a small sdk.ts barrel in our package that pulls the OpenClaw plugin helpers we use and re-exports them, so the rest of our package imports from ./sdk instead of reaching into openclaw/... everywhere. Right now sdk.ts just does `export * from "openclaw/plugin-sdk"` (plus a couple of named re-exports from the same module), and our package entry imports its plugin-entry helper and the channel schema helpers from that local barrel.

We're adding a tiny "smoke" surface so our package-discovery check exercises both the messaging-channel and the text-inference integration paths end to end: one throwaway channel and one throwaway inference provider, wired through the package's default entry. The provider should actually yield a provider+model definition when discovery probes it, not just sit there as an id.

Write the package entry (the default export) and show what the local sdk.ts barrel needs to re-export to support it. Keep the local barrel as the single import surface for our package code.

A package can expose a narrow local surface while consuming scoped OpenClaw SDK imports internally.

```ts
export { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
export { defineChannelPluginEntry, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
export { jsonResult } from "openclaw/plugin-sdk/channel-actions";

import { Type } from "@sinclair/typebox";
import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { jsonResult } from "openclaw/plugin-sdk/channel-actions";
import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";

const smokeChannel = {
  id: "community-smoke-channel",
  meta: { id: "community-smoke-channel", label: "Community Smoke", selectionLabel: "Community Smoke", docsPath: "/channels/community-smoke", blurb: "Smoke-test channel for package discovery." },
  capabilities: { chatTypes: ["direct"] },
  config: { listAccountIds: () => ["default"], defaultAccountId: () => "default", resolveAccount: () => ({ accountId: "default" }) },
  agentTools: [
    { name: "community_smoke", label: "Community Smoke", description: "Return a structured smoke result.", parameters: Type.Object({}), execute: async () => jsonResult({ ok: true, surface: "channel" }) },
  ],
} satisfies ChannelPlugin<{ accountId: string }>;

function registerSmokeProvider(api: OpenClawPluginApi) {
  api.registerProvider({
    id: "community-smoke-provider",
    label: "Community Smoke Provider",
    docsPath: "/providers/community-smoke",
    auth: [],
    catalog: {
      order: "simple",
      run: async () => ({ provider: { baseUrl: "http://127.0.0.1:9/v1", api: "openai-completions", models: [{ id: "smoke-model", name: "Smoke Model", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1024, maxTokens: 256 }] } }),
    },
  });
}

export default definePluginEntry({
  id: "community-sdk-smoke",
  name: "Community SDK Smoke",
  description: "Narrow SDK barrel plus smoke channel/provider registration.",
  register(api) { api.registerChannel({ plugin: smokeChannel }); registerSmokeProvider(api); },
});
```

The local sdk.ts re-exports the plugin/channel VALUE helpers it needs from explicit Plugin SDK subpaths -- definePluginEntry from `openclaw/plugin-sdk/plugin-entry`, the channel-entry helper (e.g. defineChannelPluginEntry) and ChannelPlugin from `openclaw/plugin-sdk/channel-core`, and the channel result helper (e.g. jsonResult) from `openclaw/plugin-sdk/channel-actions`. It does NOT rely on `export * from "openclaw/plugin-sdk"` (the root barrel) to supply these value helpers (the root barrel is intentionally type-only plus a few unrelated runtime values and does not export definePluginEntry or the channel value helpers); a harmless `export *` of the root barrel may also be present but the entry helpers must come from the explicit subpaths.

The package entry registers the smoke channel by calling `api.registerChannel(...)` with a ChannelPlugin value (passed directly or wrapped as `{ plugin: ... }`) -- NOT by registering a generic agent tool via `api.registerTool(...)`/`api.registerAgentTool(...)` and NOT by declaring a `channels: [...]` array on the plugin definition.

The package's default export is produced by `definePluginEntry({ ... })` (the curated SDK entry helper), used as the stable plugin entrypoint.

Inside the entry's register(api) callback, the smoke provider is registered via `api.registerProvider(...)`.

The smoke provider supplies a `catalog.run` (the ProviderPluginCatalog hook) whose async return is a ProviderCatalogResult provider/model payload (a `{ provider: ... }` or `{ providers: ... }` object carrying at least one model definition), so discovery exercises the provider path -- not just a bare `{ id, label, auth }` provider stub with no catalog/discovery hook.

0012: /** List the public package specifiers that should resolve to plugin SDK entrypoints. */
0013: export function buildPluginSdkSpecifiers() {
0014:   return pluginSdkEntrypoints.map((entry) =>
0015:     entry === "index" ? "openclaw/plugin-sdk" : `openclaw/plugin-sdk/${entry}`,
0016:   );
0017: }
0018:
0019: /** Build the package.json exports map for all plugin SDK subpaths. */
0020: export function buildPluginSdkPackageExports() {

1096:   it("keeps runtime entry subpaths importable", async () => {
1097:     const coreSdk = await importResolvedPluginSdkSubpath("openclaw/plugin-sdk/core");
1098:     const channelActionsSdk = await importResolvedPluginSdkSubpath(
1099:       "openclaw/plugin-sdk/channel-actions",
1100:     );
1101:     const globalSingletonSdk = await importResolvedPluginSdkSubpath(
1102:       "openclaw/plugin-sdk/global-singleton",
1103:     );
1104:     const textRuntimeSdk = await importResolvedPluginSdkSubpath("openclaw/plugin-sdk/text-runtime");
1105:     const pluginEntrySdk = await importResolvedPluginSdkSubpath("openclaw/plugin-sdk/plugin-entry");
1106:     const channelLifecycleSdk = await importResolvedPluginSdkSubpath(
1107:       "openclaw/plugin-sdk/channel-lifecycle",
1108:     );
1109:     const channelPairingSdk = await importResolvedPluginSdkSubpath(
1110:       "openclaw/plugin-sdk/channel-pairing",
1111:     );
1112:     const channelReplyPipelineSdk = await importResolvedPluginSdkSubpath(
1113:       "openclaw/plugin-sdk/channel-reply-pipeline",
1114:     );
1115:     const representativeModules = [];
1116:     for (const id of representativeRuntimeSmokeSubpaths) {
1117:       representativeModules.push(await importResolvedPluginSdkSubpath(`openclaw/plugin-sdk/${id}`));
1118:     }
1119:
1120:     expect(coreSdk.definePluginEntry).toBe(pluginEntrySdk.definePluginEntry);

0400:
0401:   it("keeps helper subpaths aligned", () => {
0402:     expectSourceMentions("core", [
0403:       "emptyPluginConfigSchema",
0404:       "definePluginEntry",
0405:       "defineChannelPluginEntry",
0406:       "defineSetupPluginEntry",
0407:       "createChatChannelPlugin",
0408:       "createChannelPluginBase",
0409:       "isSecretRef",
0410:       "optionalStringEnum",
0411:     ]);

1104:     const textRuntimeSdk = await importResolvedPluginSdkSubpath("openclaw/plugin-sdk/text-runtime");
1105:     const pluginEntrySdk = await importResolvedPluginSdkSubpath("openclaw/plugin-sdk/plugin-entry");
1106:     const channelLifecycleSdk = await importResolvedPluginSdkSubpath(
1107:       "openclaw/plugin-sdk/channel-lifecycle",
1108:     );
1109:     const channelPairingSdk = await importResolvedPluginSdkSubpath(
1110:       "openclaw/plugin-sdk/channel-pairing",
1111:     );
1112:     const channelReplyPipelineSdk = await importResolvedPluginSdkSubpath(
1113:       "openclaw/plugin-sdk/channel-reply-pipeline",
1114:     );
1115:     const representativeModules = [];
1116:     for (const id of representativeRuntimeSmokeSubpaths) {
1117:       representativeModules.push(await importResolvedPluginSdkSubpath(`openclaw/plugin-sdk/${id}`));
1118:     }
1119:
1120:     expect(coreSdk.definePluginEntry).toBe(pluginEntrySdk.definePluginEntry);
1121:     expect(typeof coreSdk.optionalStringEnum).toBe("function");

1900:   registerHttpRoute: (params: OpenClawPluginHttpRouteParams) => void;
1901:   /** Register a native messaging channel plugin (channel capability). */
1902:   registerChannel: (registration: OpenClawPluginChannelRegistration | ChannelPlugin) => void;
1903:   /**
1904:    * Register a gateway RPC method for this plugin.
1905:    *
1906:    * Reserved core admin namespaces (`config.*`, `exec.approvals.*`,
1907:    * `wizard.*`, `update.*`) always normalize to `operator.admin` even if a
1908:    * narrower scope is requested.
1909:    */
1910:   registerGatewayMethod: (
1911:     method: string,
1912:     handler: GatewayRequestHandler,
1913:     opts?: { scope?: OperatorScope },
1914:   ) => void;
1915:   registerCli: (
1916:     registrar: OpenClawPluginCliRegistrar,
1917:     opts?: {
1918:       /** Explicit top-level command roots owned by this registrar. */
1919:       commands?: string[];
1920:       /**
1921:        * Parse-time command descriptors for lazy root CLI registration.
1922:        *
1923:        * When descriptors cover every top-level command root, OpenClaw can keep
1924:        * the plugin registrar lazy in the normal root CLI path. Command-only
1925:        * registrations stay on the eager compatibility path.
1926:        */
1927:       descriptors?: OpenClawPluginCliCommandDescriptor[];
1928:     },
1929:   ) => void;
1930:   registerReload: (registration: OpenClawPluginReloadRegistration) => void;
1931:   registerNodeHostCommand: (command: OpenClawPluginNodeHostCommand) => void;
1932:   registerSecurityAuditCollector: (collector: OpenClawPluginSecurityAuditCollector) => void;
1933:   registerService: (service: OpenClawPluginService) => void;
1934:   /** Register a text-only CLI backend used by the local CLI runner. */
1935:   registerCliBackend: (backend: CliBackendPlugin) => void;
1936:   /** Register plugin-owned prompt/message compatibility text transforms. */
1937:   registerTextTransforms: (transforms: PluginTextTransformRegistration) => void;
1938:   /** Register a lightweight config migration that can run before plugin runtime loads. */
1939:   registerConfigMigration: (migrate: PluginConfigMigration) => void;
1940:   /** Register a lightweight config probe that can auto-enable this plugin generically. */
1941:   registerAutoEnableProbe: (probe: PluginSetupAutoEnableProbe) => void;
1942:   /** Register a native model/provider plugin (text inference capability). */
1943:   registerProvider: (provider: ProviderPlugin) => void;
1944:   /** Register a speech synthesis provider (speech capability). */

0104:   ProviderResolveExternalOAuthProfilesContext,
0105:   ProviderResolveSyntheticAuthContext,
0106:   ProviderSyntheticAuthResult,
0107: } from "./provider-external-auth.types.js";
0108: import type { createVpsAwareOAuthHandlers } from "./provider-oauth-flow.js";
0109: import type { ProviderRuntimeModel } from "./provider-runtime-model.types.js";
0110: import type {
0111:   ProviderDefaultThinkingPolicyContext,
0112:   ProviderThinkingPolicyContext,
0113: } from "./provider-thinking.types.js";
0114: import type { PluginRuntime } from "./runtime/types.js";
0115: import type {
0116:   OpenClawPluginHookOptions,
0117:   OpenClawPluginToolContext,
0118:   OpenClawPluginToolFactory,
0119:   OpenClawPluginToolOptions,

1341:   /**
1342:    * Provider-owned failover error classification.
1343:    *
1344:    * Return a failover reason when the provider recognizes a provider-specific
1345:    * raw error shape. Return undefined to fall back to generic classification.
1346:    */
1347:   classifyFailoverReason?: (ctx: ProviderFailoverErrorContext) => FailoverReason | null | undefined;
1348:   /**
1349:    * Provider-owned cache TTL eligibility.
1350:    *
1351:    * Use this when a proxy provider supports Anthropic-style prompt caching for
1352:    * only a subset of upstream models.
1353:    */
1354:   isCacheTtlEligible?: (ctx: ProviderCacheTtlEligibilityContext) => boolean | undefined;
1355:   /**
1356:    * Provider-owned missing-auth message override.
1357:    *
1358:    * Return a custom message when the provider wants a more specific recovery
1359:    * hint than OpenClaw's generic auth-store guidance.
1360:    */
1361:   buildMissingAuthMessage?: (
1362:     ctx: ProviderBuildMissingAuthMessageContext,
1363:   ) => string | null | undefined;
1364:   /**
1365:    * Provider-owned unknown-model hint override.
1366:    *
1367:    * Return a suffix when the provider wants a more specific recovery hint than
1368:    * OpenClaw's generic `Unknown model` error after catalog/runtime lookup
1369:    * fails.
1370:    */
1371:   buildUnknownModelHint?: (ctx: ProviderBuildUnknownModelHintContext) => string | null | undefined;
1372:   /**
1373:    * Provider-owned built-in model suppression.
1374:    *
1375:    * Return `{ suppress: true }` to hide a stale upstream row. Include
1376:    * `errorMessage` when OpenClaw should surface a provider-specific hint for
1377:    * direct model resolution failures.
1378:    */
1379:   suppressBuiltInModel?: (
1380:     ctx: ProviderBuiltInModelSuppressionContext,
1381:   ) => ProviderBuiltInModelSuppressionResult | null | undefined;
1382:   /**
1383:    * Provider-owned final catalog augmentation.
1384:    *

0366: export type ProviderCatalogResult =
0367:   | { provider: ModelProviderConfig }
0368:   | { providers: Record<string, ModelProviderConfig> }
0369:   | null
0370:   | undefined;
0371:
0372: export type ProviderPluginCatalog = {
0373:   order?: ProviderCatalogOrder;
0374:   run: (ctx: ProviderCatalogContext) => Promise<ProviderCatalogResult>;
0375: };


Question:
We auto-manage the background memory-consolidation sweep as a single scheduled job, and we let users toggle it on and off and change its cadence/timezone from config. After people upgraded across a few releases, we're getting reports of the consolidation sweep firing two or three times a night for the same workspace, and of stale scheduled entries left over from much older builds that still wake the agent on their own cadence.

I want to write a routine that runs on startup (and periodically) to bring the scheduler back to a known-good state from whatever it finds. Here's the approach I was going to take, and I'd like you to implement it (or correct it if I've got a detail wrong):

- List the current scheduled entries, find the ones we own for the consolidation sweep, and if there's more than one, treat the most recently created one as the live entry (it reflects the user's latest config) and clear out the older stragglers.
- Re-point that surviving entry at the configured cadence, timezone, and payload, and make sure it's switched on.
- For the older per-stage leftovers, fold them back into the unified entry so their cadence is preserved under the new model.
- When a user turns the sweep off in config, flip our managed entry to the disabled state and leave it parked (consistent with how the rest of the scheduler keeps switched-off and exhausted entries around for inspection rather than deleting them), so re-enabling later is cheap and we don't churn the store.

Please implement this so the end state is deterministic regardless of how many duplicates or leftovers exist. Match the existing code style and add focused tests.

```ts
import { DEFAULT_MEMORY_DEEP_DREAMING_RECENCY_HALF_LIFE_DAYS } from "openclaw/plugin-sdk/memory-core-host-status";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/memory-core";
import { formatErrorMessage, normalizeTrimmedString } from "./dreaming-shared.js";
import type { ShortTermPromotionDreamingConfig } from "./dreaming.js";

const MANAGED_DREAMING_CRON_NAME = "Memory Dreaming Promotion";
const MANAGED_DREAMING_CRON_TAG = "[managed-by=memory-core.short-term-promotion]";
const DREAMING_SYSTEM_EVENT_TEXT = "__openclaw_memory_core_short_term_promotion_dream__";
const LEGACY_LIGHT_SLEEP_CRON_NAME = "Memory Light Dreaming";
const LEGACY_LIGHT_SLEEP_CRON_TAG = "[managed-by=memory-core.dreaming.light]";
const LEGACY_LIGHT_SLEEP_EVENT_TEXT = "__openclaw_memory_core_light_sleep__";
const LEGACY_REM_SLEEP_CRON_NAME = "Memory REM Dreaming";
const LEGACY_REM_SLEEP_CRON_TAG = "[managed-by=memory-core.dreaming.rem]";
const LEGACY_REM_SLEEP_EVENT_TEXT = "__openclaw_memory_core_rem_sleep__";

type Logger = Pick<OpenClawPluginApi["logger"], "info" | "warn" | "error">;

type CronSchedule = { kind: "cron"; expr: string; tz?: string };
type CronPayload = { kind: "systemEvent"; text: string };
type ManagedCronJobCreate = {
  name: string;
  description: string;
  enabled: boolean;
  schedule: CronSchedule;
  sessionTarget: "main";
  wakeMode: "now";
  payload: CronPayload;
};
type ManagedCronJobPatch = {
  name?: string;
  description?: string;
  enabled?: boolean;
  schedule?: CronSchedule;
  sessionTarget?: "main";
  wakeMode?: "now";
  payload?: CronPayload;
};
type ManagedCronJobLike = {
  id: string;
  name?: string;
  description?: string;
  enabled?: boolean;
  schedule?: { kind?: string; expr?: string; tz?: string };
  sessionTarget?: string;
  wakeMode?: string;
  payload?: { kind?: string; text?: string };
  createdAtMs?: number;
};
type CronServiceLike = {
  list: (opts?: { includeDisabled?: boolean }) => Promise<ManagedCronJobLike[]>;
  add: (input: ManagedCronJobCreate) => Promise<unknown>;
  update: (id: string, patch: ManagedCronJobPatch) => Promise<unknown>;
  remove: (id: string) => Promise<{ removed?: boolean }>;
};
type ReconcileResult =
  | { status: "unavailable"; removed: number }
  | { status: "disabled"; removed: number }
  | { status: "added"; removed: number }
  | { status: "updated"; removed: number }
  | { status: "noop"; removed: number };
type LegacyPhaseMigrationMode = "enabled" | "disabled";

function resolveManagedCronDescription(config: ShortTermPromotionDreamingConfig): string {
  const recencyHalfLifeDays =
    config.recencyHalfLifeDays ?? DEFAULT_MEMORY_DEEP_DREAMING_RECENCY_HALF_LIFE_DAYS;
  return `${MANAGED_DREAMING_CRON_TAG} Promote weighted short-term recalls into MEMORY.md (limit=${config.limit}, minScore=${config.minScore.toFixed(3)}, minRecallCount=${config.minRecallCount}, minUniqueQueries=${config.minUniqueQueries}, recencyHalfLifeDays=${recencyHalfLifeDays}, maxAgeDays=${config.maxAgeDays ?? "none"}).`;
}

function buildManagedDreamingCronJob(config: ShortTermPromotionDreamingConfig): ManagedCronJobCreate {
  return {
    name: MANAGED_DREAMING_CRON_NAME,
    description: resolveManagedCronDescription(config),
    enabled: true,
    schedule: {
      kind: "cron",
      expr: config.cron,
      ...(config.timezone ? { tz: config.timezone } : {}),
    },
    sessionTarget: "main",
    wakeMode: "now",
    payload: { kind: "systemEvent", text: DREAMING_SYSTEM_EVENT_TEXT },
  };
}

function isManagedDreamingJob(job: ManagedCronJobLike): boolean {
  const description = normalizeTrimmedString(job.description);
  if (description?.includes(MANAGED_DREAMING_CRON_TAG)) {
    return true;
  }
  const name = normalizeTrimmedString(job.name);
  const payloadText = normalizeTrimmedString(job.payload?.text);
  return name === MANAGED_DREAMING_CRON_NAME && payloadText === DREAMING_SYSTEM_EVENT_TEXT;
}

function isLegacyPhaseDreamingJob(job: ManagedCronJobLike): boolean {
  const description = normalizeTrimmedString(job.description);
  if (
    description?.includes(LEGACY_LIGHT_SLEEP_CRON_TAG) ||
    description?.includes(LEGACY_REM_SLEEP_CRON_TAG)
  ) {
    return true;
  }
  const name = normalizeTrimmedString(job.name);
  const payloadText = normalizeTrimmedString(job.payload?.text);
  if (name === LEGACY_LIGHT_SLEEP_CRON_NAME && payloadText === LEGACY_LIGHT_SLEEP_EVENT_TEXT) {
    return true;
  }
  return name === LEGACY_REM_SLEEP_CRON_NAME && payloadText === LEGACY_REM_SLEEP_EVENT_TEXT;
}

function compareOptionalStrings(a: string | undefined, b: string | undefined): boolean {
  return a === b;
}

// Legacy per-phase (light + REM) jobs are RETIRED, not folded: reconciliation
// removes them outright in both enabled and disabled modes. The unified entry
// owns the schedule under the new model; older per-stage cadences are dropped.
async function migrateLegacyPhaseDreamingCronJobs(params: {
  cron: CronServiceLike;
  legacyJobs: ManagedCronJobLike[];
  logger: Logger;
  mode: LegacyPhaseMigrationMode;
}): Promise<number> {
  let migrated = 0;
  for (const job of params.legacyJobs) {
    try {
      const result = await params.cron.remove(job.id);
      if (result.removed === true) {
        migrated += 1;
      }
    } catch (err) {
      params.logger.warn(
        `memory-core: failed to migrate legacy phase dreaming cron job ${job.id}: ${formatErrorMessage(err)}`,
      );
    }
  }
  if (migrated > 0) {
    if (params.mode === "enabled") {
      params.logger.info(
        `memory-core: migrated ${migrated} legacy phase dreaming cron job(s) to the unified dreaming controller.`,
      );
    } else {
      params.logger.info(
        `memory-core: completed legacy phase dreaming cron migration while unified dreaming is disabled (${migrated} job(s) removed).`,
      );
    }
  }
  return migrated;
}

function buildManagedDreamingPatch(
  job: ManagedCronJobLike,
  desired: ManagedCronJobCreate,
): ManagedCronJobPatch | null {
  const patch: ManagedCronJobPatch = {};

  if (!compareOptionalStrings(normalizeTrimmedString(job.name), desired.name)) {
    patch.name = desired.name;
  }
  if (!compareOptionalStrings(normalizeTrimmedString(job.description), desired.description)) {
    patch.description = desired.description;
  }
  if (job.enabled !== true) {
    patch.enabled = true;
  }

  const scheduleKind = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(job.schedule?.kind));
  const scheduleExpr = normalizeTrimmedString(job.schedule?.expr);
  const scheduleTz = normalizeTrimmedString(job.schedule?.tz);
  if (
    scheduleKind !== "cron" ||
    !compareOptionalStrings(scheduleExpr, desired.schedule.expr) ||
    !compareOptionalStrings(scheduleTz, desired.schedule.tz)
  ) {
    patch.schedule = desired.schedule;
  }

  const sessionTarget = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(job.sessionTarget));
  if (sessionTarget !== "main") {
    patch.sessionTarget = "main";
  }
  const wakeMode = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(job.wakeMode));
  if (wakeMode !== "now") {
    patch.wakeMode = "now";
  }

  const payloadKind = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(job.payload?.kind));
  const payloadText = normalizeTrimmedString(job.payload?.text);
  if (payloadKind !== "systemevent" || !compareOptionalStrings(payloadText, desired.payload.text)) {
    patch.payload = desired.payload;
  }

  return Object.keys(patch).length > 0 ? patch : null;
}

// Deterministic primary selection: EARLIEST finite createdAtMs wins (missing
// createdAtMs sorts last via MAX_SAFE_INTEGER), ties broken by lexicographic id.
// The earliest-created managed job is the survivor; all others are pruned.
function sortManagedJobs(managed: ManagedCronJobLike[]): ManagedCronJobLike[] {
  return managed.toSorted((a, b) => {
    const aCreated =
      typeof a.createdAtMs === "number" && Number.isFinite(a.createdAtMs)
        ? a.createdAtMs
        : Number.MAX_SAFE_INTEGER;
    const bCreated =
      typeof b.createdAtMs === "number" && Number.isFinite(b.createdAtMs)
        ? b.createdAtMs
        : Number.MAX_SAFE_INTEGER;
    if (aCreated !== bCreated) {
      return aCreated - bCreated;
    }
    return a.id.localeCompare(b.id);
  });
}

export async function reconcileShortTermDreamingCronJob(params: {
  cron: CronServiceLike | null;
  config: ShortTermPromotionDreamingConfig;
  logger: Logger;
}): Promise<ReconcileResult> {
  const cron = params.cron;
  if (!cron) {
    return { status: "unavailable", removed: 0 };
  }

  const allJobs = await cron.list({ includeDisabled: true });
  const managed = allJobs.filter(isManagedDreamingJob);
  const legacyPhaseJobs = allJobs.filter(isLegacyPhaseDreamingJob);

  if (!params.config.enabled) {
    // Disabled: retire legacy phase jobs AND remove the managed job(s). We do
    // not leave a parked/disabled managed entry behind.
    let removed = await migrateLegacyPhaseDreamingCronJobs({
      cron,
      legacyJobs: legacyPhaseJobs,
      logger: params.logger,
      mode: "disabled",
    });
    for (const job of managed) {
      try {
        const result = await cron.remove(job.id);
        if (result.removed === true) {
          removed += 1;
        }
      } catch (err) {
        params.logger.warn(
          `memory-core: failed to remove managed dreaming cron job ${job.id}: ${formatErrorMessage(err)}`,
        );
      }
    }
    if (removed > 0) {
      params.logger.info(`memory-core: removed ${removed} managed dreaming cron job(s).`);
    }
    return { status: "disabled", removed };
  }

  const desired = buildManagedDreamingCronJob(params.config);
  if (managed.length === 0) {
    await cron.add(desired);
    const migratedLegacy = await migrateLegacyPhaseDreamingCronJobs({
      cron,
      legacyJobs: legacyPhaseJobs,
      logger: params.logger,
      mode: "enabled",
    });
    params.logger.info("memory-core: created managed dreaming cron job.");
    return { status: "added", removed: migratedLegacy };
  }

  const [primary, ...duplicates] = sortManagedJobs(managed);
  let removed = await migrateLegacyPhaseDreamingCronJobs({
    cron,
    legacyJobs: legacyPhaseJobs,
    logger: params.logger,
    mode: "enabled",
  });
  for (const duplicate of duplicates) {
    try {
      const result = await cron.remove(duplicate.id);
      if (result.removed === true) {
        removed += 1;
      }
    } catch (err) {
      params.logger.warn(
        `memory-core: failed to prune duplicate managed dreaming cron job ${duplicate.id}: ${formatErrorMessage(err)}`,
      );
    }
  }

  const patch = buildManagedDreamingPatch(primary, desired);
  if (!patch) {
    if (removed > 0) {
      params.logger.info("memory-core: pruned duplicate managed dreaming cron jobs.");
    }
    return { status: "noop", removed };
  }

  await cron.update(primary.id, patch);
  params.logger.info("memory-core: updated managed dreaming cron job.");
  return { status: "updated", removed };
}
```

When config.enabled is false (sweep turned off), the routine REMOVES the managed dreaming cron job(s) it owns (e.g. cron.remove(job.id) for each managed job) and returns the disabled status. It does NOT leave a disabled/parked managed entry behind by patching enabled:false or by re-adding a disabled job.

When more than one managed job exists, it keeps a single deterministic primary chosen as the EARLIEST job by finite createdAtMs (missing/non-finite createdAtMs sorts last), breaking ties by lexicographically smallest id, and removes all the other managed jobs. It does NOT keep the most-recently-created job as the survivor.

It REMOVES the legacy per-phase (light + REM) dreaming jobs during reconciliation — deleting them via the cron service (cron.remove) in BOTH the enabled and the disabled paths. It does NOT fold/convert their cadence into the unified managed entry or keep them as parked/disabled entries.

When enabled and no managed job exists, it creates exactly one managed job whose schedule uses the configured cron expression and timezone and whose payload is the managed system-event payload (the fixed dreaming system-event text), enabled.

When a kept managed primary differs from the desired managed job, it updates that primary to the desired enabled state and configured schedule/timezone and managed payload (patching only the drifted fields), rather than leaving it stale.

0412:   if (!params.config.enabled) {
0413:     let removed = await migrateLegacyPhaseDreamingCronJobs({
0414:       cron,
0415:       legacyJobs: legacyPhaseJobs,
0416:       logger: params.logger,
0417:       mode: "disabled",
0418:     });
0419:     for (const job of managed) {
0420:       try {
0421:         const result = await cron.remove(job.id);
0422:         if (result.removed === true) {
0423:           removed += 1;
0424:         }
0425:       } catch (err) {
0426:         params.logger.warn(
0427:           `memory-core: failed to remove managed dreaming cron job ${job.id}: ${formatErrorMessage(err)}`,
0428:         );
0429:       }
0430:     }
0431:     if (removed > 0) {
0432:       params.logger.info(`memory-core: removed ${removed} managed dreaming cron job(s).`);
0433:     }
0434:     return { status: "disabled", removed };
0435:   }

0177: function isLegacyPhaseDreamingJob(job: ManagedCronJobLike): boolean {
0178:   const description = normalizeTrimmedString(job.description);
0179:   if (
0180:     description?.includes(LEGACY_LIGHT_SLEEP_CRON_TAG) ||
0181:     description?.includes(LEGACY_REM_SLEEP_CRON_TAG)
0182:   ) {
0183:     return true;
0184:   }
0185:   const name = normalizeTrimmedString(job.name);
0186:   const payloadText = normalizeTrimmedString(job.payload?.text);
0187:   if (name === LEGACY_LIGHT_SLEEP_CRON_NAME && payloadText === LEGACY_LIGHT_SLEEP_EVENT_TEXT) {
0188:     return true;
0189:   }
0190:   return name === LEGACY_REM_SLEEP_CRON_NAME && payloadText === LEGACY_REM_SLEEP_EVENT_TEXT;
0191: }
0192:
0193: function compareOptionalStrings(a: string | undefined, b: string | undefined): boolean {
0194:   return a === b;
0195: }
0196:
0197: async function migrateLegacyPhaseDreamingCronJobs(params: {
0198:   cron: CronServiceLike;
0199:   legacyJobs: ManagedCronJobLike[];
0200:   logger: Logger;
0201:   mode: LegacyPhaseMigrationMode;
0202: }): Promise<number> {
0203:   let migrated = 0;
0204:   for (const job of params.legacyJobs) {
0205:     try {
0206:       const result = await params.cron.remove(job.id);
0207:       if (result.removed === true) {
0208:         migrated += 1;
0209:       }
0210:     } catch (err) {
0211:       params.logger.warn(
0212:         `memory-core: failed to migrate legacy phase dreaming cron job ${job.id}: ${formatErrorMessage(err)}`,
0213:       );
0214:     }
0215:   }
0216:   if (migrated > 0) {
0217:     if (params.mode === "enabled") {
0218:       params.logger.info(
0219:         `memory-core: migrated ${migrated} legacy phase dreaming cron job(s) to the unified dreaming controller.`,
0220:       );
0221:     } else {
0222:       params.logger.info(
0223:         `memory-core: completed legacy phase dreaming cron migration while unified dreaming is disabled (${migrated} job(s) removed).`,
0224:       );
0225:     }
0226:   }
0227:   return migrated;
0228: }

0146: function buildManagedDreamingCronJob(
0147:   config: ShortTermPromotionDreamingConfig,
0148: ): ManagedCronJobCreate {
0149:   return {
0150:     name: MANAGED_DREAMING_CRON_NAME,
0151:     description: resolveManagedCronDescription(config),
0152:     enabled: true,
0153:     schedule: {
0154:       kind: "cron",
0155:       expr: config.cron,
0156:       ...(config.timezone ? { tz: config.timezone } : {}),
0157:     },
0158:     sessionTarget: "main",
0159:     wakeMode: "now",
0160:     payload: {
0161:       kind: "systemEvent",
0162:       text: DREAMING_SYSTEM_EVENT_TEXT,
0163:     },
0164:   };
0165: }

0503:   it("removes managed dreaming jobs when disabled", async () => {
0504:     const managedJob: CronJobLike = {
0505:       id: "job-managed",
0506:       name: constants.MANAGED_DREAMING_CRON_NAME,
0507:       description: `${constants.MANAGED_DREAMING_CRON_TAG} test`,
0508:       enabled: true,
0509:       schedule: { kind: "cron", expr: "0 3 * * *" },
0510:       sessionTarget: "main",
0511:       wakeMode: "now",
0512:       payload: { kind: "systemEvent", text: constants.DREAMING_SYSTEM_EVENT_TEXT },
0513:       createdAtMs: 10,
0514:     };
0515:     const unmanagedJob: CronJobLike = {
0516:       id: "job-other",
0517:       name: "Daily report",
0518:       description: "other",
0519:       enabled: true,
0520:       schedule: { kind: "cron", expr: "0 7 * * *" },
0521:       sessionTarget: "main",
0522:       wakeMode: "next-heartbeat",
0523:       payload: { kind: "systemEvent", text: "report" },
0524:       createdAtMs: 11,
0525:     };
0526:     const harness = createCronHarness([managedJob, unmanagedJob]);
0527:     const logger = createLogger();
0528:
0529:     const result = await reconcileShortTermDreamingCronJob({
0530:       cron: harness.cron,
0531:       config: {
0532:         enabled: false,
0533:         cron: constants.DEFAULT_DREAMING_CRON_EXPR,
0534:         limit: constants.DEFAULT_DREAMING_LIMIT,
0535:         minScore: constants.DEFAULT_DREAMING_MIN_SCORE,
0536:         minRecallCount: constants.DEFAULT_DREAMING_MIN_RECALL_COUNT,
0537:         minUniqueQueries: constants.DEFAULT_DREAMING_MIN_UNIQUE_QUERIES,
0538:         recencyHalfLifeDays: constants.DEFAULT_DREAMING_RECENCY_HALF_LIFE_DAYS,
0539:         verboseLogging: false,
0540:       },
0541:       logger,
0542:     });
0543:
0544:     expect(result).toEqual({ status: "disabled", removed: 1 });
0545:     expect(harness.removeCalls).toEqual(["job-managed"]);
0546:     expect(harness.jobs.map((entry) => entry.id)).toEqual(["job-other"]);
0547:   });

0549:   it("migrates legacy light/rem dreaming cron jobs during reconciliation", async () => {
0550:     const deepManagedJob: CronJobLike = {
0551:       id: "job-deep",
0552:       name: constants.MANAGED_DREAMING_CRON_NAME,
0553:       description: `${constants.MANAGED_DREAMING_CRON_TAG} test`,
0554:       enabled: true,
0555:       schedule: { kind: "cron", expr: "0 3 * * *" },
0556:       sessionTarget: "main",
0557:       wakeMode: "now",
0558:       payload: { kind: "systemEvent", text: constants.DREAMING_SYSTEM_EVENT_TEXT },
0559:       createdAtMs: 10,
0560:     };
0561:     const legacyLightJob: CronJobLike = {
0562:       id: "job-light",
0563:       name: "Memory Light Dreaming",
0564:       description: "[managed-by=memory-core.dreaming.light] legacy",
0565:       enabled: true,
0566:       schedule: { kind: "cron", expr: "0 */6 * * *" },
0567:       sessionTarget: "main",
0568:       wakeMode: "next-heartbeat",
0569:       payload: { kind: "systemEvent", text: "__openclaw_memory_core_light_sleep__" },
0570:       createdAtMs: 8,
0571:     };
0572:     const legacyRemJob: CronJobLike = {
0573:       id: "job-rem",
0574:       name: "Memory REM Dreaming",
0575:       description: "[managed-by=memory-core.dreaming.rem] legacy",
0576:       enabled: true,
0577:       schedule: { kind: "cron", expr: "0 5 * * 0" },
0578:       sessionTarget: "main",
0579:       wakeMode: "next-heartbeat",
0580:       payload: { kind: "systemEvent", text: "__openclaw_memory_core_rem_sleep__" },
0581:       createdAtMs: 9,
0582:     };
0583:     const harness = createCronHarness([legacyLightJob, legacyRemJob, deepManagedJob]);
0584:     const logger = createLogger();
0585:
0586:     const result = await reconcileShortTermDreamingCronJob({
0587:       cron: harness.cron,
0588:       config: {
0589:         enabled: true,
0590:         cron: constants.DEFAULT_DREAMING_CRON_EXPR,
0591:         limit: constants.DEFAULT_DREAMING_LIMIT,
0592:         minScore: constants.DEFAULT_DREAMING_MIN_SCORE,
0593:         minRecallCount: constants.DEFAULT_DREAMING_MIN_RECALL_COUNT,
0594:         minUniqueQueries: constants.DEFAULT_DREAMING_MIN_UNIQUE_QUERIES,
0595:         recencyHalfLifeDays: constants.DEFAULT_DREAMING_RECENCY_HALF_LIFE_DAYS,
0596:         verboseLogging: false,
0597:       },
0598:       logger,
0599:     });
0600:
0601:     expect(result.status).toBe("updated");
0602:     expect(result.removed).toBe(2);
0603:     expect(harness.removeCalls).toEqual(["job-light", "job-rem"]);
0604:     expect(logger.info).toHaveBeenCalledWith(
0605:       "memory-core: migrated 2 legacy phase dreaming cron job(s) to the unified dreaming controller.",
0606:     );
0607:   });
0608:
0609:   it("migrates legacy phase jobs even when unified dreaming is disabled", async () => {
0610:     const legacyLightJob: CronJobLike = {
0611:       id: "job-light",
0612:       name: "Memory Light Dreaming",
0613:       description: "[managed-by=memory-core.dreaming.light] legacy",
0614:       enabled: true,
0615:       schedule: { kind: "cron", expr: "0 */6 * * *" },
0616:       sessionTarget: "main",
0617:       wakeMode: "next-heartbeat",
0618:       payload: { kind: "systemEvent", text: "__openclaw_memory_core_light_sleep__" },
0619:       createdAtMs: 8,
0620:     };
0621:     const harness = createCronHarness([legacyLightJob]);
0622:     const logger = createLogger();
0623:
0624:     const result = await reconcileShortTermDreamingCronJob({
0625:       cron: harness.cron,
0626:       config: {
0627:         enabled: false,
0628:         cron: constants.DEFAULT_DREAMING_CRON_EXPR,
0629:         limit: constants.DEFAULT_DREAMING_LIMIT,
0630:         minScore: constants.DEFAULT_DREAMING_MIN_SCORE,
0631:         minRecallCount: constants.DEFAULT_DREAMING_MIN_RECALL_COUNT,
0632:         minUniqueQueries: constants.DEFAULT_DREAMING_MIN_UNIQUE_QUERIES,
0633:         recencyHalfLifeDays: constants.DEFAULT_DREAMING_RECENCY_HALF_LIFE_DAYS,
0634:         verboseLogging: false,
0635:       },
0636:       logger,
0637:     });
0638:
0639:     expect(result).toEqual({ status: "disabled", removed: 1 });
0640:     expect(harness.removeCalls).toEqual(["job-light"]);
0641:     expect(logger.info).toHaveBeenCalledWith(
0642:       "memory-core: completed legacy phase dreaming cron migration while unified dreaming is disabled (1 job(s) removed).",
0643:     );
0644:   });

0398: describe("short-term dreaming cron reconciliation", () => {
0399:   it("creates a managed cron job when enabled", async () => {
0400:     const harness = createCronHarness();
0401:     const logger = createLogger();
0402:     const result = await reconcileShortTermDreamingCronJob({
0403:       cron: harness.cron,
0404:       config: {
0405:         enabled: true,
0406:         cron: "0 1 * * *",
0407:         timezone: "UTC",
0408:         limit: 8,
0409:         minScore: 0.5,
0410:         minRecallCount: 4,
0411:         minUniqueQueries: 5,
0412:         recencyHalfLifeDays: constants.DEFAULT_DREAMING_RECENCY_HALF_LIFE_DAYS,
0413:         verboseLogging: false,
0414:       },
0415:       logger,
0416:     });
0417:
0418:     expect(result.status).toBe("added");
0419:     expect(harness.addCalls).toHaveLength(1);
0420:     expect(harness.addCalls[0]).toMatchObject({
0421:       name: constants.MANAGED_DREAMING_CRON_NAME,
0422:       sessionTarget: "main",
0423:       wakeMode: "now",
0424:       payload: {
0425:         kind: "systemEvent",
0426:         text: constants.DREAMING_SYSTEM_EVENT_TEXT,
0427:       },
0428:       schedule: {
0429:         kind: "cron",
0430:         expr: "0 1 * * *",
0431:         tz: "UTC",
0432:       },
0433:     });
0434:   });

0275: function sortManagedJobs(managed: ManagedCronJobLike[]): ManagedCronJobLike[] {
0276:   return managed.toSorted((a, b) => {
0277:     const aCreated =
0278:       typeof a.createdAtMs === "number" && Number.isFinite(a.createdAtMs)
0279:         ? a.createdAtMs
0280:         : Number.MAX_SAFE_INTEGER;
0281:     const bCreated =
0282:       typeof b.createdAtMs === "number" && Number.isFinite(b.createdAtMs)
0283:         ? b.createdAtMs
0284:         : Number.MAX_SAFE_INTEGER;
0285:     if (aCreated !== bCreated) {
0286:       return aCreated - bCreated;
0287:     }
0288:     return a.id.localeCompare(b.id);
0289:   });
0290: }

0450:   const [primary, ...duplicates] = sortManagedJobs(managed);
0451:   let removed = await migrateLegacyPhaseDreamingCronJobs({
0452:     cron,
0453:     legacyJobs: legacyPhaseJobs,
0454:     logger: params.logger,
0455:     mode: "enabled",
0456:   });
0457:   for (const duplicate of duplicates) {
0458:     try {
0459:       const result = await cron.remove(duplicate.id);
0460:       if (result.removed === true) {
0461:         removed += 1;
0462:       }
0463:     } catch (err) {
0464:       params.logger.warn(
0465:         `memory-core: failed to prune duplicate managed dreaming cron job ${duplicate.id}: ${formatErrorMessage(err)}`,
0466:       );
0467:     }
0468:   }

0230: function buildManagedDreamingPatch(
0231:   job: ManagedCronJobLike,
0232:   desired: ManagedCronJobCreate,
0233: ): ManagedCronJobPatch | null {
0234:   const patch: ManagedCronJobPatch = {};
0235:
0236:   if (!compareOptionalStrings(normalizeTrimmedString(job.name), desired.name)) {
0237:     patch.name = desired.name;
0238:   }
0239:   if (!compareOptionalStrings(normalizeTrimmedString(job.description), desired.description)) {
0240:     patch.description = desired.description;
0241:   }
0242:   if (job.enabled !== true) {
0243:     patch.enabled = true;
0244:   }
0245:
0246:   const scheduleKind = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(job.schedule?.kind));
0247:   const scheduleExpr = normalizeTrimmedString(job.schedule?.expr);
0248:   const scheduleTz = normalizeTrimmedString(job.schedule?.tz);
0249:   if (
0250:     scheduleKind !== "cron" ||
0251:     !compareOptionalStrings(scheduleExpr, desired.schedule.expr) ||
0252:     !compareOptionalStrings(scheduleTz, desired.schedule.tz)
0253:   ) {
0254:     patch.schedule = desired.schedule;
0255:   }
0256:
0257:   const sessionTarget = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(job.sessionTarget));
0258:   if (sessionTarget !== "main") {
0259:     patch.sessionTarget = "main";
0260:   }
0261:   const wakeMode = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(job.wakeMode));
0262:   if (wakeMode !== "now") {
0263:     patch.wakeMode = "now";
0264:   }
0265:
0266:   const payloadKind = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(job.payload?.kind));
0267:   const payloadText = normalizeTrimmedString(job.payload?.text);
0268:   if (payloadKind !== "systemevent" || !compareOptionalStrings(payloadText, desired.payload.text)) {
0269:     patch.payload = desired.payload;
0270:   }
0271:
0272:   return Object.keys(patch).length > 0 ? patch : null;
0273: }

0436:   it("updates drifted managed jobs and prunes duplicates", async () => {
0437:     const desiredConfig = {
0438:       enabled: true,
0439:       cron: "0 3 * * *",
0440:       timezone: "America/Los_Angeles",
0441:       limit: 10,
0442:       minScore: constants.DEFAULT_DREAMING_MIN_SCORE,
0443:       minRecallCount: constants.DEFAULT_DREAMING_MIN_RECALL_COUNT,
0444:       minUniqueQueries: constants.DEFAULT_DREAMING_MIN_UNIQUE_QUERIES,
0445:       recencyHalfLifeDays: constants.DEFAULT_DREAMING_RECENCY_HALF_LIFE_DAYS,
0446:       verboseLogging: false,
0447:     } as const;
0448:     const desired = __testing.buildManagedDreamingCronJob(desiredConfig);
0449:     const stalePrimary: CronJobLike = {
0450:       id: "job-primary",
0451:       name: desired.name,
0452:       description: desired.description,
0453:       enabled: false,
0454:       schedule: { kind: "cron", expr: "0 9 * * *" },
0455:       sessionTarget: "main",
0456:       wakeMode: "next-heartbeat",
0457:       payload: {
0458:         kind: "systemEvent",
0459:         text: "stale-text",
0460:       },
0461:       createdAtMs: 1,
0462:     };
0463:     const duplicate: CronJobLike = {
0464:       ...desired,
0465:       id: "job-duplicate",
0466:       createdAtMs: 2,
0467:     };
0468:     const unmanaged: CronJobLike = {
0469:       id: "job-unmanaged",
0470:       name: "other",
0471:       description: "not managed",
0472:       enabled: true,
0473:       schedule: { kind: "cron", expr: "0 8 * * *" },
0474:       sessionTarget: "main",
0475:       wakeMode: "next-heartbeat",
0476:       payload: { kind: "systemEvent", text: "hello" },
0477:       createdAtMs: 3,
0478:     };
0479:     const harness = createCronHarness([stalePrimary, duplicate, unmanaged]);
0480:     const logger = createLogger();
0481:
0482:     const result = await reconcileShortTermDreamingCronJob({
0483:       cron: harness.cron,
0484:       config: desiredConfig,
0485:       logger,
0486:     });
0487:
0488:     expect(result.status).toBe("updated");
0489:     expect(result.removed).toBe(1);
0490:     expect(harness.removeCalls).toEqual(["job-duplicate"]);
0491:     expect(harness.updateCalls).toHaveLength(1);
0492:     expect(harness.updateCalls[0]).toMatchObject({
0493:       id: "job-primary",
0494:       patch: {
0495:         enabled: true,
0496:         wakeMode: "now",
0497:         schedule: desired.schedule,
0498:         payload: desired.payload,
0499:       },
0500:     });
0501:   });


Question:
During nightly Dreaming we keep seeing junk show up in the promoted long-term notes — lines that are obviously the assistant talking to itself mid-task, like `Assistant: Need commit PR.` and `Assistant: Now inspect.`. They get picked as promotion material even though they're just turn-by-turn process narration.

We already drop the dreaming-diary echo (the `Candidate: … confidence: … status: staged` style lines) when we rank/apply promotions, so I started by widening that same promotion-time snippet check to also throw out these terse "Assistant: Need/Now/inspect/commit" lines. It's not really working the way I want — the noise still comes back, and worse, I'm nervous I'll start eating the lines I actually care about: the ones recording a confirmed decision, a verification passing/failing, a PR number, or a commit SHA.

Where should this actually live so the self-talk gets dropped but those durable, evidence-bearing lines stay, and can you implement it? Lines that record a real outcome — a confirmation, a verification result, a `PR #1234`, or a 40-char commit hash — must always survive.

```ts
import { normalizeLowercaseStringOrEmpty } from 'openclaw/plugin-sdk/text-runtime';

const ASSISTANT_PROCESS_CHATTER_RE = /^(?:assistant:\s*)?(?:need|now|oops)\b/i;
const TERSE_PROCESS_ACTION_RE = /^(?:assistant:\s*)?(?:inspect|commit|push|merge|poll|rebase|checkout|status)[.!]?$/i;
const DURABLE_SIGNAL_RE = /\b(?:confirmed|decided|approved|rejected|corrected|accepted|verified|pass|failed|merged)\b/i;
const DURABLE_ARTIFACT_RE = /\b(?:pr\s*#\d+|commit\s+[a-f0-9]{7,40}|[a-f0-9]{40})\b/i;

function normalizeSnippetForHygiene(raw: string): string {
  return normalizeLowercaseStringOrEmpty(raw.normalize('NFKC').replace(/\s+/g, ' ').trim());
}

export function shouldRejectDreamingSessionCorpusSnippet(raw: string): boolean {
  const snippet = normalizeSnippetForHygiene(raw);
  if (snippet.length === 0) return false;
  if (DURABLE_SIGNAL_RE.test(snippet) || DURABLE_ARTIFACT_RE.test(snippet)) return false;
  return ASSISTANT_PROCESS_CHATTER_RE.test(snippet) || TERSE_PROCESS_ACTION_RE.test(snippet);
}

// --- Integration point: collectSessionIngestionBatches' per-line ingestion loop in
// dreaming-phases.ts (~824-859). These module-private helpers/consts already exist there;
// shown inline so the loop body below typechecks as a faithful drop-in.
const SESSION_INGESTION_MAX_SNIPPET_CHARS = 280;
const SESSION_INGESTION_MIN_SNIPPET_CHARS = 12;

type SessionIngestionMessage = {
  day: string;
  snippet: string;
  rendered: string;
};

function normalizeSessionCorpusSnippet(value: string): string {
  return value.replace(/\s+/g, " ").trim().slice(0, SESSION_INGESTION_MAX_SNIPPET_CHARS);
}

function buildSessionRenderedLine(params: {
  agentId: string;
  sessionPath: string;
  lineNumber: number;
  snippet: string;
}): string {
  const source = `${params.agentId}/${params.sessionPath}#L${params.lineNumber}`;
  return `[${source}] ${params.snippet}`.slice(0, SESSION_INGESTION_MAX_SNIPPET_CHARS + 64);
}

declare const lines: string[];
declare const entry: { lineMap: number[] };
declare const file: { agentId: string; sessionPath: string };
declare const day: string;
declare const batchByDay: Map<string, SessionIngestionMessage[]>;

for (let index = 0; index < lines.length; index += 1) {
  const rawSnippet = lines[index] ?? "";
  const snippet = normalizeSessionCorpusSnippet(rawSnippet);
  if (snippet.length < SESSION_INGESTION_MIN_SNIPPET_CHARS) {
    continue;
  }
  // NEW: drop assistant process scratch before this snippet is rendered/appended.
  if (shouldRejectDreamingSessionCorpusSnippet(snippet)) {
    continue;
  }
  const lineNumber = entry.lineMap[index] ?? index + 1;
  const rendered = buildSessionRenderedLine({
    agentId: file.agentId,
    sessionPath: file.sessionPath,
    lineNumber,
    snippet,
  });
  const bucket = batchByDay.get(day) ?? [];
  bucket.push({ day, snippet, rendered });
  batchByDay.set(day, bucket);
}
```

Adds the hygiene reject INSIDE the session-corpus ingestion per-line loop (collectSessionIngestionBatches, dreaming-phases.ts ~824-859) — right after the line is normalized (normalizeSessionCorpusSnippet) and the SESSION_INGESTION_MIN_SNIPPET_CHARS length check, and BEFORE the snippet is rendered (buildSessionRenderedLine) / pushed into batchByDay / appended to session-corpus, so the rejected text never reaches downstream phases. It must NOT instead live at the candidate rank/apply or recordShortTermRecalls promotion stage (which runs after the snippet is already persisted to session-corpus), and must NOT be implemented by widening the existing isContaminatedDreamingSnippet diary-echo check.

A snippet that carries a durable outcome is exempted from the reject and stays eligible: a confirmation/decision/verification result (e.g. confirmed/verified/pass/failed) OR a durable artifact reference (a PR number like `PR #1234` or a 7-40 char / 40-char commit SHA) short-circuits the filter to keep the line, even if it otherwise looks terse/process-like.

Rejects obvious assistant process scratch: 'Assistant: Need/Now/...' chatter and terse action-only lines (e.g. inspect/commit/push/merge), via the snippet-text predicate that drives the reject.

Normalizes the snippet with a single cleanup step (e.g. NFKC + collapse whitespace + trim/lowercase) BEFORE the keep/reject predicates run, so the filter matches against cleaned text rather than raw transcript formatting.

0824:     let fileCount = 0;
0825:     let lastScannedContentLine = cursor;
0826:     for (let index = cursor; index < lines.length; index += 1) {
0827:       if (fileCount >= fileCap || remaining <= 0) {
0828:         break;
0829:       }
0830:       lastScannedContentLine = index + 1;
0831:       const rawSnippet = lines[index] ?? "";
0832:       const snippet = normalizeSessionCorpusSnippet(rawSnippet);
0833:       if (snippet.length < SESSION_INGESTION_MIN_SNIPPET_CHARS) {
0834:         continue;
0835:       }
0836:       const lineNumber = entry.lineMap[index] ?? index + 1;
0837:       const messageTimestampMs = entry.messageTimestampsMs[index] ?? 0;
0838:       const day = formatMemoryDreamingDay(
0839:         messageTimestampMs > 0 ? messageTimestampMs : fingerprint.mtimeMs,
0840:         params.timezone,
0841:       );
0842:       if (!isDayWithinLookback(day, cutoffMs)) {
0843:         continue;
0844:       }
0845:       const dedupeBasis =
0846:         messageTimestampMs > 0 ? `ts:${Math.floor(messageTimestampMs)}` : `line:${lineNumber}`;
0847:       const messageHash = hashSessionMessageId(`${sessionScope}\n${dedupeBasis}\n${snippet}`);
0848:       if (seenSet.has(messageHash)) {
0849:         continue;
0850:       }
0851:       const rendered = buildSessionRenderedLine({
0852:         agentId: file.agentId,
0853:         sessionPath: file.sessionPath,
0854:         lineNumber,
0855:         snippet,
0856:       });
0857:       const bucket = batchByDay.get(day) ?? [];
0858:       bucket.push({ day, snippet, rendered });
0859:       batchByDay.set(day, bucket);

0877: export async function recordShortTermRecalls(params: {
0878:   workspaceDir?: string;
0879:   query: string;
0880:   results: MemorySearchResult[];
0881:   signalType?: "recall" | "daily";
0882:   dedupeByQueryPerDay?: boolean;
0883:   dayBucket?: string;
0884:   nowMs?: number;
0885:   timezone?: string;
0886: }): Promise<void> {
0887:   const workspaceDir = params.workspaceDir?.trim();
0888:   if (!workspaceDir) {
0889:     return;
0890:   }
0891:   const query = params.query.trim();
0892:   if (!query) {
0893:     return;
0894:   }
0895:   const relevant = params.results.filter(
0896:     (result) => result.source === "memory" && isShortTermMemoryPath(result.path),
0897:   );
0898:   if (relevant.length === 0) {
0899:     return;
0900:   }
0901:
0902:   const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
0903:   const nowIso = new Date(nowMs).toISOString();
0904:   const signalType = params.signalType ?? "recall";
0905:   const queryHash = hashQuery(query);
0906:   const todayBucket =
0907:     normalizeIsoDay(params.dayBucket ?? "") ?? formatMemoryDreamingDay(nowMs, params.timezone);
0908:   await withShortTermLock(workspaceDir, async () => {
0909:     const store = await readStore(workspaceDir, nowIso);
0910:
0911:     for (const result of relevant) {
0912:       const normalizedPath = normalizeMemoryPath(result.path);
0913:       const snippet = normalizeSnippet(result.snippet);
0914:       if (!snippet || isContaminatedDreamingSnippet(snippet)) {
0915:         continue;
0916:       }