mirror of
https://github.com/LeoYeAI/openclaw-master-skills.git
synced 2026-07-27 22:15:43 +00:00
feat(v0.3.0): weekly update 2026-03-09 — 37 new skills (total 164)
This commit is contained in:
@@ -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,
|
||||
);
|
||||
Reference in New Issue
Block a user