release: v2.0.6 - Intelligence & UX Optimization (2026-05-14)

This commit is contained in:
g1nation
2026-05-14 00:13:54 +09:00
parent 39386f90b5
commit f1d5dbf031
18 changed files with 592 additions and 59 deletions
@@ -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;
}