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/__init__.py +8 -0
- ctxproxy/api_anthropic.py +426 -0
- ctxproxy/api_responses.py +436 -0
- ctxproxy/autosave.py +350 -0
- ctxproxy/check.py +356 -0
- ctxproxy/compat.py +91 -0
- ctxproxy/core.py +2218 -0
- ctxproxy/demo.py +193 -0
- ctxproxy/engines.py +314 -0
- ctxproxy/fake_engine.py +304 -0
- ctxproxy/kvarchive.py +855 -0
- ctxproxy/lang.py +57 -0
- ctxproxy/mcp_server.py +381 -0
- ctxproxy/packtok.py +29 -0
- ctxproxy/paging.py +508 -0
- ctxproxy/recall2.py +1003 -0
- ctxproxy/render.py +274 -0
- ctxproxy/server.py +797 -0
- ctxproxy/tooldefs.py +111 -0
- ctxproxy/upstream.py +106 -0
- mnemonic_proxy-0.3.2.dist-info/METADATA +841 -0
- mnemonic_proxy-0.3.2.dist-info/RECORD +26 -0
- mnemonic_proxy-0.3.2.dist-info/WHEEL +5 -0
- mnemonic_proxy-0.3.2.dist-info/entry_points.txt +4 -0
- mnemonic_proxy-0.3.2.dist-info/licenses/LICENSE +21 -0
- mnemonic_proxy-0.3.2.dist-info/top_level.txt +1 -0
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
|