freegate 0.6.15 → 0.6.17

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.
@@ -0,0 +1,220 @@
1
+ // lib/compactor.js
2
+ // Detects when a conversation exceeds the context window of free models and
3
+ // compresses old messages into a single summary via a long-context model.
4
+ // Zero deps: pure Node built-ins + existing callProvider/lib.
5
+
6
+ const crypto = require('crypto');
7
+ const path = require('path');
8
+ const { PROVIDERS, callProvider } = require('./providers');
9
+
10
+ const COMPACT_THRESHOLD = 60000; // tokens — compact above 60k. estimateTokens intentionally
11
+ // UNDERCOUNTS (only text content we can see), while opencode's
12
+ // session counter includes cache/role tokens + huge system prompt.
13
+ // Empirically opencode reads ~2x our estimate on real sessions,
14
+ // so 60k est ≈ 120-160k real — safely below free-model windows
15
+ // while keeping context summaries fresh.
16
+ const KEEP_RECENT_TOKENS = 30000; // tokens — recent messages kept untouched (preserve session context)
17
+ const MAX_COMPACT_THRESHOLD = 100000; // est tokens — cap for window-aware thresholds (≈200k real,
18
+ // sits safely inside even 512k/1M windows without compacting too early)
19
+ const SUMMARY_MAX_TOKENS = 2000; // tokens — summary length cap (detailed enough to not lose the work)
20
+ const CHARS_PER_TOKEN = 1.5; // heuristic chars→tokens, DOUBLY conservative: real sessions
21
+ // (with tool outputs, files, big system prompts) average ~0.75
22
+ // chars/token, not ~4. Using 1.5 keeps us from compacting too late.
23
+
24
+ // Reliability-ordered long-context summarizers (OpenRouter + HF).
25
+ const SUMMARIZERS = [
26
+ 'or-nemotron-35', 'or-dots-3', 'or-minimax-m2-7-free',
27
+ 'or-lfm', 'deepseek',
28
+ ];
29
+
30
+ // Summary cache: hash of compacted messages → summary string.
31
+ // In-memory only (like the main cache) — survives across requests, not restarts.
32
+ const summaryCache = new Map();
33
+ const SUMMARY_CACHE_MAX = 300;
34
+
35
+ // --- Долговременная память (vector memory) ---
36
+ // Компактор извлекает факты попутно с резюме — НЕ добавляя ни одного лишнего
37
+ // LLM-вызова. Память инжектируется из server.js (setMemory), чтобы не заводить
38
+ // жёсткую связанность между модулями.
39
+ let memoryStore = null;
40
+ const MAX_FACTS_PER_COMPACTION = 5;
41
+
42
+ function setMemory(store) {
43
+ memoryStore = store;
44
+ }
45
+
46
+ // Summarizer запрашивается вернуть JSON вида {"summary": "...", "facts": [...]}.
47
+ // Многие модели вернут чистый текст — тогда берём только текст (старое
48
+ // поведение, никаких потерь). Парсим оба варианта + fenced json.
49
+ function parseSummaryResponse(text) {
50
+ if (!text) return { summary: '', facts: [] };
51
+ const trimmed = String(text).trim();
52
+ const attempt = (s) => {
53
+ try {
54
+ const obj = JSON.parse(s);
55
+ if (!obj || typeof obj !== 'object') return null;
56
+ const summary = typeof obj.summary === 'string' ? obj.summary : '';
57
+ const facts = Array.isArray(obj.facts)
58
+ ? obj.facts.filter(f => typeof f === 'string' && f.trim().length > 2)
59
+ : [];
60
+ return { summary, facts };
61
+ } catch { return null; }
62
+ };
63
+ let parsed = attempt(trimmed);
64
+ if (!parsed) {
65
+ const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);
66
+ if (fenced) parsed = attempt(fenced[1].trim());
67
+ }
68
+ if (parsed) return parsed;
69
+ return { summary: trimmed, facts: [] };
70
+ }
71
+
72
+ // Сохранить факты в долговременную память (если она подключена).
73
+ function ingestFacts(facts, sourceHash) {
74
+ if (!memoryStore || !Array.isArray(facts) || facts.length === 0) return 0;
75
+ let n = 0;
76
+ for (const f of facts.slice(0, MAX_FACTS_PER_COMPACTION)) {
77
+ if (memoryStore.add(f, { sourceHash })) n++;
78
+ }
79
+ return n;
80
+ }
81
+
82
+ // --- Estimate tokens from messages array ---
83
+ // Conservative: counts text + images + tool calls + attachments, and uses a
84
+ // lower chars-per-token ratio so the estimate is closer to real provider usage
85
+ // (which also counts role/formatting tokens). Undercounting means we compact
86
+ // too late and the free models reject the request.
87
+ function estimateTokens(messages) {
88
+ if (!Array.isArray(messages) || messages.length === 0) return 0;
89
+ let chars = 0;
90
+ for (const m of messages) {
91
+ if (!m || typeof m !== 'object') continue;
92
+ // Tool calls / attachments / function results add a lot.
93
+ if (Array.isArray(m.tool_calls)) chars += 200 * m.tool_calls.length;
94
+ if (Array.isArray(m.attachments)) chars += 300 * m.attachments.length;
95
+ if (Array.isArray(m.file)) chars += 500;
96
+ if (typeof m.content === 'string') chars += m.content.length;
97
+ else if (Array.isArray(m.content)) {
98
+ for (const c of m.content) {
99
+ if (!c || typeof c !== 'object') continue;
100
+ if (typeof c.text === 'string') chars += c.text.length;
101
+ else if (c.image_url && typeof c.image_url === 'object') chars += 800; // images/vision count a lot
102
+ else if (c.image || c.type === 'image' || c.type === 'input_image') chars += 800;
103
+ }
104
+ }
105
+ }
106
+ return Math.ceil(chars / CHARS_PER_TOKEN);
107
+ }
108
+
109
+ // Эффективный порог компакции зависит от реального окна целевого провайдера.
110
+ // estimateTokens намеренно занижает (~2x реальных токенов), поэтому для
111
+ // известного окна держим est <= win*0.5 (реальные токены точно влезут) и
112
+ // капаем на MAX_COMPACT_THRESHOLD — большие окна (512k/1M) не заставляют
113
+ // компактировать раньше необходимости. Неизвестное окно — прежнее поведение.
114
+ function compactionThresholdFor(contextWindow) {
115
+ if (!contextWindow || contextWindow <= 0) return COMPACT_THRESHOLD;
116
+ return Math.min(Math.floor(contextWindow * 0.5), MAX_COMPACT_THRESHOLD);
117
+ }
118
+
119
+ // --- Build the compaction algorithm structure (logic filled in Task 2) ---
120
+ async function prepareMessages(messages, opts = {}) {
121
+ // Compact if above threshold; otherwise return unchanged.
122
+ if (!Array.isArray(messages) || messages.length === 0) return messages;
123
+ const total = estimateTokens(messages);
124
+ if (total <= compactionThresholdFor(opts.contextWindow)) return messages;
125
+ return compactOld(messages);
126
+ }
127
+
128
+ // --- Split messages: recent kept, old summarized ---
129
+ async function compactOld(messages) {
130
+ // System messages (opencode AGENTS.md, plugin skills) are huge and critical —
131
+ // NEVER summarize or drop them. Split them off first.
132
+ const systemMsgs = messages.filter(m => m && m.role === 'system');
133
+ const rest = messages.filter(m => !m || m.role !== 'system');
134
+
135
+ // Walk from the end accumulating tokens until we fill KEEP_RECENT_TOKENS.
136
+ const recent = [];
137
+ let recentTokens = 0;
138
+ for (let i = rest.length - 1; i >= 0; i--) {
139
+ const m = rest[i];
140
+ const tokens = estimateTokens([m]);
141
+ if (recentTokens + tokens > KEEP_RECENT_TOKENS && recent.length > 0) {
142
+ break;
143
+ }
144
+ recent.unshift(m);
145
+ recentTokens += tokens;
146
+ }
147
+ // old = everything before the recent tail (non-system); never summarize system.
148
+ const old = rest.slice(0, rest.length - recent.length);
149
+ if (old.length === 0) return messages; // nothing compactable (single huge msg → keep all)
150
+
151
+ const summary = await module.exports.getSummary(old);
152
+ const finalLength = estimateTokens(systemMsgs) + estimateTokens(recent) + (summary ? Math.ceil(summary.length / CHARS_PER_TOKEN) : 0);
153
+ if (!summary || finalLength >= estimateTokens(messages)) {
154
+ // No summary produced or compaction didn't help — return unchanged.
155
+ return messages;
156
+ }
157
+ return [
158
+ ...systemMsgs,
159
+ { role: 'user', content: '[Резюме предыдущего диалога]\n' + summary },
160
+ ...recent,
161
+ ];
162
+ }
163
+
164
+ // --- Generate a summary for the given messages via fallback chain ---
165
+ async function getSummary(messages) {
166
+ const hash = crypto.createHash('sha256').update(JSON.stringify(messages)).digest('hex');
167
+ if (summaryCache.has(hash)) {
168
+ return summaryCache.get(hash);
169
+ }
170
+ const raw = await summarizeViaChain(messages);
171
+ const { summary, facts } = parseSummaryResponse(raw);
172
+ // Long-term memory: keep facts as a side effect of the same LLM call (no extra cost).
173
+ if (facts.length > 0) ingestFacts(facts, hash);
174
+ if (summary) {
175
+ if (summaryCache.size >= SUMMARY_CACHE_MAX) {
176
+ // Evict oldest inserted entry.
177
+ const firstKey = summaryCache.keys().next().value;
178
+ summaryCache.delete(firstKey);
179
+ }
180
+ summaryCache.set(hash, summary);
181
+ }
182
+ return summary;
183
+ }
184
+
185
+ // --- Call long-context models in order until one returns a summary ---
186
+ async function summarizeViaChain(messages) {
187
+ const system = 'Ты — система компактизации контекста. Сожми старую часть диалога в ПОДРОБНОЕ резюме, чтобы читатель мог продолжить работу без потери контекста.\n\n' +
188
+ 'Обязательно сохрани:\n' +
189
+ '1. ЦЕЛЬ задачи и над чем работали (проект, файлы, функции)\n' +
190
+ '2. Принятые РЕШЕНИЯ и почему\n' +
191
+ '3. Достигнутый ПРОГРЕСС и текущий статус (что готово, что нет)\n' +
192
+ '4. Ключевые факты, найденные ошибки, исправления\n' +
193
+ '5. Следующие шаги / что осталось сделать\n\n' +
194
+ 'Пиши структурированно и подробно (до ' + SUMMARY_MAX_TOKENS + ' токенов). Не выдумывай. Верни только текст резюме, без вступлений.\n\n' +
195
+ 'Идеально — сразу JSON вида {"summary": "<резюме>", "facts": ["<факт1>", "<факт2>"]}, где facts — до 5 коротких фактов (имена файлов/функций, принятые решения, найденные ошибки, текущий статус). Если JSON неудобен — просто текст резюме; он будет использован как есть.';
196
+ const textToSummarize = JSON.stringify(messages);
197
+ for (const key of SUMMARIZERS) {
198
+ const provider = PROVIDERS[key];
199
+ if (!provider || !provider.enabled) continue;
200
+ try {
201
+ const result = await callProvider(provider, {
202
+ model: provider.model,
203
+ messages: [
204
+ { role: 'system', content: system },
205
+ { role: 'user', content: 'Сожми следующий диалог:\n\n' + textToSummarize },
206
+ ],
207
+ max_tokens: SUMMARY_MAX_TOKENS,
208
+ }, 30000, 1);
209
+ const summary = result.data?.choices?.[0]?.message?.content;
210
+ if (summary && summary.trim().length >= 10) {
211
+ return summary.trim();
212
+ }
213
+ } catch (err) {
214
+ // try next summarizer
215
+ }
216
+ }
217
+ return '';
218
+ }
219
+
220
+ module.exports = { estimateTokens, prepareMessages, compactOld, getSummary, summaryCache, COMPACT_THRESHOLD, KEEP_RECENT_TOKENS, SUMMARY_MAX_TOKENS, SUMMARIZERS, parseSummaryResponse, ingestFacts, setMemory, MAX_FACTS_PER_COMPACTION, compactionThresholdFor, MAX_COMPACT_THRESHOLD };
@@ -0,0 +1,224 @@
1
+ // lib/contextstats.js
2
+ // Контекстная телеметрия: агрегаты «где теряется контекст» по бакетам
3
+ // isoHour|provider. Zero-dep, никогда не бросает, переживает рестарты через
4
+ // stats.context в state.json (см. lib/health.js).
5
+
6
+ const ISO_HOUR_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:00$/;
7
+ const RETENTION_MS = 7 * 24 * 3600 * 1000;
8
+
9
+ function isoHourOf(ts) {
10
+ const d = new Date(ts);
11
+ d.setMinutes(0, 0, 0);
12
+ return d.toISOString().slice(0, 13) + ':00'; // "2026-08-29T20:00"
13
+ }
14
+
15
+ function tsOfHour(hour) {
16
+ return Date.parse(hour + ':00Z');
17
+ }
18
+
19
+ function emptyBucket(hour, provider) {
20
+ return {
21
+ key: hour + '|' + provider,
22
+ hour,
23
+ provider,
24
+ ts: tsOfHour(hour),
25
+ requests: 0,
26
+ sumEst: 0,
27
+ sumReal: 0,
28
+ ratioSum: 0,
29
+ ratioCount: 0,
30
+ ratioMax: 0,
31
+ nearWindow: 0,
32
+ overWindow: 0,
33
+ compactCount: 0,
34
+ upgradedCount: 0,
35
+ memoryCount: 0,
36
+ cacheExact: 0,
37
+ cacheSem: 0,
38
+ status200: 0,
39
+ status400: 0,
40
+ status429: 0,
41
+ statusOther: 0,
42
+ sysShareSum: 0,
43
+ tasks: { coding: 0, reasoning: 0, search: 0, chat: 0 },
44
+ };
45
+ }
46
+
47
+ class ContextStats {
48
+ constructor() {
49
+ this.buckets = new Map(); // key -> bucket
50
+ }
51
+
52
+ record(m) {
53
+ try {
54
+ if (!m || typeof m !== 'object') return;
55
+ const provider = (typeof m.provider === 'string' && m.provider.length > 0) ? m.provider : 'unknown';
56
+ const hour = isoHourOf(typeof m.ts === 'number' && m.ts > 0 ? m.ts : Date.now());
57
+ const key = hour + '|' + provider;
58
+ let b = this.buckets.get(key);
59
+ if (!b) { b = emptyBucket(hour, provider); this.buckets.set(key, b); }
60
+
61
+ b.requests++;
62
+ if (typeof m.est === 'number' && m.est > 0) b.sumEst += m.est;
63
+ if (typeof m.real === 'number' && m.real > 0) {
64
+ b.sumReal += m.real;
65
+ const win = (typeof m.win === 'number' && m.win > 0) ? m.win : 0;
66
+ if (win > 0) {
67
+ const ratio = m.real / win;
68
+ b.ratioSum += ratio;
69
+ b.ratioCount++;
70
+ if (ratio > b.ratioMax) b.ratioMax = ratio;
71
+ if (m.real > win) b.overWindow++;
72
+ else if (ratio >= 0.9) b.nearWindow++;
73
+ }
74
+ }
75
+ if (m.compacted) b.compactCount++;
76
+ if (m.upgraded) b.upgradedCount++;
77
+ if (m.memory) b.memoryCount++;
78
+ if (m.taskCategory && b.tasks && typeof b.tasks[m.taskCategory] === 'number') b.tasks[m.taskCategory]++;
79
+ if (m.cacheType === 'exact') b.cacheExact++;
80
+ else if (m.cacheType === 'semcache') b.cacheSem++;
81
+ if (typeof m.sysShare === 'number' && m.sysShare > 0 && m.sysShare <= 1) b.sysShareSum += m.sysShare;
82
+ const st = typeof m.status === 'number' ? m.status : 0;
83
+ if (st === 200) b.status200++;
84
+ else if (st === 400) b.status400++;
85
+ else if (st === 429) b.status429++;
86
+ else if (st > 0) b.statusOther++;
87
+ } catch (err) {
88
+ // Никогда не валим запрос из-за телеметрии.
89
+ }
90
+ }
91
+
92
+ prune() {
93
+ const cutoff = Date.now() - RETENTION_MS;
94
+ for (const [key, b] of this.buckets) {
95
+ if (b.ts < cutoff) this.buckets.delete(key);
96
+ }
97
+ }
98
+
99
+ snapshot(windowMs = RETENTION_MS) {
100
+ this.prune();
101
+ const cutoff = Date.now() - windowMs;
102
+ const out = [];
103
+ for (const b of this.buckets.values()) {
104
+ if (b.ts < cutoff) continue;
105
+ out.push(b);
106
+ }
107
+ out.sort((x, y) => (x.key < y.key ? -1 : x.key > y.key ? 1 : 0));
108
+ return { buckets: out };
109
+ }
110
+
111
+ serialize() {
112
+ this.prune();
113
+ return { buckets: [...this.buckets.values()] };
114
+ }
115
+
116
+ load(saved) {
117
+ try {
118
+ this.buckets = new Map();
119
+ if (!saved || !Array.isArray(saved.buckets)) return;
120
+ const now = Date.now();
121
+ for (const raw of saved.buckets) {
122
+ if (!raw || typeof raw !== 'object' || typeof raw.key !== 'string') continue;
123
+ const sep = raw.key.indexOf('|');
124
+ if (sep < 0) continue;
125
+ const hour = raw.key.slice(0, sep);
126
+ const provider = raw.key.slice(sep + 1);
127
+ if (!ISO_HOUR_RE.test(hour) || provider.length === 0) continue;
128
+ const b = emptyBucket(hour, provider);
129
+ for (const f of Object.keys(b)) {
130
+ if (f === 'key' || f === 'hour' || f === 'provider' || f === 'ts') continue;
131
+ if (typeof raw[f] === 'number' && raw[f] >= 0) b[f] = raw[f];
132
+ }
133
+ if (raw.tasks && typeof raw.tasks === 'object') {
134
+ for (const k of Object.keys(b.tasks)) {
135
+ if (typeof raw.tasks[k] === 'number' && raw.tasks[k] >= 0) b.tasks[k] = raw.tasks[k];
136
+ }
137
+ }
138
+ // ratioCount добавлен позже: у legacy-бакетов ratioSum есть, а ratioCount
139
+ // нет (или 0). Обнуляем ratioSum, чтобы свежие записи не смешивались с
140
+ // legacy-суммой (иначе avgRatio завышается). ratioMax сохраняем — он
141
+ // по-прежнему точен для «Max ratio».
142
+ if (b.ratioSum > 0 && b.ratioCount === 0) b.ratioSum = 0;
143
+ if (b.ts > now - RETENTION_MS) this.buckets.set(b.key, b);
144
+ }
145
+ } catch (err) {
146
+ this.buckets = new Map();
147
+ }
148
+ }
149
+
150
+ summary(now = Date.now()) {
151
+ const winMs = 24 * 3600 * 1000;
152
+ const cutoff = now - winMs;
153
+ let totalRequests = 0;
154
+ let ratioSum = 0;
155
+ let ratioCount = 0;
156
+ let ratioMax = 0;
157
+ let nearWindow = 0;
158
+ let overWindow = 0;
159
+ let compactCount = 0;
160
+ let upgradedCount = 0;
161
+ let cacheHits = 0;
162
+ let sysShareSum = 0;
163
+ let sysShareRequests = 0;
164
+ const providers = {};
165
+ let taskTotal = 0;
166
+ const tasks = { coding: 0, reasoning: 0, search: 0, chat: 0 };
167
+
168
+ for (const b of this.buckets.values()) {
169
+ if (b.ts < cutoff) continue;
170
+ totalRequests += b.requests;
171
+ if (b.ratioCount > 0) {
172
+ ratioSum += b.ratioSum;
173
+ ratioCount += b.ratioCount;
174
+ }
175
+ if (b.ratioMax > ratioMax) ratioMax = b.ratioMax;
176
+ nearWindow += b.nearWindow;
177
+ overWindow += b.overWindow;
178
+ compactCount += b.compactCount;
179
+ upgradedCount += b.upgradedCount;
180
+ cacheHits += b.cacheExact + b.cacheSem;
181
+ if (b.sysShareSum > 0) { sysShareSum += b.sysShareSum; sysShareRequests += b.requests; }
182
+ for (const key of Object.keys(tasks)) {
183
+ const n = (b.tasks && b.tasks[key]) || 0;
184
+ if (n > 0) { tasks[key] += n; taskTotal += n; }
185
+ }
186
+ const p = providers[b.provider] || (providers[b.provider] = { requests: 0, nearWindow: 0, overWindow: 0, compactCount: 0, upgradedCount: 0 });
187
+ p.requests += b.requests;
188
+ p.nearWindow += b.nearWindow;
189
+ p.overWindow += b.overWindow;
190
+ p.compactCount += b.compactCount;
191
+ p.upgradedCount += b.upgradedCount;
192
+ }
193
+
194
+ const narrowProviders = Object.keys(providers)
195
+ .filter(k => providers[k].requests >= 20 && providers[k].nearWindow / providers[k].requests >= 0.2)
196
+ .sort();
197
+
198
+ let status = 'OK';
199
+ if (overWindow > 0) status = 'OVERFLOW';
200
+ else if (sysShareRequests > 0 && sysShareSum / sysShareRequests >= 0.5) status = 'SYS_HEAVY';
201
+ else if (narrowProviders.length > 0) status = 'NARROW';
202
+
203
+ return {
204
+ status,
205
+ totalRequests,
206
+ ratePerHour: Math.round((totalRequests / 24) * 10) / 10,
207
+ avgRatio: ratioCount > 0 ? Math.round((ratioSum / ratioCount) * 1000) / 1000 : 0,
208
+ ratioMax: Math.round(ratioMax * 1000) / 1000,
209
+ nearWindow,
210
+ overWindow,
211
+ narrowProviders,
212
+ compactCount,
213
+ upgradedCount,
214
+ cacheHits,
215
+ cacheHitRate: totalRequests > 0 ? Math.round((cacheHits / totalRequests) * 100) : 0,
216
+ avgSysShare: sysShareRequests > 0 ? Math.round((sysShareSum / sysShareRequests) * 1000) / 1000 : 0,
217
+ tasks,
218
+ taskTotal,
219
+ providers,
220
+ };
221
+ }
222
+ }
223
+
224
+ module.exports = { ContextStats, isoHourOf, tsOfHour };