Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0524fa83cd | |||
| 4d7bec4d05 | |||
| 9f529a30f5 | |||
| b34aff1aa2 |
@@ -10,7 +10,7 @@
|
||||
"plugins": [
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "10.6.1",
|
||||
"version": "10.6.2",
|
||||
"source": "./plugin",
|
||||
"description": "Persistent memory system for Claude Code - context compression across sessions"
|
||||
}
|
||||
|
||||
+15
-15
@@ -2,6 +2,21 @@
|
||||
|
||||
All notable changes to claude-mem.
|
||||
|
||||
## [v10.6.1] - 2026-03-18
|
||||
|
||||
### New Features
|
||||
- **Timeline Report Skill** — New `/timeline-report` skill generates narrative "Journey Into [Project]" reports from claude-mem's development history with token-aware economics
|
||||
- **Git Worktree Detection** — Timeline report automatically detects git worktrees and uses parent project as data source
|
||||
- **Compressed Context Output** — Markdown context injection compressed ~53% (tables → compact flat lines), reducing token overhead in session starts
|
||||
- **Full Observation Fetch** — Added `full=true` parameter to `/api/context/inject` for fetching all observations
|
||||
|
||||
### Improvements
|
||||
- Split `TimelineRenderer` into separate markdown/color rendering paths
|
||||
- Fixed timestamp ditto marker leaking across session summary boundaries
|
||||
|
||||
### Security
|
||||
- Removed arbitrary file write vulnerability (`dump_to_file` parameter)
|
||||
|
||||
## [v10.6.0] - 2026-03-18
|
||||
|
||||
## OpenClaw: System prompt context injection
|
||||
@@ -1103,18 +1118,3 @@ Fixed an issue where the worker service startup wasn't producing proper JSON sta
|
||||
|
||||
- Removed obsolete error handling baseline file
|
||||
|
||||
## [v9.0.2] - 2026-01-10
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- **Windows Terminal Tab Accumulation (#625, #628)**: Fixed terminal tab accumulation on Windows by implementing graceful exit strategy. All expected failure scenarios (port conflicts, version mismatches, health check timeouts) now exit with code 0 instead of code 1.
|
||||
- **Windows 11 Compatibility (#625)**: Replaced deprecated WMIC commands with PowerShell `Get-Process` and `Get-CimInstance` for process enumeration. WMIC is being removed from Windows 11.
|
||||
|
||||
## Maintenance
|
||||
|
||||
- **Removed Obsolete CLAUDE.md Files**: Cleaned up auto-generated CLAUDE.md files from `~/.claude/plans/` and `~/.claude/plugins/marketplaces/` directories.
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v9.0.1...v9.0.2
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "10.6.1",
|
||||
"version": "10.6.2",
|
||||
"description": "Memory compression system for Claude Code - persist context across sessions",
|
||||
"keywords": [
|
||||
"claude",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "10.6.1",
|
||||
"version": "10.6.2",
|
||||
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
|
||||
"author": {
|
||||
"name": "Alex Newman"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem-plugin",
|
||||
"version": "10.6.1",
|
||||
"version": "10.6.2",
|
||||
"private": true,
|
||||
"description": "Runtime dependencies for claude-mem bundled hooks",
|
||||
"type": "module",
|
||||
|
||||
File diff suppressed because one or more lines are too long
+142
-142
File diff suppressed because one or more lines are too long
+11
-11
File diff suppressed because one or more lines are too long
@@ -653,30 +653,26 @@ export class WorkerService {
|
||||
|
||||
// Do NOT restart after unrecoverable errors - prevents infinite loops
|
||||
if (hadUnrecoverableError) {
|
||||
logger.warn('SYSTEM', 'Skipping restart due to unrecoverable error', {
|
||||
sessionId: session.sessionDbId
|
||||
});
|
||||
this.broadcastProcessingStatus();
|
||||
this.terminateSession(session.sessionDbId, 'unrecoverable_error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Store for pending-count check below
|
||||
const { PendingMessageStore } = require('./sqlite/PendingMessageStore.js');
|
||||
const pendingStore = new PendingMessageStore(this.dbManager.getSessionStore().db, 3);
|
||||
|
||||
// Idle timeout means no new work arrived for 3 minutes - don't restart
|
||||
// No need to reset stale processing messages here — claimNextMessage() self-heals
|
||||
if (session.idleTimedOut) {
|
||||
logger.info('SYSTEM', 'Generator exited due to idle timeout, not restarting', {
|
||||
sessionId: session.sessionDbId
|
||||
});
|
||||
session.idleTimedOut = false; // Reset flag
|
||||
this.broadcastProcessingStatus();
|
||||
return;
|
||||
}
|
||||
const pendingStore = this.sessionManager.getPendingMessageStore();
|
||||
|
||||
// Check if there's pending work that needs processing with a fresh AbortController
|
||||
const pendingCount = pendingStore.getPendingCount(session.sessionDbId);
|
||||
|
||||
// Idle timeout means no new work arrived for 3 minutes - don't restart
|
||||
// But check pendingCount first: a message may have arrived between idle
|
||||
// abort and .finally(), and we must not abandon it
|
||||
if (session.idleTimedOut) {
|
||||
session.idleTimedOut = false; // Reset flag
|
||||
if (pendingCount === 0) {
|
||||
this.terminateSession(session.sessionDbId, 'idle_timeout');
|
||||
return;
|
||||
}
|
||||
// Fall through to pending-work restart below
|
||||
}
|
||||
const MAX_PENDING_RESTARTS = 3;
|
||||
|
||||
if (pendingCount > 0) {
|
||||
@@ -690,7 +686,7 @@ export class WorkerService {
|
||||
consecutiveRestarts: session.consecutiveRestarts
|
||||
});
|
||||
session.consecutiveRestarts = 0;
|
||||
this.broadcastProcessingStatus();
|
||||
this.terminateSession(session.sessionDbId, 'max_restarts_exceeded');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -703,12 +699,13 @@ export class WorkerService {
|
||||
session.abortController = new AbortController();
|
||||
// Restart processor
|
||||
this.startSessionProcessor(session, 'pending-work-restart');
|
||||
this.broadcastProcessingStatus();
|
||||
} else {
|
||||
// Successful completion with no pending work — reset counter
|
||||
// Successful completion with no pending work — clean up session
|
||||
// removeSessionImmediate fires onSessionDeletedCallback → broadcastProcessingStatus()
|
||||
session.consecutiveRestarts = 0;
|
||||
this.sessionManager.removeSessionImmediate(session.sessionDbId);
|
||||
}
|
||||
|
||||
this.broadcastProcessingStatus();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -784,6 +781,30 @@ export class WorkerService {
|
||||
this.sessionEventBroadcaster.broadcastSessionCompleted(sessionDbId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate a session that will not restart.
|
||||
* Enforces the restart-or-terminate invariant: every generator exit
|
||||
* must either call startSessionProcessor() or terminateSession().
|
||||
* No zombie sessions allowed.
|
||||
*
|
||||
* GENERATOR EXIT INVARIANT:
|
||||
* .finally() → restart? → startSessionProcessor()
|
||||
* no? → terminateSession()
|
||||
*/
|
||||
private terminateSession(sessionDbId: number, reason: string): void {
|
||||
const pendingStore = this.sessionManager.getPendingMessageStore();
|
||||
const abandoned = pendingStore.markAllSessionMessagesAbandoned(sessionDbId);
|
||||
|
||||
logger.info('SYSTEM', 'Session terminated', {
|
||||
sessionId: sessionDbId,
|
||||
reason,
|
||||
abandonedMessages: abandoned
|
||||
});
|
||||
|
||||
// removeSessionImmediate fires onSessionDeletedCallback → broadcastProcessingStatus()
|
||||
this.sessionManager.removeSessionImmediate(sessionDbId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process pending session queues
|
||||
*/
|
||||
@@ -907,8 +928,8 @@ export class WorkerService {
|
||||
* Broadcast processing status change to SSE clients
|
||||
*/
|
||||
broadcastProcessingStatus(): void {
|
||||
const isProcessing = this.sessionManager.isAnySessionProcessing();
|
||||
const queueDepth = this.sessionManager.getTotalActiveWork();
|
||||
const isProcessing = queueDepth > 0;
|
||||
const activeSessions = this.sessionManager.getActiveSessionCount();
|
||||
|
||||
logger.info('WORKER', 'Broadcasting processing status', {
|
||||
|
||||
@@ -350,7 +350,7 @@ export class SessionManager {
|
||||
this.sessions.delete(sessionDbId);
|
||||
this.sessionQueues.delete(sessionDbId);
|
||||
|
||||
logger.info('SESSION', 'Session removed (orphaned after SDK termination)', {
|
||||
logger.info('SESSION', 'Session removed from active sessions', {
|
||||
sessionId: sessionDbId,
|
||||
project: session.project
|
||||
});
|
||||
@@ -402,10 +402,11 @@ export class SessionManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any session has pending messages (for spinner tracking)
|
||||
* Check if any active session has pending messages (for spinner tracking).
|
||||
* Scoped to in-memory sessions only.
|
||||
*/
|
||||
hasPendingMessages(): boolean {
|
||||
return this.getPendingStore().hasAnyPendingWork();
|
||||
return this.getTotalQueueDepth() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -437,12 +438,12 @@ export class SessionManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any session is actively processing (has pending messages OR active generator)
|
||||
* Used for activity indicator to prevent spinner from stopping while SDK is processing
|
||||
* Check if any active session has pending work.
|
||||
* Scoped to in-memory sessions only — orphaned DB messages from dead
|
||||
* sessions must not keep the spinner spinning forever.
|
||||
*/
|
||||
isAnySessionProcessing(): boolean {
|
||||
// hasAnyPendingWork checks for 'pending' OR 'processing'
|
||||
return this.getPendingStore().hasAnyPendingWork();
|
||||
return this.getTotalQueueDepth() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,12 +33,6 @@ export class SessionEventBroadcaster {
|
||||
prompt
|
||||
});
|
||||
|
||||
// Start activity indicator (work is about to begin)
|
||||
this.sseBroadcaster.broadcast({
|
||||
type: 'processing_status',
|
||||
isProcessing: true
|
||||
});
|
||||
|
||||
// Update processing status based on queue depth
|
||||
this.workerService.broadcastProcessingStatus();
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* Tag Stripping Utilities
|
||||
*
|
||||
* Implements the dual-tag system for meta-observation control:
|
||||
* Implements the tag system for meta-observation control:
|
||||
* 1. <claude-mem-context> - System-level tag for auto-injected observations
|
||||
* (prevents recursive storage when context injection is active)
|
||||
* 2. <private> - User-level tag for manual privacy control
|
||||
* (allows users to mark content they don't want persisted)
|
||||
* 3. <system_instruction> / <system-instruction> - Conductor-injected system instructions
|
||||
* (should not be persisted to memory)
|
||||
*
|
||||
* EDGE PROCESSING PATTERN: Filter at hook layer before sending to worker/storage.
|
||||
* This keeps the worker service simple and follows one-way data stream.
|
||||
@@ -27,7 +29,9 @@ const MAX_TAG_COUNT = 100;
|
||||
function countTags(content: string): number {
|
||||
const privateCount = (content.match(/<private>/g) || []).length;
|
||||
const contextCount = (content.match(/<claude-mem-context>/g) || []).length;
|
||||
return privateCount + contextCount;
|
||||
const systemInstructionCount = (content.match(/<system_instruction>/g) || []).length;
|
||||
const systemInstructionHyphenCount = (content.match(/<system-instruction>/g) || []).length;
|
||||
return privateCount + contextCount + systemInstructionCount + systemInstructionHyphenCount;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,6 +53,8 @@ function stripTagsInternal(content: string): string {
|
||||
return content
|
||||
.replace(/<claude-mem-context>[\s\S]*?<\/claude-mem-context>/g, '')
|
||||
.replace(/<private>[\s\S]*?<\/private>/g, '')
|
||||
.replace(/<system_instruction>[\s\S]*?<\/system_instruction>/g, '')
|
||||
.replace(/<system-instruction>[\s\S]*?<\/system-instruction>/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Tag Stripping Utility Tests
|
||||
*
|
||||
* Tests the dual-tag privacy system for <private> and <claude-mem-context> tags.
|
||||
* Tests the tag privacy system for <private>, <claude-mem-context>, and <system_instruction> tags.
|
||||
* These tags enable users and the system to exclude content from memory storage.
|
||||
*
|
||||
* Sources:
|
||||
@@ -257,6 +257,74 @@ finish`;
|
||||
});
|
||||
});
|
||||
|
||||
describe('system_instruction tag stripping', () => {
|
||||
describe('basic system_instruction removal', () => {
|
||||
it('should strip single <system_instruction> tag from prompt', () => {
|
||||
const input = 'user content <system_instruction>injected instructions</system_instruction> more content';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('user content more content');
|
||||
});
|
||||
|
||||
it('should strip <system_instruction> mixed with <private> tags', () => {
|
||||
const input = '<system_instruction>instructions</system_instruction> public <private>secret</private> end';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('public end');
|
||||
});
|
||||
|
||||
it('should return empty string for entirely <system_instruction> content', () => {
|
||||
const input = '<system_instruction>entire prompt is system instructions</system_instruction>';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should strip <system_instruction> tags from JSON content', () => {
|
||||
const jsonContent = JSON.stringify({
|
||||
data: '<system_instruction>injected</system_instruction> real data'
|
||||
});
|
||||
const result = stripMemoryTagsFromJson(jsonContent);
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.data).toBe(' real data');
|
||||
});
|
||||
|
||||
it('should strip multiline content within <system_instruction> tags', () => {
|
||||
const input = `before
|
||||
<system_instruction>
|
||||
line one
|
||||
line two
|
||||
line three
|
||||
</system_instruction>
|
||||
after`;
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('before\n\nafter');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('system-instruction (hyphen variant) tag stripping', () => {
|
||||
it('should strip single <system-instruction> tag from prompt', () => {
|
||||
const input = 'user content <system-instruction>injected instructions</system-instruction> more content';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('user content more content');
|
||||
});
|
||||
|
||||
it('should strip both underscore and hyphen variants in same prompt', () => {
|
||||
const input = '<system_instruction>underscore</system_instruction> middle <system-instruction>hyphen</system-instruction> end';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('middle end');
|
||||
});
|
||||
|
||||
it('should strip multiline <system-instruction> content', () => {
|
||||
const input = `before
|
||||
<system-instruction>
|
||||
line one
|
||||
line two
|
||||
</system-instruction>
|
||||
after`;
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('before\n\nafter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('privacy enforcement integration', () => {
|
||||
it('should allow empty result to trigger privacy skip', () => {
|
||||
// Simulates what SessionRoutes does with private-only prompts
|
||||
|
||||
@@ -326,4 +326,152 @@ describe('Zombie Agent Prevention', () => {
|
||||
session.generatorPromise = null;
|
||||
expect(session.generatorPromise).toBeNull();
|
||||
});
|
||||
|
||||
describe('Session Termination Invariant', () => {
|
||||
// Tests the restart-or-terminate invariant:
|
||||
// When a generator exits without restarting, its messages must be
|
||||
// marked abandoned and the session removed from the active Map.
|
||||
|
||||
test('should mark messages abandoned when session is terminated', () => {
|
||||
const sessionId = createDbSession('content-terminate-1');
|
||||
enqueueTestMessage(sessionId, 'content-terminate-1');
|
||||
enqueueTestMessage(sessionId, 'content-terminate-1');
|
||||
|
||||
// Verify messages exist
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(2);
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(true);
|
||||
|
||||
// Terminate: mark abandoned (same as terminateSession does)
|
||||
const abandoned = pendingStore.markAllSessionMessagesAbandoned(sessionId);
|
||||
expect(abandoned).toBe(2);
|
||||
|
||||
// Spinner should stop: no pending work remains
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(0);
|
||||
});
|
||||
|
||||
test('should handle terminate with zero pending messages', () => {
|
||||
const sessionId = createDbSession('content-terminate-empty');
|
||||
|
||||
// No messages enqueued
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(0);
|
||||
|
||||
// Terminate with nothing to abandon
|
||||
const abandoned = pendingStore.markAllSessionMessagesAbandoned(sessionId);
|
||||
expect(abandoned).toBe(0);
|
||||
|
||||
// Still no pending work
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
});
|
||||
|
||||
test('should be idempotent — double terminate marks zero on second call', () => {
|
||||
const sessionId = createDbSession('content-terminate-idempotent');
|
||||
enqueueTestMessage(sessionId, 'content-terminate-idempotent');
|
||||
|
||||
// First terminate
|
||||
const first = pendingStore.markAllSessionMessagesAbandoned(sessionId);
|
||||
expect(first).toBe(1);
|
||||
|
||||
// Second terminate — already failed, nothing to mark
|
||||
const second = pendingStore.markAllSessionMessagesAbandoned(sessionId);
|
||||
expect(second).toBe(0);
|
||||
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
});
|
||||
|
||||
test('should remove session from Map via removeSessionImmediate', () => {
|
||||
const sessionId = createDbSession('content-terminate-map');
|
||||
const session = createMockSession(sessionId, {
|
||||
contentSessionId: 'content-terminate-map',
|
||||
});
|
||||
|
||||
// Simulate the in-memory sessions Map
|
||||
const sessions = new Map<number, ActiveSession>();
|
||||
sessions.set(sessionId, session);
|
||||
expect(sessions.has(sessionId)).toBe(true);
|
||||
|
||||
// Simulate removeSessionImmediate behavior
|
||||
sessions.delete(sessionId);
|
||||
expect(sessions.has(sessionId)).toBe(false);
|
||||
});
|
||||
|
||||
test('should return hasAnyPendingWork false after all sessions terminated', () => {
|
||||
// Create multiple sessions with messages
|
||||
const sid1 = createDbSession('content-multi-term-1');
|
||||
const sid2 = createDbSession('content-multi-term-2');
|
||||
const sid3 = createDbSession('content-multi-term-3');
|
||||
|
||||
enqueueTestMessage(sid1, 'content-multi-term-1');
|
||||
enqueueTestMessage(sid1, 'content-multi-term-1');
|
||||
enqueueTestMessage(sid2, 'content-multi-term-2');
|
||||
enqueueTestMessage(sid3, 'content-multi-term-3');
|
||||
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(true);
|
||||
|
||||
// Terminate all sessions
|
||||
pendingStore.markAllSessionMessagesAbandoned(sid1);
|
||||
pendingStore.markAllSessionMessagesAbandoned(sid2);
|
||||
pendingStore.markAllSessionMessagesAbandoned(sid3);
|
||||
|
||||
// Spinner must stop
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
});
|
||||
|
||||
test('should not affect other sessions when terminating one', () => {
|
||||
const sid1 = createDbSession('content-isolate-1');
|
||||
const sid2 = createDbSession('content-isolate-2');
|
||||
|
||||
enqueueTestMessage(sid1, 'content-isolate-1');
|
||||
enqueueTestMessage(sid2, 'content-isolate-2');
|
||||
|
||||
// Terminate only session 1
|
||||
pendingStore.markAllSessionMessagesAbandoned(sid1);
|
||||
|
||||
// Session 2 still has work
|
||||
expect(pendingStore.getPendingCount(sid1)).toBe(0);
|
||||
expect(pendingStore.getPendingCount(sid2)).toBe(1);
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(true);
|
||||
});
|
||||
|
||||
test('should mark both pending and processing messages as abandoned', () => {
|
||||
const sessionId = createDbSession('content-mixed-status');
|
||||
|
||||
// Enqueue two messages
|
||||
const msgId1 = enqueueTestMessage(sessionId, 'content-mixed-status');
|
||||
enqueueTestMessage(sessionId, 'content-mixed-status');
|
||||
|
||||
// Claim first message (transitions to 'processing')
|
||||
const claimed = pendingStore.claimNextMessage(sessionId);
|
||||
expect(claimed).not.toBeNull();
|
||||
expect(claimed!.id).toBe(msgId1);
|
||||
|
||||
// Now we have 1 processing + 1 pending
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(2);
|
||||
|
||||
// Terminate should mark BOTH as failed
|
||||
const abandoned = pendingStore.markAllSessionMessagesAbandoned(sessionId);
|
||||
expect(abandoned).toBe(2);
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
});
|
||||
|
||||
test('should enforce invariant: no pending work after terminate regardless of initial state', () => {
|
||||
const sessionId = createDbSession('content-invariant');
|
||||
|
||||
// Create a complex initial state: some pending, some processing, some with stale timestamps
|
||||
enqueueTestMessage(sessionId, 'content-invariant');
|
||||
enqueueTestMessage(sessionId, 'content-invariant');
|
||||
enqueueTestMessage(sessionId, 'content-invariant');
|
||||
|
||||
// Claim one (processing)
|
||||
pendingStore.claimNextMessage(sessionId);
|
||||
|
||||
// Verify complex state
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(3);
|
||||
|
||||
// THE INVARIANT: after terminate, hasAnyPendingWork MUST be false
|
||||
pendingStore.markAllSessionMessagesAbandoned(sessionId);
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user