File size: 6,408 Bytes
95eb75a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
118
119
120
// S516 — estratto da agentLoop.ts: topic-boundary reset + context compression
// Gestisce la finestra di contesto prima dell'ingresso nel loop principale.

import { getTopicBoundaryNotice } from "../topicBoundary";
import type { TopicSegment } from "../topicBoundary";
import type { ApiMsg } from "./networkTools";
import { buildStructuredDigest } from "./historyCompressor"; // S-HIST: structured turn digest

// ── Jaccard similarity locale (P-CTX-SEMANTIC) ──────────────────────────────
// Self-contained, zero import aggiuntivi — sub-1ms, offline, iPhone safe.
function _tokenize(text: string): Set<string> {
  return new Set(text.toLowerCase().replace(/[^a-z0-9À-ÿ]/g, ' ').split(/\s+/).filter(t => t.length > 2));
}
function _jaccardScore(query: string, text: string): number {
  const a = _tokenize(query);
  const b = _tokenize(text);
  if (a.size === 0 || b.size === 0) return 0;
  let inter = 0;
  for (const t of a) if (b.has(t)) inter++;
  return inter / (a.size + b.size - inter);
}

export interface ContextWindowParams {
  loopMessages:       ApiMsg[];
  topicBoundary:      "same" | "continuation" | "new_topic";
  prevTopicSegment:   TopicSegment | string | null | undefined;
  contextKeep?:       number; // default 12 — S178
  /** P-CTX-SEMANTIC: query corrente per scoring semantico messaggi older. Opzionale — fallback cronologico. */
  currentQuery?:      string;
}

const _CONTEXT_KEEP_DEFAULT = 12; // III-a fix S178

// S380-D: protegge REAL_DATA/OBSERVATION/EPISTEMIC dal trim
const _VALUABLE_RE = /REAL_DATA|OBSERVATION|RISULTATO|EPISTEMIC|\[REGOLA\s+17\]|\[OSSERVAZIONE\]/;

// Fix 4 (S129): regex per messaggi con contesto progetto da preservare sul topic reset
const _PROJECT_RE = /\/[\w\-\.]+\.[a-z]{2,4}|[a-f0-9]{7,40}|write_file|read_file|vfs\.|package\.json|import\s+\{|const\s+\w+\s*=|function\s+\w+|class\s+\w+|src\//i;

/**
 * S516: applica topic-boundary reset (S96) e/o context compression (S178/S380-D).
 * Restituisce il nuovo array di loopMessages — non muta l'input.
 *
 * S-HIST: la sezione di compression ora usa buildStructuredDigest() invece della
 * concatenazione grezza slice(0,140). Il digest strutturato per turni è semanticamente
 * utile al LLM (principio "Lost in the Middle": position-1 = massima attenzione).
 * Interfaccia di ritorno invariata: [sysMsg, compressedMsg, ...valuable.slice(-2), ...recent]
 */
export function applyContextWindow(p: ContextWindowParams): ApiMsg[] {
  const msgs = p.loopMessages;
  const keep = p.contextKeep ?? _CONTEXT_KEEP_DEFAULT;

  // [S96] Topic boundary: se nuovo topic, azzera il contesto del segmento precedente.
  // Mantieni solo il system prompt + un notice compatto sul topic chiuso.
  if (p.topicBoundary === "new_topic" && p.prevTopicSegment) {
    const _sysMsg = msgs.find(m => m.role === "system");
    const _notice: ApiMsg = {
      role:    "system" as const,
      content: getTopicBoundaryNotice(p.prevTopicSegment as TopicSegment),
    };
    // Fix 4 (S129): preserva fino a 4 messaggi con contesto progetto
    const _projectMsgs = msgs
      .filter(m => m.role !== "system" && _PROJECT_RE.test(String(m.content)))
      .slice(-4);
    return _sysMsg ? [_sysMsg, _notice, ..._projectMsgs] : [_notice, ..._projectMsgs];
  }

  // S178/S380-D/S-HIST: context compression — taglia messaggi non-valuable, digest strutturato
  if (msgs.length > keep + 2) {
    // S650: usa findIndex() invece di msgs[0] — consistente con il branch topic-boundary
    // che già usa msgs.find(). msgs[0] assumeva che il system message fosse sempre al
    // primo posto; findIndex() è defensive e funziona anche se messages vengono
    // riordinati o se initialMessages non inizia con system message (caso degenere).
    // Fallback a msgs[0] se nessun system message trovato (array degenere).
    const _sysIdx  = msgs.findIndex(m => m.role === "system");
    const sysMsg   = _sysIdx >= 0 ? msgs[_sysIdx] : msgs[0];
    // S650: _nonSys costruisce la lista senza sysMsg → older/recent non includono sysMsg
    // → nessun doppione nella finestra compressa; prima msgs.slice(1,-keep) assumeva
    // sysMsg a indice 0, lasciando che sysMsg potesse comparire in recent se non a idx 0.
    const _nonSys  = _sysIdx >= 0 ? msgs.filter((_, i) => i !== _sysIdx) : msgs.slice(1);
    // D3: filtra messaggi ephemeral (status puri) — libera 10-20% contesto su Groq
    // Pattern da historyCompressor._EPHEMERAL_RE (mantenuto in sync manualmente)
    const _EPHEMERAL_RECENT_RE = /^(Analisi|Ricerca|Caricamento|Sto |Processing|Elabora|Verifica|Reflection)\s.{0,50}[…\.]{1,3}$/i;
    const recent    = _nonSys.slice(-keep).filter(m => !_EPHEMERAL_RECENT_RE.test(String(m.content ?? "").trim()));
    const older     = _nonSys.slice(0, Math.max(0, _nonSys.length - keep));
    const _valuable = older.filter(m => _VALUABLE_RE.test(String(m.content)));

    // P-CTX-SEMANTIC: estrae i messaggi older più rilevanti semanticamente alla query corrente.
    // Fallback silente: se currentQuery assente o scoring fallisce → comportamento invariato.
    // Invariante: ultimi 6 messaggi di recent sempre preservati (recency bias — non toccare).
    const _semanticRecalls: ApiMsg[] = (() => {
      try {
        const q = p.currentQuery;
        if (!q || older.length === 0) return [];
        const scored = older
          .filter(m => !_VALUABLE_RE.test(String(m.content))) // valuable già inclusi
          .map(m => ({ m, s: _jaccardScore(q, String(m.content)) }))
          .filter(x => x.s > 0.05) // soglia minima per evitare rumore
          .sort((a, b) => b.s - a.s)
          .slice(0, 3) // top-3 più rilevanti
          .map(x => x.m);
        return scored;
      } catch { return []; }
    })();

    // S-HIST: digest strutturato per turni — sostituisce concatenazione grezza slice(0,140).
    // buildStructuredDigest() produce righe tipo:
    //   T1: "crea componente Button" → [write_file(Button.tsx)] ✅
    //   T2: "aggiungi dark mode" → [read_file, write_file(styles.css)] ❌
    // Zero LLM calls, < 1ms, deterministic. Interfaccia di ritorno invariata.
    const compressedMsg: ApiMsg = {
      role:    "system" as const,
      content: buildStructuredDigest(older),
    };
    return [sysMsg, compressedMsg, ..._valuable.slice(-2), ..._semanticRecalls, ...recent];
  }

  return msgs;
}