Files
openclaw/src/sessions/session-key-utils.ts
Gustavo Madeira Santana bea53d7a3f Fix: move bootstrap session grammar into plugin-owned session-key surfaces (#58400)
Merged via squash.

Prepared head SHA: b062b18b03
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-31 19:41:01 -04:00

194 lines
5.3 KiB
TypeScript

export type ParsedAgentSessionKey = {
agentId: string;
rest: string;
};
export type SessionKeyChatType = "direct" | "group" | "channel" | "unknown";
export type ParsedThreadSessionSuffix = {
baseSessionKey: string | undefined;
threadId: string | undefined;
};
export type RawSessionConversationRef = {
channel: string;
kind: "group" | "channel";
rawId: string;
prefix: string;
};
/**
* Parse agent-scoped session keys in a canonical, case-insensitive way.
* Returned values are normalized to lowercase for stable comparisons/routing.
*/
export function parseAgentSessionKey(
sessionKey: string | undefined | null,
): ParsedAgentSessionKey | null {
const raw = (sessionKey ?? "").trim().toLowerCase();
if (!raw) {
return null;
}
const parts = raw.split(":").filter(Boolean);
if (parts.length < 3) {
return null;
}
if (parts[0] !== "agent") {
return null;
}
const agentId = parts[1]?.trim();
const rest = parts.slice(2).join(":");
if (!agentId || !rest) {
return null;
}
return { agentId, rest };
}
/**
* Best-effort chat-type extraction from session keys across canonical and legacy formats.
*/
export function deriveSessionChatType(sessionKey: string | undefined | null): SessionKeyChatType {
const raw = (sessionKey ?? "").trim().toLowerCase();
if (!raw) {
return "unknown";
}
const scoped = parseAgentSessionKey(raw)?.rest ?? raw;
const tokens = new Set(scoped.split(":").filter(Boolean));
if (tokens.has("group")) {
return "group";
}
if (tokens.has("channel")) {
return "channel";
}
if (tokens.has("direct") || tokens.has("dm")) {
return "direct";
}
// Legacy Discord keys can be shaped like:
// discord:<accountId>:guild-<guildId>:channel-<channelId>
if (/^discord:(?:[^:]+:)?guild-[^:]+:channel-[^:]+$/.test(scoped)) {
return "channel";
}
return "unknown";
}
export function isCronRunSessionKey(sessionKey: string | undefined | null): boolean {
const parsed = parseAgentSessionKey(sessionKey);
if (!parsed) {
return false;
}
return /^cron:[^:]+:run:[^:]+$/.test(parsed.rest);
}
export function isCronSessionKey(sessionKey: string | undefined | null): boolean {
const parsed = parseAgentSessionKey(sessionKey);
if (!parsed) {
return false;
}
return parsed.rest.toLowerCase().startsWith("cron:");
}
export function isSubagentSessionKey(sessionKey: string | undefined | null): boolean {
const raw = (sessionKey ?? "").trim();
if (!raw) {
return false;
}
if (raw.toLowerCase().startsWith("subagent:")) {
return true;
}
const parsed = parseAgentSessionKey(raw);
return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("subagent:"));
}
export function getSubagentDepth(sessionKey: string | undefined | null): number {
const raw = (sessionKey ?? "").trim().toLowerCase();
if (!raw) {
return 0;
}
return raw.split(":subagent:").length - 1;
}
export function isAcpSessionKey(sessionKey: string | undefined | null): boolean {
const raw = (sessionKey ?? "").trim();
if (!raw) {
return false;
}
const normalized = raw.toLowerCase();
if (normalized.startsWith("acp:")) {
return true;
}
const parsed = parseAgentSessionKey(raw);
return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("acp:"));
}
function normalizeSessionConversationChannel(value: string | undefined | null): string | undefined {
const trimmed = (value ?? "").trim().toLowerCase();
return trimmed || undefined;
}
export function parseThreadSessionSuffix(
sessionKey: string | undefined | null,
): ParsedThreadSessionSuffix {
const raw = (sessionKey ?? "").trim();
if (!raw) {
return { baseSessionKey: undefined, threadId: undefined };
}
const lowerRaw = raw.toLowerCase();
const threadMarker = ":thread:";
const threadIndex = lowerRaw.lastIndexOf(threadMarker);
const markerIndex = threadIndex;
const marker = threadMarker;
const baseSessionKey = markerIndex === -1 ? raw : raw.slice(0, markerIndex);
const threadIdRaw = markerIndex === -1 ? undefined : raw.slice(markerIndex + marker.length);
const threadId = threadIdRaw?.trim() || undefined;
return { baseSessionKey, threadId };
}
export function parseRawSessionConversationRef(
sessionKey: string | undefined | null,
): RawSessionConversationRef | null {
const raw = (sessionKey ?? "").trim();
if (!raw) {
return null;
}
const rawParts = raw.split(":").filter(Boolean);
const bodyStartIndex =
rawParts.length >= 3 && rawParts[0]?.trim().toLowerCase() === "agent" ? 2 : 0;
const parts = rawParts.slice(bodyStartIndex);
if (parts.length < 3) {
return null;
}
const channel = normalizeSessionConversationChannel(parts[0]);
const kind = parts[1]?.trim().toLowerCase();
if (!channel || (kind !== "group" && kind !== "channel")) {
return null;
}
const rawId = parts.slice(2).join(":").trim();
const prefix = rawParts
.slice(0, bodyStartIndex + 2)
.join(":")
.trim();
if (!rawId || !prefix) {
return null;
}
return { channel, kind, rawId, prefix };
}
export function resolveThreadParentSessionKey(
sessionKey: string | undefined | null,
): string | null {
const { baseSessionKey, threadId } = parseThreadSessionSuffix(sessionKey);
if (!threadId) {
return null;
}
const parent = baseSessionKey?.trim();
if (!parent) {
return null;
}
return parent;
}