freegate 0.6.14 → 0.6.16

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,43 @@
1
+ // lib/economics.js — оценка экономии Freegate против платных API.
2
+ // Freegate бесплатен (маршрутизация по free-моделям), но пользователь получил
3
+ // бы объём работы. Оцениваем, сколько стоил бы этот же трафик у платных
4
+ // провайдеров, показываем экономию в $. Цены — репрезентативные (USD / 1M
5
+ // токенов), усреднённые по индустрии; расчёт консервативный (в пользу «дорого»,
6
+ // чтобы не преувеличивать экономию).
7
+
8
+ // Репрезентативные цены за 1M токенов (input / output), USD. Усреднено по
9
+ // популярным моделям (GPT-4o, Claude, Gemini Pro): input ~$3-5, output ~$15.
10
+ const PRICE_PER_M_INPUT = 3;
11
+ const PRICE_PER_M_OUTPUT = 15;
12
+
13
+ // Расчёт стоимости заданного объёма токенов.
14
+ function costOfTokens(promptTokens = 0, completionTokens = 0) {
15
+ const inputCost = (promptTokens / 1000000) * PRICE_PER_M_INPUT;
16
+ const outputCost = (completionTokens / 1000000) * PRICE_PER_M_OUTPUT;
17
+ return { usd: inputCost + outputCost, inputUsd: inputCost, outputUsd: outputCost };
18
+ }
19
+
20
+ // Агрегация по всем провайдерам из tokenUsage. Возвращает итоговую экономию.
21
+ function aggregateSavings(tokenUsage = {}) {
22
+ let prompt = 0, completion = 0;
23
+ for (const [_, tu] of Object.entries(tokenUsage)) {
24
+ prompt += tu.promptTokens || 0;
25
+ completion += tu.completionTokens || 0;
26
+ }
27
+ const total = prompt + completion;
28
+ const cost = costOfTokens(prompt, completion);
29
+ // Экономия = стоимость платного аналога (Freegate этого не стоит — 0).
30
+ return {
31
+ promptTokens: prompt,
32
+ completionTokens: completion,
33
+ totalTokens: total,
34
+ savedUsd: cost.usd,
35
+ savedInputUsd: cost.inputUsd,
36
+ savedOutputUsd: cost.outputUsd,
37
+ costPerMInput: PRICE_PER_M_INPUT,
38
+ costPerMOutput: PRICE_PER_M_OUTPUT,
39
+ basis: `input $${PRICE_PER_M_INPUT}/M, output $${PRICE_PER_M_OUTPUT}/M (репрезентативно)`,
40
+ };
41
+ }
42
+
43
+ module.exports = { PRICE_PER_M_INPUT, PRICE_PER_M_OUTPUT, costOfTokens, aggregateSavings };
package/lib/health.js CHANGED
@@ -2,12 +2,14 @@
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
  const logger = require('./logger');
5
+ const { ContextStats } = require('./contextstats');
5
6
 
6
- const STATE_PATH = path.join(__dirname, '..', 'state.json');
7
+ const STATE_PATH = process.env.STATE_PATH || path.join(__dirname, '..', 'state.json');
7
8
 
8
9
  let health = {};
9
10
  let circuitBreakers = {};
10
- let stats = { totalRequests: 0, successfulRequests: 0, failedRequests: 0, providerUsage: {}, errors: {}, startTime: Date.now(), tokenUsage: {}, bandit: { low: {}, med: {}, high: {} } };
11
+ let stats = { totalRequests: 0, successfulRequests: 0, failedRequests: 0, providerUsage: {}, errors: {}, errorsDay: '', startTime: Date.now(), tokenUsage: {}, bandit: { low: {}, med: {}, high: {} } };
12
+ const contextStats = new ContextStats();
11
13
  let lastSelection = null;
12
14
 
13
15
  const MAX_RECENT = 50;
@@ -27,12 +29,21 @@ function loadState() {
27
29
  failedRequests: saved.failedRequests || 0,
28
30
  providerUsage: saved.providerUsage || {},
29
31
  errors: saved.errors || {},
32
+ errorsDay: saved.errorsDay || '',
30
33
  startTime: saved.startTime || Date.now(),
31
34
  tokenUsage: saved.tokenUsage || {},
32
35
  dailyUsage: saved.dailyUsage || {},
33
36
  reliability: saved.reliability || {},
34
37
  bandit: saved.bandit || { low: {}, med: {}, high: {} },
35
38
  };
