Ctrl K
ComunidadGuías…

src/server/conversation-summarizer.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { readdirSync, writeFileSync, statSync, existsSync } from 'node:fs';
import path from 'node:path';
import YAML from 'yaml';
import { dataPath } from './paths.ts';
import { loadAgents } from './agents.ts';
import { runTextQuery } from './sdk-utils.ts';
import { getAllSessions, loadConversation, type ConvMessage } from './sessions.ts';

const CONV_DIR = dataPath('conversations');
const DEFAULT_MAX_AGE = 0;

export interface SummarizerOptions {
  maxAge?: number;
  minFileSize?: number;
  maxConvos?: number;
  force?: boolean;
}

function getAgent() {
  const agents = loadAgents();
  const agent = agents['summarizer'];
  if (!agent) throw new Error('No "summarizer" agent defined in defaults/agents/ or data/agents/');
  return agent;
}

function getTraceAgent() {
  const agents = loadAgents();
  return agents['trace-extractor'] ?? null;
}

function extractText(messages: ConvMessage[], maxChars = 80_000): string {
  const parts: string[] = [];
  let len = 0;
  for (const m of messages) {
    const role = m.role === 'user' ? 'User' : 'Assistant';
    for (const b of m.blocks) {
      if (b.type === 'text' && b.text) {
        const line = `${role}: ${b.text.slice(0, 500)}`;
        parts.push(line);
        len += line.length;
        if (len > maxChars) return parts.join('\n');
      } else if (b.type === 'tool_use') {
        parts.push(`Assistant: (tool: ${b.tool})`);
      }
    }
  }
  return parts.join('\n');
}

async function runSummarizerAgent(text: string): Promise<string> {
  const agent = getAgent();
  return runTextQuery({
    prompt: `Here is a transcript of a conversation between a user and an AI assistant. Write a briefing about what happened in this conversation.\n\n<transcript>\n${text}\n</transcript>`,
    model: agent.model || 'haiku',
    maxTurns: agent.maxTurns || 3,
    systemPrompt: agent.prompt,
  });
}

interface TraceMeta {
  sessionId: string;
  email: string;
  date: string;
  title: string;
  messageCount: number;
}

async function runTraceExtractor(text: string, meta: TraceMeta): Promise<string | null> {
  const agent = getTraceAgent();
  if (!agent) return null;

  const metaBlock = `Metadata:\n  session_id: ${meta.sessionId}\n  user: ${meta.email}\n  date: "${meta.date}"\n  title: "${meta.title}"\n  message_count: ${meta.messageCount}`;

  try {
    return await runTextQuery({
      prompt: `${metaBlock}\n\n<transcript>\n${text}\n</transcript>`,
      model: agent.model || 'haiku',
      maxTurns: 1,
      systemPrompt: agent.prompt,
    });
  } catch (err) {
    console.error(`[summarizer] Trace extraction failed:`, err);
    return null;
  }
}

const TRACE_REQUIRED_FIELDS = ['session_id', 'date', 'outcome'];

function validateAndWriteTrace(raw: string, tracePath: string): boolean {
  try {
    const cleaned = raw.replace(/^```ya?ml\n?/m, '').replace(/\n?```\s*$/m, '').trim();
    const parsed = YAML.parse(cleaned);
    if (!parsed || typeof parsed !== 'object') {
      console.warn(`[summarizer] Trace not an object`);
      return false;
    }
    for (const field of TRACE_REQUIRED_FIELDS) {
      if (!(field in parsed)) {
        console.warn(`[summarizer] Trace missing required field: ${field}`);
        return false;
      }
    }
    writeFileSync(tracePath, cleaned + '\n');
    return true;
  } catch (err) {
    console.warn(`[summarizer] Trace YAML parse failed:`, err);
    return false;
  }
}

