mirror of
https://github.com/sortedcord/omnia.git
synced 2026-07-22 03:52:48 +05:30
feat: Implement tier 1 memory buffer
This commit is contained in:
@@ -2,6 +2,7 @@ import { AttributableObject } from "./attribute.js";
|
||||
|
||||
export class Entity extends AttributableObject {
|
||||
locationId: string | null = null;
|
||||
readonly aliases: Map<string, string> = new Map();
|
||||
|
||||
constructor(id?: string, locationId?: string | null) {
|
||||
super(id);
|
||||
|
||||
@@ -22,6 +22,7 @@ export class SQLiteRepository {
|
||||
world_id TEXT,
|
||||
clock_iso TEXT,
|
||||
location_id TEXT,
|
||||
aliases_json TEXT,
|
||||
FOREIGN KEY (world_id) REFERENCES objects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
@@ -56,6 +57,13 @@ export class SQLiteRepository {
|
||||
} catch {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
// Safely add aliases_json column if it does not exist in an existing database
|
||||
try {
|
||||
this.db.exec("ALTER TABLE objects ADD COLUMN aliases_json TEXT;");
|
||||
} catch {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
}
|
||||
|
||||
save(obj: AttributableObject, type: string, worldId?: string): void {
|
||||
@@ -66,24 +74,27 @@ export class SQLiteRepository {
|
||||
}
|
||||
|
||||
let locationId: string | null = null;
|
||||
let aliasesJson: string | null = null;
|
||||
if (obj instanceof Entity) {
|
||||
locationId = obj.locationId;
|
||||
aliasesJson = JSON.stringify(Array.from(obj.aliases.entries()));
|
||||
}
|
||||
|
||||
// 1. Insert or ignore the object in the objects table
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO objects (id, type, world_id, clock_iso, location_id)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO objects (id, type, world_id, clock_iso, location_id, aliases_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
type = excluded.type,
|
||||
world_id = excluded.world_id,
|
||||
clock_iso = excluded.clock_iso,
|
||||
location_id = excluded.location_id
|
||||
location_id = excluded.location_id,
|
||||
aliases_json = excluded.aliases_json
|
||||
`,
|
||||
)
|
||||
.run(obj.id, type, worldId || null, clockIso, locationId);
|
||||
.run(obj.id, type, worldId || null, clockIso, locationId, aliasesJson);
|
||||
|
||||
// Get current attributes from db to delete the ones that are no longer present
|
||||
const existingAttrs = this.db
|
||||
@@ -169,16 +180,22 @@ export class SQLiteRepository {
|
||||
const objRow = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT type, location_id FROM objects WHERE id = ?
|
||||
SELECT type, location_id, aliases_json FROM objects WHERE id = ?
|
||||
`,
|
||||
)
|
||||
.get(id) as { type: string; location_id: string | null } | undefined;
|
||||
.get(id) as { type: string; location_id: string | null; aliases_json: string | null } | undefined;
|
||||
|
||||
if (!objRow || objRow.type !== "entity") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entity = new Entity(id, objRow.location_id);
|
||||
if (objRow.aliases_json) {
|
||||
const entries = JSON.parse(objRow.aliases_json) as [string, string][];
|
||||
for (const [k, v] of entries) {
|
||||
entity.aliases.set(k, v);
|
||||
}
|
||||
}
|
||||
this.reconstituteAttributes(entity);
|
||||
return entity;
|
||||
}
|
||||
@@ -204,13 +221,19 @@ export class SQLiteRepository {
|
||||
const entityRows = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, location_id FROM objects WHERE type = 'entity' AND world_id = ?
|
||||
SELECT id, location_id, aliases_json FROM objects WHERE type = 'entity' AND world_id = ?
|
||||
`,
|
||||
)
|
||||
.all(id) as { id: string; location_id: string | null }[];
|
||||
.all(id) as { id: string; location_id: string | null; aliases_json: string | null }[];
|
||||
|
||||
for (const row of entityRows) {
|
||||
const entity = new Entity(row.id, row.location_id);
|
||||
if (row.aliases_json) {
|
||||
const entries = JSON.parse(row.aliases_json) as [string, string][];
|
||||
for (const [k, v] of entries) {
|
||||
entity.aliases.set(k, v);
|
||||
}
|
||||
}
|
||||
this.reconstituteAttributes(entity);
|
||||
worldState.addEntity(entity);
|
||||
}
|
||||
@@ -222,14 +245,20 @@ export class SQLiteRepository {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id FROM objects WHERE type = 'entity'
|
||||
SELECT id, aliases_json FROM objects WHERE type = 'entity'
|
||||
`,
|
||||
)
|
||||
.all() as { id: string }[];
|
||||
.all() as { id: string; aliases_json: string | null }[];
|
||||
|
||||
const entities: Entity[] = [];
|
||||
for (const row of rows) {
|
||||
const entity = new Entity(row.id);
|
||||
if (row.aliases_json) {
|
||||
const entries = JSON.parse(row.aliases_json) as [string, string][];
|
||||
for (const [k, v] of entries) {
|
||||
entity.aliases.set(k, v);
|
||||
}
|
||||
}
|
||||
this.reconstituteAttributes(entity);
|
||||
entities.push(entity);
|
||||
}
|
||||
|
||||
@@ -171,6 +171,26 @@ describe("SQLiteRepository Unit Tests (Tier 1)", () => {
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test("Save and load entity aliases", () => {
|
||||
const db = new Database(":memory:");
|
||||
const repo = new SQLiteRepository(db);
|
||||
|
||||
const world = new WorldState("world-xyz");
|
||||
const alice = new Entity("alice");
|
||||
alice.aliases.set("bob", "the hooded man");
|
||||
alice.aliases.set("charlie", "the baker");
|
||||
world.addEntity(alice);
|
||||
|
||||
repo.saveWorldState(world);
|
||||
|
||||
const loadedWorld = repo.loadWorldState("world-xyz")!;
|
||||
const loadedAlice = loadedWorld.getEntity("alice")!;
|
||||
expect(loadedAlice.aliases.get("bob")).toBe("the hooded man");
|
||||
expect(loadedAlice.aliases.get("charlie")).toBe("the baker");
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Serializer & serializeObjectiveWorldState Unit Tests (Tier 1)", () => {
|
||||
|
||||
@@ -20,6 +20,12 @@ export class IntentDecoder {
|
||||
narrativeProse: string,
|
||||
): Promise<IntentSequence> {
|
||||
const entityIds = Array.from(worldState.entities.keys());
|
||||
const actor = worldState.getEntity(actorId);
|
||||
|
||||
const aliasEntries = actor ? Array.from(actor.aliases.entries()) : [];
|
||||
const aliasContext = aliasEntries.length > 0
|
||||
? aliasEntries.map(([targetId, alias]) => `- "${alias}" refers to entity ID: "${targetId}"`).join("\n")
|
||||
: "(No known aliases)";
|
||||
|
||||
const systemPrompt = `
|
||||
You are the Intent Decoder for a narrative simulation engine.
|
||||
@@ -32,7 +38,7 @@ For each intent you must:
|
||||
2. Extract the original text fragment from the prose that corresponds to this intent.
|
||||
3. Write a concise, structured description of the intent (what is being done or said). Include as much detail about the action as possible that was extracted from the narrative prose. Do not make up qualities.
|
||||
4. Identify the actorId (the entity performing the intent — this will always be "${actorId}").
|
||||
5. Identify targetIds — the entity IDs of the receiving parties. Use only IDs from the known entities list. If no specific target, use an empty array.
|
||||
5. Identify targetIds — the entity IDs of the receiving parties. Use the "KNOWN ENTITY IDS" and "ACTOR ALIASES" mapping to resolve any subjective names, descriptions, or nicknames used in the prose to their correct system entity IDs. If no specific target, use an empty array.
|
||||
|
||||
Rules:
|
||||
- Preserve the chronological order of intents as they appear in the prose.
|
||||
@@ -46,6 +52,10 @@ Rules:
|
||||
=== KNOWN ENTITY IDS ===
|
||||
${entityIds.length > 0 ? entityIds.join(", ") : "(No entities)"}
|
||||
|
||||
=== ACTOR ALIASES ===
|
||||
The actor refers to other entities using these subjective names/aliases:
|
||||
${aliasContext}
|
||||
|
||||
=== WORLD STATE ===
|
||||
${serializeObjectiveWorldState(worldState)}
|
||||
|
||||
|
||||
@@ -5,5 +5,10 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@omnia/core": "workspace:*",
|
||||
"@omnia/intent": "workspace:*",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
161
packages/memory/src/buffer.ts
Normal file
161
packages/memory/src/buffer.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { Entity } from "@omnia/core";
|
||||
import { Intent } from "@omnia/intent";
|
||||
|
||||
export interface BufferEntry {
|
||||
id: string;
|
||||
ownerId: string; // Whose subjective memory buffer this lives in
|
||||
timestamp: string; // WorldClock.get().toISOString() at write time
|
||||
locationId: string | null; // Actor's location when this happened
|
||||
|
||||
intent: Intent; // The actual dialogue/action intent, reused as-is
|
||||
outcome?: {
|
||||
// Present only for "action" intents processed by the Architect
|
||||
isValid: boolean;
|
||||
reason: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAlias(viewer: Entity, targetId: string): string {
|
||||
if (targetId === viewer.id) return "you";
|
||||
return viewer.aliases.get(targetId) ?? "an unfamiliar figure";
|
||||
}
|
||||
|
||||
export function serializeSubjectiveBufferEntry(
|
||||
entry: BufferEntry,
|
||||
viewer: Entity,
|
||||
): string {
|
||||
const dateObj = new Date(entry.timestamp);
|
||||
// Ensure a deterministic timezone/format for testing and model inputs:
|
||||
const timeStr = dateObj.toLocaleTimeString("en-US", {
|
||||
hour12: true,
|
||||
timeZone: "UTC",
|
||||
});
|
||||
|
||||
const actorAlias = resolveAlias(viewer, entry.intent.actorId);
|
||||
|
||||
const targetAliases = entry.intent.targetIds.map((tid) =>
|
||||
resolveAlias(viewer, tid),
|
||||
);
|
||||
|
||||
let details: string;
|
||||
if (entry.intent.type === "dialogue") {
|
||||
details = `spoke to ${targetAliases.join(", ") || "someone"}: "${entry.intent.description}"`;
|
||||
} else {
|
||||
details = `${entry.intent.description}`;
|
||||
if (entry.outcome) {
|
||||
details += ` (Outcome: ${entry.outcome.isValid ? "Succeeded" : `Failed - ${entry.outcome.reason}`})`;
|
||||
}
|
||||
}
|
||||
|
||||
return `[${timeStr}] ${actorAlias} ${details}`;
|
||||
}
|
||||
|
||||
export class BufferRepository {
|
||||
constructor(private db: Database.Database) {
|
||||
// Enable foreign keys for cascading deletes
|
||||
this.db.exec("PRAGMA foreign_keys = ON;");
|
||||
this.initializeSchema();
|
||||
}
|
||||
|
||||
private initializeSchema(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS buffer_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
location_id TEXT,
|
||||
intent_json TEXT NOT NULL,
|
||||
outcome_json TEXT,
|
||||
FOREIGN KEY (owner_id) REFERENCES objects(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
save(entry: BufferEntry): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO buffer_entries (id, owner_id, timestamp, location_id, intent_json, outcome_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
owner_id = excluded.owner_id,
|
||||
timestamp = excluded.timestamp,
|
||||
location_id = excluded.location_id,
|
||||
intent_json = excluded.intent_json,
|
||||
outcome_json = excluded.outcome_json
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
entry.id,
|
||||
entry.ownerId,
|
||||
entry.timestamp,
|
||||
entry.locationId,
|
||||
JSON.stringify(entry.intent),
|
||||
entry.outcome ? JSON.stringify(entry.outcome) : null,
|
||||
);
|
||||
}
|
||||
|
||||
load(id: string): BufferEntry | null {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, owner_id, timestamp, location_id, intent_json, outcome_json
|
||||
FROM buffer_entries WHERE id = ?
|
||||
`,
|
||||
)
|
||||
.get(id) as
|
||||
| {
|
||||
id: string;
|
||||
owner_id: string;
|
||||
timestamp: string;
|
||||
location_id: string | null;
|
||||
intent_json: string;
|
||||
outcome_json: string | null;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
ownerId: row.owner_id,
|
||||
timestamp: row.timestamp,
|
||||
locationId: row.location_id,
|
||||
intent: JSON.parse(row.intent_json),
|
||||
outcome: row.outcome_json ? JSON.parse(row.outcome_json) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
listForOwner(ownerId: string): BufferEntry[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, owner_id, timestamp, location_id, intent_json, outcome_json
|
||||
FROM buffer_entries WHERE owner_id = ?
|
||||
ORDER BY timestamp ASC
|
||||
`,
|
||||
)
|
||||
.all(ownerId) as {
|
||||
id: string;
|
||||
owner_id: string;
|
||||
timestamp: string;
|
||||
location_id: string | null;
|
||||
intent_json: string;
|
||||
outcome_json: string | null;
|
||||
}[];
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
ownerId: row.owner_id,
|
||||
timestamp: row.timestamp,
|
||||
locationId: row.location_id,
|
||||
intent: JSON.parse(row.intent_json),
|
||||
outcome: row.outcome_json ? JSON.parse(row.outcome_json) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
this.db.prepare(`DELETE FROM buffer_entries WHERE id = ?`).run(id);
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export {};
|
||||
export * from "./buffer.js";
|
||||
|
||||
200
packages/memory/tests/memory.test.ts
Normal file
200
packages/memory/tests/memory.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { Entity, SQLiteRepository } from "@omnia/core";
|
||||
import { Intent } from "@omnia/intent";
|
||||
import {
|
||||
BufferEntry,
|
||||
BufferRepository,
|
||||
serializeSubjectiveBufferEntry,
|
||||
resolveAlias,
|
||||
} from "@omnia/memory";
|
||||
|
||||
describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
test("resolveAlias correctly handles self and fallbacks", () => {
|
||||
const viewer = new Entity("alice");
|
||||
viewer.aliases.set("bob", "the hooded figure");
|
||||
|
||||
expect(resolveAlias(viewer, "alice")).toBe("you");
|
||||
expect(resolveAlias(viewer, "bob")).toBe("the hooded figure");
|
||||
expect(resolveAlias(viewer, "charlie")).toBe("an unfamiliar figure");
|
||||
});
|
||||
|
||||
test("serializes dialogue intent substituting target/actor aliases", () => {
|
||||
const viewer = new Entity("alice");
|
||||
viewer.aliases.set("bob", "the hooded figure");
|
||||
viewer.aliases.set("charlie", "the bartender");
|
||||
|
||||
const entry: BufferEntry = {
|
||||
id: "entry-1",
|
||||
ownerId: "alice",
|
||||
timestamp: "2026-07-07T12:00:00.000Z",
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: "dialogue",
|
||||
originalText: '"Hello there," Bob said to Charlie.',
|
||||
description: "Bob greets Charlie",
|
||||
actorId: "bob",
|
||||
targetIds: ["charlie"],
|
||||
},
|
||||
};
|
||||
|
||||
const result = serializeSubjectiveBufferEntry(entry, viewer);
|
||||
expect(result).toBe('[12:00:00 PM] the hooded figure spoke to the bartender: "Bob greets Charlie"');
|
||||
});
|
||||
|
||||
test("serializes action intent with outcome details", () => {
|
||||
const viewer = new Entity("alice");
|
||||
viewer.aliases.set("bob", "the hooded figure");
|
||||
|
||||
const entry: BufferEntry = {
|
||||
id: "entry-2",
|
||||
ownerId: "alice",
|
||||
timestamp: "2026-07-07T12:05:00.000Z",
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: "action",
|
||||
originalText: "Bob tried to break the latch.",
|
||||
description: "Bob attempts to break the lock latch",
|
||||
actorId: "bob",
|
||||
targetIds: [],
|
||||
},
|
||||
outcome: {
|
||||
isValid: false,
|
||||
reason: "The lock is made of reinforced steel.",
|
||||
},
|
||||
};
|
||||
|
||||
const result = serializeSubjectiveBufferEntry(entry, viewer);
|
||||
expect(result).toBe('[12:05:00 PM] the hooded figure Bob attempts to break the lock latch (Outcome: Failed - The lock is made of reinforced steel.)');
|
||||
});
|
||||
|
||||
test("serializes self-reference and unfamiliar actors", () => {
|
||||
const viewer = new Entity("alice");
|
||||
|
||||
const entrySelf: BufferEntry = {
|
||||
id: "entry-self",
|
||||
ownerId: "alice",
|
||||
timestamp: "2026-07-07T12:10:00.000Z",
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: "action",
|
||||
originalText: "I opened the window.",
|
||||
description: "open the window",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
},
|
||||
};
|
||||
|
||||
const resultSelf = serializeSubjectiveBufferEntry(entrySelf, viewer);
|
||||
expect(resultSelf).toBe("[12:10:00 PM] you open the window");
|
||||
|
||||
const entryUnfamiliar: BufferEntry = {
|
||||
id: "entry-unfamiliar",
|
||||
ownerId: "alice",
|
||||
timestamp: "2026-07-07T12:15:00.000Z",
|
||||
locationId: "room-1",
|
||||
intent: {
|
||||
type: "action",
|
||||
originalText: "Someone knocked.",
|
||||
description: "knock on the door",
|
||||
actorId: "stranger-1",
|
||||
targetIds: [],
|
||||
},
|
||||
};
|
||||
|
||||
const resultUnfamiliar = serializeSubjectiveBufferEntry(entryUnfamiliar, viewer);
|
||||
expect(resultUnfamiliar).toBe("[12:15:00 PM] an unfamiliar figure knock on the door");
|
||||
});
|
||||
});
|
||||
|
||||
describe("BufferRepository Persistence Tests (Tier 1)", () => {
|
||||
test("saves, loads, lists, and deletes buffer entries in SQLite database", () => {
|
||||
const db = new Database(":memory:");
|
||||
|
||||
// We need SQLiteRepository to initialize the objects table because buffer_entries depends on objects(id) via FK
|
||||
const coreRepo = new SQLiteRepository(db);
|
||||
const repo = new BufferRepository(db);
|
||||
|
||||
// Add owner entity to objects table
|
||||
const alice = new Entity("alice");
|
||||
coreRepo.saveEntity(alice);
|
||||
|
||||
const intent: Intent = {
|
||||
type: "action",
|
||||
originalText: "Alice picked up a stick.",
|
||||
description: "Alice gathers a stick",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
};
|
||||
|
||||
const entry: BufferEntry = {
|
||||
id: "buf-1",
|
||||
ownerId: "alice",
|
||||
timestamp: "2026-07-07T14:30:00.000Z",
|
||||
locationId: "forest",
|
||||
intent,
|
||||
outcome: {
|
||||
isValid: true,
|
||||
reason: "There are many dry sticks on the ground.",
|
||||
},
|
||||
};
|
||||
|
||||
// Save
|
||||
repo.save(entry);
|
||||
|
||||
// Load
|
||||
const loaded = repo.load("buf-1");
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.id).toBe("buf-1");
|
||||
expect(loaded!.ownerId).toBe("alice");
|
||||
expect(loaded!.timestamp).toBe("2026-07-07T14:30:00.000Z");
|
||||
expect(loaded!.locationId).toBe("forest");
|
||||
expect(loaded!.intent).toEqual(intent);
|
||||
expect(loaded!.outcome).toEqual(entry.outcome);
|
||||
|
||||
// List
|
||||
const list = repo.listForOwner("alice");
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].id).toBe("buf-1");
|
||||
|
||||
// Delete
|
||||
repo.delete("buf-1");
|
||||
expect(repo.load("buf-1")).toBeNull();
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test("cascades delete of buffer entries when owner entity is deleted", () => {
|
||||
const db = new Database(":memory:");
|
||||
const coreRepo = new SQLiteRepository(db);
|
||||
const repo = new BufferRepository(db);
|
||||
|
||||
const alice = new Entity("alice");
|
||||
coreRepo.saveEntity(alice);
|
||||
|
||||
const entry: BufferEntry = {
|
||||
id: "buf-1",
|
||||
ownerId: "alice",
|
||||
timestamp: "2026-07-07T14:30:00.000Z",
|
||||
locationId: "forest",
|
||||
intent: {
|
||||
type: "action",
|
||||
originalText: "Alice sneezed.",
|
||||
description: "Alice sneezes",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
},
|
||||
};
|
||||
|
||||
repo.save(entry);
|
||||
expect(repo.load("buf-1")).not.toBeNull();
|
||||
|
||||
// Delete owner entity from core repository
|
||||
coreRepo.delete("alice");
|
||||
|
||||
// Verify buffer entry was cascade deleted
|
||||
expect(repo.load("buf-1")).toBeNull();
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -5,5 +5,8 @@
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": []
|
||||
"references": [
|
||||
{ "path": "../core" },
|
||||
{ "path": "../intent" }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user