Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions plugin/modes/code--zh-tw.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "Code Development (Traditional Chinese)",
"prompts": {
"footer": "IMPORTANT! DO NOT do any work right now other than generating this OBSERVATIONS from tool use messages - and remember that you are a memory agent designed to summarize a DIFFERENT claude code session, not this one.\n\nNever reference yourself or your own actions. Do not output anything other than the observation content formatted in the XML structure above. All other output is ignored by the system, and the system has been designed to be smart about token usage. Please spend your tokens wisely on useful observations.\n\nRemember that we record these observations as a way of helping us stay on track with our progress, and to help us keep important decisions and changes at the forefront of our minds! :) Thank you so much for your help!\n\nLANGUAGE REQUIREMENTS: Please write the observation data in 繁體中文",

"xml_title_placeholder": "[**title**: 捕捉核心行動或主題的簡短標題]",
"xml_subtitle_placeholder": "[**subtitle**: 一句話解釋(最多24個單詞)]",
"xml_fact_placeholder": "[簡潔、獨立的陳述]",
"xml_narrative_placeholder": "[**narrative**: 完整背景:做了什麼、如何運作、為什麼重要]",
"xml_concept_placeholder": "[知識類型類別]",
"xml_file_placeholder": "[檔案路徑]",

"xml_summary_request_placeholder": "[捕捉使用者請求和討論/完成內容實質的簡短標題]",
"xml_summary_investigated_placeholder": "[到目前為止探索了什麼?檢查了什麼?]",
"xml_summary_learned_placeholder": "[你了解到了什麼運作原理?]",
"xml_summary_completed_placeholder": "[到目前為止完成了什麼工作?發佈或更改了什麼?]",
"xml_summary_next_steps_placeholder": "[在此會話中,你正在積極處理或計劃接下來處理什麼?]",
"xml_summary_notes_placeholder": "[關於當前進度的其他見解或觀察]",

"continuation_instruction": "IMPORTANT: Continue generating observations from tool use messages using the XML structure below.\n\nLANGUAGE REQUIREMENTS: Please write the observation data in 繁體中文",

"summary_footer": "IMPORTANT! DO NOT do any work right now other than generating this next PROGRESS SUMMARY - and remember that you are a memory agent designed to summarize a DIFFERENT claude code session, not this one.\n\nNever reference yourself or your own actions. Do not output anything other than the summary content formatted in the XML structure above. All other output is ignored by the system, and the system has been designed to be smart about token usage. Please spend your tokens wisely on useful summary content.\n\nThank you, this summary will be very useful for keeping track of our progress!\n\nLANGUAGE REQUIREMENTS: Please write ALL summary content (request, investigated, learned, completed, next_steps, notes) in 繁體中文"
}
}
36 changes: 22 additions & 14 deletions plugin/scripts/smart-install.js
Original file line number Diff line number Diff line change
Expand Up @@ -340,20 +340,20 @@ function installUv() {
}

