mnemonic-proxy 0.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ctxproxy/core.py ADDED
@@ -0,0 +1,2218 @@
1
+ # Author: Maurizio Verde — LastReload
2
+ """Nucleo del proxy "contesto virtuale": catena di hash, archivio, masking a pacchetti, segmenti, recall.
3
+
4
+ Niente HTTP qui: il server (server.py) e il rigioco a secco (replay_dry.py) usano lo stesso codice.
5
+
6
+ Idee chiave
7
+ - La verità è la storia che manda il client. Ogni messaggio ha un hash a catena h_i = H(h_{i-1} || canon(m_i)),
8
+ quindi h_i identifica "questo messaggio in questa posizione dopo questa storia".
9
+ - Le decisioni (maschera di un'uscita tool, segmento, eventi interni di recall) sono chiavate da h_i: una volta
10
+ prese si riapplicano identiche a ogni richiesta successiva -> prompt fisico stabile -> la cache a prefisso di
11
+ Strata riusa tutto. Se il client modifica un messaggio vecchio, gli h_i successivi cambiano e le decisioni
12
+ dopo quel punto semplicemente non valgono più (ramo nuovo).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import dataclasses
17
+ import hashlib
18
+ import json
19
+ import os
20
+ import re
21
+ import sqlite3
22
+ import threading
23
+ import time
24
+ import uuid
25
+
26
+ from .lang import IMAGE_NOTE_EN, IMAGE_NOTE_IT, LANG_KEY, CUSTOM_INPUT_DESC_EN, CUSTOM_INPUT_DESC_IT, norm as \
27
+ norm_lang, outcome_text
28
+ from .paging import (AUTO_HEAD, AUTO_HEAD_EN, AUTO_HEADS, LEGACY_TOOLS_NAME, TOOLS_NAME, ToolPager, is_tools_result,
29
+ query_terms, typed_receipt)
30
+ from .render import message_piece, render_pieces, template_kwargs
31
+ from .tooldefs import ToolShortener
32
+
33
+ # Nomi degli strumenti del proxy (0.2.0: neutri rispetto al motore). Le conversazioni nate prima della 0.2.0 tengono i
34
+ # nomi vecchi (strata_recall / strata_tools): il nome entra nel prefisso stabile del prompt (elenco strumenti e
35
+ # segnaposti) e cambiarlo invaliderebbe cache e salvataggi. Le chiamate con il nome nuovo o con quello vecchio sono
36
+ # sempre risolte dal proxy (alias in ingresso), salvo che il client dichiari lui uno strumento con quel nome.
37
+ RECALL_NAME = "recall"
38
+ LEGACY_RECALL_NAME = "strata_recall"
39
+ RECALL_ALTERNATES = ("history_recall", "archive_recall", "ctx_recall")
40
+ TOOLS_ALTERNATES = ("load_tools", "tool_catalog", "ctx_tools")
41
+ NAMES_KEY = "tool_names:" # kv: nomi scelti per conversazione (json {"recall": ..., "tools": ...})
42
+ PLACEHOLDER = "[uscita strumento omessa: {n} token \u2014 recall:{rid}]" # formato vecchio (solo riconoscimento)
43
+ THINK_PLACEHOLDER = "[ragionamento omesso: {n} token \u2014 recall:{rid}]"
44
+ # Segnaposto con provenienza (NEXT.md punto 10, GPT-ANSWER3): solo nei RISULTATI degli strumenti (il modello li legge
45
+ # ma non li scrive), con chiamata/file di origine, inizio vero dell'uscita e istruzione esplicita.
46
+ OUT_PLACEHOLDER = ("[gestore del contesto: uscita di {origin} nascosta per spazio ({n} token). Inizio: \u00ab{head}\u00bb. "
47
+ "Testo esatto: {rn} id={rid}. Per lo stato ATTUALE rileggi il file o riesegui il comando.]")
48
+ OUT_PREFIX = "[gestore del contesto: uscita di "
49
+ # Ricevuta tipizzata (card t_00c5aef6, ricerca §5 e §19; delimitatore fuori vocabolario come da RICERCA-3): al posto
50
+ # dell'«Inizio» una ricevuta secondo il tipo (scrittura/modifica, shell ok/errore, test, lettura). Solo nel RISULTATO
51
+ # dello strumento. Il delimitatore ⟪ctx-archive …⟫ non compare nel codice vero: la guardia in uscita (server.py) blocca
52
+ # le chiamate con effetti che lo contengono.
53
+ RECEIPT_OPEN = "\u27eactx-archive"
54
+ OUT_RECEIPT = ("\u27eactx-archive id={rid} \u00b7 uscita di {origin} \u00b7 questo agente \u00b7 nascosta per spazio "
55
+ "({n} token)\u27eb Ricevuta: {receipt}. Testo esatto: {rn} id={rid}. Per lo stato ATTUALE "
56
+ "rileggi il file o riesegui il comando.")
57
+ DROP_NOTE = ("\n\n[gestore del contesto: scambio \u00abusa e getta\u00bb concluso e nascosto ({n} token). Effetti: {fx}. "
58
+ "Testo completo: {rn} id={rid}.]")
59
+ PIN_HEAD = "[strata-context: punti fermi]"
60
+ NOTES_MARK = "[strata-context: richiesta note di passaggio]"
61
+ SEG_MARK = "[strata-context: segmento {seg}]"
62
+ # 0.3.1: English texts for new conversations (prompt_language, see lang.py). The Italian ones above stay verbatim
63
+ # for conversations born before 0.3.1 (byte-identical prefix). Recognition always accepts both.
64
+ PLACEHOLDER_EN = "[tool output omitted: {n} tokens \u2014 recall:{rid}]" # recognition only
65
+ THINK_PLACEHOLDER_EN = "[reasoning omitted: {n} tokens \u2014 recall:{rid}]"
66
+ OUT_PLACEHOLDER_EN = ("[context manager: output of {origin} hidden to save space ({n} tokens). Start: \u00ab{head}\u00bb. "
67
+ "Exact text: {rn} id={rid}. For the CURRENT state re-read the file or re-run the command.]")
68
+ OUT_PREFIX_EN = "[context manager: output of "
69
+ OUT_PREFIXES = (OUT_PREFIX, OUT_PREFIX_EN)
70
+ OUT_RECEIPT_EN = ("\u27eactx-archive id={rid} \u00b7 output of {origin} \u00b7 this agent \u00b7 hidden to save space "
71
+ "({n} tokens)\u27eb Receipt: {receipt}. Exact text: {rn} id={rid}. For the CURRENT state "
72
+ "re-read the file or re-run the command.")
73
+ DROP_NOTE_EN = ("\n\n[context manager: \u00abdisposable\u00bb exchange finished and hidden ({n} tokens). Effects: {fx}. "
74
+ "Full text: {rn} id={rid}.]")
75
+ PIN_HEAD_EN = "[strata-context: pinned points]"
76
+ PIN_HEADS = (PIN_HEAD, PIN_HEAD_EN)
77
+ NOTES_MARK_EN = "[strata-context: handoff notes request]"
78
+ NOTES_MARKS = (NOTES_MARK, NOTES_MARK_EN)
79
+ SEG_MARK_EN = "[strata-context: segment {seg}]"
80
+ OMITTED_PREFIXES = ("[uscita strumento omessa:", "[tool output omitted:")
81
+ THINK_OMITTED_PREFIXES = ("[ragionamento omesso:", "[reasoning omitted:")
82
+
83
+
84
+ def is_notes_request(text) -> bool:
85
+ """Request for handoff notes (either language)."""
86
+ return isinstance(text, str) and text.startswith(NOTES_MARKS)
87
+
88
+
89
+ def is_placeholder(text) -> bool:
90
+ """Tool result already replaced by the proxy (placeholder or receipt, either language, any version)."""
91
+ return isinstance(text, str) and (text.startswith(OMITTED_PREFIXES) or any(p in text for p in OUT_PREFIXES)
92
+ or RECEIPT_OPEN in text)
93
+
94
+
95
+ def recall_tool_def(name: str = RECALL_NAME, lang: str = "it") -> dict:
96
+ """Definizione dello strumento di recall col nome della conversazione (funzione pura del nome e della lingua).
97
+ Conversazioni nate prima della 0.2.0 (nome strata_recall): testo italiano di allora, byte per byte (prefisso
98
+ invariato); nomi nuovi: testo inglese (fino alla 0.3.0 con l'avviso che risultati e ricevute possono essere in
99
+ italiano; dalla 0.3.1, lang="en", senza avviso: risultati e ricevute sono in inglese)."""
100
+ if name != LEGACY_RECALL_NAME:
101
+ return {"type": "function", "function": {
102
+ "name": name,
103
+ "description": (
104
+ "Retrieve the ORIGINAL, exact text of a tool output or message that the context manager removed "
105
+ "from the prompt to save space. Use `id` when you see '" + name + " id=<id>' or an id in the "
106
+ "archive index; use `query` to search (words, path, function name, error message) across "
107
+ "everything archived; use `path` for the history of operations on a file (reads, writes, edits, "
108
+ "with outcome and id). Runs on the server and is cheap: use it instead of guessing or re-running "
109
+ "commands." + (" Results and receipts may be in Italian." if lang == "it" else "")),
110
+ "parameters": {"type": "object", "properties": {
111
+ "id": {"type": "string", "description": "id of the archived block, e.g. r1a2b3c4d5e6"},
112
+ "query": {"type": "string", "description": "text to search in the archive (words or substring)"},
113
+ "path": {"type": "string", "description": "path (or name) of a file: history of operations"},
114
+ "offset": {"type": "integer", "description": "for long texts: character offset to continue from"},
115
+ }}}}
116
+ return {
117
+ "type": "function",
118
+ "function": {
119
+ "name": name,
120
+ "description": (
121
+ "Recupera il testo ORIGINALE ed esatto di un'uscita di strumento o di un messaggio che il gestore del "
122
+ "contesto ha tolto dal prompt per fare spazio. Usa `id` quando vedi '" + name + " id=<id>' o un "
123
+ "id nell'indice dell'archivio; usa `query` per cercare (parole, percorso, nome di funzione, messaggio "
124
+ "d'errore) fra tutto ciò che è stato archiviato; usa `path` per la storia delle operazioni su un file "
125
+ "(letture, scritture, modifiche, con esito e id). Eseguito dal server, costa poco: usalo invece di "
126
+ "indovinare o rieseguire comandi."),
127
+ "parameters": {
128
+ "type": "object",
129
+ "properties": {
130
+ "id": {"type": "string", "description": "id del blocco archiviato, es. r1a2b3c4d5e6"},
131
+ "query": {"type": "string", "description": "testo da cercare nell'archivio (parole o sottostringa)"},
132
+ "path": {"type": "string", "description": "percorso (o nome) di un file: storia delle operazioni"},
133
+ "offset": {"type": "integer", "description": "per testi lunghi: carattere da cui proseguire"},
134
+ },
135
+ },
136
+ },
137
+ }
138
+
139
+
140
+ RECALL_TOOL = recall_tool_def(RECALL_NAME)
141
+
142
+ NOTES_INSTRUCTION = NOTES_MARK + """
143
+ Il contesto sta per essere compattato: i messaggi più vecchi verranno archiviati (recuperabili con {rn}) e
144
+ dopo questo punto vedrai solo queste note più la parte recente della conversazione. Scrivi ORA le note di passaggio,
145
+ brevi (al massimo ~2500 token), fattuali, con questa struttura fissa:
146
+
147
+ ## Obiettivo (quasi letterale, come chiesto dall'utente)
148
+ ## Vincoli e preferenze espliciti dell'utente
149
+ ## Decisioni prese (cosa, perché, alternative scartate)
150
+ ## Stato attuale (file toccati, comandi eseguiti, risultati, numeri)
151
+ ## Problemi aperti / prossimi passi
152
+ ## Fatti puntuali da non perdere (percorsi, valori, nomi, id recall:<id> quando utili)
153
+
154
+ Non chiamare strumenti. Non inventare: se non sei sicuro, scrivi l'id recall da consultare."""
155
+ NOTES_INSTRUCTION_EN = NOTES_MARK_EN + """
156
+ The context is about to be compacted: the oldest messages will be archived (retrievable with {rn}) and
157
+ after this point you will see only these notes plus the recent part of the conversation. Write the handoff notes NOW,
158
+ short (at most ~2500 tokens), factual, with this fixed structure:
159
+
160
+ ## Goal (almost verbatim, as the user asked)
161
+ ## Explicit user constraints and preferences
162
+ ## Decisions taken (what, why, alternatives discarded)
163
+ ## Current state (files touched, commands run, results, numbers)
164
+ ## Open problems / next steps
165
+ ## Specific facts not to lose (paths, values, names, recall:<id> ids when useful)
166
+
167
+ Do not call tools. Do not invent: if you are not sure, write the recall id to check."""
168
+
169
+
170
+ @dataclasses.dataclass
171
+ class Config:
172
+ window: int = 131072 # W: finestra fisica di Strata (128K)
173
+ # livello 1: masking
174
+ mask_enabled: bool = True
175
+ mask_trigger: int = 80000 # si maschera (a pacchetto) solo quando il prompt fisico supera questa soglia
176
+ mask_target: int = 40000 # ...e si maschera dal più vecchio finché si scende sotto questa (isteresi)
177
+ min_mask_tokens: int = 200 # uscite più corte restano sempre in chiaro
178
+ keep_recent_tokens: int = 16000 # coda protetta (mai mascherata)
179
+ min_age_turns: int = 2 # un'uscita è mascherabile solo dopo almeno N messaggi assistant successivi
180
+ min_batch_tokens: int = 16000 # un pacchetto parte solo se libera almeno tanti token (niente micro-pacchetti)
181
+ # ancora di masking (LIVE-READY §1): dopo ogni pacchetto il proxy legge il prompt fisico fino alla "frontiera"
182
+ # (primo messaggio che un pacchetto futuro potrà ancora cambiare) e lo salva con /slots save; al pacchetto
183
+ # successivo lo ripristina, così Strata rilegge solo dalla frontiera e non da 16.384 (unico checkpoint rimasto)
184
+ mask_anchor: bool = False
185
+ anchor_min_tokens: int = 24000 # ancora utile solo se la frontiera è ben oltre il checkpoint radice di Strata
186
+ slot_dir: str = "" # cartella dei file slot di Strata (stesso host): pulizia delle ancore superate
187
+ mask_reasoning: bool = False # maschera anche il ragionamento (reasoning_content) dei turni assistant vecchi
188
+ mask_tool_args: bool = False # maschera anche gli argomenti grandi delle tool call vecchie (es. write di file)
189
+ # livello 2: segmenti
190
+ segments_enabled: bool = True
191
+ reserve: int = 8192 # riserva per operazioni (recall, note) nel controllo di finestra
192
+ default_response: int = 16384 # se la richiesta non dichiara max_tokens
193
+ response_floor: int = 0 # risposta minima assunta nel controllo di finestra (rigiochi con max_tokens piccolo)
194
+ data_dir: str = "" # se impostato e slot_save: salva qui il prompt fisico di A (per il consulto)
195
+ tail_max: int = 24000 # coda tenuta in B (token stimati)
196
+ notes_max_tokens: int = 8192
197
+ index_max_tokens: int = 2000
198
+ keep_first_user: bool = True
199
+ slot_save: bool = False # SAVE di A via /slots/0?action=save prima dello switch
200
+ seal_experimental: bool = False # "trucco del sigillo" (CLAUDE-ANSWER2 §1.2-a) prima del SAVE
201
+ # recall
202
+ inject_recall: bool = True
203
+ recall_max_chars: int = 24000
204
+ recall_max_tokens: int = 6000 # tetto in TOKEN per risultato recall (il limite in caratteri non basta)
205
+ max_recall_rounds: int = 4
206
+ # nomi degli strumenti del proxy per le conversazioni NUOVE (quelle nate prima della 0.2.0 tengono strata_recall /
207
+ # strata_tools); se il client dichiara già uno strumento con lo stesso nome se ne usa uno alternativo
208
+ recall_tool_name: str = RECALL_NAME
209
+ tools_tool_name: str = TOOLS_NAME
210
+ # recall strutturato (recall2.py, RECALL-RESULT.md): tutto spento di serie
211
+ recall_struct: bool = False # passaggi, intestazioni con collegamenti, mode timeline/first/tree..., filtri
212
+ recall_multi: bool = False # queries: [...] in una sola chiamata (fusione per rango)
213
+ recall_flex: bool = False # indice normalizzato (identificatori spezzati, radice leggera it/en)
214
+ recall_struct_max_tokens: int = 3500 # tetto in token del risultato strutturato
215
+ # definizioni strumenti accorciate (tooldefs.py, TOOLDEFS-RESULT.md): 0 = spento (default). Testi deterministici
216
+ # e stabili fra richieste; strumenti in tooldefs_keep mai toccati (recall è del proxy: già corto)
217
+ tooldefs_desc_max: int = 0 # caratteri massimi della description di uno strumento
218
+ tooldefs_param_max: int = 0 # caratteri massimi della description di ogni parametro (anche annidati)
219
+ tooldefs_keep: tuple = (RECALL_NAME, LEGACY_RECALL_NAME, "read", "bash", "edit", "write", "grep", "find", "ls") # usati sempre, corti
220
+ # strumenti su richiesta (card t_00c5aef6, paging.py): nel prompt solo tools_core + recall + tools;
221
+ # gli altri si caricano con lo strumento tools (definizione nel risultato, in coda: il prefisso non cambia)
222
+ tools_paging: bool = False
223
+ tools_core: tuple = ("read", "bash", "edit", "write", "grep", "find", "ls")
224
+ tools_load_max: int = 5
225
+ # ricevute tipizzate al posto dell'«Inizio» nei segnaposti delle uscite nascoste
226
+ typed_receipts: bool = True
227
+ receipt_guard: bool = True # blocca write/edit/bash che contengono ricevute/segnaposti copiati
228
+ # richiamo automatico bm25 (ricerca §6-8): a ogni nuova richiesta dell'utente, pezzi NASCOSTI pertinenti in coda
229
+ auto_recall: bool = False
230
+ auto_recall_on_error: bool = False # anche dopo un risultato di strumento fallito
231
+ auto_recall_k: int = 4 # pezzi massimi
232
+ auto_recall_max_tokens: int = 4000 # tetto del messaggio iniettato
233
+ auto_recall_piece_tokens: int = 1200 # tetto per pezzo
234
+ auto_recall_min_score: float = 8.0 # soglia su -bm25 (FTS5)
235
+ auto_recall_min_terms: int = 2 # termini distinti della query presenti nel pezzo (esclusi i comuni)
236
+ auto_recall_common_frac: float = 0.4 # termine comune se compare in più di questa quota dei pezzi trovati
237
+ auto_recall_max_args: int = 1 # argomenti di chiamata al massimo per iniezione
238
+ auto_recall_hint: bool = False # indizio (una riga con id/messaggi) al posto dei pezzi inseriti
239
+ auto_recall_hint_k: int = 5 # candidati citati nell'indizio
240
+ # punti fermi / usa e getta (NEXT.md 11-12)
241
+ pins_max_tokens: int = 8192 # tetto del blocco "Punti fermi" copiato al cambio di segmento
242
+ # stima token
243
+ chars_per_token: float = 3.5
244
+ msg_overhead: int = 6
245
+ image_tokens: int = 1024
246
+ reasoning_last_turn_only: bool = False # template Strata: preserve_thinking indefinito => tiene tutto
247
+ # salvataggio/ripristino automatico del segmento attivo (AUTOSAVE-RESULT.md)
248
+ autosave: bool = False # salva lo stato di Strata quando la conversazione che lo occupa resta ferma
249
+ autosave_idle_s: float = 180.0 # ...per almeno tanti secondi (e Strata è libero)
250
+ autosave_min_tokens: int = 16384 # sotto questa soglia rileggere costa poco: niente file
251
+ autosave_keep: int = 10 # file di autosalvataggio tenuti (i più recenti)
252
+ autosave_max_gb: float = 25.0 # tetto di disco per i file di autosalvataggio
253
+ autosave_min_free_gb: float = 8.0 # niente salvataggio se sul disco degli slot resta meno di così
254
+ autosave_poll_s: float = 10.0
255
+ autorestore: bool = True # (con autosave) restore prima di inoltrare se Strata non ha già il prefisso
256
+ autorestore_min_gain: int = 8192 # token di prefisso in più rispetto a quanto Strata ha già, per fare restore
257
+ # archivio freddo dei salvataggi (kvarchive.py): blocchi deduplicati + zstd, originale cancellato solo dopo
258
+ # ricostruzione verificata (sha256); restore di un file archiviato = ricostruzione prima
259
+ kv_archive: bool = False
260
+ kv_archive_dir: str = "" # vuoto = <cartella madre di slot_dir>/kv-archive
261
+ kv_archive_idle_s: float = 600.0 # Strata fermo (requests invariato, nessuna in_flight) da almeno tanto
262
+ kv_archive_min_age_s: float = 1800.0 # file non modificato da almeno tanto
263
+ kv_archive_min_bytes: int = 64 * 2 ** 20
264
+ kv_archive_poll_s: float = 60.0
265
+ kv_archive_level: int = 3
266
+ kv_archive_threads: int = 4
267
+ # osservabilità
268
+ live_dump: bool = False # scrive data_dir/live/last_request.json (prompt fisico + risposta) per la dashboard
269
+ # motore (engines.py): auto = rilevato all'avvio; strata | llama.cpp | openai lo forzano
270
+ engine: str = "auto"
271
+ slot_id: int = 0 # slot del motore (llama-server con --parallel N)
272
+ engine_tokenize: bool = False # conteggio token esatto via /tokenize del motore (llama.cpp), se non c'è --tokenizer
273
+ # ds4-server: ds4 rimette nel prompt le tool call campionate prese per id (exact DSML replay), quindi
274
+ # mask_tool_args non serve e viene spento; false solo se ds4-server gira con --disable-exact-dsml-tool-replay
275
+ ds4_exact_tool_replay: bool = True
276
+ # allineamento dei pacchetti di masking ai checkpoint su disco del motore (ds4: --kv-cache-continued-interval-
277
+ # tokens arrotondato a --kv-cache-boundary-align-tokens, di serie 10240). 0 = spento. Con N > 0 il primo
278
+ # messaggio cambiato da un pacchetto viene scelto, fra i primi candidati, in modo da cadere poco dopo un
279
+ # multiplo di N token (meno token da rileggere dopo l'ultimo checkpoint). Ha senso solo con --tokenizer esatto.
280
+ checkpoint_align_tokens: int = 0
281
+ checkpoint_align_slack: float = 0.25 # spreco accettato: frazione di N dopo il multiplo
282
+ # 0.3.1: language of the texts the proxy writes into the prompt (placeholders, receipts, notes, recall results)
283
+ # for NEW conversations: "en" (default) | "it". Saved per conversation at first use; conversations born before
284
+ # 0.3.1 keep Italian (byte-identical prefix). Markers are recognized in both languages everywhere.
285
+ prompt_language: str = "en"
286
+
287
+ @classmethod
288
+ def from_dict(cls, d: dict) -> "Config":
289
+ names = {f.name for f in dataclasses.fields(cls)}
290
+ return cls(**{k: v for k, v in d.items() if k in names})
291
+
292
+
293
+ def H(*parts: str) -> str:
294
+ h = hashlib.sha256()
295
+ for p in parts:
296
+ b = p.encode("utf-8", "surrogatepass")
297
+ h.update(len(b).to_bytes(8, "little")) # codifica senza ambiguità di concatenazione
298
+ h.update(b)
299
+ return h.hexdigest()
300
+
301
+
302
+ def content_text(content) -> str:
303
+ """Testo di un campo content OpenAI (stringa o lista di parti). Le immagini diventano un segnaposto con hash."""
304
+ if content is None:
305
+ return ""
306
+ if isinstance(content, str):
307
+ return content
308
+ out = []
309
+ for p in content:
310
+ if isinstance(p, str):
311
+ out.append(p)
312
+ elif isinstance(p, dict):
313
+ t = p.get("type")
314
+ if t in ("text", "input_text", "output_text"):
315
+ out.append(p.get("text") or "")
316
+ elif t in ("image_url", "input_image", "image"):
317
+ url = p.get("image_url")
318
+ url = url.get("url") if isinstance(url, dict) else (url or p.get("data") or "")
319
+ out.append("<immagine:%s>" % H(str(url))[:16])
320
+ return "\n".join(out)
321
+
322
+
323
+ def n_images(content) -> int:
324
+ if isinstance(content, list):
325
+ return sum(1 for p in content if isinstance(p, dict) and p.get("type") in ("image_url", "input_image", "image"))
326
+ return 0
327
+
328
+
329
+ def _args(a):
330
+ if isinstance(a, str):
331
+ try:
332
+ return json.loads(a)
333
+ except ValueError:
334
+ return a
335
+ return a
336
+
337
+
338
+ def canon(m: dict) -> str:
339
+ """Forma canonica di un messaggio per la catena di hash. reasoning_content escluso (molti client non lo
340
+ rimandano identico); ordine delle chiavi JSON degli argomenti normalizzato; \\r\\n e spazi in coda normalizzati."""
341
+ d = {"role": m.get("role"), "content": content_text(m.get("content")).replace("\r\n", "\n").rstrip()}
342
+ if m.get("tool_calls"):
343
+ d["tool_calls"] = [{"id": c.get("id"), "name": (c.get("function") or {}).get("name"),
344
+ "arguments": _args((c.get("function") or {}).get("arguments"))} for c in m["tool_calls"]]
345
+ if m.get("tool_call_id"):
346
+ d["tool_call_id"] = m["tool_call_id"]
347
+ if m.get("name"):
348
+ d["name"] = m["name"]
349
+ return json.dumps(d, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
350
+
351
+
352
+ def chain(messages: list, tools: list | None) -> list[str]:
353
+ h = H("tools", json.dumps(tools or [], sort_keys=True, ensure_ascii=False))
354
+ out = []
355
+ for m in messages:
356
+ h = H(h, canon(m))
357
+ out.append(h)
358
+ return out
359
+
360
+
361
+ def rid_for(idx: int, m: dict) -> str:
362
+ return "r" + H(str(idx), canon(m))[:12]
363
+
364
+
365
+ def tid_for(idx: int, m: dict) -> str:
366
+ """id d'archivio del ragionamento di un messaggio assistant."""
367
+ return "t" + H("think", str(idx), canon(m), m.get("reasoning_content") or "")[:12]
368
+
369
+
370
+ THINK_SUFFIX = ":think"
371
+ ARGS_SUFFIX = ":args"
372
+ DROP_SUFFIX = ":drop"
373
+ AUTO_SUFFIX = ":auto"
374
+
375
+
376
+ def aid_for(idx: int, m: dict) -> str:
377
+ """id d'archivio degli argomenti delle tool call di un messaggio assistant."""
378
+ return "a" + H("args", str(idx), canon(m))[:12]
379
+
380
+
381
+ def args_text(m: dict) -> str:
382
+ out = []
383
+ for c in m.get("tool_calls") or []:
384
+ f = c.get("function") or {}
385
+ a = f.get("arguments")
386
+ out.append("%s %s" % (f.get("name"), a if isinstance(a, str) else json.dumps(a, ensure_ascii=False)))
387
+ return "\n".join(out)
388
+
389
+
390
+ ARGS_KEEP_CHARS = 160
391
+ _FAKE_OMIT = re.compile(r"\[(?:contenuto|argomenti|uscita strumento|ragionamento) omess[oaie][^\]]*\]"
392
+ r"|\[gestore del contesto:[^\]]*\]"
393
+ r"|\[(?:content|arguments|tool output|reasoning) omitted[^\]]*\]"
394
+ r"|\[context manager:[^\]]*\]")
395
+
396
+
397
+ def masked_calls(m: dict, rid: str, n: int) -> list:
398
+ """Le tool call con i valori VOLUMINOSI accorciati a testo vero (prime righe + '…'), senza formule:
399
+ nome, id, percorso e parametri piccoli restano. La nota con recall va nel RISULTATO dello strumento
400
+ (vedi call_notes), che il modello legge ma non scrive. Fix live 4/10: i segnaposti negli argomenti
401
+ venivano imitati dal modello nei suoi comandi nuovi."""
402
+ out = []
403
+ for c in m.get("tool_calls") or []:
404
+ f = dict(c.get("function") or {})
405
+ a = f.get("arguments")
406
+ try:
407
+ args = json.loads(a) if isinstance(a, str) else dict(a or {})
408
+ except Exception:
409
+ args = None
410
+ if isinstance(args, dict):
411
+ kept = {}
412
+ for k, v in args.items():
413
+ s = v if isinstance(v, str) else json.dumps(v, ensure_ascii=False)
414
+ kept[k] = (s[:ARGS_KEEP_CHARS].rstrip() + "\n…") if len(s) > 300 else v
415
+ f["arguments"] = json.dumps(kept, ensure_ascii=False)
416
+ else:
417
+ s = a if isinstance(a, str) else json.dumps(a, ensure_ascii=False)
418
+ f["arguments"] = s[:ARGS_KEEP_CHARS].rstrip() + "\n…"
419
+ out.append({**c, "function": f})
420
+ return out
421
+
422
+
423
+ def call_notes(m: dict, rid: str) -> dict:
424
+ """tool_call_id -> (nome, percorso, rid) delle chiamate i cui argomenti voluminosi vengono accorciati: build()
425
+ antepone al risultato dello strumento una nota con operazione, file, ESITO (letto dal risultato) e recall."""
426
+ notes = {}
427
+ for c in m.get("tool_calls") or []:
428
+ f = c.get("function") or {}
429
+ a = f.get("arguments")
430
+ try:
431
+ args = json.loads(a) if isinstance(a, str) else dict(a or {})
432
+ except Exception:
433
+ args = {}
434
+ big = [k for k, v in (args or {}).items()
435
+ if len(v if isinstance(v, str) else json.dumps(v, ensure_ascii=False)) > 300]
436
+ if not big and isinstance(args, dict):
437
+ continue
438
+ path = (args or {}).get("path") or (args or {}).get("file_path") or ""
439
+ notes[c.get("id")] = (f.get("name") or "", path, rid)
440
+ return notes
441
+
442
+
443
+ def call_note_text(name: str, path: str, rid: str, result: str, rn: str = RECALL_NAME, lang: str = "it") -> str:
444
+ """GPT-ANSWER3 §game.js: operazione, file, esito accertato o no, accesso allo storico, niente 'modifiche
445
+ esterne' inventate."""
446
+ esito = outcome(name, result)
447
+ if lang != "it":
448
+ what = "%s%s" % (name, (" on " + path) if path else "")
449
+ if esito == "riuscito":
450
+ e = "outcome: succeeded (see the result below)"
451
+ elif esito == "fallito":
452
+ e = "outcome: FAILED (see the result below): that content was NOT applied"
453
+ else:
454
+ e = "outcome not verified"
455
+ return ("(context manager note: call %s made by you in this session, %s. The large arguments are "
456
+ "shortened in the prompt to save space; original text: %s id=%s. For the current state of the "
457
+ "file re-read it; if it differs from what you remember, it is because of your later edits or a "
458
+ "failed outcome: no external change was detected.)\n" % (what, e, rn, rid))
459
+ what = "%s%s" % (name, (" su " + path) if path else "")
460
+ if esito == "riuscito":
461
+ e = "esito: riuscito (vedi il risultato qui sotto)"
462
+ elif esito == "fallito":
463
+ e = "esito: FALLITO (vedi il risultato qui sotto): quel contenuto NON è stato applicato"
464
+ else:
465
+ e = "esito non verificato"
466
+ return ("(nota del gestore del contesto: chiamata %s fatta da te in questa sessione, %s. Gli argomenti voluminosi "
467
+ "sono accorciati nel prompt per spazio; testo originale: %s id=%s. Per lo stato attuale del "
468
+ "file rileggilo; se differisce da ciò che ricordi, è per tue modifiche successive o per un esito "
469
+ "fallito: nessuna modifica esterna è stata rilevata.)\n" % (what, e, rn, rid))
470
+
471
+
472
+ def is_human_user(m: dict) -> bool:
473
+ if m.get("role") != "user":
474
+ return False
475
+ t = content_text(m.get("content")).strip()
476
+ return not (t.startswith("<tool_response>") and t.endswith("</tool_response>"))
477
+
478
+
479
+ # ---------- 📌 punti fermi / 🗑 usa e getta (NEXT.md 11-12) ----------
480
+ _PIN_LINE = re.compile(r"^[ \t]*(?:\U0001F4CC|!!)[ \t]*(.+)$", re.M)
481
+ _PIN_BLOCK = re.compile(r"\[\[importante\]\](.*?)\[\[/importante\]\]", re.S | re.I)
482
+ _DROP_LINE = re.compile(r"^[ \t]*(?:\U0001F5D1\uFE0F?|~)(?!~)[ \t]*\S", re.M)
483
+
484
+
485
+ def pin_texts(text: str) -> list[str]:
486
+ """Punti fermi scritti dall'utente: righe che iniziano con 📌 o !!, blocchi [[importante]]…[[/importante]]."""
487
+ out = [m.group(1).strip() for m in _PIN_BLOCK.finditer(text or "") if m.group(1).strip()]
488
+ rest = _PIN_BLOCK.sub("", text or "")
489
+ out += [m.group(1).strip() for m in _PIN_LINE.finditer(rest) if m.group(1).strip()]
490
+ return out
491
+
492
+
493
+ def is_disposable(text: str) -> bool:
494
+ """Messaggio utente 'usa e getta': una riga che inizia con 🗑 o ~ (non ~~, che è markdown barrato)."""
495
+ return bool(_DROP_LINE.search(text or ""))
496
+
497
+
498
+ def call_info(m: dict) -> dict:
499
+ """tool_call_id -> {name, args(dict), path, cmd} per le chiamate di un messaggio assistant."""
500
+ out = {}
501
+ for c in m.get("tool_calls") or []:
502
+ f = c.get("function") or {}
503
+ a = _args(f.get("arguments"))
504
+ a = a if isinstance(a, dict) else {}
505
+ path = a.get("path") or a.get("file_path") or a.get("filePath") or ""
506
+ cmd = a.get("command") or a.get("cmd") or ""
507
+ out[c.get("id")] = {"name": f.get("name") or "?", "args": a, "path": str(path), "cmd": str(cmd)}
508
+ return out
509
+
510
+
511
+ _FAIL = re.compile(r"RETRYABLE|not found in the current file|^Error|\bError:|Traceback|No such file|command not found|"
512
+ r"exit code [1-9]|exited with code [1-9]|ENOENT|EACCES", re.M)
513
+
514
+
515
+ def outcome(name: str, result: str, is_error: bool | None = None) -> str:
516
+ """Esito di una chiamata dal testo del risultato: 'riuscito' | 'fallito' | 'non verificato'."""
517
+ r = (result or "")[:600]
518
+ if is_error or _FAIL.search(r):
519
+ return "fallito"
520
+ if re.match(r"\s*(Successfully|Created|Updated|Wrote|OK\b)", r):
521
+ return "riuscito"
522
+ return "riuscito" if name in ("read",) and r.strip() else "non verificato"
523
+
524
+
525
+ def origin_text(info: dict | None, lang: str = "it") -> str:
526
+ """Descrizione breve dell'origine di un'uscita: «read src/game.js», «bash `npm test`»."""
527
+ if not info:
528
+ return "uno strumento" if lang == "it" else "a tool"
529
+ if info["path"]:
530
+ return "%s %s" % (info["name"], info["path"])
531
+ if info["cmd"]:
532
+ c = " ".join(info["cmd"].split())
533
+ return "%s `%s`" % (info["name"], c[:90] + ("…" if len(c) > 90 else ""))
534
+ a = json.dumps(info["args"], ensure_ascii=False)
535
+ return "%s %s" % (info["name"], a[:80] + ("…" if len(a) > 80 else ""))
536
+
537
+
538
+ def head_text(text: str, n: int = 140) -> str:
539
+ t = " ".join((text or "").split())
540
+ return t[:n] + ("…" if len(t) > n else "")
541
+
542
+
543
+ def receipt(info: dict | None, result: str, is_error=None, lang: str = "it") -> str:
544
+ """Ricevuta di una riga degli effetti di una chiamata: «write src/a.js: riuscito»."""
545
+ if not info:
546
+ return "?"
547
+ return "%s: %s" % (origin_text(info, lang), outcome_text(outcome(info["name"], result, is_error), lang))
548
+
549
+
550
+ class TokenCounter:
551
+ """Stima token: tokenizer HF (tokenizers) se disponibile, altrimenti caratteri / chars_per_token.
552
+ `scale` si calibra con i prompt_tokens reali restituiti da Strata."""
553
+
554
+ def __init__(self, chars_per_token: float = 3.5, tokenizer_path: str | None = None):
555
+ self.cpt = chars_per_token
556
+ self.scale = 1.0
557
+ self.tk = None
558
+ self.kind = "chars/%.1f" % chars_per_token
559
+ if tokenizer_path:
560
+ try:
561
+ self.tk = load_tokenizer(tokenizer_path)
562
+ self.kind = "esatto:" + os.path.basename(os.path.abspath(tokenizer_path).rstrip("/"))
563
+ except Exception as e: # noqa: BLE001
564
+ self.kind += " (tokenizer non caricato: %s)" % e
565
+ self._cache: dict[str, int] = {}
566
+
567
+ @property
568
+ def exact(self) -> bool:
569
+ return self.tk is not None
570
+
571
+ def raw(self, text: str) -> int:
572
+ if not text:
573
+ return 0
574
+ if self.tk is None:
575
+ return int(len(text) / self.cpt + 0.5)
576
+ key = H(text) if len(text) > 256 else text
577
+ n = self._cache.get(key)
578
+ if n is None:
579
+ n = len(self.tk.encode(text, add_special_tokens=False).ids)
580
+ if len(self._cache) > 200000:
581
+ self._cache.clear()
582
+ self._cache[key] = n
583
+ return n
584
+
585
+ def count(self, text: str) -> int:
586
+ return int(self.raw(text) * self.scale + 0.5)
587
+
588
+ def calibrate(self, estimated: int, real: int, alpha: float = 0.3) -> None:
589
+ if self.tk is not None:
590
+ return # conteggio esatto (template + tokenizer del pack): nessuna scala da correggere
591
+ if estimated > 1000 and real > 0:
592
+ r = real / estimated
593
+ self.scale = min(2.0, max(0.5, self.scale * ((1 - alpha) + alpha * r)))
594
+
595
+
596
+ def load_tokenizer(path: str):
597
+ """Tokenizer HF (`tokenizers`) per il conteggio esatto. Accetta un tokenizer.json HF oppure la cartella
598
+ tokenizer del pack di Strata (vocab.json + merges.txt + token_type.json + descrittore tokenizer.json, che NON è
599
+ in formato HF): in quel caso lo costruisce (tools/make_hf_tokenizer.py, verificato identico a Strata)."""
600
+ from tokenizers import Tokenizer # type: ignore
601
+ p = path
602
+ if os.path.isfile(p):
603
+ try:
604
+ return Tokenizer.from_file(p)
605
+ except Exception: # noqa: BLE001 descrittore del pack: si costruisce dalla cartella
606
+ p = os.path.dirname(os.path.abspath(p))
607
+ if os.path.isdir(p) and os.path.exists(os.path.join(p, "vocab.json")):
608
+ from .packtok import build
609
+ return build(p)
610
+ raise ValueError("tokenizer non riconosciuto: %s" % path)
611
+
612
+
613
+ class Journal:
614
+ def __init__(self, path: str | None):
615
+ self.path = path
616
+ self.lock = threading.Lock()
617
+ self.mem: list[dict] = []
618
+ if path:
619
+ os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
620
+
621
+ def log(self, event: str, **kw) -> dict:
622
+ rec = {"ts": round(time.time(), 3), "event": event, **kw}
623
+ with self.lock:
624
+ self.mem.append(rec)
625
+ if len(self.mem) > 5000:
626
+ del self.mem[:1000]
627
+ if self.path:
628
+ with open(self.path, "a", encoding="utf-8") as f:
629
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
630
+ f.flush()
631
+ os.fsync(f.fileno())
632
+ return rec
633
+
634
+
635
+ SCHEMA = """
636
+ CREATE TABLE IF NOT EXISTS archive(rid TEXT PRIMARY KEY, conv TEXT, idx INT, role TEXT, name TEXT,
637
+ content TEXT, tokens INT, created REAL);
638
+ CREATE INDEX IF NOT EXISTS archive_conv ON archive(conv, idx);
639
+ CREATE TABLE IF NOT EXISTS chains(h TEXT PRIMARY KEY, conv TEXT, idx INT);
640
+ CREATE TABLE IF NOT EXISTS masks(h TEXT PRIMARY KEY, conv TEXT, idx INT, rid TEXT, tokens INT, created REAL);
641
+ CREATE TABLE IF NOT EXISTS segments(conv TEXT, seg INT, cut_idx INT, cut_h TEXT, notes_msg TEXT, kind TEXT,
642
+ created REAL, PRIMARY KEY(conv, seg));
643
+ CREATE TABLE IF NOT EXISTS inserts(h TEXT PRIMARY KEY, conv TEXT, msgs TEXT, strip_content TEXT,
644
+ strip_reasoning TEXT, created REAL);
645
+ CREATE TABLE IF NOT EXISTS anchors(conv TEXT, seg INT, idx INT, phash TEXT, file TEXT, tokens INT, created REAL,
646
+ PRIMARY KEY(conv, seg));
647
+ CREATE TABLE IF NOT EXISTS pins(id INTEGER PRIMARY KEY AUTOINCREMENT, conv TEXT, idx INT, text TEXT, created REAL,
648
+ active INT DEFAULT 1);
649
+ CREATE TABLE IF NOT EXISTS autosaves(file TEXT PRIMARY KEY, conv TEXT, seg INT, phash TEXT, plen INT,
650
+ tokens INT, bytes INT, n_saved INT, created REAL, last_used REAL, deleted REAL);
651
+ CREATE TABLE IF NOT EXISTS kv(k TEXT PRIMARY KEY, v TEXT);
652
+ CREATE TABLE IF NOT EXISTS drops(conv TEXT, idx INT, created REAL, PRIMARY KEY(conv, idx));
653
+ CREATE TABLE IF NOT EXISTS fileops(conv TEXT, idx INT, call_id TEXT, op TEXT, path TEXT, outcome TEXT, rid_args TEXT,
654
+ rid_out TEXT, head TEXT, PRIMARY KEY(conv, idx, call_id));
655
+ """
656
+ # Ricerca (GPT-ANSWER3 §ricerca): indice FTS5 a contenuto esterno sull'archivio, aggiornato da trigger.
657
+ FTS_SCHEMA = """
658
+ CREATE VIRTUAL TABLE IF NOT EXISTS archive_fts USING fts5(content, content='archive', content_rowid='rowid',
659
+ tokenize='unicode61 remove_diacritics 2');
660
+ CREATE TRIGGER IF NOT EXISTS archive_ai AFTER INSERT ON archive BEGIN
661
+ INSERT INTO archive_fts(rowid, content) VALUES (new.rowid, new.content); END;
662
+ """
663
+ MIGRATIONS = ["ALTER TABLE masks ADD COLUMN saved INT", "ALTER TABLE segments ADD COLUMN pins_msg TEXT"]
664
+
665
+
666
+ class Store:
667
+ """Archivio SQLite: testo originale (fonte di verità), mappa hash->conversazione, decisioni stabili."""
668
+
669
+ def __init__(self, path: str = ":memory:"):
670
+ if path != ":memory:":
671
+ os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
672
+ self.db = sqlite3.connect(path, check_same_thread=False, isolation_level=None)
673
+ self.db.execute("PRAGMA journal_mode=WAL" if path != ":memory:" else "PRAGMA journal_mode=MEMORY")
674
+ self.db.executescript(SCHEMA)
675
+ for sql in MIGRATIONS:
676
+ try:
677
+ self.db.execute(sql)
678
+ except sqlite3.OperationalError:
679
+ pass # colonna già presente
680
+ self.fts = True
681
+ try:
682
+ self.db.executescript(FTS_SCHEMA)
683
+ n_fts = self.db.execute("SELECT count(*) FROM archive_fts_docsize").fetchone()[0]
684
+ n_arc = self.db.execute("SELECT count(*) FROM archive").fetchone()[0]
685
+ if n_arc and n_fts < n_arc: # archivio di una versione precedente: indicizza tutto una volta
686
+ self.db.execute("INSERT INTO archive_fts(archive_fts) VALUES('rebuild')")
687
+ except sqlite3.OperationalError:
688
+ self.fts = False # SQLite senza FTS5: resta la ricerca per sottostringa
689
+ self.lock = threading.RLock()
690
+
691
+ def _in(self, sql: str, keys: list, extra: tuple = ()):
692
+ out = []
693
+ for i in range(0, len(keys), 500):
694
+ part = keys[i:i + 500]
695
+ q = sql.replace("(?)", "(" + ",".join("?" * len(part)) + ")")
696
+ out.extend(self.db.execute(q, (*extra, *part)).fetchall())
697
+ return out
698
+
699
+ def find_conv(self, hs: list[str]) -> tuple[str | None, int]:
700
+ """Conversazione dell'ultimo hash noto della catena, e la sua posizione (-1 se nessuno)."""
701
+ with self.lock:
702
+ rows = self._in("SELECT h, conv, idx FROM chains WHERE h IN (?)", hs)
703
+ if not rows:
704
+ return None, -1
705
+ best = max(rows, key=lambda r: r[2])
706
+ return best[1], best[2]
707
+
708
+ def add_chain(self, conv: str, hs: list[str]) -> None:
709
+ with self.lock:
710
+ self.db.executemany("INSERT OR IGNORE INTO chains VALUES(?,?,?)", [(h, conv, i) for i, h in enumerate(hs)])
711
+
712
+ def has_successor(self, conv: str, idx: int) -> bool:
713
+ with self.lock:
714
+ return self.db.execute("SELECT 1 FROM chains WHERE conv=? AND idx=? LIMIT 1",
715
+ (conv, idx + 1)).fetchone() is not None
716
+
717
+ def archive_many(self, rows: list[tuple]) -> None:
718
+ with self.lock:
719
+ self.db.executemany("INSERT OR IGNORE INTO archive VALUES(?,?,?,?,?,?,?,?)", rows)
720
+
721
+ def get(self, rid: str, conv: str | None = None):
722
+ with self.lock:
723
+ if conv:
724
+ return self.db.execute("SELECT rid, conv, idx, role, name, content, tokens FROM archive "
725
+ "WHERE rid=? AND conv=?", (rid, conv)).fetchone()
726
+ return self.db.execute("SELECT rid, conv, idx, role, name, content, tokens FROM archive WHERE rid=?",
727
+ (rid,)).fetchone()
728
+
729
+ def search(self, conv: str, query: str, limit: int = 5, max_idx: int | None = None):
730
+ """Sottostringa esatta (identificatori, percorsi), più recenti prima. max_idx: esclude i messaggi con indice
731
+ >= max_idx (la domanda corrente e i suoi rami fratelli, che il modello vede già)."""
732
+ with self.lock:
733
+ return self.db.execute(
734
+ "SELECT rid, idx, role, name, content, tokens FROM archive WHERE conv=? AND instr(lower(content), "
735
+ "lower(?)) > 0 AND idx < ? ORDER BY idx DESC LIMIT ?",
736
+ (conv, query, 10 ** 9 if max_idx is None else max_idx, limit)).fetchall()
737
+
738
+ def search_fts(self, conv: str, query: str, limit: int = 20, max_idx: int | None = None):
739
+ """FTS5 con ranking bm25 sulle parole della domanda (OR): i pezzi con più parole/più rare prima."""
740
+ if not self.fts:
741
+ return []
742
+ terms = [t for t in re.findall(r"\w+", query or "") if len(t) > 1][:16]
743
+ if not terms:
744
+ return []
745
+ q = " OR ".join('"%s"' % t.replace('"', "") for t in terms)
746
+ with self.lock:
747
+ try:
748
+ return self.db.execute(
749
+ "SELECT a.rid, a.idx, a.role, a.name, a.content, a.tokens FROM archive_fts f "
750
+ "JOIN archive a ON a.rowid = f.rowid WHERE archive_fts MATCH ? AND a.conv=? AND a.idx < ? "
751
+ "ORDER BY bm25(archive_fts) LIMIT ?",
752
+ (q, conv, 10 ** 9 if max_idx is None else max_idx, limit)).fetchall()
753
+ except sqlite3.OperationalError:
754
+ return []
755
+
756
+ def search_fts_scored(self, conv: str, terms: list[str], limit: int = 50, max_idx: int | None = None):
757
+ """FTS5 OR dei termini dati -> righe (rid, idx, role, name, content, tokens, score) con score = -bm25 (più
758
+ alto = più pertinente), ordinate per score e poi per idx (deterministico)."""
759
+ if not self.fts or not terms:
760
+ return []
761
+ q = " OR ".join('"%s"' % t.replace('"', "") for t in terms if t)
762
+ with self.lock:
763
+ try:
764
+ rows = self.db.execute(
765
+ "SELECT a.rid, a.idx, a.role, a.name, a.content, a.tokens, -bm25(archive_fts) AS s "
766
+ "FROM archive_fts f JOIN archive a ON a.rowid = f.rowid WHERE archive_fts MATCH ? AND a.conv=? "
767
+ "AND a.idx < ? ORDER BY s DESC, a.idx DESC LIMIT ?",
768
+ (q, conv, 10 ** 9 if max_idx is None else max_idx, limit)).fetchall()
769
+ except sqlite3.OperationalError:
770
+ return []
771
+ return rows
772
+
773
+ # ---------- punti fermi / usa e getta ----------
774
+ def pins(self, conv: str, active_only: bool = True):
775
+ with self.lock:
776
+ return self.db.execute("SELECT id, idx, text, created, active FROM pins WHERE conv=?" +
777
+ (" AND active=1" if active_only else "") + " ORDER BY idx, id", (conv,)).fetchall()
778
+
779
+ def has_pin(self, conv: str, idx: int, text: str) -> bool:
780
+ with self.lock:
781
+ return self.db.execute("SELECT 1 FROM pins WHERE conv=? AND idx=? AND text=?",
782
+ (conv, idx, text)).fetchone() is not None
783
+
784
+ def add_pin(self, conv: str, idx: int, text: str) -> int:
785
+ with self.lock:
786
+ return self.db.execute("INSERT INTO pins(conv, idx, text, created, active) VALUES(?,?,?,?,1)",
787
+ (conv, idx, text, time.time())).lastrowid
788
+
789
+ def update_pin(self, pid: int, text: str | None = None, active: bool | None = None) -> bool:
790
+ with self.lock:
791
+ if text is not None:
792
+ self.db.execute("UPDATE pins SET text=? WHERE id=?", (text, pid))
793
+ if active is not None:
794
+ self.db.execute("UPDATE pins SET active=? WHERE id=?", (1 if active else 0, pid))
795
+ return self.db.execute("SELECT 1 FROM pins WHERE id=?", (pid,)).fetchone() is not None
796
+
797
+ def drops(self, conv: str) -> set[int]:
798
+ with self.lock:
799
+ return {r[0] for r in self.db.execute("SELECT idx FROM drops WHERE conv=?", (conv,))}
800
+
801
+ def add_drop(self, conv: str, idx: int) -> None:
802
+ with self.lock:
803
+ self.db.execute("INSERT OR IGNORE INTO drops VALUES(?,?,?)", (conv, idx, time.time()))
804
+
805
+ def del_drop(self, conv: str, idx: int) -> None:
806
+ with self.lock:
807
+ self.db.execute("DELETE FROM drops WHERE conv=? AND idx=?", (conv, idx))
808
+
809
+ def user_messages(self, conv: str):
810
+ with self.lock:
811
+ return self.db.execute("SELECT idx, content FROM archive WHERE conv=? AND role='user' ORDER BY idx",
812
+ (conv,)).fetchall()
813
+
814
+ def segment_pins(self, conv: str, seg: int) -> str | None:
815
+ with self.lock:
816
+ r = self.db.execute("SELECT pins_msg FROM segments WHERE conv=? AND seg=?", (conv, seg)).fetchone()
817
+ return r[0] if r else None
818
+
819
+ def set_segment_pins(self, conv: str, seg: int, pins_msg: str | None) -> None:
820
+ with self.lock:
821
+ self.db.execute("UPDATE segments SET pins_msg=? WHERE conv=? AND seg=?", (pins_msg, conv, seg))
822
+
823
+ def del_masks(self, keys: list[str]) -> None:
824
+ with self.lock:
825
+ self._in("DELETE FROM masks WHERE h IN (?)", keys)
826
+
827
+ def add_fileops(self, rows: list[tuple]) -> int:
828
+ """rows: (conv, idx, call_id, op, path, outcome, rid_args, rid_out, head). -> righe nuove."""
829
+ with self.lock:
830
+ before = self.db.total_changes
831
+ self.db.executemany("INSERT OR IGNORE INTO fileops VALUES(?,?,?,?,?,?,?,?,?)", rows)
832
+ return self.db.total_changes - before
833
+
834
+ def fileops(self, conv: str, path: str, max_idx: int | None = None):
835
+ p = (path or "").strip()
836
+ with self.lock:
837
+ return self.db.execute(
838
+ "SELECT idx, op, path, outcome, rid_args, rid_out, head FROM fileops WHERE conv=? AND idx < ? AND "
839
+ "(path=? OR path LIKE ? OR ? LIKE '%' || path) ORDER BY idx",
840
+ (conv, 10 ** 9 if max_idx is None else max_idx, p, "%" + p, p)).fetchall()
841
+
842
+ def anchor(self, conv: str, seg: int):
843
+ with self.lock:
844
+ return self.db.execute("SELECT idx, phash, file, tokens FROM anchors WHERE conv=? AND seg=?",
845
+ (conv, seg)).fetchone()
846
+
847
+ def set_anchor(self, conv: str, seg: int, idx: int, phash: str, file: str, tokens: int) -> None:
848
+ with self.lock:
849
+ self.db.execute("INSERT OR REPLACE INTO anchors VALUES(?,?,?,?,?,?,?)",
850
+ (conv, seg, idx, phash, file, tokens, time.time()))
851
+
852
+ # ---------- autosalvataggi (autosave.py) ----------
853
+ AS_COLS = ("file", "conv", "seg", "phash", "plen", "tokens", "bytes", "n_saved", "created", "last_used", "deleted")
854
+
855
+ def kv_get(self, k: str):
856
+ with self.lock:
857
+ r = self.db.execute("SELECT v FROM kv WHERE k=?", (k,)).fetchone()
858
+ return r[0] if r else None
859
+
860
+ def kv_set(self, k: str, v: str) -> None:
861
+ with self.lock:
862
+ self.db.execute("INSERT OR REPLACE INTO kv VALUES(?,?)", (k, v))
863
+
864
+ def add_autosave(self, file, conv, seg, phash, plen, tokens, nbytes, n_saved) -> None:
865
+ now = time.time()
866
+ with self.lock:
867
+ self.db.execute("INSERT OR REPLACE INTO autosaves VALUES(?,?,?,?,?,?,?,?,?,?,NULL)",
868
+ (file, conv, seg, phash, plen, tokens, nbytes, n_saved, now, now))
869
+
870
+ def autosaves(self, active_only: bool = True) -> list[dict]:
871
+ """Autosalvataggi, il più recente (creato o usato) per primo."""
872
+ with self.lock:
873
+ rows = self.db.execute("SELECT %s FROM autosaves %s ORDER BY max(created, coalesce(last_used, 0)) DESC"
874
+ % (",".join(self.AS_COLS), "WHERE deleted IS NULL" if active_only else "")
875
+ ).fetchall()
876
+ return [dict(zip(self.AS_COLS, r)) for r in rows]
877
+
878
+ def autosave_with_phash(self, phash: str):
879
+ with self.lock:
880
+ return self.db.execute("SELECT file FROM autosaves WHERE phash=? AND deleted IS NULL",
881
+ (phash,)).fetchone()
882
+
883
+ def autosave_state(self, file: str) -> dict | None:
884
+ with self.lock:
885
+ r = self.db.execute("SELECT phash, plen, tokens, conv, seg FROM autosaves WHERE file=?", (file,)).fetchone()
886
+ return dict(zip(("phash", "plen", "tokens", "conv", "seg"), r)) if r else None
887
+
888
+ def autosave_used(self, file: str) -> None:
889
+ with self.lock:
890
+ self.db.execute("UPDATE autosaves SET last_used=? WHERE file=?", (time.time(), file))
891
+
892
+ def autosave_deleted(self, file: str) -> None:
893
+ with self.lock:
894
+ self.db.execute("UPDATE autosaves SET deleted=? WHERE file=?", (time.time(), file))
895
+
896
+ def masks_for(self, hs: list[str]) -> dict[str, tuple[str, int]]:
897
+ with self.lock:
898
+ return {h: (rid, n) for h, rid, n in self._in("SELECT h, rid, tokens FROM masks WHERE h IN (?)", hs)}
899
+
900
+ def masks_saved(self, hs: list[str]) -> int:
901
+ with self.lock:
902
+ r = self._in("SELECT coalesce(sum(saved), 0) FROM masks WHERE h IN (?)", hs)
903
+ return sum(x[0] or 0 for x in r)
904
+
905
+ def add_masks(self, rows: list[tuple]) -> None:
906
+ """rows: (h, conv, idx, rid, tokens, created[, saved])"""
907
+ with self.lock:
908
+ self.db.executemany("INSERT OR IGNORE INTO masks(h, conv, idx, rid, tokens, created, saved) "
909
+ "VALUES(?,?,?,?,?,?,?)", [tuple(r) + (None,) * (7 - len(r)) for r in rows])
910
+
911
+ def segments(self, conv: str):
912
+ with self.lock:
913
+ return self.db.execute("SELECT seg, cut_idx, cut_h, notes_msg, kind FROM segments WHERE conv=? "
914
+ "ORDER BY seg DESC", (conv,)).fetchall()
915
+
916
+ def add_segment(self, conv, seg, cut_idx, cut_h, notes_msg, kind) -> None:
917
+ with self.lock:
918
+ self.db.execute("INSERT OR REPLACE INTO segments(conv, seg, cut_idx, cut_h, notes_msg, kind, created) "
919
+ "VALUES(?,?,?,?,?,?,?)",
920
+ (conv, seg, cut_idx, cut_h, notes_msg, kind, time.time()))
921
+
922
+ def inserts_for(self, hs: list[str]) -> dict[str, dict]:
923
+ with self.lock:
924
+ rows = self._in("SELECT h, msgs, strip_content, strip_reasoning FROM inserts WHERE h IN (?)", hs)
925
+ return {h: {"msgs": json.loads(m), "strip_content": sc or "", "strip_reasoning": sr or ""}
926
+ for h, m, sc, sr in rows}
927
+
928
+ def add_insert(self, h, conv, msgs, strip_content="", strip_reasoning="") -> None:
929
+ with self.lock:
930
+ self.db.execute("INSERT OR REPLACE INTO inserts VALUES(?,?,?,?,?,?)",
931
+ (h, conv, json.dumps(msgs, ensure_ascii=False), strip_content, strip_reasoning,
932
+ time.time()))
933
+
934
+ def has_conv(self, conv: str) -> bool:
935
+ with self.lock:
936
+ return self.db.execute("SELECT 1 FROM chains WHERE conv=? LIMIT 1", (conv,)).fetchone() is not None
937
+
938
+ def stats(self, conv: str) -> dict:
939
+ with self.lock:
940
+ a = self.db.execute("SELECT count(*), coalesce(sum(tokens),0) FROM archive WHERE conv=?", (conv,)).fetchone()
941
+ m = self.db.execute("SELECT count(*), coalesce(sum(tokens),0), coalesce(sum(saved),0) FROM masks "
942
+ "WHERE conv=?", (conv,)).fetchone()
943
+ return {"archived": a[0], "archived_tokens": a[1], "masked": m[0], "masked_tokens": m[1],
944
+ "masked_saved_est": m[2], "segments": len(self.segments(conv))}
945
+
946
+
947
+ @dataclasses.dataclass
948
+ class Prepared:
949
+ conv: str
950
+ messages: list # prompt fisico (lista messaggi OpenAI da mandare a Strata)
951
+ tools: list | None
952
+ hs: list[str]
953
+ seg: int # 0 = nessun segmento
954
+ est_tokens: int # stima del prompt fisico
955
+ virtual_tokens: int # stima della storia completa del client (senza masking/segmenti)
956
+ events: list[dict]
957
+ masked: int
958
+ masked_tokens: int # token ARCHIVIATI dei pezzi nascosti (testo originale)
959
+ masked_saved: int = 0 # riduzione fisica misurata (somma dei Δ dei pacchetti)
960
+ catalog: dict = dataclasses.field(default_factory=dict) # strumenti caricabili con lo strumento tools
961
+ invalidated: dict = dataclasses.field(default_factory=dict) # token di cache invalidati e causa
962
+ recall_name: str = RECALL_NAME # nomi degli strumenti del proxy in questa conversazione
963
+ tools_name: str = TOOLS_NAME
964
+ lang: str = "it" # 0.3.1: lingua dei testi del proxy in questa conversazione
965
+ recall_aliases: frozenset = frozenset((RECALL_NAME, LEGACY_RECALL_NAME)) # chiamate risolte come recall
966
+ tools_aliases: frozenset = frozenset((TOOLS_NAME, LEGACY_TOOLS_NAME))
967
+
968
+
969
+ class Manager:
970
+ """Decide e applica masking/segmenti; esegue recall. `upstream` (facoltativo) serve solo per le note,
971
+ il sigillo e il SAVE: oggetto con .chat(body)->dict e .slot(action, filename)->dict."""
972
+
973
+ def __init__(self, cfg: Config, store: Store, counter: TokenCounter, journal: Journal):
974
+ self.cfg, self.store, self.tc, self.journal = cfg, store, counter, journal
975
+ self.kw: dict = {} # kwargs del template della richiesta corrente (reasoning_effort, ...)
976
+ self._fileops_seen: dict[str, int] = {}
977
+ self.shorten_tools = ToolShortener(cfg.tooldefs_desc_max, cfg.tooldefs_param_max, cfg.tooldefs_keep)
978
+ self.pager = ToolPager(tuple(cfg.tools_core) + (RECALL_NAME, LEGACY_RECALL_NAME, TOOLS_NAME, LEGACY_TOOLS_NAME,
979
+ cfg.recall_tool_name, cfg.tools_tool_name))
980
+ # nomi degli strumenti del proxy nella conversazione corrente (impostati da prepare)
981
+ self.rn, self.tn = cfg.recall_tool_name or RECALL_NAME, cfg.tools_tool_name or TOOLS_NAME
982
+ # lingua dei testi del proxy nella conversazione corrente (impostata da prepare; 0.3.1)
983
+ self.lang = norm_lang(cfg.prompt_language)
984
+ self._last_phys: dict = {} # conv -> ultimo prompt fisico (misura dei token di cache invalidati)
985
+
986
+ def L(self, it: str, en: str) -> str:
987
+ """Testo nella lingua della conversazione corrente."""
988
+ return it if self.lang == "it" else en
989
+
990
+ def prompt_lang(self, conv: str, existed: bool) -> str:
991
+ """Lingua dei testi del proxy per la conversazione (0.3.1). Scelta alla prima richiesta e ricordata (kv), come i
992
+ nomi degli strumenti: conversazione già nell'archivio senza lingua registrata = nata prima della 0.3.1 ->
993
+ italiano (prefisso identico byte per byte); conversazione nuova -> Config.prompt_language."""
994
+ key = LANG_KEY + conv
995
+ v = self.store.kv_get(key)
996
+ if v in ("it", "en"):
997
+ return v
998
+ lang = "it" if existed else norm_lang(self.cfg.prompt_language)
999
+ self.store.kv_set(key, lang)
1000
+ return lang
1001
+
1002
+ # ---------- stima ----------
1003
+ def piece_tokens(self, text: str) -> int:
1004
+ from .render import VISION
1005
+ return self.tc.raw(text.replace(VISION, "")) + text.count(VISION) * self.cfg.image_tokens
1006
+
1007
+ def msg_tokens(self, m: dict, keep_reasoning: bool = True) -> int:
1008
+ if self.tc.exact:
1009
+ try:
1010
+ return self.piece_tokens(message_piece(m, keep_thinking=keep_reasoning))
1011
+ except ValueError:
1012
+ pass
1013
+ n = self.cfg.msg_overhead + self.tc.count(content_text(m.get("content")))
1014
+ n += n_images(m.get("content")) * self.cfg.image_tokens
1015
+ for c in m.get("tool_calls") or []:
1016
+ f = c.get("function") or {}
1017
+ a = f.get("arguments")
1018
+ n += 8 + self.tc.count((f.get("name") or "") + (a if isinstance(a, str) else json.dumps(a, ensure_ascii=False)))
1019
+ if keep_reasoning and isinstance(m.get("reasoning_content"), str):
1020
+ n += self.tc.count(m["reasoning_content"]) + 4
1021
+ return n
1022
+
1023
+ def tools_tokens(self, tools) -> int:
1024
+ return self.tc.count(json.dumps(tools, ensure_ascii=False)) if tools else 0
1025
+
1026
+ def estimate(self, msgs: list, tools) -> tuple[int, list[int]]:
1027
+ """-> (totale, token per messaggio). Con il tokenizer del pack: conteggio ESATTO del prompt che Strata
1028
+ tokenizza (render_pieces = template Jinja vero, verificato su 421 richieste di nibble2)."""
1029
+ if self.tc.exact:
1030
+ try:
1031
+ per, fixed = [0] * len(msgs), 0
1032
+ for i, t in render_pieces(msgs, tools, self.kw):
1033
+ n = self.piece_tokens(t)
1034
+ if i is None:
1035
+ fixed += n
1036
+ else:
1037
+ per[i] += n
1038
+ return fixed + sum(per), per
1039
+ except ValueError:
1040
+ pass
1041
+ last_user = max((i for i, m in enumerate(msgs) if is_human_user(m)), default=-1)
1042
+ per = [self.msg_tokens(m, not self.cfg.reasoning_last_turn_only or i > last_user) for i, m in enumerate(msgs)]
1043
+ return self.tools_tokens(tools) + sum(per) + 3, per
1044
+
1045
+ # ---------- costruzione del prompt fisico ----------
1046
+ def out_placeholder(self, info: dict | None, orig: str, n: int, rid: str, rid_args: str | None = None) -> str:
1047
+ """Segnaposto di un'uscita nascosta: ricevuta tipizzata (paging.typed_receipt) o, se il tipo non è
1048
+ riconosciuto o l'opzione è spenta, l'inizio vero dell'uscita. Funzione pura: stesso pezzo -> stesso testo."""
1049
+ if self.cfg.typed_receipts:
1050
+ failed = outcome((info or {}).get("name") or "", orig) == "fallito"
1051
+ kind, rec = typed_receipt(info, orig, failed, rid_args, self.rn, self.lang)
1052
+ if rec:
1053
+ return self.L(OUT_RECEIPT, OUT_RECEIPT_EN).format(origin=origin_text(info, self.lang), n=n, rid=rid,
1054
+ receipt=rec, rn=self.rn)
1055
+ return self.L(OUT_PLACEHOLDER, OUT_PLACEHOLDER_EN).format(
1056
+ origin=origin_text(info, self.lang), n=n, rid=rid, rn=self.rn,
1057
+ head=head_text(orig).replace("\u00bb", "\"").replace("\u00ab", "\""))
1058
+ def _segment_for(self, conv: str, hs: list[str]):
1059
+ segs = self.store.segments(conv) if conv else []
1060
+ for seg, cut_idx, cut_h, notes_msg, kind in segs:
1061
+ if 0 < cut_idx <= len(hs) and hs[cut_idx - 1] == cut_h:
1062
+ return seg, cut_idx, notes_msg, kind, bool(segs)
1063
+ return None, 0, None, None, bool(segs)
1064
+
1065
+ def build(self, msgs: list, hs: list[str], start: int, notes_msg: str | None, pins_msg: str | None = None):
1066
+ """-> (fisico, origine) dove origine[j] = indice client del messaggio fisico j, o None (testa/interni)."""
1067
+ masks = self.store.masks_for(hs[start:] + [h + THINK_SUFFIX for h in hs[start:]]
1068
+ + [h + ARGS_SUFFIX for h in hs[start:]] + [h + DROP_SUFFIX for h in hs[start:]])
1069
+ inserts = self.store.inserts_for(hs[start:] + [h + AUTO_SUFFIX for h in hs[start:]])
1070
+ phys, origin = [], []
1071
+ if notes_msg is not None:
1072
+ if msgs and msgs[0].get("role") in ("system", "developer"):
1073
+ phys.append(msgs[0]); origin.append(0)
1074
+ if pins_msg:
1075
+ phys.append({"role": "user", "content": pins_msg}); origin.append(None)
1076
+ phys.append({"role": "user", "content": notes_msg}); origin.append(None)
1077
+ strip = None
1078
+ pend_notes = {}
1079
+ calls: dict = {} # tool_call_id -> call_info (provenienza delle uscite)
1080
+ call_aid: dict = {} # tool_call_id -> id d'archivio degli argomenti
1081
+ hidden_to = -1 # scambio usa e getta nascosto: messaggi client fino a questo indice esclusi
1082
+ for i in range(start, len(msgs)):
1083
+ m = msgs[i]
1084
+ if i < hidden_to:
1085
+ continue
1086
+ if m.get("role") == "assistant":
1087
+ calls.update(call_info(m))
1088
+ if m.get("tool_calls"):
1089
+ a_id = aid_for(i, m)
1090
+ call_aid.update({c.get("id"): a_id for c in m["tool_calls"]})
1091
+ dk = masks.get(hs[i] + DROP_SUFFIX)
1092
+ if dk and is_human_user(m):
1093
+ end = self.exchange_end(msgs, i)
1094
+ fx = self.exchange_receipts(msgs, i, end, self.lang)
1095
+ n_hidden = sum(self.msg_tokens(x, False) for x in msgs[i + 1:end])
1096
+ m = {**m, "content": self._image_note(content_text(m.get("content"))) +
1097
+ self.L(DROP_NOTE, DROP_NOTE_EN).format(n=n_hidden, fx=fx or self.L("nessuna chiamata", "no calls"),
1098
+ rid=dk[0], rn=self.rn)}
1099
+ phys.append(m); origin.append(i)
1100
+ hidden_to = end
1101
+ strip = None
1102
+ continue
1103
+ if self.lang != "it" and m.get("role") in ("user", "tool"):
1104
+ m = self._image_note_msg(m)
1105
+ mk = masks.get(hs[i])
1106
+ if mk and m.get("role") == "tool":
1107
+ info = calls.get(m.get("tool_call_id"))
1108
+ orig = content_text(msgs[i].get("content"))
1109
+ m = {**m, "content": self.out_placeholder(info, orig, mk[1], mk[0],
1110
+ call_aid.get(m.get("tool_call_id")))}
1111
+ if m.get("role") == "tool" and m.get("tool_call_id") in pend_notes:
1112
+ c0 = m.get("content")
1113
+ name, path, rid = pend_notes.pop(m["tool_call_id"])
1114
+ res = content_text(msgs[i].get("content"))
1115
+ m = {**m, "content": call_note_text(name, path, rid, res, self.rn, self.lang) +
1116
+ (c0 if isinstance(c0, str) else content_text(c0))}
1117
+ tk = masks.get(hs[i] + THINK_SUFFIX)
1118
+ if tk and m.get("role") == "assistant" and isinstance(m.get("reasoning_content"), str):
1119
+ # fix live 4/10: niente segnaposto nel ragionamento (il modello lo imitava al posto di ragionare);
1120
+ # il ragionamento vecchio sparisce e basta, resta in archivio
1121
+ m = {**m, "reasoning_content": ""}
1122
+ if m.get("role") == "assistant" and isinstance(m.get("reasoning_content"), str) \
1123
+ and m["reasoning_content"].lstrip().startswith(THINK_OMITTED_PREFIXES):
1124
+ m = {**m, "reasoning_content": ""} # segnaposti finti scritti dal modello per imitazione
1125
+ ak = masks.get(hs[i] + ARGS_SUFFIX)
1126
+ if ak and m.get("role") == "assistant" and m.get("tool_calls"):
1127
+ pend_notes.update(call_notes(m, ak[0]))
1128
+ m = {**m, "tool_calls": masked_calls(m, ak[0], ak[1])}
1129
+ if m.get("role") == "assistant" and m.get("tool_calls") and _FAKE_OMIT.search(json.dumps(m["tool_calls"])):
1130
+ # segnaposti imitati dal modello dentro i propri comandi: via (non devono fare da esempio)
1131
+ m = {**m, "tool_calls": [{**c, "function": {**(c.get("function") or {}), "arguments":
1132
+ _FAKE_OMIT.sub("…", (c.get("function") or {}).get("arguments") or "")}}
1133
+ for c in m["tool_calls"]]}
1134
+ if strip and m.get("role") == "assistant":
1135
+ m = dict(m)
1136
+ c = content_text(m.get("content"))
1137
+ if strip["strip_content"] and c.startswith(strip["strip_content"]):
1138
+ m["content"] = c[len(strip["strip_content"]):]
1139
+ r = m.get("reasoning_content")
1140
+ if strip["strip_reasoning"] and isinstance(r, str) and r.startswith(strip["strip_reasoning"]):
1141
+ m["reasoning_content"] = r[len(strip["strip_reasoning"]):]
1142
+ strip = None
1143
+ phys.append(m); origin.append(i)
1144
+ ains = inserts.get(hs[i] + AUTO_SUFFIX)
1145
+ if ains: # richiamo automatico deciso a quel turno: reinserito identico
1146
+ for x in ains["msgs"]:
1147
+ phys.append(x); origin.append(None)
1148
+ ins = inserts.get(hs[i])
1149
+ if ins:
1150
+ for x in ins["msgs"]:
1151
+ phys.append(x); origin.append(None)
1152
+ strip = ins
1153
+ return phys, origin
1154
+
1155
+ def _image_note(self, text: str) -> str:
1156
+ """Nota immagine dello strato API (sempre il testo 0.3.0, parte della storia con hash) nella lingua della
1157
+ conversazione: in inglese solo nel prompt fisico."""
1158
+ if self.lang == "it" or not isinstance(text, str) or IMAGE_NOTE_IT not in text:
1159
+ return text
1160
+ return text.replace(IMAGE_NOTE_IT, IMAGE_NOTE_EN)
1161
+
1162
+ def _image_note_msg(self, m: dict) -> dict:
1163
+ c = m.get("content")
1164
+ if isinstance(c, str) and IMAGE_NOTE_IT in c:
1165
+ return {**m, "content": self._image_note(c)}
1166
+ return m
1167
+
1168
+ def _custom_desc(self, tools: list) -> list:
1169
+ """Descrizione del parametro `input` degli strumenti custom (api_responses) in inglese nel prompt fisico delle
1170
+ conversazioni inglesi (lo strato API scrive sempre il testo 0.3.0: è nella lista strumenti con hash)."""
1171
+ if self.lang == "it":
1172
+ return tools
1173
+ out = []
1174
+ for t in tools:
1175
+ f = t.get("function") if isinstance(t, dict) else None
1176
+ inp = (((f or {}).get("parameters") or {}).get("properties") or {}).get("input") \
1177
+ if isinstance(f, dict) else None
1178
+ if isinstance(inp, dict) and inp.get("description") == CUSTOM_INPUT_DESC_IT:
1179
+ p = dict(f["parameters"])
1180
+ p["properties"] = {**p["properties"], "input": {**inp, "description": CUSTOM_INPUT_DESC_EN}}
1181
+ t = {**t, "function": {**f, "parameters": p}}
1182
+ out.append(t)
1183
+ return out
1184
+
1185
+ @staticmethod
1186
+ def exchange_end(msgs: list, i: int) -> int:
1187
+ """Fine (esclusa) dello scambio che inizia col messaggio utente i: fino al prossimo messaggio utente umano."""
1188
+ for k in range(i + 1, len(msgs)):
1189
+ if is_human_user(msgs[k]):
1190
+ return k
1191
+ return len(msgs)
1192
+
1193
+ @staticmethod
1194
+ def exchange_receipts(msgs: list, i: int, end: int, lang: str = "it") -> str:
1195
+ """Ricevute di una riga per le chiamate di uno scambio: «bash `node serve.mjs`: riuscito; write a.js: …»."""
1196
+ calls, out = {}, []
1197
+ for m in msgs[i:end]:
1198
+ if m.get("role") == "assistant":
1199
+ calls.update(call_info(m))
1200
+ elif m.get("role") == "tool":
1201
+ out.append(receipt(calls.get(m.get("tool_call_id")), content_text(m.get("content")), lang=lang))
1202
+ if len(out) > 8:
1203
+ out = out[:7] + [("altre %d chiamate" if lang == "it" else "%d more calls") % (len(out) - 7)]
1204
+ return "; ".join(out)
1205
+
1206
+ # ---------- passo principale ----------
1207
+ def prepare(self, req: dict, upstream=None, hint: str | None = None) -> Prepared:
1208
+ cfg = self.cfg
1209
+ msgs = list(req.get("messages") or [])
1210
+ client_tools = req.get("tools") or None
1211
+ hs = chain(msgs, client_tools)
1212
+ events: list[dict] = []
1213
+ conv, known = self.store.find_conv(hs)
1214
+ if hint:
1215
+ conv = "c-" + H(hint)[:16]
1216
+ existed = conv is not None and self.store.has_conv(conv) # già nell'archivio prima di questa richiesta
1217
+ if conv is None:
1218
+ conv = "c" + uuid.uuid4().hex[:12]
1219
+ events.append(self.journal.log("new_conversation", conv=conv, messages=len(msgs)))
1220
+ elif known < len(hs) - 1 and known >= 0 and self.store.has_successor(conv, known):
1221
+ # un messaggio in posizione known+1 era già stato visto, ma diverso: modifica/rigenerazione = ramo
1222
+ events.append(self.journal.log("branch", conv=conv, known=known, messages=len(msgs)))
1223
+ self.store.add_chain(conv, hs)
1224
+ now = time.time()
1225
+ rows = [(rid_for(i, m), conv, i, m.get("role"), m.get("name") or m.get("tool_call_id"),
1226
+ content_text(m.get("content")), self.msg_tokens(m, False), now) for i, m in enumerate(msgs)]
1227
+ rows += [(tid_for(i, m), conv, i, "assistant-reasoning", None, m["reasoning_content"],
1228
+ self.tc.count(m["reasoning_content"]), now)
1229
+ for i, m in enumerate(msgs) if m.get("role") == "assistant" and m.get("reasoning_content")]
1230
+ rows += [(aid_for(i, m), conv, i, "assistant-tool-args", None, args_text(m), self.tc.count(args_text(m)), now)
1231
+ for i, m in enumerate(msgs) if m.get("role") == "assistant" and m.get("tool_calls")]
1232
+ self.store.archive_many(rows)
1233
+ self.kw = template_kwargs(req)
1234
+ events.extend(self._scan_marks(conv, msgs, hs))
1235
+ events.extend(self._scan_fileops(conv, msgs))
1236
+
1237
+ names = self.tool_names(conv, client_tools, legacy=existed)
1238
+ self.rn, self.tn = names["recall"], names["tools"]
1239
+ self.lang = self.prompt_lang(conv, existed)
1240
+ tools = self._custom_desc(list(self.shorten_tools(client_tools) or []))
1241
+ catalog: dict = {}
1242
+ if cfg.tools_paging:
1243
+ tools, catalog = self.pager.split(tools)
1244
+ if cfg.inject_recall and not any((t.get("function") or {}).get("name") == self.rn for t in tools):
1245
+ from .recall2 import recall_tool
1246
+ tools.append(recall_tool(cfg, recall_tool_def(self.rn, self.lang), self.lang))
1247
+ if catalog:
1248
+ tools.append(self.pager.tools_tool(catalog, self.tn))
1249
+
1250
+ seg, start, notes_msg, _, had_segments = self._segment_for(conv, hs)
1251
+ if upstream is not None and hasattr(upstream, "ctx"):
1252
+ upstream.ctx = (conv, seg or 0) # autosave.Tracker: a chi appartiene ciò che Strata legge ora
1253
+ if had_segments and seg is None:
1254
+ events.append(self.journal.log("segment_miss", conv=conv,
1255
+ note="storia modificata prima del taglio: segmenti non applicabili"))
1256
+ pins_msg = self.store.segment_pins(conv, seg) if seg else None
1257
+ phys, origin = self.build(msgs, hs, start, notes_msg, pins_msg)
1258
+ est, per = self.estimate(phys, tools)
1259
+ virtual, _ = self.estimate(msgs, client_tools)
1260
+
1261
+ # ---- livello 1: masking a pacchetti ----
1262
+ if cfg.mask_enabled and est > cfg.mask_trigger:
1263
+ ev = self._mask_batch(conv, msgs, hs, phys, origin, per, est, seg or 0)
1264
+ if ev:
1265
+ phys, origin = self.build(msgs, hs, start, notes_msg, pins_msg)
1266
+ est2, per = self.estimate(phys, tools)
1267
+ # Δ MISURATO sulla stessa richiesta con/senza il pacchetto (GPT-ANSWER3 passo 1)
1268
+ ev["tokens_saved_measured"] = est - est2
1269
+ ev["est_after_measured"] = est2
1270
+ self.journal.log("mask_measured", conv=conv, seg=seg or 0, est_before=est, est_after=est2,
1271
+ saved=est - est2, saved_planned=ev.get("tokens_saved"))
1272
+ est = est2
1273
+ events.append(ev)
1274
+ if cfg.mask_anchor and upstream is not None and cfg.slot_save:
1275
+ events.extend(self._anchor(conv, seg or 0, req, phys, origin, per, tools, ev, upstream))
1276
+
1277
+ # ---- livello 2: segmenti ----
1278
+ resp = int(req.get("max_tokens") or req.get("max_completion_tokens") or cfg.default_response)
1279
+ if resp <= 0:
1280
+ resp = cfg.default_response
1281
+ resp = max(resp, cfg.response_floor)
1282
+ if cfg.segments_enabled and est + resp + cfg.reserve + 8 > cfg.window and upstream is not None:
1283
+ ev = self._switch(conv, req, msgs, hs, phys, origin, per, tools, seg or 0, upstream, est, resp)
1284
+ events.extend(ev)
1285
+ seg, start, notes_msg, _, _ = self._segment_for(conv, hs)
1286
+ pins_msg = self.store.segment_pins(conv, seg) if seg else None
1287
+ phys, origin = self.build(msgs, hs, start, notes_msg, pins_msg)
1288
+ est, per = self.estimate(phys, tools)
1289
+
1290
+ # ---- richiamo automatico (spento di serie): pezzi nascosti pertinenti in coda, deciso una volta ----
1291
+ if cfg.auto_recall and msgs and len(hs) == len(msgs):
1292
+ ev = self._auto_recall(conv, msgs, hs, start, phys, est, resp)
1293
+ if ev is not None:
1294
+ events.append(ev)
1295
+ if ev.get("injected"):
1296
+ phys, origin = self.build(msgs, hs, start, notes_msg, pins_msg)
1297
+ est, per = self.estimate(phys, tools)
1298
+
1299
+ masks = self.store.masks_for(hs + [h + THINK_SUFFIX for h in hs] + [h + ARGS_SUFFIX for h in hs]
1300
+ + [h + DROP_SUFFIX for h in hs])
1301
+ inv = self._invalidation(conv, phys, tools, per, est, events)
1302
+ for ev in events:
1303
+ if ev.get("event") == "mask":
1304
+ # regola di convenienza (ricerca §2): S = token da rileggere, Δ = token tolti; conviene se il
1305
+ # risparmio dura più di S/Δ turni. Solo misura.
1306
+ d = ev.get("tokens_saved_measured") or ev.get("tokens_saved") or 0
1307
+ s_meas = inv.get("tokens") if inv.get("cause") == "blocco" else None
1308
+ rec = {"conv": conv, "seg": seg or 0, "S_planned": ev.get("reread_est"), "S_measured": s_meas,
1309
+ "delta": d, "ratio_planned": round(ev.get("reread_est", 0) / d, 3) if d else None,
1310
+ "ratio_measured": round(s_meas / d, 3) if d and s_meas is not None else None,
1311
+ "first_index": ev.get("first_index"), "count": ev.get("count")}
1312
+ ev["s_over_delta"] = rec["ratio_measured"] if rec["ratio_measured"] is not None \
1313
+ else rec["ratio_planned"]
1314
+ self.journal.log("mask_cost", **rec)
1315
+ return Prepared(conv=conv, messages=phys, tools=tools, hs=hs, seg=seg or 0, est_tokens=est,
1316
+ virtual_tokens=virtual, events=events, masked=len(masks),
1317
+ masked_tokens=sum(n for _, n in masks.values()),
1318
+ masked_saved=self.store.masks_saved(list(masks)), catalog=catalog,
1319
+ invalidated=inv, recall_name=self.rn, tools_name=self.tn, lang=self.lang,
1320
+ recall_aliases=self.call_aliases("recall", client_tools),
1321
+ tools_aliases=self.call_aliases("tools", client_tools))
1322
+
1323
+ # ---------- nomi degli strumenti del proxy (0.2.0) ----------
1324
+ def tool_names(self, conv: str, client_tools, legacy: bool = False) -> dict:
1325
+ """Nomi di recall/tools per la conversazione. La scelta di base si fa alla prima richiesta e si ricorda (kv),
1326
+ così il prompt resta identico fra richieste e fra riavvii: conversazione già nell'archivio senza nomi
1327
+ registrati = nata prima della 0.2.0 -> tiene strata_recall/strata_tools (prefisso, cache e salvataggi restano
1328
+ validi); conversazione nuova -> recall_tool_name/tools_tool_name. Poi, funzione pura degli strumenti del
1329
+ client: se il client dichiara già strata_recall/strata_tools si usano quelli (comportamento 0.1); se dichiara
1330
+ uno strumento con il nome scelto, si passa a un nome alternativo invece di sovrascrivere il suo."""
1331
+ taken = {(t.get("function") or {}).get("name") for t in client_tools or [] if isinstance(t, dict)}
1332
+ key = NAMES_KEY + conv
1333
+ raw = self.store.kv_get(key)
1334
+ base = None
1335
+ if raw:
1336
+ try:
1337
+ base = json.loads(raw)
1338
+ except ValueError:
1339
+ base = None
1340
+ if not isinstance(base, dict) or not base.get("recall") or not base.get("tools"):
1341
+ if legacy:
1342
+ base = {"recall": LEGACY_RECALL_NAME, "tools": LEGACY_TOOLS_NAME}
1343
+ else:
1344
+ base = {"recall": self.cfg.recall_tool_name or RECALL_NAME,
1345
+ "tools": self.cfg.tools_tool_name or TOOLS_NAME}
1346
+ self.store.kv_set(key, json.dumps(base, sort_keys=True))
1347
+ out = dict(base)
1348
+ for k, legacy_name, alts in (("recall", LEGACY_RECALL_NAME, RECALL_ALTERNATES),
1349
+ ("tools", LEGACY_TOOLS_NAME, TOOLS_ALTERNATES)):
1350
+ if legacy_name in taken:
1351
+ out[k] = legacy_name
1352
+ elif out[k] in taken:
1353
+ out[k] = next(n for n in alts + ("%s_%s" % (k, H(conv)[:6]),) if n not in taken)
1354
+ if raw is None:
1355
+ self.journal.log("tool_name_collision", conv=conv, kind=k, client=base[k], used=out[k])
1356
+ return out
1357
+
1358
+ def call_aliases(self, kind: str, client_tools) -> frozenset:
1359
+ """Nomi di chiamata risolti dal proxy come recall (o tools): quello della conversazione più i nomi storici,
1360
+ esclusi quelli che il client dichiara come propri (quelle chiamate sono sue). Il nome storico strata_* resta
1361
+ sempre del proxy (come nella 0.1)."""
1362
+ taken = {(t.get("function") or {}).get("name") for t in client_tools or [] if isinstance(t, dict)}
1363
+ if kind == "recall":
1364
+ cur, legacy, cands = self.rn, LEGACY_RECALL_NAME, (RECALL_NAME, self.cfg.recall_tool_name)
1365
+ else:
1366
+ cur, legacy, cands = self.tn, LEGACY_TOOLS_NAME, (TOOLS_NAME, self.cfg.tools_tool_name)
1367
+ return frozenset({cur, legacy} | {n for n in cands if n and n not in taken})
1368
+
1369
+ # ---------- misura: token di cache invalidati (ricerca §21) ----------
1370
+ def _invalidation(self, conv, phys, tools, per, est, events) -> dict:
1371
+ """Confronto col prompt fisico precedente della stessa conversazione: token del prompt precedente che stanno
1372
+ dopo la prima differenza (quindi persi dalla cache a prefisso e da rileggere). Causa: tipo del primo
1373
+ messaggio diverso / eventi di questa richiesta."""
1374
+ cur = [canon(m) + "\x00" + (m.get("reasoning_content") or "") for m in phys]
1375
+ th = H(json.dumps(tools or [], sort_keys=True, ensure_ascii=False), json.dumps(self.kw, sort_keys=True))
1376
+ prev = self._last_phys.get(conv)
1377
+ self._last_phys[conv] = (cur, list(per), est, th, phys)
1378
+ if len(self._last_phys) > 64:
1379
+ self._last_phys.pop(next(iter(self._last_phys)))
1380
+ if prev is None:
1381
+ return {"tokens": 0, "cause": "prima", "first_diff": None}
1382
+ pcur, pper, pest, pth, pphys = prev
1383
+ if pth != th:
1384
+ cause = "strumenti" if self.cfg.tools_paging else "testa"
1385
+ return {"tokens": pest, "cause": cause, "first_diff": 0}
1386
+ k = 0
1387
+ for a, b in zip(pcur, cur):
1388
+ if a != b:
1389
+ break
1390
+ k += 1
1391
+ if k >= len(pcur):
1392
+ return {"tokens": 0, "cause": None, "first_diff": None}
1393
+ lost = sum(pper[k:])
1394
+ names = {e.get("event") for e in events}
1395
+
1396
+ def kind(m):
1397
+ c = content_text((m or {}).get("content"))
1398
+ if c.startswith(AUTO_HEADS):
1399
+ return "richiamo_automatico"
1400
+ if m and m.get("role") == "tool" and is_tools_result(c):
1401
+ return "strumenti"
1402
+ return None
1403
+ cause = kind(pphys[k] if k < len(pphys) else None) or kind(phys[k] if k < len(phys) else None)
1404
+ if cause is None:
1405
+ cause = "segmento" if "switch" in names else "blocco" if "mask" in names else \
1406
+ "client" if names & {"branch", "new_conversation"} else "altro"
1407
+ return {"tokens": lost, "cause": cause, "first_diff": k}
1408
+
1409
+ # ---------- richiamo automatico (ricerca §6-8) ----------
1410
+ def _hidden_rids(self, msgs, hs, start) -> set:
1411
+ """id d'archivio dei pezzi della storia corrente che NON sono nel prompt fisico: uscite/ragionamenti/argomenti
1412
+ nascosti (tabella masks per questa catena) e messaggi prima del taglio di segmento."""
1413
+ masks = self.store.masks_for(hs + [h + THINK_SUFFIX for h in hs] + [h + ARGS_SUFFIX for h in hs])
1414
+ out = {rid for rid, _ in masks.values()}
1415
+ for i in range(1, min(start, len(msgs))):
1416
+ m = msgs[i]
1417
+ out.add(rid_for(i, m))
1418
+ if m.get("role") == "assistant":
1419
+ if m.get("reasoning_content"):
1420
+ out.add(tid_for(i, m))
1421
+ if m.get("tool_calls"):
1422
+ out.add(aid_for(i, m))
1423
+ return out
1424
+
1425
+ def _auto_query(self, msgs, trigger_idx) -> tuple[str, list[str]]:
1426
+ """Query deterministica: richiesta corrente, file toccati di recente, errori recenti, ultimo comando."""
1427
+ user = ""
1428
+ for k in range(trigger_idx, -1, -1):
1429
+ if is_human_user(msgs[k]):
1430
+ user = content_text(msgs[k].get("content"))
1431
+ break
1432
+ files, errors, last_cmd, calls = [], [], "", {}
1433
+ lo = max(0, trigger_idx - 40)
1434
+ for m in msgs[lo:trigger_idx + 1]:
1435
+ if m.get("role") == "assistant":
1436
+ ci = call_info(m)
1437
+ calls.update(ci)
1438
+ for c in ci.values():
1439
+ if c["path"] and c["path"] not in files:
1440
+ files.append(c["path"])
1441
+ if c["cmd"]:
1442
+ last_cmd = c["cmd"]
1443
+ elif m.get("role") == "tool":
1444
+ info = calls.get(m.get("tool_call_id"))
1445
+ txt = content_text(m.get("content"))
1446
+ if outcome((info or {}).get("name") or "", txt) == "fallito":
1447
+ errors.append(next((l for l in txt.splitlines() if _FAIL.search(l)), txt[:200])[:200])
1448
+ if is_human_user(msgs[trigger_idx]):
1449
+ errors = errors[-1:]
1450
+ terms = query_terms(user, files[-4:][::-1], errors[-2:], last_cmd)
1451
+ return user, terms
1452
+
1453
+ def auto_candidates(self, conv, terms, hidden, already, max_idx) -> list:
1454
+ """Candidati del richiamo automatico: pezzi NASCOSTI con score = -bm25 (più alto = migliore) sopra
1455
+ auto_recall_min_score e almeno auto_recall_min_terms termini non comuni. -> [(rid, idx, role, name,
1456
+ content, tokens, score, matched)] in ordine di score."""
1457
+ cfg = self.cfg
1458
+ hits = self.store.search_fts_scored(conv, terms, limit=80, max_idx=max_idx) if hidden and terms else []
1459
+ pre = []
1460
+ for rid, idx, role, name, content, tokens, score in hits:
1461
+ if rid not in hidden or rid in already or not content:
1462
+ continue
1463
+ low = content.lower()
1464
+ pre.append((rid, idx, role, name, content, tokens, score, [t for t in terms if t in low]))
1465
+ # termini comuni (presenti in gran parte dei pezzi trovati: percorso del progetto, 'node', 'game', 'ogni'…)
1466
+ # non distinguono un pezzo dall'altro: non contano per la soglia dei termini
1467
+ df = {}
1468
+ for c in pre:
1469
+ for t in c[7]:
1470
+ df[t] = df.get(t, 0) + 1
1471
+ common = {t for t, n in df.items() if len(pre) >= 8 and n > cfg.auto_recall_common_frac * len(pre)}
1472
+ cands, seen_txt = [], set()
1473
+ for rid, idx, role, name, content, tokens, score, matched in pre:
1474
+ distinct = [t for t in matched if t not in common]
1475
+ if score < cfg.auto_recall_min_score or len(distinct) < cfg.auto_recall_min_terms:
1476
+ continue
1477
+ h = hashlib.sha1(content.strip().encode("utf-8", "replace")).hexdigest()
1478
+ if h in seen_txt: # stesso testo già scelto (comandi ripetuti identici)
1479
+ continue
1480
+ seen_txt.add(h)
1481
+ cands.append((rid, idx, role, name, content, tokens, score, distinct + [t for t in matched if t in common]))
1482
+ return cands
1483
+
1484
+ def auto_hint(self, cands, terms) -> tuple[str, list]:
1485
+ """Indizio al posto dei pezzi: una riga con i termini trovati e id/messaggi dei candidati migliori (nessun
1486
+ testo dell'archivio). -> (testo, scelti)."""
1487
+ cfg = self.cfg
1488
+ what = {"tool": "uscita", "assistant-reasoning": "ragionamento", "assistant-tool-args": "argomenti",
1489
+ "user": "utente", "assistant": "risposta"} if self.lang == "it" else \
1490
+ {"tool": "output", "assistant-reasoning": "reasoning", "assistant-tool-args": "arguments",
1491
+ "user": "user", "assistant": "answer"}
1492
+ chosen = [{"rid": c[0], "idx": c[1], "role": c[2], "score": round(c[6], 2), "matched": c[7][:6]}
1493
+ for c in cands[:cfg.auto_recall_hint_k]]
1494
+ if not chosen:
1495
+ return "", []
1496
+ words = []
1497
+ for c in chosen:
1498
+ for t in c["matched"]:
1499
+ if t not in words:
1500
+ words.append(t)
1501
+ if self.lang == "it":
1502
+ text = (AUTO_HEAD + " Nell'archivio NASCOSTO di questa conversazione ci sono pezzi su: %s (%s). Se servono "
1503
+ "per la richiesta, usa %s id=<id> (testo intero) o query=...; non ricostruirli a memoria.")
1504
+ else:
1505
+ text = (AUTO_HEAD_EN + " The HIDDEN archive of this conversation has pieces about: %s (%s). If they are "
1506
+ "needed for the request, use %s id=<id> (full text) or query=...; do not rebuild them from memory.")
1507
+ text = (text
1508
+ % (", ".join(words[:8]),
1509
+ "; ".join("id=%s msg %d %s" % (c["rid"], c["idx"] + 1, what.get(c["role"], c["role"]))
1510
+ for c in chosen),
1511
+ self.rn))
1512
+ return text, chosen
1513
+
1514
+ def _auto_recall(self, conv, msgs, hs, start, phys, est, resp) -> dict | None:
1515
+ cfg = self.cfg
1516
+ i = len(msgs) - 1
1517
+ last = msgs[i]
1518
+ trig = None
1519
+ if is_human_user(last):
1520
+ trig = "utente"
1521
+ elif cfg.auto_recall_on_error and last.get("role") == "tool":
1522
+ ci = {}
1523
+ for m in msgs[max(0, i - 8):i]:
1524
+ if m.get("role") == "assistant":
1525
+ ci.update(call_info(m))
1526
+ info = ci.get(last.get("tool_call_id"))
1527
+ if outcome((info or {}).get("name") or "", content_text(last.get("content"))) == "fallito":
1528
+ trig = "errore"
1529
+ if trig is None:
1530
+ return None
1531
+ key = hs[i] + AUTO_SUFFIX
1532
+ if self.store.inserts_for([key]):
1533
+ return None # già deciso a una richiesta precedente: build() lo reinserisce
1534
+ user, terms = self._auto_query(msgs, i)
1535
+ hidden = self._hidden_rids(msgs, hs, start)
1536
+ already = set()
1537
+ for m in phys:
1538
+ c = content_text(m.get("content"))
1539
+ if c.startswith(AUTO_HEADS) or c.startswith("[recall:"):
1540
+ already.update(re.findall(r"id=([rta][0-9a-f]{12})", c))
1541
+ t0 = time.time()
1542
+ cands = self.auto_candidates(conv, terms, hidden, already, i)
1543
+ room = cfg.window - cfg.reserve - resp - est - 64
1544
+ budget = min(cfg.auto_recall_max_tokens, max(0, room))
1545
+ if cfg.auto_recall_hint:
1546
+ text, chosen = self.auto_hint(cands, terms)
1547
+ used = self.tc.count(text) if chosen else 0
1548
+ self.store.add_insert(key, conv, [{"role": "user", "content": text}] if chosen else [])
1549
+ return self.journal.log("auto_recall", conv=conv, trigger=trig, index=i, terms=terms, mode="hint",
1550
+ candidates=len(cands), hidden=len(hidden), injected=len(chosen), tokens=used,
1551
+ pieces=chosen, budget=budget, ms=round((time.time() - t0) * 1000, 1),
1552
+ last_user=user[:300])
1553
+ n_args = 0
1554
+ if self.lang == "it":
1555
+ head = (AUTO_HEAD + " Pezzi della parte NASCOSTA di questa conversazione che potrebbero servire per la "
1556
+ "richiesta qui sopra (trovati dal gestore del contesto, non scritti dall'utente). Testo vero, "
1557
+ "eventualmente estratto; testo intero con %s id=<id>. Lo stato attuale dei file può "
1558
+ "essere diverso: se serve, rileggili." % self.rn)
1559
+ whatmap = {"tool": "uscita di strumento", "assistant-reasoning": "ragionamento",
1560
+ "assistant-tool-args": "argomenti di chiamata", "user": "messaggio dell'utente",
1561
+ "assistant": "risposta dell'assistente"}
1562
+ excerpt = " · estratto"
1563
+ else:
1564
+ head = (AUTO_HEAD_EN + " Pieces of the HIDDEN part of this conversation that may be useful for the request "
1565
+ "above (found by the context manager, not written by the user). Real text, possibly an "
1566
+ "excerpt; full text with %s id=<id>. The current state of the files may differ: re-read "
1567
+ "them if needed." % self.rn)
1568
+ whatmap = {"tool": "tool output", "assistant-reasoning": "reasoning",
1569
+ "assistant-tool-args": "call arguments", "user": "user message",
1570
+ "assistant": "assistant answer"}
1571
+ excerpt = " · excerpt"
1572
+ blocks, used, chosen = [], self.tc.count(head), []
1573
+ for rid, idx, role, name, content, tokens, score, matched in cands[:cfg.auto_recall_k * 3]:
1574
+ if len(chosen) >= cfg.auto_recall_k:
1575
+ break
1576
+ if role == "assistant-tool-args": # argomenti di chiamata: al massimo N per iniezione
1577
+ if n_args >= cfg.auto_recall_max_args:
1578
+ continue
1579
+ n_args += 1
1580
+ low = content.lower()
1581
+ p = min([low.find(t) for t in matched if low.find(t) >= 0], default=0)
1582
+ s = content[max(0, p - 300):p + 2400]
1583
+ what = whatmap.get(role, role)
1584
+ b = "\n--- id=%s · %s %d (%s) · %d token%s%s\n" % (
1585
+ rid, self.L("messaggio", "message"), idx + 1, what, tokens, self.L("", "s"),
1586
+ "" if len(s) >= len(content) else excerpt)
1587
+ b += self.fit_tokens(s, cfg.auto_recall_piece_tokens) if self.tc.count(s) > cfg.auto_recall_piece_tokens \
1588
+ else s
1589
+ t = self.tc.count(b)
1590
+ if used + t > budget:
1591
+ continue
1592
+ blocks.append(b)
1593
+ used += t
1594
+ chosen.append({"rid": rid, "idx": idx, "role": role, "score": round(score, 2), "tokens": t,
1595
+ "matched": matched[:8]})
1596
+ msgs_ins = [{"role": "user", "content": head + "".join(blocks)}] if chosen else []
1597
+ self.store.add_insert(key, conv, msgs_ins)
1598
+ return self.journal.log("auto_recall", conv=conv, trigger=trig, index=i, terms=terms,
1599
+ candidates=len(cands), hidden=len(hidden), injected=len(chosen),
1600
+ tokens=used if chosen else 0, pieces=chosen, budget=budget,
1601
+ ms=round((time.time() - t0) * 1000, 1), last_user=user[:300])
1602
+
1603
+ # ---------- registro delle operazioni sui file ----------
1604
+ def _scan_fileops(self, conv: str, msgs: list) -> list:
1605
+ """Registro file (operazione, esito, id di argomenti e uscita) per recall path=… e, come segnali per il
1606
+ futuro selettore, riletture dello stesso file e modifiche fallite (eventi `reread`, `edit_failed`)."""
1607
+ calls, rows = {}, []
1608
+ for i, m in enumerate(msgs):
1609
+ if m.get("role") == "assistant":
1610
+ ci = call_info(m)
1611
+ for k in ci:
1612
+ ci[k]["idx"], ci[k]["rid_args"] = i, aid_for(i, m)
1613
+ calls.update(ci)
1614
+ elif m.get("role") == "tool":
1615
+ info = calls.get(m.get("tool_call_id"))
1616
+ if not info or not info["path"]:
1617
+ continue
1618
+ res = content_text(m.get("content"))
1619
+ rows.append((conv, i, m.get("tool_call_id") or "", info["name"], info["path"],
1620
+ outcome(info["name"], res), info["rid_args"], rid_for(i, m), head_text(res, 160)))
1621
+ if not rows or not self.store.add_fileops(rows):
1622
+ return []
1623
+ # eventi solo per le operazioni nuove dell'ultima richiesta (le precedenti sono già nel giornale)
1624
+ events, last = [], {}
1625
+ seen = self._fileops_seen.get(conv, -1)
1626
+ with self.store.lock:
1627
+ allops = self.store.db.execute("SELECT idx, op, path, outcome FROM fileops WHERE conv=? ORDER BY idx",
1628
+ (conv,)).fetchall()
1629
+ for idx, op, path, out in allops:
1630
+ prev = last.get(path)
1631
+ if idx > seen:
1632
+ if op == "read" and prev is not None:
1633
+ events.append(self.journal.log("reread", conv=conv, index=idx, path=path, prev_index=prev[0],
1634
+ prev_op=prev[1], turns_since=idx - prev[0]))
1635
+ if op in ("edit", "write") and out == "fallito":
1636
+ events.append(self.journal.log("edit_failed", conv=conv, index=idx, path=path, op=op))
1637
+ last[path] = (idx, op)
1638
+ self._fileops_seen[conv] = max(r[1] for r in rows)
1639
+ return events
1640
+
1641
+ # ---------- 📌 / 🗑 dai messaggi dell'utente ----------
1642
+ def _scan_marks(self, conv: str, msgs: list, hs: list[str]) -> list:
1643
+ """Registra i punti fermi e gli scambi usa e getta scritti dall'utente (etichette umane per il selettore,
1644
+ testo completo nell'archivio). Idempotente: ogni richiesta rivede la storia intera."""
1645
+ events = []
1646
+ drops = self.store.drops(conv)
1647
+ for i, m in enumerate(msgs):
1648
+ if not is_human_user(m):
1649
+ continue
1650
+ t = content_text(m.get("content"))
1651
+ for p in pin_texts(t):
1652
+ if not self.store.has_pin(conv, i, p):
1653
+ pid = self.store.add_pin(conv, i, p)
1654
+ events.append(self.journal.log("pin", conv=conv, id=pid, index=i, source="testo", text=p,
1655
+ tokens=self.tc.count(p)))
1656
+ if is_disposable(t) and i not in drops:
1657
+ self.store.add_drop(conv, i)
1658
+ drops.add(i)
1659
+ events.append(self.journal.log("drop_mark", conv=conv, index=i, source="testo", text=t[:2000]))
1660
+ return events
1661
+
1662
+ def _mask_batch(self, conv, msgs, hs, phys, origin, per, est, seg=0):
1663
+ cfg = self.cfg
1664
+ # frontiera monotona: con l'ancora attiva un pacchetto non tocca mai messaggi prima della frontiera del
1665
+ # pacchetto precedente (il prefisso salvato nell'ancora resta identico)
1666
+ anc = self.store.anchor(conv, seg) if cfg.mask_anchor else None
1667
+ floor = anc[0] if anc else 0
1668
+ # coda protetta INCLUSIVA: gli ultimi keep_recent_tokens del prompt fisico più il messaggio che attraversa
1669
+ # il confine (GPT-ANSWER3: prima il messaggio a cavallo restava scoperto)
1670
+ protected, acc = set(), 0
1671
+ for j in range(len(phys) - 1, -1, -1):
1672
+ protected.add(j)
1673
+ acc += per[j]
1674
+ if acc > cfg.keep_recent_tokens:
1675
+ break
1676
+ # età in turni: numero di assistant successivi nella storia client
1677
+ after = [0] * (len(msgs) + 1)
1678
+ for i in range(len(msgs) - 1, -1, -1):
1679
+ after[i] = after[i + 1] + (1 if msgs[i].get("role") == "assistant" else 0)
1680
+ pinned = {r[1] for r in self.store.pins(conv)} # messaggi con 📌: mai nascosti
1681
+ drops = self.store.drops(conv)
1682
+ exact = self.tc.exact
1683
+ calls: dict = {}
1684
+ call_aid: dict = {}
1685
+ for k, mm in enumerate(msgs):
1686
+ if mm.get("role") == "assistant":
1687
+ calls.update(call_info(mm))
1688
+ if mm.get("tool_calls"):
1689
+ a_id = aid_for(k, mm)
1690
+ call_aid.update({c.get("id"): a_id for c in mm["tool_calls"]})
1691
+ phys_of = {i: j for j, i in enumerate(origin) if i is not None}
1692
+ cand = [] # (indice client, indice fisico, chiave, id, token archiviati, risparmio, meta)
1693
+ young = None # primo messaggio ancora non mascherabile per età/coda: un pacchetto futuro potrà toccarlo
1694
+ skip_to = -1
1695
+ for j, i in enumerate(origin):
1696
+ if i is None or i < skip_to:
1697
+ continue
1698
+ if j in protected or after[i] < cfg.min_age_turns:
1699
+ if young is None:
1700
+ young = i
1701
+ continue
1702
+ if i < floor or i in pinned:
1703
+ continue
1704
+ m = phys[j]
1705
+ age = after[i]
1706
+ # 🗑 scambio usa e getta: tutto lo scambio (domanda esclusa) diventa una ricevuta di una riga
1707
+ if i in drops and is_human_user(m):
1708
+ end = self.exchange_end(msgs, i)
1709
+ js = [phys_of[k] for k in range(i + 1, end) if k in phys_of]
1710
+ if js and all(x not in protected for x in js) and after[end - 1] >= cfg.min_age_turns \
1711
+ and not any(k in pinned for k in range(i, end)):
1712
+ before = sum(per[x] for x in js)
1713
+ note = self.L(DROP_NOTE, DROP_NOTE_EN).format(
1714
+ n=before, fx=self.exchange_receipts(msgs, i, end, self.lang) or
1715
+ self.L("nessuna chiamata", "no calls"), rid=rid_for(i, msgs[i]), rn=self.rn)
1716
+ sv = before - self.tc.count(note)
1717
+ if sv > 0:
1718
+ cand.append((i, j, hs[i] + DROP_SUFFIX, rid_for(i, msgs[i]), before, sv,
1719
+ {"tipo": "scambio", "fine": end, "eta": age}))
1720
+ skip_to = end
1721
+ continue
1722
+ if m.get("role") == "tool":
1723
+ txt = content_text(m.get("content"))
1724
+ if is_placeholder(txt):
1725
+ continue
1726
+ n = self.msg_tokens(m, False)
1727
+ if n >= cfg.min_mask_tokens:
1728
+ info = calls.get(m.get("tool_call_id"))
1729
+ orig = content_text(msgs[i].get("content"))
1730
+ # build() antepone la nota della chiamata anche al segnaposto: va contata in entrambi i lati
1731
+ note = txt[:len(txt) - len(orig)] if txt.endswith(orig) and txt != orig else ""
1732
+ n_orig = self.msg_tokens(msgs[i], False)
1733
+ ph = {**m, "content": note + self.out_placeholder(info, orig, n_orig, rid_for(i, msgs[i]),
1734
+ call_aid.get(m.get("tool_call_id")))}
1735
+ sv = n - self.msg_tokens(ph, False)
1736
+ cand.append((i, j, hs[i], rid_for(i, msgs[i]), n_orig, sv,
1737
+ {"tipo": "uscita", "strumento": (info or {}).get("name"),
1738
+ "path": (info or {}).get("path") or None, "eta": age}))
1739
+ elif cfg.mask_reasoning and m.get("role") == "assistant" and isinstance(m.get("reasoning_content"), str) \
1740
+ and m["reasoning_content"].strip():
1741
+ n = self.tc.count(m["reasoning_content"])
1742
+ if n >= cfg.min_mask_tokens:
1743
+ # risparmio = Δ del frammento renderizzato (niente guadagni su campi già esclusi)
1744
+ sv = (self.msg_tokens(m, True) - self.msg_tokens({**m, "reasoning_content": ""}, True)) \
1745
+ if exact else n
1746
+ if sv > 0:
1747
+ cand.append((i, j, hs[i] + THINK_SUFFIX, tid_for(i, msgs[i]), n, sv,
1748
+ {"tipo": "ragionamento", "eta": age}))
1749
+ if cfg.mask_tool_args and m.get("role") == "assistant" and m.get("tool_calls") \
1750
+ and '"_omesso"' not in args_text(m) and '"_omitted"' not in args_text(m):
1751
+ n = self.tc.count(args_text(m))
1752
+ if n >= cfg.min_mask_tokens:
1753
+ rid = aid_for(i, msgs[i])
1754
+ m2 = {**m, "tool_calls": masked_calls(m, rid, n)}
1755
+ # costo delle note che build() aggiunge nei risultati (una per chiamata accorciata, con l'esito
1756
+ # letto dal risultato vero: il testo della nota cambia con l'esito)
1757
+ res = {x.get("tool_call_id"): content_text(x.get("content"))
1758
+ for x in msgs[i + 1:i + 1 + len(m["tool_calls"]) + 2] if x.get("role") == "tool"}
1759
+ note_cost = sum(self.tc.count(call_note_text(nm, pa, rid, res.get(cid, ""), self.rn, self.lang)) + 1
1760
+ for cid, (nm, pa, _) in call_notes(m, rid).items())
1761
+ sv = self.msg_tokens(m, True) - self.msg_tokens(m2, True) - note_cost
1762
+ if sv > 0:
1763
+ ci = list(call_info(m).values())
1764
+ cand.append((i, j, hs[i] + ARGS_SUFFIX, rid, n, sv,
1765
+ {"tipo": "argomenti", "strumento": ",".join(c["name"] for c in ci),
1766
+ "path": next((c["path"] for c in ci if c["path"]), None), "eta": age}))
1767
+ rows, saved, now, pieces = [], 0, time.time(), []
1768
+ align = int(cfg.checkpoint_align_tokens or 0)
1769
+ offs, acc = [], max(0, est - sum(per)) # testa fissa (strumenti, template) prima del primo messaggio
1770
+ for x in per:
1771
+ offs.append(acc)
1772
+ acc += x
1773
+ skipped = 0
1774
+ all_cand = cand
1775
+ if align > 0 and cand:
1776
+ # primo cambiamento poco dopo un multiplo di `align` (checkpoint del motore): si saltano i primi candidati
1777
+ # finché uno cade entro lo spreco accettato, purché i restanti bastino per un pacchetto
1778
+ for s, c in enumerate(cand):
1779
+ if sum(x[5] for x in cand[s:]) < cfg.min_batch_tokens:
1780
+ break
1781
+ if offs[c[1]] % align <= cfg.checkpoint_align_slack * align:
1782
+ skipped = s
1783
+ break
1784
+ cand = cand[skipped:]
1785
+ for i, j, key, rid, n, sv, meta in cand: # dal più vecchio
1786
+ if est - saved <= cfg.mask_target:
1787
+ break
1788
+ rows.append((key, conv, i, rid, n, now, sv))
1789
+ saved += sv
1790
+ pieces.append({"id": rid, "indice": i, "token": n, "risparmio": sv, **meta})
1791
+ if not rows or saved < cfg.min_batch_tokens:
1792
+ return None
1793
+ self.store.add_masks(rows)
1794
+ first = rows[0][2]
1795
+ rest = all_cand[:skipped] + cand[len(rows):]
1796
+ frontier = min([c[0] for c in rest] + ([young] if young is not None else []), default=len(msgs))
1797
+ reread = sum(per[j] for j, i in enumerate(origin) if i is not None and i >= first)
1798
+ extra = {"align": align, "align_skipped": skipped, "first_offset_est": offs[cand[0][1]]} if align else {}
1799
+ return self.journal.log("mask", conv=conv, seg=seg, count=len(rows), tokens_masked=sum(r[4] for r in rows),
1800
+ tokens_saved=saved, est_before=est, est_after=est - saved,
1801
+ first_index=first, frontier_index=frontier, floor_index=floor, reread_est=reread,
1802
+ ids=[r[3] for r in rows][:50], pieces=pieces, **extra)
1803
+
1804
+ # ---------- segmenti ----------
1805
+ def choose_cut(self, msgs, origin, per, start):
1806
+ """Indice client c da cui parte la coda di B. Preferenza: prima di un messaggio user umano con coda
1807
+ <= tail_max; altrimenti confine di gruppo (assistant dopo tool/user: chiamate e risultati completi).
1808
+ Mai fra un assistant con tool_calls e i suoi risultati."""
1809
+ tail_from = {}
1810
+ acc = 0
1811
+ for j in range(len(origin) - 1, -1, -1):
1812
+ acc += per[j]
1813
+ if origin[j] is not None:
1814
+ tail_from[origin[j]] = acc
1815
+ n = len(msgs)
1816
+
1817
+ def ok_user(c):
1818
+ return is_human_user(msgs[c])
1819
+
1820
+ def ok_group(c):
1821
+ return msgs[c].get("role") == "assistant" and msgs[c - 1].get("role") in ("tool", "user")
1822
+
1823
+ for kind, pred in (("user", ok_user), ("group", ok_group)):
1824
+ for c in range(start + 1, n):
1825
+ if c in tail_from and tail_from[c] <= self.cfg.tail_max and pred(c):
1826
+ return c, kind
1827
+ for c in range(n - 1, start, -1): # coda minima comunque valida
1828
+ if ok_user(c) or ok_group(c):
1829
+ return c, "group-min"
1830
+ return None, None
1831
+
1832
+ def archive_index(self, msgs, upto: int, conv: str) -> str:
1833
+ calls = {}
1834
+ for m in msgs[:upto]:
1835
+ for c in m.get("tool_calls") or []:
1836
+ f = c.get("function") or {}
1837
+ a = f.get("arguments")
1838
+ a = a if isinstance(a, str) else json.dumps(a, ensure_ascii=False)
1839
+ calls[c.get("id")] = "%s %s" % (f.get("name"), a.replace("\n", " ")[:70])
1840
+ lines, used = [], 0
1841
+ for i in range(upto - 1, -1, -1):
1842
+ m = msgs[i]
1843
+ if m.get("role") != "tool":
1844
+ continue
1845
+ n = self.msg_tokens(m, False)
1846
+ if n < self.cfg.min_mask_tokens:
1847
+ continue
1848
+ line = "- recall:%s \u00b7 %s \u00b7 %d token%s" % (rid_for(i, m), calls.get(m.get("tool_call_id"), "?"), n,
1849
+ self.L("", "s"))
1850
+ t = self.tc.count(line)
1851
+ if used + t > self.cfg.index_max_tokens:
1852
+ lines.append(self.L("- \u2026 (blocchi più vecchi: cerca con %s query)",
1853
+ "- \u2026 (older blocks: search with %s query)") % self.rn)
1854
+ break
1855
+ lines.append(line)
1856
+ used += t
1857
+ return "\n".join(lines)
1858
+
1859
+ def _switch(self, conv, req, msgs, hs, phys, origin, per, tools, seg, upstream, est, resp):
1860
+ cfg, events = self.cfg, []
1861
+ start = next((i for i in origin if i is not None and i > 0), 0) if seg else 0
1862
+ c, kind = self.choose_cut(msgs, origin, per, start)
1863
+ if c is None:
1864
+ events.append(self.journal.log("switch_skipped", conv=conv, reason="nessun confine di taglio sicuro"))
1865
+ return events
1866
+ params = {k: v for k, v in req.items() if k not in ("messages", "tools", "stream", "stream_options",
1867
+ "max_tokens", "max_completion_tokens", "n", "tool_choice")}
1868
+ events.append(self.journal.log("freeze", conv=conv, seg=seg, est_tokens=est, response=resp,
1869
+ window=cfg.window, cut_index=c, cut_kind=kind))
1870
+ # 1) note di passaggio in coda ad A (stesso system/tool/reasoning: il prefisso resta in cache)
1871
+ t0 = time.time()
1872
+ notes, usage, finish, timings = "", {}, None, None
1873
+ try:
1874
+ r = upstream.chat({**params, "messages": phys + [{"role": "user", "content": self.L(
1875
+ NOTES_INSTRUCTION, NOTES_INSTRUCTION_EN).replace("{rn}", self.rn)}],
1876
+ "tools": tools, "max_tokens": cfg.notes_max_tokens, "stream": False})
1877
+ notes = (r["choices"][0]["message"].get("content") or "").strip()
1878
+ usage = r.get("usage") or {}
1879
+ finish, timings = r["choices"][0].get("finish_reason"), r.get("timings")
1880
+ except Exception as e: # noqa: BLE001
1881
+ events.append(self.journal.log("notes_error", conv=conv, error=str(e)[:300]))
1882
+ events.append(self.journal.log("notes", conv=conv, seg=seg + 1, ms=round((time.time() - t0) * 1000),
1883
+ tokens=self.tc.count(notes), usage=usage, finish=finish, timings=timings,
1884
+ cached=(usage.get("prompt_tokens_details") or {}).get("cached_tokens")))
1885
+ if not notes:
1886
+ notes = self.L("(note non disponibili: usa %s per ricostruire i dettagli)",
1887
+ "(notes not available: use %s to reconstruct the details)") % self.rn
1888
+ # 2) sigillo (sperimentale) + SAVE di A
1889
+ if cfg.slot_save:
1890
+ if cfg.seal_experimental:
1891
+ t0 = time.time()
1892
+ try:
1893
+ r = upstream.chat({**params, "messages": phys, "tools": tools, "max_tokens": 1, "stream": False})
1894
+ u = r.get("usage") or {}
1895
+ events.append(self.journal.log("seal", conv=conv, ms=round((time.time() - t0) * 1000), usage=u,
1896
+ cached=(u.get("prompt_tokens_details") or {}).get("cached_tokens"),
1897
+ timings=r.get("timings")))
1898
+ except Exception as e: # noqa: BLE001
1899
+ events.append(self.journal.log("seal_error", conv=conv, error=str(e)[:300]))
1900
+ fn = "%s-seg%d-A.bin" % (conv, seg)
1901
+ t0 = time.time()
1902
+ try:
1903
+ r = upstream.slot("save", fn)
1904
+ events.append(self.journal.log("save", conv=conv, file=fn, ms=round((time.time() - t0) * 1000),
1905
+ result=r))
1906
+ if cfg.data_dir:
1907
+ # il prompt fisico di A serve al consulto (A + domanda deve ricostruire lo stesso prefisso)
1908
+ with open(os.path.join(cfg.data_dir, fn + ".json"), "w", encoding="utf-8") as f:
1909
+ json.dump({"conv": conv, "seg": seg, "file": fn, "messages": phys, "tools": tools,
1910
+ "params": params}, f, ensure_ascii=False)
1911
+ except Exception as e: # noqa: BLE001
1912
+ events.append(self.journal.log("save_error", conv=conv, file=fn, error=str(e)[:300]))
1913
+ # 3) B = system + tool + [note + indice] + coda
1914
+ first_user = next((m for m in msgs if is_human_user(m)), None)
1915
+ if self.lang == "it":
1916
+ parts = [SEG_MARK.format(seg=seg + 1),
1917
+ "I messaggi 1\u2013%d di questa conversazione sono stati archiviati e non sono più nel contesto. "
1918
+ "Il loro testo esatto è recuperabile con lo strumento %s (per id o per ricerca)." % (c, self.rn)]
1919
+ h_first, h_notes, h_index, tail = ("## Prima richiesta dell'utente (letterale)", "## Note di passaggio",
1920
+ "## Indice dell'archivio (più recenti prima)",
1921
+ "La conversazione prosegue qui sotto.")
1922
+ else:
1923
+ parts = [SEG_MARK_EN.format(seg=seg + 1),
1924
+ "Messages 1\u2013%d of this conversation have been archived and are no longer in the context. "
1925
+ "Their exact text can be retrieved with the %s tool (by id or by search)." % (c, self.rn)]
1926
+ h_first, h_notes, h_index, tail = ("## First user request (verbatim)", "## Handoff notes",
1927
+ "## Archive index (most recent first)",
1928
+ "The conversation continues below.")
1929
+ if cfg.keep_first_user and first_user is not None and msgs.index(first_user) < c:
1930
+ parts += ["", h_first, self._image_note(content_text(first_user.get("content")))]
1931
+ parts += ["", h_notes, notes]
1932
+ idx = self.archive_index(msgs, c, conv)
1933
+ if idx:
1934
+ parts += ["", h_index, idx]
1935
+ parts += ["", tail]
1936
+ notes_msg = "\n".join(parts)
1937
+ pins_msg, pins_ev = self.pins_block(conv, c)
1938
+ self.store.add_segment(conv, seg + 1, c, hs[c - 1], notes_msg, kind)
1939
+ self.store.set_segment_pins(conv, seg + 1, pins_msg)
1940
+ if pins_ev:
1941
+ events.append(pins_ev)
1942
+ events.append(self.journal.log("switch", conv=conv, seg=seg + 1, cut_index=c, cut_kind=kind,
1943
+ notes_msg_tokens=self.tc.count(notes_msg),
1944
+ pins_tokens=self.tc.count(pins_msg) if pins_msg else 0))
1945
+ return events
1946
+
1947
+ def pins_block(self, conv: str, cut: int | None = None) -> tuple[str | None, dict | None]:
1948
+ """Blocco «Punti fermi» (testuale) dopo il system prompt del nuovo segmento: cambia solo allo switch, quindi
1949
+ non provoca riletture. Solo i punti fermi di messaggi rimasti PRIMA del taglio (gli altri sono nella coda).
1950
+ Tetto pins_max_tokens: oltre, si tengono i più recenti e si avvisa."""
1951
+ rows = [r for r in self.store.pins(conv) if cut is None or r[1] < cut]
1952
+ if not rows:
1953
+ return None, None
1954
+ lines, used, dropped = [], 0, 0
1955
+ for pid, idx, text, _, _ in reversed(rows):
1956
+ line = self.L("- (messaggio %d) %s", "- (message %d) %s") % (idx + 1, text)
1957
+ t = self.tc.count(line)
1958
+ if used + t > self.cfg.pins_max_tokens:
1959
+ dropped += 1
1960
+ continue
1961
+ lines.append(line)
1962
+ used += t
1963
+ lines.reverse()
1964
+ if self.lang == "it":
1965
+ head = [PIN_HEAD, "Punti fissati dall'utente nella parte archiviata della conversazione: valgono ancora, "
1966
+ "testuali."]
1967
+ if dropped:
1968
+ head.append("(avviso: %d punti fermi più vecchi oltre il tetto di %d token: cercali con %s)"
1969
+ % (dropped, self.cfg.pins_max_tokens, self.rn))
1970
+ else:
1971
+ head = [PIN_HEAD_EN, "Points pinned by the user in the archived part of the conversation: they still "
1972
+ "apply, verbatim."]
1973
+ if dropped:
1974
+ head.append("(warning: %d older pinned points over the limit of %d tokens: search them with %s)"
1975
+ % (dropped, self.cfg.pins_max_tokens, self.rn))
1976
+ ev = self.journal.log("pins_block", conv=conv, count=len(lines), dropped=dropped, tokens=used,
1977
+ over_limit=bool(dropped))
1978
+ return "\n".join(head + lines), ev
1979
+
1980
+ # ---------- recall ----------
1981
+ def fit_tokens(self, text: str, max_tokens: int) -> str:
1982
+ """Taglia un testo a max_tokens (conteggio del contatore) con indicazione esplicita."""
1983
+ if self.tc.count(text) <= max_tokens:
1984
+ return text
1985
+ lo, hi = 0, len(text)
1986
+ while lo < hi:
1987
+ mid = (lo + hi + 1) // 2
1988
+ if self.tc.count(text[:mid]) <= max(max_tokens - 30, 0):
1989
+ lo = mid
1990
+ else:
1991
+ hi = mid - 1
1992
+ return text[:lo] + self.L(
1993
+ "\n[recall troncato per spazio nel contesto: chiedi un id preciso o usa offset; con id e "
1994
+ "query ricevi solo le righe che contengono la query]",
1995
+ "\n[recall truncated for space in the context: ask for a specific id or use offset; with id and "
1996
+ "query you get only the lines that contain the query]")
1997
+
1998
+ def _recall_lines(self, rid, r, text, q, meta, head_fn) -> str:
1999
+ lines = text.splitlines()
2000
+ low = q.lower()
2001
+ hit = [i for i, l in enumerate(lines) if low in l.lower()]
2002
+ terms = [t.lower() for t in re.findall(r"\w+", q) if len(t) > 1]
2003
+ if not hit and terms:
2004
+ hit = [i for i, l in enumerate(lines) if all(t in l.lower() for t in terms)]
2005
+ if not hit:
2006
+ return (self.L("[recall:%s \u2014 nessuna riga contiene %r; il testo ha %d righe, %d caratteri: usa offset]",
2007
+ "[recall:%s \u2014 no line contains %r; the text has %d lines, %d characters: use offset]")
2008
+ % (rid, q, len(lines), len(text)))
2009
+ keep = sorted({j for i in hit for j in (i - 1, i, i + 1) if 0 <= j < len(lines)})
2010
+ starts, pos = [], 0
2011
+ for l in lines:
2012
+ starts.append(pos)
2013
+ pos += len(l) + 1
2014
+ out = [self.L("[recall:%s \u2014 messaggio %d (%s%s): %d righe su %d contengono %r (con una riga di contesto; "
2015
+ "numero di riga e carattere iniziale; per il testo intorno usa offset)]",
2016
+ "[recall:%s \u2014 message %d (%s%s): %d lines of %d contain %r (with one line of context; "
2017
+ "line number and starting character; for the surrounding text use offset)]")
2018
+ % (rid, r[2] + 1, r[3], (" " + r[4]) if r[4] else "", len(hit), len(lines), q)]
2019
+ prev = None
2020
+ for j in keep:
2021
+ if prev is not None and j != prev + 1:
2022
+ out.append("\u22ee")
2023
+ out.append("%s%d@%d: %s" % (">" if j in hit else " ", j + 1, starts[j], lines[j]))
2024
+ prev = j
2025
+ if meta is not None:
2026
+ meta.append({"rid": rid, "idx": r[2], "rank": 0, "tokens": r[6], "query": q, "lines": len(hit)})
2027
+ return self.fit_tokens("\n".join(out), self.cfg.recall_max_tokens)
2028
+
2029
+ def recall(self, conv: str, args, max_idx: int | None = None, meta: list | None = None) -> str:
2030
+ """id (testo esatto, solo della conversazione corrente), path (storia delle operazioni sul file), query
2031
+ (FTS5 bm25 + sottostringa esatta per percorsi/identificatori). Filtro di catena: max_idx esclude la domanda
2032
+ corrente e i rami successivi; l'archivio è per conversazione. Tetto: recall_max_tokens.
2033
+ meta (facoltativo): riceve un dict per pezzo restituito {rid, idx, rank, tokens} (giornale recall)."""
2034
+ if isinstance(args, str):
2035
+ try:
2036
+ args = json.loads(args or "{}")
2037
+ except ValueError:
2038
+ args = {"query": args}
2039
+ args = args if isinstance(args, dict) else {"query": str(args)}
2040
+ if self.cfg.recall_struct or self.cfg.recall_multi or self.cfg.recall_flex:
2041
+ if getattr(self, "_recall2", None) is None:
2042
+ from .recall2 import Recall2
2043
+ self._recall2 = Recall2(self)
2044
+ return self._recall2.recall(conv, args, max_idx, meta)
2045
+ return self.recall_legacy(conv, args, max_idx, meta)
2046
+
2047
+ def recall_legacy(self, conv: str, args: dict, max_idx: int | None = None, meta: list | None = None,
2048
+ head_fn=None) -> str:
2049
+ """recall di serie (id / path / query). head_fn: intestazione alternativa per id (recall2)."""
2050
+ rid = str(args.get("id") or "").strip().removeprefix("recall:").removeprefix("id=")
2051
+ try:
2052
+ off = max(0, int(args.get("offset") or 0))
2053
+ except (TypeError, ValueError):
2054
+ off = 0
2055
+ lim = self.cfg.recall_max_chars
2056
+ if rid:
2057
+ r = self.store.get(rid, conv) or self.store.get(rid)
2058
+ if r is None:
2059
+ return self.L("recall: id %s non trovato nell'archivio (gli id validi sono quelli scritti dal gestore "
2060
+ "del contesto; prova con query o path)",
2061
+ "recall: id %s not found in the archive (valid ids are the ones written by the context "
2062
+ "manager; try query or path)") % rid
2063
+ text = r[5] or ""
2064
+ q = str(args.get("query") or "").strip()
2065
+ if q:
2066
+ # 0.3.0 (t_5de89914): id + query = solo le righe di QUESTO testo che contengono la query (con una
2067
+ # riga di contesto), non il pezzo dall'inizio. A finestra piccola il pezzo dall'offset non arrivava
2068
+ # mai alla riga cercata.
2069
+ return self._recall_lines(rid, r, text, q, meta, head_fn)
2070
+ chunk = text[off:off + lim]
2071
+ chunk = self.fit_tokens(chunk, self.cfg.recall_max_tokens)
2072
+ if chunk.endswith("usa offset]") or chunk.endswith("use offset]"):
2073
+ chunk = chunk[:max(chunk.rfind("\n[recall troncato"), chunk.rfind("\n[recall truncated"))]
2074
+ if meta is not None:
2075
+ meta.append({"rid": rid, "idx": r[2], "rank": 0, "tokens": r[6], "offset": off, "chars": len(chunk)})
2076
+ head = self.L("[recall:%s \u2014 messaggio %d (%s%s), %d token, caratteri %d\u2013%d di %d]\n",
2077
+ "[recall:%s \u2014 message %d (%s%s), %d tokens, characters %d\u2013%d of %d]\n") % (
2078
+ rid, r[2] + 1, r[3], (" " + r[4]) if r[4] else "", r[6], off, off + len(chunk), len(text))
2079
+ if head_fn is not None:
2080
+ head = head_fn(r, off, len(chunk), len(text))
2081
+ tail = "" if off + len(chunk) >= len(text) else \
2082
+ self.L("\n[continua: %s id=%s offset=%d]", "\n[continues: %s id=%s offset=%d]") % (
2083
+ self.rn, rid, off + len(chunk))
2084
+ return head + chunk + tail
2085
+ path = str(args.get("path") or "").strip()
2086
+ if path:
2087
+ ops = self.store.fileops(conv, path, max_idx=max_idx)
2088
+ if not ops:
2089
+ return self.L("recall: nessuna operazione registrata sul file %r",
2090
+ "recall: no operation recorded on the file %r") % path
2091
+ out = [self.L("[recall: storia del file %r, %d operazioni, dalla più vecchia; per lo stato attuale "
2092
+ "rileggilo]", "[recall: history of the file %r, %d operations, oldest first; for the "
2093
+ "current state re-read it]") % (path, len(ops))]
2094
+ for k, (idx, op, p2, esito, ra, ro, head) in enumerate(ops):
2095
+ out.append(self.L("- messaggio %d: %s %s \u2014 %s \u00b7 argomenti: id=%s \u00b7 risultato: id=%s "
2096
+ "\u00b7 %s", "- message %d: %s %s \u2014 %s \u00b7 arguments: id=%s \u00b7 "
2097
+ "result: id=%s \u00b7 %s")
2098
+ % (idx + 1, op, p2, outcome_text(esito, self.lang), ra, ro, head))
2099
+ if meta is not None:
2100
+ meta.append({"rid": ro, "idx": idx, "rank": k, "tokens": 0, "path": p2})
2101
+ return self.fit_tokens("\n".join(out), self.cfg.recall_max_tokens)
2102
+ q = str(args.get("query") or "").strip()
2103
+ if not q:
2104
+ return self.L("recall: serve id, path oppure query", "recall: id, path or query is required")
2105
+ exact = self.store.search(conv, q, limit=6, max_idx=max_idx)
2106
+ fts = self.store.search_fts(conv, q, limit=12, max_idx=max_idx)
2107
+ hits, seen = [], set()
2108
+ for h in exact + fts: # prima le corrispondenze esatte (più recenti), poi bm25
2109
+ if h[0] not in seen:
2110
+ seen.add(h[0])
2111
+ hits.append((h, h in exact))
2112
+ if not hits:
2113
+ return self.L("recall: nessun risultato per %r", "recall: no results for %r") % q
2114
+ terms = [t.lower() for t in re.findall(r"\w+", q) if len(t) > 1]
2115
+ out = [self.L("[recall: %d risultati per %r (prima i testi che contengono la frase esatta, poi per "
2116
+ "pertinenza)]", "[recall: %d results for %r (first the texts that contain the exact phrase, "
2117
+ "then by relevance)]") % (len(hits), q)]
2118
+ budget = self.cfg.recall_max_tokens - self.tc.count(out[0])
2119
+ for rank, ((rid2, idx, role, name, content, tokens), is_exact) in enumerate(hits):
2120
+ low = content.lower()
2121
+ p = low.find(q.lower()) if is_exact else min([x for x in (low.find(t) for t in terms) if x >= 0],
2122
+ default=0)
2123
+ s = content[max(0, p - 400):p + len(q) + 1200]
2124
+ block = "\n--- id=%s \u00b7 %s %d (%s%s) \u00b7 %d token%s%s\n%s" % (
2125
+ rid2, self.L("messaggio", "message"), idx + 1, role, (" " + name) if name and role == "tool" else "",
2126
+ tokens, self.L("", "s"), "" if len(s) >= len(content) else
2127
+ self.L(" \u00b7 estratto, testo intero con id", " \u00b7 excerpt, full text with id"), s)
2128
+ t = self.tc.count(block)
2129
+ if t > budget:
2130
+ if rank < 2 and budget > 300:
2131
+ block = self.fit_tokens(block, budget)
2132
+ t = budget
2133
+ else:
2134
+ out.append(self.L("\n[altri %d risultati non mostrati: affina la query]",
2135
+ "\n[%d more results not shown: refine the query]") % (len(hits) - rank))
2136
+ break
2137
+ out.append(block)
2138
+ budget -= t
2139
+ if meta is not None:
2140
+ meta.append({"rid": rid2, "idx": idx, "rank": rank, "tokens": tokens, "chars": len(s),
2141
+ "exact": is_exact})
2142
+ return "\n".join(out)
2143
+
2144
+ def remember_internal(self, conv: str, h_last: str, msgs: list, strip_content: str = "",
2145
+ strip_reasoning: str = "") -> None:
2146
+ """Eventi interni (chiamata recall + risultato) inseriti dopo l'ultimo messaggio client, chiavati dal suo
2147
+ hash: alla richiesta successiva vengono reinseriti identici, così il prefisso fisico resta stabile."""
2148
+ self.store.add_insert(h_last, conv, msgs, strip_content, strip_reasoning)
2149
+
2150
+ # ---------- ancora di masking ----------
2151
+ @staticmethod
2152
+ def anchor_cut(phys: list, origin: list, frontier: int) -> int:
2153
+ """Indice fisico F tale che phys[:F] è un prefisso che nessun pacchetto futuro cambierà e che il template
2154
+ rende identico anche dentro il prompt completo: phys[F] è un messaggio client user/assistant (mai tool,
2155
+ mai evento interno), e tutto ciò che viene prima ha indice client < frontiera."""
2156
+ f = next((j for j, i in enumerate(origin) if i is not None and i >= frontier), len(phys) - 1)
2157
+ while f > 1 and (origin[f] is None or phys[f].get("role") not in ("user", "assistant")):
2158
+ f -= 1
2159
+ return max(f, 0)
2160
+
2161
+ @staticmethod
2162
+ def prefix_hash(phys: list, tools, params: dict) -> str:
2163
+ return H(json.dumps({"m": phys, "t": tools, "p": params}, sort_keys=True, ensure_ascii=False))
2164
+
2165
+ def _anchor(self, conv, seg, req, phys, origin, per, tools, mask_ev, upstream) -> list:
2166
+ """Dopo un pacchetto di mask: (1) se c'è l'ancora del pacchetto precedente ed è ancora prefisso esatto del
2167
+ nuovo prompt fisico, RESTORE (Strata riparte dalla vecchia frontiera invece che dal checkpoint radice);
2168
+ (2) richiesta di 1 token col prompt fisico fino alla nuova frontiera (Strata mette un checkpoint esattamente
2169
+ lì); (3) SAVE in un file nuovo = nuova ancora. La richiesta vera che segue riparte dalla nuova frontiera."""
2170
+ cfg, events = self.cfg, []
2171
+ params = {k: v for k, v in req.items() if k not in ("messages", "tools", "stream", "stream_options",
2172
+ "max_tokens", "max_completion_tokens", "n",
2173
+ "tool_choice")}
2174
+ old = self.store.anchor(conv, seg)
2175
+ t0 = time.time()
2176
+ if old:
2177
+ oidx, ophash, ofile, _ = old
2178
+ oF = next((j for j, i in enumerate(origin) if i is not None and i >= oidx), None)
2179
+ if oF is not None and self.prefix_hash(phys[:oF], tools, params) == ophash:
2180
+ try:
2181
+ r = upstream.slot("restore", ofile)
2182
+ events.append(self.journal.log("anchor_restore", conv=conv, seg=seg, file=ofile, index=oidx,
2183
+ ms=round((time.time() - t0) * 1000), result=r))
2184
+ except Exception as e: # noqa: BLE001
2185
+ events.append(self.journal.log("anchor_error", conv=conv, step="restore", error=str(e)[:300]))
2186
+ else:
2187
+ events.append(self.journal.log("anchor_stale", conv=conv, seg=seg, index=oidx))
2188
+ F = self.anchor_cut(phys, origin, mask_ev["frontier_index"])
2189
+ ftok = sum(per[:F]) + self.tools_tokens(tools)
2190
+ if F <= 1 or ftok < cfg.anchor_min_tokens or origin[F] is None:
2191
+ events.append(self.journal.log("anchor_skip", conv=conv, seg=seg, phys_index=F, est_tokens=ftok))
2192
+ return events
2193
+ idx = origin[F]
2194
+ t1 = time.time()
2195
+ try:
2196
+ r = upstream.chat({**params, "messages": phys[:F], "tools": tools, "max_tokens": 1, "stream": False})
2197
+ u = r.get("usage") or {}
2198
+ t2 = time.time()
2199
+ fn = "%s-seg%d-anchor%d.bin" % (conv, seg, idx)
2200
+ s = upstream.slot("save", fn)
2201
+ self.store.set_anchor(conv, seg, idx, self.prefix_hash(phys[:F], tools, params), fn,
2202
+ int(u.get("prompt_tokens") or 0))
2203
+ events.append(self.journal.log(
2204
+ "anchor_save", conv=conv, seg=seg, file=fn, index=idx, phys_index=F, prompt_tokens=u.get("prompt_tokens"),
2205
+ cached=(u.get("prompt_tokens_details") or {}).get("cached_tokens"),
2206
+ prompt_read=(r.get("timings") or {}).get("prompt_n"), prefill_ms=round((t2 - t1) * 1000),
2207
+ save_ms=round((time.time() - t2) * 1000), total_ms=round((time.time() - t0) * 1000), result=s))
2208
+ if old and old[2] != fn and cfg.slot_dir:
2209
+ try:
2210
+ os.remove(os.path.join(cfg.slot_dir, old[2]))
2211
+ except OSError:
2212
+ pass
2213
+ if cfg.kv_archive:
2214
+ from .kvarchive import discard
2215
+ discard(cfg, old[2], self.journal)
2216
+ except Exception as e: # noqa: BLE001
2217
+ events.append(self.journal.log("anchor_error", conv=conv, step="save", error=str(e)[:300]))
2218
+ return events