Skip to content

Commit 9af5f7a

Browse files
ziggyclaude
andcommitted
fix: bound message history queries to prevent memory/token overflow (v0.7.0)
getNewMessages and getMessagesSince were loading unbounded results from SQLite. Added LIMIT (default 200) via DESC subquery, per-prompt cap (MAX_MESSAGES_PER_PROMPT=50), and cursor recovery from last bot message timestamp to prevent full history dump on restart or corrupted state. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4cadfd7 commit 9af5f7a

6 files changed

Lines changed: 67 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## v0.7.0 (2026-03-29) — Message history overflow fix
4+
5+
### Fixes
6+
- **Message history queries now have LIMIT**`getNewMessages()` and `getMessagesSince()` were loading unbounded results from SQLite. As conversations grew, this caused escalating memory usage and token costs. Both queries now cap at 200 rows (configurable), using a DESC subquery to keep the most recent messages.
7+
- **Per-prompt message cap**`MAX_MESSAGES_PER_PROMPT` (default 50, configurable via env var) limits how many messages are bundled into a single agent prompt.
8+
- **Cursor recovery on restart** — when `lastAgentTimestamp` is missing (new group, corrupted state, or restart), the system now recovers from the last bot message timestamp instead of falling back to empty string (which would send the entire conversation history).
9+
310
## v0.6.9 (2026-03-29) — Structured memory + process watchdog
411

512
### New

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ghostclaw",
3-
"version": "0.6.9",
3+
"version": "0.7.0",
44
"description": "Personal AI assistant. Bare metal, Telegram-first, no containers.",
55
"type": "module",
66
"main": "dist/index.js",

src/config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ export const MAX_CONCURRENT_CONTAINERS = Math.max(
6666
parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5,
6767
);
6868

69+
export const MAX_MESSAGES_PER_PROMPT = Math.max(
70+
1,
71+
parseInt(process.env.MAX_MESSAGES_PER_PROMPT || '50', 10) || 50,
72+
);
73+
6974
function escapeRegex(str: string): string {
7075
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
7176
}

