diff --git a/.env.example b/.env.example index 67b1a8f..bbfabb5 100644 --- a/.env.example +++ b/.env.example @@ -3,30 +3,30 @@ # ============================================ # Supabase — https://supabase.com (free tier) -SUPABASE_URL=https://xxx.supabase.co -SUPABASE_SERVICE_ROLE_KEY=eyJ... +SUPABASE_URL=https://supabase.com/ +SUPABASE_SERVICE_ROLE_KEY=your-supabase-service-role-key # Claude API — https://console.anthropic.com ANTHROPIC_API_KEY=sk-ant-... -# OpenAI API — https://platform.openai.com (pour Whisper transcription vocale) -OPENAI_API_KEY=sk-... +# OpenAI API — https://platform.openai.com (для Whisper транскрипции голоса) +OPENAI_API_KEY=sk-proj-... # Telegram Bot — create via @BotFather on Telegram -TELEGRAM_BOT_TOKEN=123456:ABC-DEF... +TELEGRAM_BOT_TOKEN=your-telegram-bot-token # Your Telegram chat ID — send /start to @userinfobot to get it -TELEGRAM_ADMIN_CHAT_ID=12345678 +TELEGRAM_ADMIN_CHAT_ID=your-chat-id # Your first name (used in AI prompts) -OWNER_NAME=Prenom +OWNER_NAME=Аслан # ============================================ # OPTIONAL # ============================================ # Bot name (shown in /start and conversation history) -BOT_NAME=Copilote +BOT_NAME=Копилот # Timezone for date display TZ=Europe/Paris diff --git a/package-lock.json b/package-lock.json index ec006b0..2d1f8c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1549,6 +1549,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/ai/context-builder.ts b/src/ai/context-builder.ts index 89fcb57..ad9abda 100644 --- a/src/ai/context-builder.ts +++ b/src/ai/context-builder.ts @@ -1,17 +1,24 @@ -import { getCoreMemory, getWorkingMemory, computeDecay, type MemoryEntry } from '../db/memory.js'; -import { getActiveTasks } from '../db/tasks.js'; -import { getClientPipeline } from '../db/clients.js'; -import { getTodayLivePlan } from '../db/daily-plans.js'; -import type { LivePlanTask } from '../types/index.js'; -import { config } from '../config.js'; -import { logger } from '../logger.js'; +import { + getCoreMemory, + getWorkingMemory, + computeDecay, + type MemoryEntry, +} from "../db/memory.js"; +import { getActiveTasks } from "../db/tasks.js"; +import { getClientPipeline } from "../db/clients.js"; +import { getTodayLivePlan } from "../db/daily-plans.js"; +import type { LivePlanTask } from "../types/index.js"; +import { config } from "../config.js"; +import { logger } from "../logger.js"; export interface BuildContextOptions { maxTasks?: number; userMessage?: string; } -export async function buildContext(options?: BuildContextOptions): Promise { +export async function buildContext( + options?: BuildContextOptions, +): Promise { try { const maxTasks = options?.maxTasks ?? 15; @@ -27,21 +34,24 @@ export async function buildContext(options?: BuildContextOptions): Promise 0) { - context += `QUI EST ${config.ownerName.toUpperCase()} :\n`; + context += `КТО ТАКОЙ ${config.ownerName.toUpperCase()}:\n`; context += formatEntries(coreMemory); - context += '\n'; + context += "\n"; } // Working memory — sorted by freshness (temporal decay) @@ -58,26 +68,26 @@ export async function buildContext(options?: BuildContextOptions): Promise d.entry)); if (freshByCategory.situation.length > 0) { - context += 'SITUATION ACTUELLE :\n'; + context += "ТЕКУЩАЯ СИТУАЦИЯ:\n"; context += formatEntries(freshByCategory.situation); - context += '\n'; + context += "\n"; } if (freshByCategory.preference.length > 0) { - context += 'PREFERENCES ET FONCTIONNEMENT :\n'; + context += "ПРЕДПОЧТЕНИЯ И СПОСОБ РАБОТЫ:\n"; context += formatEntries(freshByCategory.preference); - context += '\n'; + context += "\n"; } if (freshByCategory.relationship.length > 0) { - context += 'PERSONNES CONNUES :\n'; + context += "ЗНАКОМЫЕ ЛЮДИ:\n"; context += formatEntries(freshByCategory.relationship); - context += '\n'; + context += "\n"; } if (fading.length > 0) { - const fadingKeys = fading.map((d) => d.entry.key).join(', '); - context += `MEMOIRE ANCIENNE (${fading.length}, confirmee il y a >30j) : ${fadingKeys}\n\n`; + const fadingKeys = fading.map((d) => d.entry.key).join(", "); + context += `СТАРАЯ ПАМЯТЬ (${fading.length}, подтверждена >30д назад): ${fadingKeys}\n\n`; } } @@ -87,81 +97,85 @@ export async function buildContext(options?: BuildContextOptions): Promise maxTasks) { - context += ` ... et ${activeTasks.length - maxTasks} autres\n`; + context += ` ... и ещё ${activeTasks.length - maxTasks}\n`; } } - context += '\n'; + context += "\n"; // Live clients const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); - const terminalStatuses = new Set(['delivered', 'paid']); + const terminalStatuses = new Set(["delivered", "paid"]); const activeClients = clients.filter((c) => { if (!terminalStatuses.has(c.status)) return true; return new Date(c.updated_at) > sevenDaysAgo; }); - context += `CLIENTS (${activeClients.length}) :\n`; + context += `КЛИЕНТЫ (${activeClients.length}):\n`; if (activeClients.length === 0) { - context += '- Aucun client dans le pipeline\n'; + context += "- Нет клиентов в воронке\n"; } else { for (const c of activeClients) { - context += `- ${c.name} [${c.status}]${c.need ? ` - ${c.need}` : ''}${c.budget_range ? ` (${c.budget_range})` : ''}\n`; + context += `- ${c.name} [${c.status}]${c.need ? ` - ${c.need}` : ""}${c.budget_range ? ` (${c.budget_range})` : ""}\n`; } } - context += '\n'; + context += "\n"; // Temporal - context += `DATE ET HEURE : ${dateStr}, ${timeStr}\n`; + context += `ДАТА И ВРЕМЯ: ${dateStr}, ${timeStr}\n`; return context; } catch (error) { - logger.error({ error }, 'Failed to build context'); - return 'Erreur lors de la construction du contexte. Donnees live indisponibles.'; + logger.error({ error }, "Failed to build context"); + return "Ошибка при построении контекста. Данные недоступны."; } } const LIVE_STATUS_LABEL: Record = { - pending: 'A FAIRE', - in_progress: 'EN COURS', - done: 'FAIT', - skipped: 'SAUTE', - deferred: 'REPORTE', + pending: "СДЕЛАТЬ", + in_progress: "В ПРОЦЕССЕ", + done: "СДЕЛАНО", + skipped: "ПРОПУЩЕНО", + deferred: "ОТЛОЖЕНО", }; function formatLivePlan(plan: LivePlanTask[]): string { - const pending = plan.filter((t) => t.status === 'pending' || t.status === 'in_progress'); - const done = plan.filter((t) => t.status === 'done'); - const skippedOrDeferred = plan.filter((t) => t.status === 'skipped' || t.status === 'deferred'); + const pending = plan.filter( + (t) => t.status === "pending" || t.status === "in_progress", + ); + const done = plan.filter((t) => t.status === "done"); + const skippedOrDeferred = plan.filter( + (t) => t.status === "skipped" || t.status === "deferred", + ); - let text = `PLAN DU JOUR (${done.length}/${plan.length} fait) :\n`; + let text = `ПЛАН ДНЯ (${done.length}/${plan.length} сделано):\n`; for (const t of plan) { const statusLabel = LIVE_STATUS_LABEL[t.status] ?? t.status; - const time = t.scheduled_time ? ` [${t.scheduled_time}]` : ''; - const est = t.estimated_minutes ? ` (${t.estimated_minutes} min)` : ''; - const deferNote = t.deferred_to ? ` → reporte au ${t.deferred_to}` : ''; - const skipNote = t.skip_reason ? ` (${t.skip_reason})` : ''; + const time = t.scheduled_time ? ` [${t.scheduled_time}]` : ""; + const est = t.estimated_minutes ? ` (${t.estimated_minutes} min)` : ""; + const deferNote = t.deferred_to ? ` → перенесено на ${t.deferred_to}` : ""; + const skipNote = t.skip_reason ? ` (${t.skip_reason})` : ""; text += ` ${t.order}. [${statusLabel}] ${t.title}${time}${est}${deferNote}${skipNote}\n`; } if (pending.length > 0) { - text += `Prochaine tache prevue : ${pending[0]!.title}\n`; + text += `Следующая задача: ${pending[0]!.title}\n`; } - text += '\n'; + text += "\n"; return text; } function formatEntries(entries: MemoryEntry[]): string { - return entries.map((e) => `- ${e.key}: ${e.content}`).join('\n') + '\n'; + return entries.map((e) => `- ${e.key}: ${e.content}`).join("\n") + "\n"; } interface GroupedEntries { @@ -171,7 +185,11 @@ interface GroupedEntries { } function groupByCategory(entries: MemoryEntry[]): GroupedEntries { - const result: GroupedEntries = { situation: [], preference: [], relationship: [] }; + const result: GroupedEntries = { + situation: [], + preference: [], + relationship: [], + }; for (const entry of entries) { if (entry.category in result) { result[entry.category as keyof GroupedEntries].push(entry); diff --git a/src/ai/memory-agent.ts b/src/ai/memory-agent.ts index ddc4ca9..3526621 100644 --- a/src/ai/memory-agent.ts +++ b/src/ai/memory-agent.ts @@ -1,45 +1,51 @@ -import { askClaude } from './client.js'; -import { getCoreMemory, getWorkingMemory, upsertMemory, deleteMemory, type MemoryEntry } from '../db/memory.js'; -import { config } from '../config.js'; -import { logger } from '../logger.js'; - -const MEMORY_AGENT_PROMPT = `Tu es l'Agent Memoire. Tu analyses les messages de {ownerName} et determines si la memoire a long terme doit etre mise a jour. - -MEMOIRE ACTUELLE : +import { askClaude } from "./client.js"; +import { + getCoreMemory, + getWorkingMemory, + upsertMemory, + deleteMemory, + type MemoryEntry, +} from "../db/memory.js"; +import { config } from "../config.js"; +import { logger } from "../logger.js"; + +const MEMORY_AGENT_PROMPT = `Ты — Агент Памяти. Ты анализируешь сообщения {ownerName} и определяешь, нужно ли обновить долговременную память. + +ТЕКУЩАЯ ПАМЯТЬ: {memory} -MESSAGE DE {ownerName} : +СООБЩЕНИЕ {ownerName}: {message} -ACTIONS DEJA PRISES PAR LE SYSTEME : +ДЕЙСТВИЯ, УЖЕ ВЫПОЛНЕННЫЕ СИСТЕМОЙ: {actions} -REGLES : -- Ne mets a jour que quand c'est SIGNIFICATIF (pas pour des banalites) -- Categories possibles : identity, situation, preference, relationship, lesson -- "identity" = personnalite, competences, fonctionnement (change rarement) → TIER CORE (permanent) -- "situation" = activites en cours, equipe, objectifs, finances → TIER WORKING (expire apres 30j) -- "preference" = gouts, methodes preferees → TIER WORKING (expire apres 30j) -- "relationship" = info sur une personne specifique (nom = cle) → TIER WORKING (expire apres 30j) -- "lesson" = experience, erreur passee, chose apprise → TIER ARCHIVAL (permanent, recherche semantique) -- Pour les relationships, utilise le nom de la personne comme "key" -- Si une info existante est devenue obsolete, mets-la a jour -- Si une info est completement fausse maintenant, supprime-la - -Reponds UNIQUEMENT en JSON (pas de markdown) : +ПРАВИЛА: +- Обновляй только когда это ЗНАЧИМО (не по пустякам) +- Возможные категории: identity, situation, preference, relationship, lesson +- "identity" = личность, навыки, особенности (меняется редко) → TIER CORE (постоянный) +- "situation" = текущие дела, команда, цели, финансы → TIER WORKING (истекает через 30д) +- "preference" = вкусы, предпочитаемые методы → TIER WORKING (истекает через 30д) +- "relationship" = инфо о конкретном человеке (имя = ключ) → TIER WORKING (истекает через 30д) +- "lesson" = опыт, прошлая ошибка, усвоенный урок → TIER ARCHIVAL (постоянный, семантический поиск) +- Для relationships используй имя человека как "key" +- Если существующая информация устарела — обнови её +- Если информация полностью неверна — удали её + +Отвечай ТОЛЬКО в JSON (без markdown): { "updates": [ { "action": "create" | "update" | "delete", "category": "identity|situation|preference|relationship|lesson", - "key": "identifiant_court", - "content": "le contenu a stocker", - "reason": "pourquoi cette mise a jour" + "key": "короткий_идентификатор", + "content": "содержимое для хранения", + "reason": "почему это обновление" } ] } -Si RIEN a mettre a jour, reponds : { "updates": [] }`; +Если НИЧЕГО не нужно обновлять, отвечай: { "updates": [] }`; export async function runMemoryAgent(params: { message: string; @@ -53,27 +59,31 @@ export async function runMemoryAgent(params: { const allMemory = [...coreMemory, ...workingMemory]; const memoryFormatted = formatMemoryForPrompt(allMemory); - const prompt = MEMORY_AGENT_PROMPT - .replaceAll('{ownerName}', config.ownerName) - .replace('{memory}', memoryFormatted) - .replace('{message}', params.message) - .replace('{actions}', params.actionsSummary); + const prompt = MEMORY_AGENT_PROMPT.replaceAll( + "{ownerName}", + config.ownerName, + ) + .replace("{memory}", memoryFormatted) + .replace("{message}", params.message) + .replace("{actions}", params.actionsSummary); const response = await askClaude({ - prompt: 'Analyse ce message et mets a jour la memoire si necessaire.', + prompt: "Проанализируй это сообщение и обнови память при необходимости.", systemPrompt: prompt, - model: 'sonnet', + model: "sonnet", maxTokens: 1024, }); let jsonString = response.trim(); - if (jsonString.startsWith('```')) { - jsonString = jsonString.replace(/^```(?:json)?\s*/, '').replace(/\s*```$/, ''); + if (jsonString.startsWith("```")) { + jsonString = jsonString + .replace(/^```(?:json)?\s*/, "") + .replace(/\s*```$/, ""); } const parsed = JSON.parse(jsonString) as { updates: Array<{ - action: 'create' | 'update' | 'delete'; + action: "create" | "update" | "delete"; category: string; key: string; content?: string; @@ -82,32 +92,60 @@ export async function runMemoryAgent(params: { }; if (parsed.updates.length === 0) { - logger.debug('Memory agent: no updates needed'); + logger.debug("Memory agent: no updates needed"); return; } for (const update of parsed.updates) { try { - if (update.action === 'delete') { - await deleteMemory(update.category as 'identity' | 'situation' | 'preference' | 'relationship' | 'lesson', update.key); - logger.info({ category: update.category, key: update.key, reason: update.reason }, 'Memory deleted by agent'); + if (update.action === "delete") { + await deleteMemory( + update.category as + | "identity" + | "situation" + | "preference" + | "relationship" + | "lesson", + update.key, + ); + logger.info( + { + category: update.category, + key: update.key, + reason: update.reason, + }, + "Memory deleted by agent", + ); } else { await upsertMemory({ - category: update.category as 'identity' | 'situation' | 'preference' | 'relationship' | 'lesson', + category: update.category as + | "identity" + | "situation" + | "preference" + | "relationship" + | "lesson", key: update.key, - content: update.content ?? '', - source: 'memory_agent', + content: update.content ?? "", + source: "memory_agent", }); - logger.info({ action: update.action, category: update.category, key: update.key, reason: update.reason }, 'Memory updated by agent'); + logger.info( + { + action: update.action, + category: update.category, + key: update.key, + reason: update.reason, + }, + "Memory updated by agent", + ); } } catch (error) { - logger.error({ error, update }, 'Failed to execute memory update'); + logger.error({ error, update }, "Failed to execute memory update"); } } - logger.info({ count: parsed.updates.length }, 'Memory agent completed'); + logger.info({ count: parsed.updates.length }, "Memory agent completed"); } catch (error) { - logger.error({ error }, 'Memory agent failed'); + logger.error({ error }, "Memory agent failed"); } } @@ -120,12 +158,12 @@ function formatMemoryForPrompt(entries: MemoryEntry[]): string { grouped[entry.category]!.push(entry); } - let result = ''; + let result = ""; for (const [category, items] of Object.entries(grouped)) { result += `\n[${category.toUpperCase()}]\n`; for (const item of items) { result += `- ${item.key}: ${item.content}\n`; } } - return result || '(vide)'; + return result || "(пусто)"; } diff --git a/src/ai/memory-consolidator.ts b/src/ai/memory-consolidator.ts index 7fb51bb..5bebc0b 100644 --- a/src/ai/memory-consolidator.ts +++ b/src/ai/memory-consolidator.ts @@ -1,25 +1,32 @@ -import { askClaude } from './client.js'; -import { getExpiredMemory, moveToTier, deleteMemory, upsertMemory, type MemoryEntry, type MemoryCategory } from '../db/memory.js'; -import { logger } from '../logger.js'; - -const CONSOLIDATION_PROMPT = `Tu es l'Agent de Consolidation Memoire. Tu revois les memoires de travail expirees et decides quoi en faire. - -MEMOIRES EXPIREES A REVOIR : +import { askClaude } from "./client.js"; +import { + getExpiredMemory, + moveToTier, + deleteMemory, + upsertMemory, + type MemoryEntry, + type MemoryCategory, +} from "../db/memory.js"; +import { logger } from "../logger.js"; + +const CONSOLIDATION_PROMPT = `Ты — Агент Консолидации Памяти. Ты пересматриваешь истёкшие рабочие воспоминания и решаешь, что с ними делать. + +ИСТЁКШИЕ ВОСПОМИНАНИЯ ДЛЯ ПЕРЕСМОТРА: {memories} -Pour chaque memoire, decide : -- "archive" → info toujours utile mais pas quotidiennement (lecons apprises, anciennes situations). Sera disponible via recherche. -- "delete" → info devenue completement obsolete ou dupliquee -- "renew" → info encore d'actualite qui doit rester en memoire de travail (30 jours de plus) +Для каждого воспоминания реши: +- "archive" → инфо по-прежнему полезна, но не ежедневно (усвоенные уроки, старые ситуации). Будет доступна через поиск. +- "delete" → инфо полностью устарела или дублируется +- "renew" → инфо ещё актуальна и должна остаться в рабочей памяти (ещё 30 дней) -Reponds UNIQUEMENT en JSON (pas de markdown) : +Отвечай ТОЛЬКО в JSON (без markdown): { "decisions": [ { "category": "...", "key": "...", "action": "archive" | "delete" | "renew", - "reason": "pourquoi cette decision" + "reason": "почему такое решение" } ] }`; @@ -32,42 +39,58 @@ export interface ConsolidationResult { } export async function runMemoryConsolidation(): Promise { - const result: ConsolidationResult = { archived: 0, deleted: 0, renewed: 0, total: 0 }; + const result: ConsolidationResult = { + archived: 0, + deleted: 0, + renewed: 0, + total: 0, + }; try { const expiredMemories = await getExpiredMemory(); if (expiredMemories.length === 0) { - logger.debug('Memory consolidation: no expired memories to process'); + logger.debug("Memory consolidation: no expired memories to process"); return result; } result.total = expiredMemories.length; - logger.info({ count: expiredMemories.length }, 'Memory consolidation: reviewing expired memories'); + logger.info( + { count: expiredMemories.length }, + "Memory consolidation: reviewing expired memories", + ); const memoriesFormatted = expiredMemories - .map((m) => `[${m.category}] ${m.key}: ${m.content} (expired: ${m.expires_at})`) - .join('\n'); + .map( + (m) => + `[${m.category}] ${m.key}: ${m.content} (expired: ${m.expires_at})`, + ) + .join("\n"); - const prompt = CONSOLIDATION_PROMPT.replace('{memories}', memoriesFormatted); + const prompt = CONSOLIDATION_PROMPT.replace( + "{memories}", + memoriesFormatted, + ); const response = await askClaude({ - prompt: 'Revois ces memoires expirees et decide quoi en faire.', + prompt: "Пересмотри эти истёкшие воспоминания и реши, что с ними делать.", systemPrompt: prompt, - model: 'sonnet', + model: "sonnet", maxTokens: 1024, }); let jsonString = response.trim(); - if (jsonString.startsWith('```')) { - jsonString = jsonString.replace(/^```(?:json)?\s*/, '').replace(/\s*```$/, ''); + if (jsonString.startsWith("```")) { + jsonString = jsonString + .replace(/^```(?:json)?\s*/, "") + .replace(/\s*```$/, ""); } const parsed = JSON.parse(jsonString) as { decisions: Array<{ category: string; key: string; - action: 'archive' | 'delete' | 'renew'; + action: "archive" | "delete" | "renew"; reason: string; }>; }; @@ -77,17 +100,17 @@ export async function runMemoryConsolidation(): Promise { const category = decision.category as MemoryCategory; switch (decision.action) { - case 'archive': - await moveToTier(category, decision.key, 'archival'); + case "archive": + await moveToTier(category, decision.key, "archival"); result.archived++; break; - case 'delete': + case "delete": await deleteMemory(category, decision.key); result.deleted++; break; - case 'renew': { + case "renew": { const original = expiredMemories.find( - (m) => m.category === category && m.key === decision.key + (m) => m.category === category && m.key === decision.key, ); if (original) { await upsertMemory({ @@ -95,7 +118,7 @@ export async function runMemoryConsolidation(): Promise { key: decision.key, content: original.content, source: original.source, - tier: 'working', + tier: "working", }); } result.renewed++; @@ -104,17 +127,25 @@ export async function runMemoryConsolidation(): Promise { } logger.info( - { action: decision.action, category, key: decision.key, reason: decision.reason }, - 'Memory consolidation: decision executed' + { + action: decision.action, + category, + key: decision.key, + reason: decision.reason, + }, + "Memory consolidation: decision executed", ); } catch (error) { - logger.error({ error, decision }, 'Memory consolidation: failed to execute decision'); + logger.error( + { error, decision }, + "Memory consolidation: failed to execute decision", + ); } } - logger.info(result, 'Memory consolidation completed'); + logger.info(result, "Memory consolidation completed"); } catch (error) { - logger.error({ error }, 'Memory consolidation failed'); + logger.error({ error }, "Memory consolidation failed"); } return result; diff --git a/src/ai/memory-manager.ts b/src/ai/memory-manager.ts index fcf0939..6a196bb 100644 --- a/src/ai/memory-manager.ts +++ b/src/ai/memory-manager.ts @@ -1,11 +1,19 @@ -import { askClaude } from './client.js'; -import { getCoreMemory, getWorkingMemory, getMemoryByTier, upsertMemory, deleteMemory, type MemoryCategory, type MemoryEntry } from '../db/memory.js'; -import { config } from '../config.js'; -import { logger } from '../logger.js'; +import { askClaude } from "./client.js"; +import { + getCoreMemory, + getWorkingMemory, + getMemoryByTier, + upsertMemory, + deleteMemory, + type MemoryCategory, + type MemoryEntry, +} from "../db/memory.js"; +import { config } from "../config.js"; +import { logger } from "../logger.js"; export interface MemoryChange { - table: 'memory'; - action: 'create' | 'update' | 'delete'; + table: "memory"; + action: "create" | "update" | "delete"; category: string; key: string; oldContent: string | null; @@ -18,83 +26,87 @@ export interface MemoryManagerResult { changes: MemoryChange[]; } -const MEMORY_MANAGER_PROMPT = `Tu es l'Agent Gestionnaire de Memoire de {ownerName} — le SEUL responsable de toute modification de la base de donnees memoire. +const MEMORY_MANAGER_PROMPT = `Ты — Агент Управления Памятью {ownerName} — ЕДИНСТВЕННЫЙ ответственный за все изменения базы данных памяти. -Tu geres la MEMOIRE PERSONNELLE (table "memory") -Informations sur {ownerName}. -Categories : -- identity = qui il est, competences, personnalite (change rarement) -- situation = activites en cours, objectifs, finances (change regulierement) -- preference = gouts, methodes, outils preferes -- relationship = infos sur des personnes specifiques (nom = cle) -- lesson = experiences, erreurs, choses apprises +Ты управляешь ЛИЧНОЙ ПАМЯТЬЮ (таблица "memory") +Информация о {ownerName}. +Категории: +- identity = кто он, навыки, личность (меняется редко) +- situation = текущие дела, цели, финансы (меняется регулярно) +- preference = вкусы, методы, предпочитаемые инструменты +- relationship = инфо о конкретных людях (имя = ключ) +- lesson = опыт, ошибки, усвоенные уроки -=== MEMOIRE PERSONNELLE (${'{memory_count}'} entrees) === +=== ЛИЧНАЯ ПАМЯТЬ (${"{memory_count}"} записей) === {memory_state} -HISTORIQUE DE CONVERSATION : +ИСТОРИЯ РАЗГОВОРА: {history} -REGLES STRICTES : -1. CLES EXISTANTES : pour un update, utilise EXACTEMENT la cle existante dans la bonne categorie. JAMAIS de doublon. -2. MODIFICATIONS CHIRURGICALES : pour un update, modifie SEULEMENT la partie concernee du contenu. Garde le reste du texte INTACT. -3. VERIFICATION : montre l'ancien contenu et le nouveau dans ta reponse pour que {ownerName} voie la diff. -4. Si tu n'es PAS SUR de ce que {ownerName} veut → mets "changes": [] et pose une question precise dans "response". -5. Tu peux effectuer PLUSIEURS modifications en une seule reponse. -6. Si {ownerName} demande juste de VOIR la memoire (sans modifier) → mets "changes": [] et montre les infos demandees dans "response". +СТРОГИЕ ПРАВИЛА: +1. СУЩЕСТВУЮЩИЕ КЛЮЧИ: при update используй ТОЧНО существующий ключ в правильной категории. НИКОГДА не дублируй. +2. ТОЧЕЧНЫЕ ИЗМЕНЕНИЯ: при update меняй ТОЛЬКО затронутую часть содержимого. Остальной текст оставь НЕТРОНУТЫМ. +3. ПРОВЕРКА: покажи старое и новое содержимое в ответе, чтобы {ownerName} видел разницу. +4. Если НЕ УВЕРЕН, чего хочет {ownerName} → поставь "changes": [] и задай точный вопрос в "response". +5. Можешь выполнить НЕСКОЛЬКО изменений в одном ответе. +6. Если {ownerName} просто хочет ПОСМОТРЕТЬ память (без изменений) → поставь "changes": [] и покажи запрошенную инфо в "response". -REPONSE (JSON strict, PAS de markdown autour) : +ОТВЕТ (строгий JSON, БЕЗ markdown вокруг): { "changes": [ { "table": "memory", "action": "create" | "update" | "delete", "category": "...", - "key": "la_cle_exacte", - "old_content": "contenu actuel (null si create)", - "new_content": "nouveau contenu COMPLET de l'entree (null si delete)", - "reason": "pourquoi ce changement" + "key": "точный_ключ", + "old_content": "текущее содержимое (null если create)", + "new_content": "новое ПОЛНОЕ содержимое записи (null если delete)", + "reason": "почему это изменение" } ], - "response": "Message clair pour {ownerName} confirmant les modifications avec ancien → nouveau" + "response": "Понятное сообщение для {ownerName}, подтверждающее изменения: старое → новое" }`; export async function processMemoryRequest(params: { userMessage: string; conversationHistory?: string; }): Promise { - logger.info('Memory manager: processing request'); + logger.info("Memory manager: processing request"); const [coreMemory, workingMemory, archivalMemory] = await Promise.all([ getCoreMemory(), getWorkingMemory(), - getMemoryByTier('archival'), + getMemoryByTier("archival"), ]); const allMemory = [...coreMemory, ...workingMemory, ...archivalMemory]; const memoryState = formatMemoryState(allMemory); - const systemPrompt = MEMORY_MANAGER_PROMPT - .replaceAll('{ownerName}', config.ownerName) - .replace('{memory_count}', String(allMemory.length)) - .replace('{memory_state}', memoryState) - .replace('{history}', params.conversationHistory || '(pas d\'historique)'); + const systemPrompt = MEMORY_MANAGER_PROMPT.replaceAll( + "{ownerName}", + config.ownerName, + ) + .replace("{memory_count}", String(allMemory.length)) + .replace("{memory_state}", memoryState) + .replace("{history}", params.conversationHistory || "(нет истории)"); const response = await askClaude({ prompt: params.userMessage, systemPrompt, - model: 'sonnet', + model: "sonnet", maxTokens: 4096, }); let jsonString = response.trim(); - if (jsonString.startsWith('```')) { - jsonString = jsonString.replace(/^```(?:json)?\s*/, '').replace(/\s*```$/, ''); + if (jsonString.startsWith("```")) { + jsonString = jsonString + .replace(/^```(?:json)?\s*/, "") + .replace(/\s*```$/, ""); } interface ParsedChange { - table: 'memory'; - action: 'create' | 'update' | 'delete'; + table: "memory"; + action: "create" | "update" | "delete"; category: string; key: string; old_content: string | null; @@ -106,9 +118,15 @@ export async function processMemoryRequest(params: { try { parsed = JSON.parse(jsonString); } catch { - logger.warn({ response: jsonString.substring(0, 200) }, 'Memory manager: failed to parse JSON'); + logger.warn( + { response: jsonString.substring(0, 200) }, + "Memory manager: failed to parse JSON", + ); return { - response: response.replace(/```json\s*/g, '').replace(/```/g, '').trim(), + response: response + .replace(/```json\s*/g, "") + .replace(/```/g, "") + .trim(), changes: [], }; } @@ -122,38 +140,49 @@ export async function processMemoryRequest(params: { for (const change of parsed.changes) { try { const category = change.category as MemoryCategory; - if (change.action === 'delete') { + if (change.action === "delete") { await deleteMemory(category, change.key); } else { await upsertMemory({ category, key: change.key, - content: change.new_content ?? '', - source: 'memory_manager', + content: change.new_content ?? "", + source: "memory_manager", }); } executedChanges.push({ - table: 'memory', + table: "memory", action: change.action, category: change.category, key: change.key, oldContent: change.old_content ?? null, newContent: change.new_content ?? null, - reason: change.reason ?? '', + reason: change.reason ?? "", }); logger.info( - { table: change.table, action: change.action, category: change.category, key: change.key }, - 'Memory manager: change executed' + { + table: change.table, + action: change.action, + category: change.category, + key: change.key, + }, + "Memory manager: change executed", ); } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); - logger.error({ err: errMsg, change }, 'Memory manager: failed to execute change'); + logger.error( + { err: errMsg, change }, + "Memory manager: failed to execute change", + ); } } - logger.info({ changeCount: executedChanges.length }, 'Memory manager: completed'); + logger.info( + { changeCount: executedChanges.length }, + "Memory manager: completed", + ); return { response: parsed.response, @@ -162,17 +191,21 @@ export async function processMemoryRequest(params: { } function formatMemoryState(entries: MemoryEntry[]): string { - if (entries.length === 0) return '(vide)'; + if (entries.length === 0) return "(пусто)"; - const tiers = { core: [] as MemoryEntry[], working: [] as MemoryEntry[], archival: [] as MemoryEntry[] }; + const tiers = { + core: [] as MemoryEntry[], + working: [] as MemoryEntry[], + archival: [] as MemoryEntry[], + }; for (const entry of entries) { - const tier = entry.tier ?? 'working'; + const tier = entry.tier ?? "working"; if (tier in tiers) { tiers[tier as keyof typeof tiers].push(entry); } } - let result = ''; + let result = ""; for (const [tier, tierEntries] of Object.entries(tiers)) { if (tierEntries.length === 0) continue; result += `--- TIER ${tier.toUpperCase()} ---\n`; @@ -187,7 +220,7 @@ function formatMemoryState(entries: MemoryEntry[]): string { result += ` • ${item.key}: ${item.content}\n`; } } - result += '\n'; + result += "\n"; } return result; } diff --git a/src/ai/notification-planner.ts b/src/ai/notification-planner.ts index f47ebfa..fee4a0e 100644 --- a/src/ai/notification-planner.ts +++ b/src/ai/notification-planner.ts @@ -1,8 +1,8 @@ -import { askClaude } from './client.js'; -import { buildContext } from './context-builder.js'; -import { getMemoryByCategory } from '../db/memory.js'; -import { config } from '../config.js'; -import { logger } from '../logger.js'; +import { askClaude } from "./client.js"; +import { buildContext } from "./context-builder.js"; +import { getMemoryByCategory } from "../db/memory.js"; +import { config } from "../config.js"; +import { logger } from "../logger.js"; export interface PlannedNotification { time: string; // HH:MM format @@ -10,103 +10,115 @@ export interface PlannedNotification { type: string; } -const PLANNER_SYSTEM_PROMPT = `Tu es le systeme de notifications intelligent de {ownerName}, son copilote personnel. +const PLANNER_SYSTEM_PROMPT = `Ты — система умных уведомлений {ownerName}, его личный копилот. -Ton role : planifier exactement {count} notifications pour la journee d'aujourd'hui. +Твоя роль: запланировать ровно {count} уведомлений на сегодня. {context} -REGLES DE DISTRIBUTION : -- Distribue les notifications entre 08:30 et 23:30 -- Espace MINIMUM de 20 minutes entre deux notifications -- Concentre plus de notifications pendant la "fenetre d'or" (10h-15h) — c'est sa periode la plus productive -- Notifications moderees le matin (8h30-10h) : demarrage en douceur -- Plus de notifications en debut d'apres-midi (14h-16h) : relance apres la pause -- Moins de notifications le soir (apres 20h) : seulement 2-3 max -- UNE notification entre 23h-23h30 pour le sommeil - -TYPES DE NOTIFICATIONS : -- morning_start : demarrage journee, plan, energie -- progress_check : avancement sur une tache SPECIFIQUE (mentionne le nom !) -- focus_probe : verifier la concentration -- blocker_check : detecter les blocages -- client_followup : suivi client -- motivation : anti-procrastination -- planning : reorganiser, prochaine etape -- accountability : demander des comptes -- reflection : apprentissage -- evening_review : bilan -- sleep_reminder : sommeil - -REGLES SUR LES MESSAGES : -- COURTS : 1-3 lignes max -- Ton DIRECT, amical, parfois cash (pas corporate) -- Les questions doivent POUSSER {ownerName} a repondre (pas juste lire et ignorer) -- Reference ses VRAIES taches et clients par nom -- Utilise des emojis avec parcimonie (1 par message max) -- Varie les types — pas 3 progress_check d'affilee -- Les messages du matin sont energiques, ceux du soir plus calmes -- Si il a des taches urgentes, les mentionner plus souvent -- Si il a des clients en attente, insister sur le suivi - -FORMAT DE REPONSE (JSON strict, pas de markdown autour) : +ПРАВИЛА РАСПРЕДЕЛЕНИЯ: +- Распределяй уведомления между 08:30 и 23:30 +- МИНИМУМ 20 минут между двумя уведомлениями +- Больше уведомлений во время "золотого окна" (10ч-15ч) — это его самый продуктивный период +- Умеренные уведомления утром (8ч30-10ч): мягкий старт +- Больше уведомлений в начале дня (14ч-16ч): перезапуск после паузы +- Меньше уведомлений вечером (после 20ч): максимум 2-3 +- ОДНО уведомление между 23ч-23ч30 про сон + +ТИПЫ УВЕДОМЛЕНИЙ: +- morning_start: начало дня, план, энергия +- progress_check: прогресс по КОНКРЕТНОЙ задаче (упомяни название!) +- focus_probe: проверить концентрацию +- blocker_check: обнаружить блокеры +- client_followup: работа с клиентом +- motivation: против прокрастинации +- planning: реорганизация, следующий шаг +- accountability: спросить отчёт +- reflection: обучение +- evening_review: итоги +- sleep_reminder: сон + +ПРАВИЛА СООБЩЕНИЙ: +- КОРОТКИЕ: 1-3 строки макс +- Тон ПРЯМОЙ, дружелюбный, иногда резкий (не корпоративный) +- Вопросы должны ПОДТАЛКИВАТЬ {ownerName} отвечать (не просто читать и игнорировать) +- Упоминай его РЕАЛЬНЫЕ задачи и клиентов по имени +- Используй эмодзи умеренно (1 на сообщение макс) +- Чередуй типы — не 3 progress_check подряд +- Утренние сообщения энергичные, вечерние — спокойнее +- Если есть срочные задачи — упоминай их чаще +- Если есть клиенты на ожидании — настаивай на follow-up + +ФОРМАТ ОТВЕТА (строгий JSON, без markdown вокруг): [ { "time": "HH:MM", - "message": "Le message exact a envoyer", - "type": "le_type" + "message": "Точное сообщение для отправки", + "type": "тип" } ] -IMPORTANT : genere EXACTEMENT {count} notifications. Pas plus, pas moins.`; +ВАЖНО: сгенерируй РОВНО {count} уведомлений. Не больше, не меньше.`; -export async function planDailyNotifications(notificationCount: number): Promise { +export async function planDailyNotifications( + notificationCount: number, +): Promise { const context = await buildContext(); - const systemPrompt = PLANNER_SYSTEM_PROMPT - .replaceAll('{ownerName}', config.ownerName) - .replaceAll('{count}', String(notificationCount)) - .replace('{context}', context); + const systemPrompt = PLANNER_SYSTEM_PROMPT.replaceAll( + "{ownerName}", + config.ownerName, + ) + .replaceAll("{count}", String(notificationCount)) + .replace("{context}", context); const response = await askClaude({ - prompt: `Planifie exactement ${notificationCount} notifications intelligentes pour aujourd'hui. Retourne le JSON.`, + prompt: `Запланируй ровно ${notificationCount} умных уведомлений на сегодня. Верни JSON.`, systemPrompt, - model: 'sonnet', + model: "sonnet", maxTokens: 4096, }); let jsonString = response.trim(); - if (jsonString.startsWith('```')) { - jsonString = jsonString.replace(/^```(?:json)?\s*/, '').replace(/\s*```$/, ''); + if (jsonString.startsWith("```")) { + jsonString = jsonString + .replace(/^```(?:json)?\s*/, "") + .replace(/\s*```$/, ""); } try { const notifications = JSON.parse(jsonString) as PlannedNotification[]; const valid = notifications.filter( - (n) => n.time && n.message && n.type && /^\d{2}:\d{2}$/.test(n.time) + (n) => n.time && n.message && n.type && /^\d{2}:\d{2}$/.test(n.time), ); if (valid.length === 0) { - logger.error('No valid notifications in planner response'); + logger.error("No valid notifications in planner response"); return []; } valid.sort((a, b) => a.time.localeCompare(b.time)); - logger.info({ planned: valid.length, requested: notificationCount }, 'Daily notifications planned'); + logger.info( + { planned: valid.length, requested: notificationCount }, + "Daily notifications planned", + ); return valid; } catch { - logger.error({ response: jsonString.slice(0, 500) }, 'Failed to parse notification plan JSON'); + logger.error( + { response: jsonString.slice(0, 500) }, + "Failed to parse notification plan JSON", + ); return []; } } export async function getNotificationCount(): Promise { try { - const preferences = await getMemoryByCategory('preference'); + const preferences = await getMemoryByCategory("preference"); const notifPref = preferences.find( - (m) => m.key === 'notifications_par_jour' + (m) => m.key === "notifications_par_jour", ); if (notifPref) { const match = notifPref.content.match(/(\d+)/); diff --git a/src/ai/onboarding.ts b/src/ai/onboarding.ts index 8d5dc22..2685ec8 100644 --- a/src/ai/onboarding.ts +++ b/src/ai/onboarding.ts @@ -1,7 +1,7 @@ -import { askClaude } from './client.js'; -import { getCoreMemory, getWorkingMemory, upsertMemory } from '../db/memory.js'; -import { config } from '../config.js'; -import { logger } from '../logger.js'; +import { askClaude } from "./client.js"; +import { getCoreMemory, getWorkingMemory, upsertMemory } from "../db/memory.js"; +import { config } from "../config.js"; +import { logger } from "../logger.js"; // ---------- Onboarding steps ---------- @@ -13,106 +13,106 @@ interface OnboardingStep { const STEPS: OnboardingStep[] = [ { - key: 'identity', + key: "identity", question: - `Salut ! Je suis ton ${config.botName} — ton assistant personnel intelligent.\n\n` + - `Avant de commencer, j'aimerais apprendre a te connaitre pour etre tout de suite efficace.\n\n` + - `Parle-moi de toi : comment tu t'appelles, ce que tu fais dans la vie, ta situation pro (freelance, salarie, etudiant...) ?`, - systemPrompt: `Tu analyses la reponse de l'utilisateur pour en extraire des informations d'identite. -Extrais : prenom, metier/activite, situation professionnelle, competences mentionnees, personnalite visible. + `Привет! Я — твой ${config.botName}, твой личный умный ассистент.\n\n` + + `Перед тем как начать, я хочу узнать тебя получше, чтобы сразу быть полезным.\n\n` + + `Расскажи о себе: как тебя зовут, чем занимаешься, какая у тебя профессиональная ситуация (фриланс, наёмный, студент...)?`, + systemPrompt: `Ты анализируешь ответ пользователя, чтобы извлечь информацию о его личности. +Извлеки: имя, профессия/деятельность, профессиональная ситуация, упомянутые навыки, видимые черты характера. -Reponds UNIQUEMENT en JSON (pas de markdown) : +Отвечай ТОЛЬКО в JSON (без markdown): { "memories": [ - { "category": "identity", "key": "identifiant_court", "content": "description claire" } + { "category": "identity", "key": "короткий_идентификатор", "content": "понятное описание" } ], - "summary": "phrase courte confirmant ce que tu as compris" + "summary": "короткая фраза, подтверждающая, что ты понял" } -Exemples de keys : "prenom", "metier", "situation_pro", "competences", "personnalite". -Ne cree une entree que si l'info est clairement donnee. Pas d'invention.`, +Примеры keys: "имя", "профессия", "ситуация_про", "навыки", "характер". +Создавай запись только если инфо чётко дана. Не придумывай.`, }, { - key: 'activities', + key: "activities", question: - `Top, je note !\n\n` + - `Maintenant, parle-moi de tes activites au quotidien. Quels types de taches tu fais regulierement ?\n` + - `(clients, cours, dev, creation de contenu, sport, admin, gestion d'equipe...)`, - systemPrompt: `Tu analyses la reponse de l'utilisateur pour en extraire ses activites quotidiennes et recurrentes. -Extrais : types de taches, frequence, activites principales, activites secondaires. + `Отлично, записал!\n\n` + + `Теперь расскажи о своих ежедневных делах. Какие задачи ты делаешь регулярно?\n` + + `(клиенты, учёба, разработка, контент, спорт, админ, управление командой...)`, + systemPrompt: `Ты анализируешь ответ пользователя, чтобы извлечь его ежедневные и регулярные активности. +Извлеки: типы задач, частота, основные активности, побочные активности. -Reponds UNIQUEMENT en JSON (pas de markdown) : +Отвечай ТОЛЬКО в JSON (без markdown): { "memories": [ - { "category": "situation", "key": "identifiant_court", "content": "description claire" } + { "category": "situation", "key": "короткий_идентификатор", "content": "понятное описание" } ], - "summary": "phrase courte confirmant ce que tu as compris" + "summary": "короткая фраза, подтверждающая, что ты понял" } -Exemples de keys : "activites_principales", "taches_recurrentes", "sport", "gestion_clients", "formation". -Utilise la category "situation" pour les activites courantes, "identity" pour les traits permanents. -Ne cree une entree que si l'info est clairement donnee.`, +Примеры keys: "основные_активности", "регулярные_задачи", "спорт", "работа_с_клиентами", "обучение". +Используй категорию "situation" для текущих активностей, "identity" для постоянных черт. +Создавай запись только если инфо чётко дана.`, }, { - key: 'challenges', + key: "challenges", question: - `Tres bien !\n\n` + - `Et c'est quoi tes plus grosses problematiques au quotidien ?\n` + - `(organisation, procrastination, trop de choses a gerer, difficulte a prioriser, oublis...)`, - systemPrompt: `Tu analyses la reponse de l'utilisateur pour en extraire ses problematiques et defis quotidiens. -Extrais : problemes d'organisation, blocages, points faibles reconnus, besoins. + `Отлично!\n\n` + + `А какие у тебя главные сложности в повседневности?\n` + + `(организация, прокрастинация, слишком много дел, сложно расставить приоритеты, забываешь...)`, + systemPrompt: `Ты анализируешь ответ пользователя, чтобы извлечь его проблемы и ежедневные вызовы. +Извлеки: проблемы с организацией, блокеры, признанные слабые стороны, потребности. -Reponds UNIQUEMENT en JSON (pas de markdown) : +Отвечай ТОЛЬКО в JSON (без markdown): { "memories": [ - { "category": "identity|situation", "key": "identifiant_court", "content": "description claire" } + { "category": "identity|situation", "key": "короткий_идентификатор", "content": "понятное описание" } ], - "summary": "phrase courte empathique montrant que tu comprends" + "summary": "короткая эмпатичная фраза, показывающая, что ты понимаешь" } -Exemples de keys : "probleme_organisation", "blocages", "points_faibles", "besoins_aide". -Utilise "identity" pour les traits de caractere (ex: tendance a procrastiner), "situation" pour les problemes contextuels.`, +Примеры keys: "проблема_организация", "блокеры", "слабые_стороны", "потребности_помощь". +Используй "identity" для черт характера (напр.: склонность к прокрастинации), "situation" для контекстных проблем.`, }, { - key: 'rhythm', + key: "rhythm", question: - `Je comprends, c'est exactement pour ca que je suis la !\n\n` + - `Parlons de ton rythme : tu es plutot du matin ou du soir ?\n` + - `C'est quoi tes horaires de travail ? Quand tu es le plus productif ?`, - systemPrompt: `Tu analyses la reponse de l'utilisateur pour en extraire son rythme de vie et ses horaires. -Extrais : chronotype (matin/soir), horaires de travail, pics de productivite, habitudes. + `Понимаю, именно для этого я здесь!\n\n` + + `Поговорим о твоём ритме: ты скорее жаворонок или сова?\n` + + `Какой у тебя рабочий график? Когда ты наиболее продуктивен?`, + systemPrompt: `Ты анализируешь ответ пользователя, чтобы извлечь его ритм жизни и расписание. +Извлеки: хронотип (утро/вечер), рабочие часы, пики продуктивности, привычки. -Reponds UNIQUEMENT en JSON (pas de markdown) : +Отвечай ТОЛЬКО в JSON (без markdown): { "memories": [ - { "category": "preference|identity", "key": "identifiant_court", "content": "description claire" } + { "category": "preference|identity", "key": "короткий_идентификатор", "content": "понятное описание" } ], - "summary": "phrase courte confirmant le rythme compris" + "summary": "короткая фраза, подтверждающая понятый ритм" } -Exemples de keys : "chronotype", "horaires_travail", "pic_productivite", "routine". -Utilise "preference" pour les choix, "identity" pour les traits naturels.`, +Примеры keys: "хронотип", "рабочие_часы", "пик_продуктивности", "распорядок". +Используй "preference" для выборов, "identity" для природных черт.`, }, { - key: 'expectations', + key: "expectations", question: - `Parfait !\n\n` + - `Derniere question : tu veux que je sois comment avec toi ?\n` + - `- Plutot strict et cadrant, ou souple et suggestif ?\n` + - `- Beaucoup de rappels ou juste l'essentiel ?\n` + - `- Un ton pro ou decontracte ?`, - systemPrompt: `Tu analyses la reponse de l'utilisateur pour en extraire ses preferences d'interaction avec le bot. -Extrais : style de communication souhaite, frequence de notifications, niveau de rigueur voulu. - -Reponds UNIQUEMENT en JSON (pas de markdown) : + `Отлично!\n\n` + + `Последний вопрос: каким ты хочешь, чтобы я был?\n` + + `- Скорее строгий и организующий, или мягкий и предлагающий?\n` + + `- Много напоминаний или только самое важное?\n` + + `- Профессиональный тон или расслабленный?`, + systemPrompt: `Ты анализируешь ответ пользователя, чтобы извлечь его предпочтения по взаимодействию с ботом. +Извлеки: желаемый стиль общения, частота уведомлений, желаемый уровень строгости. + +Отвечай ТОЛЬКО в JSON (без markdown): { "memories": [ - { "category": "preference", "key": "identifiant_court", "content": "description claire" } + { "category": "preference", "key": "короткий_идентификатор", "content": "понятное описание" } ], - "summary": "phrase courte confirmant le style compris" + "summary": "короткая фраза, подтверждающая понятый стиль" } -Exemples de keys : "style_communication", "frequence_rappels", "niveau_rigueur", "ton_prefere".`, +Примеры keys: "стиль_общения", "частота_напоминаний", "уровень_строгости", "предпочитаемый_тон".`, }, ]; @@ -129,7 +129,10 @@ const onboardingStates = new Map(); export async function needsOnboarding(): Promise { try { - const [core, working] = await Promise.all([getCoreMemory(), getWorkingMemory()]); + const [core, working] = await Promise.all([ + getCoreMemory(), + getWorkingMemory(), + ]); const total = core.length + working.length; // If less than 3 memory entries, onboarding is needed @@ -137,7 +140,16 @@ export async function needsOnboarding(): Promise { // Check if we have basic identity info const hasIdentity = core.some( - (m) => m.category === 'identity' && ['prenom', 'metier', 'situation_pro'].includes(m.key) + (m) => + m.category === "identity" && + [ + "имя", + "профессия", + "ситуация_про", + "prenom", + "metier", + "situation_pro", + ].includes(m.key), ); return !hasIdentity; } catch { @@ -165,11 +177,11 @@ export function startOnboarding(chatId: string): string { export async function processOnboardingResponse( chatId: string, - userMessage: string + userMessage: string, ): Promise<{ reply: string; done: boolean }> { const state = onboardingStates.get(chatId); if (!state || state.completed) { - return { reply: '', done: true }; + return { reply: "", done: true }; } const currentStep = STEPS[state.stepIndex]!; @@ -179,13 +191,15 @@ export async function processOnboardingResponse( const response = await askClaude({ prompt: userMessage, systemPrompt: currentStep.systemPrompt, - model: 'sonnet', + model: "sonnet", maxTokens: 1024, }); let jsonString = response.trim(); - if (jsonString.startsWith('```')) { - jsonString = jsonString.replace(/^```(?:json)?\s*/, '').replace(/\s*```$/, ''); + if (jsonString.startsWith("```")) { + jsonString = jsonString + .replace(/^```(?:json)?\s*/, "") + .replace(/\s*```$/, ""); } const parsed = JSON.parse(jsonString) as { @@ -200,12 +214,20 @@ export async function processOnboardingResponse( // Store extracted memories for (const mem of parsed.memories) { await upsertMemory({ - category: mem.category as 'identity' | 'situation' | 'preference' | 'relationship' | 'lesson', + category: mem.category as + | "identity" + | "situation" + | "preference" + | "relationship" + | "lesson", key: mem.key, content: mem.content, - source: 'onboarding', + source: "onboarding", }); - logger.info({ category: mem.category, key: mem.key }, 'Onboarding memory saved'); + logger.info( + { category: mem.category, key: mem.key }, + "Onboarding memory saved", + ); } // Move to next step @@ -218,13 +240,13 @@ export async function processOnboardingResponse( const doneMessage = `${parsed.summary}\n\n` + - `C'est bon, je te connais maintenant ! Je suis pret a t'aider.\n\n` + - `Tu peux me parler librement ou utiliser les commandes :\n` + - `/plan — Plan du jour\n` + - `/next — Prochaine tache\n` + - `/add [texte] — Ajouter une tache\n` + - `/tasks — Toutes les taches\n\n` + - `Dis-moi ce que tu veux faire.`; + `Готово, теперь я тебя знаю! Я готов помогать.\n\n` + + `Можешь писать мне свободно или использовать команды:\n` + + `/plan — План дня\n` + + `/next — Следующая задача\n` + + `/add [текст] — Добавить задачу\n` + + `/tasks — Все задачи\n\n` + + `Скажи, что хочешь сделать.`; return { reply: doneMessage, done: true }; } @@ -235,7 +257,10 @@ export async function processOnboardingResponse( return { reply, done: false }; } catch (error) { - logger.error({ error, step: currentStep.key }, 'Onboarding step processing failed'); + logger.error( + { error, step: currentStep.key }, + "Onboarding step processing failed", + ); // Don't block — skip to next step state.stepIndex++; @@ -243,14 +268,14 @@ export async function processOnboardingResponse( state.completed = true; onboardingStates.delete(chatId); return { - reply: `Pas de souci, on peut commencer ! Tu peux toujours me parler de toi plus tard, j'apprendrai au fil du temps.\n\nDis-moi ce que tu veux faire.`, + reply: `Не проблема, можем начинать! Ты всегда можешь рассказать о себе позже, я буду учиться со временем.\n\nСкажи, что хочешь сделать.`, done: true, }; } const nextStep = STEPS[state.stepIndex]!; return { - reply: `Bien note !\n\n${nextStep.question}`, + reply: `Записал!\n\n${nextStep.question}`, done: false, }; } diff --git a/src/ai/orchestrator.ts b/src/ai/orchestrator.ts index b93b79e..fa0395e 100644 --- a/src/ai/orchestrator.ts +++ b/src/ai/orchestrator.ts @@ -1,58 +1,54 @@ -import { askClaude } from './client.js'; -import { buildContext } from './context-builder.js'; -import { runMemoryAgent } from './memory-agent.js'; -import { syncTaskCompletion } from './plan-reorganizer.js'; -import { - createTask, - completeTask, - getActiveTasks, -} from '../db/tasks.js'; -import { createClient } from '../db/clients.js'; -import { config } from '../config.js'; -import { logger } from '../logger.js'; - -const ORCHESTRATOR_PROMPT = `Tu es le copilote personnel de {ownerName}, son assistant IA qui le connait parfaitement. +import { askClaude } from "./client.js"; +import { buildContext } from "./context-builder.js"; +import { runMemoryAgent } from "./memory-agent.js"; +import { syncTaskCompletion } from "./plan-reorganizer.js"; +import { createTask, completeTask, getActiveTasks } from "../db/tasks.js"; +import { createClient } from "../db/clients.js"; +import { config } from "../config.js"; +import { logger } from "../logger.js"; + +const ORCHESTRATOR_PROMPT = `Ты — личный копилот {ownerName}, его ИИ-ассистент, который знает его досконально. {context} -TON ROLE : -- Comprendre ce qu'il dit et AGIR automatiquement -- Creer des taches, des clients, des rappels selon ce qu'il raconte -- Organiser les informations sans qu'il ait a faire quoi que ce soit -- Prendre des DECISIONS pour lui quand c'est possible (il deteste choisir) -- Le motiver quand il avance, le pousser gentiment quand il stagne -- Repondre avec un ton direct, amical, bienveillant - -REGLES : -- Si il parle d'un client ou d'une demande → cree un client -- Si il parle d'une chose a faire → cree une tache avec la bonne priorite et categorie -- Si il parle d'un etudiant ou de la formation → cree une tache categorie "student" -- Si il donne des nouvelles generales → note l'info -- Si il dit qu'il a fait quelque chose → marque comme fait -- Si il hesite entre plusieurs choses → choisis pour lui et explique pourquoi -- Tu peux faire PLUSIEURS actions en une seule reponse -- Reponds toujours en francais -- Sois CONCIS (max 4-5 lignes sauf si il demande plus) -- Utilise sa fenetre productive (10h-15h) pour prioriser -- Si il est apres 15h, suggere des taches legeres -- Rappelle ses objectifs quand c'est pertinent - -PLAN VIVANT DE LA JOURNEE : -- Tu vois le PLAN DU JOUR dans le contexte avec l'etat de chaque tache (A FAIRE, EN COURS, FAIT, etc.) -- Si {ownerName} mentionne un IMPREVU, un changement d'energie, ou veut faire autre chose que prevu → utilise reorganize_plan -- Si {ownerName} dit "aujourd'hui je vais faire X" et que X n'est pas dans le plan ou contredit les priorites → RAPPELLE les taches urgentes d'abord, puis adapte-toi s'il insiste -- Si une tache est completee → le plan se met a jour automatiquement -- Quand tu reponds, base-toi sur le plan pour savoir ce qui est PREVU vs ce qui a ete FAIT -- N'hesite PAS a mentionner la progression ("t'as deja fait 3/6, bien joue !") -- Si {ownerName} dit qu'il n'a pas d'energie ou qu'il est fatigue → reorganise avec des taches legeres en premier - -GESTION DE LA MEMOIRE : -- Si {ownerName} demande de MODIFIER, AJOUTER, SUPPRIMER ou CONSULTER quelque chose dans sa memoire → utilise manage_memory -- Un agent specialise prendra le relais pour effectuer les modifications intelligemment -- Dans ta reponse, dis-lui simplement que tu t'en occupes (l'agent memoire donnera les details) -- IMPORTANT : utilise manage_memory des que la demande concerne les donnees stockees (infos perso, etc.) - -FORMAT DE REPONSE (JSON strict, PAS de markdown autour) : +ТВОЯ РОЛЬ: +- Понимать, что он говорит, и ДЕЙСТВОВАТЬ автоматически +- Создавать задачи, клиентов, напоминания по тому, что он рассказывает +- Организовывать информацию, чтобы ему не нужно было ничего делать +- Принимать РЕШЕНИЯ за него, когда это возможно (он терпеть не может выбирать) +- Мотивировать, когда он продвигается, мягко подталкивать, когда буксует +- Отвечать прямо, дружелюбно, доброжелательно + +ПРАВИЛА: +- Если говорит о клиенте или запросе → создай клиента +- Если говорит о деле → создай задачу с правильным приоритетом и категорией +- Если говорит о студенте или обучении → создай задачу категории "student" +- Если делится общими новостями → запиши информацию +- Если говорит, что что-то сделал → отметь как выполнено +- Если сомневается между несколькими вариантами → выбери за него и объясни почему +- Можешь делать НЕСКОЛЬКО действий в одном ответе +- Отвечай всегда на русском языке +- Будь КРАТОК (максимум 4-5 строк, если не просит больше) +- Используй его продуктивное окно (10ч-15ч) для приоритизации +- Если после 15ч — предлагай лёгкие задачи +- Напоминай о его целях, когда это уместно + +ЖИВОЙ ПЛАН ДНЯ: +- Ты видишь ПЛАН ДНЯ в контексте с состоянием каждой задачи (СДЕЛАТЬ, В ПРОЦЕССЕ, СДЕЛАНО и т.д.) +- Если {ownerName} упоминает НЕПРЕДВИДЕННОЕ, изменение энергии или хочет заняться чем-то другим → используй reorganize_plan +- Если {ownerName} говорит "сегодня я буду делать X", а X нет в плане или противоречит приоритетам → НАПОМНИ сначала о срочных задачах, потом адаптируйся, если настаивает +- Если задача завершена → план обновляется автоматически +- Когда отвечаешь, опирайся на план, чтобы знать, что ЗАПЛАНИРОВАНО vs что СДЕЛАНО +- Не стесняйся отмечать прогресс ("ты уже сделал 3/6, молодец!") +- Если {ownerName} говорит, что нет энергии или устал → реорганизуй с лёгкими задачами в первую очередь + +УПРАВЛЕНИЕ ПАМЯТЬЮ: +- Если {ownerName} просит ИЗМЕНИТЬ, ДОБАВИТЬ, УДАЛИТЬ или ПОСМОТРЕТЬ что-то в памяти → используй manage_memory +- Специализированный агент возьмёт на себя выполнение изменений +- В ответе просто скажи, что занимаешься этим (агент памяти даст детали) +- ВАЖНО: используй manage_memory как только запрос касается сохранённых данных (личная инфо и т.д.) + +ФОРМАТ ОТВЕТА (строгий JSON, БЕЗ markdown вокруг): { "actions": [ { @@ -60,25 +56,25 @@ FORMAT DE REPONSE (JSON strict, PAS de markdown autour) : "data": { ... } } ], - "response": "Message a envoyer a {ownerName}" + "response": "Сообщение для {ownerName}" } -Pour create_task : data = { "title", "category" (client|student|content|personal|dev|team), "priority" (urgent|important|normal|low), "due_date" (YYYY-MM-DD ou null), "estimated_minutes" } -Pour complete_task : data = { "task_title_match" } -Pour create_client : data = { "name", "need", "budget_range", "source", "business_type" } -Pour note : data = { "content" } -Pour manage_memory : data = { "intent": "description de ce que l'utilisateur veut faire" } -Pour start_research : data = { "topic", "details", "include_memory" (true/false) } -Pour reorganize_plan : data = { "trigger": "description de ce qui provoque la reorganisation (imprevu, fatigue, changement de priorite, etc.)" } - -AGENT DE RECHERCHE : -- Si {ownerName} parle de "recherche approfondie", "fais une recherche", "prepare un document sur", "analyse en profondeur" → utilise start_research -- AVANT de lancer, pose des questions pour bien cerner le sujet : quel angle ? quel objectif ? quel public cible ? quelle profondeur ? -- Ne lance la recherche (start_research) QUE quand tu as assez d'info. Sinon, pose d'abord tes questions SANS action. -- "include_memory" = true si le sujet est lie a la situation personnelle de {ownerName} (ses activites, son business, etc.) -- La recherche genere un rapport detaille envoye directement dans le chat. - -HISTORIQUE DE CONVERSATION RECENTE : +Для create_task: data = { "title", "category" (client|student|content|personal|dev|team), "priority" (urgent|important|normal|low), "due_date" (YYYY-MM-DD или null), "estimated_minutes" } +Для complete_task: data = { "task_title_match" } +Для create_client: data = { "name", "need", "budget_range", "source", "business_type" } +Для note: data = { "content" } +Для manage_memory: data = { "intent": "описание того, что пользователь хочет сделать" } +Для start_research: data = { "topic", "details", "include_memory" (true/false) } +Для reorganize_plan: data = { "trigger": "описание того, что вызвало реорганизацию (непредвиденное, усталость, смена приоритетов и т.д.)" } + +АГЕНТ ИССЛЕДОВАНИЙ: +- Если {ownerName} говорит о "глубоком исследовании", "проведи исследование", "подготовь документ по", "проанализируй подробно" → используй start_research +- ПЕРЕД запуском задай вопросы, чтобы точно понять тему: какой ракурс? какая цель? какая аудитория? какая глубина? +- Запускай исследование (start_research) ТОЛЬКО когда достаточно информации. Иначе сначала задай вопросы БЕЗ действий. +- "include_memory" = true, если тема связана с личной ситуацией {ownerName} (его деятельность, бизнес и т.д.) +- Исследование генерирует подробный отчёт, отправляемый прямо в чат. + +НЕДАВНЯЯ ИСТОРИЯ РАЗГОВОРА: {history}`; export interface OrchestratorResult { @@ -86,130 +82,167 @@ export interface OrchestratorResult { actions: Array<{ type: string; data: Record }>; } -export async function processWithOrchestrator(message: string, conversationHistory?: string): Promise { +export async function processWithOrchestrator( + message: string, + conversationHistory?: string, +): Promise { const context = await buildContext({ userMessage: message }); - const systemPrompt = ORCHESTRATOR_PROMPT - .replaceAll('{ownerName}', config.ownerName) - .replace('{context}', context) - .replace('{history}', conversationHistory || '(pas d\'historique)'); + const systemPrompt = ORCHESTRATOR_PROMPT.replaceAll( + "{ownerName}", + config.ownerName, + ) + .replace("{context}", context) + .replace("{history}", conversationHistory || "(нет истории)"); const response = await askClaude({ prompt: message, systemPrompt, - model: 'sonnet', + model: "sonnet", }); let jsonString = response.trim(); - if (jsonString.startsWith('```')) { - jsonString = jsonString.replace(/^```(?:json)?\s*/, '').replace(/\s*```$/, ''); + if (jsonString.startsWith("```")) { + jsonString = jsonString + .replace(/^```(?:json)?\s*/, "") + .replace(/\s*```$/, ""); } - let parsed: { actions: Array<{ type: string; data: Record }>; response: string }; + let parsed: { + actions: Array<{ type: string; data: Record }>; + response: string; + }; try { parsed = JSON.parse(jsonString); } catch { - const cleanResponse = response.replace(/```json\s*/g, '').replace(/```/g, '').trim(); + const cleanResponse = response + .replace(/```json\s*/g, "") + .replace(/```/g, "") + .trim(); return { response: cleanResponse, actions: [] }; } - const executedActions: Array<{ type: string; data: Record }> = []; + const executedActions: Array<{ + type: string; + data: Record; + }> = []; for (const action of parsed.actions) { try { switch (action.type) { - case 'create_task': { + case "create_task": { const task = await createTask({ - title: action.data['title'] ?? 'Tache sans titre', - category: (action.data['category'] as 'personal') ?? 'personal', - priority: (action.data['priority'] as 'normal') ?? 'normal', - due_date: action.data['due_date'] ?? null, - estimated_minutes: action.data['estimated_minutes'] - ? parseInt(action.data['estimated_minutes'], 10) + title: action.data["title"] ?? "Задача без названия", + category: (action.data["category"] as "personal") ?? "personal", + priority: (action.data["priority"] as "normal") ?? "normal", + due_date: action.data["due_date"] ?? null, + estimated_minutes: action.data["estimated_minutes"] + ? parseInt(action.data["estimated_minutes"], 10) : null, - source: 'orchestrator', - status: 'todo', + source: "orchestrator", + status: "todo", + }); + executedActions.push({ + type: "create_task", + data: { id: task.id, title: task.title }, }); - executedActions.push({ type: 'create_task', data: { id: task.id, title: task.title } }); break; } - case 'complete_task': { - const match = action.data['task_title_match']; + case "complete_task": { + const match = action.data["task_title_match"]; if (match) { const tasks = await getActiveTasks(); const found = tasks.find((t) => - t.title.toLowerCase().includes(match.toLowerCase()) + t.title.toLowerCase().includes(match.toLowerCase()), ); if (found) { await completeTask(found.id); // Sync with live plan syncTaskCompletion(found.id).catch((err) => - logger.error({ err }, 'Failed to sync task completion with live plan') + logger.error( + { err }, + "Failed to sync task completion with live plan", + ), ); - executedActions.push({ type: 'complete_task', data: { id: found.id, title: found.title } }); + executedActions.push({ + type: "complete_task", + data: { id: found.id, title: found.title }, + }); } } break; } - case 'create_client': { + case "create_client": { const client = await createClient({ - name: action.data['name'] ?? 'Inconnu', - need: action.data['need'] ?? null, - budget_range: action.data['budget_range'] ?? null, - business_type: action.data['business_type'] ?? null, - source: action.data['source'] ?? 'conversation', - status: 'lead', + name: action.data["name"] ?? "Неизвестный", + need: action.data["need"] ?? null, + budget_range: action.data["budget_range"] ?? null, + business_type: action.data["business_type"] ?? null, + source: action.data["source"] ?? "conversation", + status: "lead", + }); + executedActions.push({ + type: "create_client", + data: { id: client.id, name: client.name }, }); - executedActions.push({ type: 'create_client', data: { id: client.id, name: client.name } }); break; } - case 'note': { - executedActions.push({ type: 'note', data: { content: action.data['content'] } }); + case "note": { + executedActions.push({ + type: "note", + data: { content: action.data["content"] }, + }); break; } - case 'manage_memory': { + case "manage_memory": { executedActions.push({ - type: 'manage_memory', + type: "manage_memory", data: { - intent: action.data['intent'] ?? '', + intent: action.data["intent"] ?? "", }, }); break; } - case 'start_research': { + case "start_research": { executedActions.push({ - type: 'start_research', + type: "start_research", data: { - topic: action.data['topic'] ?? '', - details: action.data['details'] ?? '', - include_memory: action.data['include_memory'] === 'true', + topic: action.data["topic"] ?? "", + details: action.data["details"] ?? "", + include_memory: action.data["include_memory"] === "true", }, }); break; } - case 'reorganize_plan': { + case "reorganize_plan": { executedActions.push({ - type: 'reorganize_plan', + type: "reorganize_plan", data: { - trigger: action.data['trigger'] ?? '', + trigger: action.data["trigger"] ?? "", }, }); break; } } } catch (error) { - logger.error({ error, actionType: action.type, actionData: action.data }, 'Failed to execute action'); + logger.error( + { error, actionType: action.type, actionData: action.data }, + "Failed to execute action", + ); } } // Run Memory Agent in background (don't wait) — only for non-memory-management messages - const hasMemoryAction = executedActions.some((a) => a.type === 'manage_memory'); + const hasMemoryAction = executedActions.some( + (a) => a.type === "manage_memory", + ); if (!hasMemoryAction) { - const actionsSummary = executedActions - .map((a) => `${a.type}: ${JSON.stringify(a.data)}`) - .join('\n') || 'Aucune action'; + const actionsSummary = + executedActions + .map((a) => `${a.type}: ${JSON.stringify(a.data)}`) + .join("\n") || "Нет действий"; runMemoryAgent({ message, actionsSummary }).catch((err) => - logger.error({ err }, 'Memory agent background error') + logger.error({ err }, "Memory agent background error"), ); } diff --git a/src/ai/plan-reorganizer.ts b/src/ai/plan-reorganizer.ts index 840e9af..190f102 100644 --- a/src/ai/plan-reorganizer.ts +++ b/src/ai/plan-reorganizer.ts @@ -1,40 +1,44 @@ -import { askClaude } from './client.js'; -import { buildContext } from './context-builder.js'; -import { getDailyPlan, updateLivePlan, initLivePlanFromStatic } from '../db/daily-plans.js'; -import { getActiveTasks, getOverdueTasks } from '../db/tasks.js'; -import { generateDailyPlan } from './planner.js'; -import type { LivePlanTask } from '../types/index.js'; -import { config } from '../config.js'; -import { logger } from '../logger.js'; -import { todayDateString, getDayOfWeek } from '../utils/format.js'; - -const REORGANIZER_PROMPT = `Tu es le reorganisateur de journee de {ownerName}. +import { askClaude } from "./client.js"; +import { buildContext } from "./context-builder.js"; +import { + getDailyPlan, + updateLivePlan, + initLivePlanFromStatic, +} from "../db/daily-plans.js"; +import { getActiveTasks, getOverdueTasks } from "../db/tasks.js"; +import { generateDailyPlan } from "./planner.js"; +import type { LivePlanTask } from "../types/index.js"; +import { config } from "../config.js"; +import { logger } from "../logger.js"; +import { todayDateString, getDayOfWeek } from "../utils/format.js"; + +const REORGANIZER_PROMPT = `Ты — реорганизатор дня {ownerName}. {context} -SITUATION : Un imprevu ou changement est survenu. Tu dois REORGANISER le reste de la journee. +СИТУАЦИЯ: Произошло непредвиденное или изменение. Тебе нужно РЕОРГАНИЗОВАТЬ остаток дня. -PLAN ACTUEL DU JOUR : +ТЕКУЩИЙ ПЛАН ДНЯ: {livePlan} -EVENEMENT DECLENCHEUR : +СОБЫТИЕ-ТРИГГЕР: {trigger} -HEURE ACTUELLE : {currentTime} - -REGLES DE REORGANISATION : -1. Les taches "done" ne changent PAS -2. Les taches "in_progress" restent sauf si explicitement abandonnees -3. Reordonne les taches "pending" selon la nouvelle realite -4. Si l'utilisateur manque d'energie → mets les taches legeres en premier, reporte les lourdes -5. Si un imprevu prend du temps → decale ou reporte les taches non-urgentes -6. Si l'utilisateur veut faire autre chose → rappelle les urgences MAIS adapte-toi s'il insiste -7. Maximum 2-3 reports par jour (sinon c'est de la procrastination, dis-le gentiment) -8. Les taches urgentes avec deadline aujourd'hui ne peuvent PAS etre reportees (sauf cas de force majeure) -9. Si tu reportes, mets la date de demain (ou le prochain jour ouvre) -10. Recalcule l'ordre en fonction du temps restant dans la journee - -FORMAT DE REPONSE (JSON strict) : +ТЕКУЩЕЕ ВРЕМЯ: {currentTime} + +ПРАВИЛА РЕОРГАНИЗАЦИИ: +1. Задачи "done" НЕ меняются +2. Задачи "in_progress" остаются, если явно не отменены +3. Переупорядочь задачи "pending" по новой реальности +4. Если у пользователя нет энергии → лёгкие задачи вперёд, тяжёлые отложить +5. Если непредвиденное занимает время → сдвинь или отложи несрочные задачи +6. Если пользователь хочет заняться другим → напомни о срочном, НО адаптируйся, если настаивает +7. Максимум 2-3 переноса в день (иначе это прокрастинация, скажи об этом мягко) +8. Срочные задачи с дедлайном сегодня НЕ МОГУТ быть перенесены (кроме форс-мажора) +9. При переносе ставь дату завтра (или следующий рабочий день) +10. Пересчитай порядок с учётом оставшегося времени в дне + +ФОРМАТ ОТВЕТА (строгий JSON): { "reorganized_plan": [ { @@ -52,14 +56,14 @@ FORMAT DE REPONSE (JSON strict) : "skip_reason": "string" | null } ], - "explanation": "Explication courte (2-3 lignes) de ce qui a change et pourquoi", + "explanation": "Краткое объяснение (2-3 строки), что изменилось и почему", "warnings": ["string"] } -IMPORTANT pour "warnings" : -- Si des taches urgentes sont reportees, mets un warning -- Si l'utilisateur reporte trop (3+), avertis gentiment -- Si le plan devient irrealiste (trop de taches pour le temps restant), dis-le`; +ВАЖНО для "warnings": +- Если срочные задачи перенесены — добавь предупреждение +- Если пользователь переносит слишком много (3+) — мягко предупреди +- Если план стал нереалистичным (слишком много задач на оставшееся время) — скажи об этом`; export interface ReorganizeResult { livePlan: LivePlanTask[]; @@ -67,7 +71,9 @@ export interface ReorganizeResult { warnings: string[]; } -export async function reorganizePlan(trigger: string): Promise { +export async function reorganizePlan( + trigger: string, +): Promise { const today = todayDateString(); let plan = await getDailyPlan(today); @@ -82,12 +88,12 @@ export async function reorganizePlan(trigger: string): Promise sportDoneRecently: false, }); - const { saveDailyPlan } = await import('../db/daily-plans.js'); + const { saveDailyPlan } = await import("../db/daily-plans.js"); plan = await saveDailyPlan({ date: today, plan: planTasks, live_plan: initLivePlanFromStatic(planTasks), - status: 'active', + status: "active", review: null, productivity_score: null, revision_count: 0, @@ -103,28 +109,38 @@ export async function reorganizePlan(trigger: string): Promise const context = await buildContext(); const now = new Date(); - const currentTime = now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); + const currentTime = now.toLocaleTimeString("ru-RU", { + hour: "2-digit", + minute: "2-digit", + }); const livePlanStr = plan.live_plan - .map((t) => ` ${t.order}. [${t.status}] ${t.title} (${t.priority}, ${t.estimated_minutes ?? '?'} min)${t.scheduled_time ? ` @${t.scheduled_time}` : ''}${t.deferred_to ? ` → reporte ${t.deferred_to}` : ''}`) - .join('\n'); - - const systemPrompt = REORGANIZER_PROMPT - .replaceAll('{ownerName}', config.ownerName) - .replace('{context}', context) - .replace('{livePlan}', livePlanStr) - .replace('{trigger}', trigger) - .replace('{currentTime}', currentTime); + .map( + (t) => + ` ${t.order}. [${t.status}] ${t.title} (${t.priority}, ${t.estimated_minutes ?? "?"} min)${t.scheduled_time ? ` @${t.scheduled_time}` : ""}${t.deferred_to ? ` → перенесено ${t.deferred_to}` : ""}`, + ) + .join("\n"); + + const systemPrompt = REORGANIZER_PROMPT.replaceAll( + "{ownerName}", + config.ownerName, + ) + .replace("{context}", context) + .replace("{livePlan}", livePlanStr) + .replace("{trigger}", trigger) + .replace("{currentTime}", currentTime); const response = await askClaude({ - prompt: `Reorganise le plan du jour suite a cet evenement : ${trigger}`, + prompt: `Реорганизуй план дня в связи с этим событием: ${trigger}`, systemPrompt, - model: 'sonnet', + model: "sonnet", }); let jsonString = response.trim(); - if (jsonString.startsWith('```')) { - jsonString = jsonString.replace(/^```(?:json)?\s*/, '').replace(/\s*```$/, ''); + if (jsonString.startsWith("```")) { + jsonString = jsonString + .replace(/^```(?:json)?\s*/, "") + .replace(/\s*```$/, ""); } try { @@ -139,7 +155,7 @@ export async function reorganizePlan(trigger: string): Promise logger.info( { taskCount: newPlan.length, revision: (plan.revision_count ?? 0) + 1 }, - 'Plan reorganized' + "Plan reorganized", ); return { @@ -148,10 +164,13 @@ export async function reorganizePlan(trigger: string): Promise warnings: parsed.warnings ?? [], }; } catch { - logger.error({ response: jsonString.slice(0, 500) }, 'Failed to parse reorganization response'); + logger.error( + { response: jsonString.slice(0, 500) }, + "Failed to parse reorganization response", + ); return { livePlan: plan.live_plan, - explanation: 'Erreur lors de la reorganisation. Le plan reste inchange.', + explanation: "Ошибка при реорганизации. План остаётся без изменений.", warnings: [], }; } @@ -167,17 +186,17 @@ export async function syncTaskCompletion(taskId: string): Promise { if (!plan?.live_plan) return; const taskInPlan = plan.live_plan.find((t) => t.task_id === taskId); - if (!taskInPlan || taskInPlan.status === 'done') return; + if (!taskInPlan || taskInPlan.status === "done") return; const updated = plan.live_plan.map((t) => { if (t.task_id !== taskId) return t; return { ...t, - status: 'done' as const, + status: "done" as const, completed_at: new Date().toISOString(), }; }); await updateLivePlan(today, updated); - logger.info({ taskId }, 'Live plan synced after task completion'); + logger.info({ taskId }, "Live plan synced after task completion"); } diff --git a/src/ai/planner.ts b/src/ai/planner.ts index df42d8c..5d43b2c 100644 --- a/src/ai/planner.ts +++ b/src/ai/planner.ts @@ -1,37 +1,37 @@ -import { askClaude } from './client.js'; -import type { Task, DailyPlanTask } from '../types/index.js'; -import { config } from '../config.js'; -import { logger } from '../logger.js'; +import { askClaude } from "./client.js"; +import type { Task, DailyPlanTask } from "../types/index.js"; +import { config } from "../config.js"; +import { logger } from "../logger.js"; -const PLANNER_SYSTEM_PROMPT = `Tu es l'assistant personnel de {ownerName}, un copilote de productivite. +const PLANNER_SYSTEM_PROMPT = `Ты — личный ассистент {ownerName}, копилот продуктивности. -CONTEXTE : -- Fenetre productive : 10h-15h (a proteger absolument pour le deep work) -- Apres 15h : taches legeres seulement (reponses, reviews, planification) -- Il fonctionne avec des objectifs concrets et des victoires rapides -- Il est motive par la peur de perdre quelque chose et par la progression visible -- Il est paralyse quand il a trop de choix → tu dois choisir pour lui -- Il a besoin qu'on decide pour lui quand il hesite +КОНТЕКСТ: +- Продуктивное окно: 10ч-15ч (защищать любой ценой для глубокой работы) +- После 15ч: только лёгкие задачи (ответы, ревью, планирование) +- Он работает с конкретными целями и быстрыми победами +- Его мотивирует страх что-то потерять и видимый прогресс +- Его парализует, когда слишком много выбора → ты должен выбирать за него +- Ему нужно, чтобы за него решали, когда он колеблется -REGLES DE PRIORISATION : -1. URGENT + deadline proche = en premier (slot "urgent", max 2-3) -2. Taches qui debloquent d'autres personnes (equipe, clients, etudiants) = prioritaires -3. Maximum 3 taches "urgent" par jour (sinon paralysie) -4. TOUJOURS commencer par une tache rapide (<15 min) pour lancer la dynamique -5. Apres 15h : taches legeres uniquement -6. Si pas de sport depuis 3+ jours, integrer une session -7. Total : 5-7 taches max par jour (pas plus) +ПРАВИЛА ПРИОРИТИЗАЦИИ: +1. СРОЧНОЕ + близкий дедлайн = в первую очередь (слот "urgent", макс 2-3) +2. Задачи, которые разблокируют других (команда, клиенты, студенты) = приоритетные +3. Максимум 3 задачи "urgent" в день (иначе паралич) +4. ВСЕГДА начинать с быстрой задачи (<15 мин) для разгона +5. После 15ч: только лёгкие задачи +6. Если спорт не делался 3+ дня — включить тренировку +7. Всего: 5-7 задач макс в день (не больше) -FORMAT DE REPONSE : -Tu dois repondre UNIQUEMENT en JSON valide, sans markdown, sans commentaire. -Le JSON doit etre un tableau d'objets avec ces champs : -- task_id: string (l'id de la tache) +ФОРМАТ ОТВЕТА: +Отвечай ТОЛЬКО валидным JSON, без markdown, без комментариев. +JSON должен быть массивом объектов с полями: +- task_id: string (id задачи) - title: string - category: string - priority: string -- estimated_minutes: number ou null +- estimated_minutes: number или null - time_slot: "urgent" | "important" | "optional" -- order: number (1 = premiere tache a faire)`; +- order: number (1 = первая задача)`; export async function generateDailyPlan(params: { activeTasks: Task[]; @@ -42,7 +42,7 @@ export async function generateDailyPlan(params: { const { activeTasks, overdueTasks, dayOfWeek, sportDoneRecently } = params; if (activeTasks.length === 0 && overdueTasks.length === 0) { - logger.info('No tasks to plan'); + logger.info("No tasks to plan"); return []; } @@ -58,35 +58,43 @@ export async function generateDailyPlan(params: { notes: t.notes, })); - const prompt = `Aujourd'hui c'est ${dayOfWeek}. -Sport fait recemment : ${sportDoneRecently ? 'Oui' : 'Non (3+ jours sans sport)'} + const prompt = `Сегодня ${dayOfWeek}. +Спорт недавно: ${sportDoneRecently ? "Да" : "Нет (3+ дней без спорта)"} -Voici toutes les taches actives et en retard : +Вот все активные и просроченные задачи: ${JSON.stringify(tasksSummary, null, 2)} -Genere le plan du jour. Rappel : max 5-7 taches, commence par une rapide.`; +Сгенерируй план дня. Напоминание: макс 5-7 задач, начни с быстрой.`; - const systemPrompt = PLANNER_SYSTEM_PROMPT.replaceAll('{ownerName}', config.ownerName); + const systemPrompt = PLANNER_SYSTEM_PROMPT.replaceAll( + "{ownerName}", + config.ownerName, + ); const response = await askClaude({ prompt, systemPrompt, - model: 'sonnet', + model: "sonnet", }); try { const parsed = JSON.parse(response) as DailyPlanTask[]; - logger.info({ taskCount: parsed.length }, 'Daily plan generated'); + logger.info({ taskCount: parsed.length }, "Daily plan generated"); return parsed; } catch { - logger.error({ response }, 'Failed to parse daily plan from Claude'); + logger.error({ response }, "Failed to parse daily plan from Claude"); return allTasks.slice(0, 5).map((t, i) => ({ task_id: t.id, title: t.title, category: t.category, priority: t.priority, estimated_minutes: t.estimated_minutes, - time_slot: i < 2 ? ('urgent' as const) : i < 4 ? ('important' as const) : ('optional' as const), + time_slot: + i < 2 + ? ("urgent" as const) + : i < 4 + ? ("important" as const) + : ("optional" as const), order: i + 1, })); } diff --git a/src/ai/research-agent.ts b/src/ai/research-agent.ts index 056c3a3..e6c2273 100644 --- a/src/ai/research-agent.ts +++ b/src/ai/research-agent.ts @@ -1,76 +1,81 @@ -import { askClaude } from './client.js'; -import { buildContext } from './context-builder.js'; -import { logger } from '../logger.js'; +import { askClaude } from "./client.js"; +import { buildContext } from "./context-builder.js"; +import { logger } from "../logger.js"; export interface ResearchResult { content: string; } -const RESEARCH_PROMPT = `Tu es un agent de recherche expert, sans limites dans ta profondeur d'analyse. +const RESEARCH_PROMPT = `Ты — экспертный исследовательский агент, без ограничений в глубине анализа. -SUJET : +ТЕМА: {topic} -DETAILS : +ДЕТАЛИ: {details} {memory_context} -TA MISSION : -Produis un rapport de recherche COMPLET et APPROFONDI. Ne te retiens pas. Va au fond du sujet. +ТВОЯ МИССИЯ: +Создай ПОЛНЫЙ и ГЛУБОКИЙ исследовательский отчёт. Не сдерживайся. Копай до сути. -- Explore TOUS les angles possibles du sujet -- Fournis des donnees concretes, des chiffres, des statistiques recentes -- Analyse les tendances actuelles, les evolutions, les perspectives -- Cite des acteurs majeurs, des entreprises, des cas concrets -- Compare differentes approches, methodologies, solutions -- Identifie les risques, les opportunites, les points de vigilance -- Donne des recommandations actionables et argumentees -- N'hesite pas a aller en profondeur sur chaque sous-sujet -- Utilise toutes tes connaissances, y compris les plus recentes +- Исследуй ВСЕ возможные ракурсы темы +- Приводи конкретные данные, цифры, актуальную статистику +- Анализируй текущие тренды, развитие, перспективы +- Упоминай ключевых игроков, компании, конкретные кейсы +- Сравнивай различные подходы, методологии, решения +- Выявляй риски, возможности, точки внимания +- Давай практические и аргументированные рекомендации +- Не стесняйся углубляться в каждую подтему +- Используй все свои знания, включая самые свежие -FORMAT : -- Ecris en francais -- Utilise des titres clairs avec des emojis pour la lisibilite -- Paragraphes detailles, pas de bullet points generiques -- Chaque section doit etre substantielle (pas juste 2 lignes) -- Termine par des recommandations concretes et des sources -- Ecris autant que necessaire, ne te limite PAS en longueur +ФОРМАТ: +- Пиши на русском языке +- Используй чёткие заголовки с эмодзи для читаемости +- Развёрнутые абзацы, не шаблонные буллет-поинты +- Каждая секция должна быть содержательной (не просто 2 строки) +- Заверши конкретными рекомендациями и источниками +- Пиши столько, сколько нужно, НЕ ограничивай себя по объёму -IMPORTANT : Reponds directement en texte structure. PAS de JSON. PAS de code. Juste le rapport.`; +ВАЖНО: Отвечай прямо структурированным текстом. БЕЗ JSON. БЕЗ кода. Только отчёт.`; export async function runResearchAgent(params: { topic: string; details: string; includeMemory?: boolean; }): Promise { - logger.info({ topic: params.topic }, 'Starting research agent'); + logger.info({ topic: params.topic }, "Starting research agent"); - let memoryContext = ''; + let memoryContext = ""; if (params.includeMemory) { try { const context = await buildContext(); - memoryContext = `CONTEXTE PERSONNEL (utilise si pertinent pour enrichir la recherche) :\n${context}`; + memoryContext = `ЛИЧНЫЙ КОНТЕКСТ (используй, если релевантно для обогащения исследования):\n${context}`; } catch (err) { - logger.warn({ err: err instanceof Error ? err.message : err }, 'Failed to build context for research'); + logger.warn( + { err: err instanceof Error ? err.message : err }, + "Failed to build context for research", + ); } } - const prompt = RESEARCH_PROMPT - .replace('{topic}', params.topic) - .replace('{details}', params.details || 'Aucun detail supplementaire. Explore le sujet librement.') - .replace('{memory_context}', memoryContext); + const prompt = RESEARCH_PROMPT.replace("{topic}", params.topic) + .replace( + "{details}", + params.details || "Нет дополнительных деталей. Исследуй тему свободно.", + ) + .replace("{memory_context}", memoryContext); const response = await askClaude({ - prompt: `Fais une recherche approfondie et complete sur ce sujet. Ne te limite pas. Vas-y a fond.`, + prompt: `Проведи глубокое и полное исследование по этой теме. Не ограничивай себя. Давай на полную.`, systemPrompt: prompt, - model: 'sonnet', + model: "sonnet", maxTokens: 16000, }); logger.info( { topic: params.topic, responseLength: response.length }, - 'Research agent completed' + "Research agent completed", ); return { content: response }; diff --git a/src/ai/transcribe.ts b/src/ai/transcribe.ts index 5e1406e..90df755 100644 --- a/src/ai/transcribe.ts +++ b/src/ai/transcribe.ts @@ -1,31 +1,43 @@ -import OpenAI from 'openai'; -import { logger } from '../logger.js'; +import OpenAI from "openai"; +import { logger } from "../logger.js"; let openai: OpenAI | null = null; function getOpenAI(): OpenAI { if (openai) return openai; - const apiKey = process.env['OPENAI_API_KEY']; + const apiKey = process.env["OPENAI_API_KEY"]; if (!apiKey) { - throw new Error('Missing OPENAI_API_KEY environment variable'); + throw new Error("Missing OPENAI_API_KEY environment variable"); } openai = new OpenAI({ apiKey }); return openai; } -export async function transcribeAudio(buffer: Buffer, filename: string, language: string = 'fr'): Promise { +export async function transcribeAudio( + buffer: Buffer, + filename: string, + language: string = "ru", +): Promise { const client = getOpenAI(); - logger.info({ filename, size: buffer.length, language }, 'Transcribing audio'); + logger.info( + { filename, size: buffer.length, language }, + "Transcribing audio", + ); - const file = new File([new Uint8Array(buffer)], filename, { type: 'audio/ogg' }); + const file = new File([new Uint8Array(buffer)], filename, { + type: "audio/ogg", + }); const transcription = await client.audio.transcriptions.create({ file, - model: 'whisper-1', + model: "whisper-1", language, }); - logger.info({ text: transcription.text.substring(0, 100) }, 'Transcription complete'); + logger.info( + { text: transcription.text.substring(0, 100) }, + "Transcription complete", + ); return transcription.text; } diff --git a/src/commands/clients.ts b/src/commands/clients.ts index 67c03ee..a498a23 100644 --- a/src/commands/clients.ts +++ b/src/commands/clients.ts @@ -1,80 +1,90 @@ -import type { Bot, Context } from 'grammy'; -import { getClientPipeline, createClient, searchClientByName } from '../db/clients.js'; -import { isAdmin } from '../utils/auth.js'; +import type { Bot, Context } from "grammy"; +import { + getClientPipeline, + createClient, + searchClientByName, +} from "../db/clients.js"; +import { isAdmin } from "../utils/auth.js"; const STATUS_EMOJI: Record = { - lead: '🔵', - qualified: '🟡', - proposal_sent: '📨', - accepted: '🟢', - in_progress: '🔧', - delivered: '📦', - paid: '✅', + lead: "🔵", + qualified: "🟡", + proposal_sent: "📨", + accepted: "🟢", + in_progress: "🔧", + delivered: "📦", + paid: "✅", }; export function registerClientCommands(bot: Bot): void { - bot.command('clients', async (ctx: Context) => { + bot.command("clients", async (ctx: Context) => { if (!isAdmin(ctx)) return; const clients = await getClientPipeline(); if (clients.length === 0) { - await ctx.reply('Aucun client dans le pipeline.'); + await ctx.reply("Нет клиентов в воронке."); return; } const lines = clients.map((c) => { - const emoji = STATUS_EMOJI[c.status] ?? '⚪'; - const need = c.need ? ` — ${c.need}` : ''; - const budget = c.budget_range ? ` (${c.budget_range})` : ''; + const emoji = STATUS_EMOJI[c.status] ?? "⚪"; + const need = c.need ? ` — ${c.need}` : ""; + const budget = c.budget_range ? ` (${c.budget_range})` : ""; return `${emoji} ${c.name}${need}${budget} [${c.status}]`; }); - await ctx.reply(`💼 Pipeline clients (${clients.length}) :\n\n${lines.join('\n')}`); + await ctx.reply( + `💼 Воронка клиентов (${clients.length}):\n\n${lines.join("\n")}`, + ); }); - bot.command('client', async (ctx: Context) => { + bot.command("client", async (ctx: Context) => { if (!isAdmin(ctx)) return; const name = ctx.match?.toString().trim(); if (!name) { - await ctx.reply('Usage : /client [nom]'); + await ctx.reply("Использование: /client [имя]"); return; } const results = await searchClientByName(name); if (results.length === 0) { - await ctx.reply(`Aucun client trouve pour "${name}".`); + await ctx.reply(`Клиент "${name}" не найден.`); return; } const client = results[0]!; - const emoji = STATUS_EMOJI[client.status] ?? '⚪'; + const emoji = STATUS_EMOJI[client.status] ?? "⚪"; let message = `${emoji} ${client.name}\n`; - message += `Statut : ${client.status}\n`; - if (client.source) message += `Source : ${client.source}\n`; - if (client.business_type) message += `Metier : ${client.business_type}\n`; - if (client.need) message += `Besoin : ${client.need}\n`; - if (client.budget_range) message += `Budget : ${client.budget_range}\n`; - if (client.phone) message += `Tel : ${client.phone}\n`; - if (client.notes) message += `Notes : ${client.notes}\n`; + message += `Статус: ${client.status}\n`; + if (client.source) message += `Источник: ${client.source}\n`; + if (client.business_type) message += `Сфера: ${client.business_type}\n`; + if (client.need) message += `Потребность: ${client.need}\n`; + if (client.budget_range) message += `Бюджет: ${client.budget_range}\n`; + if (client.phone) message += `Тел: ${client.phone}\n`; + if (client.notes) message += `Заметки: ${client.notes}\n`; await ctx.reply(message); }); - bot.command('newclient', async (ctx: Context) => { + bot.command("newclient", async (ctx: Context) => { if (!isAdmin(ctx)) return; const text = ctx.match?.toString().trim(); if (!text) { - await ctx.reply('Usage : /newclient [nom] — [besoin] — [budget]'); + await ctx.reply( + "Использование: /newclient [имя] — [потребность] — [бюджет]", + ); return; } - const parts = text.split('—').map((p) => p.trim()); + const parts = text.split("—").map((p) => p.trim()); const name = parts[0]; if (!name) { - await ctx.reply('Usage : /newclient [nom] — [besoin] — [budget]'); + await ctx.reply( + "Использование: /newclient [имя] — [потребность] — [бюджет]", + ); return; } @@ -82,12 +92,12 @@ export function registerClientCommands(bot: Bot): void { name, need: parts[1] ?? null, budget_range: parts[2] ?? null, - source: 'telegram', - status: 'lead', + source: "telegram", + status: "lead", }); await ctx.reply( - `✅ Nouveau lead cree :\n\n💼 ${client.name}\nBesoin : ${client.need ?? 'non precise'}\nBudget : ${client.budget_range ?? 'non precise'}\nStatut : lead` + `✅ Новый лид создан:\n\n💼 ${client.name}\nПотребность: ${client.need ?? "не указана"}\nБюджет: ${client.budget_range ?? "не указан"}\nСтатус: lead`, ); }); } diff --git a/src/commands/index.ts b/src/commands/index.ts index 0a313c1..2bfdbba 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,18 +1,18 @@ -import type { Bot } from 'grammy'; -import { registerPlanCommand } from './plan.js'; -import { registerTaskCommands } from './tasks.js'; -import { registerClientCommands } from './clients.js'; -import { registerNotifsCommand } from './notifs.js'; -import { registerVoiceCommand } from './voice.js'; -import { config } from '../config.js'; -import { isAdmin } from '../utils/auth.js'; -import { needsOnboarding, startOnboarding } from '../ai/onboarding.js'; -import { logger } from '../logger.js'; +import type { Bot } from "grammy"; +import { registerPlanCommand } from "./plan.js"; +import { registerTaskCommands } from "./tasks.js"; +import { registerClientCommands } from "./clients.js"; +import { registerNotifsCommand } from "./notifs.js"; +import { registerVoiceCommand } from "./voice.js"; +import { config } from "../config.js"; +import { isAdmin } from "../utils/auth.js"; +import { needsOnboarding, startOnboarding } from "../ai/onboarding.js"; +import { logger } from "../logger.js"; export function registerCommands(bot: Bot): void { - bot.command('start', async (ctx) => { + bot.command("start", async (ctx) => { if (!isAdmin(ctx)) { - await ctx.reply('Ce bot est prive.'); + await ctx.reply("Этот бот приватный."); return; } @@ -25,24 +25,24 @@ export function registerCommands(bot: Bot): void { return; } } catch (error) { - logger.error({ error }, 'Onboarding check failed, showing default start'); + logger.error({ error }, "Onboarding check failed, showing default start"); } await ctx.reply( - `Salut ! Je suis ton ${config.botName}.\n\nCommandes :\n` + - `/plan — Plan du jour\n` + - `/next — Prochaine tache\n` + - `/done — Marquer comme fait\n` + - `/add [texte] — Ajouter une tache\n` + - `/tasks — Toutes les taches\n` + - `/skip — Passer la tache\n` + - `/clients — Pipeline clients\n` + - `/client [nom] — Details client\n` + - `/newclient [nom] — [besoin] — [budget]\n` + - `/notifs — Voir/regler les notifications (ex: /notifs 20)\n` + - `/replan — Replanifier les notifications\n` + - `/voice — Activer/desactiver les reponses vocales\n\n` + - `Ou envoie un message libre, je comprendrai.` + `Привет! Я — твой ${config.botName}.\n\nКоманды:\n` + + `/plan — План дня\n` + + `/next — Следующая задача\n` + + `/done — Отметить как выполнено\n` + + `/add [текст] — Добавить задачу\n` + + `/tasks — Все задачи\n` + + `/skip — Пропустить задачу\n` + + `/clients — Воронка клиентов\n` + + `/client [имя] — Детали клиента\n` + + `/newclient [имя] — [потребность] — [бюджет]\n` + + `/notifs — Посмотреть/настроить уведомления (пр: /notifs 20)\n` + + `/replan — Перепланировать уведомления\n` + + `/voice — Вкл/выкл голосовые ответы\n\n` + + `Или просто отправь сообщение, я пойму.`, ); }); diff --git a/src/commands/notifs.ts b/src/commands/notifs.ts index 3add36d..70037ad 100644 --- a/src/commands/notifs.ts +++ b/src/commands/notifs.ts @@ -1,11 +1,14 @@ -import type { Bot, Context } from 'grammy'; -import { upsertMemory } from '../db/memory.js'; -import { logger } from '../logger.js'; -import { isAdmin } from '../utils/auth.js'; -import { planDay, getNotificationsSummary } from '../cron/dynamic-notifications.js'; +import type { Bot, Context } from "grammy"; +import { upsertMemory } from "../db/memory.js"; +import { logger } from "../logger.js"; +import { isAdmin } from "../utils/auth.js"; +import { + planDay, + getNotificationsSummary, +} from "../cron/dynamic-notifications.js"; export function registerNotifsCommand(bot: Bot): void { - bot.command('notifs', async (ctx: Context) => { + bot.command("notifs", async (ctx: Context) => { if (!isAdmin(ctx)) return; const arg = ctx.match?.toString().trim(); @@ -15,47 +18,49 @@ export function registerNotifsCommand(bot: Bot): void { const summary = await getNotificationsSummary(); await ctx.reply(summary); } catch (error) { - logger.error({ error }, 'Failed to get notifications summary'); - await ctx.reply('Erreur lors de la lecture des notifications.'); + logger.error({ error }, "Failed to get notifications summary"); + await ctx.reply("Ошибка при чтении уведомлений."); } return; } const newCount = parseInt(arg, 10); if (isNaN(newCount) || newCount < 1 || newCount > 50) { - await ctx.reply('Usage : /notifs [1-50]\nExemple : /notifs 20'); + await ctx.reply("Использование: /notifs [1-50]\nПример: /notifs 20"); return; } try { await upsertMemory({ - category: 'preference', - key: 'notifications_par_jour', - content: `${newCount} notifications par jour`, - source: 'commande_notifs', + category: "preference", + key: "notifications_par_jour", + content: `${newCount} уведомлений в день`, + source: "команда_notifs", }); - await ctx.reply(`Nombre de notifications : ${newCount}/jour\n\nReplanification en cours...`); + await ctx.reply( + `Количество уведомлений: ${newCount}/день\n\nПерепланирование...`, + ); const planned = await planDay(); - await ctx.reply(`${planned} notifications planifiees pour le reste de la journee.`); + await ctx.reply(`${planned} уведомлений запланировано на остаток дня.`); } catch (error) { - logger.error({ error }, 'Failed to update notification count'); - await ctx.reply('Erreur lors de la mise a jour. Reessaie.'); + logger.error({ error }, "Failed to update notification count"); + await ctx.reply("Ошибка при обновлении. Попробуй ещё раз."); } }); - bot.command('replan', async (ctx: Context) => { + bot.command("replan", async (ctx: Context) => { if (!isAdmin(ctx)) return; try { - await ctx.reply('Replanification en cours...'); + await ctx.reply("Перепланирование..."); const planned = await planDay(); - await ctx.reply(`${planned} notifications replanifiees pour le reste de la journee.`); + await ctx.reply(`${planned} уведомлений перепланировано на остаток дня.`); } catch (error) { - logger.error({ error }, 'Failed to replan notifications'); - await ctx.reply('Erreur lors de la replanification.'); + logger.error({ error }, "Failed to replan notifications"); + await ctx.reply("Ошибка при перепланировании."); } }); } diff --git a/src/commands/plan.ts b/src/commands/plan.ts index e721071..9b603f4 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -1,13 +1,22 @@ -import type { Bot, Context } from 'grammy'; -import { getActiveTasks } from '../db/tasks.js'; -import { getOverdueTasks } from '../db/tasks.js'; -import { getDailyPlan, saveDailyPlan, initLivePlanFromStatic } from '../db/daily-plans.js'; -import { generateDailyPlan } from '../ai/planner.js'; -import { formatDailyPlan, formatLivePlanMessage, todayDateString, getDayOfWeek } from '../utils/format.js'; -import { isAdmin } from '../utils/auth.js'; +import type { Bot, Context } from "grammy"; +import { getActiveTasks } from "../db/tasks.js"; +import { getOverdueTasks } from "../db/tasks.js"; +import { + getDailyPlan, + saveDailyPlan, + initLivePlanFromStatic, +} from "../db/daily-plans.js"; +import { generateDailyPlan } from "../ai/planner.js"; +import { + formatDailyPlan, + formatLivePlanMessage, + todayDateString, + getDayOfWeek, +} from "../utils/format.js"; +import { isAdmin } from "../utils/auth.js"; export function registerPlanCommand(bot: Bot): void { - bot.command('plan', async (ctx: Context) => { + bot.command("plan", async (ctx: Context) => { if (!isAdmin(ctx)) return; const today = todayDateString(); @@ -15,7 +24,7 @@ export function registerPlanCommand(bot: Bot): void { let plan = await getDailyPlan(today); if (!plan) { - await ctx.reply('Generation du plan en cours...'); + await ctx.reply("Генерирую план..."); const activeTasks = await getActiveTasks(); const overdueTasks = await getOverdueTasks(); @@ -33,7 +42,7 @@ export function registerPlanCommand(bot: Bot): void { date: today, plan: planTasks, live_plan: livePlan, - status: 'active', + status: "active", review: null, productivity_score: null, revision_count: 0, @@ -42,25 +51,25 @@ export function registerPlanCommand(bot: Bot): void { } const dayName = getDayOfWeek(); - const dateFormatted = new Date().toLocaleDateString('fr-FR', { - day: 'numeric', - month: 'long', + const dateFormatted = new Date().toLocaleDateString("ru-RU", { + day: "numeric", + month: "long", }); - let message = `Plan du ${dayName} ${dateFormatted}\n\n`; + let message = `План на ${dayName} ${dateFormatted}\n\n`; // Show live plan if available, otherwise static plan if (plan.live_plan && plan.live_plan.length > 0) { message += formatLivePlanMessage(plan.live_plan); if (plan.revision_count > 0) { - message += `\nReorganise ${plan.revision_count} fois aujourd'hui.\n`; + message += `\nРеорганизован ${plan.revision_count} раз(а) сегодня.\n`; } } else { message += formatDailyPlan(plan.plan); } - message += '\nFenetre d\'or : 10h-15h. Protege-la.\n\n'; - message += 'Si tu ne fais que les rouges, c\'est deja bien.'; + message += "\nЗолотое окно: 10ч-15ч. Защищай его.\n\n"; + message += "Если сделаешь хотя бы красные — уже хорошо."; await ctx.reply(message); }); diff --git a/src/commands/tasks.ts b/src/commands/tasks.ts index 76a8b03..9c9790f 100644 --- a/src/commands/tasks.ts +++ b/src/commands/tasks.ts @@ -1,38 +1,42 @@ -import type { Bot, Context } from 'grammy'; +import type { Bot, Context } from "grammy"; import { getNextTask, getActiveTasks, completeTask, createTask, -} from '../db/tasks.js'; -import type { Task } from '../types/index.js'; -import { formatTask, formatTaskList } from '../utils/format.js'; -import { isAdmin } from '../utils/auth.js'; +} from "../db/tasks.js"; +import type { Task } from "../types/index.js"; +import { formatTask, formatTaskList } from "../utils/format.js"; +import { isAdmin } from "../utils/auth.js"; export function registerTaskCommands(bot: Bot): void { - bot.command('next', async (ctx: Context) => { + bot.command("next", async (ctx: Context) => { if (!isAdmin(ctx)) return; const task = await getNextTask(); if (!task) { - await ctx.reply('Aucune tache en attente. Profite de ce moment de calme !'); + await ctx.reply("Нет задач в очереди. Наслаждайся моментом спокойствия!"); return; } - const time = task.estimated_minutes ? `\nTemps estime : ${task.estimated_minutes} min` : ''; + const time = task.estimated_minutes + ? `\nОценка времени: ${task.estimated_minutes} мин` + : ""; await ctx.reply( - `➡️ Prochaine tache :\n\n${formatTask(task)}${time}\n\nJuste celle-la. Rien d'autre.\n\n/done quand c'est fait.` + `➡️ Следующая задача:\n\n${formatTask(task)}${time}\n\nТолько эта. Ничего больше.\n\n/done когда сделаешь.`, ); }); - bot.command('tasks', async (ctx: Context) => { + bot.command("tasks", async (ctx: Context) => { if (!isAdmin(ctx)) return; const tasks = await getActiveTasks(); - await ctx.reply(`📋 Taches actives (${tasks.length}) :\n\n${formatTaskList(tasks)}`); + await ctx.reply( + `📋 Активные задачи (${tasks.length}):\n\n${formatTaskList(tasks)}`, + ); }); - bot.command('done', async (ctx: Context) => { + bot.command("done", async (ctx: Context) => { if (!isAdmin(ctx)) return; const arg = ctx.match?.toString().trim(); @@ -42,63 +46,67 @@ export function registerTaskCommands(bot: Bot): void { if (arg) { const index = parseInt(arg, 10); if (isNaN(index)) { - await ctx.reply('Usage : /done [numero] ou /done (pour la tache en cours)'); + await ctx.reply( + "Использование: /done [номер] или /done (для текущей задачи)", + ); return; } const tasks = await getActiveTasks(); const task = tasks[index - 1]; if (!task) { - await ctx.reply(`Tache #${index} introuvable.`); + await ctx.reply(`Задача #${index} не найдена.`); return; } completedTask = await completeTask(task.id); } else { const task = await getNextTask(); if (!task) { - await ctx.reply('Aucune tache a completer.'); + await ctx.reply("Нет задач для завершения."); return; } completedTask = await completeTask(task.id); } const next = await getNextTask(); - let message = `✅ Fait : ${completedTask.title}\n\nBravo !`; + let message = `✅ Сделано: ${completedTask.title}\n\nМолодец!`; if (next) { - message += `\n\n➡️ Prochaine tache :\n${formatTask(next)}`; + message += `\n\n➡️ Следующая задача:\n${formatTask(next)}`; } else { - message += '\n\n🎉 Plus aucune tache ! Tu as tout fait.'; + message += "\n\n🎉 Задач больше нет! Ты всё сделал."; } await ctx.reply(message); }); - bot.command('add', async (ctx: Context) => { + bot.command("add", async (ctx: Context) => { if (!isAdmin(ctx)) return; const text = ctx.match?.toString().trim(); if (!text) { - await ctx.reply('Usage : /add [description de la tache]'); + await ctx.reply("Использование: /add [описание задачи]"); return; } const task = await createTask({ title: text, - source: 'telegram', - category: 'personal', - priority: 'normal', - status: 'todo', + source: "telegram", + category: "personal", + priority: "normal", + status: "todo", }); - await ctx.reply(`✅ Tache ajoutee : ${task.title}\n\nCategorie : ${task.category}\nPriorite : ${task.priority}`); + await ctx.reply( + `✅ Задача добавлена: ${task.title}\n\nКатегория: ${task.category}\nПриоритет: ${task.priority}`, + ); }); - bot.command('skip', async (ctx: Context) => { + bot.command("skip", async (ctx: Context) => { if (!isAdmin(ctx)) return; const tasks = await getActiveTasks(); if (tasks.length === 0) { - await ctx.reply('Aucune tache a passer.'); + await ctx.reply("Нет задач для пропуска."); return; } @@ -107,13 +115,15 @@ export function registerTaskCommands(bot: Bot): void { const remaining = tasks.filter((t) => t.id !== next.id); if (remaining.length === 0) { - await ctx.reply(`C'est ta seule tache. Courage ! /done quand c'est fait.`); + await ctx.reply( + `Это твоя единственная задача. Давай! /done когда сделаешь.`, + ); return; } const nextAfterSkip = remaining[0]!; await ctx.reply( - `⏭️ Tache passee : ${next.title}\n\n➡️ Prochaine tache :\n${formatTask(nextAfterSkip)}` + `⏭️ Задача пропущена: ${next.title}\n\n➡️ Следующая задача:\n${formatTask(nextAfterSkip)}`, ); }); } diff --git a/src/commands/voice.ts b/src/commands/voice.ts index 992d303..89b366f 100644 --- a/src/commands/voice.ts +++ b/src/commands/voice.ts @@ -1,10 +1,10 @@ -import type { Bot, Context } from 'grammy'; -import { isAdmin } from '../utils/auth.js'; -import { isVoiceMode, setVoiceMode } from '../utils/reply.js'; -import { logger } from '../logger.js'; +import type { Bot, Context } from "grammy"; +import { isAdmin } from "../utils/auth.js"; +import { isVoiceMode, setVoiceMode } from "../utils/reply.js"; +import { logger } from "../logger.js"; export function registerVoiceCommand(bot: Bot): void { - bot.command('voice', async (ctx: Context) => { + bot.command("voice", async (ctx: Context) => { if (!isAdmin(ctx)) return; try { @@ -14,13 +14,17 @@ export function registerVoiceCommand(bot: Bot): void { await setVoiceMode(newMode); if (newMode) { - await ctx.reply('🔊 Reponses vocales activees.\nJe te parlerai en vocal maintenant.\n\n/voice pour repasser en texte.'); + await ctx.reply( + "🔊 Голосовые ответы включены.\nТеперь буду отвечать голосом.\n\n/voice чтобы вернуться к тексту.", + ); } else { - await ctx.reply('🔇 Reponses vocales desactivees.\nJe reponds en texte maintenant.\n\n/voice pour reactiver le vocal.'); + await ctx.reply( + "🔇 Голосовые ответы выключены.\nТеперь отвечаю текстом.\n\n/voice чтобы снова включить голос.", + ); } } catch (error) { - logger.error({ error }, 'Failed to toggle voice mode'); - await ctx.reply('Erreur lors du changement de mode. Reessaie.'); + logger.error({ error }, "Failed to toggle voice mode"); + await ctx.reply("Ошибка при переключении режима. Попробуй ещё раз."); } }); } diff --git a/src/cron/dynamic-notifications.ts b/src/cron/dynamic-notifications.ts index 701e3f7..2cb8c8f 100644 --- a/src/cron/dynamic-notifications.ts +++ b/src/cron/dynamic-notifications.ts @@ -1,27 +1,31 @@ -import type { Bot } from 'grammy'; +import type { Bot } from "grammy"; import { planDailyNotifications, getNotificationCount, -} from '../ai/notification-planner.js'; +} from "../ai/notification-planner.js"; import { createReminders, getDueReminders, markReminderSent, cancelActiveReminders, getTodayReminders, -} from '../db/reminders.js'; -import { getActiveTasks, getOverdueTasks } from '../db/tasks.js'; -import { getDailyPlan, saveDailyPlan, initLivePlanFromStatic } from '../db/daily-plans.js'; -import { generateDailyPlan } from '../ai/planner.js'; -import { todayDateString, getDayOfWeek } from '../utils/format.js'; -import { logger } from '../logger.js'; +} from "../db/reminders.js"; +import { getActiveTasks, getOverdueTasks } from "../db/tasks.js"; +import { + getDailyPlan, + saveDailyPlan, + initLivePlanFromStatic, +} from "../db/daily-plans.js"; +import { generateDailyPlan } from "../ai/planner.js"; +import { todayDateString, getDayOfWeek } from "../utils/format.js"; +import { logger } from "../logger.js"; export async function ensureDailyPlan(): Promise { const today = todayDateString(); const existing = await getDailyPlan(today); if (existing?.live_plan) { - logger.info('Daily live plan already exists, skipping generation'); + logger.info("Daily live plan already exists, skipping generation"); return; } @@ -37,7 +41,7 @@ export async function ensureDailyPlan(): Promise { }); if (planTasks.length === 0) { - logger.info('No tasks to plan for today'); + logger.info("No tasks to plan for today"); return; } @@ -47,16 +51,19 @@ export async function ensureDailyPlan(): Promise { date: today, plan: planTasks, live_plan: livePlan, - status: 'active', + status: "active", review: null, productivity_score: null, revision_count: 0, last_reorganized_at: null, }); - logger.info({ taskCount: livePlan.length }, 'Daily live plan auto-generated'); + logger.info( + { taskCount: livePlan.length }, + "Daily live plan auto-generated", + ); } catch (error) { - logger.error({ error }, 'Failed to auto-generate daily plan'); + logger.error({ error }, "Failed to auto-generate daily plan"); } } @@ -67,7 +74,10 @@ export async function planDay(): Promise { const cancelled = await cancelActiveReminders(); if (cancelled > 0) { - logger.info({ cancelled }, 'Cancelled stale notifications before replanning'); + logger.info( + { cancelled }, + "Cancelled stale notifications before replanning", + ); } const count = await getNotificationCount(); @@ -75,11 +85,11 @@ export async function planDay(): Promise { const planned = await planDailyNotifications(count); if (planned.length === 0) { - logger.warn('Notification planner returned 0 notifications'); + logger.warn("Notification planner returned 0 notifications"); return 0; } - const today = new Date().toISOString().split('T')[0]!; + const today = new Date().toISOString().split("T")[0]!; const now = new Date(); const reminders = planned @@ -88,35 +98,47 @@ export async function planDay(): Promise { return { message: notif.message, trigger_at: triggerDate.toISOString(), - repeat: 'once' as const, - repeat_config: { type: notif.type, planned_by: 'notification-planner' }, - channel: 'telegram' as const, + repeat: "once" as const, + repeat_config: { + type: notif.type, + planned_by: "notification-planner", + }, + channel: "telegram" as const, }; }) .filter((r) => new Date(r.trigger_at) > now); if (reminders.length === 0) { - logger.warn('All planned notifications are in the past'); + logger.warn("All planned notifications are in the past"); return 0; } await createReminders(reminders); - logger.info({ stored: reminders.length, total: planned.length }, 'Daily notifications stored in DB'); + logger.info( + { stored: reminders.length, total: planned.length }, + "Daily notifications stored in DB", + ); return reminders.length; } catch (error) { - logger.error({ error }, 'Failed to plan daily notifications'); + logger.error({ error }, "Failed to plan daily notifications"); return 0; } } -export async function dispatchNotifications(bot: Bot, chatId: string): Promise { +export async function dispatchNotifications( + bot: Bot, + chatId: string, +): Promise { try { const dueReminders = await getDueReminders(); if (dueReminders.length === 0) return; - logger.info({ count: dueReminders.length }, 'Dispatching due notifications'); + logger.info( + { count: dueReminders.length }, + "Dispatching due notifications", + ); for (const reminder of dueReminders) { try { @@ -125,16 +147,19 @@ export async function dispatchNotifications(bot: Bot, chatId: string): Promise | null; logger.info( - { id: reminder.id, type: config?.['type'] ?? 'unknown' }, - 'Notification sent' + { id: reminder.id, type: config?.["type"] ?? "unknown" }, + "Notification sent", ); } catch (error) { - logger.error({ error, reminderId: reminder.id }, 'Failed to send notification'); + logger.error( + { error, reminderId: reminder.id }, + "Failed to send notification", + ); await markReminderSent(reminder.id); } } } catch (error) { - logger.error({ error }, 'Failed to dispatch notifications'); + logger.error({ error }, "Failed to dispatch notifications"); } } @@ -142,26 +167,26 @@ export async function getNotificationsSummary(): Promise { const count = await getNotificationCount(); const reminders = await getTodayReminders(); - const active = reminders.filter((r) => r.status === 'active'); - const sent = reminders.filter((r) => r.status === 'sent'); + const active = reminders.filter((r) => r.status === "active"); + const sent = reminders.filter((r) => r.status === "sent"); - let summary = `Notifications aujourd'hui : ${count}/jour\n\n`; - summary += `Envoyees : ${sent.length}\n`; - summary += `En attente : ${active.length}\n`; + let summary = `Уведомления сегодня: ${count}/день\n\n`; + summary += `Отправлено: ${sent.length}\n`; + summary += `В ожидании: ${active.length}\n`; if (active.length > 0) { - summary += `\nProchaines :\n`; + summary += `\nСледующие:\n`; for (const r of active.slice(0, 5)) { - const time = new Date(r.trigger_at).toLocaleTimeString('fr-FR', { - hour: '2-digit', - minute: '2-digit', + const time = new Date(r.trigger_at).toLocaleTimeString("ru-RU", { + hour: "2-digit", + minute: "2-digit", }); const config = r.repeat_config as Record | null; - const type = config?.['type'] ?? ''; - summary += ` ${time} [${type}] ${r.message.slice(0, 60)}${r.message.length > 60 ? '...' : ''}\n`; + const type = config?.["type"] ?? ""; + summary += ` ${time} [${type}] ${r.message.slice(0, 60)}${r.message.length > 60 ? "..." : ""}\n`; } if (active.length > 5) { - summary += ` ... et ${active.length - 5} autres\n`; + summary += ` ... и ещё ${active.length - 5}\n`; } } diff --git a/src/handlers/free-text.ts b/src/handlers/free-text.ts index 436cb80..5bf52a3 100644 --- a/src/handlers/free-text.ts +++ b/src/handlers/free-text.ts @@ -1,20 +1,20 @@ -import type { Bot, Context } from 'grammy'; -import { processWithOrchestrator } from '../ai/orchestrator.js'; -import { runResearchAgent } from '../ai/research-agent.js'; -import { processMemoryRequest } from '../ai/memory-manager.js'; -import { reorganizePlan } from '../ai/plan-reorganizer.js'; -import { isOnboarding, processOnboardingResponse } from '../ai/onboarding.js'; -import { logger } from '../logger.js'; -import { isAdmin } from '../utils/auth.js'; -import { addMessage, formatHistoryForPrompt } from '../utils/conversation.js'; -import { sendLongMessage, smartReply } from '../utils/reply.js'; +import type { Bot, Context } from "grammy"; +import { processWithOrchestrator } from "../ai/orchestrator.js"; +import { runResearchAgent } from "../ai/research-agent.js"; +import { processMemoryRequest } from "../ai/memory-manager.js"; +import { reorganizePlan } from "../ai/plan-reorganizer.js"; +import { isOnboarding, processOnboardingResponse } from "../ai/onboarding.js"; +import { logger } from "../logger.js"; +import { isAdmin } from "../utils/auth.js"; +import { addMessage, formatHistoryForPrompt } from "../utils/conversation.js"; +import { sendLongMessage, smartReply } from "../utils/reply.js"; export function registerFreeText(bot: Bot): void { - bot.on('message:text', async (ctx: Context) => { + bot.on("message:text", async (ctx: Context) => { if (!isAdmin(ctx)) return; const text = ctx.message?.text; - if (!text || text.startsWith('/')) return; + if (!text || text.startsWith("/")) return; const chatId = String(ctx.chat?.id); @@ -24,23 +24,27 @@ export function registerFreeText(bot: Bot): void { const result = await processOnboardingResponse(chatId, text); await ctx.reply(result.reply); } catch (error) { - logger.error({ error }, 'Onboarding response failed'); - await ctx.reply('Erreur pendant l\'onboarding. Renvoie ton message.'); + logger.error({ error }, "Onboarding response failed"); + await ctx.reply( + "Ошибка во время знакомства. Отправь сообщение ещё раз.", + ); } return; } try { - addMessage(chatId, 'user', text); + addMessage(chatId, "user", text); const history = formatHistoryForPrompt(chatId); const result = await processWithOrchestrator(text, history); // Check if memory management was triggered - const memoryAction = result.actions.find((a) => a.type === 'manage_memory'); + const memoryAction = result.actions.find( + (a) => a.type === "manage_memory", + ); if (memoryAction) { - addMessage(chatId, 'assistant', result.response); + addMessage(chatId, "assistant", result.response); await smartReply(ctx, result.response); try { @@ -49,96 +53,115 @@ export function registerFreeText(bot: Bot): void { conversationHistory: history, }); await sendLongMessage(ctx, memoryResult.response); - addMessage(chatId, 'assistant', memoryResult.response); + addMessage(chatId, "assistant", memoryResult.response); } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); - logger.error({ err: errMsg }, 'Memory manager failed'); - await ctx.reply('Erreur lors de la modification memoire. Reessaie.'); + logger.error({ err: errMsg }, "Memory manager failed"); + await ctx.reply("Ошибка при изменении памяти. Попробуй ещё раз."); } return; } // Check if plan reorganization was triggered - const reorgAction = result.actions.find((a) => a.type === 'reorganize_plan'); + const reorgAction = result.actions.find( + (a) => a.type === "reorganize_plan", + ); if (reorgAction) { - const trigger = String(reorgAction.data['trigger'] ?? text); + const trigger = String(reorgAction.data["trigger"] ?? text); - addMessage(chatId, 'assistant', result.response); + addMessage(chatId, "assistant", result.response); await smartReply(ctx, result.response); try { const reorgResult = await reorganizePlan(trigger); - let reorgMessage = `Voila ton plan reorganise :\n\n${reorgResult.explanation}\n`; + let reorgMessage = `Вот твой обновлённый план:\n\n${reorgResult.explanation}\n`; // Show updated plan summary - const pending = reorgResult.livePlan.filter((t) => t.status === 'pending' || t.status === 'in_progress'); - const done = reorgResult.livePlan.filter((t) => t.status === 'done'); - const deferred = reorgResult.livePlan.filter((t) => t.status === 'deferred'); + const pending = reorgResult.livePlan.filter( + (t) => t.status === "pending" || t.status === "in_progress", + ); + const done = reorgResult.livePlan.filter((t) => t.status === "done"); + const deferred = reorgResult.livePlan.filter( + (t) => t.status === "deferred", + ); if (done.length > 0) { - reorgMessage += `\nDeja fait (${done.length}) :\n`; + reorgMessage += `\nУже сделано (${done.length}):\n`; for (const t of done) reorgMessage += ` ${t.title}\n`; } if (pending.length > 0) { - reorgMessage += `\nReste a faire :\n`; + reorgMessage += `\nОсталось сделать:\n`; for (const t of pending) { - const time = t.scheduled_time ? ` [${t.scheduled_time}]` : ''; - const est = t.estimated_minutes ? ` (${t.estimated_minutes} min)` : ''; + const time = t.scheduled_time ? ` [${t.scheduled_time}]` : ""; + const est = t.estimated_minutes + ? ` (${t.estimated_minutes} min)` + : ""; reorgMessage += ` ${t.order}. ${t.title}${time}${est}\n`; } } if (deferred.length > 0) { - reorgMessage += `\nReporte :\n`; - for (const t of deferred) reorgMessage += ` ${t.title} → ${t.deferred_to}\n`; + reorgMessage += `\nОтложено:\n`; + for (const t of deferred) + reorgMessage += ` ${t.title} → ${t.deferred_to}\n`; } if (reorgResult.warnings.length > 0) { - reorgMessage += `\n${reorgResult.warnings.join('\n')}`; + reorgMessage += `\n${reorgResult.warnings.join("\n")}`; } await sendLongMessage(ctx, reorgMessage); - addMessage(chatId, 'assistant', reorgMessage); + addMessage(chatId, "assistant", reorgMessage); } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); - logger.error({ err: errMsg }, 'Plan reorganization failed'); - await ctx.reply('Erreur lors de la reorganisation. Le plan reste inchange.'); + logger.error({ err: errMsg }, "Plan reorganization failed"); + await ctx.reply( + "Ошибка при реорганизации. План остаётся без изменений.", + ); } return; } // Check if a research was triggered - const researchAction = result.actions.find((a) => a.type === 'start_research'); + const researchAction = result.actions.find( + (a) => a.type === "start_research", + ); if (researchAction) { - const topic = String(researchAction.data['topic'] ?? ''); - const details = String(researchAction.data['details'] ?? ''); - const includeMemory = Boolean(researchAction.data['include_memory']); + const topic = String(researchAction.data["topic"] ?? ""); + const details = String(researchAction.data["details"] ?? ""); + const includeMemory = Boolean(researchAction.data["include_memory"]); - addMessage(chatId, 'assistant', result.response); + addMessage(chatId, "assistant", result.response); await smartReply(ctx, result.response); try { - const research = await runResearchAgent({ topic, details, includeMemory }); + const research = await runResearchAgent({ + topic, + details, + includeMemory, + }); await sendLongMessage(ctx, research.content); - addMessage(chatId, 'assistant', `Recherche envoyee : ${topic}`); + addMessage(chatId, "assistant", `Исследование отправлено: ${topic}`); } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); - logger.error({ err: errMsg, topic }, 'Research agent failed'); - await ctx.reply(`Erreur lors de la recherche : ${errMsg}\nEssaie de reformuler ou redemande.`); + logger.error({ err: errMsg, topic }, "Research agent failed"); + await ctx.reply( + `Ошибка при исследовании: ${errMsg}\nПопробуй переформулировать или запроси снова.`, + ); } return; } // Normal flow - addMessage(chatId, 'assistant', result.response); + addMessage(chatId, "assistant", result.response); await smartReply(ctx, result.response); } catch (error) { - logger.error({ error, text }, 'Failed to process free text'); - await ctx.reply('Erreur de traitement. Renvoie ton message.'); + logger.error({ error, text }, "Failed to process free text"); + await ctx.reply("Ошибка обработки. Отправь сообщение ещё раз."); } }); } diff --git a/src/handlers/voice.ts b/src/handlers/voice.ts index aca6cd8..9d66e27 100644 --- a/src/handlers/voice.ts +++ b/src/handlers/voice.ts @@ -1,33 +1,35 @@ -import type { Bot, Context } from 'grammy'; -import { transcribeAudio } from '../ai/transcribe.js'; -import { processWithOrchestrator } from '../ai/orchestrator.js'; -import { runResearchAgent } from '../ai/research-agent.js'; -import { processMemoryRequest } from '../ai/memory-manager.js'; -import { logger } from '../logger.js'; -import { isAdmin } from '../utils/auth.js'; -import { addMessage, formatHistoryForPrompt } from '../utils/conversation.js'; -import { sendVoiceReply, sendLongMessage } from '../utils/reply.js'; +import type { Bot, Context } from "grammy"; +import { transcribeAudio } from "../ai/transcribe.js"; +import { processWithOrchestrator } from "../ai/orchestrator.js"; +import { runResearchAgent } from "../ai/research-agent.js"; +import { processMemoryRequest } from "../ai/memory-manager.js"; +import { logger } from "../logger.js"; +import { isAdmin } from "../utils/auth.js"; +import { addMessage, formatHistoryForPrompt } from "../utils/conversation.js"; +import { sendVoiceReply, sendLongMessage } from "../utils/reply.js"; export function registerVoiceHandler(bot: Bot): void { - bot.on('message:voice', async (ctx: Context) => { + bot.on("message:voice", async (ctx: Context) => { if (!isAdmin(ctx)) return; try { - await ctx.reply('🎙️ J\'ecoute...'); + await ctx.reply("🎙️ Слушаю..."); const voice = ctx.message?.voice; if (!voice) return; const file = await ctx.api.getFile(voice.file_id); - const fileUrl = `https://api.telegram.org/file/bot${process.env['TELEGRAM_BOT_TOKEN']}/${file.file_path}`; + const fileUrl = `https://api.telegram.org/file/bot${process.env["TELEGRAM_BOT_TOKEN"]}/${file.file_path}`; const response = await fetch(fileUrl); const buffer = Buffer.from(await response.arrayBuffer()); - const text = await transcribeAudio(buffer, 'voice.ogg'); + const text = await transcribeAudio(buffer, "voice.ogg"); if (!text || text.trim().length === 0) { - await ctx.reply('Je n\'ai pas compris le message vocal. Essaie encore ?'); + await ctx.reply( + "Не удалось распознать голосовое сообщение. Попробуй ещё раз?", + ); return; } @@ -35,15 +37,17 @@ export function registerVoiceHandler(bot: Bot): void { await ctx.reply(`📝 "${text}"`); const chatId = String(ctx.chat?.id); - addMessage(chatId, 'user', text); + addMessage(chatId, "user", text); const history = formatHistoryForPrompt(chatId); const result = await processWithOrchestrator(text, history); - const memoryAction = result.actions.find((a) => a.type === 'manage_memory'); + const memoryAction = result.actions.find( + (a) => a.type === "manage_memory", + ); if (memoryAction) { - addMessage(chatId, 'assistant', result.response); + addMessage(chatId, "assistant", result.response); // Voice reply for the orchestrator response await sendVoiceReply(ctx, result.response); @@ -54,45 +58,54 @@ export function registerVoiceHandler(bot: Bot): void { }); // Memory details as text (structured data, not great for voice) await sendLongMessage(ctx, memoryResult.response); - addMessage(chatId, 'assistant', memoryResult.response); + addMessage(chatId, "assistant", memoryResult.response); } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); - logger.error({ err: errMsg }, 'Memory manager failed'); - await ctx.reply('Erreur lors de la modification memoire. Reessaie.'); + logger.error({ err: errMsg }, "Memory manager failed"); + await ctx.reply("Ошибка при изменении памяти. Попробуй ещё раз."); } return; } - const researchAction = result.actions.find((a) => a.type === 'start_research'); + const researchAction = result.actions.find( + (a) => a.type === "start_research", + ); if (researchAction) { - const topic = String(researchAction.data['topic'] ?? ''); - const details = String(researchAction.data['details'] ?? ''); - const includeMemory = Boolean(researchAction.data['include_memory']); + const topic = String(researchAction.data["topic"] ?? ""); + const details = String(researchAction.data["details"] ?? ""); + const includeMemory = Boolean(researchAction.data["include_memory"]); - addMessage(chatId, 'assistant', result.response); + addMessage(chatId, "assistant", result.response); await sendVoiceReply(ctx, result.response); try { - const research = await runResearchAgent({ topic, details, includeMemory }); + const research = await runResearchAgent({ + topic, + details, + includeMemory, + }); // Research reports are long — send as text await sendLongMessage(ctx, research.content); - addMessage(chatId, 'assistant', `Recherche envoyee : ${topic}`); + addMessage(chatId, "assistant", `Исследование отправлено: ${topic}`); } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); - logger.error({ err: errMsg, topic }, 'Research agent failed'); - await ctx.reply(`Erreur lors de la recherche : ${errMsg}\nEssaie de reformuler ou redemande.`); + logger.error({ err: errMsg, topic }, "Research agent failed"); + await ctx.reply( + `Ошибка при исследовании: ${errMsg}\nПопробуй переформулировать или запроси снова.`, + ); } return; } // Normal flow — voice response - addMessage(chatId, 'assistant', result.response); + addMessage(chatId, "assistant", result.response); await sendVoiceReply(ctx, result.response); - } catch (error) { - logger.error({ error }, 'Failed to process voice message'); - await ctx.reply('Erreur lors du traitement du vocal. Essaie en texte ou renvoie le vocal.'); + logger.error({ error }, "Failed to process voice message"); + await ctx.reply( + "Ошибка при обработке голосового. Попробуй текстом или отправь голосовое ещё раз.", + ); } }); } diff --git a/src/utils/format.ts b/src/utils/format.ts index 2e093bb..605a473 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -1,128 +1,140 @@ -import type { Task, DailyPlanTask, LivePlanTask } from '../types/index.js'; +import type { Task, DailyPlanTask, LivePlanTask } from "../types/index.js"; const PRIORITY_EMOJI: Record = { - urgent: '🔴', - important: '🟡', - normal: '🟢', - low: '⚪', + urgent: "🔴", + important: "🟡", + normal: "🟢", + low: "⚪", }; const CATEGORY_EMOJI: Record = { - client: '💼', - student: '🎓', - content: '📹', - personal: '👤', - dev: '💻', - team: '👥', + client: "💼", + student: "🎓", + content: "📹", + personal: "👤", + dev: "💻", + team: "👥", }; export function formatTask(task: Task, index?: number): string { - const priority = PRIORITY_EMOJI[task.priority] ?? '⚪'; - const category = CATEGORY_EMOJI[task.category] ?? '📌'; - const prefix = index !== undefined ? `${index}. ` : ''; - const time = task.estimated_minutes ? ` (${task.estimated_minutes} min)` : ''; - const due = task.due_date ? ` — deadline: ${task.due_date}` : ''; + const priority = PRIORITY_EMOJI[task.priority] ?? "⚪"; + const category = CATEGORY_EMOJI[task.category] ?? "📌"; + const prefix = index !== undefined ? `${index}. ` : ""; + const time = task.estimated_minutes ? ` (${task.estimated_minutes} min)` : ""; + const due = task.due_date ? ` — deadline: ${task.due_date}` : ""; return `${prefix}${priority}${category} ${task.title}${time}${due}`; } export function formatDailyPlan(planTasks: DailyPlanTask[]): string { - const urgent = planTasks.filter((t) => t.time_slot === 'urgent'); - const important = planTasks.filter((t) => t.time_slot === 'important'); - const optional = planTasks.filter((t) => t.time_slot === 'optional'); + const urgent = planTasks.filter((t) => t.time_slot === "urgent"); + const important = planTasks.filter((t) => t.time_slot === "important"); + const optional = planTasks.filter((t) => t.time_slot === "optional"); - let message = ''; + let message = ""; if (urgent.length > 0) { - message += '🔴 URGENT (avant 12h) :\n'; + message += "🔴 СРОЧНОЕ (до 12ч):\n"; urgent.forEach((t) => { - const time = t.estimated_minutes ? ` (${t.estimated_minutes} min)` : ''; + const time = t.estimated_minutes ? ` (${t.estimated_minutes} min)` : ""; message += ` ${t.order}. ${t.title}${time}\n`; }); - message += '\n'; + message += "\n"; } if (important.length > 0) { - message += '🟡 IMPORTANT (avant 17h) :\n'; + message += "🟡 ВАЖНОЕ (до 17ч):\n"; important.forEach((t) => { - const time = t.estimated_minutes ? ` (${t.estimated_minutes} min)` : ''; + const time = t.estimated_minutes ? ` (${t.estimated_minutes} min)` : ""; message += ` ${t.order}. ${t.title}${time}\n`; }); - message += '\n'; + message += "\n"; } if (optional.length > 0) { - message += '🟢 SI TU AS LE TEMPS :\n'; + message += "🟢 ЕСЛИ БУДЕТ ВРЕМЯ:\n"; optional.forEach((t) => { - const time = t.estimated_minutes ? ` (${t.estimated_minutes} min)` : ''; + const time = t.estimated_minutes ? ` (${t.estimated_minutes} min)` : ""; message += ` ${t.order}. ${t.title}${time}\n`; }); - message += '\n'; + message += "\n"; } return message; } const LIVE_STATUS_ICON: Record = { - pending: '⬜', - in_progress: '🔄', - done: '✅', - skipped: '⏭️', - deferred: '📅', + pending: "⬜", + in_progress: "🔄", + done: "✅", + skipped: "⏭️", + deferred: "📅", }; export function formatLivePlanMessage(livePlan: LivePlanTask[]): string { - const done = livePlan.filter((t) => t.status === 'done'); - const pending = livePlan.filter((t) => t.status === 'pending' || t.status === 'in_progress'); - const skippedOrDeferred = livePlan.filter((t) => t.status === 'skipped' || t.status === 'deferred'); + const done = livePlan.filter((t) => t.status === "done"); + const pending = livePlan.filter( + (t) => t.status === "pending" || t.status === "in_progress", + ); + const skippedOrDeferred = livePlan.filter( + (t) => t.status === "skipped" || t.status === "deferred", + ); - let message = `Progression : ${done.length}/${livePlan.length}\n\n`; + let message = `Прогресс: ${done.length}/${livePlan.length}\n\n`; if (pending.length > 0) { - message += 'A FAIRE :\n'; + message += "СДЕЛАТЬ:\n"; for (const t of pending) { - const icon = LIVE_STATUS_ICON[t.status] ?? '⬜'; - const priority = PRIORITY_EMOJI[t.priority] ?? '⚪'; - const time = t.scheduled_time ? ` [${t.scheduled_time}]` : ''; - const est = t.estimated_minutes ? ` (${t.estimated_minutes} min)` : ''; + const icon = LIVE_STATUS_ICON[t.status] ?? "⬜"; + const priority = PRIORITY_EMOJI[t.priority] ?? "⚪"; + const time = t.scheduled_time ? ` [${t.scheduled_time}]` : ""; + const est = t.estimated_minutes ? ` (${t.estimated_minutes} min)` : ""; message += ` ${icon}${priority} ${t.order}. ${t.title}${time}${est}\n`; } - message += '\n'; + message += "\n"; } if (done.length > 0) { - message += 'FAIT :\n'; + message += "СДЕЛАНО:\n"; for (const t of done) { message += ` ✅ ${t.title}\n`; } - message += '\n'; + message += "\n"; } if (skippedOrDeferred.length > 0) { - message += 'REPORTE/SAUTE :\n'; + message += "ОТЛОЖЕНО/ПРОПУЩЕНО:\n"; for (const t of skippedOrDeferred) { - const icon = LIVE_STATUS_ICON[t.status] ?? '📅'; - const note = t.deferred_to ? ` → ${t.deferred_to}` : ''; - const reason = t.skip_reason ? ` (${t.skip_reason})` : ''; + const icon = LIVE_STATUS_ICON[t.status] ?? "📅"; + const note = t.deferred_to ? ` → ${t.deferred_to}` : ""; + const reason = t.skip_reason ? ` (${t.skip_reason})` : ""; message += ` ${icon} ${t.title}${note}${reason}\n`; } - message += '\n'; + message += "\n"; } return message; } export function formatTaskList(tasks: Task[]): string { - if (tasks.length === 0) return 'Aucune tache active.'; + if (tasks.length === 0) return "Нет активных задач."; - return tasks.map((task, i) => formatTask(task, i + 1)).join('\n'); + return tasks.map((task, i) => formatTask(task, i + 1)).join("\n"); } export function todayDateString(): string { - return new Date().toISOString().split('T')[0]!; + return new Date().toISOString().split("T")[0]!; } export function getDayOfWeek(): string { - const days = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi']; + const days = [ + "Воскресенье", + "Понедельник", + "Вторник", + "Среда", + "Четверг", + "Пятница", + "Суббота", + ]; return days[new Date().getDay()]!; }