kernelbot 1.0.32 → 1.0.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +281 -276
- package/bin/kernel.js +51 -12
- package/package.json +2 -1
- package/src/agent.js +200 -33
- package/src/bot.js +228 -105
- package/src/conversation.js +121 -8
- package/src/prompts/orchestrator.js +44 -2
- package/src/prompts/persona.md +34 -0
- package/src/providers/base.js +16 -5
- package/src/providers/google-genai.js +198 -0
- package/src/providers/index.js +6 -1
- package/src/providers/models.js +6 -2
- package/src/providers/openai-compat.js +25 -11
- package/src/swarm/job.js +11 -0
- package/src/tools/docker.js +6 -13
- package/src/tools/monitor.js +5 -14
- package/src/tools/network.js +10 -17
- package/src/tools/orchestrator-tools.js +42 -0
- package/src/tools/os.js +37 -2
- package/src/tools/process.js +7 -14
- package/src/utils/config.js +59 -0
- package/src/utils/shell.js +31 -0
- package/src/utils/truncate.js +42 -0
- package/src/worker.js +18 -17
package/src/bot.js
CHANGED
|
@@ -16,6 +16,33 @@ import { TTSService } from './services/tts.js';
|
|
|
16
16
|
import { STTService } from './services/stt.js';
|
|
17
17
|
import { getClaudeAuthStatus, claudeLogout } from './claude-auth.js';
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Simulate a human-like typing delay based on response length.
|
|
21
|
+
* Short replies (casual chat) get a brief pause; longer replies get more.
|
|
22
|
+
* Keeps the typing indicator alive during the delay so the user sees "typing...".
|
|
23
|
+
*
|
|
24
|
+
* @param {TelegramBot} bot - Telegram bot instance
|
|
25
|
+
* @param {number} chatId - Chat to show typing in
|
|
26
|
+
* @param {string} text - The reply text (used to calculate delay)
|
|
27
|
+
* @returns {Promise<void>}
|
|
28
|
+
*/
|
|
29
|
+
async function simulateTypingDelay(bot, chatId, text) {
|
|
30
|
+
const length = (text || '').length;
|
|
31
|
+
|
|
32
|
+
// ~25ms per character, clamped between 0.4s and 4s
|
|
33
|
+
// Short "hey ❤️" (~6 chars) → 0.4s | Medium reply (~120 chars) → 3s | Long reply → 4s cap
|
|
34
|
+
const delay = Math.min(4000, Math.max(400, length * 25));
|
|
35
|
+
|
|
36
|
+
// Add a small random jitter (±15%) so it doesn't feel mechanical
|
|
37
|
+
const jitter = delay * (0.85 + Math.random() * 0.3);
|
|
38
|
+
const finalDelay = Math.round(jitter);
|
|
39
|
+
|
|
40
|
+
// Keep the typing indicator alive during the delay
|
|
41
|
+
bot.sendChatAction(chatId, 'typing').catch(() => {});
|
|
42
|
+
|
|
43
|
+
return new Promise((resolve) => setTimeout(resolve, finalDelay));
|
|
44
|
+
}
|
|
45
|
+
|
|
19
46
|
function splitMessage(text, maxLength = 4096) {
|
|
20
47
|
if (text.length <= maxLength) return [text];
|
|
21
48
|
|
|
@@ -35,6 +62,83 @@ function splitMessage(text, maxLength = 4096) {
|
|
|
35
62
|
return chunks;
|
|
36
63
|
}
|
|
37
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Create an onUpdate callback that sends or edits Telegram messages.
|
|
67
|
+
* Tries Markdown first, falls back to plain text.
|
|
68
|
+
*/
|
|
69
|
+
function createOnUpdate(bot, chatId) {
|
|
70
|
+
return async (update, opts = {}) => {
|
|
71
|
+
if (opts.editMessageId) {
|
|
72
|
+
try {
|
|
73
|
+
const edited = await bot.editMessageText(update, {
|
|
74
|
+
chat_id: chatId,
|
|
75
|
+
message_id: opts.editMessageId,
|
|
76
|
+
parse_mode: 'Markdown',
|
|
77
|
+
});
|
|
78
|
+
return edited.message_id;
|
|
79
|
+
} catch {
|
|
80
|
+
try {
|
|
81
|
+
const edited = await bot.editMessageText(update, {
|
|
82
|
+
chat_id: chatId,
|
|
83
|
+
message_id: opts.editMessageId,
|
|
84
|
+
});
|
|
85
|
+
return edited.message_id;
|
|
86
|
+
} catch {
|
|
87
|
+
// Edit failed — fall through to send new message
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const parts = splitMessage(update);
|
|
92
|
+
let lastMsgId = null;
|
|
93
|
+
for (const part of parts) {
|
|
94
|
+
try {
|
|
95
|
+
const sent = await bot.sendMessage(chatId, part, { parse_mode: 'Markdown' });
|
|
96
|
+
lastMsgId = sent.message_id;
|
|
97
|
+
} catch {
|
|
98
|
+
const sent = await bot.sendMessage(chatId, part);
|
|
99
|
+
lastMsgId = sent.message_id;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return lastMsgId;
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Create a sendPhoto callback that sends a photo with optional caption.
|
|
108
|
+
* Tries Markdown caption first, falls back to plain caption.
|
|
109
|
+
*/
|
|
110
|
+
function createSendPhoto(bot, chatId, logger) {
|
|
111
|
+
return async (filePath, caption) => {
|
|
112
|
+
const fileOpts = { contentType: 'image/png' };
|
|
113
|
+
try {
|
|
114
|
+
await bot.sendPhoto(chatId, createReadStream(filePath), {
|
|
115
|
+
caption: caption || '',
|
|
116
|
+
parse_mode: 'Markdown',
|
|
117
|
+
}, fileOpts);
|
|
118
|
+
} catch {
|
|
119
|
+
try {
|
|
120
|
+
await bot.sendPhoto(chatId, createReadStream(filePath), {
|
|
121
|
+
caption: caption || '',
|
|
122
|
+
}, fileOpts);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
logger.error(`Failed to send photo: ${err.message}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Create a sendReaction callback for reacting to messages with emoji.
|
|
132
|
+
*/
|
|
133
|
+
function createSendReaction(bot) {
|
|
134
|
+
return async (targetChatId, targetMsgId, emoji, isBig = false) => {
|
|
135
|
+
await bot.setMessageReaction(targetChatId, targetMsgId, {
|
|
136
|
+
reaction: [{ type: 'emoji', emoji }],
|
|
137
|
+
is_big: isBig,
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
38
142
|
/**
|
|
39
143
|
* Simple per-chat queue to serialize agent processing.
|
|
40
144
|
* Each chat gets its own promise chain so messages are processed in order.
|
|
@@ -56,7 +160,13 @@ class ChatQueue {
|
|
|
56
160
|
export function startBot(config, agent, conversationManager, jobManager, automationManager, lifeDeps = {}) {
|
|
57
161
|
const { lifeEngine, memoryManager, journalManager, shareQueue, evolutionTracker, codebaseKnowledge } = lifeDeps;
|
|
58
162
|
const logger = getLogger();
|
|
59
|
-
const bot = new TelegramBot(config.telegram.bot_token, {
|
|
163
|
+
const bot = new TelegramBot(config.telegram.bot_token, {
|
|
164
|
+
polling: {
|
|
165
|
+
params: {
|
|
166
|
+
allowed_updates: ['message', 'callback_query', 'message_reaction'],
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
});
|
|
60
170
|
const chatQueue = new ChatQueue();
|
|
61
171
|
const batchWindowMs = config.telegram.batch_window_ms || 3000;
|
|
62
172
|
|
|
@@ -113,54 +223,8 @@ export function startBot(config, agent, conversationManager, jobManager, automat
|
|
|
113
223
|
const sendAction = (chatId, action) => bot.sendChatAction(chatId, action).catch(() => {});
|
|
114
224
|
|
|
115
225
|
const agentFactory = (chatId) => {
|
|
116
|
-
const onUpdate =
|
|
117
|
-
|
|
118
|
-
try {
|
|
119
|
-
const edited = await bot.editMessageText(update, {
|
|
120
|
-
chat_id: chatId,
|
|
121
|
-
message_id: opts.editMessageId,
|
|
122
|
-
parse_mode: 'Markdown',
|
|
123
|
-
});
|
|
124
|
-
return edited.message_id;
|
|
125
|
-
} catch {
|
|
126
|
-
try {
|
|
127
|
-
const edited = await bot.editMessageText(update, {
|
|
128
|
-
chat_id: chatId,
|
|
129
|
-
message_id: opts.editMessageId,
|
|
130
|
-
});
|
|
131
|
-
return edited.message_id;
|
|
132
|
-
} catch {
|
|
133
|
-
// Edit failed — fall through to send new message
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
const parts = splitMessage(update);
|
|
138
|
-
let lastMsgId = null;
|
|
139
|
-
for (const part of parts) {
|
|
140
|
-
try {
|
|
141
|
-
const sent = await bot.sendMessage(chatId, part, { parse_mode: 'Markdown' });
|
|
142
|
-
lastMsgId = sent.message_id;
|
|
143
|
-
} catch {
|
|
144
|
-
const sent = await bot.sendMessage(chatId, part);
|
|
145
|
-
lastMsgId = sent.message_id;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
return lastMsgId;
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
const sendPhoto = async (filePath, caption) => {
|
|
152
|
-
const fileOpts = { contentType: 'image/png' };
|
|
153
|
-
try {
|
|
154
|
-
await bot.sendPhoto(chatId, createReadStream(filePath), { caption: caption || '', parse_mode: 'Markdown' }, fileOpts);
|
|
155
|
-
} catch {
|
|
156
|
-
try {
|
|
157
|
-
await bot.sendPhoto(chatId, createReadStream(filePath), { caption: caption || '' }, fileOpts);
|
|
158
|
-
} catch (err) {
|
|
159
|
-
logger.error(`[Automation] Failed to send photo: ${err.message}`);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
};
|
|
163
|
-
|
|
226
|
+
const onUpdate = createOnUpdate(bot, chatId);
|
|
227
|
+
const sendPhoto = createSendPhoto(bot, chatId, logger);
|
|
164
228
|
return { agent, onUpdate, sendPhoto };
|
|
165
229
|
};
|
|
166
230
|
|
|
@@ -1574,70 +1638,21 @@ export function startBot(config, agent, conversationManager, jobManager, automat
|
|
|
1574
1638
|
bot.sendChatAction(chatId, 'typing').catch(() => {});
|
|
1575
1639
|
|
|
1576
1640
|
try {
|
|
1577
|
-
const onUpdate =
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
try {
|
|
1581
|
-
const edited = await bot.editMessageText(update, {
|
|
1582
|
-
chat_id: chatId,
|
|
1583
|
-
message_id: opts.editMessageId,
|
|
1584
|
-
parse_mode: 'Markdown',
|
|
1585
|
-
});
|
|
1586
|
-
return edited.message_id;
|
|
1587
|
-
} catch {
|
|
1588
|
-
try {
|
|
1589
|
-
const edited = await bot.editMessageText(update, {
|
|
1590
|
-
chat_id: chatId,
|
|
1591
|
-
message_id: opts.editMessageId,
|
|
1592
|
-
});
|
|
1593
|
-
return edited.message_id;
|
|
1594
|
-
} catch {
|
|
1595
|
-
// Edit failed — fall through to send new message
|
|
1596
|
-
}
|
|
1597
|
-
}
|
|
1598
|
-
}
|
|
1599
|
-
|
|
1600
|
-
// Send new message(s) — also reached when edit fails
|
|
1601
|
-
const parts = splitMessage(update);
|
|
1602
|
-
let lastMsgId = null;
|
|
1603
|
-
for (const part of parts) {
|
|
1604
|
-
try {
|
|
1605
|
-
const sent = await bot.sendMessage(chatId, part, { parse_mode: 'Markdown' });
|
|
1606
|
-
lastMsgId = sent.message_id;
|
|
1607
|
-
} catch {
|
|
1608
|
-
const sent = await bot.sendMessage(chatId, part);
|
|
1609
|
-
lastMsgId = sent.message_id;
|
|
1610
|
-
}
|
|
1611
|
-
}
|
|
1612
|
-
return lastMsgId;
|
|
1613
|
-
};
|
|
1614
|
-
|
|
1615
|
-
const sendPhoto = async (filePath, caption) => {
|
|
1616
|
-
const fileOpts = { contentType: 'image/png' };
|
|
1617
|
-
try {
|
|
1618
|
-
await bot.sendPhoto(chatId, createReadStream(filePath), {
|
|
1619
|
-
caption: caption || '',
|
|
1620
|
-
parse_mode: 'Markdown',
|
|
1621
|
-
}, fileOpts);
|
|
1622
|
-
} catch {
|
|
1623
|
-
try {
|
|
1624
|
-
await bot.sendPhoto(chatId, createReadStream(filePath), {
|
|
1625
|
-
caption: caption || '',
|
|
1626
|
-
}, fileOpts);
|
|
1627
|
-
} catch (err) {
|
|
1628
|
-
logger.error(`Failed to send photo: ${err.message}`);
|
|
1629
|
-
}
|
|
1630
|
-
}
|
|
1631
|
-
};
|
|
1641
|
+
const onUpdate = createOnUpdate(bot, chatId);
|
|
1642
|
+
const sendPhoto = createSendPhoto(bot, chatId, logger);
|
|
1643
|
+
const sendReaction = createSendReaction(bot);
|
|
1632
1644
|
|
|
1633
1645
|
logger.debug(`[Bot] Sending to orchestrator: chat ${chatId}, text="${mergedText.slice(0, 80)}"`);
|
|
1634
1646
|
const reply = await agent.processMessage(chatId, mergedText, {
|
|
1635
1647
|
id: userId,
|
|
1636
1648
|
username,
|
|
1637
|
-
}, onUpdate, sendPhoto);
|
|
1649
|
+
}, onUpdate, sendPhoto, { sendReaction, messageId: msg.message_id });
|
|
1638
1650
|
|
|
1639
1651
|
clearInterval(typingInterval);
|
|
1640
1652
|
|
|
1653
|
+
// Simulate human-like typing delay before sending the reply
|
|
1654
|
+
await simulateTypingDelay(bot, chatId, reply || '');
|
|
1655
|
+
|
|
1641
1656
|
logger.info(`[Bot] Reply for chat ${chatId}: ${(reply || '').length} chars`);
|
|
1642
1657
|
const chunks = splitMessage(reply || 'Done.');
|
|
1643
1658
|
for (const chunk of chunks) {
|
|
@@ -1668,9 +1683,117 @@ export function startBot(config, agent, conversationManager, jobManager, automat
|
|
|
1668
1683
|
});
|
|
1669
1684
|
});
|
|
1670
1685
|
|
|
1686
|
+
// Handle message reactions (love, like, etc.)
|
|
1687
|
+
bot.on('message_reaction', async (reaction) => {
|
|
1688
|
+
const chatId = reaction.chat.id;
|
|
1689
|
+
const userId = reaction.user?.id;
|
|
1690
|
+
const username = reaction.user?.username || reaction.user?.first_name || 'unknown';
|
|
1691
|
+
|
|
1692
|
+
if (!userId || !isAllowedUser(userId, config)) return;
|
|
1693
|
+
|
|
1694
|
+
const newReactions = reaction.new_reaction || [];
|
|
1695
|
+
const emojis = newReactions
|
|
1696
|
+
.filter(r => r.type === 'emoji')
|
|
1697
|
+
.map(r => r.emoji);
|
|
1698
|
+
|
|
1699
|
+
if (emojis.length === 0) return;
|
|
1700
|
+
|
|
1701
|
+
logger.info(`[Bot] Reaction from ${username} (${userId}) in chat ${chatId}: ${emojis.join(' ')}`);
|
|
1702
|
+
|
|
1703
|
+
const reactionText = `[User reacted with ${emojis.join(' ')} to your message]`;
|
|
1704
|
+
|
|
1705
|
+
chatQueue.enqueue(chatId, async () => {
|
|
1706
|
+
try {
|
|
1707
|
+
const onUpdate = createOnUpdate(bot, chatId);
|
|
1708
|
+
const sendReaction = createSendReaction(bot);
|
|
1709
|
+
|
|
1710
|
+
const reply = await agent.processMessage(chatId, reactionText, {
|
|
1711
|
+
id: userId,
|
|
1712
|
+
username,
|
|
1713
|
+
}, onUpdate, null, { sendReaction, messageId: reaction.message_id });
|
|
1714
|
+
|
|
1715
|
+
if (reply && reply.trim()) {
|
|
1716
|
+
const chunks = splitMessage(reply);
|
|
1717
|
+
for (const chunk of chunks) {
|
|
1718
|
+
try {
|
|
1719
|
+
await bot.sendMessage(chatId, chunk, { parse_mode: 'Markdown' });
|
|
1720
|
+
} catch {
|
|
1721
|
+
await bot.sendMessage(chatId, chunk);
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
} catch (err) {
|
|
1726
|
+
logger.error(`[Bot] Error processing reaction in chat ${chatId}: ${err.message}`);
|
|
1727
|
+
}
|
|
1728
|
+
});
|
|
1729
|
+
});
|
|
1730
|
+
|
|
1671
1731
|
bot.on('polling_error', (err) => {
|
|
1672
1732
|
logger.error(`Telegram polling error: ${err.message}`);
|
|
1673
1733
|
});
|
|
1674
1734
|
|
|
1735
|
+
// ── Resume active chats after restart ────────────────────────
|
|
1736
|
+
setTimeout(async () => {
|
|
1737
|
+
const sendMsg = async (chatId, text) => {
|
|
1738
|
+
try {
|
|
1739
|
+
await bot.sendMessage(chatId, text, { parse_mode: 'Markdown' });
|
|
1740
|
+
} catch {
|
|
1741
|
+
await bot.sendMessage(chatId, text);
|
|
1742
|
+
}
|
|
1743
|
+
};
|
|
1744
|
+
try {
|
|
1745
|
+
await agent.resumeActiveChats(sendMsg);
|
|
1746
|
+
} catch (err) {
|
|
1747
|
+
logger.error(`[Bot] Resume active chats failed: ${err.message}`);
|
|
1748
|
+
}
|
|
1749
|
+
}, 5000);
|
|
1750
|
+
|
|
1751
|
+
// ── Proactive share delivery (randomized, self-rearming) ────
|
|
1752
|
+
const lifeConfig = config.life || {};
|
|
1753
|
+
const quietStart = lifeConfig.quiet_hours?.start ?? 2;
|
|
1754
|
+
const quietEnd = lifeConfig.quiet_hours?.end ?? 6;
|
|
1755
|
+
|
|
1756
|
+
const armShareDelivery = (delivered) => {
|
|
1757
|
+
// If we just delivered something, wait longer (1–4h) before next check
|
|
1758
|
+
// If nothing was delivered, check again sooner (10–45min) in case new shares appear
|
|
1759
|
+
const minMin = delivered ? 60 : 10;
|
|
1760
|
+
const maxMin = delivered ? 240 : 45;
|
|
1761
|
+
const delayMs = (minMin + Math.random() * (maxMin - minMin)) * 60_000;
|
|
1762
|
+
|
|
1763
|
+
logger.debug(`[Bot] Next share check in ${Math.round(delayMs / 60_000)}m`);
|
|
1764
|
+
|
|
1765
|
+
setTimeout(async () => {
|
|
1766
|
+
// Respect quiet hours
|
|
1767
|
+
const hour = new Date().getHours();
|
|
1768
|
+
if (hour >= quietStart && hour < quietEnd) {
|
|
1769
|
+
armShareDelivery(false);
|
|
1770
|
+
return;
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
const sendMsg = async (chatId, text) => {
|
|
1774
|
+
try {
|
|
1775
|
+
await bot.sendMessage(chatId, text, { parse_mode: 'Markdown' });
|
|
1776
|
+
} catch {
|
|
1777
|
+
await bot.sendMessage(chatId, text);
|
|
1778
|
+
}
|
|
1779
|
+
};
|
|
1780
|
+
|
|
1781
|
+
let didDeliver = false;
|
|
1782
|
+
try {
|
|
1783
|
+
const before = shareQueue ? shareQueue.getPending(null, 1).length : 0;
|
|
1784
|
+
await agent.deliverPendingShares(sendMsg);
|
|
1785
|
+
const after = shareQueue ? shareQueue.getPending(null, 1).length : 0;
|
|
1786
|
+
didDeliver = before > 0 && after < before;
|
|
1787
|
+
} catch (err) {
|
|
1788
|
+
logger.error(`[Bot] Proactive share delivery failed: ${err.message}`);
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
armShareDelivery(didDeliver);
|
|
1792
|
+
}, delayMs);
|
|
1793
|
+
};
|
|
1794
|
+
|
|
1795
|
+
// Start the first check after a random 10–30 min
|
|
1796
|
+
armShareDelivery(false);
|
|
1797
|
+
|
|
1675
1798
|
return bot;
|
|
1676
1799
|
}
|
package/src/conversation.js
CHANGED
|
@@ -1,22 +1,42 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { homedir } from 'os';
|
|
4
|
+
import { getLogger } from './utils/logger.js';
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the file path for persisted conversations.
|
|
8
|
+
* Ensures the parent directory (~/.kernelbot/) exists.
|
|
9
|
+
* @returns {string} Absolute path to conversations.json.
|
|
10
|
+
*/
|
|
5
11
|
function getConversationsPath() {
|
|
6
12
|
const dir = join(homedir(), '.kernelbot');
|
|
7
13
|
mkdirSync(dir, { recursive: true });
|
|
8
14
|
return join(dir, 'conversations.json');
|
|
9
15
|
}
|
|
10
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Manages per-chat conversation history, including persistence to disk,
|
|
19
|
+
* summarization of older messages, and per-chat skill tracking.
|
|
20
|
+
*/
|
|
11
21
|
export class ConversationManager {
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} config - Application config containing `conversation` settings.
|
|
24
|
+
* @param {number} config.conversation.max_history - Maximum messages to retain per chat.
|
|
25
|
+
* @param {number} [config.conversation.recent_window=10] - Number of recent messages kept verbatim in summarized history.
|
|
26
|
+
*/
|
|
12
27
|
constructor(config) {
|
|
13
28
|
this.maxHistory = config.conversation.max_history;
|
|
14
29
|
this.recentWindow = config.conversation.recent_window || 10;
|
|
15
30
|
this.conversations = new Map();
|
|
16
31
|
this.activeSkills = new Map();
|
|
17
32
|
this.filePath = getConversationsPath();
|
|
33
|
+
this.logger = getLogger();
|
|
18
34
|
}
|
|
19
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Load persisted conversations and skills from disk.
|
|
38
|
+
* @returns {boolean} True if at least one conversation was restored.
|
|
39
|
+
*/
|
|
20
40
|
load() {
|
|
21
41
|
if (!existsSync(this.filePath)) return false;
|
|
22
42
|
try {
|
|
@@ -34,12 +54,18 @@ export class ConversationManager {
|
|
|
34
54
|
if (chatId === '_skills') continue;
|
|
35
55
|
this.conversations.set(String(chatId), messages);
|
|
36
56
|
}
|
|
57
|
+
this.logger.debug(`Conversations loaded: ${this.conversations.size} chats, ${this.activeSkills.size} active skills`);
|
|
37
58
|
return this.conversations.size > 0;
|
|
38
|
-
} catch {
|
|
59
|
+
} catch (err) {
|
|
60
|
+
this.logger.warn(`Failed to load conversations from ${this.filePath}: ${err.message}`);
|
|
39
61
|
return false;
|
|
40
62
|
}
|
|
41
63
|
}
|
|
42
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Persist all conversations and active skills to disk.
|
|
67
|
+
* Failures are logged but never thrown to avoid crashing the bot.
|
|
68
|
+
*/
|
|
43
69
|
save() {
|
|
44
70
|
try {
|
|
45
71
|
const data = {};
|
|
@@ -55,11 +81,16 @@ export class ConversationManager {
|
|
|
55
81
|
data._skills = skills;
|
|
56
82
|
}
|
|
57
83
|
writeFileSync(this.filePath, JSON.stringify(data, null, 2));
|
|
58
|
-
} catch {
|
|
59
|
-
|
|
84
|
+
} catch (err) {
|
|
85
|
+
this.logger.warn(`Failed to save conversations: ${err.message}`);
|
|
60
86
|
}
|
|
61
87
|
}
|
|
62
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Retrieve the message history for a chat, initializing an empty array if none exists.
|
|
91
|
+
* @param {string|number} chatId - Telegram chat identifier.
|
|
92
|
+
* @returns {Array<{role: string, content: string, timestamp?: number}>} Message array (mutable reference).
|
|
93
|
+
*/
|
|
63
94
|
getHistory(chatId) {
|
|
64
95
|
const key = String(chatId);
|
|
65
96
|
if (!this.conversations.has(key)) {
|
|
@@ -68,6 +99,48 @@ export class ConversationManager {
|
|
|
68
99
|
return this.conversations.get(key);
|
|
69
100
|
}
|
|
70
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Get the timestamp of the most recent message in a chat.
|
|
104
|
+
* Used by agent.js for time-gap detection before the current message is added.
|
|
105
|
+
*/
|
|
106
|
+
getLastMessageTimestamp(chatId) {
|
|
107
|
+
const history = this.getHistory(chatId);
|
|
108
|
+
if (history.length === 0) return null;
|
|
109
|
+
return history[history.length - 1].timestamp || null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Format a timestamp as a relative time marker.
|
|
114
|
+
* Returns null for missing timestamps (backward compat with old messages).
|
|
115
|
+
*/
|
|
116
|
+
_formatRelativeTime(ts) {
|
|
117
|
+
if (!ts) return null;
|
|
118
|
+
const diff = Date.now() - ts;
|
|
119
|
+
const seconds = Math.floor(diff / 1000);
|
|
120
|
+
if (seconds < 60) return '[just now]';
|
|
121
|
+
const minutes = Math.floor(seconds / 60);
|
|
122
|
+
if (minutes < 60) return `[${minutes}m ago]`;
|
|
123
|
+
const hours = Math.floor(minutes / 60);
|
|
124
|
+
if (hours < 24) return `[${hours}h ago]`;
|
|
125
|
+
const days = Math.floor(hours / 24);
|
|
126
|
+
return `[${days}d ago]`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Return a shallow copy of a message with a time marker prepended to string content.
|
|
131
|
+
* Skips tool_result arrays and messages without timestamps.
|
|
132
|
+
*/
|
|
133
|
+
_annotateWithTime(msg) {
|
|
134
|
+
const marker = this._formatRelativeTime(msg.timestamp);
|
|
135
|
+
if (!marker || typeof msg.content !== 'string') return msg;
|
|
136
|
+
return { ...msg, content: `${marker} ${msg.content}` };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Strip internal metadata fields, returning only API-safe {role, content}. */
|
|
140
|
+
_sanitize(msg) {
|
|
141
|
+
return { role: msg.role, content: msg.content };
|
|
142
|
+
}
|
|
143
|
+
|
|
71
144
|
/**
|
|
72
145
|
* Get history with older messages compressed into a summary.
|
|
73
146
|
* Keeps the last `recentWindow` messages verbatim and summarizes older ones.
|
|
@@ -76,18 +149,19 @@ export class ConversationManager {
|
|
|
76
149
|
const history = this.getHistory(chatId);
|
|
77
150
|
|
|
78
151
|
if (history.length <= this.recentWindow) {
|
|
79
|
-
return
|
|
152
|
+
return history.map(m => this._sanitize(this._annotateWithTime(m)));
|
|
80
153
|
}
|
|
81
154
|
|
|
82
155
|
const olderMessages = history.slice(0, history.length - this.recentWindow);
|
|
83
156
|
const recentMessages = history.slice(history.length - this.recentWindow);
|
|
84
157
|
|
|
85
|
-
// Compress older messages into a single summary
|
|
158
|
+
// Compress older messages into a single summary (include time markers when available)
|
|
86
159
|
const summaryLines = olderMessages.map((msg) => {
|
|
160
|
+
const timeTag = this._formatRelativeTime(msg.timestamp);
|
|
87
161
|
const content = typeof msg.content === 'string'
|
|
88
162
|
? msg.content.slice(0, 200)
|
|
89
163
|
: JSON.stringify(msg.content).slice(0, 200);
|
|
90
|
-
return `[${msg.role}]: ${content}`;
|
|
164
|
+
return `[${msg.role}]${timeTag ? ` ${timeTag}` : ''}: ${content}`;
|
|
91
165
|
});
|
|
92
166
|
|
|
93
167
|
const summaryMessage = {
|
|
@@ -95,17 +169,27 @@ export class ConversationManager {
|
|
|
95
169
|
content: `[CONVERSATION SUMMARY - ${olderMessages.length} earlier messages]\n${summaryLines.join('\n')}`,
|
|
96
170
|
};
|
|
97
171
|
|
|
172
|
+
// Annotate recent messages with time markers and strip metadata
|
|
173
|
+
const annotatedRecent = recentMessages.map(m => this._sanitize(this._annotateWithTime(m)));
|
|
174
|
+
|
|
98
175
|
// Ensure result starts with user role
|
|
99
|
-
const result = [summaryMessage, ...
|
|
176
|
+
const result = [summaryMessage, ...annotatedRecent];
|
|
100
177
|
|
|
101
178
|
// If the first real message after summary is assistant, that's fine since
|
|
102
179
|
// our summary is role:user. But ensure recent starts correctly.
|
|
103
180
|
return result;
|
|
104
181
|
}
|
|
105
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Append a message to a chat's history, trim to max length, and persist.
|
|
185
|
+
* Automatically ensures the conversation starts with a user message.
|
|
186
|
+
* @param {string|number} chatId - Telegram chat identifier.
|
|
187
|
+
* @param {'user'|'assistant'} role - Message role.
|
|
188
|
+
* @param {string} content - Message content.
|
|
189
|
+
*/
|
|
106
190
|
addMessage(chatId, role, content) {
|
|
107
191
|
const history = this.getHistory(chatId);
|
|
108
|
-
history.push({ role, content });
|
|
192
|
+
history.push({ role, content, timestamp: Date.now() });
|
|
109
193
|
|
|
110
194
|
// Trim to max history
|
|
111
195
|
while (history.length > this.maxHistory) {
|
|
@@ -120,31 +204,60 @@ export class ConversationManager {
|
|
|
120
204
|
this.save();
|
|
121
205
|
}
|
|
122
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Delete all history and active skill for a specific chat.
|
|
209
|
+
* @param {string|number} chatId - Telegram chat identifier.
|
|
210
|
+
*/
|
|
123
211
|
clear(chatId) {
|
|
124
212
|
this.conversations.delete(String(chatId));
|
|
125
213
|
this.activeSkills.delete(String(chatId));
|
|
214
|
+
this.logger.debug(`Conversation cleared for chat ${chatId}`);
|
|
126
215
|
this.save();
|
|
127
216
|
}
|
|
128
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Delete all conversations across every chat.
|
|
220
|
+
*/
|
|
129
221
|
clearAll() {
|
|
222
|
+
const count = this.conversations.size;
|
|
130
223
|
this.conversations.clear();
|
|
224
|
+
this.logger.info(`All conversations cleared (${count} chats removed)`);
|
|
131
225
|
this.save();
|
|
132
226
|
}
|
|
133
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Return the number of messages stored for a chat.
|
|
230
|
+
* @param {string|number} chatId - Telegram chat identifier.
|
|
231
|
+
* @returns {number} Message count.
|
|
232
|
+
*/
|
|
134
233
|
getMessageCount(chatId) {
|
|
135
234
|
const history = this.getHistory(chatId);
|
|
136
235
|
return history.length;
|
|
137
236
|
}
|
|
138
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Activate a skill for a specific chat, persisted across restarts.
|
|
240
|
+
* @param {string|number} chatId - Telegram chat identifier.
|
|
241
|
+
* @param {string} skillId - Skill identifier to activate.
|
|
242
|
+
*/
|
|
139
243
|
setSkill(chatId, skillId) {
|
|
140
244
|
this.activeSkills.set(String(chatId), skillId);
|
|
141
245
|
this.save();
|
|
142
246
|
}
|
|
143
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Get the currently active skill for a chat.
|
|
250
|
+
* @param {string|number} chatId - Telegram chat identifier.
|
|
251
|
+
* @returns {string|null} Active skill identifier, or null if none.
|
|
252
|
+
*/
|
|
144
253
|
getSkill(chatId) {
|
|
145
254
|
return this.activeSkills.get(String(chatId)) || null;
|
|
146
255
|
}
|
|
147
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Deactivate the active skill for a chat.
|
|
259
|
+
* @param {string|number} chatId - Telegram chat identifier.
|
|
260
|
+
*/
|
|
148
261
|
clearSkill(chatId) {
|
|
149
262
|
this.activeSkills.delete(String(chatId));
|
|
150
263
|
this.save();
|