mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-22 10:25:20 -06:00
274 lines
8.6 KiB
TypeScript
274 lines
8.6 KiB
TypeScript
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
|
import { normalizeBoundedOptionalString } from "@openclaw/normalization-core/string-coerce";
|
|
import type { SessionCatalogTranscriptItem } from "../../packages/gateway-protocol/src/schema/sessions-catalog.js";
|
|
|
|
export type {
|
|
SessionCatalogArchiveProviderParams,
|
|
SessionCatalogContinueProviderParams,
|
|
SessionCatalogContinueProviderResult,
|
|
SessionCatalogEntrySnapshot,
|
|
SessionCatalogEntrySummary,
|
|
SessionCatalogListProviderParams,
|
|
SessionCatalogProvider,
|
|
SessionCatalogReadProviderParams,
|
|
SessionCatalogStartTerminalProviderParams,
|
|
SessionCatalogTerminalPlan,
|
|
SessionUpstreamActivity,
|
|
SessionUpstreamJsonValue,
|
|
SessionUpstreamKind,
|
|
SessionUpstreamProbe,
|
|
} from "../plugins/session-catalog.js";
|
|
export {
|
|
createSessionCatalogAdoptionCoordinator,
|
|
isExternalUserText,
|
|
listAdoptedSessionCatalogSessions,
|
|
listSessionCatalogEntries,
|
|
normalizeUserText,
|
|
sessionCatalogAdoptedSessionKey,
|
|
sessionCatalogAdoptedSourceKey,
|
|
} from "../plugins/session-catalog.js";
|
|
export { importSessionCatalogHistory } from "../plugins/session-catalog-history-import.js";
|
|
export type {
|
|
SessionCatalog,
|
|
SessionCatalogCapabilities,
|
|
SessionCatalogDescriptor,
|
|
SessionCatalogHost,
|
|
SessionCatalogLocator,
|
|
SessionCatalogPullRequestSummary,
|
|
SessionCatalogSession,
|
|
SessionCatalogTranscriptItem,
|
|
SessionsCatalogArchiveParams,
|
|
SessionsCatalogArchiveResult,
|
|
SessionsCatalogContinueParams,
|
|
SessionsCatalogContinueResult,
|
|
SessionsCatalogListParams,
|
|
SessionsCatalogListResult,
|
|
SessionsCatalogReadParams,
|
|
SessionsCatalogReadResult,
|
|
SessionsCatalogStartTerminalParams,
|
|
SessionsCatalogStartTerminalResult,
|
|
} from "../../packages/gateway-protocol/src/schema/sessions-catalog.js";
|
|
export {
|
|
deleteSessionUpstreamLink,
|
|
upsertSessionUpstreamLink,
|
|
} from "../sessions/session-upstream-links.js";
|
|
export {
|
|
classifyClaudeCliHistoryMessage,
|
|
classifyClaudeCliHistoryLine,
|
|
type ClaudeCliHistoryLineClassification,
|
|
} from "../gateway/cli-session-history.claude-activity.js";
|
|
export {
|
|
createSessionCatalogFamily,
|
|
createSessionCatalogNodeHostBindings,
|
|
type SessionCatalogFamilyOptions,
|
|
type SessionCatalogNodeHostBindingsOptions,
|
|
} from "../plugins/session-catalog-family.js";
|
|
|
|
const DEFAULT_PAGE_LIMIT = 20;
|
|
const MAX_PAGE_LIMIT = 100;
|
|
const MAX_CURSOR_LENGTH = 128;
|
|
const MAX_TRANSCRIPT_ITEM_BYTES = 512 * 1024;
|
|
const MAX_TRANSCRIPT_PAGE_BYTES = 20 * 1024 * 1024;
|
|
|
|
type SessionCatalogParameterMessages = {
|
|
listNotObject: string;
|
|
unknownListParameter: (key: string) => string;
|
|
invalidSearchTerm: string;
|
|
readNotObject: string;
|
|
unknownReadParameter: (key: string) => string;
|
|
invalidThreadId: string;
|
|
};
|
|
|
|
type SessionCatalogListParams = {
|
|
searchTerm?: string;
|
|
limit: number;
|
|
cursor?: string;
|
|
};
|
|
|
|
type SessionCatalogReadParams = {
|
|
threadId: string;
|
|
limit: number;
|
|
cursor?: string;
|
|
};
|
|
|
|
function boundedSessionCatalogLimit(value: unknown, fallback = DEFAULT_PAGE_LIMIT): number {
|
|
if (value === undefined) {
|
|
return fallback;
|
|
}
|
|
if (!Number.isInteger(value) || Number(value) < 1 || Number(value) > MAX_PAGE_LIMIT) {
|
|
throw new Error(`limit must be an integer between 1 and ${String(MAX_PAGE_LIMIT)}`);
|
|
}
|
|
return Number(value);
|
|
}
|
|
|
|
function encodeSessionCatalogCursor(offset: number): string {
|
|
return Buffer.from(JSON.stringify({ offset }), "utf8").toString("base64url");
|
|
}
|
|
|
|
function optionalSessionCatalogCursor(value: unknown): string | undefined {
|
|
if (value === undefined) {
|
|
return undefined;
|
|
}
|
|
if (typeof value !== "string" || value.length === 0 || value.length > MAX_CURSOR_LENGTH) {
|
|
throw new Error("cursor is invalid");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function parseSessionCatalogListParams(
|
|
value: unknown,
|
|
options: {
|
|
searchMaxLength: number;
|
|
messages: SessionCatalogParameterMessages;
|
|
},
|
|
): SessionCatalogListParams {
|
|
if (value === undefined || value === null) {
|
|
return { limit: DEFAULT_PAGE_LIMIT };
|
|
}
|
|
if (!isRecord(value)) {
|
|
throw new Error(options.messages.listNotObject);
|
|
}
|
|
const unknown = Object.keys(value).find(
|
|
(key) => !["searchTerm", "limit", "cursor"].includes(key),
|
|
);
|
|
if (unknown) {
|
|
throw new Error(options.messages.unknownListParameter(unknown));
|
|
}
|
|
const searchTerm = normalizeBoundedOptionalString(value.searchTerm, options.searchMaxLength);
|
|
if (value.searchTerm !== undefined && !searchTerm) {
|
|
throw new Error(options.messages.invalidSearchTerm);
|
|
}
|
|
const cursor = optionalSessionCatalogCursor(value.cursor);
|
|
return {
|
|
limit: boundedSessionCatalogLimit(value.limit),
|
|
...(searchTerm ? { searchTerm } : {}),
|
|
...(cursor ? { cursor } : {}),
|
|
};
|
|
}
|
|
|
|
function parseSessionCatalogReadParams(
|
|
value: unknown,
|
|
options: {
|
|
threadIdMaxLength: number;
|
|
threadIdPattern: RegExp;
|
|
messages: SessionCatalogParameterMessages;
|
|
},
|
|
): SessionCatalogReadParams {
|
|
if (!isRecord(value)) {
|
|
throw new Error(options.messages.readNotObject);
|
|
}
|
|
const unknown = Object.keys(value).find((key) => !["threadId", "limit", "cursor"].includes(key));
|
|
if (unknown) {
|
|
throw new Error(options.messages.unknownReadParameter(unknown));
|
|
}
|
|
const threadId = normalizeBoundedOptionalString(value.threadId, options.threadIdMaxLength);
|
|
if (!threadId || !options.threadIdPattern.test(threadId)) {
|
|
throw new Error(options.messages.invalidThreadId);
|
|
}
|
|
const cursor = optionalSessionCatalogCursor(value.cursor);
|
|
return {
|
|
threadId,
|
|
limit: boundedSessionCatalogLimit(value.limit),
|
|
...(cursor ? { cursor } : {}),
|
|
};
|
|
}
|
|
|
|
function decodeSessionCatalogCursor(value: unknown): number {
|
|
const cursor = optionalSessionCatalogCursor(value);
|
|
if (cursor === undefined) {
|
|
return 0;
|
|
}
|
|
try {
|
|
const bytes = Buffer.from(cursor, "base64url");
|
|
if (bytes.toString("base64url") !== cursor) {
|
|
throw new Error("non-canonical base64url");
|
|
}
|
|
const parsed = JSON.parse(bytes.toString("utf8")) as unknown;
|
|
if (!isRecord(parsed) || !Number.isSafeInteger(parsed.offset) || Number(parsed.offset) < 0) {
|
|
throw new Error("invalid offset");
|
|
}
|
|
const offset = Number(parsed.offset);
|
|
if (encodeSessionCatalogCursor(offset) !== cursor) {
|
|
throw new Error("non-canonical cursor payload");
|
|
}
|
|
return offset;
|
|
} catch (error) {
|
|
throw new Error("cursor is invalid", { cause: error });
|
|
}
|
|
}
|
|
|
|
function isExactSessionCatalogCursor(value: unknown): value is string {
|
|
if (typeof value !== "string") {
|
|
return false;
|
|
}
|
|
try {
|
|
decodeSessionCatalogCursor(value);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function truncateUtf8(text: string, maxBytes: number): string {
|
|
if (Buffer.byteLength(text, "utf8") <= maxBytes) {
|
|
return text;
|
|
}
|
|
let low = 0;
|
|
let high = text.length;
|
|
while (low < high) {
|
|
const middle = Math.ceil((low + high) / 2);
|
|
if (Buffer.byteLength(text.slice(0, middle), "utf8") <= maxBytes - 3) {
|
|
low = middle;
|
|
} else {
|
|
high = middle - 1;
|
|
}
|
|
}
|
|
const end = low > 0 && /[\uD800-\uDBFF]/u.test(text.charAt(low - 1)) ? low - 1 : low;
|
|
return `${text.slice(0, end)}…`;
|
|
}
|
|
|
|
/** Page transcript items from the tail, bounding per-item and per-page byte budgets. */
|
|
function boundSessionCatalogTranscriptPage(
|
|
items: SessionCatalogTranscriptItem[],
|
|
limit: number,
|
|
offset: number,
|
|
): { items: SessionCatalogTranscriptItem[]; nextCursor?: string } {
|
|
const end = Math.max(0, items.length - offset);
|
|
const start = Math.max(0, end - limit);
|
|
const page: SessionCatalogTranscriptItem[] = [];
|
|
let pageBytes = 2;
|
|
for (let index = end - 1; index >= start; index -= 1) {
|
|
const item = items[index];
|
|
if (!item) {
|
|
continue;
|
|
}
|
|
const bounded: SessionCatalogTranscriptItem = {
|
|
...item,
|
|
text: truncateUtf8(item.text ?? "", MAX_TRANSCRIPT_ITEM_BYTES),
|
|
};
|
|
const itemBytes = Buffer.byteLength(JSON.stringify(bounded), "utf8") + 1;
|
|
if (page.length > 0 && pageBytes + itemBytes > MAX_TRANSCRIPT_PAGE_BYTES) {
|
|
break;
|
|
}
|
|
page.unshift(bounded);
|
|
pageBytes += itemBytes;
|
|
}
|
|
const consumed = offset + page.length;
|
|
return {
|
|
items: page,
|
|
...(consumed < items.length ? { nextCursor: encodeSessionCatalogCursor(consumed) } : {}),
|
|
};
|
|
}
|
|
|
|
/** Canonical bounded parameter, base64url cursor, and UTF-8 transcript paging contract. */
|
|
export const sessionCatalogPaging = {
|
|
boundedLimit: boundedSessionCatalogLimit,
|
|
encodeCursor: encodeSessionCatalogCursor,
|
|
optionalCursor: optionalSessionCatalogCursor,
|
|
parseListParams: parseSessionCatalogListParams,
|
|
parseReadParams: parseSessionCatalogReadParams,
|
|
decodeCursor: decodeSessionCatalogCursor,
|
|
isExactCursor: isExactSessionCatalogCursor,
|
|
boundTranscriptPage: boundSessionCatalogTranscriptPage,
|
|
} as const;
|