File size: 4,202 Bytes
cc11e77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
/**
 * memorySummarizer.ts — Auto-summarization della memoria agente (S87 — GAP 4)
 *
 * Quando la memoria supera la soglia (400 entry), le 100 più vecchie vengono
 * compresse in un riassunto compatto via Gemini Flash, poi eliminate.
 * Il riassunto entra nella memoria come categoria "summary".
 *
 * Design:
 *  - Un solo job alla volta (_summarizing flag)
 *  - Fire-and-forget: non blocca mai il render path
 *  - Graceful fallback: se Gemini fallisce -> LRU delete classica
 *  - Safari-safe: fetch standard, no Worker
 *  - Il riassunto e in italiano (stessa lingua del sistema)
 *
 * Modello: gemini-2.5-flash-lite (S325: era gemini-2.5-flash, lite = 15 RPM/1000 RPD gratuiti, piu veloce per compressione)
 */

import { getGeminiToken } from "@/lib/providerChain";
import type { StoredMemoryEntry } from "@/lib/vfsDb";
import { makeTimedSignal } from "@/lib/agentLoop/networkConstants"; // Loop-15: iOS-safe

export const SUMMARIZE_THRESHOLD = 200; // S325: era 400 — riassume piu spesso, memoria piu compatta
export const SUMMARIZE_BATCH     = 100; // quante entry comprimere per volta

// -- Gemini generateContent (non-streaming) -----------------------------------

const GEMINI_GEN_URL =
  "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent"; // S325: era gemini-2.5-flash

interface GeminiResponse {
  candidates?: Array<{
    content?: { parts?: Array<{ text?: string }> };
  }>;
}

async function callGemini(prompt: string): Promise<string | null> {
  const token = getGeminiToken();
  if (!token) return null;
  try {
    const res = await fetch(`${GEMINI_GEN_URL}?key=${token}`, {
      method:  "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        contents: [{ role: "user", parts: [{ text: prompt }] }],
        generationConfig: { temperature: 0.1, maxOutputTokens: 900 },
      }),
      signal: makeTimedSignal(15_000),
    });
    if (!res.ok) return null;
    const data = await res.json() as GeminiResponse;
    return data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ?? null;
  } catch {
    return null;
  }
}

// -- Prompt builder -----------------------------------------------------------

function buildPrompt(entries: StoredMemoryEntry[]): string {
  const lines = entries
    .map(e => `[${e.category}] ${e.key}: ${e.value.slice(0, 300)}`)
    .join("\n");

  return [
    "Sei un assistente che comprime la memoria a lungo termine di un agente AI.",
    `Di seguito ci sono ${entries.length} ricordi da condensare in un riassunto compatto.`,
    "",
    "RICORDI DA COMPRIMERE:",
    lines,
    "",
    "REGOLE:",
    "- Scrivi un riassunto strutturato in bullet point (max 25 righe)",
    "- Mantieni SOLO le informazioni utili per future conversazioni (preferenze, fatti, nomi, date importanti)",
    "- Scarta dati meteo, news, prezzi (gia filtrati per staleness)",
    "- Usa italiano conciso",
    "- Inizia direttamente con i bullet, senza preambolo",
    "",
    "RIASSUNTO:",
  ].join("\n");
}

// -- Main export --------------------------------------------------------------

export interface SummaryResult {
  summary:  string;
  key:      string;
  replaced: StoredMemoryEntry[];
}

/**
 * Comprime le N entry piu vecchie in un riassunto via Gemini Flash.
 * Ritorna null se Gemini non e disponibile o fallisce (il caller fa LRU delete).
 */
export async function summarizeOldEntries(
  entries: StoredMemoryEntry[],
  batchSize = SUMMARIZE_BATCH,
): Promise<SummaryResult | null> {
  const candidates = entries
    .filter(e => e.category !== "summary")
    .sort((a, b) => a.updatedAt - b.updatedAt)
    .slice(0, batchSize);

  if (candidates.length < 10) return null;

  const prompt  = buildPrompt(candidates);
  const summary = await callGemini(prompt);
  if (!summary || summary.length < 50) return null;

  const key = `summary:${Date.now()}`;
  return { summary, key, replaced: candidates };
}

/** Stima quante token consuma un batch di entry (approssimata, 4 char = 1 token). */
export function estimateBatchTokens(entries: StoredMemoryEntry[]): number {
  return entries.reduce((acc, e) => acc + (e.key.length + e.value.length) / 4, 0);
}