talon-agent 1.0.0
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 +137 -0
- package/bin/talon.js +5 -0
- package/package.json +86 -0
- package/prompts/base.md +13 -0
- package/prompts/custom.md.example +22 -0
- package/prompts/dream.md +41 -0
- package/prompts/identity.md +45 -0
- package/prompts/teams.md +52 -0
- package/prompts/telegram.md +89 -0
- package/prompts/terminal.md +13 -0
- package/src/__tests__/chat-id.test.ts +91 -0
- package/src/__tests__/chat-settings.test.ts +337 -0
- package/src/__tests__/config.test.ts +546 -0
- package/src/__tests__/cron-store.test.ts +440 -0
- package/src/__tests__/daily-log.test.ts +146 -0
- package/src/__tests__/dispatcher.test.ts +383 -0
- package/src/__tests__/errors.test.ts +240 -0
- package/src/__tests__/fuzz.test.ts +302 -0
- package/src/__tests__/gateway-actions.test.ts +1453 -0
- package/src/__tests__/gateway-context.test.ts +102 -0
- package/src/__tests__/gateway-http.test.ts +245 -0
- package/src/__tests__/handlers.test.ts +351 -0
- package/src/__tests__/history-persistence.test.ts +172 -0
- package/src/__tests__/history.test.ts +659 -0
- package/src/__tests__/integration.test.ts +189 -0
- package/src/__tests__/log.test.ts +110 -0
- package/src/__tests__/media-index.test.ts +277 -0
- package/src/__tests__/plugin.test.ts +317 -0
- package/src/__tests__/prompt-builder.test.ts +71 -0
- package/src/__tests__/sessions.test.ts +594 -0
- package/src/__tests__/teams-frontend.test.ts +239 -0
- package/src/__tests__/telegram.test.ts +177 -0
- package/src/__tests__/terminal-commands.test.ts +367 -0
- package/src/__tests__/terminal-frontend.test.ts +141 -0
- package/src/__tests__/terminal-renderer.test.ts +278 -0
- package/src/__tests__/watchdog.test.ts +287 -0
- package/src/__tests__/workspace.test.ts +184 -0
- package/src/backend/claude-sdk/index.ts +438 -0
- package/src/backend/claude-sdk/tools.ts +605 -0
- package/src/backend/opencode/index.ts +252 -0
- package/src/bootstrap.ts +134 -0
- package/src/cli.ts +611 -0
- package/src/core/cron.ts +148 -0
- package/src/core/dispatcher.ts +126 -0
- package/src/core/dream.ts +295 -0
- package/src/core/errors.ts +206 -0
- package/src/core/gateway-actions.ts +267 -0
- package/src/core/gateway.ts +258 -0
- package/src/core/plugin.ts +432 -0
- package/src/core/prompt-builder.ts +43 -0
- package/src/core/pulse.ts +175 -0
- package/src/core/types.ts +85 -0
- package/src/frontend/teams/actions.ts +101 -0
- package/src/frontend/teams/formatting.ts +220 -0
- package/src/frontend/teams/graph.ts +297 -0
- package/src/frontend/teams/index.ts +308 -0
- package/src/frontend/teams/proxy-fetch.ts +28 -0
- package/src/frontend/teams/tools.ts +177 -0
- package/src/frontend/telegram/actions.ts +437 -0
- package/src/frontend/telegram/admin.ts +178 -0
- package/src/frontend/telegram/callbacks.ts +251 -0
- package/src/frontend/telegram/commands.ts +543 -0
- package/src/frontend/telegram/formatting.ts +101 -0
- package/src/frontend/telegram/handlers.ts +1008 -0
- package/src/frontend/telegram/helpers.ts +105 -0
- package/src/frontend/telegram/index.ts +130 -0
- package/src/frontend/telegram/middleware.ts +177 -0
- package/src/frontend/telegram/userbot.ts +546 -0
- package/src/frontend/terminal/commands.ts +303 -0
- package/src/frontend/terminal/index.ts +282 -0
- package/src/frontend/terminal/input.ts +297 -0
- package/src/frontend/terminal/renderer.ts +248 -0
- package/src/index.ts +144 -0
- package/src/login.ts +89 -0
- package/src/storage/chat-settings.ts +218 -0
- package/src/storage/cron-store.ts +165 -0
- package/src/storage/daily-log.ts +97 -0
- package/src/storage/history.ts +278 -0
- package/src/storage/media-index.ts +116 -0
- package/src/storage/sessions.ts +328 -0
- package/src/util/chat-id.ts +21 -0
- package/src/util/config.ts +244 -0
- package/src/util/log.ts +122 -0
- package/src/util/paths.ts +80 -0
- package/src/util/time.ts +86 -0
- package/src/util/trace.ts +35 -0
- package/src/util/watchdog.ts +108 -0
- package/src/util/workspace.ts +208 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group message history buffer. Stores recent messages from all users
|
|
3
|
+
* so Claude has full conversation context even for messages that didn't
|
|
4
|
+
* trigger the bot.
|
|
5
|
+
*
|
|
6
|
+
* Persisted to disk — survives restarts so pulse, search, and group
|
|
7
|
+
* threading context don't lose state.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync, mkdirSync } from "node:fs";
|
|
11
|
+
import { dirname } from "node:path";
|
|
12
|
+
import writeFileAtomic from "write-file-atomic";
|
|
13
|
+
import { log, logError } from "../util/log.js";
|
|
14
|
+
import { files } from "../util/paths.js";
|
|
15
|
+
import { formatSmartTimestamp } from "../util/time.js";
|
|
16
|
+
|
|
17
|
+
export type HistoryMessage = {
|
|
18
|
+
msgId: number;
|
|
19
|
+
senderId: number;
|
|
20
|
+
senderName: string;
|
|
21
|
+
text: string;
|
|
22
|
+
replyToMsgId?: number;
|
|
23
|
+
timestamp: number;
|
|
24
|
+
mediaType?:
|
|
25
|
+
| "photo"
|
|
26
|
+
| "document"
|
|
27
|
+
| "voice"
|
|
28
|
+
| "sticker"
|
|
29
|
+
| "video"
|
|
30
|
+
| "animation";
|
|
31
|
+
stickerFileId?: string;
|
|
32
|
+
/** Saved file path for downloaded media. */
|
|
33
|
+
filePath?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const MAX_HISTORY_PER_CHAT = 500;
|
|
37
|
+
const MAX_CHAT_COUNT = 1000;
|
|
38
|
+
const STORE_FILE = files.history;
|
|
39
|
+
|
|
40
|
+
const chatHistories = new Map<string, HistoryMessage[]>();
|
|
41
|
+
let dirty = false;
|
|
42
|
+
|
|
43
|
+
// ── Persistence ─────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export function loadHistory(): void {
|
|
46
|
+
try {
|
|
47
|
+
if (existsSync(STORE_FILE)) {
|
|
48
|
+
const raw = JSON.parse(readFileSync(STORE_FILE, "utf-8")) as Record<
|
|
49
|
+
string,
|
|
50
|
+
HistoryMessage[]
|
|
51
|
+
>;
|
|
52
|
+
for (const [chatId, messages] of Object.entries(raw)) {
|
|
53
|
+
// Only load recent messages (cap per chat)
|
|
54
|
+
const trimmed = messages.slice(-MAX_HISTORY_PER_CHAT);
|
|
55
|
+
chatHistories.set(chatId, trimmed);
|
|
56
|
+
}
|
|
57
|
+
log("sessions", `Loaded history for ${chatHistories.size} chat(s)`);
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// Primary file corrupt — try backup
|
|
61
|
+
const bakFile = STORE_FILE + ".bak";
|
|
62
|
+
try {
|
|
63
|
+
if (existsSync(bakFile)) {
|
|
64
|
+
const raw = JSON.parse(readFileSync(bakFile, "utf-8")) as Record<string, HistoryMessage[]>;
|
|
65
|
+
for (const [chatId, messages] of Object.entries(raw)) {
|
|
66
|
+
chatHistories.set(chatId, messages.slice(-MAX_HISTORY_PER_CHAT));
|
|
67
|
+
}
|
|
68
|
+
logError("sessions", "Loaded history from backup (primary was corrupt)");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
} catch { /* backup also corrupt */ }
|
|
72
|
+
logError("sessions", "History data corrupt and no valid backup — starting fresh");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function saveHistory(): void {
|
|
77
|
+
if (!dirty) return;
|
|
78
|
+
try {
|
|
79
|
+
const dir = dirname(STORE_FILE);
|
|
80
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
81
|
+
const obj: Record<string, HistoryMessage[]> = {};
|
|
82
|
+
for (const [chatId, messages] of chatHistories) {
|
|
83
|
+
obj[chatId] = messages;
|
|
84
|
+
}
|
|
85
|
+
const data = JSON.stringify(obj) + "\n";
|
|
86
|
+
// Write backup of current file before overwriting
|
|
87
|
+
if (existsSync(STORE_FILE)) {
|
|
88
|
+
try { writeFileAtomic.sync(STORE_FILE + ".bak", readFileSync(STORE_FILE)); } catch { /* best effort */ }
|
|
89
|
+
}
|
|
90
|
+
writeFileAtomic.sync(STORE_FILE, data);
|
|
91
|
+
dirty = false;
|
|
92
|
+
} catch (err) {
|
|
93
|
+
logError("sessions", "Failed to persist history", err);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Auto-save every 30 seconds (less frequent than sessions since history is larger)
|
|
98
|
+
const autoSaveTimer = setInterval(saveHistory, 30_000);
|
|
99
|
+
process.on("exit", saveHistory);
|
|
100
|
+
|
|
101
|
+
export function flushHistory(): void {
|
|
102
|
+
clearInterval(autoSaveTimer);
|
|
103
|
+
dirty = true;
|
|
104
|
+
saveHistory();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Core operations ─────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
export function pushMessage(chatId: string, msg: HistoryMessage): void {
|
|
110
|
+
let history = chatHistories.get(chatId);
|
|
111
|
+
if (!history) {
|
|
112
|
+
if (chatHistories.size >= MAX_CHAT_COUNT) {
|
|
113
|
+
const evictCount = Math.floor(MAX_CHAT_COUNT * 0.1);
|
|
114
|
+
const iter = chatHistories.keys();
|
|
115
|
+
for (let i = 0; i < evictCount; i++) {
|
|
116
|
+
const oldest = iter.next();
|
|
117
|
+
if (oldest.done) break;
|
|
118
|
+
chatHistories.delete(oldest.value);
|
|
119
|
+
}
|
|
120
|
+
// Mark dirty so evicted chats are removed from disk on next save
|
|
121
|
+
dirty = true;
|
|
122
|
+
}
|
|
123
|
+
history = [];
|
|
124
|
+
chatHistories.set(chatId, history);
|
|
125
|
+
}
|
|
126
|
+
history.push(msg);
|
|
127
|
+
if (history.length > MAX_HISTORY_PER_CHAT) {
|
|
128
|
+
history.splice(0, history.length - MAX_HISTORY_PER_CHAT);
|
|
129
|
+
}
|
|
130
|
+
dirty = true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function getRecentHistory(chatId: string, limit = 50): HistoryMessage[] {
|
|
134
|
+
const history = chatHistories.get(chatId);
|
|
135
|
+
if (!history) return [];
|
|
136
|
+
return history.slice(-limit);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Update a message's file path after media download. */
|
|
140
|
+
export function setMessageFilePath(chatId: string, msgId: number, filePath: string): void {
|
|
141
|
+
const history = chatHistories.get(chatId);
|
|
142
|
+
if (!history) return;
|
|
143
|
+
const msg = history.find((m) => m.msgId === msgId);
|
|
144
|
+
if (msg) { msg.filePath = filePath; dirty = true; }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function clearHistory(chatId: string): void {
|
|
148
|
+
chatHistories.delete(chatId);
|
|
149
|
+
dirty = true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Formatted queries ───────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
function formatMessage(m: HistoryMessage): string {
|
|
155
|
+
const replyTag = m.replyToMsgId ? ` (replying to msg:${m.replyToMsgId})` : "";
|
|
156
|
+
const mediaTag = m.mediaType ? ` [${m.mediaType}]` : "";
|
|
157
|
+
const stickerTag = m.stickerFileId
|
|
158
|
+
? ` (sticker_file_id: ${m.stickerFileId})`
|
|
159
|
+
: "";
|
|
160
|
+
const fileTag = m.filePath ? ` (file: ${m.filePath})` : "";
|
|
161
|
+
const time = formatSmartTimestamp(m.timestamp);
|
|
162
|
+
return `[msg:${m.msgId} ${time}] ${m.senderName}${replyTag}${mediaTag}${stickerTag}${fileTag}: ${m.text}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function getRecentFormatted(chatId: string, limit = 20): string {
|
|
166
|
+
const messages = getRecentHistory(chatId, limit);
|
|
167
|
+
if (messages.length === 0) return "No messages in history.";
|
|
168
|
+
return messages.map(formatMessage).join("\n");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function searchHistory(
|
|
172
|
+
chatId: string,
|
|
173
|
+
query: string,
|
|
174
|
+
limit = 20,
|
|
175
|
+
): string {
|
|
176
|
+
const history = chatHistories.get(chatId);
|
|
177
|
+
if (!history || history.length === 0) return "No messages in history.";
|
|
178
|
+
const lower = query.toLowerCase();
|
|
179
|
+
const matches = history.filter(
|
|
180
|
+
(m) =>
|
|
181
|
+
m.text.toLowerCase().includes(lower) ||
|
|
182
|
+
m.senderName.toLowerCase().includes(lower),
|
|
183
|
+
);
|
|
184
|
+
if (matches.length === 0) return `No messages matching "${query}".`;
|
|
185
|
+
return matches.slice(-limit).map(formatMessage).join("\n");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function getMessagesByUser(
|
|
189
|
+
chatId: string,
|
|
190
|
+
userName: string,
|
|
191
|
+
limit = 20,
|
|
192
|
+
): string {
|
|
193
|
+
const history = chatHistories.get(chatId);
|
|
194
|
+
if (!history || history.length === 0) return "No messages in history.";
|
|
195
|
+
const lower = userName.toLowerCase();
|
|
196
|
+
const matches = history.filter((m) =>
|
|
197
|
+
m.senderName.toLowerCase().includes(lower),
|
|
198
|
+
);
|
|
199
|
+
if (matches.length === 0) return `No messages from "${userName}".`;
|
|
200
|
+
return matches.slice(-limit).map(formatMessage).join("\n");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function getMessageById(chatId: string, msgId: number): string {
|
|
204
|
+
const history = chatHistories.get(chatId);
|
|
205
|
+
if (!history) return "No messages in history.";
|
|
206
|
+
const msg = history.find((m) => m.msgId === msgId);
|
|
207
|
+
if (!msg) return `Message ${msgId} not found in recent history.`;
|
|
208
|
+
return formatMessage(msg);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function getKnownUsers(chatId: string): string {
|
|
212
|
+
const history = chatHistories.get(chatId);
|
|
213
|
+
if (!history || history.length === 0) return "No users seen yet.";
|
|
214
|
+
const users = new Map<
|
|
215
|
+
number,
|
|
216
|
+
{ name: string; lastSeen: number; messageCount: number }
|
|
217
|
+
>();
|
|
218
|
+
for (const m of history) {
|
|
219
|
+
const existing = users.get(m.senderId);
|
|
220
|
+
if (!existing || m.timestamp > existing.lastSeen) {
|
|
221
|
+
users.set(m.senderId, {
|
|
222
|
+
name: m.senderName,
|
|
223
|
+
lastSeen: m.timestamp,
|
|
224
|
+
messageCount: (existing?.messageCount ?? 0) + 1,
|
|
225
|
+
});
|
|
226
|
+
} else {
|
|
227
|
+
existing.messageCount++;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const lines = [...users.entries()]
|
|
231
|
+
.sort((a, b) => b[1].lastSeen - a[1].lastSeen)
|
|
232
|
+
.map(([id, u]) => {
|
|
233
|
+
const ago = formatTimeAgo(u.lastSeen);
|
|
234
|
+
return `${u.name} (user_id: ${id}) — ${u.messageCount} msgs, last seen ${ago}`;
|
|
235
|
+
});
|
|
236
|
+
return lines.join("\n");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function formatTimeAgo(ts: number): string {
|
|
240
|
+
const diff = Date.now() - ts;
|
|
241
|
+
if (diff < 60_000) return "just now";
|
|
242
|
+
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
|
243
|
+
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
|
244
|
+
return `${Math.floor(diff / 86_400_000)}d ago`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function getRecentBySenderId(
|
|
248
|
+
chatId: string,
|
|
249
|
+
senderId: number,
|
|
250
|
+
limit = 5,
|
|
251
|
+
): HistoryMessage[] {
|
|
252
|
+
const history = chatHistories.get(chatId);
|
|
253
|
+
if (!history) return [];
|
|
254
|
+
const matches = history.filter((m) => m.senderId === senderId);
|
|
255
|
+
return matches.slice(-limit);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function getLatestMessageId(chatId: string): number | undefined {
|
|
259
|
+
const history = chatHistories.get(chatId);
|
|
260
|
+
if (!history || history.length === 0) return undefined;
|
|
261
|
+
return history[history.length - 1].msgId;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function getHistoryStats(chatId: string): {
|
|
265
|
+
totalMessages: number;
|
|
266
|
+
uniqueUsers: number;
|
|
267
|
+
oldestTimestamp: number;
|
|
268
|
+
newestTimestamp: number;
|
|
269
|
+
} {
|
|
270
|
+
const history = chatHistories.get(chatId) ?? [];
|
|
271
|
+
const users = new Set(history.map((m) => m.senderId));
|
|
272
|
+
return {
|
|
273
|
+
totalMessages: history.length,
|
|
274
|
+
uniqueUsers: users.size,
|
|
275
|
+
oldestTimestamp: history[0]?.timestamp ?? 0,
|
|
276
|
+
newestTimestamp: history[history.length - 1]?.timestamp ?? 0,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media index — tracks downloaded media files with metadata and expiry.
|
|
3
|
+
*
|
|
4
|
+
* Provides fast lookup of recent photos/files by chat, sender, or type.
|
|
5
|
+
* Auto-expires entries older than RETENTION_DAYS.
|
|
6
|
+
* Persisted to .talon/data/media-index.json.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, mkdirSync, unlinkSync } from "node:fs";
|
|
10
|
+
import { dirname } from "node:path";
|
|
11
|
+
import writeFileAtomic from "write-file-atomic";
|
|
12
|
+
import { log } from "../util/log.js";
|
|
13
|
+
import { files } from "../util/paths.js";
|
|
14
|
+
|
|
15
|
+
export type MediaEntry = {
|
|
16
|
+
id: string; // unique key: chatId:msgId
|
|
17
|
+
chatId: string;
|
|
18
|
+
msgId: number;
|
|
19
|
+
senderName: string;
|
|
20
|
+
type: "photo" | "document" | "voice" | "video" | "animation" | "audio" | "sticker";
|
|
21
|
+
filePath: string;
|
|
22
|
+
caption?: string;
|
|
23
|
+
timestamp: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const STORE_FILE = files.mediaIndex;
|
|
27
|
+
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
28
|
+
|
|
29
|
+
let entries: MediaEntry[] = [];
|
|
30
|
+
let dirty = false;
|
|
31
|
+
|
|
32
|
+
// ── Persistence ─────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export function loadMediaIndex(): void {
|
|
35
|
+
try {
|
|
36
|
+
if (existsSync(STORE_FILE)) {
|
|
37
|
+
entries = JSON.parse(readFileSync(STORE_FILE, "utf-8"));
|
|
38
|
+
}
|
|
39
|
+
} catch { entries = []; }
|
|
40
|
+
// Purge expired on load
|
|
41
|
+
purgeExpired();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function save(): void {
|
|
45
|
+
if (!dirty) return;
|
|
46
|
+
try {
|
|
47
|
+
const dir = dirname(STORE_FILE);
|
|
48
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
49
|
+
writeFileAtomic.sync(STORE_FILE, JSON.stringify(entries) + "\n");
|
|
50
|
+
dirty = false;
|
|
51
|
+
} catch { /* non-fatal */ }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const autoSaveTimer = setInterval(save, 30_000);
|
|
55
|
+
process.on("exit", save);
|
|
56
|
+
|
|
57
|
+
export function flushMediaIndex(): void {
|
|
58
|
+
clearInterval(autoSaveTimer);
|
|
59
|
+
dirty = true;
|
|
60
|
+
save();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── CRUD ────────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
export function addMedia(entry: Omit<MediaEntry, "id">): void {
|
|
66
|
+
const id = `${entry.chatId}:${entry.msgId}`;
|
|
67
|
+
// Dedupe
|
|
68
|
+
const existing = entries.findIndex((e) => e.id === id);
|
|
69
|
+
if (existing >= 0) entries[existing] = { ...entry, id };
|
|
70
|
+
else entries.push({ ...entry, id });
|
|
71
|
+
dirty = true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Get recent media for a chat, newest first. */
|
|
75
|
+
export function getRecentMedia(chatId: string, limit = 10): MediaEntry[] {
|
|
76
|
+
return entries
|
|
77
|
+
.filter((e) => e.chatId === chatId)
|
|
78
|
+
.sort((a, b) => b.timestamp - a.timestamp)
|
|
79
|
+
.slice(0, limit);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Get all media matching a type in a chat. */
|
|
83
|
+
export function getMediaByType(chatId: string, type: MediaEntry["type"], limit = 10): MediaEntry[] {
|
|
84
|
+
return entries
|
|
85
|
+
.filter((e) => e.chatId === chatId && e.type === type)
|
|
86
|
+
.sort((a, b) => b.timestamp - a.timestamp)
|
|
87
|
+
.slice(0, limit);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Format media index as text for Claude. */
|
|
91
|
+
export function formatMediaIndex(chatId: string, limit = 10): string {
|
|
92
|
+
const media = getRecentMedia(chatId, limit);
|
|
93
|
+
if (media.length === 0) return "No recent media in this chat.";
|
|
94
|
+
return media.map((m) => {
|
|
95
|
+
const time = new Date(m.timestamp).toISOString().slice(0, 16).replace("T", " ");
|
|
96
|
+
const cap = m.caption ? ` "${m.caption.slice(0, 50)}"` : "";
|
|
97
|
+
return `[${m.type}] msg:${m.msgId} by ${m.senderName} at ${time}${cap}\n file: ${m.filePath}`;
|
|
98
|
+
}).join("\n");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Expiry ──────────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
function purgeExpired(): void {
|
|
104
|
+
const cutoff = Date.now() - RETENTION_MS;
|
|
105
|
+
const before = entries.length;
|
|
106
|
+
entries = entries.filter((e) => {
|
|
107
|
+
if (e.timestamp >= cutoff) return true;
|
|
108
|
+
// Delete the file too
|
|
109
|
+
try { if (existsSync(e.filePath)) unlinkSync(e.filePath); } catch { /* skip */ }
|
|
110
|
+
return false;
|
|
111
|
+
});
|
|
112
|
+
if (entries.length < before) {
|
|
113
|
+
dirty = true;
|
|
114
|
+
log("workspace", `Purged ${before - entries.length} expired media entries`);
|
|
115
|
+
}
|
|
116
|
+
}
|