mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:10:44 +00:00
fix: include primary dreaming workspace
This commit is contained in:
@@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord/gateway: reconnect when the gateway socket closes while waiting for the shared IDENTIFY concurrency window, instead of silently skipping IDENTIFY and leaving the bot online but unresponsive. Fixes #74617. Thanks @zeeskdr-ai.
|
||||
- Voice Call: add `sessionScope: "per-call"` for fresh per-call agent memory while preserving the default per-phone caller history. Fixes #45280. Thanks @pondcountry.
|
||||
- Music generation: raise too-small tool timeouts to the provider-safe 10-second floor and collapse cascading abort fallback errors into a clearer root-cause summary. Thanks @shakkernerd.
|
||||
- Memory-core/dreaming: include the primary runtime workspace in multi-agent dreaming sweeps without mixing main-agent session transcripts into configured subagent workspaces. Fixes #70014. Thanks @ttomiczek.
|
||||
- Telegram/startup: use the existing `getMe` request guard for the gateway bot probe instead of a fixed 2.5-second budget, and honor higher `timeoutSeconds` configs for slow Telegram API paths. Fixes #75783. Thanks @tankotan.
|
||||
- Telegram/models: make model picker confirmations say selections are session-scoped and do not change the agent's persistent default. Fixes #75965. Thanks @sd1114820.
|
||||
- Control UI/slash commands: keep fallback command metadata on a browser-safe registry path, so provider thinking runtime imports cannot blank the Web UI with `process is not defined`. Fixes #75987. Thanks @novkien.
|
||||
|
||||
@@ -111,6 +111,8 @@ Light and REM phase hits add a small recency-decayed boost from `memory/.dreams/
|
||||
|
||||
When enabled, `memory-core` auto-manages one cron job for a full dreaming sweep. Each sweep runs phases in order: light → REM → deep.
|
||||
|
||||
The sweep includes the primary runtime workspace and any configured agent workspaces, deduped by path, so subagent workspace fan-out does not exclude the main agent's `DREAMS.md` and memory state.
|
||||
|
||||
Default cadence behavior:
|
||||
|
||||
| Setting | Default |
|
||||
|
||||
@@ -691,6 +691,97 @@ describe("memory-core dreaming phases", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps primary session transcripts out of configured subagent workspaces", async () => {
|
||||
const workspaceDir = await createDreamingWorkspace();
|
||||
const subagentWorkspaceDir = await createDreamingWorkspace();
|
||||
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
|
||||
|
||||
const mainSessionsDir = resolveSessionTranscriptsDirForAgent("main");
|
||||
const subagentSessionsDir = resolveSessionTranscriptsDirForAgent("agi-ceo");
|
||||
await fs.mkdir(mainSessionsDir, { recursive: true });
|
||||
await fs.mkdir(subagentSessionsDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(mainSessionsDir, "main-session.jsonl"),
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "user",
|
||||
timestamp: "2026-04-05T18:01:00.000Z",
|
||||
content: [{ type: "text", text: "Main workspace should stay in main dreams." }],
|
||||
},
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(subagentSessionsDir, "subagent-session.jsonl"),
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "user",
|
||||
timestamp: "2026-04-05T18:02:00.000Z",
|
||||
content: [{ type: "text", text: "CEO workspace should stay in CEO dreams." }],
|
||||
},
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { beforeAgentReply } = createHarness(
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
},
|
||||
list: [{ id: "agi-ceo", workspace: subagentWorkspaceDir }],
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
phases: {
|
||||
light: {
|
||||
enabled: true,
|
||||
limit: 20,
|
||||
lookbackDays: 7,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaceDir,
|
||||
);
|
||||
|
||||
try {
|
||||
await withDreamingTestClock(async () => {
|
||||
await triggerLightDreaming(beforeAgentReply, workspaceDir, 5);
|
||||
});
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
|
||||
const mainCorpus = await fs.readFile(
|
||||
path.join(workspaceDir, "memory", ".dreams", "session-corpus", "2026-04-05.txt"),
|
||||
"utf-8",
|
||||
);
|
||||
const subagentCorpus = await fs.readFile(
|
||||
path.join(subagentWorkspaceDir, "memory", ".dreams", "session-corpus", "2026-04-05.txt"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(mainCorpus).toContain("Main workspace should stay in main dreams.");
|
||||
expect(mainCorpus).not.toContain("CEO workspace should stay in CEO dreams.");
|
||||
expect(subagentCorpus).toContain("CEO workspace should stay in CEO dreams.");
|
||||
expect(subagentCorpus).not.toContain("Main workspace should stay in main dreams.");
|
||||
});
|
||||
|
||||
it("redacts sensitive session content before writing session corpus", async () => {
|
||||
const workspaceDir = await createDreamingWorkspace();
|
||||
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
|
||||
|
||||
@@ -112,9 +112,14 @@ function resolveWorkspaces(params: {
|
||||
cfg?: DreamingHostConfig;
|
||||
fallbackWorkspaceDir?: string;
|
||||
}): string[] {
|
||||
const fallbackWorkspaceDir = normalizeTrimmedString(params.fallbackWorkspaceDir);
|
||||
const workspaceCandidates = params.cfg
|
||||
? resolveMemoryDreamingWorkspaces(
|
||||
params.cfg as Parameters<typeof resolveMemoryDreamingWorkspaces>[0],
|
||||
{
|
||||
primaryWorkspaceDir: fallbackWorkspaceDir,
|
||||
primaryAgentId: "main",
|
||||
},
|
||||
).map((entry) => entry.workspaceDir)
|
||||
: [];
|
||||
const seen = new Set<string>();
|
||||
@@ -125,7 +130,6 @@ function resolveWorkspaces(params: {
|
||||
seen.add(workspaceDir);
|
||||
return true;
|
||||
});
|
||||
const fallbackWorkspaceDir = normalizeTrimmedString(params.fallbackWorkspaceDir);
|
||||
if (workspaces.length === 0 && fallbackWorkspaceDir) {
|
||||
workspaces.push(fallbackWorkspaceDir);
|
||||
}
|
||||
@@ -641,13 +645,22 @@ function buildSessionRenderedLine(params: {
|
||||
return `[${source}] ${params.snippet}`.slice(0, SESSION_INGESTION_MAX_SNIPPET_CHARS + 64);
|
||||
}
|
||||
|
||||
function resolveSessionAgentsForWorkspace(cfg: DreamingHostConfig, workspaceDir: string): string[] {
|
||||
function resolveSessionAgentsForWorkspace(params: {
|
||||
cfg: DreamingHostConfig;
|
||||
workspaceDir: string;
|
||||
primaryWorkspaceDir?: string;
|
||||
}): string[] {
|
||||
const { cfg, workspaceDir, primaryWorkspaceDir } = params;
|
||||
if (!cfg) {
|
||||
return [];
|
||||
}
|
||||
const target = normalizeWorkspaceKey(workspaceDir);
|
||||
const workspaces = resolveMemoryDreamingWorkspaces(
|
||||
cfg as Parameters<typeof resolveMemoryDreamingWorkspaces>[0],
|
||||
{
|
||||
primaryWorkspaceDir,
|
||||
primaryAgentId: "main",
|
||||
},
|
||||
);
|
||||
const match = workspaces.find((entry) => normalizeWorkspaceKey(entry.workspaceDir) === target);
|
||||
if (!match) {
|
||||
@@ -706,6 +719,7 @@ async function appendSessionCorpusLines(params: {
|
||||
async function collectSessionIngestionBatches(params: {
|
||||
workspaceDir: string;
|
||||
cfg?: DreamingHostConfig;
|
||||
primaryWorkspaceDir?: string;
|
||||
lookbackDays: number;
|
||||
nowMs: number;
|
||||
timezone?: string;
|
||||
@@ -720,7 +734,11 @@ async function collectSessionIngestionBatches(params: {
|
||||
Object.keys(params.state.seenMessages).length > 0,
|
||||
};
|
||||
}
|
||||
const agentIds = resolveSessionAgentsForWorkspace(params.cfg, params.workspaceDir);
|
||||
const agentIds = resolveSessionAgentsForWorkspace({
|
||||
cfg: params.cfg,
|
||||
workspaceDir: params.workspaceDir,
|
||||
primaryWorkspaceDir: params.primaryWorkspaceDir,
|
||||
});
|
||||
const cutoffMs = calculateLookbackCutoffMs(params.nowMs, params.lookbackDays);
|
||||
const batchByDay = new Map<string, SessionIngestionMessage[]>();
|
||||
const nextFiles: Record<string, SessionIngestionFileState> = {};
|
||||
@@ -1003,6 +1021,7 @@ async function collectSessionIngestionBatches(params: {
|
||||
async function ingestSessionTranscriptSignals(params: {
|
||||
workspaceDir: string;
|
||||
cfg?: DreamingHostConfig;
|
||||
primaryWorkspaceDir?: string;
|
||||
lookbackDays: number;
|
||||
nowMs: number;
|
||||
timezone?: string;
|
||||
@@ -1011,6 +1030,7 @@ async function ingestSessionTranscriptSignals(params: {
|
||||
const collected = await collectSessionIngestionBatches({
|
||||
workspaceDir: params.workspaceDir,
|
||||
cfg: params.cfg,
|
||||
primaryWorkspaceDir: params.primaryWorkspaceDir,
|
||||
lookbackDays: params.lookbackDays,
|
||||
nowMs: params.nowMs,
|
||||
timezone: params.timezone,
|
||||
@@ -1520,6 +1540,7 @@ export function previewRemDreaming(params: {
|
||||
async function runLightDreaming(params: {
|
||||
workspaceDir: string;
|
||||
cfg?: DreamingHostConfig;
|
||||
primaryWorkspaceDir?: string;
|
||||
config: LightDreamingConfig;
|
||||
logger: Logger;
|
||||
subagent?: Parameters<typeof generateAndAppendDreamNarrative>[0]["subagent"];
|
||||
@@ -1537,6 +1558,7 @@ async function runLightDreaming(params: {
|
||||
await ingestSessionTranscriptSignals({
|
||||
workspaceDir: params.workspaceDir,
|
||||
cfg: params.cfg,
|
||||
primaryWorkspaceDir: params.primaryWorkspaceDir,
|
||||
lookbackDays: params.config.lookbackDays,
|
||||
nowMs,
|
||||
timezone: params.config.timezone,
|
||||
@@ -1617,6 +1639,7 @@ async function runLightDreaming(params: {
|
||||
async function runRemDreaming(params: {
|
||||
workspaceDir: string;
|
||||
cfg?: DreamingHostConfig;
|
||||
primaryWorkspaceDir?: string;
|
||||
config: RemDreamingConfig;
|
||||
logger: Logger;
|
||||
subagent?: Parameters<typeof generateAndAppendDreamNarrative>[0]["subagent"];
|
||||
@@ -1634,6 +1657,7 @@ async function runRemDreaming(params: {
|
||||
await ingestSessionTranscriptSignals({
|
||||
workspaceDir: params.workspaceDir,
|
||||
cfg: params.cfg,
|
||||
primaryWorkspaceDir: params.primaryWorkspaceDir,
|
||||
lookbackDays: params.config.lookbackDays,
|
||||
nowMs,
|
||||
timezone: params.config.timezone,
|
||||
@@ -1766,9 +1790,10 @@ async function runPhaseIfTriggered(
|
||||
if (!params.config.enabled) {
|
||||
return { handled: true, reason: `memory-core: ${params.phase} dreaming disabled` };
|
||||
}
|
||||
const primaryWorkspaceDir = normalizeTrimmedString(params.workspaceDir);
|
||||
const workspaces = resolveWorkspaces({
|
||||
cfg: params.cfg,
|
||||
fallbackWorkspaceDir: params.workspaceDir,
|
||||
fallbackWorkspaceDir: primaryWorkspaceDir,
|
||||
});
|
||||
if (workspaces.length === 0) {
|
||||
params.logger.warn(
|
||||
@@ -1786,6 +1811,7 @@ async function runPhaseIfTriggered(
|
||||
await runLightDreaming({
|
||||
workspaceDir,
|
||||
cfg: params.cfg,
|
||||
primaryWorkspaceDir,
|
||||
config: params.config,
|
||||
logger: params.logger,
|
||||
subagent: params.subagent,
|
||||
@@ -1794,6 +1820,7 @@ async function runPhaseIfTriggered(
|
||||
await runRemDreaming({
|
||||
workspaceDir,
|
||||
cfg: params.cfg,
|
||||
primaryWorkspaceDir,
|
||||
config: params.config,
|
||||
logger: params.logger,
|
||||
subagent: params.subagent,
|
||||
|
||||
@@ -2316,11 +2316,27 @@ describe("short-term dreaming trigger", () => {
|
||||
it("fans out one dreaming run across configured agent workspaces", async () => {
|
||||
const logger = createLogger();
|
||||
const workspaceRoot = await createTempWorkspace("memory-dreaming-multi-");
|
||||
const mainWorkspace = path.join(workspaceRoot, "main");
|
||||
const alphaWorkspace = path.join(workspaceRoot, "alpha");
|
||||
const betaWorkspace = path.join(workspaceRoot, "beta");
|
||||
|
||||
await writeDailyMemoryNote(mainWorkspace, "2026-04-02", ["Main workspace note."]);
|
||||
await writeDailyMemoryNote(alphaWorkspace, "2026-04-02", ["Alpha backup note."]);
|
||||
await writeDailyMemoryNote(betaWorkspace, "2026-04-02", ["Beta router note."]);
|
||||
await recordShortTermRecalls({
|
||||
workspaceDir: mainWorkspace,
|
||||
query: "main workspace",
|
||||
results: [
|
||||
{
|
||||
path: "memory/2026-04-02.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
score: 0.9,
|
||||
snippet: "Main workspace note.",
|
||||
source: "memory",
|
||||
},
|
||||
],
|
||||
});
|
||||
await recordShortTermRecalls({
|
||||
workspaceDir: alphaWorkspace,
|
||||
query: "alpha backup",
|
||||
@@ -2353,7 +2369,7 @@ describe("short-term dreaming trigger", () => {
|
||||
const result = await runShortTermDreamingPromotionIfTriggered({
|
||||
cleanedBody: constants.DREAMING_SYSTEM_EVENT_TEXT,
|
||||
trigger: "heartbeat",
|
||||
workspaceDir: alphaWorkspace,
|
||||
workspaceDir: mainWorkspace,
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -2387,6 +2403,9 @@ describe("short-term dreaming trigger", () => {
|
||||
});
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(await fs.readFile(path.join(mainWorkspace, "MEMORY.md"), "utf-8")).toContain(
|
||||
"Main workspace note.",
|
||||
);
|
||||
expect(await fs.readFile(path.join(alphaWorkspace, "MEMORY.md"), "utf-8")).toContain(
|
||||
"Alpha backup note.",
|
||||
);
|
||||
@@ -2394,7 +2413,7 @@ describe("short-term dreaming trigger", () => {
|
||||
"Beta router note.",
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
"memory-core: dreaming promotion complete (workspaces=2, candidates=2, applied=2, failed=0).",
|
||||
"memory-core: dreaming promotion complete (workspaces=3, candidates=3, applied=3, failed=0).",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -511,8 +511,12 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
|
||||
|
||||
const recencyHalfLifeDays =
|
||||
params.config.recencyHalfLifeDays ?? DEFAULT_MEMORY_DREAMING_RECENCY_HALF_LIFE_DAYS;
|
||||
const fallbackWorkspaceDir = normalizeTrimmedString(params.workspaceDir);
|
||||
const workspaceCandidates = params.cfg
|
||||
? resolveMemoryDreamingWorkspaces(params.cfg).map((entry) => entry.workspaceDir)
|
||||
? resolveMemoryDreamingWorkspaces(params.cfg, {
|
||||
primaryWorkspaceDir: fallbackWorkspaceDir,
|
||||
primaryAgentId: "main",
|
||||
}).map((entry) => entry.workspaceDir)
|
||||
: [];
|
||||
const seenWorkspaces = new Set<string>();
|
||||
const workspaces = workspaceCandidates.filter((workspaceDir) => {
|
||||
@@ -522,7 +526,6 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
|
||||
seenWorkspaces.add(workspaceDir);
|
||||
return true;
|
||||
});
|
||||
const fallbackWorkspaceDir = normalizeTrimmedString(params.workspaceDir);
|
||||
if (workspaces.length === 0 && fallbackWorkspaceDir) {
|
||||
workspaces.push(fallbackWorkspaceDir);
|
||||
}
|
||||
|
||||
@@ -472,10 +472,7 @@ describe("doctor.memory.status", () => {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{ id: "main", workspace: mainWorkspaceDir },
|
||||
{ id: "alpha", workspace: alphaWorkspaceDir },
|
||||
],
|
||||
list: [{ id: "alpha", workspace: alphaWorkspaceDir }],
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
@@ -649,7 +646,7 @@ describe("doctor.memory.status", () => {
|
||||
expect.objectContaining({
|
||||
dreaming: expect.objectContaining({
|
||||
shortTermCount: 0,
|
||||
promotedTotal: 0,
|
||||
promotedTotal: 1,
|
||||
phases: expect.objectContaining({
|
||||
deep: expect.objectContaining({
|
||||
managedCronPresent: false,
|
||||
|
||||
@@ -905,9 +905,10 @@ export const doctorHandlers: GatewayRequestHandlers = {
|
||||
const nowMs = Date.now();
|
||||
const dreamingConfig = resolveDreamingConfig(cfg);
|
||||
const workspaceDir = normalizeTrimmedString((status as Record<string, unknown>).workspaceDir);
|
||||
const configuredWorkspaces = resolveMemoryDreamingWorkspaces(cfg).map(
|
||||
(entry) => entry.workspaceDir,
|
||||
);
|
||||
const configuredWorkspaces = resolveMemoryDreamingWorkspaces(cfg, {
|
||||
primaryWorkspaceDir: workspaceDir,
|
||||
primaryAgentId: resolveDefaultAgentId(cfg),
|
||||
}).map((entry) => entry.workspaceDir);
|
||||
const allWorkspaces =
|
||||
configuredWorkspaces.length > 0 ? configuredWorkspaces : workspaceDir ? [workspaceDir] : [];
|
||||
const storeStats =
|
||||
|
||||
@@ -175,6 +175,37 @@ describe("memory dreaming host helpers", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes the runtime primary workspace alongside configured subagent workspaces", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "agi-ceo", workspace: "/workspace/agi-ceo" },
|
||||
{ id: "agi-cdo", workspace: "/workspace/agi-cdo" },
|
||||
],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(
|
||||
resolveMemoryDreamingWorkspaces(cfg, {
|
||||
primaryWorkspaceDir: "/workspace/main",
|
||||
primaryAgentId: "main",
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
workspaceDir: "/workspace/agi-ceo",
|
||||
agentIds: ["agi-ceo"],
|
||||
},
|
||||
{
|
||||
workspaceDir: "/workspace/agi-cdo",
|
||||
agentIds: ["agi-cdo"],
|
||||
},
|
||||
{
|
||||
workspaceDir: "/workspace/main",
|
||||
agentIds: ["main"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses default agent fallback and timezone-aware day helpers", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
|
||||
@@ -146,6 +146,11 @@ export type MemoryDreamingWorkspace = {
|
||||
agentIds: string[];
|
||||
};
|
||||
|
||||
export type MemoryDreamingWorkspaceOptions = {
|
||||
primaryWorkspaceDir?: string | null;
|
||||
primaryAgentId?: string | null;
|
||||
};
|
||||
|
||||
const DEFAULT_MEMORY_LIGHT_DREAMING_SOURCES: MemoryLightDreamingSource[] = [
|
||||
"daily",
|
||||
"sessions",
|
||||
@@ -603,7 +608,10 @@ export function isSameMemoryDreamingDay(
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMemoryDreamingWorkspaces(cfg: OpenClawConfig): MemoryDreamingWorkspace[] {
|
||||
export function resolveMemoryDreamingWorkspaces(
|
||||
cfg: OpenClawConfig,
|
||||
options: MemoryDreamingWorkspaceOptions = {},
|
||||
): MemoryDreamingWorkspace[] {
|
||||
const configured = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
|
||||
const agentIds: string[] = [];
|
||||
const seenAgents = new Set<string>();
|
||||
@@ -623,18 +631,29 @@ export function resolveMemoryDreamingWorkspaces(cfg: OpenClawConfig): MemoryDrea
|
||||
}
|
||||
|
||||
const byWorkspace = new Map<string, MemoryDreamingWorkspace>();
|
||||
for (const agentId of agentIds) {
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId)?.trim();
|
||||
const addWorkspace = (workspaceDirRaw: string | undefined, agentIdRaw: string): void => {
|
||||
const workspaceDir = workspaceDirRaw?.trim();
|
||||
if (!workspaceDir) {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
const agentId = normalizeOptionalLowercaseString(agentIdRaw) || resolveDefaultAgentId(cfg);
|
||||
const key = normalizePathForComparison(workspaceDir);
|
||||
const existing = byWorkspace.get(key);
|
||||
if (existing) {
|
||||
existing.agentIds.push(agentId);
|
||||
continue;
|
||||
if (!existing.agentIds.includes(agentId)) {
|
||||
existing.agentIds.push(agentId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
byWorkspace.set(key, { workspaceDir, agentIds: [agentId] });
|
||||
};
|
||||
|
||||
for (const agentId of agentIds) {
|
||||
addWorkspace(resolveAgentWorkspaceDir(cfg, agentId), agentId);
|
||||
}
|
||||
addWorkspace(
|
||||
options.primaryWorkspaceDir ?? undefined,
|
||||
options.primaryAgentId ?? resolveDefaultAgentId(cfg),
|
||||
);
|
||||
return [...byWorkspace.values()];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user