/**
* Add shell alias for claude-mem command
* Add shell alias for claude-mem command.
* Uses a version-agnostic path that resolves the latest installed version at runtime,
* so the alias survives plugin upgrades without needing to re-source shell config.
*/
function installCLI() {
const WORKER_CLI = join(ROOT, 'scripts', 'worker-service.cjs');
const bunPath = getBunPath() || 'bun';
const aliasLine = `alias claude-mem='${bunPath} "${WORKER_CLI}"'`;
const markerPath = join(ROOT, '.cli-installed');

// Skip if already installed
if (existsSync(markerPath)) return;
// Version-agnostic: resolve the latest installed version at runtime
const versionAgnosticScript = `$(ls -d ~/.claude/plugins/cache/thedotmack/claude-mem/*/scripts/worker-service.cjs 2>/dev/null | sort -V | tail -1 || echo "${join(ROOT, 'scripts', 'worker-service.cjs')}")`;
const aliasLine = `alias claude-mem='${bunPath} "${versionAgnosticScript}"'`;

try {
if (IS_WINDOWS) {
// Windows: Add to PATH via PowerShell profile
const WORKER_CLI = join(ROOT, 'scripts', 'worker-service.cjs');
const profilePath = join(process.env.USERPROFILE || homedir(), 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1');
const profileDir = join(process.env.USERPROFILE || homedir(), 'Documents', 'PowerShell');
const functionDef = `function claude-mem { & "${bunPath}" "${WORKER_CLI}" $args }\n`;
Expand All @@ -363,13 +363,18 @@ function installCLI() {
}

const existingContent = existsSync(profilePath) ? readFileSync(profilePath, 'utf-8') : '';
if (!existingContent.includes('function claude-mem')) {
if (existingContent.includes('function claude-mem')) {
// Update existing function definition
const updated = existingContent.replace(/function claude-mem \{[^\n]*\}\n?/, functionDef);
writeFileSync(profilePath, updated);
console.error(`✅ PowerShell function updated in profile`);
} else {
writeFileSync(profilePath, existingContent + '\n' + functionDef);
console.error(`✅ PowerShell function added to profile`);
console.error(' Restart your terminal to use: claude-mem <command>');
}
console.error(' Restart your terminal to use: claude-mem <command>');
} else {
// Unix: Add alias to shell configs
// Unix: Add or update alias in shell configs
const shellConfigs = [
join(homedir(), '.bashrc'),
join(homedir(), '.zshrc')
Expand All @@ -378,19 +383,22 @@ function installCLI() {
for (const config of shellConfigs) {
if (existsSync(config)) {
const content = readFileSync(config, 'utf-8');
if (!content.includes('alias claude-mem=')) {
if (content.includes('alias claude-mem=')) {
// Update existing alias to use version-agnostic path
const updated = content.replace(/alias claude-mem='[^']*'/g, aliasLine);
writeFileSync(config, updated);
console.error(`✅ Alias updated in ${config}`);
} else {
writeFileSync(config, content + '\n' + aliasLine + '\n');
console.error(`✅ Alias added to ${config}`);
}
}
}
console.error(' Restart your terminal to use: claude-mem <command>');
}

writeFileSync(markerPath, new Date().toISOString());
} catch (error) {
console.error(`⚠️ Could not add shell alias: ${error.message}`);
console.error(` Use directly: ${bunPath} "${WORKER_CLI}" <command>`);
console.error(` Use directly: ${bunPath} "${join(ROOT, 'scripts', 'worker-service.cjs')}" <command>`);
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/services/sync/ChromaMcpManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@ export class ChromaMcpManager {

const args = [
'--python', pythonVersion,
'--with', 'httpcore',
'--with', 'httpx',
'chroma-mcp',
'--client-type', 'http',
'--host', chromaHost,
Expand All @@ -233,6 +235,8 @@ export class ChromaMcpManager {
// Local mode: persistent client with data directory
return [
'--python', pythonVersion,
'--with', 'httpcore',
'--with', 'httpx',
'chroma-mcp',
'--client-type', 'persistent',
'--data-dir', DEFAULT_CHROMA_DATA_DIR.replace(/\\/g, '/')
Expand Down
30 changes: 17 additions & 13 deletions src/services/worker-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,13 @@ export class WorkerService {
if (reaped > 0) {
logger.info('SYSTEM', `Reaped ${reaped} stale sessions`);
}

// Apply restart decay: if a session has been processing successfully
// for 5+ minutes since its last restart, clear the restart history
const { applyRestartDecay } = await import('./worker/RestartGuard.js');
this.sessionManager.forEachActiveSession((session) => {
applyRestartDecay(session);
});
} catch (e) {
logger.error('SYSTEM', 'Stale session reaper error', { error: e instanceof Error ? e.message : String(e) });
}
Expand Down Expand Up @@ -759,19 +766,12 @@ export class WorkerService {
}
// Fall through to pending-work restart below
}
const MAX_PENDING_RESTARTS = 3;

if (pendingCount > 0) {
// Track consecutive pending-work restarts to prevent infinite loops (e.g. FK errors)
session.consecutiveRestarts = (session.consecutiveRestarts || 0) + 1;

if (session.consecutiveRestarts > MAX_PENDING_RESTARTS) {
logger.error('SYSTEM', 'Exceeded max pending-work restarts, stopping to prevent infinite loop', {
sessionId: session.sessionDbId,
pendingCount,
consecutiveRestarts: session.consecutiveRestarts
});
session.consecutiveRestarts = 0;
// Time-windowed restart guard: only count restarts within last 60s, cap at 10
const { recordRestartAndCheckAllowed, resetRestartCounter } = await import('./worker/RestartGuard.js');

if (!recordRestartAndCheckAllowed(session, 'Pending-work restart')) {
resetRestartCounter(session);
this.terminateSession(session.sessionDbId, 'max_restarts_exceeded');
return;
}
Expand All @@ -789,7 +789,8 @@ export class WorkerService {
} else {
// Successful completion with no pending work — clean up session
// removeSessionImmediate fires onSessionDeletedCallback → broadcastProcessingStatus()
session.consecutiveRestarts = 0;
const { resetRestartCounter: resetCounter } = await import('./worker/RestartGuard.js');
resetCounter(session);
this.sessionManager.removeSessionImmediate(session.sessionDbId);
}
});
Expand Down Expand Up @@ -1007,6 +1008,9 @@ export class WorkerService {
this.staleSessionReaperInterval = null;
}

// Stop SSE broadcaster cleanup interval to prevent timer leak
this.sseBroadcaster.dispose();

await performGracefulShutdown({
server: this.server.getHttpServer(),
sessionManager: this.sessionManager,
Expand Down
1 change: 1 addition & 0 deletions src/services/worker-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export interface ActiveSession {
conversationHistory: ConversationMessage[]; // Shared conversation history for provider switching
currentProvider: 'claude' | 'gemini' | 'openrouter' | null; // Track which provider is currently running
consecutiveRestarts: number; // Track consecutive restart attempts to prevent infinite loops
restartTimestamps: number[]; // Timestamps of recent restarts for time-windowed counting
forceInit?: boolean; // Force fresh SDK session (skip resume)
idleTimedOut?: boolean; // Set when session exits due to idle timeout (prevents restart loop)
lastGeneratorActivity: number; // Timestamp of last generator progress (for stale detection, Issue #1099)
Expand Down
27 changes: 17 additions & 10 deletions src/services/worker/OpenRouterAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ import {
type WorkerRef
} from './agents/index.js';

// OpenRouter API endpoint
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
// OpenRouter API endpoint (configurable via env or settings)
const DEFAULT_OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';

// Context window management constants (defaults, overridable via settings)
const DEFAULT_MAX_CONTEXT_MESSAGES = 20; // Maximum messages to keep in conversation history
Expand Down Expand Up @@ -86,7 +86,7 @@ export class OpenRouterAgent {
async startSession(session: ActiveSession, worker?: WorkerRef): Promise<void> {
try {
// Get OpenRouter configuration
const { apiKey, model, siteUrl, appName } = this.getOpenRouterConfig();
const { apiKey, model, siteUrl, appName, baseUrl } = this.getOpenRouterConfig();

if (!apiKey) {
throw new Error('OpenRouter API key not configured. Set CLAUDE_MEM_OPENROUTER_API_KEY in settings or OPENROUTER_API_KEY environment variable.');
Expand All @@ -110,7 +110,7 @@ export class OpenRouterAgent {

// Add to conversation history and query OpenRouter with full context
session.conversationHistory.push({ role: 'user', content: initPrompt });
const initResponse = await this.queryOpenRouterMultiTurn(session.conversationHistory, apiKey, model, siteUrl, appName);
const initResponse = await this.queryOpenRouterMultiTurn(session.conversationHistory, apiKey, model, siteUrl, appName, baseUrl);

if (initResponse.content) {
// Add response to conversation history
Expand Down Expand Up @@ -181,7 +181,7 @@ export class OpenRouterAgent {

// Add to conversation history and query OpenRouter with full context
session.conversationHistory.push({ role: 'user', content: obsPrompt });
const obsResponse = await this.queryOpenRouterMultiTurn(session.conversationHistory, apiKey, model, siteUrl, appName);
const obsResponse = await this.queryOpenRouterMultiTurn(session.conversationHistory, apiKey, model, siteUrl, appName, baseUrl);

let tokensUsed = 0;
if (obsResponse.content) {
Expand Down Expand Up @@ -224,7 +224,7 @@ export class OpenRouterAgent {

// Add to conversation history and query OpenRouter with full context
session.conversationHistory.push({ role: 'user', content: summaryPrompt });
const summaryResponse = await this.queryOpenRouterMultiTurn(session.conversationHistory, apiKey, model, siteUrl, appName);
const summaryResponse = await this.queryOpenRouterMultiTurn(session.conversationHistory, apiKey, model, siteUrl, appName, baseUrl);

let tokensUsed = 0;
if (summaryResponse.content) {
Expand Down Expand Up @@ -356,7 +356,8 @@ export class OpenRouterAgent {
apiKey: string,
model: string,
siteUrl?: string,
appName?: string
appName?: string,
apiUrl?: string
): Promise<{ content: string; tokensUsed?: number }> {
// Truncate history to prevent runaway costs
const truncatedHistory = this.truncateHistory(history);
Expand All @@ -370,7 +371,8 @@ export class OpenRouterAgent {
estimatedTokens
});

const response = await fetch(OPENROUTER_API_URL, {
const resolvedApiUrl = apiUrl || DEFAULT_OPENROUTER_API_URL;
const response = await fetch(resolvedApiUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
Expand Down Expand Up @@ -438,7 +440,7 @@ export class OpenRouterAgent {
* Get OpenRouter configuration from settings or environment
* Issue #733: Uses centralized ~/.claude-mem/.env for credentials, not random project .env files
*/
private getOpenRouterConfig(): { apiKey: string; model: string; siteUrl?: string; appName?: string } {
private getOpenRouterConfig(): { apiKey: string; model: string; siteUrl?: string; appName?: string; baseUrl: string } {
const settingsPath = USER_SETTINGS_PATH;
const settings = SettingsDefaultsManager.loadFromFile(settingsPath);

Expand All @@ -449,11 +451,16 @@ export class OpenRouterAgent {
// Model: from settings or default
const model = settings.CLAUDE_MEM_OPENROUTER_MODEL || 'xiaomi/mimo-v2-flash:free';

// Base URL: configurable for proxies or alternative endpoints
const baseUrl = process.env.CLAUDE_MEM_OPENROUTER_BASE_URL ||
settings.CLAUDE_MEM_OPENROUTER_BASE_URL ||
DEFAULT_OPENROUTER_API_URL;

// Optional analytics headers
const siteUrl = settings.CLAUDE_MEM_OPENROUTER_SITE_URL || '';
const appName = settings.CLAUDE_MEM_OPENROUTER_APP_NAME || 'claude-mem';

return { apiKey, model, siteUrl, appName };
return { apiKey, model, siteUrl, appName, baseUrl };
}
}

Expand Down
92 changes: 92 additions & 0 deletions src/services/worker/RestartGuard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* RestartGuard: Time-windowed restart counter for session generators.
*
* Replaces the flat consecutiveRestarts counter with a windowed approach:
* - Only counts restarts within the last RESTART_WINDOW_MS (60 seconds)
* - Higher raw cap (10) to accommodate legitimate long sessions
* - Resets after RESTART_DECAY_MS (5 minutes) of successful processing
*
* Shared between worker-service.ts and SessionRoutes.ts to prevent
* inconsistent restart guard logic.
*/

import type { ActiveSession } from '../worker-types.js';
import { logger } from '../../utils/logger.js';

/** Only count restarts within this window */
const RESTART_WINDOW_MS = 60_000; // 60 seconds

/** Reset counter after this much successful processing */
const RESTART_DECAY_MS = 5 * 60_000; // 5 minutes

/** Maximum restarts allowed within the window */
const MAX_WINDOWED_RESTARTS = 10;

/**
* Record a restart attempt and check whether the session has exceeded the limit.
*
* @returns true if the restart is allowed, false if it should be blocked
*/
export function recordRestartAndCheckAllowed(session: ActiveSession, logContext: string): boolean {
const now = Date.now();

// Initialize restartTimestamps if missing (backward compat)
if (!session.restartTimestamps) {
session.restartTimestamps = [];
}

// Add current restart timestamp
session.restartTimestamps.push(now);

// Prune timestamps outside the window
session.restartTimestamps = session.restartTimestamps.filter(
ts => (now - ts) < RESTART_WINDOW_MS
);

// Also maintain the legacy counter for logging
session.consecutiveRestarts = (session.consecutiveRestarts || 0) + 1;

const restartsInWindow = session.restartTimestamps.length;

if (restartsInWindow > MAX_WINDOWED_RESTARTS) {
logger.error('SYSTEM', `${logContext}: Exceeded max windowed restarts (${restartsInWindow}/${MAX_WINDOWED_RESTARTS} in ${RESTART_WINDOW_MS / 1000}s)`, {
sessionId: session.sessionDbId,
restartsInWindow,
maxRestarts: MAX_WINDOWED_RESTARTS,
windowMs: RESTART_WINDOW_MS
});
return false;
}

return true;
}

/**
* Reset the restart counter after successful processing.
* Called when a session completes with no pending work, or after
* sustained successful processing (decay).
*/
export function resetRestartCounter(session: ActiveSession): void {
session.consecutiveRestarts = 0;
session.restartTimestamps = [];
}

/**
* Apply time decay: if enough time has passed since the last restart,
* clear the restart history. Call this periodically during successful processing.
*/
export function applyRestartDecay(session: ActiveSession): void {
if (!session.restartTimestamps || session.restartTimestamps.length === 0) return;

const now = Date.now();
const mostRecentRestart = Math.max(...session.restartTimestamps);

if (now - mostRecentRestart > RESTART_DECAY_MS) {
logger.debug('SYSTEM', 'Restart counter decayed after sustained success', {
sessionId: session.sessionDbId,
previousRestarts: session.restartTimestamps.length,
decayMs: RESTART_DECAY_MS
});
resetRestartCounter(session);
}
}
Comment on lines +68 to +92
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 applyRestartDecay is defined but never called

applyRestartDecay is exported but nothing in this PR (or anywhere in the diff) calls it. The docstring says "call this periodically during successful processing," but no callsite wires it up. The 5-minute decay won't happen; timestamps age out only via the 60 s window filter. Consider either connecting it to the message-processing loop or removing it until it is needed.

Fix in Claude Code

Loading
Loading