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
31 changes: 18 additions & 13 deletions src/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,20 +326,25 @@ export class ConversationMemory {
}

private async persist(): Promise<void> {
const interruptedByUser: Record<string, PendingTaskRecovery> = {};
for (const [key, value] of this.interruptedByUser.entries()) {
interruptedByUser[String(key)] = value;
}
const frameStateByUser: Record<string, RollingConversationFrameState> = {};
for (const [key, value] of this.frameStateByUser.entries()) {
frameStateByUser[String(key)] = value;
try {
const interruptedByUser: Record<string, PendingTaskRecovery> = {};
for (const [key, value] of this.interruptedByUser.entries()) {
interruptedByUser[String(key)] = value;
}
const frameStateByUser: Record<string, RollingConversationFrameState> = {};
for (const [key, value] of this.frameStateByUser.entries()) {
frameStateByUser[String(key)] = value;
}
await writeJsonAtomic(this.statePath, {
recentByUser: this.recordFromMap(this.recentByUser),
notesByUser: this.recordFromMap(this.notesByUser),
interruptedByUser,
frameStateByUser
});
} catch (error) {
console.error('[ConversationMemory] persist failed:', error);
throw error;
}
await writeJsonAtomic(this.statePath, {
recentByUser: this.recordFromMap(this.recentByUser),
notesByUser: this.recordFromMap(this.notesByUser),
interruptedByUser,
frameStateByUser
});
}

private async pushBounded(map: Map<number, string[]>, key: number, value: string, limit: number): Promise<void> {
Expand Down
35 changes: 31 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import { generateBuildClarificationMicrocopy, llm, type BuildClarificationMicroc
import { sanitizeAndSplitTelegramText } from './outboundSanitize';
import { applyPlainWordsSurfaceRequest } from './telegramSurface';
import { installConsoleRedaction, redactIdentifier, redactText } from './redaction';
import { readJsonFile } from './jsonState';
import { readJsonFile, closeJsonState } from './jsonState';
import {
formatCreatorMissionExecutionSummary,
formatCreatorMissionStatusSummary,
Expand Down Expand Up @@ -1753,6 +1753,7 @@ const CLARIFICATION_TTL_MS = 30 * 60 * 1000; // 30 minutes
const MISSION_CANCEL_CONFIRMATION_TTL_MS = 5 * 60 * 1000;

// Periodic cleanup of stale entries in all unbounded maps
const RATE_LIMIT_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
const mapCleanupTimer = setInterval(() => {
const now = Date.now();
for (const [key, entry] of lastNoEditProbeMissions) {
Expand Down Expand Up @@ -1782,7 +1783,6 @@ const mapCleanupTimer = setInterval(() => {
}
}, MAP_CLEANUP_INTERVAL_MS);
mapCleanupTimer.unref?.();

const PUBLIC_ONBOARDING_COMMANDS = new Set(['/start', '/myid']);
const TELEGRAM_POLLING_READY_GRACE_MS = 3000;
let pollingActive = false;
Expand Down Expand Up @@ -1830,6 +1830,31 @@ async function handlePendingMissionCancelConfirmation(ctx: any, text: string): P
return true;
}

// Periodic cleanup of stale rate-limit and pending-clarification entries to prevent memory leaks
setInterval(() => {
const now = Date.now();
for (const [userId, timestamp] of userLastAction.entries()) {
if (now - timestamp > RATE_LIMIT_CLEANUP_INTERVAL_MS) {
userLastAction.delete(userId);
}
}
for (const [key, pending] of pendingClarifications.entries()) {
if (now - pending.timestamp > CLARIFICATION_TTL_MS) {
pendingClarifications.delete(key);
}
}
for (const [key, pending] of pendingDomainChipBuilds.entries()) {
if (now - pending.timestamp > CLARIFICATION_TTL_MS) {
pendingDomainChipBuilds.delete(key);
}
}
for (const [key, pending] of pendingCreatorMissions.entries()) {
if (now - pending.timestamp > CLARIFICATION_TTL_MS) {
pendingCreatorMissions.delete(key);
}
}
}, RATE_LIMIT_CLEANUP_INTERVAL_MS);

function extractCommandName(text: string | undefined): string | null {
if (!text?.startsWith('/')) {
return null;
Expand Down Expand Up @@ -3940,8 +3965,8 @@ function isBareExecutionStart(text: string): boolean {

export function shouldUsePendingClarificationForMessage(pending: { timestamp: number } | null | undefined, text: string): boolean {
if (!pending) return false;
const expired = Date.now() - pending.timestamp > CLARIFICATION_TTL_MS;
return !expired && isPendingClarificationFollowup(text);
if (Date.now() - pending.timestamp > CLARIFICATION_TTL_MS) return false;
return true;
}

function pendingClarificationForMessage(key: string, text: string): PendingClarification | null {
Expand Down Expand Up @@ -7019,13 +7044,15 @@ bot.on(message('audio'), handleVoiceMessage);
process.once('SIGINT', () => {
console.log('Shutting down...');
void releaseGatewayOwnership();
closeJsonState();
if (pollingActive) {
bot.stop('SIGINT');
}
});
process.once('SIGTERM', () => {
console.log('Shutting down...');
void releaseGatewayOwnership();
closeJsonState();
if (pollingActive) {
bot.stop('SIGTERM');
}
Expand Down
12 changes: 12 additions & 0 deletions src/jsonState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ export function resolveStatePath(filename: string): string {
return path.join(stateDir || process.cwd(), filename);
}

export function closeJsonState(): void {
if (!db) return;
try {
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
db.close();
} catch {
// Best-effort cleanup
} finally {
db = null;
}
}

export function resetJsonStateForTests(): void {
if (!db) return;
try {
Expand Down