refactor(gateway): split session-utils.ts into focused modules
Extract cohesive functionality from session-utils.ts (644 LOC) into focused submodules while maintaining full backwards compatibility: - session-utils.avatar.ts: Avatar resolution (data URIs, files, URLs) - session-utils.agents.ts: Agent listing and discovery - session-utils.store.ts: Store resolution and loading The main session-utils.ts now orchestrates and re-exports all functions preserving the existing public API. Core session listing functions (deriveSessionTitle, loadSessionEntry, classifySessionKey, etc.) remain in the main file. Part of Phase 2: Architecture Cleanup Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2fd698d137
commit
50186056d0
135
src/gateway/session-utils.agents.ts
Normal file
135
src/gateway/session-utils.agents.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* Agent listing and management utilities for gateway sessions.
|
||||||
|
* Handles discovery of configured agents and their metadata.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
|
import type { MoltbotConfig } from "../config/config.js";
|
||||||
|
import { resolveStateDir } from "../config/paths.js";
|
||||||
|
import type { SessionScope } from "../config/sessions.js";
|
||||||
|
import { normalizeAgentId, normalizeMainKey } from "../routing/session-key.js";
|
||||||
|
import { resolveIdentityAvatarUrl } from "./session-utils.avatar.js";
|
||||||
|
import type { GatewayAgentRow } from "./session-utils.types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a store path is templated (contains {agentId}).
|
||||||
|
*/
|
||||||
|
export function isStorePathTemplate(store?: string): boolean {
|
||||||
|
return typeof store === "string" && store.includes("{agentId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List agent IDs that have directories on disk.
|
||||||
|
*/
|
||||||
|
export function listExistingAgentIdsFromDisk(): string[] {
|
||||||
|
const root = resolveStateDir();
|
||||||
|
const agentsDir = path.join(root, "agents");
|
||||||
|
try {
|
||||||
|
const entries = fs.readdirSync(agentsDir, { withFileTypes: true });
|
||||||
|
return entries
|
||||||
|
.filter((entry) => entry.isDirectory())
|
||||||
|
.map((entry) => normalizeAgentId(entry.name))
|
||||||
|
.filter(Boolean);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all configured agent IDs, with default agent first.
|
||||||
|
*/
|
||||||
|
export function listConfiguredAgentIds(cfg: MoltbotConfig): string[] {
|
||||||
|
const agents = cfg.agents?.list ?? [];
|
||||||
|
if (agents.length > 0) {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const entry of agents) {
|
||||||
|
if (entry?.id) ids.add(normalizeAgentId(entry.id));
|
||||||
|
}
|
||||||
|
const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg));
|
||||||
|
ids.add(defaultId);
|
||||||
|
const sorted = Array.from(ids).filter(Boolean);
|
||||||
|
sorted.sort((a, b) => a.localeCompare(b));
|
||||||
|
return sorted.includes(defaultId)
|
||||||
|
? [defaultId, ...sorted.filter((id) => id !== defaultId)]
|
||||||
|
: sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg));
|
||||||
|
ids.add(defaultId);
|
||||||
|
for (const id of listExistingAgentIdsFromDisk()) ids.add(id);
|
||||||
|
const sorted = Array.from(ids).filter(Boolean);
|
||||||
|
sorted.sort((a, b) => a.localeCompare(b));
|
||||||
|
if (sorted.includes(defaultId)) {
|
||||||
|
return [defaultId, ...sorted.filter((id) => id !== defaultId)];
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of agents for gateway display, with identity metadata.
|
||||||
|
*/
|
||||||
|
export function listAgentsForGateway(cfg: MoltbotConfig): {
|
||||||
|
defaultId: string;
|
||||||
|
mainKey: string;
|
||||||
|
scope: SessionScope;
|
||||||
|
agents: GatewayAgentRow[];
|
||||||
|
} {
|
||||||
|
const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg));
|
||||||
|
const mainKey = normalizeMainKey(cfg.session?.mainKey);
|
||||||
|
const scope = cfg.session?.scope ?? "per-sender";
|
||||||
|
|
||||||
|
const configuredById = new Map<
|
||||||
|
string,
|
||||||
|
{ name?: string; identity?: GatewayAgentRow["identity"] }
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const entry of cfg.agents?.list ?? []) {
|
||||||
|
if (!entry?.id) continue;
|
||||||
|
const identity = entry.identity
|
||||||
|
? {
|
||||||
|
name: entry.identity.name?.trim() || undefined,
|
||||||
|
theme: entry.identity.theme?.trim() || undefined,
|
||||||
|
emoji: entry.identity.emoji?.trim() || undefined,
|
||||||
|
avatar: entry.identity.avatar?.trim() || undefined,
|
||||||
|
avatarUrl: resolveIdentityAvatarUrl(
|
||||||
|
cfg,
|
||||||
|
normalizeAgentId(entry.id),
|
||||||
|
entry.identity.avatar?.trim(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
configuredById.set(normalizeAgentId(entry.id), {
|
||||||
|
name: typeof entry.name === "string" && entry.name.trim() ? entry.name.trim() : undefined,
|
||||||
|
identity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const explicitIds = new Set(
|
||||||
|
(cfg.agents?.list ?? [])
|
||||||
|
.map((entry) => (entry?.id ? normalizeAgentId(entry.id) : ""))
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
const allowedIds = explicitIds.size > 0 ? new Set([...explicitIds, defaultId]) : null;
|
||||||
|
let agentIds = listConfiguredAgentIds(cfg).filter((id) =>
|
||||||
|
allowedIds ? allowedIds.has(id) : true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mainKey && !agentIds.includes(mainKey)) {
|
||||||
|
agentIds = [...agentIds, mainKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
const agents = agentIds.map((id) => {
|
||||||
|
const meta = configuredById.get(id);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: meta?.name,
|
||||||
|
identity: meta?.identity,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { defaultId, mainKey, scope, agents };
|
||||||
|
}
|
||||||
80
src/gateway/session-utils.avatar.ts
Normal file
80
src/gateway/session-utils.avatar.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Avatar resolution utilities for session identity display.
|
||||||
|
* Handles file-based avatars, data URIs, and HTTP URLs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
|
||||||
|
import type { MoltbotConfig } from "../config/config.js";
|
||||||
|
|
||||||
|
const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
const AVATAR_DATA_RE = /^data:/i;
|
||||||
|
const AVATAR_HTTP_RE = /^https?:\/\//i;
|
||||||
|
const AVATAR_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
||||||
|
const WINDOWS_ABS_RE = /^[a-zA-Z]:[\\/]/;
|
||||||
|
|
||||||
|
const AVATAR_MIME_BY_EXT: Record<string, string> = {
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".bmp": "image/bmp",
|
||||||
|
".tif": "image/tiff",
|
||||||
|
".tiff": "image/tiff",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveAvatarMime(filePath: string): string {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
return AVATAR_MIME_BY_EXT[ext] ?? "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWorkspaceRelativePath(value: string): boolean {
|
||||||
|
if (!value) return false;
|
||||||
|
if (value.startsWith("~")) return false;
|
||||||
|
if (AVATAR_SCHEME_RE.test(value) && !WINDOWS_ABS_RE.test(value)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an avatar path to a displayable URL.
|
||||||
|
* Supports: data URIs, HTTP URLs, and workspace-relative file paths.
|
||||||
|
* File paths are read and converted to base64 data URIs.
|
||||||
|
*/
|
||||||
|
export function resolveIdentityAvatarUrl(
|
||||||
|
cfg: MoltbotConfig,
|
||||||
|
agentId: string,
|
||||||
|
avatar: string | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!avatar) return undefined;
|
||||||
|
const trimmed = avatar.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
|
||||||
|
// Already a data URI or HTTP URL - return as-is
|
||||||
|
if (AVATAR_DATA_RE.test(trimmed) || AVATAR_HTTP_RE.test(trimmed)) return trimmed;
|
||||||
|
|
||||||
|
// Must be a workspace-relative path
|
||||||
|
if (!isWorkspaceRelativePath(trimmed)) return undefined;
|
||||||
|
|
||||||
|
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||||
|
const workspaceRoot = path.resolve(workspaceDir);
|
||||||
|
const resolved = path.resolve(workspaceRoot, trimmed);
|
||||||
|
|
||||||
|
// Security: ensure path doesn't escape workspace
|
||||||
|
const relative = path.relative(workspaceRoot, resolved);
|
||||||
|
if (relative.startsWith("..") || path.isAbsolute(relative)) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(resolved);
|
||||||
|
if (!stat.isFile() || stat.size > AVATAR_MAX_BYTES) return undefined;
|
||||||
|
const buffer = fs.readFileSync(resolved);
|
||||||
|
const mime = resolveAvatarMime(resolved);
|
||||||
|
return `data:${mime};base64,${buffer.toString("base64")}`;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
204
src/gateway/session-utils.store.ts
Normal file
204
src/gateway/session-utils.store.ts
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* Session store resolution and loading utilities.
|
||||||
|
* Handles canonical key resolution, store path mapping, and store merging.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
|
import type { MoltbotConfig } from "../config/config.js";
|
||||||
|
import {
|
||||||
|
canonicalizeMainSessionAlias,
|
||||||
|
loadSessionStore,
|
||||||
|
resolveMainSessionKey,
|
||||||
|
resolveStorePath,
|
||||||
|
type SessionEntry,
|
||||||
|
} from "../config/sessions.js";
|
||||||
|
import {
|
||||||
|
normalizeAgentId,
|
||||||
|
normalizeMainKey,
|
||||||
|
parseAgentSessionKey,
|
||||||
|
} from "../routing/session-key.js";
|
||||||
|
import { isStorePathTemplate, listConfiguredAgentIds } from "./session-utils.agents.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonicalize a session key for a specific agent.
|
||||||
|
* Adds the agent: prefix if not already present.
|
||||||
|
*/
|
||||||
|
export function canonicalizeSessionKeyForAgent(agentId: string, key: string): string {
|
||||||
|
if (key === "global" || key === "unknown") return key;
|
||||||
|
if (key.startsWith("agent:")) return key;
|
||||||
|
return `agent:${normalizeAgentId(agentId)}:${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default store agent ID from config.
|
||||||
|
*/
|
||||||
|
export function resolveDefaultStoreAgentId(cfg: MoltbotConfig): string {
|
||||||
|
return normalizeAgentId(resolveDefaultAgentId(cfg));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a session key to its canonical store key form.
|
||||||
|
*/
|
||||||
|
export function resolveSessionStoreKey(params: {
|
||||||
|
cfg: MoltbotConfig;
|
||||||
|
sessionKey: string;
|
||||||
|
}): string {
|
||||||
|
const raw = params.sessionKey.trim();
|
||||||
|
if (!raw) return raw;
|
||||||
|
if (raw === "global" || raw === "unknown") return raw;
|
||||||
|
|
||||||
|
const parsed = parseAgentSessionKey(raw);
|
||||||
|
if (parsed) {
|
||||||
|
const agentId = normalizeAgentId(parsed.agentId);
|
||||||
|
const canonical = canonicalizeMainSessionAlias({
|
||||||
|
cfg: params.cfg,
|
||||||
|
agentId,
|
||||||
|
sessionKey: raw,
|
||||||
|
});
|
||||||
|
if (canonical !== raw) return canonical;
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawMainKey = normalizeMainKey(params.cfg.session?.mainKey);
|
||||||
|
if (raw === "main" || raw === rawMainKey) {
|
||||||
|
return resolveMainSessionKey(params.cfg);
|
||||||
|
}
|
||||||
|
const agentId = resolveDefaultStoreAgentId(params.cfg);
|
||||||
|
return canonicalizeSessionKeyForAgent(agentId, raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the agent ID for a canonical session key.
|
||||||
|
*/
|
||||||
|
export function resolveSessionStoreAgentId(cfg: MoltbotConfig, canonicalKey: string): string {
|
||||||
|
if (canonicalKey === "global" || canonicalKey === "unknown") {
|
||||||
|
return resolveDefaultStoreAgentId(cfg);
|
||||||
|
}
|
||||||
|
const parsed = parseAgentSessionKey(canonicalKey);
|
||||||
|
if (parsed?.agentId) return normalizeAgentId(parsed.agentId);
|
||||||
|
return resolveDefaultStoreAgentId(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonicalize a spawnedBy reference for an agent.
|
||||||
|
*/
|
||||||
|
export function canonicalizeSpawnedByForAgent(
|
||||||
|
agentId: string,
|
||||||
|
spawnedBy?: string,
|
||||||
|
): string | undefined {
|
||||||
|
const raw = spawnedBy?.trim();
|
||||||
|
if (!raw) return undefined;
|
||||||
|
if (raw === "global" || raw === "unknown") return raw;
|
||||||
|
if (raw.startsWith("agent:")) return raw;
|
||||||
|
return `agent:${normalizeAgentId(agentId)}:${raw}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the target store for a gateway session operation.
|
||||||
|
*/
|
||||||
|
export function resolveGatewaySessionStoreTarget(params: { cfg: MoltbotConfig; key: string }): {
|
||||||
|
agentId: string;
|
||||||
|
storePath: string;
|
||||||
|
canonicalKey: string;
|
||||||
|
storeKeys: string[];
|
||||||
|
} {
|
||||||
|
const key = params.key.trim();
|
||||||
|
const canonicalKey = resolveSessionStoreKey({
|
||||||
|
cfg: params.cfg,
|
||||||
|
sessionKey: key,
|
||||||
|
});
|
||||||
|
const agentId = resolveSessionStoreAgentId(params.cfg, canonicalKey);
|
||||||
|
const storeConfig = params.cfg.session?.store;
|
||||||
|
const storePath = resolveStorePath(storeConfig, { agentId });
|
||||||
|
|
||||||
|
if (canonicalKey === "global" || canonicalKey === "unknown") {
|
||||||
|
const storeKeys = key && key !== canonicalKey ? [canonicalKey, key] : [key];
|
||||||
|
return { agentId, storePath, canonicalKey, storeKeys };
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeKeys = new Set<string>();
|
||||||
|
storeKeys.add(canonicalKey);
|
||||||
|
if (key && key !== canonicalKey) storeKeys.add(key);
|
||||||
|
return {
|
||||||
|
agentId,
|
||||||
|
storePath,
|
||||||
|
canonicalKey,
|
||||||
|
storeKeys: Array.from(storeKeys),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge a session entry into a combined store, preferring newer data.
|
||||||
|
*/
|
||||||
|
export function mergeSessionEntryIntoCombined(params: {
|
||||||
|
combined: Record<string, SessionEntry>;
|
||||||
|
entry: SessionEntry;
|
||||||
|
agentId: string;
|
||||||
|
canonicalKey: string;
|
||||||
|
}): void {
|
||||||
|
const { combined, entry, agentId, canonicalKey } = params;
|
||||||
|
const existing = combined[canonicalKey];
|
||||||
|
|
||||||
|
if (existing && (existing.updatedAt ?? 0) > (entry.updatedAt ?? 0)) {
|
||||||
|
combined[canonicalKey] = {
|
||||||
|
...entry,
|
||||||
|
...existing,
|
||||||
|
spawnedBy: canonicalizeSpawnedByForAgent(agentId, existing.spawnedBy ?? entry.spawnedBy),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
combined[canonicalKey] = {
|
||||||
|
...existing,
|
||||||
|
...entry,
|
||||||
|
spawnedBy: canonicalizeSpawnedByForAgent(agentId, entry.spawnedBy ?? existing?.spawnedBy),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and merge all session stores into a combined view for gateway.
|
||||||
|
*/
|
||||||
|
export function loadCombinedSessionStoreForGateway(cfg: MoltbotConfig): {
|
||||||
|
storePath: string;
|
||||||
|
store: Record<string, SessionEntry>;
|
||||||
|
} {
|
||||||
|
const storeConfig = cfg.session?.store;
|
||||||
|
|
||||||
|
// Single store mode (no template)
|
||||||
|
if (storeConfig && !isStorePathTemplate(storeConfig)) {
|
||||||
|
const storePath = resolveStorePath(storeConfig);
|
||||||
|
const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg));
|
||||||
|
const store = loadSessionStore(storePath);
|
||||||
|
const combined: Record<string, SessionEntry> = {};
|
||||||
|
for (const [key, entry] of Object.entries(store)) {
|
||||||
|
const canonicalKey = canonicalizeSessionKeyForAgent(defaultAgentId, key);
|
||||||
|
mergeSessionEntryIntoCombined({
|
||||||
|
combined,
|
||||||
|
entry,
|
||||||
|
agentId: defaultAgentId,
|
||||||
|
canonicalKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { storePath, store: combined };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-agent store mode (templated path)
|
||||||
|
const agentIds = listConfiguredAgentIds(cfg);
|
||||||
|
const combined: Record<string, SessionEntry> = {};
|
||||||
|
for (const agentId of agentIds) {
|
||||||
|
const storePath = resolveStorePath(storeConfig, { agentId });
|
||||||
|
const store = loadSessionStore(storePath);
|
||||||
|
for (const [key, entry] of Object.entries(store)) {
|
||||||
|
const canonicalKey = canonicalizeSessionKeyForAgent(agentId, key);
|
||||||
|
mergeSessionEntryIntoCombined({
|
||||||
|
combined,
|
||||||
|
entry,
|
||||||
|
agentId,
|
||||||
|
canonicalKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const storePath =
|
||||||
|
typeof storeConfig === "string" && storeConfig.trim() ? storeConfig.trim() : "(multiple)";
|
||||||
|
return { storePath, store: combined };
|
||||||
|
}
|
||||||
@ -1,26 +1,25 @@
|
|||||||
import fs from "node:fs";
|
/**
|
||||||
import path from "node:path";
|
* Session utilities for the gateway.
|
||||||
|
*
|
||||||
|
* This module orchestrates session management by delegating to focused submodules:
|
||||||
|
* - session-utils.avatar.ts: Avatar resolution (data URIs, file paths, HTTP URLs)
|
||||||
|
* - session-utils.agents.ts: Agent listing and discovery
|
||||||
|
* - session-utils.store.ts: Store resolution and loading
|
||||||
|
* - session-utils.fs.ts: File system operations (transcript reading)
|
||||||
|
* - session-utils.types.ts: Type definitions
|
||||||
|
*/
|
||||||
|
|
||||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
|
||||||
import { lookupContextTokens } from "../agents/context.js";
|
import { lookupContextTokens } from "../agents/context.js";
|
||||||
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||||
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
||||||
import { type MoltbotConfig, loadConfig } from "../config/config.js";
|
import { type MoltbotConfig, loadConfig } from "../config/config.js";
|
||||||
import { resolveStateDir } from "../config/paths.js";
|
|
||||||
import {
|
import {
|
||||||
buildGroupDisplayName,
|
buildGroupDisplayName,
|
||||||
canonicalizeMainSessionAlias,
|
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
resolveMainSessionKey,
|
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
type SessionScope,
|
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
import {
|
import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js";
|
||||||
normalizeAgentId,
|
|
||||||
normalizeMainKey,
|
|
||||||
parseAgentSessionKey,
|
|
||||||
} from "../routing/session-key.js";
|
|
||||||
import { normalizeSessionDeliveryFields } from "../utils/delivery-context.js";
|
import { normalizeSessionDeliveryFields } from "../utils/delivery-context.js";
|
||||||
import {
|
import {
|
||||||
readFirstUserMessageFromTranscript,
|
readFirstUserMessageFromTranscript,
|
||||||
@ -33,6 +32,31 @@ import type {
|
|||||||
SessionsListResult,
|
SessionsListResult,
|
||||||
} from "./session-utils.types.js";
|
} from "./session-utils.types.js";
|
||||||
|
|
||||||
|
// Re-export from submodules for backwards compatibility
|
||||||
|
export {
|
||||||
|
resolveAvatarMime,
|
||||||
|
isWorkspaceRelativePath,
|
||||||
|
resolveIdentityAvatarUrl,
|
||||||
|
} from "./session-utils.avatar.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
isStorePathTemplate,
|
||||||
|
listExistingAgentIdsFromDisk,
|
||||||
|
listConfiguredAgentIds,
|
||||||
|
listAgentsForGateway,
|
||||||
|
} from "./session-utils.agents.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
canonicalizeSessionKeyForAgent,
|
||||||
|
resolveDefaultStoreAgentId,
|
||||||
|
resolveSessionStoreKey,
|
||||||
|
resolveSessionStoreAgentId,
|
||||||
|
canonicalizeSpawnedByForAgent,
|
||||||
|
resolveGatewaySessionStoreTarget,
|
||||||
|
mergeSessionEntryIntoCombined,
|
||||||
|
loadCombinedSessionStoreForGateway,
|
||||||
|
} from "./session-utils.store.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
archiveFileOnDisk,
|
archiveFileOnDisk,
|
||||||
capArrayByJsonBytes,
|
capArrayByJsonBytes,
|
||||||
@ -42,6 +66,7 @@ export {
|
|||||||
readSessionMessages,
|
readSessionMessages,
|
||||||
resolveSessionTranscriptCandidates,
|
resolveSessionTranscriptCandidates,
|
||||||
} from "./session-utils.fs.js";
|
} from "./session-utils.fs.js";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
GatewayAgentRow,
|
GatewayAgentRow,
|
||||||
GatewaySessionRow,
|
GatewaySessionRow,
|
||||||
@ -52,63 +77,10 @@ export type {
|
|||||||
SessionsPreviewResult,
|
SessionsPreviewResult,
|
||||||
} from "./session-utils.types.js";
|
} from "./session-utils.types.js";
|
||||||
|
|
||||||
|
// Import from store module for internal use
|
||||||
|
import { resolveSessionStoreKey, resolveSessionStoreAgentId } from "./session-utils.store.js";
|
||||||
|
|
||||||
const DERIVED_TITLE_MAX_LEN = 60;
|
const DERIVED_TITLE_MAX_LEN = 60;
|
||||||
const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
|
|
||||||
|
|
||||||
const AVATAR_DATA_RE = /^data:/i;
|
|
||||||
const AVATAR_HTTP_RE = /^https?:\/\//i;
|
|
||||||
const AVATAR_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
|
||||||
const WINDOWS_ABS_RE = /^[a-zA-Z]:[\\/]/;
|
|
||||||
|
|
||||||
const AVATAR_MIME_BY_EXT: Record<string, string> = {
|
|
||||||
".png": "image/png",
|
|
||||||
".jpg": "image/jpeg",
|
|
||||||
".jpeg": "image/jpeg",
|
|
||||||
".webp": "image/webp",
|
|
||||||
".gif": "image/gif",
|
|
||||||
".svg": "image/svg+xml",
|
|
||||||
".bmp": "image/bmp",
|
|
||||||
".tif": "image/tiff",
|
|
||||||
".tiff": "image/tiff",
|
|
||||||
};
|
|
||||||
|
|
||||||
function resolveAvatarMime(filePath: string): string {
|
|
||||||
const ext = path.extname(filePath).toLowerCase();
|
|
||||||
return AVATAR_MIME_BY_EXT[ext] ?? "application/octet-stream";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWorkspaceRelativePath(value: string): boolean {
|
|
||||||
if (!value) return false;
|
|
||||||
if (value.startsWith("~")) return false;
|
|
||||||
if (AVATAR_SCHEME_RE.test(value) && !WINDOWS_ABS_RE.test(value)) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveIdentityAvatarUrl(
|
|
||||||
cfg: MoltbotConfig,
|
|
||||||
agentId: string,
|
|
||||||
avatar: string | undefined,
|
|
||||||
): string | undefined {
|
|
||||||
if (!avatar) return undefined;
|
|
||||||
const trimmed = avatar.trim();
|
|
||||||
if (!trimmed) return undefined;
|
|
||||||
if (AVATAR_DATA_RE.test(trimmed) || AVATAR_HTTP_RE.test(trimmed)) return trimmed;
|
|
||||||
if (!isWorkspaceRelativePath(trimmed)) return undefined;
|
|
||||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
|
||||||
const workspaceRoot = path.resolve(workspaceDir);
|
|
||||||
const resolved = path.resolve(workspaceRoot, trimmed);
|
|
||||||
const relative = path.relative(workspaceRoot, resolved);
|
|
||||||
if (relative.startsWith("..") || path.isAbsolute(relative)) return undefined;
|
|
||||||
try {
|
|
||||||
const stat = fs.statSync(resolved);
|
|
||||||
if (!stat.isFile() || stat.size > AVATAR_MAX_BYTES) return undefined;
|
|
||||||
const buffer = fs.readFileSync(resolved);
|
|
||||||
const mime = resolveAvatarMime(resolved);
|
|
||||||
return `data:${mime};base64,${buffer.toString("base64")}`;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSessionIdPrefix(sessionId: string, updatedAt?: number | null): string {
|
function formatSessionIdPrefix(sessionId: string, updatedAt?: number | null): string {
|
||||||
const prefix = sessionId.slice(0, 8);
|
const prefix = sessionId.slice(0, 8);
|
||||||
@ -128,6 +100,9 @@ function truncateTitle(text: string, maxLen: number): string {
|
|||||||
return cut + "…";
|
return cut + "…";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a display title for a session from its metadata or first message.
|
||||||
|
*/
|
||||||
export function deriveSessionTitle(
|
export function deriveSessionTitle(
|
||||||
entry: SessionEntry | undefined,
|
entry: SessionEntry | undefined,
|
||||||
firstUserMessage?: string | null,
|
firstUserMessage?: string | null,
|
||||||
@ -154,6 +129,9 @@ export function deriveSessionTitle(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a session entry by key, resolving store paths automatically.
|
||||||
|
*/
|
||||||
export function loadSessionEntry(sessionKey: string) {
|
export function loadSessionEntry(sessionKey: string) {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const sessionCfg = cfg.session;
|
const sessionCfg = cfg.session;
|
||||||
@ -165,6 +143,9 @@ export function loadSessionEntry(sessionKey: string) {
|
|||||||
return { cfg, storePath, store, entry, canonicalKey };
|
return { cfg, storePath, store, entry, canonicalKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a session key into its kind (global, unknown, group, direct).
|
||||||
|
*/
|
||||||
export function classifySessionKey(key: string, entry?: SessionEntry): GatewaySessionRow["kind"] {
|
export function classifySessionKey(key: string, entry?: SessionEntry): GatewaySessionRow["kind"] {
|
||||||
if (key === "global") return "global";
|
if (key === "global") return "global";
|
||||||
if (key === "unknown") return "unknown";
|
if (key === "unknown") return "unknown";
|
||||||
@ -177,6 +158,9 @@ export function classifySessionKey(key: string, entry?: SessionEntry): GatewaySe
|
|||||||
return "direct";
|
return "direct";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a group session key into its components.
|
||||||
|
*/
|
||||||
export function parseGroupKey(
|
export function parseGroupKey(
|
||||||
key: string,
|
key: string,
|
||||||
): { channel?: string; kind?: "group" | "channel"; id?: string } | null {
|
): { channel?: string; kind?: "group" | "channel"; id?: string } | null {
|
||||||
@ -193,259 +177,9 @@ export function parseGroupKey(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isStorePathTemplate(store?: string): boolean {
|
/**
|
||||||
return typeof store === "string" && store.includes("{agentId}");
|
* Get the default model configuration for sessions.
|
||||||
}
|
*/
|
||||||
|
|
||||||
function listExistingAgentIdsFromDisk(): string[] {
|
|
||||||
const root = resolveStateDir();
|
|
||||||
const agentsDir = path.join(root, "agents");
|
|
||||||
try {
|
|
||||||
const entries = fs.readdirSync(agentsDir, { withFileTypes: true });
|
|
||||||
return entries
|
|
||||||
.filter((entry) => entry.isDirectory())
|
|
||||||
.map((entry) => normalizeAgentId(entry.name))
|
|
||||||
.filter(Boolean);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function listConfiguredAgentIds(cfg: MoltbotConfig): string[] {
|
|
||||||
const agents = cfg.agents?.list ?? [];
|
|
||||||
if (agents.length > 0) {
|
|
||||||
const ids = new Set<string>();
|
|
||||||
for (const entry of agents) {
|
|
||||||
if (entry?.id) ids.add(normalizeAgentId(entry.id));
|
|
||||||
}
|
|
||||||
const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg));
|
|
||||||
ids.add(defaultId);
|
|
||||||
const sorted = Array.from(ids).filter(Boolean);
|
|
||||||
sorted.sort((a, b) => a.localeCompare(b));
|
|
||||||
return sorted.includes(defaultId)
|
|
||||||
? [defaultId, ...sorted.filter((id) => id !== defaultId)]
|
|
||||||
: sorted;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ids = new Set<string>();
|
|
||||||
const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg));
|
|
||||||
ids.add(defaultId);
|
|
||||||
for (const id of listExistingAgentIdsFromDisk()) ids.add(id);
|
|
||||||
const sorted = Array.from(ids).filter(Boolean);
|
|
||||||
sorted.sort((a, b) => a.localeCompare(b));
|
|
||||||
if (sorted.includes(defaultId)) {
|
|
||||||
return [defaultId, ...sorted.filter((id) => id !== defaultId)];
|
|
||||||
}
|
|
||||||
return sorted;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listAgentsForGateway(cfg: MoltbotConfig): {
|
|
||||||
defaultId: string;
|
|
||||||
mainKey: string;
|
|
||||||
scope: SessionScope;
|
|
||||||
agents: GatewayAgentRow[];
|
|
||||||
} {
|
|
||||||
const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg));
|
|
||||||
const mainKey = normalizeMainKey(cfg.session?.mainKey);
|
|
||||||
const scope = cfg.session?.scope ?? "per-sender";
|
|
||||||
const configuredById = new Map<
|
|
||||||
string,
|
|
||||||
{ name?: string; identity?: GatewayAgentRow["identity"] }
|
|
||||||
>();
|
|
||||||
for (const entry of cfg.agents?.list ?? []) {
|
|
||||||
if (!entry?.id) continue;
|
|
||||||
const identity = entry.identity
|
|
||||||
? {
|
|
||||||
name: entry.identity.name?.trim() || undefined,
|
|
||||||
theme: entry.identity.theme?.trim() || undefined,
|
|
||||||
emoji: entry.identity.emoji?.trim() || undefined,
|
|
||||||
avatar: entry.identity.avatar?.trim() || undefined,
|
|
||||||
avatarUrl: resolveIdentityAvatarUrl(
|
|
||||||
cfg,
|
|
||||||
normalizeAgentId(entry.id),
|
|
||||||
entry.identity.avatar?.trim(),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
configuredById.set(normalizeAgentId(entry.id), {
|
|
||||||
name: typeof entry.name === "string" && entry.name.trim() ? entry.name.trim() : undefined,
|
|
||||||
identity,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const explicitIds = new Set(
|
|
||||||
(cfg.agents?.list ?? [])
|
|
||||||
.map((entry) => (entry?.id ? normalizeAgentId(entry.id) : ""))
|
|
||||||
.filter(Boolean),
|
|
||||||
);
|
|
||||||
const allowedIds = explicitIds.size > 0 ? new Set([...explicitIds, defaultId]) : null;
|
|
||||||
let agentIds = listConfiguredAgentIds(cfg).filter((id) =>
|
|
||||||
allowedIds ? allowedIds.has(id) : true,
|
|
||||||
);
|
|
||||||
if (mainKey && !agentIds.includes(mainKey)) {
|
|
||||||
agentIds = [...agentIds, mainKey];
|
|
||||||
}
|
|
||||||
const agents = agentIds.map((id) => {
|
|
||||||
const meta = configuredById.get(id);
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: meta?.name,
|
|
||||||
identity: meta?.identity,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return { defaultId, mainKey, scope, agents };
|
|
||||||
}
|
|
||||||
|
|
||||||
function canonicalizeSessionKeyForAgent(agentId: string, key: string): string {
|
|
||||||
if (key === "global" || key === "unknown") return key;
|
|
||||||
if (key.startsWith("agent:")) return key;
|
|
||||||
return `agent:${normalizeAgentId(agentId)}:${key}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDefaultStoreAgentId(cfg: MoltbotConfig): string {
|
|
||||||
return normalizeAgentId(resolveDefaultAgentId(cfg));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveSessionStoreKey(params: { cfg: MoltbotConfig; sessionKey: string }): string {
|
|
||||||
const raw = params.sessionKey.trim();
|
|
||||||
if (!raw) return raw;
|
|
||||||
if (raw === "global" || raw === "unknown") return raw;
|
|
||||||
|
|
||||||
const parsed = parseAgentSessionKey(raw);
|
|
||||||
if (parsed) {
|
|
||||||
const agentId = normalizeAgentId(parsed.agentId);
|
|
||||||
const canonical = canonicalizeMainSessionAlias({
|
|
||||||
cfg: params.cfg,
|
|
||||||
agentId,
|
|
||||||
sessionKey: raw,
|
|
||||||
});
|
|
||||||
if (canonical !== raw) return canonical;
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawMainKey = normalizeMainKey(params.cfg.session?.mainKey);
|
|
||||||
if (raw === "main" || raw === rawMainKey) {
|
|
||||||
return resolveMainSessionKey(params.cfg);
|
|
||||||
}
|
|
||||||
const agentId = resolveDefaultStoreAgentId(params.cfg);
|
|
||||||
return canonicalizeSessionKeyForAgent(agentId, raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSessionStoreAgentId(cfg: MoltbotConfig, canonicalKey: string): string {
|
|
||||||
if (canonicalKey === "global" || canonicalKey === "unknown") {
|
|
||||||
return resolveDefaultStoreAgentId(cfg);
|
|
||||||
}
|
|
||||||
const parsed = parseAgentSessionKey(canonicalKey);
|
|
||||||
if (parsed?.agentId) return normalizeAgentId(parsed.agentId);
|
|
||||||
return resolveDefaultStoreAgentId(cfg);
|
|
||||||
}
|
|
||||||
|
|
||||||
function canonicalizeSpawnedByForAgent(agentId: string, spawnedBy?: string): string | undefined {
|
|
||||||
const raw = spawnedBy?.trim();
|
|
||||||
if (!raw) return undefined;
|
|
||||||
if (raw === "global" || raw === "unknown") return raw;
|
|
||||||
if (raw.startsWith("agent:")) return raw;
|
|
||||||
return `agent:${normalizeAgentId(agentId)}:${raw}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveGatewaySessionStoreTarget(params: { cfg: MoltbotConfig; key: string }): {
|
|
||||||
agentId: string;
|
|
||||||
storePath: string;
|
|
||||||
canonicalKey: string;
|
|
||||||
storeKeys: string[];
|
|
||||||
} {
|
|
||||||
const key = params.key.trim();
|
|
||||||
const canonicalKey = resolveSessionStoreKey({
|
|
||||||
cfg: params.cfg,
|
|
||||||
sessionKey: key,
|
|
||||||
});
|
|
||||||
const agentId = resolveSessionStoreAgentId(params.cfg, canonicalKey);
|
|
||||||
const storeConfig = params.cfg.session?.store;
|
|
||||||
const storePath = resolveStorePath(storeConfig, { agentId });
|
|
||||||
|
|
||||||
if (canonicalKey === "global" || canonicalKey === "unknown") {
|
|
||||||
const storeKeys = key && key !== canonicalKey ? [canonicalKey, key] : [key];
|
|
||||||
return { agentId, storePath, canonicalKey, storeKeys };
|
|
||||||
}
|
|
||||||
|
|
||||||
const storeKeys = new Set<string>();
|
|
||||||
storeKeys.add(canonicalKey);
|
|
||||||
if (key && key !== canonicalKey) storeKeys.add(key);
|
|
||||||
return {
|
|
||||||
agentId,
|
|
||||||
storePath,
|
|
||||||
canonicalKey,
|
|
||||||
storeKeys: Array.from(storeKeys),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge with existing entry based on latest timestamp to ensure data consistency and avoid overwriting with less complete data.
|
|
||||||
function mergeSessionEntryIntoCombined(params: {
|
|
||||||
combined: Record<string, SessionEntry>;
|
|
||||||
entry: SessionEntry;
|
|
||||||
agentId: string;
|
|
||||||
canonicalKey: string;
|
|
||||||
}) {
|
|
||||||
const { combined, entry, agentId, canonicalKey } = params;
|
|
||||||
const existing = combined[canonicalKey];
|
|
||||||
|
|
||||||
if (existing && (existing.updatedAt ?? 0) > (entry.updatedAt ?? 0)) {
|
|
||||||
combined[canonicalKey] = {
|
|
||||||
...entry,
|
|
||||||
...existing,
|
|
||||||
spawnedBy: canonicalizeSpawnedByForAgent(agentId, existing.spawnedBy ?? entry.spawnedBy),
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
combined[canonicalKey] = {
|
|
||||||
...existing,
|
|
||||||
...entry,
|
|
||||||
spawnedBy: canonicalizeSpawnedByForAgent(agentId, entry.spawnedBy ?? existing?.spawnedBy),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadCombinedSessionStoreForGateway(cfg: MoltbotConfig): {
|
|
||||||
storePath: string;
|
|
||||||
store: Record<string, SessionEntry>;
|
|
||||||
} {
|
|
||||||
const storeConfig = cfg.session?.store;
|
|
||||||
if (storeConfig && !isStorePathTemplate(storeConfig)) {
|
|
||||||
const storePath = resolveStorePath(storeConfig);
|
|
||||||
const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg));
|
|
||||||
const store = loadSessionStore(storePath);
|
|
||||||
const combined: Record<string, SessionEntry> = {};
|
|
||||||
for (const [key, entry] of Object.entries(store)) {
|
|
||||||
const canonicalKey = canonicalizeSessionKeyForAgent(defaultAgentId, key);
|
|
||||||
mergeSessionEntryIntoCombined({
|
|
||||||
combined,
|
|
||||||
entry,
|
|
||||||
agentId: defaultAgentId,
|
|
||||||
canonicalKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { storePath, store: combined };
|
|
||||||
}
|
|
||||||
|
|
||||||
const agentIds = listConfiguredAgentIds(cfg);
|
|
||||||
const combined: Record<string, SessionEntry> = {};
|
|
||||||
for (const agentId of agentIds) {
|
|
||||||
const storePath = resolveStorePath(storeConfig, { agentId });
|
|
||||||
const store = loadSessionStore(storePath);
|
|
||||||
for (const [key, entry] of Object.entries(store)) {
|
|
||||||
const canonicalKey = canonicalizeSessionKeyForAgent(agentId, key);
|
|
||||||
mergeSessionEntryIntoCombined({
|
|
||||||
combined,
|
|
||||||
entry,
|
|
||||||
agentId,
|
|
||||||
canonicalKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const storePath =
|
|
||||||
typeof storeConfig === "string" && storeConfig.trim() ? storeConfig.trim() : "(multiple)";
|
|
||||||
return { storePath, store: combined };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSessionDefaults(cfg: MoltbotConfig): GatewaySessionsDefaults {
|
export function getSessionDefaults(cfg: MoltbotConfig): GatewaySessionsDefaults {
|
||||||
const resolved = resolveConfiguredModelRef({
|
const resolved = resolveConfiguredModelRef({
|
||||||
cfg,
|
cfg,
|
||||||
@ -463,6 +197,9 @@ export function getSessionDefaults(cfg: MoltbotConfig): GatewaySessionsDefaults
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the model reference for a session, considering overrides.
|
||||||
|
*/
|
||||||
export function resolveSessionModelRef(
|
export function resolveSessionModelRef(
|
||||||
cfg: MoltbotConfig,
|
cfg: MoltbotConfig,
|
||||||
entry?: SessionEntry,
|
entry?: SessionEntry,
|
||||||
@ -482,6 +219,9 @@ export function resolveSessionModelRef(
|
|||||||
return { provider, model };
|
return { provider, model };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List sessions from a store with filtering and derived titles.
|
||||||
|
*/
|
||||||
export function listSessionsFromStore(params: {
|
export function listSessionsFromStore(params: {
|
||||||
cfg: MoltbotConfig;
|
cfg: MoltbotConfig;
|
||||||
storePath: string;
|
storePath: string;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user