export async function summarizeConversations(options?: SummarizerOptions): Promise<{
  summarized: number;
  skipped: number;
  errors: number;
  tracesGenerated: number;
}> {
  const maxAge = options?.maxAge ?? DEFAULT_MAX_AGE;
  const minFileSize = options?.minFileSize ?? 5_000; // 5KB — filters out trivial "hey/hi" convos
  const maxConvos = options?.maxConvos ?? 20;
  const force = options?.force ?? false;

  const now = Date.now();
  const sessions = getAllSessions();
  const sessionMap = new Map(sessions.map(s => [s.sessionId, s]));

  const jsonlFiles = readdirSync(CONV_DIR).filter(f => f.endsWith('.jsonl'));

  const candidates: { sessionId: string; jsonlPath: string; summaryPath: string; jsonlMtime: number }[] = [];

  for (const f of jsonlFiles) {
    const sessionId = f.slice(0, -6);
    const jsonlPath = path.join(CONV_DIR, f);
    const summaryPath = path.join(CONV_DIR, `${sessionId}.summary.md`);
    const stat = statSync(jsonlPath);
    const jsonlMtime = stat.mtimeMs;

    if (now - jsonlMtime < maxAge) continue;
    if (stat.size < minFileSize) continue;

    if (!force && existsSync(summaryPath)) {
      const summaryMtime = statSync(summaryPath).mtimeMs;
      if (summaryMtime >= jsonlMtime) continue;
    }

    candidates.push({ sessionId, jsonlPath, summaryPath, jsonlMtime });
  }

  candidates.sort((a, b) => a.jsonlMtime - b.jsonlMtime);
  const batch = candidates.slice(0, maxConvos);

  let summarized = 0;
  let skipped = 0;
  let errors = 0;
  let tracesGenerated = 0;

  for (const { sessionId, jsonlPath, summaryPath, jsonlMtime } of batch) {
    try {
      const messages = loadConversation(sessionId);
      const text = extractText(messages);
      if (!text.trim()) {
        skipped++;
        continue;
      }

      const session = sessionMap.get(sessionId);
      const title = session?.title || '(untitled)';
      const email = session?.userEmail || 'unknown';
      const date = new Date(session?.lastModified ?? Date.now()).toISOString().slice(0, 10);

      console.log(`[summarizer] Summarizing ${sessionId.slice(0, 8)}... (${messages.length} msgs)`);
      const summary = await runSummarizerAgent(text);

      if (!summary?.trim()) {
        console.warn(`[summarizer] Empty summary for ${sessionId.slice(0, 8)}`);
        skipped++;
        continue;
      }

      const content = `<!-- session: ${sessionId} | user: ${email} | date: ${date} | title: ${title} -->\n\n${summary}\n`;
      writeFileSync(summaryPath, content);
      summarized++;
      console.log(`[summarizer] Done ${sessionId.slice(0, 8)} (${summary.length} chars)`);

      // Trace extraction (second pass)
      const tracePath = path.join(CONV_DIR, `${sessionId}.trace.yaml`);
      const traceUpToDate = !force && existsSync(tracePath) && statSync(tracePath).mtimeMs >= jsonlMtime;

      if (!traceUpToDate) {
        const traceMeta: TraceMeta = { sessionId, email, date, title, messageCount: messages.length };
        const traceRaw = await runTraceExtractor(text, traceMeta);
        if (traceRaw && validateAndWriteTrace(traceRaw, tracePath)) {
          tracesGenerated++;
          console.log(`[summarizer] Trace written for ${sessionId.slice(0, 8)}`);
        }
      }
    } catch (err) {
      console.error(`[summarizer] Error on ${sessionId.slice(0, 8)}:`, err);
      errors++;
    }
  }

  console.log(`[summarizer] Finished: ${summarized} summarized, ${tracesGenerated} traces, ${skipped} skipped, ${errors} errors (${candidates.length - batch.length} deferred)`);
  return { summarized, skipped, errors, tracesGenerated };
}