import { withFileWriteLock } from "./file-lock"; import { safeReaddir, type TelegramFs } from "./fs"; import type { TelegramPaths } from "./paths"; /** * Who authored a message in a chat transcript. * - `interlocutor`: the external person writing into the account. * - `owner`: the account holder (the human the bot writes on behalf of). * - `bot`: generated by the model/bot. */ export type ChatAuthor = "interlocutor" | "owner" | "bot"; /** A Telegram file reference that lets the manager rehydrate an image later. */ export interface StoredImageRef { fileId: string; fileSize?: number; mimeType?: string; } export interface ChatMessageRecord { author: ChatAuthor; /** * The author's OWN words — never the text of a message they replied to or a * quote they picked from it. Those live in {@link ChatMessageRecord.context}, * because everything downstream (the model's transcript, and above all the * evidence check that guards the contact's memory) must be able to tell what a * person actually said from what they merely pointed at. */ text: string; /** * Cross-message context lines (reply, quote, forward origin, cross-chat reply) * as rendered by `buildContextLines` — words that belong to SOMEONE ELSE. */ context?: string; /** The body was forwarded: `text` is the origin's words, not the sender's. */ forwarded?: boolean; timestamp: number; senderId?: string; senderName?: string; messageId?: number; /** Attachment kind, if the message carried media. */ kind?: string; /** Downloadable image references; the bytes are deliberately not stored in JSONL. */ imageRefs?: StoredImageRef[]; } /** A pre-`context` record: the context lines were merged into `text`. */ const LEGACY_CONTEXT_LINE = /^\[(?:reply|replying|answering|quoting|forwarded)\b[^\n]*$/i; const LEGACY_FORWARD_LINE = /^\[forwarded\b/i; /** * What this author actually SAID — the only text that may serve as evidence for * a fact about them. * * Empty for a forward (they wrote none of it, they passed it on). For records * written before `context` existed, the context lines sit at the head of `text` * and are stripped here, so old transcripts cannot keep feeding someone else's * quoted words to the memory pass as if they were the speaker's own. */ export function ownWords(record: ChatMessageRecord): string { if (record.forwarded) return ""; if (record.context !== undefined) return record.text.trim(); const lines = record.text.split("\n"); let start = 0; while (start < lines.length && LEGACY_CONTEXT_LINE.test(lines[start])) { if (LEGACY_FORWARD_LINE.test(lines[start])) return ""; start += 1; } return lines.slice(start).join("\n").trim(); } /** * Append-only per-chat transcript, one JSONL file per chat. This is the ACID * source of truth for the manager's last-N memory and for context isolation * (`pi.on("context")` rebuilds the model's messages from here). Reads tolerate * malformed trailing lines (e.g. a crash mid-append). */ export interface ChatStore { append(chatId: string, record: ChatMessageRecord): Promise; /** Newest `limit` records, oldest-first. */ getRecent(chatId: string, limit: number): Promise; all(chatId: string): Promise; /** True once a chat has at least one recorded message (first-contact check). */ hasHistory(chatId: string): Promise; /** Every chat id with a stored transcript (for catch-up scanning). */ listChatIds(): Promise; } /** * @param retentionLimit When > 0, the transcript on disk is compacted to the * last `retentionLimit` messages once it grows past twice that — so old messages * are pruned and the file stays bounded (to ~2× the limit) instead of growing * forever. Omit/0 to keep the full append-only log. Callers pass the manager's * `rememberMessages` (the last-N window the model reads), so nothing still in * that window is ever pruned. */ export function createChatStore( fs: TelegramFs, paths: TelegramPaths, retentionLimit = 0, ): ChatStore { async function readAll(chatId: string): Promise { const path = paths.chatFile(chatId); if (!(await fs.exists(path))) { return []; } const text = await fs.readText(path); const records: ChatMessageRecord[] = []; for (const line of text.split("\n")) { if (!line.trim()) continue; try { records.push(JSON.parse(line) as ChatMessageRecord); } catch { // Tolerate a partial/corrupt trailing line from an interrupted append. } } return records; } return { async append(chatId, record) { const path = paths.chatFile(chatId); const line = `${JSON.stringify(record)}\n`; // Every append is serialized under the per-path write lock, so "no lost // write" holds in both modes (retention on or off) — not just when the // lazy compaction below rewrites the file. When retention is on we also // lazily compact: only once the log passed twice the retention window do we // trim back to the last `retentionLimit`, which amortises the rewrite to // ~once per `retentionLimit` appends and always keeps the full window on disk. await withFileWriteLock(path, async () => { await fs.appendText(path, line); if (retentionLimit <= 0) return; const all = await readAll(chatId); if (all.length > retentionLimit * 2) { const kept = all.slice(-retentionLimit); await fs.writeTextAtomic( path, `${kept.map((r) => JSON.stringify(r)).join("\n")}\n`, ); } }); }, async getRecent(chatId, limit) { const all = await readAll(chatId); return limit >= 0 ? all.slice(-limit) : all; }, all: readAll, async hasHistory(chatId) { const path = paths.chatFile(chatId); if (!(await fs.exists(path))) return false; return (await fs.readText(path)).trim().length > 0; }, async listChatIds() { const entries = await safeReaddir(fs, paths.chatsDir); return entries .filter((name) => name.endsWith(".jsonl")) .map((name) => name.slice(0, -".jsonl".length)); }, }; }