feat: add chat compaction built-in plugin
Nicolas Leão committed
Mar 26, 2026 at 13:55 UTC
5b3240677d1e0e75b161e0e9dba48e92fcd9abf7
20 files changed
+1620
plugins/chat_compaction/.toggle-1
plugins/chat_compaction/api/compact_chat.py
new
+71
@@ -0,0 +1,71 @@
1
+"""API handler for chat compaction."""
2
+import importlib
3
+from helpers.api import ApiHandler, Input, Output, Request, Response
4
+from agent import AgentContext
5
+
6
+
7
+class CompactChat(ApiHandler):
8
+ """Compact the current chat history into a summarized message."""
9
+
10
+ async def process(self, input: Input, request: Request) -> Output:
11
+ ctxid = input.get("context", "")
12
+ action = input.get("action", "compact")
13
+
14
+ if not ctxid:
15
+ return Response("Missing context id", 400)
16
+
17
+ context = AgentContext.get(ctxid)
18
+ if not context:
19
+ return Response("Context not found", 404)
20
+
21
+ # Validate context is not running
22
+ if context.is_running():
23
+ return Response("Cannot compact while agent is running", 409)
24
+
25
+ # Check if there's enough content to compact
26
+ # Count user-visible log items (what the user sees in the UI)
27
+ visible_count = len(context.log.logs)
28
+ if visible_count <= 1:
29
+ return Response("Not enough messages to compact", 400)
30
+
31
+ # Force reload compactor module to pick up latest changes
32
+ import usr.plugins.compaction.helpers.compactor as _compactor_mod
33
+ importlib.reload(_compactor_mod)
34
+
35
+ if action == "stats":
36
+ # Return statistics for confirmation modal
37
+ stats = await _compactor_mod.get_compaction_stats(context)
38
+ return {"ok": True, "stats": stats}
39
+
40
+ elif action == "compact":
41
+ # Get plugin config for model choice
42
+ from helpers.plugins import get_plugin_config
43
+ agent = context.agent0
44
+ plugin_config = get_plugin_config("compaction", agent=agent) or {}
45
+ use_chat_model = plugin_config.get("use_chat_model", True)
46
+
47
+ # Start compaction as a deferred task
48
+ context.run_task(_run_compaction_task, context, use_chat_model)
49
+
50
+ return {"ok": True, "message": "Compaction started"}
51
+
52
+ else:
53
+ return Response(f"Unknown action: {action}", 400)
54
+
55
+
56
+async def _run_compaction_task(context, use_chat_model: bool):
57
+ """Wrapper to run compaction and handle errors."""
58
+ try:
59
+ import importlib
60
+ import usr.plugins.compaction.helpers.compactor as _compactor_mod
61
+ importlib.reload(_compactor_mod)
62
+ await _compactor_mod.run_compaction(context, use_chat_model)
63
+ except Exception as e:
64
+ # Log error but don't crash the task
65
+ context.log.log(
66
+ type="error",
67
+ heading="Compaction Failed",
68
+ content=str(e),
69
+ )
70
+ from helpers.state_monitor_integration import mark_dirty_all
71
+ mark_dirty_all(reason="plugins.compaction.compact_chat_error")
plugins/chat_compaction/compaction/.toggle-1
plugins/chat_compaction/compaction/api/compact_chat.py
new
+71
@@ -0,0 +1,71 @@
1
+"""API handler for chat compaction."""
2
+import importlib
3
+from helpers.api import ApiHandler, Input, Output, Request, Response
4
+from agent import AgentContext
5
+
6
+
7
+class CompactChat(ApiHandler):
8
+ """Compact the current chat history into a summarized message."""
9
+
10
+ async def process(self, input: Input, request: Request) -> Output:
11
+ ctxid = input.get("context", "")
12
+ action = input.get("action", "compact")
13
+
14
+ if not ctxid:
15
+ return Response("Missing context id", 400)
16
+
17
+ context = AgentContext.get(ctxid)
18
+ if not context:
19
+ return Response("Context not found", 404)
20
+
21
+ # Validate context is not running
22
+ if context.is_running():
23
+ return Response("Cannot compact while agent is running", 409)
24
+
25
+ # Check if there's enough content to compact
26
+ # Count user-visible log items (what the user sees in the UI)
27
+ visible_count = len(context.log.logs)
28
+ if visible_count <= 1:
29
+ return Response("Not enough messages to compact", 400)
30
+
31
+ # Force reload compactor module to pick up latest changes
32
+ import usr.plugins.compaction.helpers.compactor as _compactor_mod
33
+ importlib.reload(_compactor_mod)
34
+
35
+ if action == "stats":
36
+ # Return statistics for confirmation modal
37
+ stats = await _compactor_mod.get_compaction_stats(context)
38
+ return {"ok": True, "stats": stats}
39
+
40
+ elif action == "compact":
41
+ # Get plugin config for model choice
42
+ from helpers.plugins import get_plugin_config
43
+ agent = context.agent0
44
+ plugin_config = get_plugin_config("compaction", agent=agent) or {}
45
+ use_chat_model = plugin_config.get("use_chat_model", True)
46
+
47
+ # Start compaction as a deferred task
48
+ context.run_task(_run_compaction_task, context, use_chat_model)
49
+
50
+ return {"ok": True, "message": "Compaction started"}
51
+
52
+ else:
53
+ return Response(f"Unknown action: {action}", 400)
54
+
55
+
56
+async def _run_compaction_task(context, use_chat_model: bool):
57
+ """Wrapper to run compaction and handle errors."""
58
+ try:
59
+ import importlib
60
+ import usr.plugins.compaction.helpers.compactor as _compactor_mod
61
+ importlib.reload(_compactor_mod)
62
+ await _compactor_mod.run_compaction(context, use_chat_model)
63
+ except Exception as e:
64
+ # Log error but don't crash the task
65
+ context.log.log(
66
+ type="error",
67
+ heading="Compaction Failed",
68
+ content=str(e),
69
+ )
70
+ from helpers.state_monitor_integration import mark_dirty_all
71
+ mark_dirty_all(reason="plugins.compaction.compact_chat_error")
plugins/chat_compaction/compaction/default_config.yaml
new
+1
@@ -0,0 +1 @@
1
+use_chat_model: true
plugins/chat_compaction/compaction/extensions/webui/chat-input-bottom-actions-start/compact-button.html
new
+282
@@ -0,0 +1,282 @@
1
+<html>
2
+<head>
3
+ <title>Compact Button Extension</title>
4
+ <script type="module" src="/plugins/compaction/webui/compact-store.js"></script>
5
+</head>
6
+<body>
7
+ <div x-data="{
8
+ get disabled() {
9
+ const ctx = globalThis.getContext?.();
10
+ const store = Alpine?.store?.('compactStore');
11
+ const chatInput = Alpine?.store?.('chatInput');
12
+ return !ctx || store?.compacting || chatInput?.running;
13
+ },
14
+ get disabledReason() {
15
+ const ctx = globalThis.getContext?.();
16
+ const store = Alpine?.store?.('compactStore');
17
+ const chatInput = Alpine?.store?.('chatInput');
18
+ if (!ctx) return 'No active chat selected';
19
+ if (store?.compacting) return 'Compaction in progress';
20
+ if (chatInput?.running) return 'Cannot compact while agent is running';
21
+ return 'Compact chat history into a single summary';
22
+ }
23
+ }">
24
+ <template x-if="$store.compactStore">
25
+ <button
26
+ class="text-button"
27
+ @click="$store.compactStore.fetchStats()"
28
+ :disabled="disabled"
29
+ :title="disabledReason"
30
+ >
31
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" width="14" height="14" aria-hidden="true">
32
+ <polyline points="4 14 10 14 10 20"/><line x1="3" y1="21" x2="10" y2="14"/>
33
+ <polyline points="20 10 14 10 14 4"/><line x1="21" y1="3" x2="14" y2="10"/>
34
+ </svg>
35
+ <p>Compact</p>
36
+ </button>
37
+ </template>
38
+ </div>
39
+
40
+ <!-- Modal with unique class names to avoid global CSS collision -->
41
+ <div x-data x-show="$store.compactStore?.showModal" style="display: none;">
42
+ <div class="cmpct-overlay" @click="$store.compactStore?.closeModal()">
43
+ <div class="cmpct-dialog" @click.stop>
44
+ <div class="cmpct-header">
45
+ <h3>Compact Chat History</h3>
46
+ <button class="cmpct-close" @click="$store.compactStore?.closeModal()">
47
+ <span class="material-symbols-outlined">close</span>
48
+ </button>
49
+ </div>
50
+
51
+ <div class="cmpct-body">
52
+ <template x-if="$store.compactStore?.stats">
53
+ <div>
54
+ <p class="cmpct-desc">
55
+ This will summarize your entire conversation into a single optimized message.
56
+ </p>
57
+
58
+ <div class="cmpct-stats">
59
+ <div class="cmpct-stat">
60
+ <span class="cmpct-stat-label">Messages</span>
61
+ <span class="cmpct-stat-value" x-text="$store.compactStore?.stats?.message_count"></span>
62
+ </div>
63
+ <div class="cmpct-stat">
64
+ <span class="cmpct-stat-label">Tokens</span>
65
+ <span class="cmpct-stat-value" x-text="$store.compactStore?.stats?.token_count?.toLocaleString()"></span>
66
+ </div>
67
+ <div class="cmpct-stat">
68
+ <span class="cmpct-stat-label">Model</span>
69
+ <span class="cmpct-stat-value" x-text="$store.compactStore?.stats?.model_name"></span>
70
+ </div>
71
+ </div>
72
+
73
+ <div class="cmpct-warning">
74
+ <span class="material-symbols-outlined">warning</span>
75
+ <span>This action cannot be undone. The original conversation will be replaced with a summary.</span>
76
+ </div>
77
+ </div>
78
+ </template>
79
+
80
+ <template x-if="!$store.compactStore?.stats">
81
+ <div class="cmpct-loading">
82
+ <span class="cmpct-spinner"></span>
83
+ <span>Loading statistics...</span>
84
+ </div>
85
+ </template>
86
+ </div>
87
+
88
+ <div class="cmpct-footer">
89
+ <button class="cmpct-btn cmpct-btn-cancel" @click="$store.compactStore?.closeModal()">
90
+ Cancel
91
+ </button>
92
+ <button
93
+ class="cmpct-btn cmpct-btn-danger"
94
+ @click="$store.compactStore?.compact()"
95
+ :disabled="$store.compactStore?.compacting || !$store.compactStore?.stats"
96
+ >
97
+ <template x-if="$store.compactStore?.compacting">
98
+ <span class="cmpct-spinner"></span>
99
+ </template>
100
+ <span>Compact</span>
101
+ </button>
102
+ </div>
103
+ </div>
104
+ </div>
105
+ </div>
106
+
107
+ <style>
108
+ /* All classes prefixed with cmpct- to avoid global CSS collision */
109
+ .cmpct-overlay {
110
+ position: fixed;
111
+ top: 0; left: 0; right: 0; bottom: 0;
112
+ background: rgba(0, 0, 0, 0.5);
113
+ display: flex;
114
+ align-items: center;
115
+ justify-content: center;
116
+ z-index: 2002;
117
+ }
118
+
119
+ .cmpct-dialog {
120
+ background: var(--color-panel, #1a1a1a);
121
+ border-radius: 12px;
122
+ box-shadow: 0 4px 23px rgba(0, 0, 0, 0.3);
123
+ width: 90%;
124
+ max-width: 420px;
125
+ overflow: hidden;
126
+ }
127
+
128
+ .cmpct-header {
129
+ display: flex;
130
+ align-items: center;
131
+ justify-content: space-between;
132
+ padding: 16px 20px;
133
+ border-bottom: 1px solid var(--color-border, #333);
134
+ }
135
+
136
+ .cmpct-header h3 {
137
+ margin: 0;
138
+ font-size: 1.1rem;
139
+ font-weight: 600;
140
+ color: var(--color-text, #e5e5e5);
141
+ }
142
+
143
+ .cmpct-close {
144
+ background: transparent;
145
+ border: none;
146
+ cursor: pointer;
147
+ padding: 4px;
148
+ border-radius: 4px;
149
+ color: var(--color-text-secondary, #999);
150
+ transition: color 0.2s;
151
+ }
152
+
153
+ .cmpct-close:hover {
154
+ color: var(--color-text, #e5e5e5);
155
+ }
156
+
157
+ .cmpct-body {
158
+ padding: 20px;
159
+ }
160
+
161
+ .cmpct-desc {
162
+ margin: 0 0 16px 0;
163
+ color: var(--color-text-secondary, #999);
164
+ font-size: 0.9rem;
165
+ }
166
+
167
+ .cmpct-stats {
168
+ display: grid;
169
+ grid-template-columns: repeat(3, 1fr);
170
+ gap: 12px;
171
+ margin-bottom: 16px;
172
+ }
173
+
174
+ .cmpct-stat {
175
+ display: flex;
176
+ flex-direction: column;
177
+ align-items: center;
178
+ padding: 12px 8px;
179
+ background: var(--color-background-muted, #252525);
180
+ border-radius: 8px;
181
+ }
182
+
183
+ .cmpct-stat-label {
184
+ font-size: 0.7rem;
185
+ color: var(--color-text-secondary, #999);
186
+ text-transform: uppercase;
187
+ letter-spacing: 0.05em;
188
+ margin-bottom: 4px;
189
+ }
190
+
191
+ .cmpct-stat-value {
192
+ font-size: 1.1rem;
193
+ font-weight: 600;
194
+ color: var(--color-text, #e5e5e5);
195
+ }
196
+
197
+ .cmpct-warning {
198
+ display: flex;
199
+ align-items: flex-start;
200
+ gap: 8px;
201
+ padding: 10px 12px;
202
+ background: rgba(245, 158, 11, 0.1);
203
+ border: 1px solid rgba(245, 158, 11, 0.3);
204
+ border-radius: 8px;
205
+ color: #f59e0b;
206
+ font-size: 0.8rem;
207
+ }
208
+
209
+ .cmpct-warning .material-symbols-outlined {
210
+ font-size: 1.1rem;
211
+ flex-shrink: 0;
212
+ }
213
+
214
+ .cmpct-loading {
215
+ display: flex;
216
+ align-items: center;
217
+ justify-content: center;
218
+ gap: 8px;
219
+ padding: 30px;
220
+ color: var(--color-text-secondary, #999);
221
+ }
222
+
223
+ .cmpct-footer {
224
+ display: flex;
225
+ justify-content: flex-end;
226
+ gap: 8px;
227
+ padding: 12px 20px;
228
+ border-top: 1px solid var(--color-border, #333);
229
+ }
230
+
231
+ .cmpct-btn {
232
+ padding: 8px 16px;
233
+ border-radius: 6px;
234
+ font-size: 0.9rem;
235
+ font-weight: 500;
236
+ cursor: pointer;
237
+ transition: all 0.2s;
238
+ display: flex;
239
+ align-items: center;
240
+ gap: 6px;
241
+ border: none;
242
+ }
243
+
244
+ .cmpct-btn:disabled {
245
+ opacity: 0.6;
246
+ cursor: not-allowed;
247
+ }
248
+
249
+ .cmpct-btn-cancel {
250
+ background: var(--color-background-muted, #333);
251
+ color: var(--color-text, #e5e5e5);
252
+ }
253
+
254
+ .cmpct-btn-cancel:hover:not(:disabled) {
255
+ background: var(--color-background-hover, #444);
256
+ }
257
+
258
+ .cmpct-btn-danger {
259
+ background: #dc2626;
260
+ color: white;
261
+ }
262
+
263
+ .cmpct-btn-danger:hover:not(:disabled) {
264
+ background: #b91c1c;
265
+ }
266
+
267
+ .cmpct-spinner {
268
+ width: 16px;
269
+ height: 16px;
270
+ border: 2px solid currentColor;
271
+ border-top-color: transparent;
272
+ border-radius: 50%;
273
+ animation: cmpct-spin 0.8s linear infinite;
274
+ display: inline-block;
275
+ }
276
+
277
+ @keyframes cmpct-spin {
278
+ to { transform: rotate(360deg); }
279
+ }
280
+ </style>
281
+</body>
282
+</html>
plugins/chat_compaction/compaction/helpers/compactor.py
new
+270
@@ -0,0 +1,270 @@
1
+"""Core compaction logic for the compaction plugin."""
2
+import asyncio
3
+from typing import Callable
4
+
5
+from agent import Agent
6
+from helpers import history, tokens
7
+from helpers.history import History, output_text
8
+from helpers.log import Log
9
+from helpers.persist_chat import save_tmp_chat, remove_msg_files
10
+from helpers.state_monitor_integration import mark_dirty_all
11
+from plugins._model_config.helpers.model_config import get_chat_model_config
12
+
13
+
14
+
15
+async def run_compaction(context, use_chat_model: bool = True) -> None:
16
+ """
17
+ Compact the chat history into a single summarized message.
18
+
19
+ This function:
20
+ 1. Extracts the full conversation text
21
+ 2. Estimates token count and checks against model context window
22
+ 3. If needed, splits history and summarizes iteratively
23
+ 4. Calls the LLM to generate a comprehensive summary
24
+ 5. Replaces the history with a single AI message containing the summary
25
+ 6. Resets the log and creates a response log item
26
+ 7. Persists the changes
27
+
28
+ The function streams progress to the frontend via the log system.
29
+ If any error occurs, the original history is preserved.
30
+ """
31
+ agent = context.agent0
32
+
33
+ try:
34
+ # Step 1: Extract full conversation text
35
+ history_output = agent.history.output()
36
+ full_text = output_text(history_output, ai_label="assistant", human_label="user")
37
+
38
+ if not full_text.strip():
39
+ raise ValueError("No conversation content to compact")
40
+
41
+ # Step 2: Estimate tokens and get model config
42
+ token_count = tokens.approximate_tokens(full_text)
43
+
44
+ model_config = get_chat_model_config() if use_chat_model else None
45
+ if model_config is None:
46
+ # Fallback: use default context length
47
+ ctx_length = 128000
48
+ else:
49
+ ctx_length = int(model_config.get("ctx_length", 128000))
50
+
51
+ # Leave some buffer for the prompt and response
52
+ max_input_tokens = int(ctx_length * 0.7)
53
+
54
+ # Step 3: Create progress log item
55
+ log_item = context.log.log(
56
+ type="info",
57
+ heading="Compacting chat history...",
58
+ content=f"Analyzing {len(agent.history.current.messages)} messages (~{token_count} tokens)...",
59
+ )
60
+
61
+ # Step 4: Handle large histories by chunking if necessary
62
+ if token_count > max_input_tokens:
63
+ summary = await _compact_large_history(
64
+ agent, full_text, token_count, max_input_tokens, log_item, use_chat_model
65
+ )
66
+ else:
67
+ # Single-pass compaction
68
+ summary = await _compact_single_pass(
69
+ agent, full_text, log_item, use_chat_model
70
+ )
71
+
72
+ if not summary or not summary.strip():
73
+ raise ValueError("Compaction produced empty summary")
74
+
75
+ # Step 5: Replace history with compacted version
76
+ agent.history = History(agent=agent)
77
+ agent.history.add_message(
78
+ ai=True,
79
+ content=f"# Chat Compacted\n\n{summary}"
80
+ )
81
+
82
+ # Clear subordinate chain
83
+ agent.data.pop(Agent.DATA_NAME_SUBORDINATE, None)
84
+ context.streaming_agent = None
85
+
86
+ # Step 6: Reset log and create response
87
+ context.log.reset()
88
+ context.log.log(
89
+ type="response",
90
+ heading="Chat Compacted",
91
+ content=summary,
92
+ update_progress="none",
93
+ )
94
+
95
+ # Step 7: Persist and notify
96
+ save_tmp_chat(context)
97
+ remove_msg_files(context.id)
98
+
99
+ # Step 8: Force progress bar to inactive state LAST
100
+ # This must happen after all log operations and persist
101
+ context.log.set_progress("Waiting for input", 0, False)
102
+ mark_dirty_all(reason="plugins.compaction.compact_chat")
103
+
104
+ except Exception as e:
105
+ # Log error but don't modify history
106
+ context.log.log(
107
+ type="error",
108
+ heading="Compaction Failed",
109
+ content=str(e),
110
+ )
111
+ mark_dirty_all(reason="plugins.compaction.compact_chat_error")
112
+ raise
113
+
114
+
115
+async def _compact_single_pass(
116
+ agent,
117
+ full_text: str,
118
+ log_item,
119
+ use_chat_model: bool
120
+) -> str:
121
+ """Compact history in a single LLM call."""
122
+
123
+ system_prompt = agent.read_prompt("compact.sys.md")
124
+ user_prompt = agent.read_prompt("compact.msg.md", conversation=full_text)
125
+
126
+ if use_chat_model:
127
+ from langchain_core.messages import HumanMessage, SystemMessage
128
+ messages = [
129
+ SystemMessage(content=system_prompt),
130
+ HumanMessage(content=user_prompt)
131
+ ]
132
+
133
+ async def chat_stream_cb(chunk: str, total: str):
134
+ if chunk:
135
+ log_item.stream(content=chunk)
136
+
137
+ summary, _ = await agent.call_chat_model(
138
+ messages=messages,
139
+ response_callback=chat_stream_cb,
140
+ )
141
+ else:
142
+ async def util_stream_cb(chunk: str):
143
+ if chunk:
144
+ log_item.stream(content=chunk)
145
+
146
+ summary = await agent.call_utility_model(
147
+ system=system_prompt,
148
+ message=user_prompt,
149
+ callback=util_stream_cb,
150
+ )
151
+
152
+ return summary
153
+
154
+
155
+async def _compact_large_history(
156
+ agent,
157
+ full_text: str,
158
+ token_count: int,
159
+ max_input_tokens: int,
160
+ log_item,
161
+ use_chat_model: bool
162
+) -> str:
163
+ """
164
+ Handle large histories by splitting into chunks and summarizing iteratively.
165
+ """
166
+ log_item.update(
167
+ content=f"History is large (~{token_count} tokens). Splitting into chunks...",
168
+ )
169
+
170
+ # Split conversation into roughly equal halves
171
+ lines = full_text.split('\n')
172
+ mid = len(lines) // 2
173
+
174
+ chunks = [
175
+ '\n'.join(lines[:mid]),
176
+ '\n'.join(lines[mid:])
177
+ ]
178
+
179
+ summaries = []
180
+ for i, chunk in enumerate(chunks, 1):
181
+ log_item.update(
182
+ content=f"Summarizing part {i}/{len(chunks)}...",
183
+ )
184
+
185
+ system_prompt = agent.read_prompt("compact.sys.md")
186
+ user_prompt = agent.read_prompt("compact.msg.md", conversation=chunk)
187
+
188
+ if use_chat_model:
189
+ from langchain_core.messages import HumanMessage, SystemMessage
190
+ messages = [
191
+ SystemMessage(content=system_prompt),
192
+ HumanMessage(content=user_prompt)
193
+ ]
194
+ chunk_summary, _ = await agent.call_chat_model(
195
+ messages=messages,
196
+ response_callback=None, # No streaming for chunks
197
+ )
198
+ else:
199
+ chunk_summary = await agent.call_utility_model(
200
+ system=system_prompt,
201
+ message=user_prompt,
202
+ callback=None,
203
+ )
204
+
205
+ summaries.append(chunk_summary)
206
+
207
+ # Combine summaries
208
+ combined = "\n\n---\n\n".join(summaries)
209
+
210
+ log_item.update(
211
+ content="Creating final summary from parts...",
212
+ )
213
+
214
+ # Final compaction of combined summaries
215
+ final_prompt = agent.read_prompt("compact.sys.md")
216
+ final_user = agent.read_prompt(
217
+ "compact.msg.md",
218
+ conversation=f"This is a multi-part conversation. Here are summaries of each part:\n\n{combined}"
219
+ )
220
+
221
+ if use_chat_model:
222
+ from langchain_core.messages import HumanMessage, SystemMessage
223
+ messages = [
224
+ SystemMessage(content=final_prompt),
225
+ HumanMessage(content=final_user)
226
+ ]
227
+ final_summary, _ = await agent.call_chat_model(
228
+ messages=messages,
229
+ response_callback=lambda chunk, total: log_item.stream(content=chunk),
230
+ )
231
+ else:
232
+ final_summary = await agent.call_utility_model(
233
+ system=final_prompt,
234
+ message=final_user,
235
+ callback=lambda chunk: log_item.stream(content=chunk),
236
+ )
237
+
238
+ return final_summary
239
+async def get_compaction_stats(context) -> dict:
240
+ """
241
+ Get statistics about the current chat for the confirmation modal.
242
+
243
+ Returns:
244
+ dict with message_count, token_count, model_name
245
+ """
246
+ agent = context.agent0
247
+
248
+ # Count user-visible conversation turns only
249
+ # 'user' = user sent a message, 'response' = agent final response
250
+ # Other types (agent, tool, code_exe, etc.) are intermediate processing steps
251
+ visible_types = {"user", "response"}
252
+ message_count = sum(
253
+ 1 for item in context.log.logs
254
+ if item.type in visible_types
255
+ )
256
+
257
+ # Estimate tokens
258
+ history_output = agent.history.output()
259
+ full_text = output_text(history_output, ai_label="assistant", human_label="user")
260
+ token_count = tokens.approximate_tokens(full_text) if full_text else 0
261
+
262
+ # Get model name
263
+ model_config = get_chat_model_config()
264
+ model_name = model_config.get("name", "Default Model") if model_config else "Utility Model"
265
+
266
+ return {
267
+ "message_count": message_count,
268
+ "token_count": token_count,
269
+ "model_name": model_name,
270
+ }
plugins/chat_compaction/compaction/plugin.yaml
new
+8
@@ -0,0 +1,8 @@
1
+name: compaction
2
+title: Chat Compaction
3
+description: Compact entire chat history into a single optimized summary message.
4
+version: 1.0.0
5
+settings_sections:
6
+ - agent
7
+per_project_config: false
8
+per_agent_config: false
plugins/chat_compaction/compaction/prompts/compact.msg.md
new
+5
@@ -0,0 +1,5 @@
1
+Compact this conversation to its essential facts. Be maximally concise.
2
+
3
+---
4
+{{conversation}}
5
+---
plugins/chat_compaction/compaction/prompts/compact.sys.md
new
+17
@@ -0,0 +1,17 @@
1
+You are a conversation compactor. Produce the most concise summary possible while preserving critical information.
2
+
3
+Rules:
4
+- Extract only: key decisions, final outcomes, actionable facts, unresolved items
5
+- Discard: intermediate reasoning, failed attempts, redundant exchanges, pleasantries
6
+- Use terse bullet points, not prose
7
+- Collapse related items into single lines
8
+- Keep exact values: file paths, config values, code identifiers, credentials, URLs
9
+- Omit anything that can be re-derived from context
10
+- Group by topic, not chronology
11
+- No meta-commentary about the summarization
12
+- Target: 10-20% of original length
13
+
14
+Output format:
15
+- Markdown with short section headers
16
+- Bullet lists, no paragraphs
17
+- Code/paths in backticks inline, not fenced blocks unless multi-line
plugins/chat_compaction/compaction/webui/compact-modal.html
new
+261
@@ -0,0 +1,261 @@
1
+<html>
2
+<head>
3
+ <title>Compact Chat</title>
4
+</head>
5
+<body>
6
+ <div x-data x-show="$store.compactStore?.showModal" style="display: none;">
7
+ <div class="modal-overlay" @click="$store.compactStore?.closeModal()">
8
+ <div class="modal-container" @click.stop>
9
+ <div class="modal-header">
10
+ <h3>Compact Chat History</h3>
11
+ <button class="modal-close" @click="$store.compactStore?.closeModal()">
12
+ <span class="material-symbols-outlined">close</span>
13
+ </button>
14
+ </div>
15
+
16
+ <div class="modal-content">
17
+ <template x-if="$store.compactStore?.stats">
18
+ <div class="stats-container">
19
+ <p class="stats-description">
20
+ This will summarize your entire conversation into a single optimized message.
21
+ </p>
22
+
23
+ <div class="stats-grid">
24
+ <div class="stat-item">
25
+ <span class="stat-label">Messages</span>
26
+ <span class="stat-value" x-text="$store.compactStore?.stats?.message_count"></span>
27
+ </div>
28
+ <div class="stat-item">
29
+ <span class="stat-label">Tokens</span>
30
+ <span class="stat-value" x-text="$store.compactStore?.stats?.token_count?.toLocaleString()"></span>
31
+ </div>
32
+ <div class="stat-item">
33
+ <span class="stat-label">Model</span>
34
+ <span class="stat-value" x-text="$store.compactStore?.stats?.model_name"></span>
35
+ </div>
36
+ </div>
37
+
38
+ <div class="stats-warning">
39
+ <span class="material-symbols-outlined">warning</span>
40
+ <span>This action cannot be undone. The original conversation will be replaced with a summary.</span>
41
+ </div>
42
+ </div>
43
+ </template>
44
+
45
+ <template x-if="!$store.compactStore?.stats">
46
+ <div class="stats-loading">
47
+ <span class="loading-spinner"></span>
48
+ <span>Loading statistics...</span>
49
+ </div>
50
+ </template>
51
+ </div>
52
+
53
+ <div class="modal-footer">
54
+ <button class="button secondary" @click="$store.compactStore?.closeModal()">
55
+ Cancel
56
+ </button>
57
+ <button
58
+ class="button primary danger"
59
+ @click="$store.compactStore?.compact()"
60
+ :disabled="$store.compactStore?.compacting || !$store.compactStore?.stats"
61
+ >
62
+ <template x-if="$store.compactStore?.compacting">
63
+ <span class="loading-spinner"></span>
64
+ </template>
65
+ <span>Compact</span>
66
+ </button>
67
+ </div>
68
+ </div>
69
+ </div>
70
+ </div>
71
+
72
+ <style>
73
+ .modal-overlay {
74
+ position: fixed;
75
+ top: 0;
76
+ left: 0;
77
+ right: 0;
78
+ bottom: 0;
79
+ background: rgba(0, 0, 0, 0.5);
80
+ display: flex;
81
+ align-items: center;
82
+ justify-content: center;
83
+ z-index: 1000;
84
+ }
85
+
86
+ .modal-container {
87
+ background: var(--color-background-elevated);
88
+ border-radius: var(--border-radius-md);
89
+ box-shadow: var(--shadow-lg);
90
+ width: 90%;
91
+ max-width: 500px;
92
+ max-height: 90vh;
93
+ overflow-y: auto;
94
+ }
95
+
96
+ .modal-header {
97
+ display: flex;
98
+ align-items: center;
99
+ justify-content: space-between;
100
+ padding: var(--spacing-md) var(--spacing-lg);
101
+ border-bottom: 1px solid var(--color-border);
102
+ }
103
+
104
+ .modal-header h3 {
105
+ margin: 0;
106
+ font-size: 1.1rem;
107
+ font-weight: 600;
108
+ }
109
+
110
+ .modal-close {
111
+ background: transparent;
112
+ border: none;
113
+ cursor: pointer;
114
+ padding: var(--spacing-xs);
115
+ border-radius: var(--border-radius-sm);
116
+ color: var(--color-text-secondary);
117
+ transition: color 0.2s;
118
+ }
119
+
120
+ .modal-close:hover {
121
+ color: var(--color-text);
122
+ }
123
+
124
+ .modal-content {
125
+ padding: var(--spacing-lg);
126
+ }
127
+
128
+ .stats-description {
129
+ margin: 0 0 var(--spacing-md) 0;
130
+ color: var(--color-text-secondary);
131
+ font-size: 0.9rem;
132
+ }
133
+
134
+ .stats-grid {
135
+ display: grid;
136
+ grid-template-columns: repeat(3, 1fr);
137
+ gap: var(--spacing-md);
138
+ margin-bottom: var(--spacing-lg);
139
+ }
140
+
141
+ .stat-item {
142
+ display: flex;
143
+ flex-direction: column;
144
+ align-items: center;
145
+ padding: var(--spacing-md);
146
+ background: var(--color-background-muted);
147
+ border-radius: var(--border-radius-md);
148
+ }
149
+
150
+ .stat-label {
151
+ font-size: 0.75rem;
152
+ color: var(--color-text-secondary);
153
+ text-transform: uppercase;
154
+ letter-spacing: 0.05em;
155
+ margin-bottom: var(--spacing-xs);
156
+ }
157
+
158
+ .stat-value {
159
+ font-size: 1.25rem;
160
+ font-weight: 600;
161
+ color: var(--color-text);
162
+ }
163
+
164
+ .stats-warning {
165
+ display: flex;
166
+ align-items: flex-start;
167
+ gap: var(--spacing-sm);
168
+ padding: var(--spacing-md);
169
+ background: var(--color-warning-bg, rgba(245, 158, 11, 0.1));
170
+ border: 1px solid var(--color-warning-border, rgba(245, 158, 11, 0.3));
171
+ border-radius: var(--border-radius-md);
172
+ color: var(--color-warning-text, #92400e);
173
+ font-size: 0.85rem;
174
+ }
175
+
176
+ .stats-warning .material-symbols-outlined {
177
+ font-size: 1.2rem;
178
+ flex-shrink: 0;
179
+ }
180
+
181
+ .stats-loading {
182
+ display: flex;
183
+ align-items: center;
184
+ justify-content: center;
185
+ gap: var(--spacing-sm);
186
+ padding: var(--spacing-xl);
187
+ color: var(--color-text-secondary);
188
+ }
189
+
190
+ .modal-footer {
191
+ display: flex;
192
+ justify-content: flex-end;
193
+ gap: var(--spacing-sm);
194
+ padding: var(--spacing-md) var(--spacing-lg);
195
+ border-top: 1px solid var(--color-border);
196
+ }
197
+
198
+ .button {
199
+ padding: var(--spacing-sm) var(--spacing-md);
200
+ border-radius: var(--border-radius-md);
201
+ font-size: 0.9rem;
202
+ font-weight: 500;
203
+ cursor: pointer;
204
+ transition: all 0.2s;
205
+ display: flex;
206
+ align-items: center;
207
+ gap: var(--spacing-xs);
208
+ }
209
+
210
+ .button:disabled {
211
+ opacity: 0.6;
212
+ cursor: not-allowed;
213
+ }
214
+
215
+ .button.secondary {
216
+ background: var(--color-background-muted);
217
+ border: 1px solid var(--color-border);
218
+ color: var(--color-text);
219
+ }
220
+
221
+ .button.secondary:hover:not(:disabled) {
222
+ background: var(--color-background-hover);
223
+ }
224
+
225
+ .button.primary {
226
+ background: var(--color-primary);
227
+ border: none;
228
+ color: white;
229
+ }
230
+
231
+ .button.primary:hover:not(:disabled) {
232
+ background: var(--color-primary-hover);
233
+ }
234
+
235
+ .button.danger {
236
+ background: var(--color-danger, #dc2626);
237
+ }
238
+
239
+ .button.danger:hover:not(:disabled) {
240
+ background: var(--color-danger-hover, #b91c1c);
241
+ }
242
+
243
+ .loading-spinner {
244
+ width: 16px;
245
+ height: 16px;
246
+ border: 2px solid currentColor;
247
+ border-top-color: transparent;
248
+ border-radius: 50%;
249
+ animation: spin 0.8s linear infinite;
250
+ }
251
+
252
+ @keyframes spin {
253
+ to { transform: rotate(360deg); }
254
+ }
255
+ </style>
256
+
257
+ <script type="module">
258
+ import { store } from "/plugins/compaction/webui/compact-store.js";
259
+ </script>
260
+</body>
261
+</html>
plugins/chat_compaction/compaction/webui/compact-store.js
new
+67
@@ -0,0 +1,67 @@
1
+import { createStore } from "/js/AlpineStore.js";
2
+import { callJsonApi } from "/js/api.js";
3
+import {
4
+ toastFrontendSuccess,
5
+ toastFrontendError,
6
+} from "/components/notifications/notification-store.js";
7
+
8
+export const store = createStore("compactStore", {
9
+ compacting: false,
10
+ stats: null,
11
+ showModal: false,
12
+
13
+ async fetchStats() {
14
+ try {
15
+ const ctxid = globalThis.getContext?.();
16
+ if (!ctxid) {
17
+ toastFrontendError("No active chat", "Compaction");
18
+ return;
19
+ }
20
+
21
+ const res = await callJsonApi("/plugins/compaction/compact_chat", {
22
+ context: ctxid,
23
+ action: "stats",
24
+ });
25
+
26
+ if (!res?.ok) {
27
+ throw new Error(res?.message || "Failed to fetch stats");
28
+ }
29
+
30
+ this.stats = res.stats;
31
+ this.showModal = true;
32
+ } catch (e) {
33
+ toastFrontendError(e.message, "Compaction");
34
+ }
35
+ },
36
+
37
+ async compact() {
38
+ this.compacting = true;
39
+ try {
40
+ const ctxid = globalThis.getContext?.();
41
+ if (!ctxid) {
42
+ throw new Error("No active chat");
43
+ }
44
+
45
+ const res = await callJsonApi("/plugins/compaction/compact_chat", {
46
+ context: ctxid,
47
+ action: "compact",
48
+ });
49
+
50
+ if (!res?.ok) {
51
+ throw new Error(res?.message || "Compaction failed");
52
+ }
53
+
54
+ toastFrontendSuccess("Compaction started", "Compaction");
55
+ this.showModal = false;
56
+ } catch (e) {
57
+ toastFrontendError(e.message, "Compaction");
58
+ } finally {
59
+ this.compacting = false;
60
+ }
61
+ },
62
+
63
+ closeModal() {
64
+ this.showModal = false;
65
+ this.stats = null;
66
+ },
67
+});
plugins/chat_compaction/compaction/webui/config.html
new
+104
@@ -0,0 +1,104 @@
1
+<html>
2
+<head>
3
+ <title>Compaction Plugin Settings</title>
4
+</head>
5
+<body>
6
+ <div x-data>
7
+ <template x-if="config">
8
+ <div>
9
+ <div class="section-title">Compaction Configuration</div>
10
+ <div class="section-description">
11
+ Configure how the chat compaction feature works.
12
+ </div>
13
+
14
+ <div class="field">
15
+ <div class="field-label">
16
+ <div class="field-title">Use chat model</div>
17
+ <div class="field-description">
18
+ When enabled, uses the currently selected chat model for compaction.
19
+ When disabled, uses the utility model (usually faster and cheaper).
20
+ </div>
21
+ </div>
22
+ <div class="field-control">
23
+ <label class="toggle">
24
+ <input type="checkbox" x-model="config.use_chat_model" />
25
+ <span class="toggler"></span>
26
+ </label>
27
+ </div>
28
+ </div>
29
+ </div>
30
+ </template>
31
+ </div>
32
+
33
+ <style>
34
+ .section-title {
35
+ font-size: 1rem;
36
+ font-weight: 600;
37
+ margin-bottom: 8px;
38
+ color: var(--color-text, #e5e5e5);
39
+ }
40
+ .section-description {
41
+ font-size: 0.85rem;
42
+ color: var(--color-text-secondary, #999);
43
+ margin-bottom: 20px;
44
+ line-height: 1.5;
45
+ }
46
+ .field {
47
+ margin-bottom: 20px;
48
+ }
49
+ .field-label {
50
+ margin-bottom: 8px;
51
+ }
52
+ .field-title {
53
+ font-weight: 500;
54
+ color: var(--color-text, #e5e5e5);
55
+ margin-bottom: 4px;
56
+ }
57
+ .field-description {
58
+ font-size: 0.8rem;
59
+ color: var(--color-text-secondary, #999);
60
+ line-height: 1.4;
61
+ }
62
+ .field-control {
63
+ margin-top: 8px;
64
+ }
65
+ .toggle {
66
+ display: inline-flex;
67
+ align-items: center;
68
+ cursor: pointer;
69
+ }
70
+ .toggle input {
71
+ display: none;
72
+ }
73
+ .toggler {
74
+ width: 44px;
75
+ height: 24px;
76
+ background: var(--color-border, #444);
77
+ border-radius: 12px;
78
+ position: relative;
79
+ transition: background 0.2s;
80
+ }
81
+ .toggler::after {
82
+ content: '';
83
+ position: absolute;
84
+ width: 20px;
85
+ height: 20px;
86
+ background: white;
87
+ border-radius: 50%;
88
+ top: 2px;
89
+ left: 2px;
90
+ transition: transform 0.2s;
91
+ }
92
+ .toggle input:checked + .toggler {
93
+ background: var(--color-primary, #3b82f6);
94
+ }
95
+ .toggle input:checked + .toggler::after {
96
+ transform: translateX(20px);
97
+ }
98
+ </style>
99
+
100
+ <script type="module">
101
+ import { store } from "/components/plugins/plugin-settings-store.js";
102
+ </script>
103
+</body>
104
+</html>
plugins/chat_compaction/default_config.yaml
new
+1
@@ -0,0 +1 @@
1
+use_chat_model: true
plugins/chat_compaction/plugin.yaml
new
+8
@@ -0,0 +1,8 @@
1
+name: compaction
2
+title: Chat Compaction
3
+description: Compact entire chat history into a single optimized summary message.
4
+version: 1.0.0
5
+settings_sections:
6
+ - agent
7
+per_project_config: false
8
+per_agent_config: false
plugins/chat_compaction/prompts/compact.msg.md
new
+5
@@ -0,0 +1,5 @@
1
+Compact this conversation to its essential facts. Be maximally concise.
2
+
3
+---
4
+{{conversation}}
5
+---
plugins/chat_compaction/prompts/compact.sys.md
new
+17
@@ -0,0 +1,17 @@
1
+You are a conversation compactor. Produce the most concise summary possible while preserving critical information.
2
+
3
+Rules:
4
+- Extract only: key decisions, final outcomes, actionable facts, unresolved items
5
+- Discard: intermediate reasoning, failed attempts, redundant exchanges, pleasantries
6
+- Use terse bullet points, not prose
7
+- Collapse related items into single lines
8
+- Keep exact values: file paths, config values, code identifiers, credentials, URLs
9
+- Omit anything that can be re-derived from context
10
+- Group by topic, not chronology
11
+- No meta-commentary about the summarization
12
+- Target: 10-20% of original length
13
+
14
+Output format:
15
+- Markdown with short section headers
16
+- Bullet lists, no paragraphs
17
+- Code/paths in backticks inline, not fenced blocks unless multi-line
plugins/chat_compaction/webui/compact-modal.html
new
+261
@@ -0,0 +1,261 @@
1
+<html>
2
+<head>
3
+ <title>Compact Chat</title>
4
+</head>
5
+<body>
6
+ <div x-data x-show="$store.compactStore?.showModal" style="display: none;">
7
+ <div class="modal-overlay" @click="$store.compactStore?.closeModal()">
8
+ <div class="modal-container" @click.stop>
9
+ <div class="modal-header">
10
+ <h3>Compact Chat History</h3>
11
+ <button class="modal-close" @click="$store.compactStore?.closeModal()">
12
+ <span class="material-symbols-outlined">close</span>
13
+ </button>
14
+ </div>
15
+
16
+ <div class="modal-content">
17
+ <template x-if="$store.compactStore?.stats">
18
+ <div class="stats-container">
19
+ <p class="stats-description">
20
+ This will summarize your entire conversation into a single optimized message.
21
+ </p>
22
+
23
+ <div class="stats-grid">
24
+ <div class="stat-item">
25
+ <span class="stat-label">Messages</span>
26
+ <span class="stat-value" x-text="$store.compactStore?.stats?.message_count"></span>
27
+ </div>
28
+ <div class="stat-item">
29
+ <span class="stat-label">Tokens</span>
30
+ <span class="stat-value" x-text="$store.compactStore?.stats?.token_count?.toLocaleString()"></span>
31
+ </div>
32
+ <div class="stat-item">
33
+ <span class="stat-label">Model</span>
34
+ <span class="stat-value" x-text="$store.compactStore?.stats?.model_name"></span>
35
+ </div>
36
+ </div>
37
+
38
+ <div class="stats-warning">
39
+ <span class="material-symbols-outlined">warning</span>
40
+ <span>This action cannot be undone. The original conversation will be replaced with a summary.</span>
41
+ </div>
42
+ </div>
43
+ </template>
44
+
45
+ <template x-if="!$store.compactStore?.stats">
46
+ <div class="stats-loading">
47
+ <span class="loading-spinner"></span>
48
+ <span>Loading statistics...</span>
49
+ </div>
50
+ </template>
51
+ </div>
52
+
53
+ <div class="modal-footer">
54
+ <button class="button secondary" @click="$store.compactStore?.closeModal()">
55
+ Cancel
56
+ </button>
57
+ <button
58
+ class="button primary danger"
59
+ @click="$store.compactStore?.compact()"
60
+ :disabled="$store.compactStore?.compacting || !$store.compactStore?.stats"
61
+ >
62
+ <template x-if="$store.compactStore?.compacting">
63
+ <span class="loading-spinner"></span>
64
+ </template>
65
+ <span>Compact</span>
66
+ </button>
67
+ </div>
68
+ </div>
69
+ </div>
70
+ </div>
71
+
72
+ <style>
73
+ .modal-overlay {
74
+ position: fixed;
75
+ top: 0;
76
+ left: 0;
77
+ right: 0;
78
+ bottom: 0;
79
+ background: rgba(0, 0, 0, 0.5);
80
+ display: flex;
81
+ align-items: center;
82
+ justify-content: center;
83
+ z-index: 1000;
84
+ }
85
+
86
+ .modal-container {
87
+ background: var(--color-background-elevated);
88
+ border-radius: var(--border-radius-md);
89
+ box-shadow: var(--shadow-lg);
90
+ width: 90%;
91
+ max-width: 500px;
92
+ max-height: 90vh;
93
+ overflow-y: auto;
94
+ }
95
+
96
+ .modal-header {
97
+ display: flex;
98
+ align-items: center;
99
+ justify-content: space-between;
100
+ padding: var(--spacing-md) var(--spacing-lg);
101
+ border-bottom: 1px solid var(--color-border);
102
+ }
103
+
104
+ .modal-header h3 {
105
+ margin: 0;
106
+ font-size: 1.1rem;
107
+ font-weight: 600;
108
+ }
109
+
110
+ .modal-close {
111
+ background: transparent;
112
+ border: none;
113
+ cursor: pointer;
114
+ padding: var(--spacing-xs);
115
+ border-radius: var(--border-radius-sm);
116
+ color: var(--color-text-secondary);
117
+ transition: color 0.2s;
118
+ }
119
+
120
+ .modal-close:hover {
121
+ color: var(--color-text);
122
+ }
123
+
124
+ .modal-content {
125
+ padding: var(--spacing-lg);
126
+ }
127
+
128
+ .stats-description {
129
+ margin: 0 0 var(--spacing-md) 0;
130
+ color: var(--color-text-secondary);
131
+ font-size: 0.9rem;
132
+ }
133
+
134
+ .stats-grid {
135
+ display: grid;
136
+ grid-template-columns: repeat(3, 1fr);
137
+ gap: var(--spacing-md);
138
+ margin-bottom: var(--spacing-lg);
139
+ }
140
+
141
+ .stat-item {
142
+ display: flex;
143
+ flex-direction: column;
144
+ align-items: center;
145
+ padding: var(--spacing-md);
146
+ background: var(--color-background-muted);
147
+ border-radius: var(--border-radius-md);
148
+ }
149
+
150
+ .stat-label {
151
+ font-size: 0.75rem;
152
+ color: var(--color-text-secondary);
153
+ text-transform: uppercase;
154
+ letter-spacing: 0.05em;
155
+ margin-bottom: var(--spacing-xs);
156
+ }
157
+
158
+ .stat-value {
159
+ font-size: 1.25rem;
160
+ font-weight: 600;
161
+ color: var(--color-text);
162
+ }
163
+
164
+ .stats-warning {
165
+ display: flex;
166
+ align-items: flex-start;
167
+ gap: var(--spacing-sm);
168
+ padding: var(--spacing-md);
169
+ background: var(--color-warning-bg, rgba(245, 158, 11, 0.1));
170
+ border: 1px solid var(--color-warning-border, rgba(245, 158, 11, 0.3));
171
+ border-radius: var(--border-radius-md);
172
+ color: var(--color-warning-text, #92400e);
173
+ font-size: 0.85rem;
174
+ }
175
+
176
+ .stats-warning .material-symbols-outlined {
177
+ font-size: 1.2rem;
178
+ flex-shrink: 0;
179
+ }
180
+
181
+ .stats-loading {
182
+ display: flex;
183
+ align-items: center;
184
+ justify-content: center;
185
+ gap: var(--spacing-sm);
186
+ padding: var(--spacing-xl);
187
+ color: var(--color-text-secondary);
188
+ }
189
+
190
+ .modal-footer {
191
+ display: flex;
192
+ justify-content: flex-end;
193
+ gap: var(--spacing-sm);
194
+ padding: var(--spacing-md) var(--spacing-lg);
195
+ border-top: 1px solid var(--color-border);
196
+ }
197
+
198
+ .button {
199
+ padding: var(--spacing-sm) var(--spacing-md);
200
+ border-radius: var(--border-radius-md);
201
+ font-size: 0.9rem;
202
+ font-weight: 500;
203
+ cursor: pointer;
204
+ transition: all 0.2s;
205
+ display: flex;
206
+ align-items: center;
207
+ gap: var(--spacing-xs);
208
+ }
209
+
210
+ .button:disabled {
211
+ opacity: 0.6;
212
+ cursor: not-allowed;
213
+ }
214
+
215
+ .button.secondary {
216
+ background: var(--color-background-muted);
217
+ border: 1px solid var(--color-border);
218
+ color: var(--color-text);
219
+ }
220
+
221
+ .button.secondary:hover:not(:disabled) {
222
+ background: var(--color-background-hover);
223
+ }
224
+
225
+ .button.primary {
226
+ background: var(--color-primary);
227
+ border: none;
228
+ color: white;
229
+ }
230
+
231
+ .button.primary:hover:not(:disabled) {
232
+ background: var(--color-primary-hover);
233
+ }
234
+
235
+ .button.danger {
236
+ background: var(--color-danger, #dc2626);
237
+ }
238
+
239
+ .button.danger:hover:not(:disabled) {
240
+ background: var(--color-danger-hover, #b91c1c);
241
+ }
242
+
243
+ .loading-spinner {
244
+ width: 16px;
245
+ height: 16px;
246
+ border: 2px solid currentColor;
247
+ border-top-color: transparent;
248
+ border-radius: 50%;
249
+ animation: spin 0.8s linear infinite;
250
+ }
251
+
252
+ @keyframes spin {
253
+ to { transform: rotate(360deg); }
254
+ }
255
+ </style>
256
+
257
+ <script type="module">
258
+ import { store } from "/plugins/compaction/webui/compact-store.js";
259
+ </script>
260
+</body>
261
+</html>
plugins/chat_compaction/webui/compact-store.js
new
+67
@@ -0,0 +1,67 @@
1
+import { createStore } from "/js/AlpineStore.js";
2
+import { callJsonApi } from "/js/api.js";
3
+import {
4
+ toastFrontendSuccess,
5
+ toastFrontendError,
6
+} from "/components/notifications/notification-store.js";
7
+
8
+export const store = createStore("compactStore", {
9
+ compacting: false,
10
+ stats: null,
11
+ showModal: false,
12
+
13
+ async fetchStats() {
14
+ try {
15
+ const ctxid = globalThis.getContext?.();
16
+ if (!ctxid) {
17
+ toastFrontendError("No active chat", "Compaction");
18
+ return;
19
+ }
20
+
21
+ const res = await callJsonApi("/plugins/compaction/compact_chat", {
22
+ context: ctxid,
23
+ action: "stats",
24
+ });
25
+
26
+ if (!res?.ok) {
27
+ throw new Error(res?.message || "Failed to fetch stats");
28
+ }
29
+
30
+ this.stats = res.stats;
31
+ this.showModal = true;
32
+ } catch (e) {
33
+ toastFrontendError(e.message, "Compaction");
34
+ }
35
+ },
36
+
37
+ async compact() {
38
+ this.compacting = true;
39
+ try {
40
+ const ctxid = globalThis.getContext?.();
41
+ if (!ctxid) {
42
+ throw new Error("No active chat");
43
+ }
44
+
45
+ const res = await callJsonApi("/plugins/compaction/compact_chat", {
46
+ context: ctxid,
47
+ action: "compact",
48
+ });
49
+
50
+ if (!res?.ok) {
51
+ throw new Error(res?.message || "Compaction failed");
52
+ }
53
+
54
+ toastFrontendSuccess("Compaction started", "Compaction");
55
+ this.showModal = false;
56
+ } catch (e) {
57
+ toastFrontendError(e.message, "Compaction");
58
+ } finally {
59
+ this.compacting = false;
60
+ }
61
+ },
62
+
63
+ closeModal() {
64
+ this.showModal = false;
65
+ this.stats = null;
66
+ },
67
+});
plugins/chat_compaction/webui/config.html
new
+104
@@ -0,0 +1,104 @@
1
+<html>
2
+<head>
3
+ <title>Compaction Plugin Settings</title>
4
+</head>
5
+<body>
6
+ <div x-data>
7
+ <template x-if="config">
8
+ <div>
9
+ <div class="section-title">Compaction Configuration</div>
10
+ <div class="section-description">
11
+ Configure how the chat compaction feature works.
12
+ </div>
13
+
14
+ <div class="field">
15
+ <div class="field-label">
16
+ <div class="field-title">Use chat model</div>
17
+ <div class="field-description">
18
+ When enabled, uses the currently selected chat model for compaction.
19
+ When disabled, uses the utility model (usually faster and cheaper).
20
+ </div>
21
+ </div>
22
+ <div class="field-control">
23
+ <label class="toggle">
24
+ <input type="checkbox" x-model="config.use_chat_model" />
25
+ <span class="toggler"></span>
26
+ </label>
27
+ </div>
28
+ </div>
29
+ </div>
30
+ </template>
31
+ </div>
32
+
33
+ <style>
34
+ .section-title {
35
+ font-size: 1rem;
36
+ font-weight: 600;
37
+ margin-bottom: 8px;
38
+ color: var(--color-text, #e5e5e5);
39
+ }
40
+ .section-description {
41
+ font-size: 0.85rem;
42
+ color: var(--color-text-secondary, #999);
43
+ margin-bottom: 20px;
44
+ line-height: 1.5;
45
+ }
46
+ .field {
47
+ margin-bottom: 20px;
48
+ }
49
+ .field-label {
50
+ margin-bottom: 8px;
51
+ }
52
+ .field-title {
53
+ font-weight: 500;
54
+ color: var(--color-text, #e5e5e5);
55
+ margin-bottom: 4px;
56
+ }
57
+ .field-description {
58
+ font-size: 0.8rem;
59
+ color: var(--color-text-secondary, #999);
60
+ line-height: 1.4;
61
+ }
62
+ .field-control {
63
+ margin-top: 8px;
64
+ }
65
+ .toggle {
66
+ display: inline-flex;
67
+ align-items: center;
68
+ cursor: pointer;
69
+ }
70
+ .toggle input {
71
+ display: none;
72
+ }
73
+ .toggler {
74
+ width: 44px;
75
+ height: 24px;
76
+ background: var(--color-border, #444);
77
+ border-radius: 12px;
78
+ position: relative;
79
+ transition: background 0.2s;
80
+ }
81
+ .toggler::after {
82
+ content: '';
83
+ position: absolute;
84
+ width: 20px;
85
+ height: 20px;
86
+ background: white;
87
+ border-radius: 50%;
88
+ top: 2px;
89
+ left: 2px;
90
+ transition: transform 0.2s;
91
+ }
92
+ .toggle input:checked + .toggler {
93
+ background: var(--color-primary, #3b82f6);
94
+ }
95
+ .toggle input:checked + .toggler::after {
96
+ transform: translateX(20px);
97
+ }
98
+ </style>
99
+
100
+ <script type="module">
101
+ import { store } from "/components/plugins/plugin-settings-store.js";
102
+ </script>
103
+</body>
104
+</html>