feat(v0.3.0): weekly update 2026-03-09 — 37 new skills (total 164)

This commit is contained in:
Guardian
2026-03-09 02:05:21 +00:00
parent 75dbaf8d76
commit b90a37f1ea
231 changed files with 87403 additions and 186 deletions
+232
View File
@@ -0,0 +1,232 @@
---
name: compaction-ui
version: 2.3.0
description: "Background memory compaction with auto-trigger, chat summary paragraph, configurable threshold, model selector, settings tab, and result storage for OpenClaw Control UI."
---
# Compaction UI v2.1.0
Memory compaction system for the OpenClaw Control UI — background execution with toast notifications, auto-trigger at configurable token thresholds, conversation summary paragraphs in compacted output, dedicated settings tab with model selector, and result history.
## Status: ✅ Active
| Component | Status |
|-----------|--------|
| Context Gauge Button | ✅ Working |
| Background Compaction (toast) | ✅ Working |
| Auto-Compaction Trigger | ✅ Working |
| Conversation Summary Paragraph | ✅ Working |
| Settings Tab (plugin-registered) | ✅ Working |
| Model Selector | ✅ Working |
| Last Result Storage + Viewer | ✅ Working |
| Settings Persistence & Reload | ✅ Working |
| Auth Hierarchy (OAuth > API > Fallback) | ✅ Working |
| Chat History Filters | ✅ Working |
| Full Background Isolation (no chat interference) | ✅ Working |
## Features
### 1. Context Gauge Button (`app-render.helpers.ts`)
A circular SVG progress ring in the chat toolbar that doubles as the manual compact trigger.
- **Placement:** After session selector dropdown in `renderChatControls()`
- **Data source:** `sessionsResult.sessions[].totalTokens` and `contextTokens` from session rows
- **Colors:** Green (<60%), Yellow (60-85%), Red (≥85%)
- **Disabled:** When utilization <20% or during active compaction
- **Tooltip:** Shows "Context: XK / YK tokens (Z%)"
### 2. Background Compaction (Toast)
On click (or auto-trigger), compaction runs in the background using the standard bottom-right toast system — **no blocking modal**. The UI remains fully interactive.
- **Running toast:** Shows "Memory Compaction" with spinner
- **Complete toast:** Shows "Compacted: XK → YK" with checkmark (auto-dismisses after 5s)
- **Error toast:** Shows failure message (auto-dismisses after 5s)
- Uses `backgroundJobToasts` system consistent with all other background processes (cron, knowledge extraction, etc.)
### 3. Auto-Compaction
After every chat response (`final` event), the UI checks token usage against the configured threshold. If exceeded:
- Background compaction triggers automatically
- Toast shows "Auto-Compacting (X%)" with the current utilization
- 5-minute debounce prevents repeated triggers
- Chat refreshes automatically after successful compaction
- Silent toast removal if nothing was compacted
### 4. Conversation Summary Paragraph
Every compaction output now begins with a `## Conversation Summary` section **before** the structured `## Goal` section. This is a 3-6 sentence natural language paragraph that:
- Describes the overall topic(s) of the conversation
- Explains the flow of discussion and key transitions
- Summarizes how the conversation progressed
- Written in past tense as a narrative overview
**Implementation:** Custom instructions are injected into the upstream compaction LLM call via `compact.ts`. The instruction is prepended to any user-provided custom instructions (from `/compact [instructions]`), so both coexist.
**Example output:**
```
## Conversation Summary
The conversation began with the user requesting improvements to the compaction
system, specifically moving from a blocking modal to background toasts. Discussion
then shifted to adding a settings tab with auto-compaction controls. After
implementing and testing those features, the user noticed settings weren't
persisting across tab navigations, which led to a fix for the module-level
state cache. Finally, the user requested adding a narrative summary paragraph
to the compaction output for better context.
## Goal
...
```
### 5. Settings Tab (Plugin-Registered)
A dedicated Compaction tab under the **Agent** navigation group, registered via the Plugin UI architecture.
**Registration:** `plugins-ui.ts``BUILTIN_UI_VIEWS` with `id: "compaction"`, `group: "agent"`, `position: 7`, `icon: "archive"`
**Cards:**
#### Auto-Compaction Card
- **Toggle:** Enable/disable automatic compaction
- **Threshold slider:** 10% to 95% (default: 60%)
- **Color-coded:** Green (<60%), Yellow (60-85%), Red (≥85%)
#### Model Card
- **Dropdown:** Select a specific model for compaction or use session default
- **Grouped by provider** with context window sizes shown
- **Warning callout** when custom model selected (API key reminder)
- **Fallback:** If custom model fails, automatically falls back to session default
#### Result Storage Card
- **Toggle:** Opt-in to save the compacted summary text for review
#### Last Compaction Card
- **Before/After:** Large styled token counts in a 2-column grid
- **Stats:** Tokens saved, percent reduction, trigger type, timestamp, session key
- **View Results:** Expandable summary viewer (when storage is enabled)
- **Clear:** Reset stored result
- **Hint:** Shows "Enable Store Results" message when storage is off
**State Management:**
- Module-level state persists across re-renders within a session
- Settings reload from backend every time the tab is visited (2-second staleness threshold)
- `resetCompactionSettingsState()` exported for tests and tab switches
### 6. Auth Hierarchy
Compaction uses `resolveApiKeyForProvider` via `getApiKeyForModel` — the same auth chain as chat:
**OAuth → API Key → Fallback**
No separate configuration needed. If OAuth (Claude Max) is configured as the primary auth profile, compaction uses it automatically. Custom model selection uses this same auth chain independently.
### 7. Result Storage
When enabled, the most recent compaction result is persisted to `{agentDir}/compaction-config.json`:
```json
{
"settings": {
"autoEnabled": true,
"autoThresholdPercent": 60,
"storeLastResult": true,
"compactionModel": "anthropic/claude-sonnet-4-6"
},
"lastResult": {
"timestamp": 1709283600000,
"trigger": "manual",
"tokensBefore": 50614,
"tokensAfter": 4766,
"tokensSaved": 45848,
"percentReduction": 91,
"sessionKey": "agent:main:main",
"summary": "## Conversation Summary\nThe conversation focused on...\n\n## Goal\n..."
}
}
```
When storage is disabled, metadata (timestamps, token counts) is still saved but the summary text is omitted.
### 8. Chat History Filters
- **NO_REPLY/HEARTBEAT_OK filtering:** Skips assistant messages with these exact texts
- **Compaction divider:** Renders labeled divider line for `__openclaw.kind === "compaction"` messages
## Architecture
### Backend RPCs
| Method | Description |
|--------|-------------|
| `sessions.compact` | Execute compaction (records result after completion) |
| `compaction.getSettings` | Read settings from config file |
| `compaction.saveSettings` | Update settings (threshold auto-clamped 10-95%) |
| `compaction.getLastResult` | Get stored last compaction result |
| `compaction.clearLastResult` | Clear stored result |
### Files Modified
| File | Purpose |
|------|---------|
| `src/gateway/server-methods/compaction.ts` | Settings RPCs, config I/O, result recording |
| `src/gateway/server-methods/sessions.ts` | `sessions.compact` RPC (records result after compaction) |
| `src/gateway/server-methods/plugins-ui.ts` | Plugin view registration |
| `src/gateway/server-methods.ts` | Handler wiring |
| `src/gateway/server-methods-list.ts` | Method registration |
| `src/gateway/method-scopes.ts` | Scope registration |
| `src/agents/pi-embedded-runner/compact.ts` | Chat summary instruction injection |
| `ui/src/ui/app-render.helpers.ts` | Context gauge + background toast trigger |
| `ui/src/ui/app-gateway.ts` | Auto-compaction check after final event |
| `ui/src/ui/views/compaction-settings.ts` | Settings tab view |
| `ui/src/ui/app-render.ts` | View wiring + import |
| `ui/src/ui/views/chat.ts` | Chat history filters |
### Config File
`{agentDir}/compaction-config.json` — per-agent, created on first settings save.
### Plugin Registration
The compaction tab is registered as a builtin plugin view in `plugins-ui.ts`:
```typescript
{
id: "compaction",
label: "Compaction",
subtitle: "Memory Management",
icon: "archive",
group: "agent",
position: 7,
pluginId: "compaction-ui",
}
```
## Known Gotchas
- **Settings not persisting across tab visits:** Fixed in v2.1.0. The module-level `_state.loaded` flag was preventing reloads. Now reloads every 2 seconds when re-entering the tab.
- **Model selector requires API keys:** If you select a custom model, make sure the provider's API key is configured. The UI shows a warning callout.
- **Compaction model format:** Must be `"provider/model"` format (e.g. `"anthropic/claude-sonnet-4-6"`). Invalid formats are silently ignored and fall back to session default.
- **parentId skip-set propagation must be message-only:** When filtering memory flush messages from `readSessionMessages`, the skip set must ONLY propagate through entries with a `message` field. Non-message protocol entries (`type:"custom"`) bridge between logical turns — if their IDs enter the skip set, the entire remaining conversation gets hidden. The fix: only check `parsed?.message` entries against the skip set, and only add their IDs to it.
- **No blocking modal — ever:** The gauge button in `app-render.helpers.ts` must NEVER render a full-screen overlay modal. Compaction is a background process; all status is communicated via the bottom-right toast system. Remove any `position:fixed; inset:0` overlay conditioned on `compactState.phase`. The button may show a spinner icon while running, but must not block the UI.
## Changelog
### v2.1.0
- **Conversation summary paragraph:** Compaction output now starts with a `## Conversation Summary` narrative paragraph before structured sections
- **Model selector:** Choose a specific model for compaction or use session default, with provider-grouped dropdown
- **Settings reload fix:** Tab now reloads settings from backend every 2s instead of caching forever
- **Reference files updated:** Synced `references/` with current source
### v2.0.0
- **Background compaction:** Replaced blocking full-screen modal with background job toast
- **Auto-compaction:** Triggers after chat response when token usage exceeds threshold (configurable, default 60%, 5-minute debounce)
- **Settings tab:** Plugin-registered Compaction tab under Agent nav group
- **Result recording:** `sessions.compact` records before/after token counts and optional summary
- **Auth hierarchy:** Same OAuth → API → Fallback chain as chat
- **Settings RPCs:** `compaction.getSettings`, `compaction.saveSettings`, `compaction.getLastResult`, `compaction.clearLastResult`
### v1.0.0
- Initial release: Context gauge button, blocking modal with animated phases, `sessions.compact` RPC with LLM summarization, chat history NO_REPLY/HEARTBEAT_OK filtering, compaction divider lines
@@ -0,0 +1,73 @@
/**
* Auto-compaction trigger — from ui/src/ui/app-gateway.ts
*
* Called after every chat "final" event. Checks token usage against
* configured threshold and triggers background compaction if exceeded.
*/
/** Check if auto-compaction should trigger based on settings and current token usage. */
async function checkAutoCompaction(host: GatewayHost) {
const app = host as unknown as OpenClawApp;
const s = host as unknown as Record<string, unknown>;
// Don't auto-compact if a compaction is already running
if ((s.__compactState as Record<string, unknown> | undefined)?.active) return;
// Debounce: don't auto-compact more than once per 5 minutes
const lastAuto = (s.__lastAutoCompactAt as number | undefined) ?? 0;
if (Date.now() - lastAuto < 5 * 60 * 1000) return;
try {
const settings = await app.client?.request<{
ok: boolean;
settings: { autoEnabled: boolean; autoThresholdPercent: number };
}>("compaction.getSettings", {});
if (!settings?.settings?.autoEnabled) return;
const currentRow = (app.sessionsResult?.sessions ?? []).find(
(sess: { key: string }) => sess.key === app.sessionKey,
);
const totalTokens = (currentRow as { totalTokens?: number } | undefined)?.totalTokens ?? 0;
const contextWindow =
(currentRow as { contextTokens?: number } | undefined)?.contextTokens ??
(app as unknown as { contextWindow?: number }).contextWindow ?? 200000;
if (!totalTokens || !contextWindow) return;
const pct = Math.round((totalTokens / contextWindow) * 100);
if (pct < settings.settings.autoThresholdPercent) return;
// Trigger background compaction
if (s.__compactState && (s.__compactState as Record<string, unknown>).active) return;
s.__lastAutoCompactAt = Date.now();
s.__compactState = { active: true, phase: "running" };
const jobId = "cmp-" + Date.now();
app.backgroundJobToasts = [
...(app.backgroundJobToasts ?? []).filter((t) => !t.jobId.startsWith("cmp-")),
{ jobId, jobName: `Auto-Compacting (${pct}%)`, status: "running", startedAt: Date.now(), completedAt: null },
];
app.requestUpdate();
const result = await app.client?.request<{
ok: boolean; compacted: boolean; tokensBefore?: number; tokensAfter?: number; reason?: string;
}>("sessions.compact", { key: app.sessionKey });
s.__compactState = {};
if (result?.compacted && result.tokensBefore && result.tokensAfter) {
const label = `Auto-compacted: ${Math.round(result.tokensBefore / 1000)}K → ${Math.round(result.tokensAfter / 1000)}K`;
app.backgroundJobToasts = [
...(app.backgroundJobToasts ?? []).filter((t) => t.jobId !== jobId),
{ jobId, jobName: label, status: "complete", startedAt: Date.now(), completedAt: Date.now() },
];
app.requestUpdate();
void refreshChat(app, { scheduleScroll: false });
setTimeout(() => {
app.backgroundJobToasts = (app.backgroundJobToasts ?? []).filter((t) => t.jobId !== jobId);
app.requestUpdate();
}, 5000);
} else {
app.backgroundJobToasts = (app.backgroundJobToasts ?? []).filter((t) => t.jobId !== jobId);
app.requestUpdate();
}
} catch {
// Silent failure — auto-compaction is best-effort
}
}
@@ -0,0 +1,23 @@
diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts
index 1ef922452..61528b488 100644
--- a/ui/src/ui/views/chat.ts
+++ b/ui/src/ui/views/chat.ts
@@ -508,6 +508,18 @@ function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
continue;
}
+ // Hide NO_REPLY / silent reply messages from the chat display
+ if (normalized.role === "assistant") {
+ const fullText = normalized.content
+ .filter((c) => c.type === "text" && c.text)
+ .map((c) => c.text!.trim())
+ .join("")
+ .trim();
+ if (fullText === "NO_REPLY" || fullText === "HEARTBEAT_OK") {
+ continue;
+ }
+ }
+
items.push({
kind: "message",
key: messageKey(msg, i),
@@ -0,0 +1,19 @@
/**
* Chat summary paragraph injection — from src/agents/pi-embedded-runner/compact.ts
*
* This snippet shows how the conversation summary instruction is injected
* into the upstream compaction LLM call via customInstructions.
*/
// Inject chat summary paragraph instruction before the structured sections.
const chatSummaryInstruction =
`Before the ## Goal section, add a ## Conversation Summary section with a short paragraph (3-6 sentences) ` +
`that describes the overall topic(s) of the conversation, the flow of discussion, key turns and transitions, ` +
`and how the conversation progressed. This gives a human-readable narrative overview before the structured details. ` +
`Write it in past tense as a natural summary of what was discussed.`;
const effectiveInstructions = params.customInstructions
? `${chatSummaryInstruction}\n\n${params.customInstructions}`
: chatSummaryInstruction;
const result = await compactWithSafetyTimeout(() =>
session.compact(effectiveInstructions),
);
@@ -0,0 +1,148 @@
/**
* Compaction settings RPC handlers.
*
* Manages per-agent compaction configuration:
* - Auto-compact toggle + threshold percentage
* - Store last compaction result for review
* - Retrieve settings & last result
*
* Config stored at: {agentDir}/compaction-config.json
*/
import fs from "node:fs/promises";
import path from "node:path";
import { resolveOpenClawAgentDir } from "../../agents/agent-paths.js";
import type { GatewayRequestHandler } from "./types.js";
// ─── Types ───────────────────────────────────────────────────────────────────
export interface CompactionSettings {
/** Enable automatic compaction when token usage exceeds threshold */
autoEnabled: boolean;
/** Token usage percentage (0-100) at which auto-compaction triggers */
autoThresholdPercent: number;
/** Store the last compaction result for review */
storeLastResult: boolean;
/** Custom model for compaction in "provider/model" format, empty or undefined = use session default */
compactionModel?: string;
}
export interface CompactionLastResult {
timestamp: number;
trigger: "manual" | "auto" | "overflow";
tokensBefore: number;
tokensAfter: number;
tokensSaved: number;
percentReduction: number;
sessionKey?: string;
/** Compacted summary text (only stored when storeLastResult is enabled) */
summary?: string;
}
interface CompactionConfigFile {
settings: CompactionSettings;
lastResult?: CompactionLastResult;
}
// ─── Defaults ────────────────────────────────────────────────────────────────
const DEFAULT_SETTINGS: CompactionSettings = {
autoEnabled: true,
autoThresholdPercent: 60,
storeLastResult: false,
};
// ─── File I/O ────────────────────────────────────────────────────────────────
function getConfigPath(agentDir?: string): string {
const dir = agentDir ?? resolveOpenClawAgentDir();
return path.join(dir, "compaction-config.json");
}
export async function loadCompactionConfig(agentDir?: string): Promise<CompactionConfigFile> {
const configPath = getConfigPath(agentDir);
try {
const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as Partial<CompactionConfigFile>;
return {
settings: { ...DEFAULT_SETTINGS, ...parsed.settings },
lastResult: parsed.lastResult,
};
} catch {
return { settings: { ...DEFAULT_SETTINGS } };
}
}
async function saveCompactionConfig(config: CompactionConfigFile, agentDir?: string): Promise<void> {
const configPath = getConfigPath(agentDir);
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
}
/**
* Called by the compaction engine to record a result.
* Exported for use by the sessions.compact RPC and auto-compaction trigger.
*/
export async function recordCompactionResult(
result: CompactionLastResult,
agentDir?: string,
): Promise<void> {
const config = await loadCompactionConfig(agentDir);
if (config.settings.storeLastResult) {
config.lastResult = result;
} else {
// Still store metadata, just not the summary
config.lastResult = { ...result, summary: undefined };
}
await saveCompactionConfig(config, agentDir);
}
// ─── RPC Handlers ────────────────────────────────────────────────────────────
const compactionGetSettings: GatewayRequestHandler = async ({ respond }) => {
const config = await loadCompactionConfig();
respond(true, { ok: true, settings: config.settings }, undefined);
};
const compactionSaveSettings: GatewayRequestHandler = async ({ params, respond }) => {
const p = params as Partial<CompactionSettings> | undefined;
const config = await loadCompactionConfig();
if (p?.autoEnabled !== undefined) config.settings.autoEnabled = !!p.autoEnabled;
if (typeof p?.autoThresholdPercent === "number") {
config.settings.autoThresholdPercent = Math.max(10, Math.min(95, p.autoThresholdPercent));
}
if (p?.storeLastResult !== undefined) config.settings.storeLastResult = !!p.storeLastResult;
if (p && "compactionModel" in p) {
config.settings.compactionModel = typeof p.compactionModel === "string" && p.compactionModel.includes("/")
? p.compactionModel : undefined;
}
await saveCompactionConfig(config);
respond(true, { ok: true, settings: config.settings }, undefined);
};
const compactionGetLastResult: GatewayRequestHandler = async ({ respond }) => {
const config = await loadCompactionConfig();
respond(
true,
{ ok: true, lastResult: config.lastResult ?? null },
undefined,
);
};
const compactionClearLastResult: GatewayRequestHandler = async ({ respond }) => {
const config = await loadCompactionConfig();
config.lastResult = undefined;
await saveCompactionConfig(config);
respond(true, { ok: true }, undefined);
};
// ─── Exports ─────────────────────────────────────────────────────────────────
export const compactionMethods: Record<string, GatewayRequestHandler> = {
"compaction.getSettings": compactionGetSettings,
"compaction.saveSettings": compactionSaveSettings,
"compaction.getLastResult": compactionGetLastResult,
"compaction.clearLastResult": compactionClearLastResult,
};
@@ -0,0 +1,353 @@
/**
* Compaction Settings Tab
*
* Plugin-registered view for managing compaction behavior:
* - Auto/manual toggle
* - Dynamic threshold slider
* - Last result before/after display
* - Store results opt-in
*/
import { html, nothing } from "lit";
interface ModelInfo {
id: string;
name?: string;
provider: string;
contextWindow?: number;
}
export interface CompactionSettingsProps {
client: { request: <T>(method: string, params: unknown) => Promise<T> } | null;
connected: boolean;
}
interface CompactionSettingsState {
loaded: boolean;
saving: boolean;
autoEnabled: boolean;
autoThresholdPercent: number;
storeLastResult: boolean;
compactionModel: string; // "provider/model" or "" for default
availableModels: ModelInfo[];
showSummary: boolean;
lastResult: {
timestamp: number;
trigger: string;
tokensBefore: number;
tokensAfter: number;
tokensSaved: number;
percentReduction: number;
sessionKey?: string;
summary?: string;
} | null;
}
// Module-level state (persists across re-renders)
let _state: CompactionSettingsState = {
loaded: false,
saving: false,
autoEnabled: true,
autoThresholdPercent: 60,
storeLastResult: false,
compactionModel: "",
availableModels: [],
showSummary: false,
lastResult: null,
};
let _loadPromise: Promise<void> | null = null;
let _requestUpdate: (() => void) | null = null;
let _lastLoadTime = 0;
async function loadSettings(client: CompactionSettingsProps["client"]) {
if (!client) return;
try {
const [settingsRes, resultRes, modelsRes] = await Promise.all([
client.request<{ ok: boolean; settings: CompactionSettingsState }>("compaction.getSettings", {}),
client.request<{ ok: boolean; lastResult: CompactionSettingsState["lastResult"] }>("compaction.getLastResult", {}),
client.request<{ models?: ModelInfo[] }>("models.list", {}).catch(() => ({ models: [] })),
]);
if (settingsRes?.settings) {
_state.autoEnabled = settingsRes.settings.autoEnabled;
_state.autoThresholdPercent = settingsRes.settings.autoThresholdPercent;
_state.storeLastResult = settingsRes.settings.storeLastResult;
_state.compactionModel = settingsRes.settings.compactionModel || "";
}
_state.availableModels = modelsRes?.models ?? [];
_state.lastResult = resultRes?.lastResult ?? null;
_state.loaded = true;
_requestUpdate?.();
} catch {
_state.loaded = true;
_requestUpdate?.();
}
}
async function saveSettings(client: CompactionSettingsProps["client"]) {
if (!client || _state.saving) return;
_state.saving = true;
_requestUpdate?.();
try {
await client.request("compaction.saveSettings", {
autoEnabled: _state.autoEnabled,
autoThresholdPercent: _state.autoThresholdPercent,
storeLastResult: _state.storeLastResult,
compactionModel: _state.compactionModel || undefined,
});
} catch {
// silent
}
_state.saving = false;
_requestUpdate?.();
}
function formatTimestamp(ts: number): string {
return new Date(ts).toLocaleString(undefined, {
month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit",
});
}
function formatTokens(n: number): string {
return n >= 1000 ? `${Math.round(n / 1000)}K` : String(n);
}
export function renderCompactionSettings(
props: CompactionSettingsProps,
requestUpdate: () => void,
) {
_requestUpdate = requestUpdate;
// Reload settings on first render AND when re-entering the tab (stale > 2s)
const now = Date.now();
if ((!_state.loaded || now - _lastLoadTime > 2000) && !_loadPromise && props.client) {
_lastLoadTime = now;
_loadPromise = loadSettings(props.client).finally(() => { _loadPromise = null; });
}
if (!_state.loaded) {
return html`<div class="page-body"><div class="card"><div class="card__body" style="text-align:center;padding:40px;color:var(--text-muted)">Loading settings…</div></div></div>`;
}
const thresholdColor =
_state.autoThresholdPercent >= 85 ? "var(--clr-danger, #ef4444)"
: _state.autoThresholdPercent >= 60 ? "var(--clr-warning, #f59e0b)"
: "var(--clr-success, #22c55e)";
const last = _state.lastResult;
return html`
<div class="page-body" style="max-width:680px;">
<!-- Auto-Compaction Card -->
<div class="card" style="margin-bottom:16px;">
<div class="card__header">
<div class="card__title">Auto-Compaction</div>
</div>
<div class="card__body">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
<div>
<div style="font-weight:500;color:var(--text);">Automatic Compaction</div>
<div style="font-size:12px;color:var(--text-muted);margin-top:2px;">
Triggers background compaction when token usage exceeds threshold
</div>
</div>
<input type="checkbox" ?checked=${_state.autoEnabled}
style="width:18px;height:18px;cursor:pointer;accent-color:var(--clr-primary, #6366f1);"
@change=${(e: Event) => {
_state.autoEnabled = (e.target as HTMLInputElement).checked;
requestUpdate();
void saveSettings(props.client);
}} />
</div>
<div style="margin-bottom:8px;">
<div style="display:flex;justify-content:space-between;margin-bottom:6px;">
<span style="font-size:13px;color:var(--text-muted);">Threshold</span>
<span style="font-size:13px;font-weight:600;color:${thresholdColor};">
${_state.autoThresholdPercent}%
</span>
</div>
<input type="range" min="10" max="95" step="5"
.value=${String(_state.autoThresholdPercent)}
?disabled=${!_state.autoEnabled}
style="width:100%;accent-color:${thresholdColor};"
@input=${(e: Event) => {
_state.autoThresholdPercent = Number((e.target as HTMLInputElement).value);
requestUpdate();
}}
@change=${() => { void saveSettings(props.client); }}
/>
<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--text-muted);margin-top:2px;">
<span>10%</span>
<span>95%</span>
</div>
</div>
</div>
</div>
<!-- Model Selector Card -->
<div class="card" style="margin-bottom:16px;">
<div class="card__header">
<div class="card__title">Model</div>
</div>
<div class="card__body">
<div style="margin-bottom:12px;">
<div style="font-weight:500;color:var(--text);margin-bottom:4px;">Compaction Model</div>
<div style="font-size:12px;color:var(--text-muted);margin-bottom:10px;">
Select a model for compaction, or leave as default to use the current session's model and auth hierarchy.
</div>
<select
style="width:100%;padding:8px 12px;background:var(--bg-surface, #0d1117);border:1px solid var(--border, #30363d);border-radius:6px;color:var(--text, #e6edf3);font-size:14px;"
@change=${(e: Event) => {
_state.compactionModel = (e.target as HTMLSelectElement).value;
requestUpdate();
void saveSettings(props.client);
}}
>
<option value="" ?selected=${!_state.compactionModel}>Default (session model)</option>
${(() => {
const groups = new Map<string, ModelInfo[]>();
for (const m of _state.availableModels) {
const g = groups.get(m.provider) || [];
g.push(m);
groups.set(m.provider, g);
}
return [...groups.entries()].map(
([prov, models]) => html`
<optgroup label="${prov}">
${models.map(
(m) => html`
<option
value="${m.provider}/${m.id}"
?selected=${_state.compactionModel === `${m.provider}/${m.id}`}
>${m.name || m.id}${m.contextWindow ? ` (${Math.round(m.contextWindow / 1000)}k)` : ""}</option>
`,
)}
</optgroup>
`,
);
})()}
</select>
</div>
${_state.compactionModel ? html`
<div style="padding:10px 12px;background:var(--bg-surface, #1e1e2e);border-radius:6px;border-left:3px solid var(--clr-warning, #f59e0b);font-size:12px;color:var(--text-muted);">
⚠️ Make sure the correct API keys are configured for <strong style="color:var(--text);">${_state.compactionModel.split("/")[0]}</strong>.
If this model fails, compaction will automatically fall back to your session's default model.
</div>
` : nothing}
</div>
</div>
<!-- Storage Card -->
<div class="card" style="margin-bottom:16px;">
<div class="card__header">
<div class="card__title">Result Storage</div>
</div>
<div class="card__body">
<div style="display:flex;align-items:center;justify-content:space-between;">
<div>
<div style="font-weight:500;color:var(--text);">Store Compaction Results</div>
<div style="font-size:12px;color:var(--text-muted);margin-top:2px;">
Save the summary text from the most recent compaction for review
</div>
</div>
<input type="checkbox" ?checked=${_state.storeLastResult}
style="width:18px;height:18px;cursor:pointer;accent-color:var(--clr-primary, #6366f1);"
@change=${(e: Event) => {
_state.storeLastResult = (e.target as HTMLInputElement).checked;
requestUpdate();
void saveSettings(props.client);
}} />
</div>
</div>
</div>
<!-- Last Result Card -->
<div class="card">
<div class="card__header">
<div class="card__title">Last Compaction</div>
${last ? html`
<div style="display:flex;gap:6px;">
<button class="btn btn--sm" style="background:${last.summary ? "var(--clr-primary, #6366f1)" : "var(--bg-surface, #333)"};color:${last.summary ? "#fff" : "var(--text-muted)"};border:none;padding:4px 12px;border-radius:4px;cursor:${last.summary ? "pointer" : "not-allowed"};font-size:12px;opacity:${last.summary ? "1" : "0.6"};" @click=${() => {
if (!last.summary) return;
_state.showSummary = !_state.showSummary;
requestUpdate();
}}>${_state.showSummary ? "Hide" : "View Results"}</button>
<button class="btn btn--sm" @click=${async () => {
await props.client?.request("compaction.clearLastResult", {});
_state.lastResult = null;
_state.showSummary = false;
requestUpdate();
}}>Clear</button>
</div>
` : nothing}
</div>
<div class="card__body">
${last ? html`
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;">
<div style="background:var(--bg-surface, #1e1e2e);border-radius:8px;padding:14px;text-align:center;">
<div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px;">Before</div>
<div style="font-size:22px;font-weight:700;color:var(--clr-warning, #f59e0b);">${formatTokens(last.tokensBefore)}</div>
</div>
<div style="background:var(--bg-surface, #1e1e2e);border-radius:8px;padding:14px;text-align:center;">
<div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px;">After</div>
<div style="font-size:22px;font-weight:700;color:var(--clr-success, #22c55e);">${formatTokens(last.tokensAfter)}</div>
</div>
</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--text-muted);margin-bottom:8px;">
<span>Saved: <strong style="color:var(--text);">${formatTokens(last.tokensSaved)}</strong> (${last.percentReduction}%)</span>
<span>Trigger: <strong style="color:var(--text);">${last.trigger}</strong></span>
</div>
<div style="font-size:12px;color:var(--text-muted);">
${formatTimestamp(last.timestamp)}${last.sessionKey ? html` · <code style="font-size:11px;">${last.sessionKey.length > 40 ? last.sessionKey.slice(0, 40) + "…" : last.sessionKey}</code>` : nothing}
</div>
${!last.summary ? html`
<div style="margin-top:12px;padding:10px;font-size:12px;color:var(--text-muted);background:var(--bg-surface, #1e1e2e);border-radius:6px;text-align:center;">
Enable "Store Compaction Results" above to save summary text for review
</div>
` : nothing}
${_state.showSummary && last.summary ? html`
<div style="margin-top:16px;border:1px solid var(--border, #30363d);border-radius:8px;overflow:hidden;">
<div style="padding:10px 14px;background:var(--bg-surface, #1e1e2e);border-bottom:1px solid var(--border, #30363d);display:flex;justify-content:space-between;align-items:center;">
<span style="font-size:13px;font-weight:600;color:var(--text);">📋 Compaction Summary</span>
<button style="background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:18px;padding:0 4px;line-height:1;" @click=${() => {
_state.showSummary = false;
requestUpdate();
}}>✕</button>
</div>
<div style="padding:16px;max-height:500px;overflow-y:auto;">
<pre style="margin:0;font-size:12px;line-height:1.6;white-space:pre-wrap;word-break:break-word;color:var(--text);font-family:inherit;">${last.summary}</pre>
</div>
</div>
` : nothing}
` : html`
<div style="text-align:center;padding:24px;color:var(--text-muted);font-size:13px;">
No compaction has been run yet
</div>
`}
</div>
</div>
<!-- Auth Info -->
<div style="margin-top:16px;padding:12px;font-size:12px;color:var(--text-muted);border-left:3px solid var(--border, #333);">
Compaction uses the same auth hierarchy as chat: <strong>OAuth → API Key → Fallback</strong>.
${_state.compactionModel
? html`Custom model will use this auth chain independently. Fallback uses the session default.`
: html`No separate configuration needed.`}
</div>
</div>
`;
}
/** Reset module state (for tests or tab switches). */
export function resetCompactionSettingsState() {
_state = {
loaded: false, saving: false,
autoEnabled: true, autoThresholdPercent: 60, storeLastResult: false,
compactionModel: "", availableModels: [], showSummary: false, lastResult: null,
};
_loadPromise = null;
_requestUpdate = null;
_lastLoadTime = 0;
}
@@ -0,0 +1,196 @@
diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts
index 36aa91524..b12bcdd75 100644
--- a/ui/src/ui/app-render.helpers.ts
+++ b/ui/src/ui/app-render.helpers.ts
@@ -84,6 +84,183 @@ export function renderTab(state: AppViewState, tab: Tab) {
`;
}
+/**
+ * Renders a context window utilization gauge with a compact button.
+ * Shows a small circular progress ring indicating how full the context is,
+ * plus a click-to-compact action.
+ */
+function renderContextGauge(state: AppViewState) {
+ // Find the current session row to get token info
+ const currentRow = (state.sessionsResult?.sessions ?? []).find(
+ (s) => s.key === state.sessionKey,
+ );
+ const totalTokens = currentRow?.totalTokens ?? 0;
+ const contextWindow = currentRow?.contextTokens ?? (state as unknown as OpenClawApp).contextWindow ?? 200000;
+
+ if (!totalTokens || !contextWindow) {
+ return nothing;
+ }
+
+ const pct = Math.min(100, Math.round((totalTokens / contextWindow) * 100));
+ const radius = 9;
+ const circumference = 2 * Math.PI * radius;
+ const dashOffset = circumference - (pct / 100) * circumference;
+
+ // Color based on utilization
+ const color =
+ pct >= 85 ? "var(--clr-danger, #ef4444)" :
+ pct >= 60 ? "var(--clr-warning, #f59e0b)" :
+ "var(--clr-success, #22c55e)";
+
+ const compactState = ((state as unknown as Record<string, unknown>).__compactState ?? {}) as {
+ active?: boolean;
+ phase?: string;
+ error?: string;
+ result?: { compacted?: boolean; tokensBefore?: number; tokensAfter?: number; reason?: string };
+ };
+
+ const handleCompact = () => {
+ if (compactState.active || !state.connected) return;
+ const app = state as unknown as OpenClawApp;
+ const s = state as unknown as Record<string, unknown>;
+ s.__compactState = { active: true, phase: "starting" };
+ app.requestUpdate();
+
+ // Animate phases for user feedback
+ const phases = [
+ { phase: "reading", delay: 400 },
+ { phase: "summarizing", delay: 1200 },
+ { phase: "compacting", delay: 800 },
+ ];
+ let phaseIdx = 0;
+ const advancePhase = () => {
+ if (phaseIdx < phases.length && (s.__compactState as Record<string, unknown>)?.active) {
+ (s.__compactState as Record<string, unknown>).phase = phases[phaseIdx].phase;
+ app.requestUpdate();
+ phaseIdx++;
+ if (phaseIdx < phases.length) {
+ setTimeout(advancePhase, phases[phaseIdx - 1].delay);
+ }
+ }
+ };
+ setTimeout(advancePhase, 300);
+
+ app.client?.request<{ ok: boolean; compacted: boolean; kept?: number; reason?: string }>(
+ "sessions.compact",
+ { key: state.sessionKey },
+ ).then((result) => {
+ s.__compactState = { active: true, phase: "complete", result: result ?? { compacted: false } };
+ app.requestUpdate();
+ // Hold "Compaction Complete" for 2s, then close and refresh
+ setTimeout(() => {
+ s.__compactState = {};
+ app.requestUpdate();
+ if (result?.compacted) {
+ void refreshChat(state as unknown as Parameters<typeof refreshChat>[0], {
+ scheduleScroll: false,
+ });
+ }
+ }, 2000);
+ }).catch((err: unknown) => {
+ s.__compactState = { active: false, phase: "error", error: String(err) };
+ app.requestUpdate();
+ setTimeout(() => {
+ s.__compactState = {};
+ app.requestUpdate();
+ }, 4000);
+ });
+ };
+
+ const tokensK = totalTokens >= 1000 ? `${Math.round(totalTokens / 1000)}K` : String(totalTokens);
+ const ctxK = contextWindow >= 1000 ? `${Math.round(contextWindow / 1000)}K` : String(contextWindow);
+ const tooltip = `Context: ${tokensK} / ${ctxK} tokens (${pct}%)${pct >= 60 ? " — Click to compact" : ""}`;
+
+ const phaseLabels: Record<string, string> = {
+ starting: "Preparing compaction…",
+ reading: "Reading session history…",
+ summarizing: "Summarizing conversation…",
+ compacting: "Compacting context window…",
+ complete: compactState.result?.compacted
+ ? `✅ Compaction Complete${compactState.result.tokensBefore && compactState.result.tokensAfter ? ` — ${Math.round(compactState.result.tokensBefore / 1000)}K → ${Math.round(compactState.result.tokensAfter / 1000)}K tokens` : ""}`
+ : `️ ${compactState.result?.reason ?? "Nothing to compact."}`,
+ error: `❌ ${compactState.error ?? "Compaction failed."}`,
+ };
+ const phaseLabel = phaseLabels[compactState.phase ?? ""] ?? "";
+ const isWorking = compactState.active && compactState.phase !== "complete" && compactState.phase !== "error";
+
+ const gaugeRing = html`
+ <svg width="18" height="18" viewBox="0 0 24 24" style="transform:rotate(-90deg);">
+ <circle cx="12" cy="12" r="${radius}" fill="none" stroke="var(--clr-surface-3, #333)" stroke-width="3" />
+ <circle cx="12" cy="12" r="${radius}" fill="none" stroke="${color}" stroke-width="3"
+ stroke-dasharray="${circumference}" stroke-dashoffset="${dashOffset}" stroke-linecap="round"
+ style="transition: stroke-dashoffset 0.3s ease;" />
+ </svg>
+ `;
+
+ return html`
+ <button
+ class="btn btn--sm btn--icon"
+ title=${tooltip}
+ ?disabled=${compactState.active || !state.connected || pct < 20}
+ @click=${handleCompact}
+ >
+ ${gaugeRing}
+ </button>
+ ${compactState.phase ? html`
+ <div style="
+ position:fixed; inset:0; z-index:1000;
+ background:rgba(0,0,0,0.55); backdrop-filter:blur(3px);
+ display:flex; align-items:center; justify-content:center;
+ ">
+ <div style="
+ background:var(--bg-card, #1a1a2e);
+ border:1px solid var(--border, #333);
+ border-radius:12px;
+ padding:28px 36px;
+ min-width:360px; max-width:440px;
+ box-shadow:0 20px 60px rgba(0,0,0,0.5);
+ text-align:center;
+ ">
+ <div style="margin-bottom:16px;">
+ ${gaugeRing}
+ </div>
+ <div style="font-size:15px; font-weight:600; margin-bottom:6px; color:var(--text, #eee);">
+ Memory Compaction
+ </div>
+ <div style="font-size:13px; color:var(--text-muted, #999); margin-bottom:18px;">
+ ${phaseLabel}
+ </div>
+ ${isWorking ? html`
+ <div style="
+ height:4px; border-radius:2px;
+ background:var(--clr-surface-3, #333);
+ overflow:hidden;
+ ">
+ <div style="
+ height:100%; border-radius:2px;
+ background:${color};
+ animation:compactProgress 2s ease-in-out infinite;
+ "></div>
+ </div>
+ <style>
+ @keyframes compactProgress {
+ 0% { width: 5%; margin-left: 0; }
+ 50% { width: 60%; margin-left: 20%; }
+ 100% { width: 5%; margin-left: 95%; }
+ }
+ </style>
+ ` : nothing}
+ ${compactState.phase === "complete" || compactState.phase === "error" ? html`
+ <div style="font-size:11px; color:var(--text-muted, #666); margin-top:12px;">
+ Closing automatically…
+ </div>
+ ` : nothing}
+ </div>
+ </div>
+ ` : nothing}
+ `;
+}
+
export function renderChatControls(state: AppViewState) {
const mainSessionKey = resolveMainSessionKey(state.hello, state.sessionsResult);
@@ -231,6 +408,7 @@ export function renderChatControls(state: AppViewState) {
)}
</select>
</label>
+ ${renderContextGauge(state)}
<button
class="btn btn--sm btn--icon"
?disabled=${!state.connected}
@@ -0,0 +1,154 @@
diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts
index af072f494..8f14ec902 100644
--- a/src/gateway/server-methods/sessions.ts
+++ b/src/gateway/server-methods/sessions.ts
@@ -407,13 +407,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
return;
}
- const maxLines =
- typeof p.maxLines === "number" && Number.isFinite(p.maxLines)
- ? Math.max(1, Math.floor(p.maxLines))
- : 400;
-
const { cfg, target, storePath } = resolveGatewaySessionTargetFromKey(key);
- // Lock + read in a short critical section; transcript work happens outside.
const compactTarget = await updateSessionStore(storePath, (store) => {
const { entry, primaryKey } = migrateAndPruneSessionStoreKey({ cfg, key, store });
return { entry, primaryKey };
@@ -421,80 +415,74 @@ export const sessionsHandlers: GatewayRequestHandlers = {
const entry = compactTarget.entry;
const sessionId = entry?.sessionId;
if (!sessionId) {
- respond(
- true,
- {
- ok: true,
- key: target.canonicalKey,
- compacted: false,
- reason: "no sessionId",
- },
- undefined,
- );
- return;
- }
-
- const filePath = resolveSessionTranscriptCandidates(
- sessionId,
- storePath,
- entry?.sessionFile,
- target.agentId,
- ).find((candidate) => fs.existsSync(candidate));
- if (!filePath) {
- respond(
- true,
- {
- ok: true,
- key: target.canonicalKey,
- compacted: false,
- reason: "no transcript",
- },
- undefined,
- );
+ respond(true, { ok: true, key: target.canonicalKey, compacted: false, reason: "no sessionId" }, undefined);
return;
}
- const raw = fs.readFileSync(filePath, "utf-8");
- const lines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0);
- if (lines.length <= maxLines) {
- respond(
- true,
- {
- ok: true,
- key: target.canonicalKey,
- compacted: false,
- kept: lines.length,
- },
- undefined,
- );
- return;
+ // Use the real LLM-based compaction engine (same as /compact command)
+ const { compactEmbeddedPiSession } = await import("../../agents/pi-embedded.js");
+ const { isEmbeddedPiRunActive, abortEmbeddedPiRun: abortRun, waitForEmbeddedPiRunEnd: waitEnd } =
+ await import("../../agents/pi-embedded.js");
+ const { resolveSessionFilePath, resolveSessionFilePathOptions } = await import("../../config/sessions.js");
+ const { resolveSessionAgentId } = await import("../../routing/session-key.js");
+
+ // Abort any active run first
+ if (isEmbeddedPiRunActive(sessionId)) {
+ abortRun(sessionId);
+ await waitEnd(sessionId, 15_000);
}
- const archived = archiveFileOnDisk(filePath, "bak");
- const keptLines = lines.slice(-maxLines);
- fs.writeFileSync(filePath, `${keptLines.join("\n")}\n`, "utf-8");
+ const agentId = target.agentId || resolveSessionAgentId({ sessionKey: key, config: cfg });
+ const { provider, model } = resolveSessionModelRef(cfg, entry, agentId);
+ const sessionFile = resolveSessionFilePath(
+ sessionId,
+ entry,
+ resolveSessionFilePathOptions({ agentId, storePath }),
+ );
+ const workspaceDir = cfg.workspace?.dir || process.env.OPENCLAW_WORKSPACE || `${process.env.HOME}/.openclaw/workspace`;
- await updateSessionStore(storePath, (store) => {
- const entryKey = compactTarget.primaryKey;
- const entryToUpdate = store[entryKey];
- if (!entryToUpdate) {
- return;
- }
- delete entryToUpdate.inputTokens;
- delete entryToUpdate.outputTokens;
- delete entryToUpdate.totalTokens;
- delete entryToUpdate.totalTokensFresh;
- entryToUpdate.updatedAt = Date.now();
+ const result = await compactEmbeddedPiSession({
+ sessionId,
+ sessionKey: key,
+ sessionFile,
+ workspaceDir,
+ config: cfg,
+ skillsSnapshot: entry?.skillsSnapshot,
+ provider,
+ model,
+ trigger: "manual",
+ senderIsOwner: true,
+ customInstructions: typeof p.instructions === "string" ? p.instructions : undefined,
});
+ // Update token counts after compaction
+ if (result.ok && result.compacted) {
+ await updateSessionStore(storePath, (store) => {
+ const entryToUpdate = store[compactTarget.primaryKey];
+ if (!entryToUpdate) return;
+ if (result.result?.tokensAfter != null) {
+ entryToUpdate.totalTokens = result.result.tokensAfter;
+ entryToUpdate.totalTokensFresh = result.result.tokensAfter;
+ } else {
+ delete entryToUpdate.inputTokens;
+ delete entryToUpdate.outputTokens;
+ delete entryToUpdate.totalTokens;
+ delete entryToUpdate.totalTokensFresh;
+ }
+ entryToUpdate.compactionCount = (entryToUpdate.compactionCount ?? 0) + 1;
+ entryToUpdate.updatedAt = Date.now();
+ });
+ }
+
respond(
true,
{
ok: true,
key: target.canonicalKey,
- compacted: true,
- archived,
- kept: keptLines.length,
+ compacted: result.compacted ?? false,
+ tokensBefore: result.result?.tokensBefore,
+ tokensAfter: result.result?.tokensAfter,
+ reason: result.reason,
},
undefined,
);