docs: document utility helpers

This commit is contained in:
Peter Steinberger
2026-06-03 13:41:49 -04:00
parent 3f80bfa42d
commit 10467a98aa
5 changed files with 61 additions and 0 deletions
+7
View File
@@ -1,5 +1,12 @@
import { normalizeOptionalAccountId } from "../routing/account-id.js";
/**
* Compatibility wrapper for account-id normalization.
*
* Runtime code imports this utility when it needs the older utils path while
* the canonical normalization logic lives in routing/account-id.
*/
/** Normalize an optional account id, returning undefined for blank/invalid input. */
export function normalizeAccountId(value?: string): string | undefined {
return normalizeOptionalAccountId(value);
}
+13
View File
@@ -31,6 +31,12 @@ import {
} from "./message-channel-constants.js";
import { normalizeMessageChannel } from "./message-channel-normalize.js";
/**
* Message channel and Gateway client classification helpers.
*
* This module keeps channel normalization, client identity checks, and markdown
* capability lookup in one place for send/render decisions.
*/
export { GATEWAY_CLIENT_NAMES, GATEWAY_CLIENT_MODES };
export type { GatewayClientName, GatewayClientMode };
export { normalizeGatewayClientName, normalizeGatewayClientMode };
@@ -40,24 +46,29 @@ type GatewayClientInfoLike = {
id?: string | null;
};
/** Return whether a Gateway client is the CLI transport. */
export function isGatewayCliClient(client?: GatewayClientInfoLike | null): boolean {
return normalizeGatewayClientMode(client?.mode) === GATEWAY_CLIENT_MODES.CLI;
}
/** Return whether a client is one of the operator UI clients. */
export function isOperatorUiClient(client?: GatewayClientInfoLike | null): boolean {
const clientId = normalizeGatewayClientName(client?.id);
return clientId === GATEWAY_CLIENT_NAMES.CONTROL_UI || clientId === GATEWAY_CLIENT_NAMES.TUI;
}
/** Return whether a client is the browser Control UI. */
export function isBrowserOperatorUiClient(client?: GatewayClientInfoLike | null): boolean {
const clientId = normalizeGatewayClientName(client?.id);
return clientId === GATEWAY_CLIENT_NAMES.CONTROL_UI;
}
/** Return whether a raw channel id resolves to OpenClaw's internal channel. */
export function isInternalMessageChannel(raw?: string | null): raw is InternalMessageChannel {
return normalizeMessageChannel(raw) === INTERNAL_MESSAGE_CHANNEL;
}
/** Return whether a Gateway client is the public webchat surface. */
export function isWebchatClient(client?: GatewayClientInfoLike | null): boolean {
const mode = normalizeGatewayClientMode(client?.mode);
if (mode === GATEWAY_CLIENT_MODES.WEBCHAT) {
@@ -66,6 +77,7 @@ export function isWebchatClient(client?: GatewayClientInfoLike | null): boolean
return normalizeGatewayClientName(client?.id) === GATEWAY_CLIENT_NAMES.WEBCHAT_UI;
}
/** Resolve whether a channel can receive markdown without plain-text downgrade. */
export function isMarkdownCapableMessageChannel(raw?: string | null): boolean {
const channel = normalizeMessageChannel(raw);
if (!channel) {
@@ -80,6 +92,7 @@ export function isMarkdownCapableMessageChannel(raw?: string | null): boolean {
if (builtInMeta) {
return builtInMeta.markdownCapable === true;
}
// Catalog metadata covers bundled channels whose runtime plugin is not loaded yet.
const catalogMeta = listBundledChannelCatalogEntries().find(
(entry) => entry.id === builtInChannel,
);
+27
View File
@@ -1,21 +1,32 @@
/**
* Shared queue overflow, debounce, and collection helpers.
*
* Queue owners use these helpers to cap pending work, summarize dropped items,
* debounce drains, and force individual collection when cross-channel ordering matters.
*/
/** Mutable summary state for a capped queue. */
export type QueueSummaryState = {
dropPolicy: "summarize" | "old" | "new";
droppedCount: number;
summaryLines: string[];
};
/** Queue overflow strategy. */
export type QueueDropPolicy = QueueSummaryState["dropPolicy"];
/** Generic capped queue state with shared overflow summary fields. */
export type QueueState<T> = QueueSummaryState & {
items: T[];
cap: number;
};
/** Clear accumulated overflow summary state after it has been emitted. */
export function clearQueueSummaryState(state: QueueSummaryState): void {
state.droppedCount = 0;
state.summaryLines = [];
}
/** Build a summary prompt preview without mutating the source queue state. */
export function previewQueueSummaryPrompt(params: {
state: QueueSummaryState;
noun: string;
@@ -32,6 +43,7 @@ export function previewQueueSummaryPrompt(params: {
});
}
/** Apply runtime queue settings while preserving previous values for omitted fields. */
export function applyQueueRuntimeSettings<TMode extends string>(params: {
target: {
mode: TMode;
@@ -58,6 +70,7 @@ export function applyQueueRuntimeSettings<TMode extends string>(params: {
params.target.dropPolicy = params.settings.dropPolicy ?? params.target.dropPolicy;
}
/** Trim queue summary text to a bounded single-line preview. */
export function elideQueueText(text: string, limit = 140): string {
if (text.length <= limit) {
return text;
@@ -65,11 +78,13 @@ export function elideQueueText(text: string, limit = 140): string {
return `${text.slice(0, Math.max(0, limit - 1)).trimEnd()}`;
}
/** Normalize whitespace and elide one dropped item for queue summaries. */
export function buildQueueSummaryLine(text: string, limit = 160): string {
const cleaned = text.replace(/\s+/g, " ").trim();
return elideQueueText(cleaned, limit);
}
/** Run optional duplicate detection before an item enters a queue. */
export function shouldSkipQueueItem<T>(params: {
item: T;
items: T[];
@@ -81,6 +96,7 @@ export function shouldSkipQueueItem<T>(params: {
return params.dedupe(params.item, params.items);
}
/** Apply overflow policy before enqueueing another item. */
export function applyQueueDropPolicy<T>(params: {
queue: QueueState<T>;
summarize: (item: T) => string;
@@ -102,6 +118,7 @@ export function applyQueueDropPolicy<T>(params: {
params.queue.droppedCount += 1;
params.queue.summaryLines.push(buildQueueSummaryLine(params.summarize(item)));
}
// Summary memory is bounded independently from the item cap to avoid prompt blowups.
const limit = Math.max(0, params.summaryLimit ?? cap);
while (params.queue.summaryLines.length > limit) {
params.queue.summaryLines.shift();
@@ -110,11 +127,13 @@ export function applyQueueDropPolicy<T>(params: {
return true;
}
/** Wait until the queue has been quiet for its debounce window. */
export function waitForQueueDebounce(queue: {
debounceMs: number;
lastEnqueuedAt: number;
}): Promise<void> {
if (process.env.OPENCLAW_TEST_FAST === "1") {
// Tests use this escape hatch so debounce logic does not slow deterministic queue specs.
return Promise.resolve();
}
const debounceMs = Math.max(0, queue.debounceMs);
@@ -134,6 +153,7 @@ export function waitForQueueDebounce(queue: {
});
}
/** Mark one queue as draining unless another drain is already active. */
export function beginQueueDrain<T extends { draining: boolean }>(
map: Map<string, T>,
key: string,
@@ -146,6 +166,7 @@ export function beginQueueDrain<T extends { draining: boolean }>(
return queue;
}
/** Run and remove the next queued item, returning false when empty. */
export async function drainNextQueueItem<T>(
items: T[],
run: (item: T) => Promise<void>,
@@ -159,6 +180,7 @@ export async function drainNextQueueItem<T>(
return true;
}
/** Drain one item when collect mode requires individual processing. */
export async function drainCollectItemIfNeeded<T>(params: {
forceIndividualCollect: boolean;
isCrossChannel: boolean;
@@ -170,12 +192,14 @@ export async function drainCollectItemIfNeeded<T>(params: {
return "skipped";
}
if (params.isCrossChannel) {
// Once cross-channel items appear, future collection stays individual to preserve ordering.
params.setForceIndividualCollect?.(true);
}
const drained = await drainNextQueueItem(params.items, params.run);
return drained ? "drained" : "empty";
}
/** Drain one collect step using mutable queue collection state. */
export async function drainCollectQueueStep<T>(params: {
collectState: { forceIndividualCollect: boolean };
isCrossChannel: boolean;
@@ -193,6 +217,7 @@ export async function drainCollectQueueStep<T>(params: {
});
}
/** Build and consume the queue overflow summary prompt. */
export function buildQueueSummaryPrompt(params: {
state: QueueSummaryState;
noun: string;
@@ -216,6 +241,7 @@ export function buildQueueSummaryPrompt(params: {
return lines.join("\n");
}
/** Render a collect prompt from queued items and optional overflow summary. */
export function buildCollectPrompt<T>(params: {
title: string;
items: T[];
@@ -232,6 +258,7 @@ export function buildCollectPrompt<T>(params: {
return blocks.join("\n\n");
}
/** Return true when queued items span keys or explicitly mark cross-channel state. */
export function hasCrossChannelItems<T>(
items: T[],
resolveKey: (item: T) => { key?: string; cross?: boolean },
+8
View File
@@ -1,3 +1,10 @@
/**
* Defensive JSON stringify helper for diagnostics.
*
* The replacer handles values common in runtime logs that JSON.stringify would
* otherwise reject or erase, and returns null for circular structures.
*/
/** Safely stringify diagnostic values, preserving bigint/errors/functions in readable form. */
export function safeJsonStringify(value: unknown): string | null {
try {
return JSON.stringify(value, (_key, val) => {
@@ -11,6 +18,7 @@ export function safeJsonStringify(value: unknown): string | null {
return { name: val.name, message: val.message, stack: val.stack };
}
if (val instanceof Uint8Array) {
// Binary payloads are base64 encoded so diagnostic JSON remains valid UTF-8 text.
return { type: "Uint8Array", data: Buffer.from(val).toString("base64") };
}
return val;
+6
View File
@@ -1 +1,7 @@
/**
* Compatibility export for timeout-wrapped operations.
*
* The implementation lives in infra/fs-safe; this keeps older utils imports on
* the same public helper without duplicating timeout behavior.
*/
export { withTimeout } from "../infra/fs-safe.js";