src/db.ts

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -302,24 +302,29 @@ export function getNewMessages(
302302
jids: string[],
303303
lastTimestamp: string,
304304
botPrefix: string,
305+
limit = 200,
305306
): { messages: NewMessage[]; newTimestamp: string } {
306307
if (jids.length === 0) return { messages: [], newTimestamp: lastTimestamp };
307308

308309
const placeholders = jids.map(() => '?').join(',');
309310
// Filter bot messages using both the is_bot_message flag AND the content
310311
// prefix as a backstop for messages written before the migration ran.
312+
// Use a subquery to get the most recent N messages, then re-sort chronologically.
311313
const sql = `
312-
SELECT id, chat_jid, sender, sender_name, content, timestamp
313-
FROM messages
314-
WHERE timestamp > ? AND chat_jid IN (${placeholders})
315-
AND is_bot_message = 0 AND content NOT LIKE ?
316-
AND content != '' AND content IS NOT NULL
317-
ORDER BY timestamp
314+
SELECT * FROM (
315+
SELECT id, chat_jid, sender, sender_name, content, timestamp
316+
FROM messages
317+
WHERE timestamp > ? AND chat_jid IN (${placeholders})
318+
AND is_bot_message = 0 AND content NOT LIKE ?
319+
AND content != '' AND content IS NOT NULL
320+
ORDER BY timestamp DESC
321+
LIMIT ?
322+
) ORDER BY timestamp
318323
`;
319324

320325
const rows = db
321326
.prepare(sql)
322-
.all(lastTimestamp, ...jids, `${botPrefix}:%`) as NewMessage[];
327+
.all(lastTimestamp, ...jids, `${botPrefix}:%`, limit) as NewMessage[];
323328

324329
let newTimestamp = lastTimestamp;
325330
for (const row of rows) {
@@ -333,20 +338,36 @@ export function getMessagesSince(
333338
chatJid: string,
334339
sinceTimestamp: string,
335340
botPrefix: string,
341+
limit = 200,
336342
): NewMessage[] {
337343
// Filter bot messages using both the is_bot_message flag AND the content
338344
// prefix as a backstop for messages written before the migration ran.
345+
// Use a subquery to get the most recent N messages, then re-sort chronologically.
339346
const sql = `
340-
SELECT id, chat_jid, sender, sender_name, content, timestamp
341-
FROM messages
342-
WHERE chat_jid = ? AND timestamp > ?
343-
AND is_bot_message = 0 AND content NOT LIKE ?
344-
AND content != '' AND content IS NOT NULL
345-
ORDER BY timestamp
347+
SELECT * FROM (
348+
SELECT id, chat_jid, sender, sender_name, content, timestamp
349+
FROM messages
350+
WHERE chat_jid = ? AND timestamp > ?
351+
AND is_bot_message = 0 AND content NOT LIKE ?
352+
AND content != '' AND content IS NOT NULL
353+
ORDER BY timestamp DESC
354+
LIMIT ?
355+
) ORDER BY timestamp
346356
`;
347357
return db
348358
.prepare(sql)
349-
.all(chatJid, sinceTimestamp, `${botPrefix}:%`) as NewMessage[];
359+
.all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit) as NewMessage[];
360+
}
361+
362+
export function getLastBotMessageTimestamp(chatJid: string): string | null {
363+
const sql = `
364+
SELECT timestamp FROM messages
365+
WHERE chat_jid = ? AND is_bot_message = 1
366+
ORDER BY timestamp DESC
367+
LIMIT 1
368+
`;
369+
const row = db.prepare(sql).get(chatJid) as { timestamp: string } | undefined;
370+
return row?.timestamp ?? null;
350371
}
351372

352373
export function createTask(

src/index.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
DATA_DIR,
1111
IDLE_TIMEOUT,
1212
MAIN_GROUP_FOLDER,
13+
MAX_MESSAGES_PER_PROMPT,
1314
POLL_INTERVAL,
1415
TELEGRAM_BOT_TOKEN,
1516
TELEGRAM_ONLY,
@@ -30,6 +31,7 @@ import {
3031
getAllRegisteredGroups,
3132
getAllSessions,
3233
getAllTasks,
34+
getLastBotMessageTimestamp,
3335
getMessagesSince,
3436
getNewMessages,
3537
getRouterState,
@@ -151,11 +153,13 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
151153

152154
const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
153155

154-
const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
156+
const sinceTimestamp =
157+
lastAgentTimestamp[chatJid] || getLastBotMessageTimestamp(chatJid) || '';
155158
const missedMessages = getMessagesSince(
156159
chatJid,
157160
sinceTimestamp,
158161
ASSISTANT_NAME,
162+
MAX_MESSAGES_PER_PROMPT,
159163
);
160164

161165
if (missedMessages.length === 0) return true;
@@ -440,8 +444,11 @@ async function startMessageLoop(): Promise<void> {
440444

441445
const allPending = getMessagesSince(
442446
chatJid,
443-
lastAgentTimestamp[chatJid] || '',
447+
lastAgentTimestamp[chatJid] ||
448+
getLastBotMessageTimestamp(chatJid) ||
449+
'',
444450
ASSISTANT_NAME,
451+
MAX_MESSAGES_PER_PROMPT,
445452
);
446453
const messagesToSend =
447454
allPending.length > 0 ? allPending : groupMessages;
@@ -474,8 +481,14 @@ async function startMessageLoop(): Promise<void> {
474481

475482
function recoverPendingMessages(): void {
476483
for (const [chatJid, group] of Object.entries(registeredGroups)) {
477-
const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
478-
const pending = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME);
484+
const sinceTimestamp =
485+
lastAgentTimestamp[chatJid] || getLastBotMessageTimestamp(chatJid) || '';
486+
const pending = getMessagesSince(
487+
chatJid,
488+
sinceTimestamp,
489+
ASSISTANT_NAME,
490+
MAX_MESSAGES_PER_PROMPT,
491+
);
479492
if (pending.length > 0) {
480493
logger.info(
481494
{ group: group.name, pendingCount: pending.length },

0 commit comments

Comments
 (0)