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.
- package/README.md +72 -7
- package/README.ru.md +93 -7
- package/assets/dashboard-models.png +0 -0
- package/assets/dashboard.png +0 -0
- package/bin/freegate.js +32 -5
- package/config.example.json +13 -0
- package/lib/bandit.js +8 -1
- package/lib/cache.js +54 -5
- package/lib/clean.js +7 -2
- package/lib/compactor.js +220 -0
- package/lib/contextstats.js +224 -0
- package/lib/dashboard.js +782 -127
- package/lib/economics.js +43 -0
- package/lib/health.js +56 -9
- package/lib/logger.js +1 -1
- package/lib/memory-store.js +68 -0
- package/lib/memory.js +167 -0
- package/lib/methodology.js +70 -0
- package/lib/modeldb.js +125 -0
- package/lib/modelmanager.js +335 -0
- package/lib/modelscan.js +190 -0
- package/lib/normalize.js +14 -4
- package/lib/providers.js +68 -5
- package/lib/routing.js +14 -2
- package/lib/semcache.js +31 -0
- package/lib/setup.js +309 -0
- package/lib/taskclassify.js +59 -0
- package/package.json +1 -1
- package/providers.json +113 -137
- package/server.js +728 -99
package/lib/economics.js
ADDED
|
@@ -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
|
-
|
|
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)
|
|
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 };
|