Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

128 changes: 73 additions & 55 deletions src/ai/context-builder.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
export async function buildContext(
options?: BuildContextOptions,
): Promise<string> {
try {
const maxTasks = options?.maxTasks ?? 15;

Expand All @@ -27,21 +34,24 @@ export async function buildContext(options?: BuildContextOptions): Promise<strin
]);

const now = new Date();
const dateStr = now.toLocaleDateString('fr-FR', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
const dateStr = now.toLocaleDateString("ru-RU", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
});
const timeStr = now.toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
});
const timeStr = now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });

let context = '';
let context = "";

// Core memory (identity — always present, no decay)
if (coreMemory.length > 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)
Expand All @@ -58,26 +68,26 @@ export async function buildContext(options?: BuildContextOptions): Promise<strin
const freshByCategory = groupByCategory(fresh.map((d) => 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`;
}
}

Expand All @@ -87,81 +97,85 @@ export async function buildContext(options?: BuildContextOptions): Promise<strin
}

// Live tasks
context += `TACHES ACTIVES (${activeTasks.length}) :\n`;
context += `АКТИВНЫЕ ЗАДАЧИ (${activeTasks.length}):\n`;
if (activeTasks.length === 0) {
context += '- Aucune tache\n';
context += "- Нет задач\n";
} else {
for (const t of activeTasks.slice(0, maxTasks)) {
const due = t.due_date ? ` (deadline: ${t.due_date})` : '';
const due = t.due_date ? ` (deadline: ${t.due_date})` : "";
context += `- [${t.priority}] ${t.title} (${t.category})${due}\n`;
}
if (activeTasks.length > 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<string, string> = {
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 {
Expand All @@ -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);
Expand Down
Loading