beast-agent 2.3.4 → 2.4.1

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "beast-agent",
3
3
  "productName": "Beast Agent",
4
- "version": "2.3.4",
4
+ "version": "2.4.1",
5
5
  "description": "Ultra-fast local agent shell for Windows.",
6
6
  "author": "algokodcom (AlgoKod)",
7
7
  "license": "MIT",
@@ -0,0 +1,616 @@
1
+ 'use strict';
2
+
3
+ /* Empati Loop — proaktif algı/event alt sistemi (ana sohbet motorundan bağımsız).
4
+ Boru hattı: SİNYAL → NORMALİZE → DEDUP → UCUZ FİLTRE (LLM) → ÖNCELİK → KUYRUK
5
+ Bu modül saf mantık + olay deposudur; LLM çağrıları (llmFilter) ve sinyal
6
+ toplayıcıları (signals) main.js tarafından enjekte edilir.
7
+ İlke: her sinyal event olmaz, her event büyük modele gitmez, her event
8
+ kullanıcıya bildirilmez. Depo: %APPDATA%\beast\perception\events.json */
9
+
10
+ const crypto = require('crypto');
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const { beastRoot } = require('./memory');
14
+
15
+ const DEFAULTS = {
16
+ enabled: false,
17
+ intervalMin: 15, // tarama aralığı (3-1440 dk)
18
+ cooldownMin: 30, // aynı konu için sessizlik (0-10080 dk)
19
+ minNotifyPriority: 60, // bu kompozit puanın altı yalnız depoya yazılır (0-100)
20
+ maxNotifyPerCycle: 1, // döngü başına en fazla bildirim (maliyet freni)
21
+ notifyTarget: '', // bildirim hedefi: '' = bağlı entegrasyonlar | whatsapp | telegram | discord
22
+ filterModel: '', // ucuz filtre modeli ('provider::model'); boşsa ana model
23
+ interests: '', // kullanıcı ilgi alanları (relevance ağırlığı)
24
+ newsTopics: '', // Google News RSS sorguları (virgülle)
25
+ weights: { importance: 0.40, relevance: 0.25, urgency: 0.20, novelty: 0.15 },
26
+ };
27
+
28
+ const EVENT_CAP = 250; // depo tavanı — eskiler düşer
29
+ const SEEN_CAP = 800; // dedup parmakizi havuzu
30
+ const SEEN_TTL = 6 * 3600 * 1000; // aynı olay 6 saat içinde tekrar gelirse yut
31
+ const FILTER_MAX = 12; // tek filtre çağrısında en fazla olay
32
+ const NEWS_MAX = 12; // konu başına en fazla haber adayı
33
+
34
+ /* ---------- config ---------- */
35
+
36
+ function clampW(v, d) {
37
+ const n = Number(v);
38
+ return Number.isFinite(n) && n > 0 && n < 1 ? n : d;
39
+ }
40
+
41
+ function mergeCfg(raw) {
42
+ const r = raw || {};
43
+ const num = (v, min, max, d) => {
44
+ const n = Math.round(Number(v));
45
+ return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : d;
46
+ };
47
+ const w = r.weights || {};
48
+ return {
49
+ enabled: r.enabled === true,
50
+ intervalMin: num(r.intervalMin, 3, 1440, DEFAULTS.intervalMin),
51
+ cooldownMin: num(r.cooldownMin, 0, 10080, DEFAULTS.cooldownMin),
52
+ minNotifyPriority: num(r.minNotifyPriority, 0, 100, DEFAULTS.minNotifyPriority),
53
+ maxNotifyPerCycle: num(r.maxNotifyPerCycle, 1, 5, DEFAULTS.maxNotifyPerCycle),
54
+ notifyTarget: ['whatsapp', 'telegram', 'discord'].includes(String(r.notifyTarget || ''))
55
+ ? String(r.notifyTarget)
56
+ : '',
57
+ filterModel: String(r.filterModel || '').trim(),
58
+ interests: String(r.interests || '').slice(0, 400),
59
+ newsTopics: String(r.newsTopics || '').slice(0, 400),
60
+ weights: {
61
+ importance: clampW(w.importance, DEFAULTS.weights.importance),
62
+ relevance: clampW(w.relevance, DEFAULTS.weights.relevance),
63
+ urgency: clampW(w.urgency, DEFAULTS.weights.urgency),
64
+ novelty: clampW(w.novelty, DEFAULTS.weights.novelty),
65
+ },
66
+ };
67
+ }
68
+
69
+ /* ---------- depo ---------- */
70
+
71
+ function stateFile() {
72
+ return path.join(beastRoot(), 'perception', 'events.json');
73
+ }
74
+
75
+ function loadState() {
76
+ try {
77
+ const raw = JSON.parse(fs.readFileSync(stateFile(), 'utf8'));
78
+ return {
79
+ events: Array.isArray(raw.events) ? raw.events : [],
80
+ seen: raw.seen && typeof raw.seen === 'object' ? raw.seen : {},
81
+ cooldowns: raw.cooldowns && typeof raw.cooldowns === 'object' ? raw.cooldowns : {},
82
+ lastRunAt: raw.lastRunAt || null,
83
+ };
84
+ } catch {
85
+ return { events: [], seen: {}, cooldowns: {}, lastRunAt: null };
86
+ }
87
+ }
88
+
89
+ function saveState(st) {
90
+ try {
91
+ fs.mkdirSync(path.dirname(stateFile()), { recursive: true });
92
+ fs.writeFileSync(stateFile(), JSON.stringify(st, null, 2));
93
+ } catch {}
94
+ }
95
+
96
+ /* ---------- empati hafızası: sohbet kaydı + öğrenilen ilgi alanları ----------
97
+ Sohbet akışı (chat/WA/TG/DC) main tarafından buraya düşürülür; kullanıcı
98
+ mesajlarından anlamlı kelimeler toplanır, sık geçenler "öğrenilen ilgi
99
+ alanı" olur. Tarama filtresi ve bildirim mesajı bu hafızayla kişiselleşir:
100
+ elle girilen ilgi alanları + öğrenilenler birlikte prompta girer. */
101
+
102
+ const CHAT_CAP = 120; // son sohbet kaydı tavanı
103
+ const INTEREST_CAP = 24; // öğrenilen ilgi etiketi tavanı
104
+ const CHAT_SNIPPET = 220;
105
+
106
+ const TR_FOLD_MEM = { ç: 'c', ğ: 'g', ı: 'i', ö: 'o', ş: 's', ü: 'u', â: 'a', î: 'i', û: 'u' };
107
+
108
+ function memFold(s) {
109
+ return String(s || '').toLowerCase().replace(/[çğıöşüâîû]/g, (ch) => TR_FOLD_MEM[ch] || ch);
110
+ }
111
+
112
+ /* gereksiz kelimeler — ilgi alanı sayılmaz. Set FOLD'LU kurulur: kelimeler
113
+ zaten memFold ile ASCII'ye indirgendikten sonra arandığı için 'bugün' gibi
114
+ stopword'ler fold'lu haliyle ('bugun') listede olmalı. */
115
+ const MEM_STOP_RAW =
116
+ 'the and for with this that have from was are were been will would could should ' +
117
+ 'bir bu şu o ve ile için gibi ama fakat çok daha en ne mi mı mu mü de da ki ise ya ' +
118
+ 'veya yani bana bize sana ona beni bizi seni onun benim bizim senin onları var yok ' +
119
+ 'olur olan olarak nasıl neden niye şey şeyi her hem acaba lazım gerek evet hayır ' +
120
+ 'tamam tmm kanka abi hocam merhaba selam hey lütfen teşekkür sağol eyvallah bugün ' +
121
+ 'yarın dün şimdi sonra önce hala henüz yine tekrar biraz az kadar çünkü eğer ' +
122
+ 'yapabilir misin musun isterim istiyorum lazım gerekiyor beast asistan önemli ' +
123
+ 'hakkında üzerine konuştuk konuşuyoruz konuşmak kendi aynı başka diğer belki ' +
124
+ 'sanırım galiba gerekli olsun olmuyor oluyor bakalım biriyle hangi kim nerede';
125
+
126
+ const MEM_STOP = new Set(MEM_STOP_RAW.split(/\s+/).map((w) => memFold(w)).filter(Boolean));
127
+
128
+ function chatFile() {
129
+ return path.join(beastRoot(), 'perception', 'chatmem.json');
130
+ }
131
+
132
+ function loadChatMem() {
133
+ try {
134
+ const r = JSON.parse(fs.readFileSync(chatFile(), 'utf8'));
135
+ return {
136
+ chats: Array.isArray(r.chats) ? r.chats : [],
137
+ learned: r.learned && typeof r.learned === 'object' ? r.learned : {},
138
+ };
139
+ } catch {
140
+ return { chats: [], learned: {} };
141
+ }
142
+ }
143
+
144
+ function saveChatMem(cm) {
145
+ try {
146
+ fs.mkdirSync(path.dirname(chatFile()), { recursive: true });
147
+ fs.writeFileSync(chatFile(), JSON.stringify(cm));
148
+ } catch {}
149
+ }
150
+
151
+ /* Sohbet parçasını hafızaya yaz (kullanıcı + beast cevapları + proaktif mesajlar) */
152
+ function rememberConversation(text, channel) {
153
+ try {
154
+ const t = String(text || '').replace(/\s+/g, ' ').trim().slice(0, CHAT_SNIPPET);
155
+ if (t.length < 4) return false;
156
+ const cm = loadChatMem();
157
+ /* birebir tekrar yut */
158
+ if (cm.chats.length && cm.chats[cm.chats.length - 1].t === t) return false;
159
+ cm.chats.push({ ts: new Date().toISOString(), ch: String(channel || 'sohbet').slice(0, 12), t });
160
+ while (cm.chats.length > CHAT_CAP) cm.chats.shift();
161
+ saveChatMem(cm);
162
+ return true;
163
+ } catch {
164
+ return false;
165
+ }
166
+ }
167
+
168
+ /* Kullanıcı mesajından ilgi alanı öğren: fold + stopword temizliği, sayaca yaz */
169
+ function learnInterests(text) {
170
+ try {
171
+ const toks = (memFold(text).match(/[a-z0-9_+#.]{3,}/g) || [])
172
+ .filter((w) => !MEM_STOP.has(w) && !/^\d+$/.test(w))
173
+ .slice(0, 12);
174
+ if (!toks.length) return false;
175
+ const cm = loadChatMem();
176
+ const now = Date.now();
177
+ for (const w of toks) {
178
+ const cur = cm.learned[w] || { c: 0, at: 0 };
179
+ cur.c++;
180
+ cur.at = now;
181
+ cm.learned[w] = cur;
182
+ }
183
+ /* tavana in: en sık + en taze etiketler kalır */
184
+ const keys = Object.keys(cm.learned);
185
+ if (keys.length > INTEREST_CAP) {
186
+ keys.sort((a, b) => (cm.learned[b].c - cm.learned[a].c) || (cm.learned[b].at - cm.learned[a].at));
187
+ for (const k of keys.slice(INTEREST_CAP)) delete cm.learned[k];
188
+ }
189
+ saveChatMem(cm);
190
+ return true;
191
+ } catch {
192
+ return false;
193
+ }
194
+ }
195
+
196
+ /* Elle girilen + öğrenilen ilgi alanları birleşik (promptlar bunu kullanır) */
197
+ function combinedInterests(cfg) {
198
+ const manual = String((cfg && cfg.interests) || '').trim();
199
+ try {
200
+ const cm = loadChatMem();
201
+ const learned = Object.keys(cm.learned)
202
+ .sort((a, b) => cm.learned[b].c - cm.learned[a].c)
203
+ .slice(0, 12);
204
+ return [manual, learned.join(', ')].filter(Boolean).join(', ');
205
+ } catch {
206
+ return manual;
207
+ }
208
+ }
209
+
210
+ /* Son konuşma satırları — compose/filtre promptuna bağlam olarak girer */
211
+ function chatContextLine(max = 8) {
212
+ try {
213
+ const cm = loadChatMem();
214
+ const items = cm.chats.slice(-Math.max(1, Number(max) || 8));
215
+ if (!items.length) return '';
216
+ const when = (iso) => {
217
+ try {
218
+ const d = new Date(iso);
219
+ return ('0' + d.getDate()).slice(-2) + '.' + ('0' + (d.getMonth() + 1)).slice(-2);
220
+ } catch { return ''; }
221
+ };
222
+ return (
223
+ 'SON KONUŞMALAR (kullanıcıyla yakın zamanda konuşulanlar):\n' +
224
+ items.map((c) => '- [' + when(c.ts) + ' ' + (c.ch || 'sohbet') + '] ' + c.t).join('\n')
225
+ );
226
+ } catch {
227
+ return '';
228
+ }
229
+ }
230
+
231
+ /* UI için hafıza görünümü: öğrenilen etiketler + son konuşmalar */
232
+ function memSnapshot(limit = 40) {
233
+ const cm = loadChatMem();
234
+ const learned = Object.keys(cm.learned)
235
+ .sort((a, b) => (cm.learned[b].c - cm.learned[a].c) || (cm.learned[b].at - cm.learned[a].at))
236
+ .slice(0, 30)
237
+ .map((w) => ({ w, c: cm.learned[w].c || 0 }));
238
+ const n = Math.max(1, Math.min(80, Number(limit) || 40));
239
+ return { learned, chats: cm.chats.slice(-n).reverse(), total: cm.chats.length };
240
+ }
241
+
242
+ function memClear() {
243
+ try {
244
+ fs.rmSync(chatFile(), { force: true });
245
+ return { ok: true };
246
+ } catch (e) {
247
+ return { ok: false, error: String((e && e.message) || e) };
248
+ }
249
+ }
250
+
251
+ /* ---------- normalize / dedup ---------- */
252
+
253
+ function normTitle(t) {
254
+ const base = String(t || '')
255
+ .toLowerCase()
256
+ .replace(/[^\p{L}\p{N}\s]/gu, ' ')
257
+ .replace(/\s+/g, ' ')
258
+ .trim();
259
+ return base.split(' ').slice(0, 12).join(' ');
260
+ }
261
+
262
+ function fingerprint(type, title) {
263
+ return crypto.createHash('sha1').update(normTitle(type + '|' + title)).digest('hex').slice(0, 14);
264
+ }
265
+
266
+ /* cooldown anahtarı: konunun ilk 5 kelimesi — varyasyonlar aynı kovaya düşer */
267
+ function topicKey(type, title) {
268
+ return normTitle(type + ' ' + title).split(' ').slice(0, 5).join(' ');
269
+ }
270
+
271
+ function uid() {
272
+ return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
273
+ }
274
+
275
+ function normalizeRaw(item) {
276
+ const title = String((item && item.title) || '').replace(/\s+/g, ' ').trim().slice(0, 300);
277
+ if (!title) return null;
278
+ const type = String((item && item.type) || 'news').slice(0, 24);
279
+ return {
280
+ id: uid(),
281
+ ts: item.ts || new Date().toISOString(),
282
+ source: String((item && item.source) || type).slice(0, 40),
283
+ type,
284
+ title,
285
+ detail: String((item && item.detail) || '').slice(0, 300),
286
+ sources: [(item && item.source) || type],
287
+ scores: {},
288
+ priority: 0,
289
+ reason: '',
290
+ status: 'new',
291
+ level: '',
292
+ text: '',
293
+ notifiedAt: null,
294
+ };
295
+ }
296
+
297
+ /* ---------- haber kaynağı (Google News RSS — anahtar gerektirmez,
298
+ Reuters/BBC/AP gibi kaynakları zaten tek beslemede harmanlar) ---------- */
299
+
300
+ async function fetchNews(topics) {
301
+ const out = [];
302
+ for (const q of (topics || []).slice(0, 4)) {
303
+ try {
304
+ const url = 'https://news.google.com/rss/search?q=' + encodeURIComponent(q) + '&hl=tr&gl=TR&ceid=TR:tr';
305
+ const res = await fetch(url, { signal: AbortSignal.timeout(12000) });
306
+ if (!res.ok) continue;
307
+ const xml = await res.text();
308
+ const re = /<item>[\s\S]*?<\/item>/g;
309
+ let m;
310
+ let count = 0;
311
+ while ((m = re.exec(xml)) && count < NEWS_MAX) {
312
+ const t = /<title>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/title>/.exec(m[0]);
313
+ const title = t ? String(t[1]).replace(/\s+/g, ' ').trim() : '';
314
+ if (!title) continue;
315
+ count++;
316
+ out.push({ type: 'news', title, detail: 'Google News · ' + q, source: 'news:' + q });
317
+ }
318
+ } catch {}
319
+ }
320
+ return out;
321
+ }
322
+
323
+ /* ---------- ucuz deterministik puanlayıcı (LLM yoksa/çökerse fallback) ---------- */
324
+
325
+ const URGENT_RE = /acil|critical|son dakika|outage|arıza|crash|down|kesinti|kapatıldı|breach|güvenlik|saldırı|risk/i;
326
+
327
+ function heuristicScores(ev, cfg) {
328
+ const t = normTitle(ev.title);
329
+ const URGENT = URGENT_RE.test(t);
330
+ let importance = ev.type === 'news' ? 40 : 65;
331
+ if (URGENT) importance += 20;
332
+ /* ilgi kelimeleri fold'lanır (öğrenilen etiketler zaten fold'lu) */
333
+ const words = String(cfg.interests || '').split(/[,\s]+/).map((w) => memFold(w).trim()).filter((w) => w.length > 2);
334
+ let hits = 0;
335
+ const hay = memFold(t);
336
+ for (const w of words) if (hay.includes(w)) hits++;
337
+ const relevance = hits ? Math.min(100, 55 + hits * 15) : (ev.type === 'news' ? 30 : 60);
338
+ const urgency = URGENT ? 75 : (ev.type === 'news' ? 35 : 45);
339
+ const novelty = ev.type === 'news' ? 80 : 60;
340
+ return { importance, relevance, urgency, novelty };
341
+ }
342
+
343
+ function compositePriority(scores, cfg) {
344
+ const w = cfg.weights;
345
+ const s = (x) => Math.max(0, Math.min(100, Number(x) || 0));
346
+ const sum = w.importance + w.relevance + w.urgency + w.novelty || 1;
347
+ return Math.round(
348
+ (s(scores.importance) * w.importance +
349
+ s(scores.relevance) * w.relevance +
350
+ s(scores.urgency) * w.urgency +
351
+ s(scores.novelty) * w.novelty) / sum
352
+ );
353
+ }
354
+
355
+ /* ---------- LLM filtre promptu / ayrıştırıcı ---------- */
356
+
357
+ const FILTER_SYSTEM =
358
+ 'Sen bir bilgi filtresisin; sohbet etmezsin. Sana verilen olayları değerlendirip YALNIZCA JSON döndürürsün. ' +
359
+ 'Skorlar 0-100 arası tamsayı. "relevant": kullanıcının ilgi alanlarıyla alakalı mı. ' +
360
+ '"notify": kullanıcıya bildirmeye gerçekten değer mi (önemsiz/spam/klasik haberlerde false). ' +
361
+ '"reason": en fazla 60 karakter kısa gerekçe.';
362
+
363
+ function filterPrompt(events, interests, chatCtx) {
364
+ const list = events.map((e) => ({
365
+ id: e.id,
366
+ type: e.type,
367
+ title: e.title,
368
+ detail: e.detail,
369
+ }));
370
+ return (
371
+ 'KULLANICI İLGİLERİ: ' + (String(interests || '').trim() || '(belirtilmemiş)') + '\n' +
372
+ (String(chatCtx || '').trim() ? String(chatCtx).trim() + '\n' : '') +
373
+ 'OLAYLAR:\n' + JSON.stringify(list) + '\n\n' +
374
+ 'Her olay için {"id","relevant","importance","urgency","novelty","notify","reason"} içeren ' +
375
+ 'TEK bir JSON dizisi döndür. Sadece JSON, başka metin yok.'
376
+ );
377
+ }
378
+
379
+ function parseFilterJson(text) {
380
+ try {
381
+ let s = String(text || '').replace(/```(?:json)?/gi, '');
382
+ const a = s.indexOf('[');
383
+ const b = s.lastIndexOf(']');
384
+ if (a < 0 || b <= a) return [];
385
+ const arr = JSON.parse(s.slice(a, b + 1));
386
+ if (!Array.isArray(arr)) return [];
387
+ const map = new Map();
388
+ for (const it of arr) {
389
+ if (it && it.id !== undefined) map.set(String(it.id).toLowerCase(), it);
390
+ }
391
+ return map;
392
+ } catch {
393
+ return [];
394
+ }
395
+ }
396
+
397
+ /* ---------- ana LLM compose promptu / fallback ---------- */
398
+
399
+ const COMPOSE_SYSTEM =
400
+ 'Beast adlı yerel yardımcı için proaktif bildirim metni yazarsın. Türkçe, samimi "kanka" tonunda, ' +
401
+ 'EN FAZLA 2 kısa cümle: ne olduğunu soyutla, neden önemli olabileceğini söyle, istersen tek kısa soru sor. ' +
402
+ 'Alarm spam\'i yok; başlık/emoji/Markdown ekleme, yalnız düz metin yaz.';
403
+
404
+ function composePrompt(ev, cfg) {
405
+ const ctx = chatContextLine(5);
406
+ return (
407
+ 'OLAY: ' + ev.title + '\n' +
408
+ (ev.detail ? 'DETAY: ' + ev.detail + '\n' : '') +
409
+ 'KAYNAK: ' + ev.source + '\n' +
410
+ 'ÖNCELİK: ' + ev.priority + '/100 · ' + (ev.reason || '') + '\n' +
411
+ 'KULLANICI İLGİLERİ: ' + (combinedInterests(cfg) || '(belirtilmemiş)') + '\n' +
412
+ (ctx ? ctx + '\n' : '') +
413
+ '\nBu olay için kullanıcıya gönderilecek proaktif kısa mesajı yaz.'
414
+ );
415
+ }
416
+
417
+ function composeFallback(ev) {
418
+ return ev.title + (ev.detail ? ' — ' + ev.detail : '');
419
+ }
420
+
421
+ /* ---------- döngü ---------- */
422
+
423
+ /* signals: { self: async()=>[raw], news: async()=>[raw] } — her biri bağımsız;
424
+ biri çökerse diğerleri çalışmaya devam eder. llmFilter: async(prompt)=>text. */
425
+ async function runCycle({ cfg, signals, llmFilter, now = new Date(), log = () => {} }) {
426
+ const t0 = Date.now();
427
+ log('cycle başladı');
428
+ const st = loadState();
429
+ const raws = [];
430
+ const srcStats = {};
431
+ for (const [name, fn] of Object.entries(signals || {})) {
432
+ if (typeof fn !== 'function') continue;
433
+ try {
434
+ const items = (await fn()) || [];
435
+ srcStats[name] = items.length;
436
+ for (const it of items) {
437
+ const ev = normalizeRaw(it);
438
+ if (ev) raws.push(ev);
439
+ }
440
+ } catch (e) {
441
+ srcStats[name] = 'hata: ' + String((e && e.message) || e).slice(0, 60);
442
+ }
443
+ }
444
+ log('sinyaller: ' + JSON.stringify(srcStats));
445
+
446
+ /* dedup: havuzda taze parmakizi varsa yut; toplu içinde tekrar varsa birleştir */
447
+ const pending = [];
448
+ let dups = 0;
449
+ for (const ev of raws) {
450
+ const fp = fingerprint(ev.type, ev.title);
451
+ const seenAt = st.seen[fp];
452
+ if (seenAt && now.getTime() - seenAt < SEEN_TTL) {
453
+ dups++;
454
+ continue;
455
+ }
456
+ st.seen[fp] = now.getTime();
457
+ const same = pending.find((p) => p._fp === fp);
458
+ if (same) {
459
+ if (!same.sources.includes(ev.source)) same.sources.push(ev.source);
460
+ dups++;
461
+ continue;
462
+ }
463
+ ev._fp = fp;
464
+ pending.push(ev);
465
+ }
466
+ /* parmakizi havuzu tavanı — en eskiyi düşür */
467
+ const seenKeys = Object.keys(st.seen);
468
+ if (seenKeys.length > SEEN_CAP) {
469
+ seenKeys.sort((a, b) => st.seen[a] - st.seen[b]);
470
+ for (const k of seenKeys.slice(0, seenKeys.length - SEEN_CAP)) delete st.seen[k];
471
+ }
472
+
473
+ /* UCUZ FİLTRE: tüm adaylar TEK toplu çağrıda (maliyet freni). Çökerse deterministik.
474
+ İlgi alanları: elle girilen + sohbetlerden öğrenilenler; son konuşmalar bağlam. */
475
+ const interests = combinedInterests(cfg);
476
+ const chatCtx = chatContextLine(8);
477
+ let graded = null;
478
+ if (typeof llmFilter === 'function' && pending.length) {
479
+ try {
480
+ const out = await llmFilter(filterPrompt(pending.slice(0, FILTER_MAX), interests, chatCtx));
481
+ graded = parseFilterJson(out);
482
+ if (graded.size) log('filtre: LLM ' + graded.size + ' olayı puanladı');
483
+ } catch {
484
+ graded = null;
485
+ }
486
+ }
487
+ if (!graded || !graded.size) log('filtre: deterministik puanlama');
488
+
489
+ const counts = { ignored: 0, stored: 0, queued: 0, dup: dups, raw: raws.length };
490
+ const actions = [];
491
+ for (const ev of pending) {
492
+ const g = graded && graded.get(String(ev.id).toLowerCase());
493
+ ev.scores = g
494
+ ? {
495
+ importance: Math.max(0, Math.min(100, Math.round(Number(g.importance) || 0))),
496
+ relevance: Math.max(0, Math.min(100, Math.round(Number(g.relevance) || 0))),
497
+ urgency: Math.max(0, Math.min(100, Math.round(Number(g.urgency) || 0))),
498
+ novelty: Math.max(0, Math.min(100, Math.round(Number(g.novelty) || 0))),
499
+ }
500
+ : heuristicScores(ev, { ...cfg, interests });
501
+ ev.priority = compositePriority(ev.scores, cfg);
502
+ ev.reason = g ? String(g.reason || '').slice(0, 80) : 'deterministik puan';
503
+ if (g && g.notify === false && ev.priority < cfg.minNotifyPriority) {
504
+ ev.status = 'ignored';
505
+ counts.ignored++;
506
+ } else if (ev.priority < 30) {
507
+ ev.status = 'ignored';
508
+ counts.ignored++;
509
+ } else {
510
+ const key = topicKey(ev.type, ev.title);
511
+ const cd = st.cooldowns[key];
512
+ if (cd && now.getTime() < cd.until && ev.priority < (cd.lastPriority || 0) + 15) {
513
+ ev.status = 'stored';
514
+ ev.reason = (ev.reason || '') + ' · sessizlik penceresi';
515
+ counts.stored++;
516
+ } else if (ev.priority < cfg.minNotifyPriority) {
517
+ ev.status = 'stored';
518
+ counts.stored++;
519
+ } else {
520
+ ev.status = 'queued';
521
+ counts.queued++;
522
+ actions.push({
523
+ event: ev,
524
+ level: ev.priority >= 80 ? 'high' : 'medium',
525
+ });
526
+ }
527
+ }
528
+ st.events.push(ev);
529
+ }
530
+
531
+ /* kuyruğu önceliğe göre kes — döngü başına en fazla maxNotifyPerCycle bildirim */
532
+ actions.sort((a, b) => b.event.priority - a.event.priority);
533
+ const kept = actions.slice(0, cfg.maxNotifyPerCycle);
534
+ const dropped = actions.slice(cfg.maxNotifyPerCycle);
535
+ for (const a of dropped) {
536
+ a.event.status = 'stored';
537
+ a.event.reason += ' · kuyruk tavanı';
538
+ counts.stored++;
539
+ }
540
+ counts.queued = kept.length;
541
+
542
+ /* depo tavanı */
543
+ if (st.events.length > EVENT_CAP) st.events = st.events.slice(st.events.length - EVENT_CAP);
544
+
545
+ st.lastRunAt = now.toISOString();
546
+ saveState(st);
547
+ log(
548
+ 'cycle tamam: raw=' + counts.raw + ' dup=' + dups +
549
+ ' ignored=' + counts.ignored + ' stored=' + counts.stored + ' queued=' + counts.queued +
550
+ ' (' + (Date.now() - t0) + 'ms)'
551
+ );
552
+ return { summary: counts, actions: kept };
553
+ }
554
+
555
+ function markNotified(id, level, text, cooldownMin) {
556
+ const st = loadState();
557
+ const ev = st.events.find((e) => e.id === id);
558
+ if (ev) {
559
+ ev.status = 'notified';
560
+ ev.level = level || 'medium';
561
+ ev.text = String(text || '').slice(0, 600);
562
+ ev.notifiedAt = new Date().toISOString();
563
+ const key = topicKey(ev.type, ev.title);
564
+ st.cooldowns[key] = {
565
+ until: Date.now() + Math.max(0, Number(cooldownMin) || 0) * 60000,
566
+ lastPriority: ev.priority,
567
+ notifiedAt: ev.notifiedAt,
568
+ };
569
+ saveState(st);
570
+ }
571
+ }
572
+
573
+ function listEvents(limit) {
574
+ const st = loadState();
575
+ const n = Math.max(1, Math.min(200, Number(limit) || 60));
576
+ return st.events.slice(-n).reverse().map((e) => ({
577
+ id: e.id,
578
+ ts: e.ts,
579
+ source: e.source,
580
+ type: e.type,
581
+ title: e.title,
582
+ detail: e.detail,
583
+ priority: e.priority,
584
+ scores: e.scores || {},
585
+ reason: e.reason,
586
+ status: e.status,
587
+ level: e.level,
588
+ text: e.text,
589
+ }));
590
+ }
591
+
592
+ function lastRunAt() {
593
+ return loadState().lastRunAt;
594
+ }
595
+
596
+ module.exports = {
597
+ DEFAULTS,
598
+ mergeCfg,
599
+ fetchNews,
600
+ runCycle,
601
+ markNotified,
602
+ listEvents,
603
+ lastRunAt,
604
+ rememberConversation,
605
+ learnInterests,
606
+ combinedInterests,
607
+ chatContextLine,
608
+ memSnapshot,
609
+ memClear,
610
+ FILTER_SYSTEM,
611
+ COMPOSE_SYSTEM,
612
+ filterPrompt,
613
+ composePrompt,
614
+ composeFallback,
615
+ fingerprint,
616
+ };