beast-agent 0.28.0 → 0.30.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": "0.28.0",
4
+ "version": "0.30.0",
5
5
  "description": "Ultra-fast local agent shell for Windows.",
6
6
  "author": "algokodcom (AlgoKod)",
7
7
  "license": "MIT",
package/src/agent/bots.js CHANGED
@@ -199,6 +199,8 @@ function add({ name, icon, prompt }) {
199
199
  loadRegistry();
200
200
  const n = String(name || '').trim().slice(0, 40);
201
201
  if (!n) return { ok: false, error: 'bot adı zorunlu' };
202
+ /* İLK HARF ZORUNLU: bot adı harf karakteriyle başlamalı */
203
+ if (!/^\p{L}/u.test(n)) return { ok: false, error: 'bot adı harf ile başlamalı — ilk karakter harf olmalı' };
202
204
  if (REG.bots.length >= MAX_BOTS) return { ok: false, error: `en fazla ${MAX_BOTS} bot olabilir (1 admin + ${MAX_BOTS - 1} müşteri)` };
203
205
  let id = slugify(n);
204
206
  while (get(id)) id = slugify(n) + '-' + uid().slice(-4);
@@ -261,6 +263,12 @@ function update(id, patch) {
261
263
  loadRegistry();
262
264
  const b = get(id);
263
265
  if (!b) return { ok: false, error: 'bot yok' };
266
+ /* İLK HARF ZORUNLU: ad değişikliği harf karakteriyle başlamalı —
267
+ diğer alanlara dokunmadan reddet */
268
+ if (patch && typeof patch.name === 'string' && patch.name.trim() &&
269
+ !/^\p{L}/u.test(patch.name.trim())) {
270
+ return { ok: false, error: 'bot adı harf ile başlamalı — ilk karakter harf olmalı' };
271
+ }
264
272
  const changes = [];
265
273
  if (patch && typeof patch === 'object') {
266
274
  if (typeof patch.name === 'string' && patch.name.trim() && patch.name.trim() !== b.name) {
@@ -13,6 +13,7 @@ const research = require('./research');
13
13
  const memory = require('./memory');
14
14
  const skills = require('./skills');
15
15
  const { estTokens, estMsgTokens } = require('./tokens');
16
+ const log = require('./logger');
16
17
 
17
18
  const MAX_TURNS = 25;
18
19
  const SUB_MAX_TURNS = 8;
@@ -258,6 +259,8 @@ class Engine {
258
259
  this._bgLimit = Math.max(1, Number(opts.bgLimit || process.env.BEAST_BG_LIMIT) || BG_MAX_CONCURRENT_DEFAULT);
259
260
  this._bgPendingStart = new Map(); // sid -> ilk görev metni (slot açılınca gönderilir)
260
261
  this._bgGroups = new Map(); // groupId -> {parentId,total,results,dead}
262
+ /* silinen oturum işaretleri: rapor/zincir buraya gönderilmez (hayalet oturum yok) */
263
+ this._deletedSessions = new Set();
261
264
  /* #17 kalıcı ajan geçmişi: restart sonrası işler + sohbetler kaybolmasın */
262
265
  this._bgJobsFile = path.join(this.sessionsDir, 'bg-jobs.json');
263
266
  this._loadBgJobsPersisted();
@@ -630,10 +633,13 @@ class Engine {
630
633
  {
631
634
  signal,
632
635
  onDelta,
633
- onRetry: (attempt) =>
636
+ onRetry: (attempt, st) =>
634
637
  emitSafe(this, session.id, {
635
638
  type: 'status',
636
- status: `sağlayıcı yanıtsız — tekrar deniyor (${attempt}/${3})`,
639
+ status:
640
+ st === undefined || st === null
641
+ ? `bağlantı sorunu — internet dönünce ya da yeniden denemeyle sürer (${attempt}/6)`
642
+ : `sağlayıcı yanıtsız — tekrar deniyor (${attempt}/3)`,
637
643
  }),
638
644
  }
639
645
  );
@@ -1170,13 +1176,14 @@ class Engine {
1170
1176
  }
1171
1177
 
1172
1178
  deleteSession(id) {
1179
+ const sid = String(id || '');
1173
1180
  try {
1174
- const c = this.ctrls.get(id);
1181
+ const c = this.ctrls.get(sid);
1175
1182
  if (c) {
1176
1183
  this._abortReasons = this._abortReasons || new Map();
1177
- this._abortReasons.set(String(id), 'oturum silindiği için tur iptal edildi');
1184
+ this._abortReasons.set(sid, 'oturum silindiği için tur iptal edildi');
1178
1185
  c.abort();
1179
- this.ctrls.delete(id);
1186
+ this.ctrls.delete(sid);
1180
1187
  }
1181
1188
  /* #17 silinecek ajanın bekleyen öz-kurtarma zamanlayıcısını iptal et —
1182
1189
  yoksa send() oturum dosyasını yeniden yaratıp işi diriltir */
@@ -1203,6 +1210,14 @@ class Engine {
1203
1210
  this._saveBgJobs();
1204
1211
  this._bgEmit();
1205
1212
  }
1213
+ /* silinme işareti: bekleyen raporlar/rapor zinciri bu oturuma ASLA düşmez */
1214
+ if (this._deletedSessions) {
1215
+ this._deletedSessions.add(sid);
1216
+ if (this._deletedSessions.size > 300) {
1217
+ const first = this._deletedSessions.values().next().value;
1218
+ if (first !== undefined) this._deletedSessions.delete(first);
1219
+ }
1220
+ }
1206
1221
  this.emit({ type: 'sessions' });
1207
1222
  return true;
1208
1223
  }
@@ -1210,23 +1225,37 @@ class Engine {
1210
1225
  /* ---------- prompt assembly (the frugal part) ---------- */
1211
1226
 
1212
1227
  /* Beast Code (IDE paneli): VS Code hızında çalışsın diye SECMET prompt.
1228
+ OpenCode kodlama disiplini gömülü: bağlam → plan → küçük diff → doğrula.
1213
1229
  Hafıza embedding araması, skills taraması, kişilik/kural blokları YOK —
1214
1230
  prompt kısa kalır → ilk token hızlı, tur maliyeti düşük. */
1215
1231
  buildBcSystem(session) {
1216
1232
  const nowD = new Date();
1217
1233
  const localDate = nowD.toLocaleDateString('tr-TR');
1218
1234
  const localTime = nowD.toLocaleTimeString('tr-TR', { hour: '2-digit', minute: '2-digit' });
1235
+ const mode = String((session && session.bcMode) || 'auto').toLowerCase();
1236
+ const modeBlock =
1237
+ mode === 'plan'
1238
+ ? 'ÇALIŞMA MODU: PLAN 🔍 — dosyaları oku/incele (read_file, list_dir), KOD YAZMA; mevcut yapıyı özetle ve adım adım UYGULAMA PLANI ver (hangi dosya, ne değişecek, nasıl doğrulanır). Kullanıcı /build deyince plan uygulanır.'
1239
+ : mode === 'build'
1240
+ ? 'ÇALIŞMA MODU: BUILD 🛠 — son planı SOHBETTEKİ bağlamdan al ve UYGULA: dosyaları düzenle, komutları çalıştır, doğrula. Yeni plan açma; en fazla 1 cümlelik yön gösterimi + uygulama.'
1241
+ : 'ÇALIŞMA MODU: OTOMATİK ⚡ — önce 2-4 satırlık mini plan (todo_write), sonra hemen uygula + doğrula.';
1219
1242
  return (
1220
- 'Sen BEAST CODE\u2019sun — IDE panelinde çalışan hızlı bir kodlama ajanı. VS Code gibi çevik ol.\n' +
1243
+ 'Sen BEAST CODE\u2019sun — IDE panelinde çalışan, OpenCode disiplinli hızlı bir kodlama ajanı. VS Code gibi çevik ol.\n' +
1221
1244
  `Çalışma klasörü: ${(session && session.workspace) || this.workspace}\n` +
1222
1245
  `Yerel zaman: ${localDate} ${localTime}\n` +
1246
+ `${modeBlock}\n` +
1247
+ 'WORKFLOW (her işte bu sıra):\n' +
1248
+ '1) BAĞLAM: değiştirmeden önce ilgili dosyaları OKU (read_file / list_dir / grep mantığıyla hedefli ara) — varsayım yapma, mevcut stili/konvansiyonu takip et.\n' +
1249
+ '2) PLAN: 2+ adımlı işlerde İLK EYLEM todo_write olsun (2-6 madde); her adım bitince status:"done" ile GÜNCELLE — liste bitene kadar iş bitmiş sayılmaz.\n' +
1250
+ '3) EDİT: küçük, HEDEFLİ diff\u2019ler yaz (write_file ile yalnız ilgili bölümü etkile); dosyayı baştan yazma; ilgisiz yeniden biçimleme/kayıt boşluk değişikliği YAPMA.\n' +
1251
+ '4) DOĞRULA: edit sonrası mümkünse derle/test et/lint çalıştır (run_command); hata varsa DÜZELT ve TEKRAR dene (en fazla 2 doğrulama turu) — kırmızı bırakma.\n' +
1252
+ '5) RAPOR: 1-3 satır — ne değişti + doğrulama sonucu (ör. "npm test ✓ 154/154"). Uzun açıklama yok.\n' +
1223
1253
  'HIZ KURALLARI:\n' +
1224
- '- İLK EYLEMİN daima todo_write olsun: işi 2-6 maddelik plana böl; her adım bitince status:"done" ile güncelle.\n' +
1225
- '- Kodu ve dosyaları DOĞRUDAN write_file ile yaz/güncelle. Basit dosya oluşturma/düzenleme için asla Python scripti yazma; python_run yalnız gerçek hesap/veri işleme gerekiyorsa.\n' +
1226
- '- Kurulum, test, build, git gibi işler için run_command kullan (Windows PowerShell ortamı).\n' +
1227
- '- Bağımsız araç çağrılarını AYNI TURDA paralel ver.\n' +
1254
+ '- Kodu ve dosyaları DOĞRUDAN write_file ile yaz; basit dosya oluşturma/düzenleme için Python scripti yazma; python_run yalnız gerçek hesap/veri işleme gerekiyorsa.\n' +
1255
+ '- Bağımsız araç çağrılarını AYNI TURDA paralel ver (birden çok read_file tek turda).\n' +
1228
1256
  '- Dosyayı kullanıcı söylememişse list_dir ile yapıyı görüp kendin karar ver.\n' +
1229
- '- Uzun açıklama yok: doğrudan çalış, bitince 1-3 satırlık kısa özet ver. Soru sorma, sohbet etme.\n' +
1257
+ '- Run_command PowerShell ortamında çalışır (Windows).\n' +
1258
+ '- Soru sorma, sohbet etme; uygulayıp özetle. Kullanıcı /plan /build /auto ile modu değiştirir.\n' +
1230
1259
  FORMAT_RULES
1231
1260
  );
1232
1261
  }
@@ -1798,6 +1827,11 @@ class Engine {
1798
1827
  _bgGroupRecord(title, status, summary) {
1799
1828
  for (const [gid, g] of this._bgGroups) {
1800
1829
  if (g.dead) continue;
1830
+ /* üst oturum silinmişse grup ölü sayılır — hayalet oturuma yazma */
1831
+ if (this._deletedSessions && this._deletedSessions.has(g.parentId)) {
1832
+ g.dead = true;
1833
+ continue;
1834
+ }
1801
1835
  g.results.push({ title, status, summary: String(summary || '').slice(0, 900) });
1802
1836
  if (g.results.length >= g.total) {
1803
1837
  this._bgGroups.delete(gid);
@@ -1995,8 +2029,18 @@ class Engine {
1995
2029
  const reason = Engine.superviseReason(job, now);
1996
2030
  if (!reason) continue;
1997
2031
  /* #16 öz-kurtarma: takılan ajan CEO'yu meşgul etmeden kendini toparlar */
1998
- if (reason === 'stuck' && (job.fixes || 0) < BG_FIX_MAX) {
1999
- this._bgSelfHeal(job, now);
2032
+ if (reason === 'stuck') {
2033
+ if ((job.fixes || 0) < BG_FIX_MAX) {
2034
+ this._bgSelfHeal(job, now);
2035
+ } else {
2036
+ /* öz-kurtarma hakları tükendi + hâlâ takılı → ZORLA KAPAT:
2037
+ ajan sonsuza dek 'running' kalırsa fan-out grubu ASLA tamamlanmaz
2038
+ ve birleşik rapor hiç düşmez — CEO "uyuyor" kalır */
2039
+ this._bgForceClose(
2040
+ job,
2041
+ `superyorizon: ajan ${Math.round((now - Date.parse(job.startedAt)) / 60000)} dk'dır takılı, öz-kurtarma hakları tükendi`
2042
+ );
2043
+ }
2000
2044
  continue;
2001
2045
  }
2002
2046
  job.lastNudgeAt = nowIso();
@@ -2037,6 +2081,26 @@ class Engine {
2037
2081
  this._bgKick(job, text);
2038
2082
  }
2039
2083
 
2084
+ /* Takılı işi ZORLA sonlandır: tur abort edilir, iş kaydı SEBEPİYLE kapanır,
2085
+ grup üyesiyse 'aborted' olarak gruba yazılır → birleşik rapor AKAR,
2086
+ CEO "uyumak" yerine hemen sonucu kullanıcıya aktarır. */
2087
+ _bgForceClose(job, reason) {
2088
+ const sid = String(job.id);
2089
+ job.revive = false; /* kick-in-flight guard'ı devre dışı — bu gerçek bir bitiş */
2090
+ this._clearKicks(sid);
2091
+ this._abortReasons = this._abortReasons || new Map();
2092
+ this._abortReasons.set(sid, this._abortReason(reason));
2093
+ const ctrl = this.ctrls.get(sid);
2094
+ if (ctrl) {
2095
+ try { ctrl.abort(); } catch {}
2096
+ /* _run catch'i sebebi okuyup _bgFinish('aborted') yapacak */
2097
+ } else {
2098
+ /* tur zaten yok — kaydı doğrudan kapat */
2099
+ this._bgFinish(sid, 'aborted', reason);
2100
+ }
2101
+ try { log.info('bg', `iş zorla kapatıldı: ${job.title} — ${reason}`); } catch {}
2102
+ }
2103
+
2040
2104
  /* Arka plan oturumuna müdahale: asılı turu kes (ctrl.abort), sonra
2041
2105
  kurtarma mesajını bas. abort → eski _run sonlanır → send yeni tur açar.
2042
2106
  revive bayrağı: /stop veya silme sonrası bekleyen müdahaleler patlamasın. */
@@ -2090,6 +2154,11 @@ class Engine {
2090
2154
  try {
2091
2155
  const meta = this._bgMeta && this._bgMeta.get(String(bgSessionId));
2092
2156
  if (!meta) return false;
2157
+ /* üst oturum silinmişse rapor düşer — hayalet oturum yaratma */
2158
+ if (this._deletedSessions && this._deletedSessions.has(meta.parentId)) {
2159
+ this._bgMeta.delete(String(bgSessionId));
2160
+ return false;
2161
+ }
2093
2162
  /* Öz-kurtarma/wrap-up kick'i bekliyorsa bu bir ABORT turudur — gerçek
2094
2163
  bitiş DEĞİL; erken "ARKA PLAN BİTTİ (sonuca ulaşmadan)" ilanı basma */
2095
2164
  const pendingKick = this._bgJobs.get(String(bgSessionId));
@@ -2131,14 +2200,19 @@ class Engine {
2131
2200
  }
2132
2201
  }
2133
2202
 
2134
- /* Ana oturum işini bitirince bekleyen arka plan raporlarını boşalt */
2203
+ /* Ana oturum işini bitirince bekleyen arka plan raporlarını boşalt.
2204
+ Güvenli: oturum SİLİNMİŞSE rapor düşer (hayalet oturum yaratılmaz);
2205
+ send başarısızsa (aniden meşgul oldu) rapor sırada kalır, sonra denenir. */
2135
2206
  flushPendingReports(sessionId) {
2136
2207
  if (!this._pendingReports || !this._pendingReports.length) return;
2137
2208
  const rest = [];
2138
2209
  for (const r of this._pendingReports) {
2139
- if (r.parentId === sessionId && !this.isBusy(sessionId)) {
2140
- try { this.send(sessionId, { text: r.text }); } catch {}
2141
- } else rest.push(r);
2210
+ if (r.parentId !== sessionId) { rest.push(r); continue; }
2211
+ if (this._deletedSessions && this._deletedSessions.has(sessionId)) continue;
2212
+ if (this.isBusy(sessionId)) { rest.push(r); continue; }
2213
+ let sent = false;
2214
+ try { sent = !!this.send(sessionId, { text: r.text }); } catch {}
2215
+ if (!sent) rest.push(r);
2142
2216
  }
2143
2217
  this._pendingReports = rest;
2144
2218
  }
@@ -2471,6 +2545,14 @@ class Engine {
2471
2545
  } finally {
2472
2546
  this.ctrls.delete(sid);
2473
2547
  emit({ type: 'status', status: 'idle' });
2548
+ /* bekleyen paralel-ajan raporları HER bitiş yolunda boşaltılır —
2549
+ hata/durdurma/tur-limiti sonrası bile sonuç raporu kullanıcıya ulaşır.
2550
+ DİKKAT: buradan flush'u DOĞRUDAN çağırma! send → _run senkron-hata
2551
+ yolunda (model-yok gibi) await'siz zincirlenir → stack taşması.
2552
+ Macrotask'a ertele: her hop taze stack alır, pop-gönder sırası bozulmaz. */
2553
+ const fl = () => { try { this.flushPendingReports(sid); } catch {} };
2554
+ const t = setTimeout(fl, 0);
2555
+ if (t.unref) t.unref();
2474
2556
  }
2475
2557
  }
2476
2558
 
package/src/agent/llm.js CHANGED
@@ -1,10 +1,51 @@
1
1
  'use strict';
2
2
 
3
3
  /* OpenAI-compatible streaming chat client with SSE parsing.
4
- Geçici sağlayıcı hatalarında (5xx/429/ağ) üstel beklemeyle otomatik retry. */
4
+ Geçici sağlayıcı hatalarında (5xx/429) üstel beklemeyle retry; İNTERNET
5
+ KOPMASINDA (fetch failed) çok daha sabırlı: 6 deneme + internet dönene
6
+ dek bekleme (main'deki net izleyicisinden beslenir). Akış ortasında kopan
7
+ bağlantıda: hiç veri akmadıysa sessiz baştan dener, kısmi metin geldiyse
8
+ 'length' gibi döner → chatStreamAuto kaldığı yerden DEVAM ETTİRİR. */
5
9
 
6
10
  const RETRYABLE_STATUS = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
7
- const MAX_RETRIES = 3;
11
+ const MAX_RETRIES = 3; /* sağlayıcı geçici HTTP hataları */
12
+ const NET_MAX_RETRIES = 6; /* bağlantı kopması — daha sabırlı */
13
+ const NET_BACKOFF = [1000, 2000, 4000, 8000, 15000, 25000]; /* ≈55 sn toplam */
14
+ const NET_WAIT_CAP_MS = 120000; /* internet dönene dek bekleme üst sınırı */
15
+
16
+ /* main process'teki net izleyicisi besler: () => boolean | Promise<boolean> */
17
+ let _netProbe = null;
18
+ function setNetProbe(fn) {
19
+ _netProbe = typeof fn === 'function' ? fn : null;
20
+ }
21
+
22
+ /* fetch failed / ENOTFOUND / ECONNRESET / socket hang up / terminated —
23
+ HTTP yanıtı ALINAMADI demektir (status yok) */
24
+ function isNetworkError(e) {
25
+ if (!e) return false;
26
+ if (e.name === 'AbortError') return false;
27
+ if (e.status !== undefined) return false;
28
+ return true;
29
+ }
30
+
31
+ /* internet yoksa dönene kadar bekle (en fazla NET_WAIT_CAP_MS); probe yoksa
32
+ ya da durum bilinmiyorsa hemen geç */
33
+ async function waitForNet(signal) {
34
+ if (!_netProbe) return true;
35
+ const t0 = Date.now();
36
+ for (;;) {
37
+ if (signal && signal.aborted) {
38
+ const e = new Error('iptal');
39
+ e.name = 'AbortError';
40
+ throw e;
41
+ }
42
+ let online;
43
+ try { online = await _netProbe(); } catch { online = null; }
44
+ if (online !== false) return true; /* false DEĞİLSE devam (bilinmiyor = iyimser) */
45
+ if (Date.now() - t0 > NET_WAIT_CAP_MS) return false;
46
+ await sleep(2000, signal);
47
+ }
48
+ }
8
49
 
9
50
  const HINTS = {
10
51
  401: 'API anahtarı geçersiz',
@@ -47,8 +88,9 @@ function sleep(ms, signal) {
47
88
  });
48
89
  }
49
90
 
50
- /* fn: denenecek istek. hataları ve RETRYABLE_STATUS durumlarında
51
- 0.8s 1.6s 3.2s bekleyip tekrar dener. */
91
+ /* fn: denenecek istek. Sağlayıcı geçici HTTP hatalarında 0.8s→1.6s→3.2s;
92
+ İNTERNET KOPMASINDA (status yok) 6 denemeye kadar 1s→2s→4s→8s→15s→25s ve
93
+ net izleyicisi "çevrimdışı" diyorsa internet dönene kadar bekler. */
52
94
  async function withRetries(fn, { signal, onRetry } = {}) {
53
95
  let attempt = 0;
54
96
  for (;;) {
@@ -57,14 +99,23 @@ async function withRetries(fn, { signal, onRetry } = {}) {
57
99
  } catch (e) {
58
100
  const aborted = e && (e.name === 'AbortError' || (signal && signal.aborted));
59
101
  const status = e && e.status;
60
- const retriable =
61
- !aborted && (status === undefined || RETRYABLE_STATUS.has(status));
62
- if (!retriable || attempt >= MAX_RETRIES) throw e;
102
+ const netErr = !aborted && status === undefined;
103
+ const retriable = !aborted && (status === undefined || RETRYABLE_STATUS.has(status));
104
+ if (!retriable) throw e;
105
+ const cap = netErr ? NET_MAX_RETRIES : MAX_RETRIES;
106
+ if (attempt >= cap) throw e;
107
+ if (netErr && _netProbe) {
108
+ /* internet kopmuş: dönene kadar bekle — kısa kopmalarda görev ölmez */
109
+ await waitForNet(signal);
110
+ }
63
111
  attempt++;
64
112
  try {
65
113
  onRetry && onRetry(attempt, status);
66
114
  } catch {}
67
- await sleep(800 * Math.pow(2, attempt - 1), signal);
115
+ const wait = netErr
116
+ ? NET_BACKOFF[Math.min(attempt - 1, NET_BACKOFF.length - 1)]
117
+ : 800 * Math.pow(2, attempt - 1);
118
+ await sleep(wait, signal);
68
119
  }
69
120
  }
70
121
  }
@@ -113,79 +164,101 @@ async function chatStream(sel, body, { signal, onDelta, onRetry } = {}) {
113
164
  }
114
165
 
115
166
  async function streamOnce(sel, body, { signal, onDelta, onRetry } = {}, omitReasoning = false) {
116
- const res = await withRetries(
117
- async () => {
118
- const r = await openChat(sel, body, { stream: true, signal, omitReasoning });
119
- if (!r.ok) {
120
- let detail = '';
121
- try {
122
- detail = (await r.text()).slice(0, 300);
123
- } catch {}
124
- const err = new Error(friendlyError(r.status, r.statusText, detail));
125
- err.status = r.status;
126
- throw err;
127
- }
128
- return r;
129
- },
130
- { signal, onRetry }
131
- );
167
+ /* akış durumu dışarıda tutulur: gövde okunurken bağlantı koparsa
168
+ ne kadarı geldiğine bakılır (kısmi metin → devam; boş/araç → baştan) */
169
+ const state = { content: '', reasoning: '', toolCalls: [], usage: null, finishReason: null };
170
+ let dropAttempt = 0;
171
+
172
+ for (;;) {
173
+ const res = await withRetries(
174
+ async () => {
175
+ const r = await openChat(sel, body, { stream: true, signal, omitReasoning });
176
+ if (!r.ok) {
177
+ let detail = '';
178
+ try {
179
+ detail = (await r.text()).slice(0, 300);
180
+ } catch {}
181
+ const err = new Error(friendlyError(r.status, r.statusText, detail));
182
+ err.status = r.status;
183
+ throw err;
184
+ }
185
+ return r;
186
+ },
187
+ { signal, onRetry }
188
+ );
132
189
 
133
- let content = '';
134
- let reasoning = '';
135
- const toolCalls = [];
136
- let usage = null;
137
- let finishReason = null;
190
+ try {
191
+ const reader = res.body.getReader();
192
+ const decoder = new TextDecoder();
193
+ let buf = '';
138
194
 
139
- const reader = res.body.getReader();
140
- const decoder = new TextDecoder();
141
- let buf = '';
195
+ while (true) {
196
+ const { done, value } = await reader.read();
197
+ if (done) break;
198
+ buf += decoder.decode(value, { stream: true });
142
199
 
143
- while (true) {
144
- const { done, value } = await reader.read();
145
- if (done) break;
146
- buf += decoder.decode(value, { stream: true });
200
+ let idx;
201
+ while ((idx = buf.indexOf('\n')) !== -1) {
202
+ const line = buf.slice(0, idx).trim();
203
+ buf = buf.slice(idx + 1);
204
+ if (!line.startsWith('data:')) continue;
205
+ const data = line.slice(5).trim();
206
+ if (!data || data === '[DONE]') continue;
147
207
 
148
- let idx;
149
- while ((idx = buf.indexOf('\n')) !== -1) {
150
- const line = buf.slice(0, idx).trim();
151
- buf = buf.slice(idx + 1);
152
- if (!line.startsWith('data:')) continue;
153
- const data = line.slice(5).trim();
154
- if (!data || data === '[DONE]') continue;
208
+ let json;
209
+ try {
210
+ json = JSON.parse(data);
211
+ } catch {
212
+ continue;
213
+ }
155
214
 
156
- let json;
157
- try {
158
- json = JSON.parse(data);
159
- } catch {
160
- continue;
215
+ if (json.usage) state.usage = json.usage;
216
+ const ch = json.choices && json.choices[0];
217
+ if (!ch) continue;
218
+ if (ch.finish_reason) state.finishReason = ch.finish_reason;
219
+ const d = ch.delta || {};
220
+ if (d.content) {
221
+ state.content += d.content;
222
+ onDelta && onDelta(d.content, state.content);
223
+ }
224
+ if (d.reasoning_content) state.reasoning += d.reasoning_content;
225
+ if (d.reasoning) state.reasoning += d.reasoning;
226
+ for (const tc of d.tool_calls || []) {
227
+ const i = typeof tc.index === 'number' ? tc.index : state.toolCalls.length;
228
+ while (state.toolCalls.length <= i) {
229
+ state.toolCalls.push({ id: '', type: 'function', function: { name: '', arguments: '' } });
230
+ }
231
+ if (tc.id) state.toolCalls[i].id = tc.id;
232
+ if (tc.function) {
233
+ if (tc.function.name) state.toolCalls[i].function.name += tc.function.name;
234
+ if (tc.function.arguments) state.toolCalls[i].function.arguments += tc.function.arguments;
235
+ }
236
+ }
237
+ }
161
238
  }
162
239
 
163
- if (json.usage) usage = json.usage;
164
- const ch = json.choices && json.choices[0];
165
- if (!ch) continue;
166
- if (ch.finish_reason) finishReason = ch.finish_reason;
167
- const d = ch.delta || {};
168
- if (d.content) {
169
- content += d.content;
170
- onDelta && onDelta(d.content, content);
171
- }
172
- if (d.reasoning_content) reasoning += d.reasoning_content;
173
- if (d.reasoning) reasoning += d.reasoning;
174
- for (const tc of d.tool_calls || []) {
175
- const i = typeof tc.index === 'number' ? tc.index : toolCalls.length;
176
- while (toolCalls.length <= i) {
177
- toolCalls.push({ id: '', type: 'function', function: { name: '', arguments: '' } });
178
- }
179
- if (tc.id) toolCalls[i].id = tc.id;
180
- if (tc.function) {
181
- if (tc.function.name) toolCalls[i].function.name += tc.function.name;
182
- if (tc.function.arguments) toolCalls[i].function.arguments += tc.function.arguments;
240
+ return { ...state };
241
+ } catch (e) {
242
+ /* okuma sırasında bağlantı koptu */
243
+ const aborted = e && (e.name === 'AbortError' || (signal && signal.aborted));
244
+ if (!aborted && isNetworkError(e)) {
245
+ if (state.content && !state.toolCalls.length) {
246
+ /* kısmi metin geldi, araç çağrısı yok → hata balonu YOK:
247
+ 'length' gibi dön, chatStreamAuto CONTINUE_PROMPT ile kaldığı
248
+ yerden sürdürür ve metni birleştirir */
249
+ return { ...state, finishReason: 'length' };
183
250
  }
251
+ /* hiç veri akmadı YA DA araç çağrısı yarım kaldı → BAŞTAN dene */
252
+ if (dropAttempt >= NET_MAX_RETRIES) throw e;
253
+ dropAttempt++;
254
+ try { onRetry && onRetry(dropAttempt, undefined); } catch {}
255
+ if (_netProbe) await waitForNet(signal);
256
+ await sleep(NET_BACKOFF[Math.min(dropAttempt - 1, NET_BACKOFF.length - 1)], signal);
257
+ continue; /* yeni stream — UI'daki kısmi token'lar nihai mesajla ezilir */
184
258
  }
259
+ throw e;
185
260
  }
186
261
  }
187
-
188
- return { content, reasoning, toolCalls, usage, finishReason };
189
262
  }
190
263
 
191
264
  /* usage toplama: devam turları gerçek faturalamayı yansıtsın (prompt yeniden sayılır) */
@@ -261,4 +334,13 @@ async function chatOnce(sel, body, { signal, onRetry } = {}) {
261
334
  };
262
335
  }
263
336
 
264
- module.exports = { chatStream, chatStreamAuto, chatOnce, withRetries, friendlyError, sumUsage };
337
+ module.exports = {
338
+ chatStream,
339
+ chatStreamAuto,
340
+ chatOnce,
341
+ withRetries,
342
+ friendlyError,
343
+ sumUsage,
344
+ setNetProbe,
345
+ isNetworkError,
346
+ };
@@ -20,6 +20,8 @@ const RELEASE_BASE = 'https://github.com/h4ckf0r0day/obscura/releases/latest/dow
20
20
  const WINDOWS_ZIP = 'obscura-x86_64-windows-stealth.zip';
21
21
  const MAX_ZIP_BYTES = 250 * 1024 * 1024;
22
22
  const FETCH_TIMEOUT_MS = 25000;
23
+ /* content-length gelmezse yüzde tahmini için yaklaşık boyut (stealth zip ≈74 MB) */
24
+ const EST_ZIP_BYTES = 74 * 1024 * 1024;
23
25
 
24
26
  function obscuraDir() {
25
27
  const base = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
@@ -58,19 +60,29 @@ function obscuraOk() {
58
60
  return _okProbe;
59
61
  }
60
62
 
61
- function httpsDownload(url, redirectsLeft = 5, signal) {
63
+ function httpsDownload(url, redirectsLeft = 5, signal, onProgress) {
62
64
  return new Promise((resolve, reject) => {
63
65
  const req = https.get(url, { headers: { 'User-Agent': 'BeastAgent/1.0 (+obscura bootstrap)' } }, (res) => {
64
66
  if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && redirectsLeft > 0) {
65
67
  res.resume();
66
- return resolve(httpsDownload(new URL(res.headers.location, url).toString(), redirectsLeft - 1, signal));
68
+ return resolve(httpsDownload(new URL(res.headers.location, url).toString(), redirectsLeft - 1, signal, onProgress));
67
69
  }
68
70
  if (res.statusCode !== 200) {
69
71
  res.resume();
70
72
  return reject(new Error(`indirme başarısız: HTTP ${res.statusCode}`));
71
73
  }
74
+ const total = Number(res.headers['content-length']) || EST_ZIP_BYTES;
72
75
  const chunks = [];
73
76
  let size = 0;
77
+ let lastPct = -1;
78
+ const tick = () => {
79
+ /* yüzdeyi INDIRME fazının %1-88 aralığına map et; %1'lik adımda bildir */
80
+ const pct = 1 + Math.min(88, Math.floor((size / total) * 88));
81
+ if (typeof onProgress === 'function' && pct !== lastPct) {
82
+ lastPct = pct;
83
+ try { onProgress({ pct, phase: 'indiriliyor', loaded: size, total }); } catch {}
84
+ }
85
+ };
74
86
  res.on('data', (c) => {
75
87
  size += c.length;
76
88
  if (size > MAX_ZIP_BYTES) {
@@ -78,8 +90,12 @@ function httpsDownload(url, redirectsLeft = 5, signal) {
78
90
  return;
79
91
  }
80
92
  chunks.push(c);
93
+ tick();
94
+ });
95
+ res.on('end', () => {
96
+ tick();
97
+ resolve(Buffer.concat(chunks));
81
98
  });
82
- res.on('end', () => resolve(Buffer.concat(chunks)));
83
99
  res.on('error', reject);
84
100
  });
85
101
  req.on('error', reject);
@@ -114,22 +130,37 @@ function psExpand(zipPath, destDir) {
114
130
  });
115
131
  }
116
132
 
117
- /* Obscura'yı kur / güncelle (yeniden indirip üzerine yazar) */
118
- async function installObscura(signal) {
119
- const dest = obscuraDir();
120
- fs.mkdirSync(dest, { recursive: true });
121
- const zipPath = path.join(os.tmpdir(), 'beast-obscura.zip');
122
- const buf = await httpsDownload(RELEASE_BASE + WINDOWS_ZIP, 5, signal);
123
- fs.writeFileSync(zipPath, buf);
124
- const ok = await psExpand(zipPath, dest);
125
- try { fs.unlinkSync(zipPath); } catch {}
126
- if (!ok || !obscuraInstalled()) {
127
- return { ok: false, error: 'obscura kurulumu başarısız (zip açılamadı)' };
133
+ /* Obscura'yı kur / güncelle (yeniden indirip üzerine yazar).
134
+ onProgress({pct, phase}) — 1-88 indirme, 89-96 açma, 97-99 doğrulama. */
135
+ async function installObscura(signal, onProgress) {
136
+ const tick = (pct, phase) => {
137
+ if (typeof onProgress === 'function') {
138
+ try { onProgress({ pct, phase: phase || 'indiriliyor' }); } catch {}
139
+ }
140
+ };
141
+ try {
142
+ tick(0, 'hazırlanıyor');
143
+ const dest = obscuraDir();
144
+ fs.mkdirSync(dest, { recursive: true });
145
+ const zipPath = path.join(os.tmpdir(), 'beast-obscura.zip');
146
+ const buf = await httpsDownload(RELEASE_BASE + WINDOWS_ZIP, 5, signal, onProgress);
147
+ tick(89, 'kuruluyor');
148
+ fs.writeFileSync(zipPath, buf);
149
+ tick(90, 'kuruluyor');
150
+ const ok = await psExpand(zipPath, dest);
151
+ try { fs.unlinkSync(zipPath); } catch {}
152
+ tick(97, 'doğrulanıyor');
153
+ if (!ok || !obscuraInstalled()) {
154
+ return { ok: false, error: 'obscura kurulumu başarısız (zip açılamadı)' };
155
+ }
156
+ markVariant('stealth');
157
+ _okProbe = null; /* yeniden doğrula */
158
+ const good = await obscuraOk();
159
+ tick(good ? 100 : 98, good ? 'tamamlandı' : 'doğrulanıyor');
160
+ return { ok: good, dir: dest, error: good ? null : 'obscura.exe doğrulanamadı' };
161
+ } catch (e) {
162
+ return { ok: false, error: String((e && e.message) || e) };
128
163
  }
129
- markVariant('stealth');
130
- _okProbe = null; /* yeniden doğrula */
131
- const good = await obscuraOk();
132
- return { ok: good, dir: dest, error: good ? null : 'obscura.exe doğrulanamadı' };
133
164
  }
134
165
 
135
166
  /* obscura fetch — sayfa HTML'i (abort ile süreç kesilir) */