beast-agent 2.3.4 → 2.4.0

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.0",
5
5
  "description": "Ultra-fast local agent shell for Windows.",
6
6
  "author": "algokodcom (AlgoKod)",
7
7
  "license": "MIT",
@@ -0,0 +1,447 @@
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
+ /* ---------- normalize / dedup ---------- */
97
+
98
+ function normTitle(t) {
99
+ const base = String(t || '')
100
+ .toLowerCase()
101
+ .replace(/[^\p{L}\p{N}\s]/gu, ' ')
102
+ .replace(/\s+/g, ' ')
103
+ .trim();
104
+ return base.split(' ').slice(0, 12).join(' ');
105
+ }
106
+
107
+ function fingerprint(type, title) {
108
+ return crypto.createHash('sha1').update(normTitle(type + '|' + title)).digest('hex').slice(0, 14);
109
+ }
110
+
111
+ /* cooldown anahtarı: konunun ilk 5 kelimesi — varyasyonlar aynı kovaya düşer */
112
+ function topicKey(type, title) {
113
+ return normTitle(type + ' ' + title).split(' ').slice(0, 5).join(' ');
114
+ }
115
+
116
+ function uid() {
117
+ return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
118
+ }
119
+
120
+ function normalizeRaw(item) {
121
+ const title = String((item && item.title) || '').replace(/\s+/g, ' ').trim().slice(0, 300);
122
+ if (!title) return null;
123
+ const type = String((item && item.type) || 'news').slice(0, 24);
124
+ return {
125
+ id: uid(),
126
+ ts: item.ts || new Date().toISOString(),
127
+ source: String((item && item.source) || type).slice(0, 40),
128
+ type,
129
+ title,
130
+ detail: String((item && item.detail) || '').slice(0, 300),
131
+ sources: [(item && item.source) || type],
132
+ scores: {},
133
+ priority: 0,
134
+ reason: '',
135
+ status: 'new',
136
+ level: '',
137
+ text: '',
138
+ notifiedAt: null,
139
+ };
140
+ }
141
+
142
+ /* ---------- haber kaynağı (Google News RSS — anahtar gerektirmez,
143
+ Reuters/BBC/AP gibi kaynakları zaten tek beslemede harmanlar) ---------- */
144
+
145
+ async function fetchNews(topics) {
146
+ const out = [];
147
+ for (const q of (topics || []).slice(0, 4)) {
148
+ try {
149
+ const url = 'https://news.google.com/rss/search?q=' + encodeURIComponent(q) + '&hl=tr&gl=TR&ceid=TR:tr';
150
+ const res = await fetch(url, { signal: AbortSignal.timeout(12000) });
151
+ if (!res.ok) continue;
152
+ const xml = await res.text();
153
+ const re = /<item>[\s\S]*?<\/item>/g;
154
+ let m;
155
+ let count = 0;
156
+ while ((m = re.exec(xml)) && count < NEWS_MAX) {
157
+ const t = /<title>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/title>/.exec(m[0]);
158
+ const title = t ? String(t[1]).replace(/\s+/g, ' ').trim() : '';
159
+ if (!title) continue;
160
+ count++;
161
+ out.push({ type: 'news', title, detail: 'Google News · ' + q, source: 'news:' + q });
162
+ }
163
+ } catch {}
164
+ }
165
+ return out;
166
+ }
167
+
168
+ /* ---------- ucuz deterministik puanlayıcı (LLM yoksa/çökerse fallback) ---------- */
169
+
170
+ const URGENT_RE = /acil|critical|son dakika|outage|arıza|crash|down|kesinti|kapatıldı|breach|güvenlik|saldırı|risk/i;
171
+
172
+ function heuristicScores(ev, cfg) {
173
+ const t = normTitle(ev.title);
174
+ const URGENT = URGENT_RE.test(t);
175
+ let importance = ev.type === 'news' ? 40 : 65;
176
+ if (URGENT) importance += 20;
177
+ const words = String(cfg.interests || '').toLowerCase().split(/[,\s]+/).filter((w) => w.length > 2);
178
+ let hits = 0;
179
+ for (const w of words) if (t.includes(w)) hits++;
180
+ const relevance = hits ? Math.min(100, 55 + hits * 15) : (ev.type === 'news' ? 30 : 60);
181
+ const urgency = URGENT ? 75 : (ev.type === 'news' ? 35 : 45);
182
+ const novelty = ev.type === 'news' ? 80 : 60;
183
+ return { importance, relevance, urgency, novelty };
184
+ }
185
+
186
+ function compositePriority(scores, cfg) {
187
+ const w = cfg.weights;
188
+ const s = (x) => Math.max(0, Math.min(100, Number(x) || 0));
189
+ const sum = w.importance + w.relevance + w.urgency + w.novelty || 1;
190
+ return Math.round(
191
+ (s(scores.importance) * w.importance +
192
+ s(scores.relevance) * w.relevance +
193
+ s(scores.urgency) * w.urgency +
194
+ s(scores.novelty) * w.novelty) / sum
195
+ );
196
+ }
197
+
198
+ /* ---------- LLM filtre promptu / ayrıştırıcı ---------- */
199
+
200
+ const FILTER_SYSTEM =
201
+ 'Sen bir bilgi filtresisin; sohbet etmezsin. Sana verilen olayları değerlendirip YALNIZCA JSON döndürürsün. ' +
202
+ 'Skorlar 0-100 arası tamsayı. "relevant": kullanıcının ilgi alanlarıyla alakalı mı. ' +
203
+ '"notify": kullanıcıya bildirmeye gerçekten değer mi (önemsiz/spam/klasik haberlerde false). ' +
204
+ '"reason": en fazla 60 karakter kısa gerekçe.';
205
+
206
+ function filterPrompt(events, interests) {
207
+ const list = events.map((e) => ({
208
+ id: e.id,
209
+ type: e.type,
210
+ title: e.title,
211
+ detail: e.detail,
212
+ }));
213
+ return (
214
+ 'KULLANICI İLGİLERİ: ' + (String(interests || '').trim() || '(belirtilmemiş)') + '\n' +
215
+ 'OLAYLAR:\n' + JSON.stringify(list) + '\n\n' +
216
+ 'Her olay için {"id","relevant","importance","urgency","novelty","notify","reason"} içeren ' +
217
+ 'TEK bir JSON dizisi döndür. Sadece JSON, başka metin yok.'
218
+ );
219
+ }
220
+
221
+ function parseFilterJson(text) {
222
+ try {
223
+ let s = String(text || '').replace(/```(?:json)?/gi, '');
224
+ const a = s.indexOf('[');
225
+ const b = s.lastIndexOf(']');
226
+ if (a < 0 || b <= a) return [];
227
+ const arr = JSON.parse(s.slice(a, b + 1));
228
+ if (!Array.isArray(arr)) return [];
229
+ const map = new Map();
230
+ for (const it of arr) {
231
+ if (it && it.id !== undefined) map.set(String(it.id).toLowerCase(), it);
232
+ }
233
+ return map;
234
+ } catch {
235
+ return [];
236
+ }
237
+ }
238
+
239
+ /* ---------- ana LLM compose promptu / fallback ---------- */
240
+
241
+ const COMPOSE_SYSTEM =
242
+ 'Beast adlı yerel yardımcı için proaktif bildirim metni yazarsın. Türkçe, samimi "kanka" tonunda, ' +
243
+ 'EN FAZLA 2 kısa cümle: ne olduğunu soyutla, neden önemli olabileceğini söyle, istersen tek kısa soru sor. ' +
244
+ 'Alarm spam\'i yok; başlık/emoji/Markdown ekleme, yalnız düz metin yaz.';
245
+
246
+ function composePrompt(ev, cfg) {
247
+ return (
248
+ 'OLAY: ' + ev.title + '\n' +
249
+ (ev.detail ? 'DETAY: ' + ev.detail + '\n' : '') +
250
+ 'KAYNAK: ' + ev.source + '\n' +
251
+ 'ÖNCELİK: ' + ev.priority + '/100 · ' + (ev.reason || '') + '\n' +
252
+ 'KULLANICI İLGİLERİ: ' + (String((cfg && cfg.interests) || '').trim() || '(belirtilmemiş)') + '\n\n' +
253
+ 'Bu olay için kullanıcıya gönderilecek proaktif kısa mesajı yaz.'
254
+ );
255
+ }
256
+
257
+ function composeFallback(ev) {
258
+ return ev.title + (ev.detail ? ' — ' + ev.detail : '');
259
+ }
260
+
261
+ /* ---------- döngü ---------- */
262
+
263
+ /* signals: { self: async()=>[raw], news: async()=>[raw] } — her biri bağımsız;
264
+ biri çökerse diğerleri çalışmaya devam eder. llmFilter: async(prompt)=>text. */
265
+ async function runCycle({ cfg, signals, llmFilter, now = new Date(), log = () => {} }) {
266
+ const t0 = Date.now();
267
+ log('cycle başladı');
268
+ const st = loadState();
269
+ const raws = [];
270
+ const srcStats = {};
271
+ for (const [name, fn] of Object.entries(signals || {})) {
272
+ if (typeof fn !== 'function') continue;
273
+ try {
274
+ const items = (await fn()) || [];
275
+ srcStats[name] = items.length;
276
+ for (const it of items) {
277
+ const ev = normalizeRaw(it);
278
+ if (ev) raws.push(ev);
279
+ }
280
+ } catch (e) {
281
+ srcStats[name] = 'hata: ' + String((e && e.message) || e).slice(0, 60);
282
+ }
283
+ }
284
+ log('sinyaller: ' + JSON.stringify(srcStats));
285
+
286
+ /* dedup: havuzda taze parmakizi varsa yut; toplu içinde tekrar varsa birleştir */
287
+ const pending = [];
288
+ let dups = 0;
289
+ for (const ev of raws) {
290
+ const fp = fingerprint(ev.type, ev.title);
291
+ const seenAt = st.seen[fp];
292
+ if (seenAt && now.getTime() - seenAt < SEEN_TTL) {
293
+ dups++;
294
+ continue;
295
+ }
296
+ st.seen[fp] = now.getTime();
297
+ const same = pending.find((p) => p._fp === fp);
298
+ if (same) {
299
+ if (!same.sources.includes(ev.source)) same.sources.push(ev.source);
300
+ dups++;
301
+ continue;
302
+ }
303
+ ev._fp = fp;
304
+ pending.push(ev);
305
+ }
306
+ /* parmakizi havuzu tavanı — en eskiyi düşür */
307
+ const seenKeys = Object.keys(st.seen);
308
+ if (seenKeys.length > SEEN_CAP) {
309
+ seenKeys.sort((a, b) => st.seen[a] - st.seen[b]);
310
+ for (const k of seenKeys.slice(0, seenKeys.length - SEEN_CAP)) delete st.seen[k];
311
+ }
312
+
313
+ /* UCUZ FİLTRE: tüm adaylar TEK toplu çağrıda (maliyet freni). Çökerse deterministik. */
314
+ let graded = null;
315
+ if (typeof llmFilter === 'function' && pending.length) {
316
+ try {
317
+ const out = await llmFilter(filterPrompt(pending.slice(0, FILTER_MAX), cfg.interests));
318
+ graded = parseFilterJson(out);
319
+ if (graded.size) log('filtre: LLM ' + graded.size + ' olayı puanladı');
320
+ } catch {
321
+ graded = null;
322
+ }
323
+ }
324
+ if (!graded || !graded.size) log('filtre: deterministik puanlama');
325
+
326
+ const counts = { ignored: 0, stored: 0, queued: 0, dup: dups, raw: raws.length };
327
+ const actions = [];
328
+ for (const ev of pending) {
329
+ const g = graded && graded.get(String(ev.id).toLowerCase());
330
+ ev.scores = g
331
+ ? {
332
+ importance: Math.max(0, Math.min(100, Math.round(Number(g.importance) || 0))),
333
+ relevance: Math.max(0, Math.min(100, Math.round(Number(g.relevance) || 0))),
334
+ urgency: Math.max(0, Math.min(100, Math.round(Number(g.urgency) || 0))),
335
+ novelty: Math.max(0, Math.min(100, Math.round(Number(g.novelty) || 0))),
336
+ }
337
+ : heuristicScores(ev, cfg);
338
+ ev.priority = compositePriority(ev.scores, cfg);
339
+ ev.reason = g ? String(g.reason || '').slice(0, 80) : 'deterministik puan';
340
+ if (g && g.notify === false && ev.priority < cfg.minNotifyPriority) {
341
+ ev.status = 'ignored';
342
+ counts.ignored++;
343
+ } else if (ev.priority < 30) {
344
+ ev.status = 'ignored';
345
+ counts.ignored++;
346
+ } else {
347
+ const key = topicKey(ev.type, ev.title);
348
+ const cd = st.cooldowns[key];
349
+ if (cd && now.getTime() < cd.until && ev.priority < (cd.lastPriority || 0) + 15) {
350
+ ev.status = 'stored';
351
+ ev.reason = (ev.reason || '') + ' · sessizlik penceresi';
352
+ counts.stored++;
353
+ } else if (ev.priority < cfg.minNotifyPriority) {
354
+ ev.status = 'stored';
355
+ counts.stored++;
356
+ } else {
357
+ ev.status = 'queued';
358
+ counts.queued++;
359
+ actions.push({
360
+ event: ev,
361
+ level: ev.priority >= 80 ? 'high' : 'medium',
362
+ });
363
+ }
364
+ }
365
+ st.events.push(ev);
366
+ }
367
+
368
+ /* kuyruğu önceliğe göre kes — döngü başına en fazla maxNotifyPerCycle bildirim */
369
+ actions.sort((a, b) => b.event.priority - a.event.priority);
370
+ const kept = actions.slice(0, cfg.maxNotifyPerCycle);
371
+ const dropped = actions.slice(cfg.maxNotifyPerCycle);
372
+ for (const a of dropped) {
373
+ a.event.status = 'stored';
374
+ a.event.reason += ' · kuyruk tavanı';
375
+ counts.stored++;
376
+ }
377
+ counts.queued = kept.length;
378
+
379
+ /* depo tavanı */
380
+ if (st.events.length > EVENT_CAP) st.events = st.events.slice(st.events.length - EVENT_CAP);
381
+
382
+ st.lastRunAt = now.toISOString();
383
+ saveState(st);
384
+ log(
385
+ 'cycle tamam: raw=' + counts.raw + ' dup=' + dups +
386
+ ' ignored=' + counts.ignored + ' stored=' + counts.stored + ' queued=' + counts.queued +
387
+ ' (' + (Date.now() - t0) + 'ms)'
388
+ );
389
+ return { summary: counts, actions: kept };
390
+ }
391
+
392
+ function markNotified(id, level, text, cooldownMin) {
393
+ const st = loadState();
394
+ const ev = st.events.find((e) => e.id === id);
395
+ if (ev) {
396
+ ev.status = 'notified';
397
+ ev.level = level || 'medium';
398
+ ev.text = String(text || '').slice(0, 600);
399
+ ev.notifiedAt = new Date().toISOString();
400
+ const key = topicKey(ev.type, ev.title);
401
+ st.cooldowns[key] = {
402
+ until: Date.now() + Math.max(0, Number(cooldownMin) || 0) * 60000,
403
+ lastPriority: ev.priority,
404
+ notifiedAt: ev.notifiedAt,
405
+ };
406
+ saveState(st);
407
+ }
408
+ }
409
+
410
+ function listEvents(limit) {
411
+ const st = loadState();
412
+ const n = Math.max(1, Math.min(200, Number(limit) || 60));
413
+ return st.events.slice(-n).reverse().map((e) => ({
414
+ id: e.id,
415
+ ts: e.ts,
416
+ source: e.source,
417
+ type: e.type,
418
+ title: e.title,
419
+ detail: e.detail,
420
+ priority: e.priority,
421
+ scores: e.scores || {},
422
+ reason: e.reason,
423
+ status: e.status,
424
+ level: e.level,
425
+ text: e.text,
426
+ }));
427
+ }
428
+
429
+ function lastRunAt() {
430
+ return loadState().lastRunAt;
431
+ }
432
+
433
+ module.exports = {
434
+ DEFAULTS,
435
+ mergeCfg,
436
+ fetchNews,
437
+ runCycle,
438
+ markNotified,
439
+ listEvents,
440
+ lastRunAt,
441
+ FILTER_SYSTEM,
442
+ COMPOSE_SYSTEM,
443
+ filterPrompt,
444
+ composePrompt,
445
+ composeFallback,
446
+ fingerprint,
447
+ };
package/src/main.js CHANGED
@@ -3256,6 +3256,7 @@ app.whenReady().then(() => {
3256
3256
  createTray();
3257
3257
  cron.init({ onFire: cronFire });
3258
3258
  watchers.start({ onTrigger: watcherFire });
3259
+ empatiKickoff(); // empati loop: açılış + 90 sn sonra ilk tarama, sonra cfg aralığı
3259
3260
  startEventBus();
3260
3261
  ideWatchStart(); // soldaki dosya ağacı canlı izlemede
3261
3262
  studioWatchStart(); // Beast Studio klasörü canlı izlemede
@@ -6187,6 +6188,193 @@ function watcherFire(w, value) {
6187
6188
  } catch {}
6188
6189
  }
6189
6190
 
6191
+ /* ---------- EMPATİ LOOP: proaktif algı/event alt sistemi ----------
6192
+ Ana sohbet motorundan bağımsız: sinyal topla → ucuz filtre modeliyle puanla →
6193
+ kompozit öncelik → değerliyse ANA modelle kısa proaktif mesaj üret →
6194
+ masaüstü + WA'ya bildir. Önemsiz olaylar yalnız depoya yazılır, rahatsız etmez. */
6195
+
6196
+ const empati = require('./agent/perception');
6197
+ const empatiRuntime = { running: false, timer: null };
6198
+
6199
+ function empatiCfg() {
6200
+ return empati.mergeCfg(settings.empati || {});
6201
+ }
6202
+
6203
+ function empatiLog(line) {
6204
+ try { waLog('[EMPATİ] ' + line); } catch {}
6205
+ }
6206
+
6207
+ /* tarama (filtre) modeli: sekmeden seçilmişse onu çöz; seçilmemişse ANA model */
6208
+ function empatiFilterSel() {
6209
+ const fm = empatiCfg().filterModel;
6210
+ if (fm) {
6211
+ try {
6212
+ const r = engine._resolve(fm);
6213
+ if (r) return r;
6214
+ } catch {}
6215
+ }
6216
+ return engine.sel;
6217
+ }
6218
+
6219
+ /* sinyal toplayıcılar — perception modülü saf kalır, engine/köprülerle burada konuşur */
6220
+ async function empatiSignalSelf() {
6221
+ const out = [];
6222
+ try {
6223
+ const w = engine.lastWhereWasI();
6224
+ if (w && w.pendingTodos && w.pendingTodos.length) {
6225
+ out.push({
6226
+ type: 'todo',
6227
+ title: 'Yarım kalan görevler: ' + w.pendingTodos.map((t) => t.title).join(' · ').slice(0, 200),
6228
+ detail: 'oturum ' + (w.code || '') + ' · ' + w.pendingTodos.length + ' görev bekliyor',
6229
+ });
6230
+ }
6231
+ } catch {}
6232
+ return out;
6233
+ }
6234
+
6235
+ async function empatiSignalNews() {
6236
+ const topics = String(empatiCfg().newsTopics || '').split(',').map((s) => s.trim()).filter(Boolean);
6237
+ if (!topics.length) return [];
6238
+ return empati.fetchNews(topics);
6239
+ }
6240
+
6241
+ /* tek toplu filtre çağrısı (maliyet freni); model yok/çökerse boş → deterministik puan */
6242
+ function empatiLlmFilter(prompt) {
6243
+ const sel = empatiFilterSel();
6244
+ if (!sel) return Promise.resolve('');
6245
+ const ctrl = new AbortController();
6246
+ const kill = setTimeout(() => ctrl.abort(), 45000);
6247
+ return require('./agent/llm')
6248
+ .chatOnce(sel, {
6249
+ messages: [
6250
+ { role: 'system', content: empati.FILTER_SYSTEM },
6251
+ { role: 'user', content: prompt },
6252
+ ],
6253
+ temperature: 0.1,
6254
+ }, { signal: ctrl.signal })
6255
+ .then((r) => String(r.content || ''))
6256
+ .catch(() => '')
6257
+ .finally(() => clearTimeout(kill));
6258
+ }
6259
+
6260
+ /* compose her zaman ANA model kullanır — filtre ucuz, anlamlandırma güçlü */
6261
+ function empatiLlmCompose(prompt) {
6262
+ if (!engine.sel) return Promise.resolve('');
6263
+ const ctrl = new AbortController();
6264
+ const kill = setTimeout(() => ctrl.abort(), 60000);
6265
+ return require('./agent/llm')
6266
+ .chatOnce(engine.sel, {
6267
+ messages: [
6268
+ { role: 'system', content: empati.COMPOSE_SYSTEM },
6269
+ { role: 'user', content: prompt },
6270
+ ],
6271
+ temperature: 0.6,
6272
+ }, { signal: ctrl.signal })
6273
+ .then((r) => String(r.content || '').trim().slice(0, 600))
6274
+ .catch(() => '')
6275
+ .finally(() => clearTimeout(kill));
6276
+ }
6277
+
6278
+ /* bildirim hedefi: sekmeden seçilen entegrasyon; seçilmemişse bağlı olanlar.
6279
+ Hiçbir entegrasyon yazılamazsa masaüstü chat UI (toast) kalır. */
6280
+ function empatiNotify(text, ev) {
6281
+ const cfg = empatiCfg();
6282
+ const senders = [];
6283
+ const tryWa = () => {
6284
+ try {
6285
+ const own = waOwnerNum();
6286
+ if (own && wa && wa.connected) senders.push(() => sendWaSafe(own + '@s.whatsapp.net', '🫡 *Beast proaktif:*\n' + text));
6287
+ } catch {}
6288
+ };
6289
+ const tryTg = () => {
6290
+ try {
6291
+ if (tg && tg.connected) for (const id of tgOwnerIds()) senders.push(() => sendTgSafe(id, '🫡 *Beast proaktif:*\n' + text));
6292
+ } catch {}
6293
+ };
6294
+ const tryDc = () => {
6295
+ try {
6296
+ if (dc && dc.connected) for (const id of dcOwnerIds()) senders.push(() => sendDcSafe(id, '🫡 **Beast proaktif:**\n' + text));
6297
+ } catch {}
6298
+ };
6299
+ if (cfg.notifyTarget === 'whatsapp') tryWa();
6300
+ else if (cfg.notifyTarget === 'telegram') tryTg();
6301
+ else if (cfg.notifyTarget === 'discord') tryDc();
6302
+ else { tryWa(); tryTg(); tryDc(); } // auto: ekli/bağlı entegrasyonlar
6303
+ let sent = 0;
6304
+ for (const fn of senders) {
6305
+ try { fn(); sent++; } catch {}
6306
+ }
6307
+ /* hiçbir entegrasyona yazılamadıysa yalnız masaüstü chat UI'a düş */
6308
+ try {
6309
+ if (!sent && win && !win.isDestroyed()) {
6310
+ win.webContents.send('agent:event', { type: 'proactive', id: ev.id, level: ev.level, title: ev.title, text });
6311
+ }
6312
+ } catch {}
6313
+ }
6314
+
6315
+ async function empatiCycle(manual) {
6316
+ if (empatiRuntime.running) return { ok: false, error: 'döngü zaten çalışıyor' };
6317
+ const cfg = empatiCfg();
6318
+ if (!cfg.enabled && !manual) return { ok: false, error: 'kapalı' };
6319
+ empatiRuntime.running = true;
6320
+ try {
6321
+ const r = await empati.runCycle({
6322
+ cfg,
6323
+ signals: { self: empatiSignalSelf, news: empatiSignalNews },
6324
+ llmFilter: empatiLlmFilter,
6325
+ now: new Date(),
6326
+ log: empatiLog,
6327
+ });
6328
+ let notified = 0;
6329
+ for (const a of r.actions) {
6330
+ let text = '';
6331
+ try { text = await empatiLlmCompose(empati.composePrompt(a.event, cfg)); } catch {}
6332
+ if (!text) text = empati.composeFallback(a.event);
6333
+ empatiNotify(text, a.event);
6334
+ empati.markNotified(a.event.id, a.level, text, cfg.cooldownMin);
6335
+ notified++;
6336
+ }
6337
+ if (notified && win && !win.isDestroyed()) {
6338
+ win.webContents.send('agent:event', { type: 'empati', notified });
6339
+ }
6340
+ return { ok: true, ...r.summary, notified };
6341
+ } finally {
6342
+ empatiRuntime.running = false;
6343
+ }
6344
+ }
6345
+
6346
+ function empatiSchedule() {
6347
+ if (empatiRuntime.timer) clearTimeout(empatiRuntime.timer);
6348
+ empatiRuntime.timer = null;
6349
+ const cfg = empatiCfg();
6350
+ if (!cfg.enabled) return;
6351
+ empatiRuntime.timer = setTimeout(async () => {
6352
+ try { await empatiCycle(false); } catch {}
6353
+ empatiSchedule();
6354
+ }, cfg.intervalMin * 60000);
6355
+ }
6356
+
6357
+ /* açılış + 90 sn: ilk tarama (başlangıç fırtınasını önle), sonra cfg aralığı */
6358
+ function empatiKickoff() {
6359
+ setTimeout(() => {
6360
+ empatiCycle(false).catch(() => {});
6361
+ empatiSchedule();
6362
+ }, 90 * 1000);
6363
+ }
6364
+
6365
+ ipcMain.handle('empati:get', () => ({ ...empatiCfg(), running: empatiRuntime.running, lastRunAt: empati.lastRunAt() }));
6366
+ ipcMain.handle('empati:set', (_e, patch) => {
6367
+ const cfg = empati.mergeCfg({ ...empatiCfg(), ...(patch || {}) });
6368
+ settings.empati = cfg;
6369
+ saveSettings();
6370
+ empatiSchedule(); // aralık/model değişmiş olabilir
6371
+ return { ...cfg };
6372
+ });
6373
+ ipcMain.handle('empati:scan', async () => {
6374
+ try { return await empatiCycle(true); } catch (e) { return { ok: false, error: String((e && e.message) || e).slice(0, 200) }; }
6375
+ });
6376
+ ipcMain.handle('empati:events', () => empati.listEvents(80));
6377
+
6190
6378
  ipcMain.handle('cron:list', () => cron.list());
6191
6379
  /* #23 Fallout: provider → kayıtlı API key haritası.
6192
6380
  Birincil kaynak: engine chain (config+custom+env çözülmüş).
package/src/preload.js CHANGED
@@ -207,4 +207,8 @@ contextBridge.exposeInMainWorld('beast', {
207
207
  ideDelete: (rel) => ipcRenderer.invoke('ide:delete', rel),
208
208
  onWaEvent: (cb) => ipcRenderer.on('wa:event', (_e, ev) => cb(ev)),
209
209
  onEvent: (cb) => ipcRenderer.on('agent:event', (_e, ev) => cb(ev)),
210
+ empatiGet: () => ipcRenderer.invoke('empati:get'),
211
+ empatiSet: (patch) => ipcRenderer.invoke('empati:set', patch),
212
+ empatiScan: () => ipcRenderer.invoke('empati:scan'),
213
+ empatiEvents: () => ipcRenderer.invoke('empati:events'),
210
214
  });
@@ -114,6 +114,40 @@
114
114
  up_install_now: 'Yeniden Başlat & Kur',
115
115
  up_note: 'Kurulumdan sonra uygulama otomatik yeniden başlar. İstersen WhatsApp\u2019tan /update komutunu da kullanabilirsin.',
116
116
  tab_events: 'Olay Merkezi',
117
+ tab_empati: 'Empati Loop',
118
+ em_h2: 'Empati Loop — Proaktif Algı',
119
+ em_sub: 'Beast arka planda periyodik tarar: yarım kalan işler, haber akışı. Boru hattı: sinyal → ucuz filtre modeli → öncelik puanı → bildirim. Önemsiz olaylar yalnız depoya yazılır, seni rahatsız etmez.',
120
+ em_ipc_err: 'Empati servisine ulaşılamadı.',
121
+ em_on: 'Empati Loop açık',
122
+ em_interval: 'Tarama aralığı (dk)',
123
+ em_min_notify: 'Bildirim eşiği (puan)',
124
+ em_cooldown: 'Konu sessizliği (dk)',
125
+ em_model: 'Tarama (filtre) modeli',
126
+ em_model_main: 'Ana model (seçili)',
127
+ em_model_sub: 'Seçmezsen tarama ana modelle yapılır. Anlamlandırma (mesaj yazımı) her zaman ana modelle.',
128
+ em_notify: 'Bildirim hedefi',
129
+ em_notify_auto: 'Otomatik — bağlı entegrasyonlar',
130
+ em_notify_wa: 'WhatsApp',
131
+ em_notify_tg: 'Telegram',
132
+ em_notify_dc: 'Discord',
133
+ em_notify_sub: 'Seçmezsen ekli ve bağlı entegrasyonların hepsine yazar; hiçbiri bağlı değilse masaüstü sohbete düşer.',
134
+ em_interests: 'İlgi alanların — haber filtresi buna göre ağırlıklandırılır',
135
+ em_interests_ph: 'örn: yapay zeka, yazılım, trading, open source',
136
+ em_news: 'Haber kaynağı (Google News)',
137
+ em_news_topics_ph: 'haber konuları, virgülle: yapay zeka, ekonomi…',
138
+ em_scan: 'Şimdi Tara',
139
+ em_scan_ok: 'Tarama: {raw} sinyal · {queued} bildirim · {stored} depo',
140
+ em_events: 'Son olaylar',
141
+ em_no_events: 'Henüz olay yok — Şimdi Tara ile deneyebilirsin.',
142
+ em_saved: 'Empati ayarları kaydedildi',
143
+ em_lastrun: 'Son tarama',
144
+ em_note: 'İzleyiciler (watchers) kendi bildirimlerini zaten yapıyor; Empati Loop onları tekrar bildirmez. Filtre modeli tek toplu çağrıyla çalışır; model yoksa puanlama deterministik yapılır.',
145
+ em_st_notified: 'bildirildi',
146
+ em_st_queued: 'kuyrukta',
147
+ em_st_stored: 'depo',
148
+ em_st_ignored: 'yok sayıldı',
149
+ em_lv_high: 'YÜKSEK',
150
+ em_lv_medium: 'ORTA',
117
151
  tab_cron: 'Cron',
118
152
  tab_usage: 'Maliyet · Limit',
119
153
  tab_logs: 'Log',
@@ -691,6 +725,40 @@
691
725
  up_install_now: 'Restart & Install',
692
726
  up_note: 'The app restarts itself after installing. You can also use the /update command from WhatsApp.',
693
727
  tab_events: 'Event Center',
728
+ tab_empati: 'Empathy Loop',
729
+ em_h2: 'Empathy Loop — Proactive Perception',
730
+ em_sub: 'Beast scans periodically in the background: half-done work, news flow. Pipeline: signal → cheap filter model → priority score → notification. Unimportant events are only stored, they never bother you.',
731
+ em_ipc_err: 'Could not reach the empathy service.',
732
+ em_on: 'Empathy Loop enabled',
733
+ em_interval: 'Scan interval (min)',
734
+ em_min_notify: 'Notify threshold (score)',
735
+ em_cooldown: 'Topic silence (min)',
736
+ em_model: 'Scan (filter) model',
737
+ em_model_main: 'Main model (selected)',
738
+ em_model_sub: 'If left empty, scanning uses the main model. Composing the message always uses the main model.',
739
+ em_notify: 'Notification target',
740
+ em_notify_auto: 'Auto — connected integrations',
741
+ em_notify_wa: 'WhatsApp',
742
+ em_notify_tg: 'Telegram',
743
+ em_notify_dc: 'Discord',
744
+ em_notify_sub: 'If left empty, it writes to all connected integrations; with none connected it lands in the desktop chat.',
745
+ em_interests: 'Your interests — news filtering is weighted by these',
746
+ em_interests_ph: 'e.g. AI, software, trading, open source',
747
+ em_news: 'News source (Google News)',
748
+ em_news_topics_ph: 'news topics, comma separated: AI, economy…',
749
+ em_scan: 'Scan Now',
750
+ em_scan_ok: 'Scan: {raw} signals · {queued} notify · {stored} stored',
751
+ em_events: 'Recent events',
752
+ em_no_events: 'No events yet — try Scan Now.',
753
+ em_saved: 'Empathy settings saved',
754
+ em_lastrun: 'Last scan',
755
+ em_note: 'Watchers already notify on their own; the Empathy Loop does not repeat them. The filter model runs in a single batched call; without a model, scoring is deterministic.',
756
+ em_st_notified: 'notified',
757
+ em_st_queued: 'queued',
758
+ em_st_stored: 'stored',
759
+ em_st_ignored: 'ignored',
760
+ em_lv_high: 'HIGH',
761
+ em_lv_medium: 'MEDIUM',
694
762
  tab_cron: 'Cron',
695
763
  tab_usage: 'Cost · Limit',
696
764
  tab_logs: 'Logs',
@@ -239,6 +239,7 @@
239
239
  <button class="tab" data-tab="websearch" data-i18n="tab_websearch">Web Arama</button>
240
240
  <button class="tab" data-tab="mcp" data-i18n="tab_mcp">MCP</button>
241
241
  <button class="tab" data-tab="events" data-i18n="tab_events">Olay Merkezi</button>
242
+ <button class="tab" data-tab="empati" data-i18n="tab_empati">Empati Loop</button>
242
243
  <button class="tab" data-tab="cron" data-i18n="tab_cron">Cron</button>
243
244
  <button class="tab" data-tab="usage" data-i18n="tab_usage">Maliyet · Limit</button>
244
245
  <button class="tab" data-tab="logs" data-i18n="tab_logs">Log</button>
@@ -264,6 +265,7 @@
264
265
  <div id="tab-websearch" class="pane" hidden></div>
265
266
  <div id="tab-mcp" class="pane" hidden></div>
266
267
  <div id="tab-events" class="pane" hidden></div>
268
+ <div id="tab-empati" class="pane" hidden></div>
267
269
  <div id="tab-usage" class="pane" hidden></div>
268
270
  <div id="tab-logs" class="pane" hidden></div>
269
271
  <div id="tab-dash" class="pane" hidden></div>
@@ -1066,6 +1066,7 @@ async function renderActiveSettingsTab() {
1066
1066
  case 'websearch': await renderWebSearchPane(); break;
1067
1067
  case 'mcp': await renderMcpPane(); break;
1068
1068
  case 'events': await renderEventsPane(); break;
1069
+ case 'empati': await renderEmpatiPane(); break;
1069
1070
  case 'cron': await openCron(); break;
1070
1071
  case 'usage': await renderUsagePane(); break;
1071
1072
  case 'agents': await refreshAgentsPane(); break;
@@ -1151,7 +1152,7 @@ function switchTab(name) {
1151
1152
  document.querySelectorAll('#setTabs .tab').forEach((b) =>
1152
1153
  b.classList.toggle('active', b.dataset.tab === name)
1153
1154
  );
1154
- for (const p of ['lang', 'provider', 'fallout', 'skills', 'agents', 'tts', 'install', 'email', 'integrations', 'websearch', 'mcp', 'events', 'cron', 'usage', 'logs', 'dash', 'sec', 'update']) {
1155
+ for (const p of ['lang', 'provider', 'fallout', 'skills', 'agents', 'tts', 'install', 'email', 'integrations', 'websearch', 'mcp', 'events', 'empati', 'cron', 'usage', 'logs', 'dash', 'sec', 'update']) {
1155
1156
  const el = $('#tab-' + p);
1156
1157
  if (el) el.hidden = p !== name; // guard: eksik pane tüm sekmeleri kilitlemesin
1157
1158
  }
@@ -1160,6 +1161,7 @@ function switchTab(name) {
1160
1161
  if (name === 'usage') renderUsagePane();
1161
1162
  if (name === 'install') renderInstallPane();
1162
1163
  if (name === 'events') renderEventsPane();
1164
+ if (name === 'empati') renderEmpatiPane();
1163
1165
  if (name === 'logs') renderLogPane();
1164
1166
  if (name === 'dash') renderDashboardPane();
1165
1167
  if (name === 'sec') renderSecurityPane();
@@ -3022,6 +3024,136 @@ async function renderEventsPane() {
3022
3024
  });
3023
3025
  }
3024
3026
 
3027
+ /* ---------------- EMPATİ LOOP (proaktif algı sekmesi) ---------------- */
3028
+ async function renderEmpatiPane() {
3029
+ const pane = $('#tab-empati');
3030
+ if (!pane) return;
3031
+ const cfg = await beast.empatiGet().catch(() => null);
3032
+ let events = [];
3033
+ try { events = await beast.empatiEvents(); } catch {}
3034
+ let models = [];
3035
+ try { models = (await beast.getState()).models || []; } catch {}
3036
+ if (!cfg) {
3037
+ pane.innerHTML = '<h2>' + _t('em_h2') + '</h2><div class="sub">' + _t('em_ipc_err') + '</div>';
3038
+ return;
3039
+ }
3040
+ const modelOpts =
3041
+ '<option value="">' + _t('em_model_main') + '</option>' +
3042
+ models.map((m) =>
3043
+ '<option value="' + escapeHtml(m.sel || '') + '"' + (cfg.filterModel === m.sel ? ' selected' : '') + '>' +
3044
+ escapeHtml((m.providerName || '') + ' · ' + (m.model || '')) + '</option>'
3045
+ ).join('');
3046
+ const targetOpts = [
3047
+ ['whatsapp', _t('em_notify_wa')],
3048
+ ['telegram', _t('em_notify_tg')],
3049
+ ['discord', _t('em_notify_dc')],
3050
+ ].map(([v, lbl]) =>
3051
+ '<option value="' + v + '"' + (cfg.notifyTarget === v ? ' selected' : '') + '>' + escapeHtml(lbl) + '</option>'
3052
+ ).join('');
3053
+ const when = (iso) => {
3054
+ try {
3055
+ return new Date(iso).toLocaleString('tr-TR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
3056
+ } catch { return String(iso || ''); }
3057
+ };
3058
+ const statusT = { notified: _t('em_st_notified'), queued: _t('em_st_queued'), stored: _t('em_st_stored'), ignored: _t('em_st_ignored') };
3059
+ const lvBadge = (e) => {
3060
+ if (!e.level) return '';
3061
+ const color = e.level === 'high' ? '#e06c75' : '#d9a441';
3062
+ return ' · <b style="color:' + color + '">' + (e.level === 'high' ? _t('em_lv_high') : _t('em_lv_medium')) + '</b>';
3063
+ };
3064
+ let rows = '';
3065
+ for (const e of events) {
3066
+ rows +=
3067
+ '<div class="usage-row" style="align-items:flex-start">' +
3068
+ '<div style="min-width:0;flex:1">' +
3069
+ '<div style="font-size:12px">' + escapeHtml(e.title || '') + '</div>' +
3070
+ '<div class="ur-meta">' + escapeHtml(e.source || '') + ' · ' + when(e.ts) + ' · %' + (e.priority || 0) +
3071
+ (e.reason ? ' · ' + escapeHtml(e.reason) : '') + '</div>' +
3072
+ (e.text ? '<div class="sub" style="font-size:11px">' + escapeHtml(e.text) + '</div>' : '') +
3073
+ '</div>' +
3074
+ '<span style="flex:none;font-size:11px;color:var(--muted)">' + (statusT[e.status] || e.status) + lvBadge(e) + '</span>' +
3075
+ '</div>';
3076
+ }
3077
+
3078
+ pane.innerHTML =
3079
+ '<h2>' + _t('em_h2') + '</h2>' +
3080
+ '<div class="sub">' + _t('em_sub') + '</div>' +
3081
+ (cfg.lastRunAt ? '<div class="sub">' + _t('em_lastrun') + ': ' + when(cfg.lastRunAt) + (cfg.running ? ' · ⏳' : '') + '</div>' : '') +
3082
+ `<div class="fo-toggles" style="margin-top:10px">
3083
+ <label class="lock-row"><input type="checkbox" id="emOn" ${cfg.enabled ? 'checked' : ''}/><span>${_t('em_on')}</span></label>
3084
+ </div>
3085
+ <div id="emDetail" style="${cfg.enabled ? '' : 'opacity:.5'}">
3086
+ <div class="form-grid" style="grid-template-columns:1fr 1fr 1fr;margin-top:8px">
3087
+ <div><label class="sub">${_t('em_interval')}</label><input id="emInterval" class="inp" type="number" min="3" max="1440" value="${cfg.intervalMin}"/></div>
3088
+ <div><label class="sub">${_t('em_min_notify')}</label><input id="emMin" class="inp" type="number" min="0" max="100" value="${cfg.minNotifyPriority}"/></div>
3089
+ <div><label class="sub">${_t('em_cooldown')}</label><input id="emCd" class="inp" type="number" min="0" max="10080" value="${cfg.cooldownMin}"/></div>
3090
+ </div>
3091
+ <div style="margin-top:8px">
3092
+ <label class="sub">${_t('em_model')}</label>
3093
+ <select id="emModel" class="inp">${modelOpts}</select>
3094
+ <div class="sub">${_t('em_model_sub')}</div>
3095
+ </div>
3096
+ <div style="margin-top:8px">
3097
+ <label class="sub">${_t('em_notify')}</label>
3098
+ <select id="emTarget" class="inp"><option value="">${_t('em_notify_auto')}</option>${targetOpts}</select>
3099
+ <div class="sub">${_t('em_notify_sub')}</div>
3100
+ </div>
3101
+ <div style="margin-top:8px">
3102
+ <label class="sub">${_t('em_interests')}</label>
3103
+ <textarea id="emInterests" class="inp" rows="2" placeholder="${_t('em_interests_ph')}">${escapeHtml(cfg.interests || '')}</textarea>
3104
+ </div>
3105
+ <label class="lock-row" style="margin-top:8px"><input type="checkbox" id="emNews" ${cfg.newsTopics ? 'checked' : ''}/><span>${_t('em_news')}</span></label>
3106
+ <input id="emTopics" class="inp" style="${cfg.newsTopics ? '' : 'opacity:.5'}" placeholder="${_t('em_news_topics_ph')}" value="${escapeHtml(cfg.newsTopics || '')}" spellcheck="false"/>
3107
+ <div style="display:flex;gap:8px;margin-top:10px;align-items:center">
3108
+ <button id="emSave" class="btn">${_t('mcp_save')}</button>
3109
+ <button id="emScan" class="btn ghost">${_t('em_scan')}</button>
3110
+ <span id="emMsg" class="sub" style="margin:0"></span>
3111
+ </div>
3112
+ <div class="sub" style="margin-top:6px">${_t('em_note')}</div>
3113
+ </div>
3114
+ <h3 style="margin-top:16px;color:var(--muted)">${_t('em_events')}</h3>`;
3115
+ const wrap = document.createElement('div');
3116
+ if (!rows) wrap.innerHTML = '<p class="sub">' + _t('em_no_events') + '</p>';
3117
+ else wrap.innerHTML = rows;
3118
+ pane.appendChild(wrap);
3119
+
3120
+ const emOn = $('#emOn');
3121
+ const emDetail = $('#emDetail');
3122
+ const emNews = $('#emNews');
3123
+ const emTopics = $('#emTopics');
3124
+ emOn.addEventListener('change', () => { emDetail.style.opacity = emOn.checked ? '' : '.5'; });
3125
+ emNews.addEventListener('change', () => { emTopics.style.opacity = emNews.checked ? '' : '.5'; });
3126
+ $('#emSave').addEventListener('click', async () => {
3127
+ const patch = {
3128
+ enabled: emOn.checked,
3129
+ intervalMin: Number($('#emInterval').value) || cfg.intervalMin,
3130
+ minNotifyPriority: Number($('#emMin').value),
3131
+ cooldownMin: Number($('#emCd').value),
3132
+ notifyTarget: $('#emTarget') ? $('#emTarget').value : '',
3133
+ filterModel: $('#emModel') ? $('#emModel').value : '',
3134
+ interests: $('#emInterests').value.trim(),
3135
+ newsTopics: emNews.checked ? emTopics.value.trim() : '',
3136
+ };
3137
+ const r = await beast.empatiSet(patch).catch(() => null);
3138
+ toast(r ? _t('em_saved') : 'Hata');
3139
+ if (r) renderEmpatiPane();
3140
+ });
3141
+ $('#emScan').addEventListener('click', async () => {
3142
+ const msg = $('#emMsg');
3143
+ msg.textContent = '⏳';
3144
+ const r = await beast.empatiScan().catch(() => null);
3145
+ if (r && r.ok) {
3146
+ msg.textContent = _t('em_scan_ok')
3147
+ .replace('{raw}', String(r.raw || 0))
3148
+ .replace('{queued}', String(r.queued || 0))
3149
+ .replace('{stored}', String(r.stored || 0));
3150
+ renderEmpatiPane();
3151
+ } else {
3152
+ msg.textContent = (r && r.error) || 'hata';
3153
+ }
3154
+ });
3155
+ }
3156
+
3025
3157
  async function renderWaAllow() {
3026
3158
  const wrap = $('#waAllowChips');
3027
3159
  if (!wrap) return;
@@ -4706,6 +4838,15 @@ function onEvent(ev) {
4706
4838
  return;
4707
4839
  }
4708
4840
  if (ev.type === 'install-progress') { updateInstallPct(ev); return; }
4841
+ /* EMPATİ LOOP: proaktif bildirim (masaüstü) + sekme canlı yenileme */
4842
+ if (ev.type === 'proactive') {
4843
+ toast('🫡 ' + (ev.text || ev.title || ''));
4844
+ return;
4845
+ }
4846
+ if (ev.type === 'empati') {
4847
+ if (!els.settingsOverlay.hidden && setTab === 'empati') renderEmpatiPane();
4848
+ return;
4849
+ }
4709
4850
  if (ev.type === 'update') {
4710
4851
  if (ev.downloaded) toast(_t('up_downloaded') + ' (v' + (ev.version || '?') + ') — /update now');
4711
4852
  else if (ev.available && ev.version && ev.version !== ev.current) toast(_t('up_available') + ' (v' + ev.version + ')');