release: v2.0.6 - Intelligence & UX Optimization (2026-05-14)
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Per-chat conversation history for the Telegram bot.
|
||||
*
|
||||
* Why this exists: the previous bot was *stateless* — every inbound
|
||||
* message hit `AIService.chat({system, user})` in isolation, with no
|
||||
* memory of what the user said two minutes ago or what the secretary
|
||||
* just reported. Users hit this immediately: "I just told you about
|
||||
* project X, why don't you remember?"
|
||||
*
|
||||
* This module solves it with a thin append-only log:
|
||||
*
|
||||
* - Each `(chatId, role, text)` triple is appended to a JSONL file at
|
||||
* `<workspace>/.astra/company/_agents/secretary/telegram_history.jsonl`.
|
||||
* One file per workspace, scoped by `chatId` at read time, so
|
||||
* multi-chat setups don't bleed into each other.
|
||||
* - A small in-memory cache (last 200 entries) sits in front so the
|
||||
* hot path is `O(1)` lookup, not "scan the whole file".
|
||||
* - `getRecentMessages` returns the most recent N entries for a chat,
|
||||
* including the secretary's company-turn mirror entries that the
|
||||
* dispatcher appends — so when the user asks a follow-up question
|
||||
* ("저 폴더가 없다고"), the AI sees its own report from 30 seconds ago.
|
||||
*
|
||||
* The file is human-readable on purpose — users can grep it / delete it
|
||||
* to clear history without any CLI.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { logError } from '../../utils';
|
||||
|
||||
export interface TelegramHistoryEntry {
|
||||
chatId: number;
|
||||
role: 'user' | 'assistant';
|
||||
text: string;
|
||||
/** ISO timestamp at append time. */
|
||||
ts: string;
|
||||
/** Optional tag: `'company-mirror'` for secretary's auto-reports, `'reply'` for normal replies. */
|
||||
kind?: 'company-mirror' | 'reply' | 'user';
|
||||
}
|
||||
|
||||
/** Max entries kept across all chats in the in-memory cache. */
|
||||
const MEMORY_CAP = 200;
|
||||
/** Max entries returned by `getRecentMessages`. */
|
||||
const DEFAULT_RECENT = 12;
|
||||
|
||||
let _cache: TelegramHistoryEntry[] = [];
|
||||
let _hydratedForWorkspace: string | null = null;
|
||||
|
||||
function _historyPath(): string {
|
||||
const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
if (!ws) return '';
|
||||
return path.join(ws, '.astra', 'company', '_agents', 'secretary', 'telegram_history.jsonl');
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-load the on-disk JSONL into the cache the first time we need it
|
||||
* for the current workspace. Switching workspaces re-hydrates from the
|
||||
* new file — that way each project keeps its own chat memory.
|
||||
*/
|
||||
function _hydrateIfNeeded(): void {
|
||||
const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
|
||||
if (_hydratedForWorkspace === ws) return;
|
||||
_hydratedForWorkspace = ws;
|
||||
_cache = [];
|
||||
const p = _historyPath();
|
||||
if (!p || !fs.existsSync(p)) return;
|
||||
try {
|
||||
const lines = fs.readFileSync(p, 'utf8').split('\n').filter((l) => l.trim());
|
||||
// Trim to the cap from the *end* so we keep the freshest entries.
|
||||
const tail = lines.length > MEMORY_CAP ? lines.slice(-MEMORY_CAP) : lines;
|
||||
for (const line of tail) {
|
||||
try {
|
||||
const entry = JSON.parse(line) as Partial<TelegramHistoryEntry>;
|
||||
if (typeof entry.chatId === 'number'
|
||||
&& (entry.role === 'user' || entry.role === 'assistant')
|
||||
&& typeof entry.text === 'string'
|
||||
&& typeof entry.ts === 'string') {
|
||||
_cache.push(entry as TelegramHistoryEntry);
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed line — keep going so a single bad write doesn't poison everything.
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
logError('telegram.history: read failed.', { error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one entry. Best-effort: cache always updates so the *current*
|
||||
* session sees its own writes immediately even if the disk write fails.
|
||||
*/
|
||||
export function appendTelegramMessage(entry: Omit<TelegramHistoryEntry, 'ts'> & { ts?: string }): void {
|
||||
_hydrateIfNeeded();
|
||||
const fullEntry: TelegramHistoryEntry = {
|
||||
chatId: entry.chatId,
|
||||
role: entry.role,
|
||||
text: entry.text,
|
||||
kind: entry.kind,
|
||||
ts: entry.ts ?? new Date().toISOString(),
|
||||
};
|
||||
_cache.push(fullEntry);
|
||||
if (_cache.length > MEMORY_CAP) _cache.splice(0, _cache.length - MEMORY_CAP);
|
||||
const p = _historyPath();
|
||||
if (!p) return;
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.appendFileSync(p, JSON.stringify(fullEntry) + '\n', 'utf8');
|
||||
} catch (e: any) {
|
||||
logError('telegram.history: append failed.', { chatId: entry.chatId, error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the most recent N messages for one chat, oldest-first so the
|
||||
* caller can feed them to an LLM in turn order. `maxEntries` defaults to
|
||||
* 12 — enough for a couple of follow-up turns without exploding the
|
||||
* context window of a small local model.
|
||||
*/
|
||||
export function getRecentMessages(
|
||||
chatId: number,
|
||||
maxEntries: number = DEFAULT_RECENT,
|
||||
): TelegramHistoryEntry[] {
|
||||
_hydrateIfNeeded();
|
||||
const forChat = _cache.filter((e) => e.chatId === chatId);
|
||||
return forChat.slice(-Math.max(1, maxEntries));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format recent history as a readable block we can inline into the user
|
||||
* message. We don't use the AI service's multi-message API because
|
||||
* `IAIService.chat({system, user})` only takes one user turn — embedding
|
||||
* the back-and-forth in the user message keeps the API surface unchanged
|
||||
* while still giving the model the context it needs.
|
||||
*/
|
||||
export function formatHistoryForPrompt(history: TelegramHistoryEntry[]): string {
|
||||
if (!history.length) return '';
|
||||
const lines: string[] = ['[Previous conversation in this Telegram chat]'];
|
||||
for (const e of history) {
|
||||
const speaker = e.role === 'user' ? 'User' : 'Bot';
|
||||
// Keep each message bounded so a single huge company-mirror report
|
||||
// doesn't dominate the prompt budget.
|
||||
const trimmed = e.text.length > 800 ? e.text.slice(0, 800) + '…' : e.text;
|
||||
lines.push(`${speaker}: ${trimmed}`);
|
||||
}
|
||||
lines.push('[End of previous conversation]');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** For tests + the "clear history" command. */
|
||||
export function _resetCacheForTests(): void {
|
||||
_cache = [];
|
||||
_hydratedForWorkspace = null;
|
||||
}
|
||||
Reference in New Issue
Block a user