fix: align compaction cuts and native usage accounting
This commit is contained in:
+62
-51
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* COMPACTION (owner round 14, 2026-10-05): the context-fitting mechanic,
|
||||
* mirroring pi's design (docs aside, this is OUR implementation — pi's
|
||||
* compaction lives in the coding-agent package we don't depend on).
|
||||
* with a bounded local port of Pi coding-agent 1.4.0 cutoff selection.
|
||||
* No coding-agent dependency. See docs/compaction-accounting.md for the
|
||||
* behavior boundary, native golden fixtures, and upstream MIT notice.
|
||||
*
|
||||
* THE CONTRACT: full history stays persisted in the record — extraction
|
||||
* and the user's transcript keep reading everything. A compaction marker
|
||||
@@ -38,7 +39,7 @@ import { COMPACTED_HISTORY_HEADER } from "./contracts/chat.ts";
|
||||
import type { LhMessage, LhMarkerMessage } from "./native-messages.ts";
|
||||
import { newMessageId } from "./native-messages.ts";
|
||||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
|
||||
import type { AssistantMessage, ToolResultMessage, SystemMessage } from "@earendil-works/pi-ai";
|
||||
|
||||
// ─── constants (the tunables, owner-approved) ────────────────────────────────
|
||||
|
||||
@@ -77,9 +78,8 @@ export function calculateContextTokens(usage: {
|
||||
);
|
||||
}
|
||||
|
||||
/** Estimate a message's context tokens from its WIRE-relevant text only
|
||||
* (pi's estimator): text + thinking + toolCall name/args for assistants,
|
||||
* content text for user/toolResult/custom. NEVER JSON.stringify of the
|
||||
/** Pi 1.4.0's heuristic: text + thinking + toolCall name/args for
|
||||
* assistants, content text plus a fixed image allowance for other roles. NEVER JSON.stringify of the
|
||||
* whole message — record scaffolding (ids, usage, responseId, app-side
|
||||
* details, escaped quotes/backslashes) inflates ~3x (measured on the
|
||||
* 2026-10-05 relational-databases incident: 84k estimated vs ~30k real). */
|
||||
@@ -100,23 +100,32 @@ export function estimateMessageTokens(message: LhMessage | AgentMessage): number
|
||||
}
|
||||
} else {
|
||||
chars += textCharsOfContent(content);
|
||||
if (message.role === "system") {
|
||||
const system = message as SystemMessage;
|
||||
for (const section of Object.values(system.sections ?? {})) chars += section?.length ?? 0;
|
||||
if (system.toolsAdded) chars += JSON.stringify(system.toolsAdded).length;
|
||||
}
|
||||
}
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
|
||||
// Pi coding-agent 1.4.0 estimateTokens: fixed image allowance, NOT a
|
||||
// measurement of encrypted reasoning or actual provider vision tokens.
|
||||
const ESTIMATED_IMAGE_CHARS = 4_800;
|
||||
function textCharsOfContent(content: unknown): number {
|
||||
if (typeof content === "string") return content.length;
|
||||
if (!Array.isArray(content)) return 0;
|
||||
let chars = 0;
|
||||
for (const block of content as Array<string | { type?: string; text?: string }>) {
|
||||
if (typeof block === "string") chars += block.length;
|
||||
else if (block?.type === "text" || "text" in block) chars += block.text?.length ?? 0;
|
||||
else if (block?.type === "image") chars += ESTIMATED_IMAGE_CHARS;
|
||||
else if (block?.type === "text") chars += block.text?.length ?? 0;
|
||||
}
|
||||
return chars;
|
||||
}
|
||||
|
||||
/** The newest assistant message carrying VALID provider-reported usage
|
||||
* (pi's rule: skip aborted messages and all-zero usage — they carry no
|
||||
* (pi's rule: skip aborted/error messages and all-zero usage — they carry no
|
||||
* truth about the wire). The scan STOPS at the newest compaction
|
||||
* marker: usage captured before a compaction described the pre-cut
|
||||
* context — the next request carries the summary + kept tail instead,
|
||||
@@ -131,7 +140,7 @@ export function lastAssistantUsageIndex(
|
||||
if ((m as { role?: string }).role === "custom" && (m as LhMarkerMessage).customType === "compaction") return null;
|
||||
if ((m as { role?: string }).role !== "assistant") continue;
|
||||
const a = m as AssistantMessage & { stopReason?: string };
|
||||
if (a.stopReason === "aborted") continue;
|
||||
if (a.stopReason === "aborted" || a.stopReason === "error") continue;
|
||||
if (calculateContextTokens((a.usage ?? {}) as Parameters<typeof calculateContextTokens>[0]) === 0) continue;
|
||||
return i;
|
||||
}
|
||||
@@ -180,51 +189,53 @@ export function estimateTokens(messages: readonly (LhMessage | AgentMessage)[]):
|
||||
|
||||
// ─── the cut walk ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Find the compaction cut index: walk BACKWARDS from the end accumulating
|
||||
* estimates until ~keepRecentTokens are kept; the cut lands there.
|
||||
*
|
||||
* HARD INVARIANT — a tool result is never separated from its call: if the
|
||||
* walk stops on a toolResult, the KEPT region extends to the assistant
|
||||
* message carrying its call (keeping the result means keeping the call).
|
||||
* This is the provider protocol's shape law: dropping a call while
|
||||
* keeping its result strands an orphan the provider rejects. A PENDING
|
||||
* question/problem call (no result yet — the pose→answer window) sits at
|
||||
* the transcript end and is always kept: the walk never crosses it.
|
||||
*
|
||||
* Returns 0 when everything must be summarized (the whole history is the
|
||||
* span — valid), or messages.length when there is nothing meaningful to
|
||||
* cut (kept budget covers everything — caller checks before compacting).
|
||||
*/
|
||||
export function compactionCutIndex(messages: readonly LhMessage[], keepRecentTokens = KEEP_RECENT_TOKENS): number {
|
||||
let kept = 0;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (kept >= keepRecentTokens) {
|
||||
// The cut candidate: extend past orphaned tool results to the
|
||||
// assistant message carrying their calls.
|
||||
let cut = i + 1;
|
||||
while (cut < messages.length && (messages[cut] as { role?: string }).role === "toolResult") {
|
||||
const callIndex = assistantIndexOfCall(messages, (messages[cut] as ToolResultMessage).toolCallId, cut);
|
||||
if (callIndex === -1) break; // no call found (defensive): stop extending
|
||||
cut = callIndex;
|
||||
}
|
||||
return cut;
|
||||
}
|
||||
kept += estimateMessageTokens(messages[i]!);
|
||||
}
|
||||
return 0; // the whole history fits inside the kept budget — summarize all
|
||||
/** Model-visible entry adapter for the local Pi 1.4.0 cutoff port.
|
||||
* Compaction entries are boundaries, not ordinary cut candidates; UI-only
|
||||
* markers have no context, just like native metadata entries. */
|
||||
function cutMessage(message: LhMessage): LhMessage | null {
|
||||
if (message.role !== "custom") return message;
|
||||
if (["compaction", "plan-block", "problem-dismissed"].includes(message.customType)) return null;
|
||||
return message;
|
||||
}
|
||||
|
||||
/** The newest assistant message at or before `before` whose content
|
||||
* carries the tool call `toolCallId` — the call a result answers. */
|
||||
function assistantIndexOfCall(messages: readonly LhMessage[], toolCallId: string, before: number): number {
|
||||
for (let i = before - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
if ((m as { role?: string }).role !== "assistant") continue;
|
||||
const assistant = m as AssistantMessage;
|
||||
if (assistant.content.some((b) => b.type === "toolCall" && b.id === toolCallId)) return i;
|
||||
/** Local port of pi-coding-agent 1.4.0 findCutPoint (MIT).
|
||||
* Walk backwards to >= budget, then choose the nearest valid cut AT OR
|
||||
* AFTER that entry. If oversized trailing results exhaust the budget,
|
||||
* retain their preceding assistant call, not the entire session.
|
||||
* No encrypted-signature sizing and no exact retained-token guarantee.
|
||||
* Returns the first kept record index; a zero/no-progress cut drops nothing.
|
||||
* Keep this and estimateMessageTokens aligned deliberately when upgrading Pi.
|
||||
*/
|
||||
export function compactionCutIndex(messages: readonly LhMessage[], keepRecentTokens = KEEP_RECENT_TOKENS): number {
|
||||
const previous = activeCompactionOf(messages);
|
||||
const previousKept = previous ? keptIndexFor(messages, previous) : -1;
|
||||
const start = Math.max(0, previousKept);
|
||||
const points: number[] = [];
|
||||
for (let i = start; i < messages.length; i++) {
|
||||
const message = cutMessage(messages[i]!);
|
||||
if (message && (message.role === "user" || message.role === "assistant" || message.role === "custom")) points.push(i);
|
||||
}
|
||||
return -1;
|
||||
if (!points.length) return start;
|
||||
let cut = points[0]!;
|
||||
let accumulated = 0;
|
||||
for (let i = messages.length - 1; i >= start; i--) {
|
||||
const message = cutMessage(messages[i]!);
|
||||
const tokens = message ? estimateMessageTokens(message) : 0;
|
||||
if (tokens === 0) continue;
|
||||
accumulated += tokens;
|
||||
if (accumulated >= keepRecentTokens) {
|
||||
cut = points.find((candidate) => candidate >= i) ?? points.at(-1)!;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Native entries preserve adjacent non-context metadata, but never walk
|
||||
// backwards across a previous compaction entry.
|
||||
while (cut > start) {
|
||||
const before = messages[cut - 1]!;
|
||||
if ((before.role === "custom" && before.customType === "compaction") || cutMessage(before)) break;
|
||||
cut--;
|
||||
}
|
||||
return cut;
|
||||
}
|
||||
|
||||
// ─── the marker ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
||||
import type { LhMessage } from "./native-messages.ts";
|
||||
|
||||
/** Latest completed conversation measurement, never a request-size estimate.
|
||||
* Ordered compaction markers invalidate every earlier reading. Live callers
|
||||
* must exclude assistant slots that have not received message_end.
|
||||
* Reasoning is already inside output; totalTokens is deliberately not used.
|
||||
*/
|
||||
export function measuredContextTokens(
|
||||
messages: readonly LhMessage[],
|
||||
incompleteIds: ReadonlySet<string> = new Set(),
|
||||
): number | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i]!;
|
||||
if (message.role === "custom" && message.customType === "compaction") return null;
|
||||
if (message.role !== "assistant" || incompleteIds.has(message.id)) continue;
|
||||
const assistant = message as AssistantMessage;
|
||||
if (!["stop", "length", "toolUse"].includes(assistant.stopReason)) continue;
|
||||
const usage = assistant.usage;
|
||||
if (!usage) continue;
|
||||
const buckets = [usage.input, usage.output, usage.cacheRead, usage.cacheWrite];
|
||||
if (buckets.some((value) => !Number.isFinite(value) || value < 0)) continue;
|
||||
const tokens = buckets.reduce((sum, value) => sum + value, 0);
|
||||
if (tokens > 0) return tokens;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -87,8 +87,8 @@ export interface ChatSettings {
|
||||
* billed (CallUsage per agent step). Steps, not a sum: cost and context
|
||||
* folds happen at read time (core/prices.ts). */
|
||||
export interface ExtractionStageUsage {
|
||||
/** The model that ran this stage, provider-qualified ("provider:id" —
|
||||
* model slots wave, 2026-09-20; matches PRICES' uniform keys). */
|
||||
/** The model that ran this stage, provider-qualified ("provider:id")
|
||||
* for key-independent Pi catalog lookup at read time. */
|
||||
model: string;
|
||||
steps: CallUsage[];
|
||||
}
|
||||
@@ -118,7 +118,7 @@ export interface ExtractionRunUsage {
|
||||
* flattened to the fields we persist (undefined = provider didn't report;
|
||||
* renderers show absent, never zero). */
|
||||
export interface CallUsage {
|
||||
/** Total input (prompt) tokens, cached portion included. */
|
||||
/** Pi-normalized fresh input tokens; cache buckets are disjoint. */
|
||||
inputTokens: number | undefined;
|
||||
outputTokens: number | undefined;
|
||||
/** Cached input tokens READ (billed at the cache-read rate, if any). */
|
||||
@@ -126,6 +126,9 @@ export interface CallUsage {
|
||||
/** Input tokens written to the cache this call (OpenRouter bills writes
|
||||
* for some providers; undefined = not reported/not billed). */
|
||||
cacheWriteTokens: number | undefined;
|
||||
/** Subset of cacheWriteTokens written with one-hour retention. Missing
|
||||
* on older logs; new native captures explicitly record zero if absent. */
|
||||
cacheWrite1hTokens?: number;
|
||||
/** The reasoning portion of the output tokens. */
|
||||
reasoningTokens: number | undefined;
|
||||
}
|
||||
@@ -144,8 +147,8 @@ export interface TurnUsage {
|
||||
* entry to the transcript (the block this turn "belongs" to). */
|
||||
messageId: string;
|
||||
at: string;
|
||||
/** The model that ran the turn, provider-qualified ("provider:id" —
|
||||
* model slots wave, 2026-09-20; matches PRICES' uniform keys). */
|
||||
/** The model that ran the turn, provider-qualified ("provider:id")
|
||||
* for key-independent Pi catalog lookup at read time. */
|
||||
model: string;
|
||||
/** One entry per agent step (model call) in this turn. */
|
||||
steps: CallUsage[];
|
||||
|
||||
+21
-10
@@ -43,7 +43,7 @@ export interface JobRecord {
|
||||
* money-channel doctrine; cost renders "—", never $0). */
|
||||
export interface JobUsage {
|
||||
/** The model that ran the sub-agent, provider-qualified ("provider:id" —
|
||||
* model slots wave, 2026-09-20; matches PRICES' uniform keys). */
|
||||
* model slots wave, 2026-09-20; matches Pi catalog lookup keys). */
|
||||
model: string;
|
||||
/** Running totals summed over the run's model calls so far. */
|
||||
inputTokens: number | undefined;
|
||||
@@ -51,6 +51,10 @@ export interface JobUsage {
|
||||
cacheReadTokens: number | undefined;
|
||||
cacheWriteTokens: number | undefined;
|
||||
reasoningTokens: number | undefined;
|
||||
cacheWrite1hTokens?: number;
|
||||
/** Individual calls for current-catalog per-call tier pricing. Absent on
|
||||
* older jobs; never backfill them from cumulative totals. */
|
||||
calls?: CallUsage[];
|
||||
/** How many model calls the run has made (each bills separately). */
|
||||
steps: number;
|
||||
/** The MOST RECENT model call's accounting (2026-09 dock contract): the
|
||||
@@ -67,7 +71,7 @@ export interface JobUsage {
|
||||
* object with these fields folds, including the real thing and test fakes). */
|
||||
export interface UsageStepLike {
|
||||
inputTokens?: number | undefined;
|
||||
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } | undefined;
|
||||
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number; cacheWrite1hTokens?: number } | undefined;
|
||||
outputTokens?: number | undefined;
|
||||
outputTokenDetails?: { reasoningTokens?: number } | undefined;
|
||||
}
|
||||
@@ -80,12 +84,13 @@ export function piUsageToStep(usage: {
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
cacheWrite1h?: number;
|
||||
reasoning?: number;
|
||||
}): UsageStepLike {
|
||||
return {
|
||||
inputTokens: usage.input,
|
||||
outputTokens: usage.output,
|
||||
inputTokenDetails: { cacheReadTokens: usage.cacheRead, cacheWriteTokens: usage.cacheWrite },
|
||||
inputTokenDetails: { cacheReadTokens: usage.cacheRead, cacheWriteTokens: usage.cacheWrite, cacheWrite1hTokens: usage.cacheWrite1h ?? 0 },
|
||||
outputTokenDetails: usage.reasoning !== undefined ? { reasoningTokens: usage.reasoning } : undefined,
|
||||
};
|
||||
}
|
||||
@@ -101,6 +106,14 @@ export function accumulateJobUsage(
|
||||
): JobUsage {
|
||||
const add = (a: number | undefined, b: number | undefined): number | undefined =>
|
||||
a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0);
|
||||
const call: CallUsage = {
|
||||
inputTokens: step.inputTokens,
|
||||
outputTokens: step.outputTokens,
|
||||
cacheReadTokens: step.inputTokenDetails?.cacheReadTokens,
|
||||
cacheWriteTokens: step.inputTokenDetails?.cacheWriteTokens,
|
||||
cacheWrite1hTokens: step.inputTokenDetails?.cacheWrite1hTokens,
|
||||
reasoningTokens: step.outputTokenDetails?.reasoningTokens,
|
||||
};
|
||||
return {
|
||||
model,
|
||||
inputTokens: add(total?.inputTokens, step.inputTokens),
|
||||
@@ -108,14 +121,12 @@ export function accumulateJobUsage(
|
||||
cacheReadTokens: add(total?.cacheReadTokens, step.inputTokenDetails?.cacheReadTokens),
|
||||
cacheWriteTokens: add(total?.cacheWriteTokens, step.inputTokenDetails?.cacheWriteTokens),
|
||||
reasoningTokens: add(total?.reasoningTokens, step.outputTokenDetails?.reasoningTokens),
|
||||
cacheWrite1hTokens: ((total?.cacheWriteTokens ?? 0) > 0 && total?.cacheWrite1hTokens === undefined) ||
|
||||
((call.cacheWriteTokens ?? 0) > 0 && call.cacheWrite1hTokens === undefined)
|
||||
? undefined : add(total?.cacheWrite1hTokens, call.cacheWrite1hTokens),
|
||||
steps: (total?.steps ?? 0) + 1,
|
||||
lastStep: {
|
||||
inputTokens: step.inputTokens,
|
||||
outputTokens: step.outputTokens,
|
||||
cacheReadTokens: step.inputTokenDetails?.cacheReadTokens,
|
||||
cacheWriteTokens: step.inputTokenDetails?.cacheWriteTokens,
|
||||
reasoningTokens: step.outputTokenDetails?.reasoningTokens,
|
||||
},
|
||||
...(!total || total.calls ? { calls: [...(total?.calls ?? []), call] } : {}),
|
||||
lastStep: call,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { BackgroundModelRole, ModelSlotName } from "../settings.ts";
|
||||
|
||||
/** One picker row: everything the UI shows about a model, projected from
|
||||
* pi-ai's catalog. Costs are USD per 1M tokens (the same catalog, the same
|
||||
* units core/prices.ts's generated table carries). */
|
||||
* units as Pi's server-side pricing calculator). */
|
||||
export interface ModelCatalogEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* the same ruling as wave 7's stages.embedding.)
|
||||
*
|
||||
* MONEY CHANNEL LAW: dollars are INFERRED here at read time via
|
||||
* core/prices.ts — never stored anywhere. An unpriced model contributes
|
||||
* an injected server-side Pi pricer — never stored anywhere. An unpriced model contributes
|
||||
* ZERO dollars but is counted in `unpricedRuns` so the UI can render
|
||||
* "— unpriced" honestly, never a silent zero. Tokens always sum.
|
||||
*
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import type { CallUsage, EmissionStats, ExtractionRunUsage } from "./contracts.ts";
|
||||
import { callCost, sumSteps, isChatGPTPlan } from "./prices.ts";
|
||||
import { sumSteps, isChatGPTPlan, type CallPricer } from "./prices.ts";
|
||||
|
||||
/** One pipeline stage's cumulative cost across every run. */
|
||||
export interface StageCost {
|
||||
@@ -26,7 +26,7 @@ export interface StageCost {
|
||||
subscriptionRuns?: number;
|
||||
/** Inferred USD summed over PRICED runs (partial when unpricedRuns > 0). */
|
||||
dollars: number;
|
||||
/** Stage-runs whose model is missing from PRICES (render "—", never zero). */
|
||||
/** Stage-runs whose model is missing from Pi's catalog (render "—", never zero). */
|
||||
unpricedRuns: number;
|
||||
/** Runs that billed this stage. */
|
||||
runs: number;
|
||||
@@ -92,11 +92,11 @@ const emptyStage = (): StageCost => ({ dollars: 0, unpricedRuns: 0, runs: 0, tok
|
||||
|
||||
/** One stage's dollars over its steps; null when the model is unpriced
|
||||
* (all-or-nothing: a stage runs one model). */
|
||||
function stageDollars(model: string, steps: readonly CallUsage[]): number | null {
|
||||
if (isChatGPTPlan(model)) return null;
|
||||
function stageDollars(model: string, steps: readonly CallUsage[], price: CallPricer): number | null {
|
||||
if (isChatGPTPlan(model) || steps.length === 0) return null;
|
||||
let total = 0;
|
||||
for (const step of steps) {
|
||||
const c = callCost(step, model);
|
||||
const c = price(step, model);
|
||||
if (c === null) return null;
|
||||
total += c;
|
||||
}
|
||||
@@ -115,7 +115,7 @@ function mergeTokens(into: CallUsage, steps: readonly CallUsage[]): void {
|
||||
|
||||
/** Fold every usage channel into the pill's payload. Pure: same input,
|
||||
* same output — no clock, no fs, no store reads. */
|
||||
export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
|
||||
export function graphCostSummary(input: CostSummaryInput, price: CallPricer): GraphCostSummary {
|
||||
const extraction = emptyStage();
|
||||
const audit = emptyStage();
|
||||
const runs: RecentRun[] = [];
|
||||
@@ -125,14 +125,14 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
|
||||
// Per-stage: tokens always count; dollars only when priced.
|
||||
extraction.runs++;
|
||||
mergeTokens(extraction.tokens, stages.extractor.steps);
|
||||
const extDollars = stageDollars(stages.extractor.model, stages.extractor.steps);
|
||||
const extDollars = stageDollars(stages.extractor.model, stages.extractor.steps, price);
|
||||
if (isChatGPTPlan(stages.extractor.model)) extraction.subscriptionRuns = (extraction.subscriptionRuns ?? 0) + 1;
|
||||
else if (extDollars === null) extraction.unpricedRuns++;
|
||||
else extraction.dollars += extDollars;
|
||||
|
||||
audit.runs++;
|
||||
mergeTokens(audit.tokens, stages.auditor.steps);
|
||||
const audDollars = stageDollars(stages.auditor.model, stages.auditor.steps);
|
||||
const audDollars = stageDollars(stages.auditor.model, stages.auditor.steps, price);
|
||||
if (isChatGPTPlan(stages.auditor.model)) audit.subscriptionRuns = (audit.subscriptionRuns ?? 0) + 1;
|
||||
else if (audDollars === null) audit.unpricedRuns++;
|
||||
else audit.dollars += audDollars;
|
||||
|
||||
@@ -48,6 +48,7 @@ export * from "./input-events.ts";
|
||||
export * from "./view.ts";
|
||||
export * from "./tool-parts.ts";
|
||||
export * from "./prices.ts";
|
||||
export * from "./context-usage.ts";
|
||||
export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.ts";
|
||||
export * from "./cost-summary.ts";
|
||||
export * from "./fuzzy.ts";
|
||||
|
||||
+38
-1742
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user