feat: add manual/auto conversation compaction and preserve full export history

This commit is contained in:
Xiaohan-Tian
2026-06-01 19:55:59 -07:00
parent 936fb461e3
commit c1d9f4c9fb
23 changed files with 1059 additions and 27 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "K.G.Studio",
"version": "0.17.4-build.20260520",
"version": "0.19.0-build.20260531",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "K.G.Studio",
"version": "0.17.4-build.20260520",
"version": "0.19.0-build.20260531",
"dependencies": {
"@breezystack/lamejs": "^1.2.7",
"class-transformer": "^0.5.1",
+1
View File
@@ -3,6 +3,7 @@
"language": "auto",
"llm_provider": "local_browser",
"persist_api_keys_non_localhost": false,
"auto_compact_threshold_percent": 90,
"openai": {
"api_key": "",
"flex": false,
+16
View File
@@ -0,0 +1,16 @@
You are summarizing a K.G.Studio Musician Assistant conversation so it can continue in a smaller context window.
Your job:
- Preserve the current task objective.
- Preserve accepted constraints, decisions, and user preferences.
- Preserve important tool calls, tool outcomes, and error messages that still matter.
- Preserve relevant project context such as BPM, key, time signature, region boundaries, and track/instrument context when it affects the task.
- Preserve unfinished work and the next best action.
Rules:
- Be concise but specific.
- Prefer durable facts over conversational filler.
- Do not rewrite the user's intent.
- Do not include long verbatim transcript excerpts.
- Do not invent missing information.
- Make the summary usable as a direct handoff for the next model turn.
@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from 'vitest';
import { ConversationCompactor } from './ConversationCompactor';
import type { LLMProvider } from '../llm/LLMProvider';
import type { Message } from '../core/AgentState';
function createStubProvider(summaryPrefix = 'summary'): LLMProvider {
return {
async *generateStream(messages) {
const source = messages[0]?.content ?? '';
yield { type: 'text', content: `${summaryPrefix}:${String(source).slice(0, 12)}` };
yield { type: 'done', content: '', finishReason: 'stop' };
},
};
}
describe('ConversationCompactor', () => {
it('preserves the recent raw tail while compacting the prefix', async () => {
const compactor = new ConversationCompactor({
provider: createStubProvider(),
systemPrompt: 'compact prompt',
});
const messages: Message[] = [
{ id: '1', role: 'user', content: 'older user', timestamp: 1 },
{ id: '2', role: 'assistant', content: 'older reply', timestamp: 2 },
{ id: '3', role: 'user', content: 'recent user', timestamp: 3 },
{ id: '4', role: 'assistant', content: 'recent reply', timestamp: 4 },
];
const result = await compactor.compact(messages, 2);
expect(result.changed).toBe(true);
expect(result.summary).toContain('Compacted conversation summary:');
expect(result.compactedConversation).toContain('recent user');
expect(result.compactedConversation).toContain('recent reply');
});
it('returns unchanged when there is no compactable prefix', async () => {
const compactor = new ConversationCompactor({
provider: createStubProvider(),
systemPrompt: 'compact prompt',
});
const messages: Message[] = [
{ id: '1', role: 'user', content: 'only user', timestamp: 1 },
{ id: '2', role: 'assistant', content: 'only reply', timestamp: 2 },
];
const result = await compactor.compact(messages, 0);
expect(result.changed).toBe(false);
});
it('emits progress while generating chunk summaries', async () => {
const onProgress = vi.fn();
const compactor = new ConversationCompactor({
provider: createStubProvider(),
systemPrompt: 'compact prompt',
onProgress,
});
const messages: Message[] = [
{ id: '1', role: 'user', content: 'older user', timestamp: 1 },
{ id: '2', role: 'assistant', content: 'older reply', timestamp: 2 },
{ id: '3', role: 'user', content: 'recent user', timestamp: 3 },
{ id: '4', role: 'assistant', content: 'recent reply', timestamp: 4 },
];
await compactor.compact(messages, 2);
expect(onProgress).toHaveBeenCalled();
});
});
+204
View File
@@ -0,0 +1,204 @@
import type { Message } from '../core/AgentState';
import type { LLMProvider } from '../llm/LLMProvider';
import type { OpenAIToolDefinition } from '../tools/BaseTool';
export interface CompactProgress {
chunkIndex: number;
chunkCount: number;
receivedTokenCount: number;
}
export interface ConversationCompactorOptions {
provider: LLMProvider;
systemPrompt: string;
tools?: OpenAIToolDefinition[];
focus?: string;
onProgress?: (progress: CompactProgress) => void;
}
export interface ConversationCompactionResult {
changed: boolean;
compactedConversation: string;
summary: string;
tailStartIndex: number;
}
const CHUNK_CHARACTER_BUDGET = 24_000;
function formatMessage(message: Message): string {
const parts = [`[${message.role.toUpperCase()}]`];
if (message.is_compacted_summary) {
parts.push('[COMPACTED_SUMMARY]');
}
if (message.content) {
parts.push(message.content);
}
if (message.tool_calls?.length) {
for (const toolCall of message.tool_calls) {
parts.push(
`TOOL_CALL ${toolCall.function.name}: ${toolCall.function.arguments}`,
);
}
}
if (message.tool_call_id) {
parts.push(`TOOL_RESULT_FOR ${message.tool_call_id}`);
}
return parts.join('\n');
}
function splitIntoChunks(serializedMessages: string[]): string[] {
const chunks: string[] = [];
let currentChunk = '';
for (const serialized of serializedMessages) {
if (!currentChunk) {
currentChunk = serialized;
continue;
}
if ((currentChunk.length + serialized.length + 2) > CHUNK_CHARACTER_BUDGET) {
chunks.push(currentChunk);
currentChunk = serialized;
continue;
}
currentChunk += `\n\n${serialized}`;
}
if (currentChunk) {
chunks.push(currentChunk);
}
return chunks;
}
function buildCompactionUserPrompt(chunkText: string, chunkIndex: number, chunkCount: number, focus?: string): string {
const focusSection = focus?.trim()
? `Focus instruction from the user: ${focus.trim()}\n\n`
: '';
return `${focusSection}Summarize this conversation history chunk for future continuation.
Preserve:
- the active goal
- accepted constraints and decisions
- important tool results and errors
- relevant project, track, region, and music context
- unfinished work and next steps
Do not quote the full transcript. Produce a concise but durable handoff summary.
Chunk ${chunkIndex + 1} of ${chunkCount}:
${chunkText}`;
}
export class ConversationCompactor {
private readonly provider: LLMProvider;
private readonly systemPrompt: string;
private readonly tools: OpenAIToolDefinition[];
private readonly focus?: string;
private readonly onProgress?: (progress: CompactProgress) => void;
constructor(options: ConversationCompactorOptions) {
this.provider = options.provider;
this.systemPrompt = options.systemPrompt;
this.tools = options.tools ?? [];
this.focus = options.focus;
this.onProgress = options.onProgress;
}
async compact(messages: Message[], tailStartIndex: number): Promise<ConversationCompactionResult> {
if (tailStartIndex <= 0 || tailStartIndex >= messages.length) {
return {
changed: false,
compactedConversation: this.renderConversation(messages),
summary: '',
tailStartIndex,
};
}
const prefix = messages.slice(0, tailStartIndex);
const serializedMessages = prefix.map(formatMessage);
const chunks = splitIntoChunks(serializedMessages);
if (chunks.length === 0) {
return {
changed: false,
compactedConversation: this.renderConversation(messages),
summary: '',
tailStartIndex,
};
}
let summaries = await Promise.all(
chunks.map((chunk, index) => this.summarizeChunk(chunk, index, chunks.length)),
);
while (summaries.length > 1) {
const mergedChunks = splitIntoChunks(summaries.map((summary, index) => `SUMMARY ${index + 1}\n${summary}`));
summaries = await Promise.all(
mergedChunks.map((chunk, index) => this.summarizeChunk(chunk, index, mergedChunks.length)),
);
}
const summary = `Compacted conversation summary:\n${summaries[0].trim()}`;
const compactedConversation = this.renderConversation([
{
...messages[0],
id: 'compacted-summary-preview',
role: 'assistant',
content: summary,
is_compacted_summary: true,
compact_trigger: 'manual',
tool_calls: undefined,
tool_call_id: undefined,
},
...messages.slice(tailStartIndex),
]);
return {
changed: true,
compactedConversation,
summary,
tailStartIndex,
};
}
renderConversation(messages: Message[]): string {
return messages.map(formatMessage).join('\n\n');
}
private async summarizeChunk(chunkText: string, chunkIndex: number, chunkCount: number): Promise<string> {
const prompt = buildCompactionUserPrompt(chunkText, chunkIndex, chunkCount, this.focus);
const messages: Message[] = [
{
id: `compact_user_${chunkIndex}`,
role: 'user',
content: prompt,
timestamp: Date.now(),
},
];
let receivedTokenCount = 0;
let summary = '';
for await (const chunk of this.provider.generateStream(messages, this.systemPrompt, [])) {
if (chunk.type === 'text') {
summary += chunk.content;
receivedTokenCount += 1;
this.onProgress?.({
chunkIndex,
chunkCount,
receivedTokenCount,
});
}
}
return summary.trim();
}
}
+134
View File
@@ -6,6 +6,19 @@ import { useProjectStore } from '../../stores/projectStore';
import type { StreamChunk } from '../llm/StreamingTypes';
import type { ToolCall } from './AgentState';
import type { OpenAIToolDefinition } from '../tools/BaseTool';
import { ConversationCompactor, type CompactProgress } from '../compact/ConversationCompactor';
import { ConfigManager } from '../../core/config/ConfigManager';
export interface CompactConversationOptions {
trigger: 'manual' | 'auto';
focus?: string;
onProgress?: (progress: CompactProgress) => void;
}
export interface CompactConversationResult {
changed: boolean;
compactedConversation: string;
}
/**
* Main orchestrator for the AI agent system.
@@ -52,6 +65,10 @@ export class AgentCore {
});
}
private async getSystemPrompt(templatePath?: string): Promise<string> {
return SystemPrompts.getSystemPromptWithContext(templatePath);
}
/**
* Execute a single tool call and return the result
*/
@@ -207,6 +224,105 @@ export class AgentCore {
this.agentState.clearMessages();
}
async shouldCompactBeforeNextTurn(userInput: string): Promise<boolean> {
if (!this.llmProvider?.estimateHistoryTokens || !this.llmProvider.getContextWindow) {
return false;
}
const contextWindow = this.llmProvider.getContextWindow();
if (!contextWindow) {
return false;
}
const tools = this.getToolDefinitions();
const systemPrompt = await this.getSystemPrompt(
this.llmProvider.getPreferredSystemPromptPath?.(),
);
const thresholdPercent = await this.getAutoCompactThresholdPercent();
const reservedOutputTokens = this.llmProvider.getReservedOutputTokens?.() ?? 4096;
const hypotheticalMessages = [
...this.agentState.getMessages(),
{
id: `preflight_${Date.now()}`,
role: 'user' as const,
content: userInput,
timestamp: Date.now(),
},
];
const estimatedTokens = await this.llmProvider.estimateHistoryTokens(
hypotheticalMessages,
systemPrompt,
tools,
);
const thresholdTokens = Math.floor(contextWindow * (thresholdPercent / 100));
return (estimatedTokens + reservedOutputTokens) >= thresholdTokens;
}
async compactConversation(options: CompactConversationOptions): Promise<CompactConversationResult> {
if (!this.llmProvider) {
throw new Error('No LLM provider configured');
}
const messages = this.agentState.getMessages();
if (messages.length < 2) {
return {
changed: false,
compactedConversation: messages.map(message => message.content ?? '').join('\n\n'),
};
}
const tailStartIndex = this.agentState.findRecentTailStartIndex();
if (tailStartIndex <= 0 || tailStartIndex >= messages.length) {
return {
changed: false,
compactedConversation: messages.map(message => message.content ?? '').join('\n\n'),
};
}
const compactionPrompt = await this.getSystemPrompt('prompts/system_compaction.md');
const compactor = new ConversationCompactor({
provider: this.llmProvider,
systemPrompt: compactionPrompt,
tools: this.getToolDefinitions(),
focus: options.focus,
onProgress: options.onProgress,
});
const result = await compactor.compact(messages, tailStartIndex);
if (!result.changed) {
return {
changed: false,
compactedConversation: result.compactedConversation,
};
}
const nextMessages = this.agentState.createCompactedHistory(
result.summary,
result.tailStartIndex,
options.trigger,
);
this.agentState.replaceMessages(nextMessages);
console.log('------------ COMPACTED CONVERSATION ------------');
console.log(result.compactedConversation);
console.log('------------------------------------------------');
return {
changed: true,
compactedConversation: result.compactedConversation,
};
}
async retryAfterCompaction(
userInput: string,
options: Omit<CompactConversationOptions, 'trigger'> = {},
): Promise<CompactConversationResult> {
return this.compactConversation({
trigger: 'auto',
focus: options.focus,
onProgress: options.onProgress,
});
}
getIsWorkingOnTask(): boolean {
return this.agentState.getIsWorkingOnTask();
}
@@ -214,4 +330,22 @@ export class AgentCore {
setIsWorkingOnTask(isWorking: boolean): void {
this.agentState.setIsWorkingOnTask(isWorking);
}
private async getAutoCompactThresholdPercent(): Promise<80 | 90 | 95> {
try {
const configManager = ConfigManager.instance();
if (!configManager.getIsInitialized()) {
await configManager.initialize();
}
const configured = Number(configManager.get('general.auto_compact_threshold_percent'));
if (configured === 80 || configured === 95) {
return configured;
}
} catch (error) {
console.warn('Failed to load auto-compact threshold, using default.', error);
}
return 90;
}
}
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import { AgentState } from './AgentState';
describe('AgentState compaction helpers', () => {
it('preserves the most recent exchange block as the raw tail', () => {
const state = new AgentState('conv_test');
state.addMessage('user', 'first');
state.addMessage('assistant', 'first reply');
state.addMessage('user', 'second');
state.addMessage('assistant', 'second reply');
expect(state.findRecentTailStartIndex()).toBe(2);
});
it('creates compacted history with a synthetic summary message', () => {
const state = new AgentState('conv_test');
state.addMessage('user', 'first');
state.addMessage('assistant', 'first reply');
state.addMessage('user', 'second');
state.addMessage('assistant', 'second reply');
const compacted = state.createCompactedHistory('summary', 2, 'manual');
expect(compacted).toHaveLength(3);
expect(compacted[0]).toMatchObject({
role: 'assistant',
content: 'summary',
is_compacted_summary: true,
compact_trigger: 'manual',
});
expect(compacted[1].content).toBe('second');
expect(compacted[2].content).toBe('second reply');
});
it('retains full history after current history is compacted', () => {
const state = new AgentState('conv_test');
state.addMessage('user', 'first');
state.addMessage('assistant', 'first reply');
state.addMessage('user', 'second');
state.addMessage('assistant', 'second reply');
const compacted = state.createCompactedHistory('summary', 2, 'manual');
state.replaceMessages(compacted);
expect(state.getMessages()).toHaveLength(3);
expect(state.getFullMessages()).toHaveLength(4);
expect(state.getFullMessages().map(message => message.content)).toEqual([
'first',
'first reply',
'second',
'second reply',
]);
});
it('clears full history when conversation is cleared', () => {
const state = new AgentState('conv_test');
state.addMessage('user', 'first');
state.addMessage('assistant', 'reply');
state.clearMessages();
expect(state.getMessages()).toHaveLength(0);
expect(state.getFullMessages()).toHaveLength(0);
});
});
+64 -1
View File
@@ -21,10 +21,13 @@ export interface Message {
timestamp: number;
tool_calls?: ToolCall[]; // present on assistant messages when LLM invokes tools
tool_call_id?: string; // present on tool-result messages, links back to ToolCall.id
is_compacted_summary?: boolean;
compact_trigger?: 'manual' | 'auto';
}
export class AgentState {
private messages: Message[] = [];
private fullMessages: Message[] = [];
private conversationId: string;
private isWorkingOnTask: boolean = false;
@@ -39,7 +42,12 @@ export class AgentState {
addMessage(
role: 'user' | 'assistant' | 'tool',
content: string | null,
options?: { tool_calls?: ToolCall[]; tool_call_id?: string }
options?: {
tool_calls?: ToolCall[];
tool_call_id?: string;
is_compacted_summary?: boolean;
compact_trigger?: 'manual' | 'auto';
}
): string {
const message: Message = {
id: this.generateMessageId(),
@@ -48,9 +56,12 @@ export class AgentState {
timestamp: Date.now(),
...(options?.tool_calls ? { tool_calls: options.tool_calls } : {}),
...(options?.tool_call_id ? { tool_call_id: options.tool_call_id } : {}),
...(options?.is_compacted_summary ? { is_compacted_summary: true } : {}),
...(options?.compact_trigger ? { compact_trigger: options.compact_trigger } : {}),
};
this.messages.push(message);
this.fullMessages.push({ ...message });
return message.id;
}
@@ -64,6 +75,17 @@ export class AgentState {
if (options?.tool_calls) {
this.messages[messageIndex].tool_calls = options.tool_calls;
}
}
const fullMessageIndex = this.fullMessages.findIndex(msg => msg.id === messageId);
if (fullMessageIndex !== -1) {
this.fullMessages[fullMessageIndex].content = content;
if (options?.tool_calls) {
this.fullMessages[fullMessageIndex].tool_calls = options.tool_calls;
}
}
if (messageIndex !== -1 || fullMessageIndex !== -1) {
return true;
}
return false;
@@ -76,6 +98,14 @@ export class AgentState {
const messageIndex = this.messages.findIndex(msg => msg.id === messageId);
if (messageIndex !== -1) {
this.messages.splice(messageIndex, 1);
}
const fullMessageIndex = this.fullMessages.findIndex(msg => msg.id === messageId);
if (fullMessageIndex !== -1) {
this.fullMessages.splice(fullMessageIndex, 1);
}
if (messageIndex !== -1 || fullMessageIndex !== -1) {
return true;
}
return false;
@@ -86,6 +116,7 @@ export class AgentState {
*/
removeLastMessages(count: number): void {
this.messages.splice(-count, count);
this.fullMessages.splice(-count, count);
}
/**
@@ -95,6 +126,10 @@ export class AgentState {
return [...this.messages];
}
getFullMessages(): Message[] {
return [...this.fullMessages];
}
/**
* Get the conversation ID
*/
@@ -107,6 +142,11 @@ export class AgentState {
*/
clearMessages(): void {
this.messages = [];
this.fullMessages = [];
}
replaceMessages(messages: Message[]): void {
this.messages = [...messages];
}
/**
@@ -116,6 +156,29 @@ export class AgentState {
return this.messages.slice(-count);
}
findRecentTailStartIndex(): number {
for (let i = this.messages.length - 1; i >= 0; i -= 1) {
if (this.messages[i].role === 'user') {
return i;
}
}
return this.messages.length;
}
createCompactedHistory(summary: string, tailStartIndex: number, trigger: 'manual' | 'auto'): Message[] {
const preservedTail = this.messages.slice(Math.max(0, tailStartIndex));
const summaryMessage: Message = {
id: this.generateMessageId(),
role: 'assistant',
content: summary,
timestamp: Date.now(),
is_compacted_summary: true,
compact_trigger: trigger,
};
return [summaryMessage, ...preservedTail];
}
private generateConversationId(): string {
return `conv_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
}
+78
View File
@@ -2,9 +2,18 @@ import OpenAI from 'openai';
import type { StreamChunk } from './StreamingTypes';
import type { Message, ToolCall } from '../core/AgentState';
import type { OpenAIToolDefinition } from '../tools/BaseTool';
import { getModelTokenLimits } from './modelTokenLimits';
export interface LLMProvider {
getPreferredSystemPromptPath?(): string | undefined;
getContextWindow?(): number | undefined;
getReservedOutputTokens?(): number | undefined;
estimateHistoryTokens?(
messages: Message[],
systemPrompt?: string,
tools?: OpenAIToolDefinition[],
): Promise<number> | number;
isContextTooLongError?(error: unknown): boolean;
generateStream(
messages: Message[],
systemPrompt?: string,
@@ -12,6 +21,28 @@ export interface LLMProvider {
): AsyncIterableIterator<StreamChunk>;
}
function extractErrorDetails(error: unknown): { code?: string; message?: string } {
if (!error || typeof error !== 'object') {
return {};
}
const asRecord = error as Record<string, unknown>;
const nestedError = asRecord.error && typeof asRecord.error === 'object'
? asRecord.error as Record<string, unknown>
: undefined;
const code = typeof asRecord.code === 'string'
? asRecord.code
: typeof nestedError?.code === 'string'
? nestedError.code
: undefined;
const message = typeof asRecord.message === 'string'
? asRecord.message
: typeof nestedError?.message === 'string'
? nestedError.message
: undefined;
return { code, message };
}
/**
* OpenAI-compatible provider implementation.
* Works with OpenAI and OpenAI-compatible APIs (OpenRouter, Ollama, vLLM, etc.)
@@ -31,6 +62,53 @@ export class OpenAICompatibleLLMProvider implements LLMProvider {
this.model = model;
}
getContextWindow(): number | undefined {
return getModelTokenLimits(this.model)?.contextWindow;
}
getReservedOutputTokens(): number | undefined {
const limits = getModelTokenLimits(this.model);
if (!limits) {
return undefined;
}
if (typeof limits.reservedOutputTokens === 'number') {
return limits.reservedOutputTokens;
}
if (typeof limits.maxOutputTokens === 'number') {
return Math.min(limits.maxOutputTokens, 8_192);
}
return undefined;
}
estimateHistoryTokens(
messages: Message[],
systemPrompt?: string,
tools?: OpenAIToolDefinition[],
): number {
const openaiMessages = this.convertMessages(messages, systemPrompt);
const payload = JSON.stringify({
model: this.model,
messages: openaiMessages,
tools: tools ?? [],
});
// Conservative browser-side estimate for preflight checks.
return Math.ceil(payload.length / 3);
}
isContextTooLongError(error: unknown): boolean {
const { code, message } = extractErrorDetails(error);
if (code === 'context_length_exceeded') {
return true;
}
return typeof message === 'string'
&& /context window|maximum context length|input exceeds the context window|context too long/i.test(message);
}
private convertMessages(
messages: Message[],
systemPrompt?: string,
+27
View File
@@ -60,6 +60,33 @@ export class LocalBrowserLLMProvider implements LLMProvider {
return 'prompts/system_compact.md';
}
getContextWindow(): number {
return this.getConfiguredContextLength();
}
getReservedOutputTokens(): number {
const contextWindow = this.getConfiguredContextLength();
return Math.max(1024, Math.min(4096, Math.floor(contextWindow * 0.1)));
}
async estimateHistoryTokens(
messages: Message[],
systemPrompt?: string,
tools?: OpenAIToolDefinition[],
): Promise<number> {
const inference = await this.ensureInference();
const prompt = this.renderPrompt(messages, systemPrompt, tools);
return inference.sizeInTokens(prompt);
}
isContextTooLongError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
return /context|token|maxTokens|kv-cache|too long|overflow/i.test(error.message);
}
private async ensureInference(): Promise<GemmaInference> {
await LocalLLMModelManager.ensureRuntimeSupported();
if (this.inference) {
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { getModelTokenLimits } from './modelTokenLimits';
describe('modelTokenLimits', () => {
it('returns OpenAI limits for supported GPT models', () => {
expect(getModelTokenLimits('gpt-5.2')).toEqual({
contextWindow: 400_000,
maxOutputTokens: 128_000,
});
expect(getModelTokenLimits('gpt-4o')).toEqual({
contextWindow: 128_000,
maxOutputTokens: 16_384,
});
});
it('returns Claude limits for supported direct and OpenRouter aliases', () => {
expect(getModelTokenLimits('claude-sonnet-4.6')).toEqual({
contextWindow: 200_000,
reservedOutputTokens: 8_192,
});
expect(getModelTokenLimits('anthropic/claude-opus-4.6')).toEqual({
contextWindow: 200_000,
reservedOutputTokens: 8_192,
});
});
it('matches snapshot-style suffixes by prefix', () => {
expect(getModelTokenLimits('gpt-5.2-2025-12-11')).toEqual({
contextWindow: 400_000,
maxOutputTokens: 128_000,
});
expect(getModelTokenLimits('anthropic/claude-sonnet-4.6-20260101')).toEqual({
contextWindow: 200_000,
reservedOutputTokens: 8_192,
});
});
it('returns Gemini limits for supported Gemini models', () => {
expect(getModelTokenLimits('gemini-2.5-flash')).toEqual({
contextWindow: 1_048_576,
maxOutputTokens: 65_536,
});
});
it('returns undefined for unknown models', () => {
expect(getModelTokenLimits('custom-company-model')).toBeUndefined();
});
});
+49
View File
@@ -0,0 +1,49 @@
export interface ModelTokenLimits {
contextWindow: number;
maxOutputTokens?: number;
reservedOutputTokens?: number;
}
const MODEL_TOKEN_LIMITS: Record<string, ModelTokenLimits> = {
// OpenAI official model pages / compare docs
'gpt-5.2': { contextWindow: 400_000, maxOutputTokens: 128_000 },
'gpt-5-mini': { contextWindow: 400_000, maxOutputTokens: 128_000 },
'gpt-5-nano': { contextWindow: 400_000, maxOutputTokens: 128_000 },
'gpt-5': { contextWindow: 400_000, maxOutputTokens: 128_000 },
'gpt-4o': { contextWindow: 128_000, maxOutputTokens: 16_384 },
// GPT-5.4 family is present in current OpenAI docs, but exact limit pages were not surfaced.
// Use GPT-5 family limits as a best-effort alias until exact per-model docs are available.
'gpt-5.4': { contextWindow: 400_000, maxOutputTokens: 128_000 },
'gpt-5.4-mini': { contextWindow: 400_000, maxOutputTokens: 128_000 },
'gpt-5.4-nano': { contextWindow: 400_000, maxOutputTokens: 128_000 },
// Claude official docs: 200k standard context window across these families.
// Anthropic docs do not expose a simple per-model max output token table for these aliases,
// so we keep a conservative preflight reserve instead of claiming an exact output maximum.
'claude-sonnet-4.6': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
'claude-opus-4.6': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
'claude-sonnet-4.5': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
'claude-opus-4.5': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
'claude-sonnet-4': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
'claude-opus-4.1': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
'anthropic/claude-sonnet-4.6': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
'anthropic/claude-opus-4.6': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
'anthropic/claude-sonnet-4.5': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
'anthropic/claude-opus-4.5': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
'anthropic/claude-sonnet-4': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
'anthropic/claude-opus-4.1': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
// Gemini official model docs
'gemini-2.5-flash': { contextWindow: 1_048_576, maxOutputTokens: 65_536 },
};
export function getModelTokenLimits(model: string): ModelTokenLimits | undefined {
const exact = MODEL_TOKEN_LIMITS[model];
if (exact) {
return exact;
}
const prefix = Object.keys(MODEL_TOKEN_LIMITS).find(key => model.startsWith(`${key}-`));
return prefix ? MODEL_TOKEN_LIMITS[prefix] : undefined;
}
+23 -1
View File
@@ -313,6 +313,28 @@
color: #909090;
}
.message-divider-banner {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 2px 0;
}
.message-divider-banner-line {
flex: 1 1 auto;
height: 1px;
background: linear-gradient(90deg, rgba(102, 102, 102, 0.2) 0%, rgba(112, 112, 112, 0.55) 50%, rgba(102, 102, 102, 0.2) 100%);
}
.message-divider-banner-label {
flex: 0 0 auto;
color: #8c8c8c;
font-size: 11px;
letter-spacing: 0.02em;
white-space: nowrap;
}
/* Abort link styling */
.abort-link {
background: none !important;
@@ -326,7 +348,7 @@
}
.abort-link:hover {
color: #9b88ff !important;
color: #5a9fd4 !important;
text-decoration: none !important;
}
+65 -11
View File
@@ -1,11 +1,28 @@
import React from 'react';
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import ChatBox from './ChatBox';
import { I18nContext } from '../i18n/I18nProvider';
import type { ResolvedLocaleCode } from '../i18n/types';
import { translate } from '../i18n/translate';
const {
agentCoreMock,
processUserMessageMock,
processStreamMock,
} = vi.hoisted(() => ({
agentCoreMock: {
setLLMProvider: vi.fn(),
getLLMProvider: vi.fn(() => ({ getPreferredSystemPromptPath: vi.fn() })),
abortCurrentRequest: vi.fn(),
getAgentState: vi.fn(() => ({ getMessages: vi.fn(() => []) })),
compactConversation: vi.fn(async () => ({ changed: true, compactedConversation: 'summary' })),
shouldCompactBeforeNextTurn: vi.fn(async () => false),
},
processUserMessageMock: vi.fn(),
processStreamMock: vi.fn(async () => ''),
}));
vi.mock('./chat', () => ({
UserMessage: ({ content }: { content: string }) => <div>{content}</div>,
AssistantMessage: ({ content }: { content: string }) => <div>{content}</div>,
@@ -13,12 +30,7 @@ vi.mock('./chat', () => ({
vi.mock('../agent/core/AgentCore', () => ({
AgentCore: {
instance: () => ({
setLLMProvider: vi.fn(),
getLLMProvider: vi.fn(),
abortCurrentRequest: vi.fn(),
getAgentState: vi.fn(() => ({ getMessages: vi.fn(() => []) })),
}),
instance: () => agentCoreMock,
},
}));
@@ -66,18 +78,29 @@ vi.mock('../util/chatUtil', () => ({
}));
vi.mock('../util/messageFilter/UserMessageFilter', () => ({
processUserMessage: vi.fn(),
processUserMessage: processUserMessageMock,
}));
vi.mock('../hooks/useStreamProcessor', () => ({
useStreamProcessor: () => ({
abortController: null,
processStream: vi.fn(),
processStream: processStreamMock,
}),
}));
vi.mock('../utils/chatMessageUtils', () => ({
createMessage: vi.fn(),
createMessage: vi.fn((role: 'user' | 'assistant', content: string) => ({
id: `${role}-${content}`,
role,
content,
})),
createStreamingMessage: vi.fn(() => ({
id: 'streaming-message',
role: 'assistant',
content: '<span class="processing-wave">Thinking...</span> click here to abort.',
isStreaming: true,
tokenCount: 0,
})),
addWelcomeMessage: vi.fn().mockResolvedValue(null),
}));
@@ -134,6 +157,13 @@ describe('ChatBox', () => {
Element.prototype.scrollIntoView = vi.fn();
});
beforeEach(() => {
processUserMessageMock.mockReset();
processStreamMock.mockClear();
agentCoreMock.compactConversation.mockClear();
agentCoreMock.shouldCompactBeforeNextTurn.mockResolvedValue(false);
});
it('renders the English assistant title under en_us', () => {
renderWithLocale('en_us');
@@ -151,4 +181,28 @@ describe('ChatBox', () => {
expect(screen.getByRole('heading', { level: 3, name: 'Assistant musical K.G.Studio' })).toBeTruthy();
});
it('shows compacting status and completion for /compact', async () => {
processUserMessageMock.mockResolvedValue({
displayUserMessage: false,
sendToLLM: false,
finalMessageForLLM: null,
pseudoAssistantResponse: null,
metadata: {
command: 'compact',
focus: 'keep the latest work',
},
});
renderWithLocale('en_us');
const input = screen.getByPlaceholderText('Press Enter to send message, Shift + Enter for new line');
fireEvent.change(input, { target: { value: '/compact keep the latest work' } });
fireEvent.keyDown(input, { key: 'Enter', shiftKey: false });
await waitFor(() => {
expect(agentCoreMock.compactConversation).toHaveBeenCalled();
expect(screen.getByText('Conversation Compacted')).toBeTruthy();
});
});
});
+93 -8
View File
@@ -11,7 +11,7 @@ import { SystemPrompts } from '../agent/core/SystemPrompts';
import { clearChatHistoryAndUI, registerClearChatUICallback } from '../util/chatUtil';
import { processUserMessage } from '../util/messageFilter/UserMessageFilter';
import { useStreamProcessor } from '../hooks/useStreamProcessor';
import { createMessage, addWelcomeMessage } from '../utils/chatMessageUtils';
import { createMessage, createStreamingMessage, addWelcomeMessage } from '../utils/chatMessageUtils';
import { formatLocalDateTime } from '../util/timeUtil';
import { downloadBlob, buildTimestampSuffix } from '../util/miscUtil';
import { LocalLLMModelManager, type LocalLLMModelState } from '../util/localLLMModelManager';
@@ -70,6 +70,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [isCompacting, setIsCompacting] = useState(false);
const [lastUserMessage, setLastUserMessage] = useState<string>('');
const [localModelState, setLocalModelState] = useState<LocalLLMModelState>(LocalLLMModelManager.getState());
const [activeProvider, setActiveProvider] = useState<string>('openai');
@@ -87,7 +88,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
const handleExportOptionSelect = (option: string) => {
if (option === 'Export conversation as JSON') {
try {
const agentMessages = AgentCore.instance().getAgentState().getMessages();
const agentMessages = AgentCore.instance().getAgentState().getFullMessages();
const exportMessages = agentMessages.map((m) => ({
id: m.id,
role: m.role,
@@ -105,7 +106,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
} else if (option === 'Export conversation as Markdown') {
(async () => {
try {
const agentMessages = AgentCore.instance().getAgentState().getMessages();
const agentMessages = AgentCore.instance().getAgentState().getFullMessages();
const templateUrl = `${import.meta.env.BASE_URL}chat/export_conversation_template.md`;
const res = await fetch(templateUrl);
const template = await res.text();
@@ -204,6 +205,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
changedKeys.includes('general.llm_provider') ||
changedKeys.includes('general.local_browser.context_length') ||
changedKeys.some(k => k.startsWith('general.openai.')) ||
changedKeys.some(k => k.startsWith('general.claude_openrouter.')) ||
changedKeys.some(k => k.startsWith('general.openai_compatible.'))
) {
applyProviderFromConfig();
@@ -255,8 +257,78 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
clearChatHistoryAndUI(setStatus);
};
const runCompactionWithStatus = useCallback(async (
trigger: 'manual' | 'auto',
focus?: string,
): Promise<boolean> => {
const agentCore = AgentCore.instance();
const statusMessage = createMessage('assistant', 'Compacting Conversation');
const progressMessage = createStreamingMessage();
let progressTokenCount = 0;
handleMessageAdd(statusMessage);
handleMessageAdd(progressMessage);
setIsCompacting(true);
try {
const result = await agentCore.compactConversation({
trigger,
focus,
onProgress: () => {
progressTokenCount += 1;
handleMessageUpdate(progressMessage.id, (msg) => ({
...msg,
content: `<span class="processing-wave">Processing...</span>${progressTokenCount > 0 ? ` ${progressTokenCount} tokens received.` : ''} click here to abort.`,
tokenCount: progressTokenCount,
}));
},
});
handleMessageUpdate(statusMessage.id, (msg) => ({
...msg,
content: result.changed ? 'Conversation Compacted' : 'Nothing to Compact Yet',
}));
handleMessageRemove(progressMessage.id);
return result.changed;
} catch (error) {
console.error('Conversation compaction failed:', error);
handleMessageUpdate(statusMessage.id, (msg) => ({
...msg,
content: `Compaction failed: ${error instanceof Error ? error.message : 'Unable to compact the conversation.'}`,
}));
handleMessageRemove(progressMessage.id);
return false;
} finally {
setIsCompacting(false);
}
}, [handleMessageAdd, handleMessageRemove, handleMessageUpdate]);
const sendWithCompactionRecovery = useCallback(async (
llmInput: string,
originalUserMessage: string,
): Promise<void> => {
try {
await streamProcessor.processStream(llmInput, 'USER');
} catch (error) {
const provider = AgentCore.instance().getLLMProvider();
if (provider?.isContextTooLongError?.(error)) {
const compacted = await runCompactionWithStatus('auto');
if (compacted) {
try {
await streamProcessor.processStream(llmInput, 'USER');
} catch (retryError) {
console.error('Retry after compaction failed:', retryError, originalUserMessage);
}
return;
}
}
console.error('Failed to process user message:', error, originalUserMessage);
}
}, [runCompactionWithStatus, streamProcessor]);
const handleSend = async () => {
if (inputValue.trim() && !isProcessing) {
if (inputValue.trim() && !isProcessing && !isCompacting) {
const userMessage = inputValue.trim();
setLastUserMessage(userMessage);
setInputValue('');
@@ -273,6 +345,14 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
handleMessageAdd(pseudoMessage);
}
if (filterResult.metadata?.command === 'compact') {
await runCompactionWithStatus(
'manual',
typeof filterResult.metadata.focus === 'string' ? filterResult.metadata.focus : undefined,
);
return;
}
if (!filterResult.sendToLLM || !filterResult.finalMessageForLLM) {
return;
}
@@ -294,7 +374,12 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
}
// Process through stream processor — AgentCore handles the full agentic loop internally
await streamProcessor.processStream(filterResult.finalMessageForLLM, 'USER');
const agentCore = AgentCore.instance();
if (await agentCore.shouldCompactBeforeNextTurn(filterResult.finalMessageForLLM)) {
await runCompactionWithStatus('auto');
}
await sendWithCompactionRecovery(filterResult.finalMessageForLLM, userMessage);
}
};
@@ -333,13 +418,13 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
// Auto-focus input when processing completes
useEffect(() => {
if (!isProcessing && textareaRef.current) {
if (!isProcessing && !isCompacting && textareaRef.current) {
setTimeout(() => {
textareaRef.current?.focus();
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, 0);
}
}, [isProcessing]);
}, [isCompacting, isProcessing]);
const localRuntimeMessage = localModelState.runtimeSupport.reason;
const hasLocalRuntimeHardFailure = !localModelState.runtimeSupport.supported;
@@ -448,7 +533,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
<div ref={messagesEndRef} />
</div>
{!isProcessing && (
{!isProcessing && !isCompacting && (
<div className="chatbox-input-area">
<textarea
ref={textareaRef}
@@ -93,4 +93,21 @@ describe('AssistantMessage', () => {
expect(codeElement).toBeInTheDocument();
expect(codeElement).toHaveTextContent('const value = 1;');
});
it('renders compacting and compacted messages as divider banners', () => {
const { rerender, container } = render(
<AssistantMessage content="Compacting Conversation" />
);
expect(container.querySelector('.message-divider-banner')).toBeInTheDocument();
expect(screen.getByLabelText('Compacting Conversation')).toBeInTheDocument();
rerender(<AssistantMessage content="Conversation Compacted" />);
expect(screen.getByLabelText('Conversation Compacted')).toBeInTheDocument();
rerender(<AssistantMessage content="Nothing to Compact Yet" />);
expect(screen.getByLabelText('Nothing to Compact Yet')).toBeInTheDocument();
});
});
+16
View File
@@ -44,6 +44,9 @@ const formatTps = (value?: number): string | null => {
const THINKING_LABEL = 'Thinking...';
const PROCESSING_LABEL = 'Processing...';
const COMPACTION_IN_PROGRESS_LABEL = 'Compacting Conversation';
const COMPACTION_DONE_LABEL = 'Conversation Compacted';
const COMPACTION_EMPTY_LABEL = 'Nothing to Compact Yet';
const formatThinkingDuration = (elapsedSeconds: number): string => {
if (elapsedSeconds < 60) {
@@ -62,6 +65,9 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreamin
const [thinkingElapsedSeconds, setThinkingElapsedSeconds] = useState(0);
const processingWaveLabels = [THINKING_LABEL, PROCESSING_LABEL];
const isThinking = isStreaming && content.includes(`<span class="processing-wave">${THINKING_LABEL}</span>`);
const isCompactionBanner = content === COMPACTION_IN_PROGRESS_LABEL
|| content === COMPACTION_DONE_LABEL
|| content === COMPACTION_EMPTY_LABEL;
useEffect(() => {
if (!isThinking) {
@@ -83,6 +89,16 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreamin
}, [isThinking]);
const renderContent = () => {
if (isCompactionBanner) {
return (
<div className="message-divider-banner" aria-label={content}>
<span className="message-divider-banner-line" aria-hidden="true" />
<span className="message-divider-banner-label">{content}</span>
<span className="message-divider-banner-line" aria-hidden="true" />
</div>
);
}
// Handle special abort link for streaming messages
if (isStreaming && onAbort && content.includes('click here to abort')) {
const processingWaveMarkup = processingWaveLabels
@@ -16,6 +16,7 @@ const configState = new Map<string, unknown>([
['general.language', 'auto'],
['general.llm_provider', 'local_browser'],
['general.persist_api_keys_non_localhost', false],
['general.auto_compact_threshold_percent', 90],
['general.openai.api_key', ''],
['general.openai.model', 'gpt-5.4-mini'],
['general.openai.flex', false],
@@ -107,6 +108,7 @@ describe('GeneralSettings', () => {
beforeEach(() => {
configState.set('general.language', 'auto');
configState.set('general.local_browser.context_length', 65536);
configState.set('general.auto_compact_threshold_percent', 90);
configManagerMock.get.mockClear();
configManagerMock.set.mockClear();
localModelState.isCached = false;
@@ -173,6 +175,19 @@ describe('GeneralSettings', () => {
});
});
it('renders and persists the auto-compact threshold', async () => {
renderSettings();
const select = await screen.findByLabelText('Auto-Compact Threshold');
expect((select as HTMLSelectElement).value).toBe('90');
fireEvent.change(select, { target: { value: '80' } });
await waitFor(() => {
expect(configManagerMock.set).toHaveBeenCalledWith('general.auto_compact_threshold_percent', 80);
});
});
it('renders and persists local runtime download URLs', async () => {
renderSettings();
@@ -41,6 +41,7 @@ const GeneralSettings: React.FC = () => {
const [claudeOpenRouterModel, setClaudeOpenRouterModel] = useState<string>('');
const [openaiFlex, setOpenaiFlex] = useState<boolean>(false);
const [persistApiKeysNonLocalhost, setPersistApiKeysNonLocalhost] = useState<boolean>(false);
const [autoCompactThresholdPercent, setAutoCompactThresholdPercent] = useState<80 | 90 | 95>(90);
const [compatibleKey, setCompatibleKey] = useState<string>('');
const [compatibleBaseUrl, setCompatibleBaseUrl] = useState<string>('');
const [compatibleModel, setCompatibleModel] = useState<string>('');
@@ -107,6 +108,9 @@ const GeneralSettings: React.FC = () => {
setOpenaiModel((configManager.get('general.openai.model') as string) || '');
setOpenaiFlex((configManager.get('general.openai.flex') as boolean) ?? false);
setPersistApiKeysNonLocalhost((configManager.get('general.persist_api_keys_non_localhost') as boolean) ?? false);
setAutoCompactThresholdPercent(
((configManager.get('general.auto_compact_threshold_percent') as 80 | 90 | 95 | undefined) ?? 90),
);
setGeminiKey((configManager.get('general.gemini.api_key') as string) || '');
setGeminiModel((configManager.get('general.gemini.model') as string) || '');
setClaudeKey((configManager.get('general.claude.api_key') as string) || '');
@@ -206,6 +210,18 @@ const GeneralSettings: React.FC = () => {
}
};
const handleAutoCompactThresholdChange = async (value: string) => {
const parsed = Number(value);
const normalized: 80 | 90 | 95 = parsed === 80 || parsed === 95 ? parsed : 90;
setAutoCompactThresholdPercent(normalized);
try {
await configManager.set('general.auto_compact_threshold_percent', normalized);
console.log('Auto-compact threshold changed to:', normalized);
} catch (error) {
console.error('Failed to save auto-compact threshold:', error);
}
};
const handleGeminiKeyChange = (value: string) => {
setGeminiKey(value);
debouncedSave('general.gemini.api_key', value);
@@ -407,6 +423,25 @@ const GeneralSettings: React.FC = () => {
{t('settings.general.persistKeys.help')}
</div>
</div>
<div className="settings-item">
<label className="settings-label" htmlFor="general-auto-compact-threshold">
Auto-Compact Threshold
</label>
<select
id="general-auto-compact-threshold"
className="settings-select"
value={autoCompactThresholdPercent}
onChange={(e) => void handleAutoCompactThresholdChange(e.target.value)}
>
<option value="95">Conservative (95%)</option>
<option value="90">Standard (90%)</option>
<option value="80">Early (80%)</option>
</select>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Compact the conversation before the next request when estimated context usage reaches this level.
</div>
</div>
</div>
<div className="settings-group">
+2
View File
@@ -10,6 +10,7 @@ interface AppConfig {
language: LanguageSetting;
llm_provider: 'local_browser' | 'openai' | 'gemini' | 'claude' | 'claude_openrouter' | 'openai_compatible';
persist_api_keys_non_localhost: boolean;
auto_compact_threshold_percent: 80 | 90 | 95;
local_browser: {
context_length: 32768 | 65536 | 131072;
model_url: string;
@@ -209,6 +210,7 @@ export class ConfigManager {
language: 'auto',
llm_provider: 'local_browser',
persist_api_keys_non_localhost: false,
auto_compact_threshold_percent: 90,
openai: {
api_key: '',
flex: false,
+1 -1
View File
@@ -144,7 +144,7 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce
isStreaming: false,
tokenCount: undefined
}));
return '';
throw error;
} finally {
setAbortController(null);
setIsProcessing(false);
@@ -287,14 +287,29 @@ describe('processUserMessage slash commands', () => {
expect(result.pseudoAssistantResponse).toContain('chat/hotkeys.md');
});
it('parses /compact and returns manual compaction metadata', async () => {
const result = await processUserMessage('/compact keep the current chord decisions');
expect(result).toMatchObject({
displayUserMessage: false,
sendToLLM: false,
finalMessageForLLM: null,
pseudoAssistantResponse: null,
metadata: {
command: 'compact',
focus: 'keep the current chord decisions',
},
});
});
it('lists the new hotkeys commands for unknown slash commands', async () => {
const result = await processUserMessage('/unknown foo');
expect(storeState.setStatus).toHaveBeenCalledWith(
'Unknown command: /unknown. Available commands: /clear, /welcome, /help, /hotkeys, /hotkey'
'Unknown command: /unknown. Available commands: /clear, /welcome, /help, /hotkeys, /hotkey, /compact'
);
expect(result.pseudoAssistantResponse).toBe(
'Unknown command: /unknown foo.\nAvailable commands: /clear, /welcome, /help, /hotkeys, /hotkey'
'Unknown command: /unknown foo.\nAvailable commands: /clear, /welcome, /help, /hotkeys, /hotkey, /compact'
);
expect(result).toMatchObject({
displayUserMessage: false,
+14 -1
View File
@@ -218,9 +218,22 @@ export async function processUserMessage(originalMessage: string): Promise<UserM
}
}
case '/compact': {
return {
displayUserMessage: false,
sendToLLM: false,
finalMessageForLLM: null,
pseudoAssistantResponse: null,
metadata: {
command: 'compact',
focus: argString.trim() || undefined,
}
};
}
default: {
const { setStatus } = useProjectStore.getState();
const help = 'Available commands: /clear, /welcome, /help, /hotkeys, /hotkey';
const help = 'Available commands: /clear, /welcome, /help, /hotkeys, /hotkey, /compact';
setStatus(`Unknown command: ${command}. ${help}`);
return {
displayUserMessage: false,