39
+ contextStats.load(saved.context);
40
+ // Reset the error counter for a new day. state.json may have been written
41
+ // on a previous day (or lack errorsDay), so clear all-time errors now.
42
+ const today = new Date().toISOString().slice(0, 10);
43
+ if (stats.errorsDay !== today) {
44
+ stats.errorsDay = today;
45
+ stats.errors = {};
46
+ }
36
47
  logger.info('State loaded', { healthKeys: Object.keys(health) });
37
48
  } catch {
38
49
  logger.info('No state file, starting fresh');
@@ -52,7 +63,8 @@ function getLastSelection() { return lastSelection; }
52
63
 
53
64
  function saveState() {
54
65
  try {
55
- fs.writeFileSync(STATE_PATH, JSON.stringify({ health, circuitBreakers, stats }, null, 2));
66
+ const context = contextStats.serialize();
67
+ fs.writeFileSync(STATE_PATH, JSON.stringify({ health, circuitBreakers, stats: { ...stats, context } }, null, 2));
56
68
  } catch (err) {
57
69
  logger.error('Failed to save state', { error: err.message });
58
70
  }
@@ -82,13 +94,13 @@ function recordSuccess(key) {
82
94
  health[key].score = Math.min(100, health[key].score + 10);
83
95
  }
84
96
 
85
- function recordFailure(key, statusCode) {
97
+ function recordFailure(key, statusCode, opts) {
86
98
  if (!circuitBreakers[key]) circuitBreakers[key] = { failures: 0, lastFailure: 0, openUntil: null };
87
99
  const cb = circuitBreakers[key];
88
100
  cb.failures++;
89
101
  cb.lastFailure = Date.now();
90
102
  // Classify errors: hard errors open the breaker much longer than soft ones
91
- const openMs = classifyErrorMs(statusCode);
103
+ const openMs = classifyErrorMs(statusCode, opts);
92
104
  if (cb.failures >= 3 || openMs > 60000) {
93
105
  cb.openUntil = Date.now() + Math.max(openMs, 60000);
94
106
  cb.failures = 0;
@@ -99,12 +111,19 @@ function recordFailure(key, statusCode) {
99
111
 
100
112
  // How long to hold the breaker open for a given HTTP status.
101
113
  // 401/403 = bad key (long), 429 = rate limit (medium), 5xx = overload (short, retry soon)
102
- function classifyErrorMs(statusCode) {
114
+ function classifyErrorMs(statusCode, opts) {
103
115
  const s = statusCode || 0;
104
116
  if (s === 401 || s === 403) return 300000; // 5 min — key is dead
105
117
  if (s === 429) return 120000; // 2 min — rate limited, wait for reset
106
118
  if (s >= 500) return 30000; // 30s — transient overload
107
- if (s === 404) return 300000; // 5 min — model/endpoint gone
119
+ if (s === 404) {
120
+ // provider-side 404 (upstream temporarily failing) → short break so the
121
+ // periodic health-check gets a chance to re-enable; only a TRUE "model
122
+ // does not exist" 404 (handled in server.js as permanent disable) gets the
123
+ // long trip.
124
+ if (opts && opts.providerSide) return 70000; // 70s — Nvidia/etc. quota/overload
125
+ return 300000; // 5 min — model/endpoint gone
126
+ }
108
127
  return 60000; // default
109
128
  }
110
129
 
@@ -112,6 +131,8 @@ function recordRequest(providerKey, success, error = null) {
112
131
  recordRpm();
113
132
  stats.totalRequests++;
114
133
  const today = new Date().toISOString().slice(0, 10);
134
+ // Error counter is all-time by default; reset it once per UTC day (like reliability).
135
+ if (stats.errorsDay !== today) { stats.errorsDay = today; stats.errors = {}; }
115
136
  if (success) {
116
137
  stats.successfulRequests++;
117
138
  stats.providerUsage[providerKey] = (stats.providerUsage[providerKey] || 0) + 1;
@@ -153,6 +174,7 @@ function recordTokens(providerKey, usage) {
153
174
 
154
175
  function getHealth() { return health; }
155
176
  function getStats() { return stats; }
177
+ function getCircuitBreakers() { return circuitBreakers; }
156
178
 
157
179
  function recordRecent(entry) {
158
180
  recent.unshift({ timestamp: Date.now(), ...entry });
@@ -185,6 +207,30 @@ function recordBandit(bucketName, key, success) {
185
207
  else stats.bandit[bucketName][key].b += 1;
186
208
  }
187
209
 
210
+ // Warm start for bandit priors: a brand-new (or auto-disabled-then-re-enabled)
211
+ // provider enters with Beta(1,1) = uniform, which makes Thompson sampling pick it
212
+ // with ~random weight at first and explore for a LONG time before trusting it —
213
+ // even if its weight/score is good. This biases those providers toward a mild
214
+ // optimistic prior (a=6,b=2 → mean 75%, ~8 trials of confidence) so they get
215
+ // selected soon after going live, without ever dominating hardened favorites
216
+ // (e.g. or-ox-alpha with dozens of real outcomes).
217
+ function warmBanditPriors(enabledKeys, bucketKeys) {
218
+ if (!Array.isArray(enabledKeys) || bucketKeys.length === 0) return 0;
219
+ let warmed = 0;
220
+ for (const key of enabledKeys) {
221
+ for (const bucketName of bucketKeys) {
222
+ stats.bandit[bucketName] = stats.bandit[bucketName] || {};
223
+ const p = stats.bandit[bucketName][key];
224
+ // Only touch true cold-starts or tiny dead priors; NEVER reset proven ones.
225
+ if (!p || (p.a === 1 && p.b === 1) || (p.a + p.b <= 4)) {
226
+ stats.bandit[bucketName][key] = { a: 6, b: 2 };
227
+ warmed++;
228
+ }
229
+ }
230
+ }
231
+ return warmed;
232
+ }
233
+
188
234
  // Auto-save every 30 seconds
189
235
  const _saveTimer = setInterval(saveState, 30000);
190
236
 
@@ -193,10 +239,11 @@ function _stopTimers() { clearInterval(_saveTimer); }
193
239
  module.exports = {
194
240
  loadState, saveState, initHealth, isCircuitOpen,
195
241
  recordSuccess, recordFailure, recordRequest, recordTokens,
196
- getHealth, getStats,
242
+ getHealth, getStats, getCircuitBreakers,
197
243
  recordRecent, recordRpm, getRecent, getRpm,
198
244
  getDailyUsage, getReliability,
199
245
  recordSelection, getLastSelection,
200
- getBandit, recordBandit,
246
+ getBandit, recordBandit, warmBanditPriors,
247
+ getContextStats: () => contextStats,
201
248
  _stopTimers,
202
249
  };
package/lib/logger.js CHANGED
@@ -2,7 +2,7 @@
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
 
5
- const LOG_PATH = path.join(__dirname, '..', 'proxy.log');
5
+ const LOG_PATH = process.env.LOG_PATH || path.join(__dirname, '..', 'proxy.log');
6
6
  const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10MB
7
7
  const MAX_ROTATIONS = 3;
8
8
 
@@ -0,0 +1,68 @@
1
+ // lib/memory-store.js
2
+ // Персистентность долговременной памяти (memory.json) поверх MemStore из memory.js.
3
+ // Zero deps, corrupt-safe (аналогично state.json/cache.json).
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { MemStore } = require('./memory');
8
+
9
+ const DEFAULT_PATH = path.join(__dirname, '..', 'memory.json');
10
+ const TRANSACTION_SUFFIX = '.tmp';
11
+
12
+ class MemoryStore extends MemStore {
13
+ constructor(opts = {}) {
14
+ const capacity = opts.capacity ?? 800;
15
+ super(capacity);
16
+ this.filePath = opts.filePath || DEFAULT_PATH;
17
+ this.saveTimeout = null;
18
+ this.load();
19
+ }
20
+
21
+ load() {
22
+ try {
23
+ const raw = fs.readFileSync(this.filePath, 'utf8');
24
+ const data = JSON.parse(raw);
25
+ if (data && Array.isArray(data.facts)) {
26
+ const entries = data.facts.map(f => ({ text: f.text, sourceHash: f.sourceHash, created: f.created, access: f.access }));
27
+ const added = this.import(entries);
28
+ if (added > 0 && entries.length !== added) {
29
+ // частичная дедупликация при загрузке — норм
30
+ }
31
+ }
32
+ } catch (err) {
33
+ // отсутствует или битый файл → пустой старт, не падаем
34
+ }
35
+ }
36
+
37
+ save() {
38
+ try {
39
+ const facts = this._facts.map(f => ({
40
+ text: f.text,
41
+ sourceHash: f.sourceHash || null,
42
+ created: f.created,
43
+ access: f.access,
44
+ }));
45
+ const payload = JSON.stringify({ version: 1, saved: Date.now(), facts }, null, 2);
46
+ const tmp = this.filePath + TRANSACTION_SUFFIX;
47
+ fs.writeFileSync(tmp, payload);
48
+ fs.renameSync(tmp, this.filePath); // atomic-ish write
49
+ return true;
50
+ } catch (err) {
51
+ try { fs.unlinkSync(this.filePath + TRANSACTION_SUFFIX); } catch {}
52
+ return false;
53
+ }
54
+ }
55
+
56
+ // Debounce: не писать на каждый add — асинхронный приверженный по таймеру.
57
+ scheduleSave(delayMs = 10000) {
58
+ if (this.saveTimeout) clearTimeout(this.saveTimeout);
59
+ this.saveTimeout = setTimeout(() => { this.saveTimeout = null; this.save(); }, delayMs);
60
+ if (this.saveTimeout.unref) this.saveTimeout.unref();
61
+ }
62
+
63
+ stopTimer() {
64
+ if (this.saveTimeout) { clearTimeout(this.saveTimeout); this.saveTimeout = null; }
65
+ }
66
+ }
67
+
68
+ module.exports = { create: (opts) => new MemoryStore(opts || {}), MemoryStore, DEFAULT_PATH };
package/lib/memory.js ADDED
@@ -0,0 +1,167 @@
1
+ // lib/memory.js
2
+ // Долговременная память Freegate: факты, извлечённые при компакции контекста,
3
+ // с поиском по лёгкому вектору в стиле TF-IDF/Jaccard (без внешних зависимостей).
4
+ // Токенизация учитывает русскую морфологию через суффикс-триграммы.
5
+
6
+ const crypto = require('crypto');
7
+
8
+ // Минимальный стоп-лист (рус + англ). Токены короче 3 символов и пунктуация
9
+ // выпадают вместе с ними — это снимает большинство «служебного» шума.
10
+ const STOP_WORDS = new Set([
11
+ 'как', 'что', 'чем', 'кому', 'кем', 'где', 'куда', 'откуда', 'когда', 'почему',
12
+ 'зачем', 'кто', 'наш', 'нам', 'нами', 'вам', 'все', 'всё', 'это', 'этот', 'эта',
13
+ 'при', 'про', 'без', 'для', 'до', 'из', 'на', 'над', 'не', 'ни', 'но', 'и', 'или',
14
+ 'о', 'об', 'от', 'по', 'под', 'с', 'со', 'у', 'в', 'во', 'же', 'бы', 'был', 'быть',
15
+ 'есть', 'будет', 'будем', 'было', 'его', 'её', 'их', 'my', 'the', 'and', 'for',
16
+ 'with', 'does', 'what', 'how', 'why', 'when', 'from', 'into', 'would',
17
+ ]);
18
+
19
+ // Suffix-trigram-маркер: последние 3 символа слова, чтобы русские словоформы
20
+ // («отчёт», «отчётами») пересекались. Префикс '_' исключает конфликт с токенами.
21
+ function trigram(word) {
22
+ if (word.length < 4) return '';
23
+ return '_' + word.slice(-3);
24
+ }
25
+
26
+ // wordCounts: частоты токенов в тексте (схлопывает «keepAlive» → «keepalive»).
27
+ function tokenize(text) {
28
+ const counts = {};
29
+ const add = (t) => { if (t && t.length >= 3 && !STOP_WORDS.has(t)) { counts[t] = (counts[t] || 0) + 1; } };
30
+ const words = String(text).toLowerCase().split(/[^\p{L}\p{N}_]+/u).filter(Boolean);
31
+ for (const w of words) {
32
+ add(w);
33
+ const tr = trigram(w);
34
+ if (tr) add(tr);
35
+ }
36
+ return Object.keys(counts);
37
+ }
38
+
39
+ // Хранилище фактов. capacity → вытеснение least-accessed.
40
+ class MemStore {
41
+ constructor(capacity = 800) {
42
+ this.capacity = capacity;
43
+ this._facts = []; // { id, text, tokens:Set<string>, access, created, sourceHash }
44
+ this._docFreq = {}; // token → сколько фактов его содержит
45
+ this._hashIndex = new Map(); // textHash → id
46
+ }
47
+
48
+ size() { return this._facts.length; }
49
+
50
+ _refreshDocFreq() {
51
+ this._docFreq = {};
52
+ for (const f of this._facts) {
53
+ for (const t of f.tokens) this._docFreq[t] = (this._docFreq[t] || 0) + 1;
54
+ }
55
+ }
56
+
57
+ // idf влазит в [0, ln(пул+1)] — редкие токены важнее частых.
58
+ _idf(token) {
59
+ const n = this._docFreq[token] || 0;
60
+ if (n === 0) return Math.log(this._facts.length + 1) || 1;
61
+ return Math.log((this._facts.length + 1) / n);
62
+ }
63
+
64
+ add(text, meta = {}) {
65
+ const normalized = String(text).trim().replace(/\s+/g, ' ');
66
+ if (!normalized) return null;
67
+ const hash = crypto.createHash('sha1').update(normalized).digest('hex').slice(0, 16);
68
+ if (this._hashIndex.has(hash)) return null; // дедупликация
69
+ const tokens = new Set(tokenize(normalized));
70
+ // Prefix-expand: «запросы» покрывает «запрос», «погодный» → «погод» и т.п.
71
+ // Даёт словоформам/словосочетаниям пересекаться без стеммера. Минимум 4
72
+ // символа, чтобы не плодить ложные «год» из «погода».
73
+ const expanded = new Set(tokens);
74
+ for (const t of tokens) {
75
+ if (t.startsWith('_') || t.length < 4) continue;
76
+ for (let L = 4; L < t.length; L++) expanded.add(t.slice(0, L));
77
+ }
78
+ if (expanded.size === 0) return null;
79
+ const fact = {
80
+ id: 'f' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
81
+ text: normalized,
82
+ tokens: expanded,
83
+ access: 0,
84
+ created: Date.now(),
85
+ sourceHash: meta.sourceHash || null,
86
+ };
87
+ this._facts.push(fact);
88
+ this._hashIndex.set(hash, fact.id);
89
+ for (const t of expanded) this._docFreq[t] = (this._docFreq[t] || 0) + 1;
90
+ this._evictIfNeeded();
91
+ return fact;
92
+ }
93
+
94
+ _evictIfNeeded() {
95
+ if (this._facts.length <= this.capacity) return;
96
+ // least-accessed, при равенстве — старейший
97
+ this._facts.sort((a, b) => (a.access - b.access) || (a.created - b.created));
98
+ const victims = this._facts.slice(0, this._facts.length - this.capacity);
99
+ for (const v of victims) this._remove(v.id);
100
+ }
101
+
102
+ _remove(id) {
103
+ const idx = this._facts.findIndex(f => f.id === id);
104
+ if (idx === -1) return;
105
+ const [f] = this._facts.splice(idx, 1);
106
+ // убрать из hashIndex: нужен исходный hash — пересчитаем по тексту
107
+ const h = crypto.createHash('sha1').update(f.text).digest('hex').slice(0, 16);
108
+ if (this._hashIndex.get(h) === id) this._hashIndex.delete(h);
109
+ for (const t of f.tokens) this._docFreq[t] = Math.max(0, (this._docFreq[t] || 1) - 1);
110
+ }
111
+
112
+ // recall: скор = сумма idf по общим токенам (полные слова вес 1, триграммы 0.5),
113
+ // нормализованный по sqrt(len запроса) — масштаб одинаков внутри одного вызова
114
+ // (не зависит от длины факта). 0 общих токенов → 0, unrelated отсекается любым
115
+ // порогом > 0.
116
+ recall(query, opts = {}) {
117
+ const topK = opts.topK ?? 3;
118
+ const minSimilarity = opts.minSimilarity ?? 0.05;
119
+ const qTokens = tokenize(query);
120
+ if (qTokens.length === 0) return [];
121
+ const qSet = new Set(qTokens);
122
+ const norm = Math.sqrt(qTokens.length);
123
+ const scored = [];
124
+ for (const f of this._facts) {
125
+ let score = 0;
126
+ for (const t of qSet) {
127
+ if (f.tokens.has(t)) {
128
+ const w = t.startsWith('_') ? 0.5 : 1;
129
+ score += w * this._idf(t);
130
+ }
131
+ }
132
+ const final = score / norm;
133
+ if (final >= minSimilarity) scored.push({ text: f.text, score: final });
134
+ }
135
+ scored.sort((a, b) => b.score - a.score);
136
+ return scored.slice(0, topK);
137
+ }
138
+
139
+ // Дедупликация при подмешивании: факт не нужен, если его текст почти весь
140
+ // покрыт другим текстом (например, резюме компактора в этой же сессии).
141
+ isCovered(text, others) {
142
+ const words = tokenize(text).filter(t => !t.startsWith('_'));
143
+ if (words.length === 0) return false;
144
+ for (const other of others) {
145
+ const foreign = tokenize(other);
146
+ const overlap = words.filter(w => foreign.includes(w)).length;
147
+ if (overlap / words.length >= 0.6) return true;
148
+ }
149
+ return false;
150
+ }
151
+
152
+ export() {
153
+ return this._facts.map(f => ({ text: f.text, sourceHash: f.sourceHash, created: f.created, access: f.access }));
154
+ }
155
+
156
+ import(entries) {
157
+ if (!Array.isArray(entries)) return 0;
158
+ let added = 0;
159
+ for (const e of entries) {
160
+ if (e && typeof e.text === 'string' && this.add(e.text, { sourceHash: e.sourceHash })) added++;
161
+ }
162
+ if (added > 0) this._refreshDocFreq();
163
+ return added;
164
+ }
165
+ }
166
+
167
+ module.exports = { tokenize, MemStore };
@@ -0,0 +1,70 @@
1
+ // Методолог — краткий системный промпт, дающий модели «инженерную дисциплину»
2
+ // по мотивам superpowers (plan-before-code, tests-first, reasoning). Не является
3
+ // копией — это сжатый производный текст, встроенный в запрос к провайдеру.
4
+ // Механика субагентов/инструментов работает на стороне клиента (opencode, Cursor),
5
+ // здесь — только стиль ответа.
6
+
7
+ const METHODOLOGIES = {
8
+ coding: [
9
+ 'Методолог (craft): перед тем как давать код — составь план в 1-2 строки.',
10
+ 'Сначала набросай approach, потом пиши реализации. Не выдумывай API.',
11
+ 'Предложи (или опиши) короткий тест, который проверит поведение.',
12
+ 'Если правишь код — укажи файл и строку; если есть сомнение — спроси.',
13
+ 'После решения дай в одну строку, как это проверить.',
14
+ ].join('\n'),
15
+ reasoning: [
16
+ 'Методолог (thinking): рассуждай пошагово, прежде чем дать вывод.',
17
+ 'Покажи ключевые предположения, чтобы ответ можно было проверить.',
18
+ 'Сначала краткий ход мысли, затем краткий ответ.',
19
+ ].join('\n'),
20
+ search: [
21
+ 'Методолог (search): дай короткий фактологичный ответ по существу.',
22
+ 'Если отвечаешь по внешнему источнику — не выдумывай, укажи источник или скажи, что не уверен.',
23
+ 'Без воды и лишних вступлений.',
24
+ ].join('\n'),
25
+ chat: [
26
+ 'Методолог (chat): отвечай по существу и кратко, по-русски.',
27
+ 'Если просят код или план — переключись на соответствующий стиль.',
28
+ ].join('\n'),
29
+ };
30
+
31
+ function getMethodology(category, options) {
32
+ const opts = options || {};
33
+ const prompts = (opts.prompts && typeof opts.prompts === 'object') ? opts.prompts : {};
34
+ if (prompts[category] && typeof prompts[category] === 'string' && prompts[category].trim().length > 0) {
35
+ return prompts[category];
36
+ }
37
+ return METHODOLOGIES[category] || METHODOLOGIES.chat;
38
+ }
39
+
40
+ function enabledByDefault() {
41
+ return true;
42
+ }
43
+
44
+ // Вставляет системное сообщение-методолог после системного промпта клиента и
45
+ // после memory-инъекции, но перед первым активным user-сообщением.
46
+ function injectMethodology(messages, category, options) {
47
+ const opts = options || {};
48
+ if (opts.enabled === false) return messages;
49
+ const text = getMethodology(category, opts);
50
+ if (!messages || !Array.isArray(messages)) return messages;
51
+
52
+ // Конец вводных: после последнего system и после исчерпания «заголовочных»
53
+ // user-вставок вида [Память: ...] / [Факты: ...].
54
+ let insertAt = 0;
55
+ for (let i = 0; i < messages.length; i++) {
56
+ const m = messages[i];
57
+ if (!m) continue;
58
+ if (m.role === 'system') { insertAt = i + 1; continue; }
59
+ if (m.role === 'user' && typeof m.content === 'string' && /^\[(Память|Факты|Memory|Facts)[:\]]/.test(m.content)) {
60
+ insertAt = i + 1; continue;
61
+ }
62
+ break;
63
+ }
64
+
65
+ const copy = messages.slice();
66
+ copy.splice(insertAt, 0, { role: 'system', content: text });
67
+ return copy;
68
+ }
69
+
70
+ module.exports = { getMethodology, injectMethodology, enabledByDefault, METHODOLOGIES };
package/lib/modeldb.js ADDED
@@ -0,0 +1,125 @@
1
+ // lib/modeldb.js — структурированная база моделей Freegate.
2
+ // Паспорт каждой модели: источник, категория, контекст-окно, латентность,
3
+ // success-rate, скор, статус, история проверок. Хранение: models-db.json
4
+ // (атомарная запись через tmp+rename). Zero-dependency.
5
+ //
6
+ // Статусы: untested → active | dead (404/402) | disabled (авто-отключён,
7
+ // перепроверяется) | user-disabled (решение пользователя, не трогаем).
8
+
9
+ const fs = require('fs');
10
+
11
+ const CHECK_HISTORY_MAX = 20;
12
+ const SCORE_WEIGHTS = { success: 0.4, latency: 0.3, context: 0.2, freshness: 0.1 };
13
+ const LATENCY_PENALTY_MS = 5000; // латентность ≥5s → latencyScore 0
14
+ const FRESH_DECAY_DAYS = 14; // после 14 дней без успеха freshness 0
15
+ const CONTEXT_REF_MS = 1000000; // 1M окна достаточно для contextScore 1
16
+
17
+ function clamp01(v) {
18
+ const n = Number(v) || 0;
19
+ return Math.max(0, Math.min(1, n));
20
+ }
21
+
22
+ // Технический скор 0..100: success-rate + латентность + окно + свежесть.
23
+ function computeScore({ successRate = 0.5, latencyEma = 0, contextWindow = 0, lastOkAt = 0, now = Date.now() } = {}) {
24
+ const sSuccess = clamp01(successRate);
25
+ const sLatency = 1 - clamp01((Number(latencyEma) || 0) / LATENCY_PENALTY_MS);
26
+ const sContext = clamp01((Number(contextWindow) || 0) / CONTEXT_REF_MS);
27
+ const days = (Number(lastOkAt) > 0) ? Math.max(0, (now - lastOkAt) / 86400000) : FRESH_DECAY_DAYS + 1;
28
+ const sFresh = days <= 1 ? 1 : Math.max(0, 1 - days / FRESH_DECAY_DAYS);
29
+ const raw = sSuccess * SCORE_WEIGHTS.success +
30
+ sLatency * SCORE_WEIGHTS.latency +
31
+ sContext * SCORE_WEIGHTS.context +
32
+ sFresh * SCORE_WEIGHTS.freshness;
33
+ return Math.round(clamp01(raw) * 100);
34
+ }
35
+
36
+ class ModelDB {
37
+ constructor(filePath) {
38
+ this.filePath = filePath;
39
+ this.models = new Map(); // key -> entry
40
+ }
41
+
42
+ load() {
43
+ this.models = new Map();
44
+ try {
45
+ if (!fs.existsSync(this.filePath)) return this;
46
+ const raw = JSON.parse(fs.readFileSync(this.filePath, 'utf8'));
47
+ for (const [key, m] of Object.entries(raw.models || {})) {
48
+ if (m && typeof m === 'object' && m.model && m.endpoint) this.models.set(key, m);
49
+ }
50
+ } catch {
51
+ // повреждённый файл → пустая база, перезапишется при первом save
52
+ }
53
+ return this;
54
+ }
55
+
56
+ save() {
57
+ const out = { version: 1, updatedAt: Date.now(), models: Object.fromEntries(this.models) };
58
+ const tmp = this.filePath + '.tmp';
59
+ fs.writeFileSync(tmp, JSON.stringify(out, null, 2) + '\n');
60
+ fs.renameSync(tmp, this.filePath); // атомарно: читатели не видят полузапись
61
+ return this;
62
+ }
63
+
64
+ get(key) { return this.models.get(key) || null; }
65
+
66
+ all() { return [...this.models.values()]; }
67
+
68
+ // Слияние: новые поля поверх старых, существующие (status/history) сохраняются.
69
+ upsert(entry) {
70
+ if (!entry || !entry.key || !entry.model || !entry.endpoint) return null;
71
+ const prev = this.models.get(entry.key) || {};
72
+ const merged = {
73
+ status: 'untested',
74
+ category: 'general',
75
+ source: 'unknown',
76
+ contextWindow: 0,
77
+ dailyLimit: 0,
78
+ checkHistory: [],
79
+ score: 0,
80
+ addedAt: Date.now(),
81
+ ...prev,
82
+ ...entry,
83
+ };
84
+ this.models.set(entry.key, merged);
85
+ return merged;
86
+ }
87
+
88
+ // Результат проверки модели. dead только для 404/402 (перманентно для аккаунта).
89
+ // 429 и прочее — транзитное, статус не меняет. user-disabled не трогаем никогда.
90
+ markChecked(key, result, opts = {}) {
91
+ const m = this.models.get(key);
92
+ if (!m) return null;
93
+ const now = opts.now || Date.now();
94
+ const ok = !!(result && result.ok);
95
+ m.lastCheckedAt = now;
96
+ if (m.status !== 'user-disabled') {
97
+ if (ok) { m.status = 'active'; m.lastOkAt = now; }
98
+ else if (result.status === 404 || result.status === 402) m.status = 'dead';
99
+ } else if (ok) {
100
+ m.lastOkAt = now;
101
+ }
102
+ const history = Array.isArray(m.checkHistory) ? m.checkHistory : [];
103
+ history.push({ ts: now, ok, status: (result && result.status) || 0, latencyMs: (result && result.latencyMs) || 0 });
104
+ m.checkHistory = history.slice(-CHECK_HISTORY_MAX);
105
+ return m;
106
+ }
107
+
108
+ setStatus(key, status) {
109
+ const m = this.models.get(key);
110
+ if (m && status) m.status = status;
111
+ return m;
112
+ }
113
+
114
+ stats() {
115
+ const byCategory = {}, byStatus = {}, bySource = {};
116
+ for (const m of this.models.values()) {
117
+ byCategory[m.category] = (byCategory[m.category] || 0) + 1;
118
+ byStatus[m.status] = (byStatus[m.status] || 0) + 1;
119
+ bySource[m.source] = (bySource[m.source] || 0) + 1;
120
+ }
121
+ return { total: this.models.size, byCategory, byStatus, bySource };
122
+ }
123
+ }
124
+
125
+ module.exports = { ModelDB, computeScore, SCORE_WEIGHTS };