fix(providers): remove client-side context truncation ("second system")

The OpenAICompatibleProvider.truncateHistory() sliding-window dropped
conversation messages based on a hardcoded 20-message cap and a 100k
"safety" token limit — a client-side context manager layered on top of
the provider's own context window. In practice it fired on message count
alone (dropping messages at ~12k tokens, nowhere near the token limit)
and silently corrupted conversation history, mislabeled as "runaway cost"
prevention.

Rip it out entirely. The full history is now sent to the provider, which
owns its own context window.

Removed:
- truncateHistory() + requireNonEmptyToTruncate from OpenAICompatibleProvider
- truncateHistoryForOpenRouter / truncateHistoryForGemini wrappers + constants
- CLAUDE_MEM_{GEMINI,OPENROUTER}_MAX_CONTEXT_MESSAGES / _MAX_TOKENS settings,
  their defaults, and SettingsRoutes validation
- truncation-specific tests; docs + openclaw installer references

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-06-30 20:52:54 -07:00
parent 5def052993
commit 29af028403
15 changed files with 373 additions and 588 deletions

View File

@@ -38,8 +38,6 @@ See [Gemini Provider](usage/gemini-provider) for detailed configuration and free
|----------------------------------------------|-----------------------------|---------------------------------------| |----------------------------------------------|-----------------------------|---------------------------------------|
| `CLAUDE_MEM_OPENROUTER_API_KEY` | — | OpenRouter API key ([get key](https://openrouter.ai/keys)) | | `CLAUDE_MEM_OPENROUTER_API_KEY` | — | OpenRouter API key ([get key](https://openrouter.ai/keys)) |
| `CLAUDE_MEM_OPENROUTER_MODEL` | `xiaomi/mimo-v2-flash:free` | Model identifier (supports 100+ models) | | `CLAUDE_MEM_OPENROUTER_MODEL` | `xiaomi/mimo-v2-flash:free` | Model identifier (supports 100+ models) |
| `CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES` | `20` | Max messages in conversation history |
| `CLAUDE_MEM_OPENROUTER_MAX_TOKENS` | `100000` | Token budget safety limit |
| `CLAUDE_MEM_OPENROUTER_SITE_URL` | — | Optional: URL for analytics | | `CLAUDE_MEM_OPENROUTER_SITE_URL` | — | Optional: URL for analytics |
| `CLAUDE_MEM_OPENROUTER_APP_NAME` | `claude-mem` | Optional: App name for analytics | | `CLAUDE_MEM_OPENROUTER_APP_NAME` | `claude-mem` | Optional: App name for analytics |

View File

@@ -74,8 +74,6 @@ All free models support:
| `CLAUDE_MEM_PROVIDER` | `claude`, `gemini`, `openrouter` | `claude` | AI provider for observation extraction | | `CLAUDE_MEM_PROVIDER` | `claude`, `gemini`, `openrouter` | `claude` | AI provider for observation extraction |
| `CLAUDE_MEM_OPENROUTER_API_KEY` | string | — | Your OpenRouter API key | | `CLAUDE_MEM_OPENROUTER_API_KEY` | string | — | Your OpenRouter API key |
| `CLAUDE_MEM_OPENROUTER_MODEL` | string | `xiaomi/mimo-v2-flash:free` | Model identifier (see list above) | | `CLAUDE_MEM_OPENROUTER_MODEL` | string | `xiaomi/mimo-v2-flash:free` | Model identifier (see list above) |
| `CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES` | number | `20` | Max messages in conversation history |
| `CLAUDE_MEM_OPENROUTER_MAX_TOKENS` | number | `100000` | Token budget safety limit |
| `CLAUDE_MEM_OPENROUTER_SITE_URL` | string | — | Optional: URL for analytics attribution | | `CLAUDE_MEM_OPENROUTER_SITE_URL` | string | — | Optional: URL for analytics attribution |
| `CLAUDE_MEM_OPENROUTER_APP_NAME` | string | `claude-mem` | Optional: App name for analytics | | `CLAUDE_MEM_OPENROUTER_APP_NAME` | string | `claude-mem` | Optional: App name for analytics |
@@ -134,21 +132,10 @@ The settings file takes precedence over the environment variable.
## Context Window Management ## Context Window Management
OpenRouter agent implements intelligent context management to prevent runaway costs: The full conversation history is sent to the model on each request. claude-mem does
**not** apply any client-side truncation or message-count cap — the provider and model
### Automatic Truncation own their own context window. If you need to bound context for a specific model, choose a
model with an appropriate context length or manage limits at the OpenRouter/provider level.
The agent uses a sliding window strategy:
1. Checks if message count exceeds `MAX_CONTEXT_MESSAGES` (default: 20)
2. Checks if estimated tokens exceed `MAX_TOKENS` (default: 100,000)
3. If limits exceeded, keeps most recent messages only
4. Logs warnings with dropped message counts
### Token Estimation
- Conservative estimate: 1 token ≈ 4 characters
- Used for proactive context management
- Actual usage logged from API response
### Cost Tracking ### Cost Tracking
@@ -244,12 +231,12 @@ Verify the model ID is correct:
- Use the `:free` suffix for free model variants - Use the `:free` suffix for free model variants
- Model IDs are case-sensitive - Model IDs are case-sensitive
### High Token Usage Warning ### High Token Usage
If you see warnings about high token usage (>50,000 per request): If a session accumulates a large conversation history, requests can grow. To keep
- Reduce `CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES` per-request cost down:
- Reduce `CLAUDE_MEM_OPENROUTER_MAX_TOKENS` - Choose a model with pricing suited to your usage
- Consider a model with larger context window - Consider a model with a larger context window if you hit provider-side limits
### Connection Errors ### Connection Errors

View File

@@ -985,8 +985,6 @@ write_settings() {
CLAUDE_MEM_OPENROUTER_MODEL: 'xiaomi/mimo-v2-flash:free', CLAUDE_MEM_OPENROUTER_MODEL: 'xiaomi/mimo-v2-flash:free',
CLAUDE_MEM_OPENROUTER_SITE_URL: '', CLAUDE_MEM_OPENROUTER_SITE_URL: '',
CLAUDE_MEM_OPENROUTER_APP_NAME: 'claude-mem', CLAUDE_MEM_OPENROUTER_APP_NAME: 'claude-mem',
CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES: '20',
CLAUDE_MEM_OPENROUTER_MAX_TOKENS: '100000',
CLAUDE_MEM_DATA_DIR: path.join(homedir, '.claude-mem'), CLAUDE_MEM_DATA_DIR: path.join(homedir, '.claude-mem'),
CLAUDE_MEM_LOG_LEVEL: 'INFO', CLAUDE_MEM_LOG_LEVEL: 'INFO',
CLAUDE_MEM_PYTHON_VERSION: '3.13', CLAUDE_MEM_PYTHON_VERSION: '3.13',

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -114,8 +114,6 @@ const GEMINI_RPM_LIMITS: Record<GeminiModel, number> = {
let lastRequestTime = 0; let lastRequestTime = 0;
const DEFAULT_MAX_CONTEXT_MESSAGES = 20;
const DEFAULT_MAX_ESTIMATED_TOKENS = 100000;
const GEMINI_EMPTY_HISTORY_FALLBACK = 'Continue the memory observation request.'; const GEMINI_EMPTY_HISTORY_FALLBACK = 'Continue the memory observation request.';
export type GeminiBadRequestCategory = export type GeminiBadRequestCategory =
@@ -222,7 +220,6 @@ interface GeminiConfig {
export class GeminiProvider extends OpenAICompatibleProvider<GeminiConfig> { export class GeminiProvider extends OpenAICompatibleProvider<GeminiConfig> {
protected readonly providerName = 'Gemini'; protected readonly providerName = 'Gemini';
protected readonly syntheticIdPrefix = 'gemini'; protected readonly syntheticIdPrefix = 'gemini';
protected readonly requireNonEmptyToTruncate = true;
protected readonly forwardEmptyMessageResponse = false; protected readonly forwardEmptyMessageResponse = false;
constructor(dbManager: DatabaseManager, sessionManager: SessionManager) { constructor(dbManager: DatabaseManager, sessionManager: SessionManager) {
@@ -249,13 +246,6 @@ export class GeminiProvider extends OpenAICompatibleProvider<GeminiConfig> {
: null; : null;
} }
protected truncateHistoryForGemini(history: ConversationMessage[]): ConversationMessage[] {
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
const MAX_CONTEXT_MESSAGES = parseInt(settings.CLAUDE_MEM_GEMINI_MAX_CONTEXT_MESSAGES) || DEFAULT_MAX_CONTEXT_MESSAGES;
const MAX_ESTIMATED_TOKENS = parseInt(settings.CLAUDE_MEM_GEMINI_MAX_TOKENS) || DEFAULT_MAX_ESTIMATED_TOKENS;
return this.truncateHistory(history, MAX_CONTEXT_MESSAGES, MAX_ESTIMATED_TOKENS);
}
private conversationToGeminiContents(history: ConversationMessage[]): GeminiContent[] { private conversationToGeminiContents(history: ConversationMessage[]): GeminiContent[] {
const contents: GeminiContent[] = []; const contents: GeminiContent[] = [];
let newestNonEmptyContent: string | null = null; let newestNonEmptyContent: string | null = null;
@@ -309,13 +299,11 @@ export class GeminiProvider extends OpenAICompatibleProvider<GeminiConfig> {
model: GeminiModel, model: GeminiModel,
rateLimitingEnabled: boolean rateLimitingEnabled: boolean
): Promise<ProviderQueryResult> { ): Promise<ProviderQueryResult> {
const truncatedHistory = this.truncateHistoryForGemini(history); const contents = this.conversationToGeminiContents(history);
const contents = this.conversationToGeminiContents(truncatedHistory); const totalChars = history.reduce((sum, m) => sum + m.content.length, 0);
const totalChars = truncatedHistory.reduce((sum, m) => sum + m.content.length, 0);
logger.debug('SDK', `Querying Gemini multi-turn (${model})`, { logger.debug('SDK', `Querying Gemini multi-turn (${model})`, {
turns: truncatedHistory.length, turns: history.length,
totalTurns: history.length,
totalChars totalChars
}); });

View File

@@ -44,8 +44,6 @@ export abstract class OpenAICompatibleProvider<TConfig extends { apiKey: string;
protected abstract readonly providerName: string; protected abstract readonly providerName: string;
/** Prefix for the synthetic memorySessionId (e.g. 'gemini', 'openrouter'). */ /** Prefix for the synthetic memorySessionId (e.g. 'gemini', 'openrouter'). */
protected abstract readonly syntheticIdPrefix: string; protected abstract readonly syntheticIdPrefix: string;
/** Gemini guards its truncation loop with `truncated.length > 0`; OpenRouter does not. */
protected abstract readonly requireNonEmptyToTruncate: boolean;
/** /**
* When a query returns empty content for an observation/summary message: * When a query returns empty content for an observation/summary message:
* OpenRouter still calls processAgentResponse('') (forwards the empty batch * OpenRouter still calls processAgentResponse('') (forwards the empty batch
@@ -283,37 +281,4 @@ export abstract class OpenAICompatibleProvider<TConfig extends { apiKey: string;
throw error; throw error;
} }
protected truncateHistory(history: ConversationMessage[], maxContextMessages: number, maxEstimatedTokens: number): ConversationMessage[] {
if (history.length <= maxContextMessages) {
const totalTokens = history.reduce((sum, m) => sum + this.estimateTokens(m.content), 0);
if (totalTokens <= maxEstimatedTokens) {
return history;
}
}
const truncated: ConversationMessage[] = [];
let tokenCount = 0;
for (let i = history.length - 1; i >= 0; i--) {
const msg = history[i];
const msgTokens = this.estimateTokens(msg.content);
const overLimit = truncated.length >= maxContextMessages || tokenCount + msgTokens > maxEstimatedTokens;
if ((!this.requireNonEmptyToTruncate || truncated.length > 0) && overLimit) {
logger.warn('SDK', 'Context window truncated to prevent runaway costs', {
originalMessages: history.length,
keptMessages: truncated.length,
droppedMessages: i + 1,
estimatedTokens: tokenCount,
tokenLimit: maxEstimatedTokens
});
break;
}
truncated.unshift(msg);
tokenCount += msgTokens;
}
return truncated;
}
} }

View File

@@ -94,8 +94,6 @@ export function classifyOpenRouterError(input: {
); );
} }
const DEFAULT_MAX_CONTEXT_MESSAGES = 20;
const DEFAULT_MAX_ESTIMATED_TOKENS = 100000;
const CHARS_PER_TOKEN_ESTIMATE = 4; const CHARS_PER_TOKEN_ESTIMATE = 4;
interface OpenAIMessage { interface OpenAIMessage {
@@ -141,7 +139,6 @@ interface OpenRouterConfig {
export class OpenRouterProvider extends OpenAICompatibleProvider<OpenRouterConfig> { export class OpenRouterProvider extends OpenAICompatibleProvider<OpenRouterConfig> {
protected readonly providerName = 'OpenRouter'; protected readonly providerName = 'OpenRouter';
protected readonly syntheticIdPrefix = 'openrouter'; protected readonly syntheticIdPrefix = 'openrouter';
protected readonly requireNonEmptyToTruncate = false;
protected readonly forwardEmptyMessageResponse = true; protected readonly forwardEmptyMessageResponse = true;
constructor(dbManager: DatabaseManager, sessionManager: SessionManager) { constructor(dbManager: DatabaseManager, sessionManager: SessionManager) {
@@ -182,13 +179,6 @@ export class OpenRouterProvider extends OpenAICompatibleProvider<OpenRouterConfi
}; };
} }
protected truncateHistoryForOpenRouter(history: ConversationMessage[]): ConversationMessage[] {
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
const MAX_CONTEXT_MESSAGES = parseInt(settings.CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES) || DEFAULT_MAX_CONTEXT_MESSAGES;
const MAX_ESTIMATED_TOKENS = parseInt(settings.CLAUDE_MEM_OPENROUTER_MAX_TOKENS) || DEFAULT_MAX_ESTIMATED_TOKENS;
return this.truncateHistory(history, MAX_CONTEXT_MESSAGES, MAX_ESTIMATED_TOKENS);
}
private conversationToOpenAIMessages(history: ConversationMessage[]): OpenAIMessage[] { private conversationToOpenAIMessages(history: ConversationMessage[]): OpenAIMessage[] {
return history.map(msg => ({ return history.map(msg => ({
role: msg.role === 'assistant' ? 'assistant' : 'user', role: msg.role === 'assistant' ? 'assistant' : 'user',
@@ -208,13 +198,12 @@ export class OpenRouterProvider extends OpenAICompatibleProvider<OpenRouterConfi
siteUrl?: string, siteUrl?: string,
appName?: string appName?: string
): Promise<ProviderQueryResult> { ): Promise<ProviderQueryResult> {
const truncatedHistory = this.truncateHistoryForOpenRouter(history); const messages = this.conversationToOpenAIMessages(history);
const messages = this.conversationToOpenAIMessages(truncatedHistory); const totalChars = history.reduce((sum, m) => sum + m.content.length, 0);
const totalChars = truncatedHistory.reduce((sum, m) => sum + m.content.length, 0); const estimatedTokens = this.estimateTokens(history.map(m => m.content).join(''));
const estimatedTokens = this.estimateTokens(truncatedHistory.map(m => m.content).join(''));
logger.debug('SDK', `Querying OpenRouter multi-turn (${model})`, { logger.debug('SDK', `Querying OpenRouter multi-turn (${model})`, {
turns: truncatedHistory.length, turns: history.length,
totalChars, totalChars,
estimatedTokens estimatedTokens
}); });
@@ -310,7 +299,7 @@ export class OpenRouterProvider extends OpenAICompatibleProvider<OpenRouterConfi
outputTokens: realOutputTokens || 0, outputTokens: realOutputTokens || 0,
totalTokens: tokensUsed, totalTokens: tokensUsed,
...(costUsd !== undefined ? { costUSD: costUsd.toFixed(6) } : {}), ...(costUsd !== undefined ? { costUSD: costUsd.toFixed(6) } : {}),
messagesInContext: truncatedHistory.length messagesInContext: history.length
}); });
if (tokensUsed > 50000) { if (tokensUsed > 50000) {

View File

@@ -93,14 +93,10 @@ export class SettingsRoutes extends BaseRouteHandler {
'CLAUDE_MEM_GEMINI_API_KEY', 'CLAUDE_MEM_GEMINI_API_KEY',
'CLAUDE_MEM_GEMINI_MODEL', 'CLAUDE_MEM_GEMINI_MODEL',
'CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED', 'CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED',
'CLAUDE_MEM_GEMINI_MAX_CONTEXT_MESSAGES',
'CLAUDE_MEM_GEMINI_MAX_TOKENS',
'CLAUDE_MEM_OPENROUTER_API_KEY', 'CLAUDE_MEM_OPENROUTER_API_KEY',
'CLAUDE_MEM_OPENROUTER_MODEL', 'CLAUDE_MEM_OPENROUTER_MODEL',
'CLAUDE_MEM_OPENROUTER_SITE_URL', 'CLAUDE_MEM_OPENROUTER_SITE_URL',
'CLAUDE_MEM_OPENROUTER_APP_NAME', 'CLAUDE_MEM_OPENROUTER_APP_NAME',
'CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES',
'CLAUDE_MEM_OPENROUTER_MAX_TOKENS',
'CLAUDE_MEM_DATA_DIR', 'CLAUDE_MEM_DATA_DIR',
'CLAUDE_MEM_LOG_LEVEL', 'CLAUDE_MEM_LOG_LEVEL',
'CLAUDE_MEM_PYTHON_VERSION', 'CLAUDE_MEM_PYTHON_VERSION',
@@ -211,20 +207,6 @@ export class SettingsRoutes extends BaseRouteHandler {
} }
} }
if (settings.CLAUDE_MEM_GEMINI_MAX_CONTEXT_MESSAGES) {
const count = parseInt(settings.CLAUDE_MEM_GEMINI_MAX_CONTEXT_MESSAGES, 10);
if (isNaN(count) || count < 1 || count > 100) {
return { valid: false, error: 'CLAUDE_MEM_GEMINI_MAX_CONTEXT_MESSAGES must be between 1 and 100' };
}
}
if (settings.CLAUDE_MEM_GEMINI_MAX_TOKENS) {
const tokens = parseInt(settings.CLAUDE_MEM_GEMINI_MAX_TOKENS, 10);
if (isNaN(tokens) || tokens < 1000 || tokens > 1000000) {
return { valid: false, error: 'CLAUDE_MEM_GEMINI_MAX_TOKENS must be between 1000 and 1000000' };
}
}
if (settings.CLAUDE_MEM_CONTEXT_OBSERVATIONS) { if (settings.CLAUDE_MEM_CONTEXT_OBSERVATIONS) {
const obsCount = parseInt(settings.CLAUDE_MEM_CONTEXT_OBSERVATIONS, 10); const obsCount = parseInt(settings.CLAUDE_MEM_CONTEXT_OBSERVATIONS, 10);
if (isNaN(obsCount) || obsCount < 1 || obsCount > 200) { if (isNaN(obsCount) || obsCount < 1 || obsCount > 200) {
@@ -296,20 +278,6 @@ export class SettingsRoutes extends BaseRouteHandler {
} }
} }
if (settings.CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES) {
const count = parseInt(settings.CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES, 10);
if (isNaN(count) || count < 1 || count > 100) {
return { valid: false, error: 'CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES must be between 1 and 100' };
}
}
if (settings.CLAUDE_MEM_OPENROUTER_MAX_TOKENS) {
const tokens = parseInt(settings.CLAUDE_MEM_OPENROUTER_MAX_TOKENS, 10);
if (isNaN(tokens) || tokens < 1000 || tokens > 1000000) {
return { valid: false, error: 'CLAUDE_MEM_OPENROUTER_MAX_TOKENS must be between 1000 and 1000000' };
}
}
if (settings.CLAUDE_MEM_OPENROUTER_SITE_URL) { if (settings.CLAUDE_MEM_OPENROUTER_SITE_URL) {
try { try {
new URL(settings.CLAUDE_MEM_OPENROUTER_SITE_URL); new URL(settings.CLAUDE_MEM_OPENROUTER_SITE_URL);

View File

@@ -15,16 +15,12 @@ export interface SettingsDefaults {
CLAUDE_MEM_CLAUDE_AUTH_METHOD: string; CLAUDE_MEM_CLAUDE_AUTH_METHOD: string;
CLAUDE_MEM_GEMINI_API_KEY: string; CLAUDE_MEM_GEMINI_API_KEY: string;
CLAUDE_MEM_GEMINI_MODEL: string; CLAUDE_MEM_GEMINI_MODEL: string;
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: string; CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: string;
CLAUDE_MEM_GEMINI_MAX_CONTEXT_MESSAGES: string;
CLAUDE_MEM_GEMINI_MAX_TOKENS: string;
CLAUDE_MEM_OPENROUTER_API_KEY: string; CLAUDE_MEM_OPENROUTER_API_KEY: string;
CLAUDE_MEM_OPENROUTER_MODEL: string; CLAUDE_MEM_OPENROUTER_MODEL: string;
CLAUDE_MEM_OPENROUTER_BASE_URL: string; CLAUDE_MEM_OPENROUTER_BASE_URL: string;
CLAUDE_MEM_OPENROUTER_SITE_URL: string; CLAUDE_MEM_OPENROUTER_SITE_URL: string;
CLAUDE_MEM_OPENROUTER_APP_NAME: string; CLAUDE_MEM_OPENROUTER_APP_NAME: string;
CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES: string;
CLAUDE_MEM_OPENROUTER_MAX_TOKENS: string;
CLAUDE_MEM_DATA_DIR: string; CLAUDE_MEM_DATA_DIR: string;
CLAUDE_MEM_LOG_LEVEL: string; CLAUDE_MEM_LOG_LEVEL: string;
CLAUDE_MEM_PYTHON_VERSION: string; CLAUDE_MEM_PYTHON_VERSION: string;
@@ -104,15 +100,11 @@ export class SettingsDefaultsManager {
CLAUDE_MEM_GEMINI_API_KEY: '', // Empty by default, can be set via UI or env CLAUDE_MEM_GEMINI_API_KEY: '', // Empty by default, can be set via UI or env
CLAUDE_MEM_GEMINI_MODEL: 'gemini-2.5-flash-lite', // Default Gemini model (highest free tier RPM) CLAUDE_MEM_GEMINI_MODEL: 'gemini-2.5-flash-lite', // Default Gemini model (highest free tier RPM)
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: 'true', // Rate limiting ON by default for free tier users CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: 'true', // Rate limiting ON by default for free tier users
CLAUDE_MEM_GEMINI_MAX_CONTEXT_MESSAGES: '20', // Max messages in Gemini context window
CLAUDE_MEM_GEMINI_MAX_TOKENS: '100000', // Max estimated tokens (~100k safety limit)
CLAUDE_MEM_OPENROUTER_API_KEY: '', // Empty by default, can be set via UI or env CLAUDE_MEM_OPENROUTER_API_KEY: '', // Empty by default, can be set via UI or env
CLAUDE_MEM_OPENROUTER_MODEL: 'xiaomi/mimo-v2-flash:free', // Default OpenRouter model (free tier) CLAUDE_MEM_OPENROUTER_MODEL: 'xiaomi/mimo-v2-flash:free', // Default OpenRouter model (free tier)
CLAUDE_MEM_OPENROUTER_BASE_URL: '', // #2382/#2590/#2622/#2393 — optional OpenAI-compatible base URL (e.g. https://api.deepseek.com, http://localhost:1234/v1). Empty = default OpenRouter endpoint. CLAUDE_MEM_OPENROUTER_BASE_URL: '', // #2382/#2590/#2622/#2393 — optional OpenAI-compatible base URL (e.g. https://api.deepseek.com, http://localhost:1234/v1). Empty = default OpenRouter endpoint.
CLAUDE_MEM_OPENROUTER_SITE_URL: '', // Optional: for OpenRouter analytics CLAUDE_MEM_OPENROUTER_SITE_URL: '', // Optional: for OpenRouter analytics
CLAUDE_MEM_OPENROUTER_APP_NAME: 'claude-mem', // App name for OpenRouter analytics CLAUDE_MEM_OPENROUTER_APP_NAME: 'claude-mem', // App name for OpenRouter analytics
CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES: '20', // Max messages in context window
CLAUDE_MEM_OPENROUTER_MAX_TOKENS: '100000', // Max estimated tokens (~100k safety limit)
CLAUDE_MEM_DATA_DIR: join(homedir(), '.claude-mem'), CLAUDE_MEM_DATA_DIR: join(homedir(), '.claude-mem'),
CLAUDE_MEM_LOG_LEVEL: 'INFO', CLAUDE_MEM_LOG_LEVEL: 'INFO',
CLAUDE_MEM_PYTHON_VERSION: '3.13', CLAUDE_MEM_PYTHON_VERSION: '3.13',

View File

@@ -41,14 +41,12 @@ function makeSession(overrides: Record<string, unknown> = {}) {
} as any; } as any;
} }
function mockGeminiLimits(maxContextMessages: string) { function mockGeminiConfig() {
loadFromFileSpy.mockImplementation(() => ({ loadFromFileSpy.mockImplementation(() => ({
...SettingsDefaultsManager.getAllDefaults(), ...SettingsDefaultsManager.getAllDefaults(),
CLAUDE_MEM_GEMINI_API_KEY: 'test-api-key', CLAUDE_MEM_GEMINI_API_KEY: 'test-api-key',
CLAUDE_MEM_GEMINI_MODEL: 'gemini-2.5-flash-lite', CLAUDE_MEM_GEMINI_MODEL: 'gemini-2.5-flash-lite',
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: 'false', CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: 'false',
CLAUDE_MEM_GEMINI_MAX_CONTEXT_MESSAGES: maxContextMessages,
CLAUDE_MEM_GEMINI_MAX_TOKENS: '100000',
CLAUDE_MEM_DATA_DIR: '/tmp/claude-mem-test', CLAUDE_MEM_DATA_DIR: '/tmp/claude-mem-test',
})); }));
} }
@@ -242,30 +240,7 @@ describe('GeminiProvider', () => {
expect(body.contents[2].role).toBe('user'); expect(body.contents[2].role).toBe('user');
}); });
it('repairs truncated history that would otherwise start with a model turn', async () => { it('keeps Gemini roles alternating for full conversation history', async () => {
mockGeminiLimits('2');
const session = makeSession({
userPrompt: 'current user prompt',
lastPromptNumber: 2,
conversationHistory: [
{ role: 'user', content: 'old user turn' },
{ role: 'assistant', content: 'assistant turn kept by truncation' },
],
});
mockSuccessfulGeminiFetch();
await agent.startSession(session);
const contents = sentGeminiContents();
expectAlternatingGeminiRoles(contents);
expect(contents[0].role).toBe('user');
expect(contents[0].parts[0].text).toContain('current user prompt');
expect(contents.map((content: any) => content.parts[0].text).join('\n')).not.toContain('assistant turn kept by truncation');
});
it('keeps Gemini roles alternating with odd and even context message limits', async () => {
const history = [ const history = [
{ role: 'user', content: 'u0' }, { role: 'user', content: 'u0' },
{ role: 'assistant', content: 'm1' }, { role: 'assistant', content: 'm1' },
@@ -275,12 +250,12 @@ describe('GeminiProvider', () => {
{ role: 'assistant', content: 'm5' }, { role: 'assistant', content: 'm5' },
]; ];
for (const maxContextMessages of ['4', '5']) { for (const label of ['a', 'b']) {
mockGeminiLimits(maxContextMessages); mockGeminiConfig();
mockSuccessfulGeminiFetch(); mockSuccessfulGeminiFetch();
await agent.startSession(makeSession({ await agent.startSession(makeSession({
userPrompt: `current prompt ${maxContextMessages}`, userPrompt: `current prompt ${label}`,
lastPromptNumber: 2, lastPromptNumber: 2,
conversationHistory: history.map(message => ({ ...message })), conversationHistory: history.map(message => ({ ...message })),
})); }));
@@ -288,7 +263,7 @@ describe('GeminiProvider', () => {
const contents = sentGeminiContents(); const contents = sentGeminiContents();
expectAlternatingGeminiRoles(contents); expectAlternatingGeminiRoles(contents);
expect(contents[contents.length - 1].role).toBe('user'); expect(contents[contents.length - 1].role).toBe('user');
expect(contents[contents.length - 1].parts[0].text).toContain(`current prompt ${maxContextMessages}`); expect(contents[contents.length - 1].parts[0].text).toContain(`current prompt ${label}`);
} }
}); });
@@ -489,81 +464,6 @@ describe('GeminiProvider', () => {
} }
}); });
describe('conversation history truncation', () => {
it('should truncate history when message count exceeds limit', async () => {
const history: any[] = [];
for (let i = 0; i < 25; i++) {
history.push({ role: i % 2 === 0 ? 'user' : 'assistant', content: `message ${i}` });
}
const session = {
sessionDbId: 1,
contentSessionId: 'test-session',
memorySessionId: 'mem-session-123',
project: 'test-project',
userPrompt: 'test prompt',
conversationHistory: history,
lastPromptNumber: 2,
cumulativeInputTokens: 0,
cumulativeOutputTokens: 0,
pendingMessages: [],
abortController: new AbortController(),
generatorPromise: null,
currentProvider: null,
startTime: Date.now(),
} as any;
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
candidates: [{ content: { parts: [{ text: 'response' }] } }]
}))));
await agent.startSession(session);
const body = JSON.parse((global.fetch as any).mock.calls[0][1].body);
expect(body.contents.length).toBeLessThanOrEqual(20);
});
it('should always keep at least the newest message even if it exceeds token limit', async () => {
loadFromFileSpy.mockImplementation(() => ({
...SettingsDefaultsManager.getAllDefaults(),
CLAUDE_MEM_GEMINI_API_KEY: 'test-api-key',
CLAUDE_MEM_GEMINI_MODEL: 'gemini-2.5-flash-lite',
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: 'false',
CLAUDE_MEM_GEMINI_MAX_CONTEXT_MESSAGES: '20',
CLAUDE_MEM_GEMINI_MAX_TOKENS: '1000', // Very low: ~250 chars
CLAUDE_MEM_DATA_DIR: '/tmp/claude-mem-test',
}));
const largeContent = 'x'.repeat(8000);
const session = {
sessionDbId: 1,
contentSessionId: 'test-session',
memorySessionId: 'mem-session-123',
project: 'test-project',
userPrompt: largeContent,
conversationHistory: [],
lastPromptNumber: 1,
cumulativeInputTokens: 0,
cumulativeOutputTokens: 0,
pendingMessages: [],
abortController: new AbortController(),
generatorPromise: null,
currentProvider: null,
startTime: Date.now(),
} as any;
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
candidates: [{ content: { parts: [{ text: 'response' }] } }]
}))));
await agent.startSession(session);
const body = JSON.parse((global.fetch as any).mock.calls[0][1].body);
expect(body.contents.length).toBeGreaterThanOrEqual(1);
});
});
describe('gemini-3-flash-preview model support', () => { describe('gemini-3-flash-preview model support', () => {
it('should accept gemini-3-flash-preview as a valid model', async () => { it('should accept gemini-3-flash-preview as a valid model', async () => {
const validModels = [ const validModels = [