File size: 49,048 Bytes
1e214ed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
"""
EIM Chat  -  a multimodal chat app on top of the EIM engine
============================================================

    python chat_app.py              start the app
    python chat_app.py --selftest   check the core without a model, GPU or Gradio

What it does
  * Chat with streaming answers, in the language of the user's message (Arabic or English).
  * Attach files in the same message: code, text, PDF, Word, Excel/CSV, notebooks, zip, images.
    Big files are not blindly truncated: a BM25 retriever picks the parts relevant to each question.
  * Images: shown in the chat; understood through an optional vision model (EIM_VISION_MODEL); without one the app
    says so honestly instead of inventing what is in the picture. OCR is used when pytesseract is installed.
  * Verified code: whenever the message contains tests (assert lines, a fenced test block, or an attached
    test_*.py), the EIM+ loop runs instead of plain chat: it can start from YOUR code, repairs it in the sandbox,
    checks the result on hidden tests, flags hard-coded answers, and gives you solution.py to download.
  * Mode switch: Auto / Chat / EIM.

Environment
  EIM_MODEL         code model used by the EIM loop (default from app.py)
  EIM_CHAT_MODEL    model for conversation (default: the same model; a bigger instruct model chats much better,
                    for example Qwen/Qwen2.5-7B-Instruct)
  EIM_VISION_MODEL  optional vision-language model, for example Qwen/Qwen2-VL-2B-Instruct (not loaded unless set)
  EIM_CONTEXT_TOKENS  prompt budget for chat history + file excerpts (default 6000, or 4000 on weak machines)
  EIM_PROFILE       lean | balanced | full  - force the hardware profile (default: detected from CPU / RAM / GPU)
  EIM_TIME_BUDGET   seconds after which the EIM loop starts no new round and returns its best version (default: off)
  EIM_GPU_SECONDS   ZeroGPU only: the `duration` of the @spaces.GPU call in app.py (default 60). The EIM loop stops
                    starting new rounds at 65% of it and returns its best version instead of being cut off
  EIM_MEMORY_REPO   optional private HF dataset ("user/name", needs HF_TOKEN) that keeps the learned memory across restarts
  EIM_REPAIR_LOG    0 disables the verified-repair log (eim_repairs.jsonl next to the memory file; EIM_REPAIR_LOG_PATH moves it)
  EIM_RESULT_CACHE  0 disables replaying identical, fully successful EIM runs from memory

Security: uploaded files are only READ as text. Code is executed only by the EIM sandbox (see app.py for its limits;
for public traffic run everything in a container without network access).
"""
from __future__ import annotations

import ast
import os
import re
import sys
import tempfile
import threading
import time
from dataclasses import dataclass, field
from typing import Iterator, Protocol, Sequence

from app import CFG, EUTV, Config, ExperienceMemory, Step, gpu, get_lm
from eim_plus import (Attachment, EIMPlus, Report, SmartMemory, hardware_profile, is_test_file, read_attachment,
                      select_context, tests_from_source)
from correction_memory import CorrectionMemory

def _accepts(function, name: str) -> bool:
    try:
        import inspect
        return name in inspect.signature(function).parameters
    except (TypeError, ValueError):
        return False


_GEN_LOCK = threading.Lock()          # one generation at a time on the shared model


# ============================================================================
# 1. Language helpers
# ============================================================================
STR = {
    "reading_image": ("🔍 Reading the image…", "🔍 جارٍ قراءة الصورة…"),
    "thinking": ("⏳ Thinking…", "⏳ جارٍ التفكير…"),
    "eim_start": ("⏳ Generating the first version and verifying it in the sandbox…",
                  "⏳ جارٍ توليد النسخة الأولى والتحقق منها في البيئة المعزولة…"),
    "eim_needs_tests": ("EIM mode needs tests. Add lines such as `assert f(2) == 4`, or attach a `test_*.py` file.",
                        "وضع EIM يحتاج اختبارات. أضف أسطراً مثل `assert f(2) == 4` أو أرفق ملف `test_*.py`."),
    "default_q": ("Summarise the attached files and tell me what is in them.",
                  "لخّص الملفات المرفقة وأخبرني بما فيها."),
    "ctx_head": ("Content of the files the user attached (use it; say so if the answer is not in it):",
                 "محتوى الملفات التي أرفقها المستخدم (استخدمه، وصرّح إن لم تكن الإجابة فيه):"),
    "ctx_trunc": ("(Large files: only the parts most relevant to the question are shown.)",
                  "(الملفات كبيرة: عُرضت فقط الأجزاء الأقرب لسؤال المستخدم.)"),
    "no_vision": ("An image named {name} ({w}x{h}) was attached, but no vision model is configured, so its content is unknown. "
                  "Do not guess what it shows.",
                  "أُرفقت صورة باسم {name} ({w}x{h}) لكن لا يوجد نموذج رؤية مُفعّل، فمحتواها مجهول. لا تخمّن ما فيها."),
    "ocr": ("Text found in the image by OCR:", "نص مستخرج من الصورة بالـ OCR:"),
    "error": ("⚠️ Something went wrong: {err}", "⚠️ حدث خطأ: {err}"),
    "ok": ("✅ All visible tests pass", "✅ نجحت كل الاختبارات الظاهرة"),
    "partial": ("⚠️ Best version found: {p}/{t} visible tests", "⚠️ أفضل نسخة وُجدت: {p}/{t} من الاختبارات الظاهرة"),
    "reward": ("Reward", "المكافأة"),
    "visible": ("Visible tests", "الاختبارات الظاهرة"),
    "hidden": ("Hidden tests", "الاختبارات المخفية"),
    "iters": ("Iterations", "التكرارات"),
    "restarts": ("Restarts", "إعادات البدء"),
    "final_code": ("Final code", "الكود النهائي"),
    "file_note": ("Files you can download: solution.py", "ملف للتنزيل: solution.py"),
    "attached": ("[attached: {names}]", "[مرفق: {names}]"),
    "skipped": ("{n} test(s) using pytest fixtures/classes were skipped.", "تم تخطي {n} اختبار يعتمد على fixtures/classes الخاصة بـ pytest."),
}


def detect_lang(text: str) -> str:
    return "ar" if re.search(r"[\u0600-\u06FF]", text or "") else "en"


def tr(key: str, lang: str, **kwargs) -> str:
    en, ar = STR[key]
    return (ar if lang == "ar" else en).format(**kwargs)


SYS_CHAT = (
    "You are EIM Assistant, a careful, capable assistant for programming and for working with documents. "
    "Always answer in the language of the user's latest message. "
    "When file excerpts are provided, ground your answer in them and mention the file name; if the answer is not in them, say so. "
    "Never invent what an image shows: rely only on the image description you are given. "
    "For code, give complete, runnable code in fenced blocks and explain briefly. "
    "Write general solutions that are correct for every valid input, not only for the examples shown: never embed "
    "expected values taken from tests, never special-case test inputs, and never build lookup tables of answers. "
    "Think about edge cases first (empty input, negatives, duplicates, boundaries). "
    "Keep the function names and signatures that the user's code or tests use, and when fixing code change only what "
    "is needed. Prefer the standard library and make no network calls at import time. "
    "Treat explicit user corrections and rejected approaches as constraints: do not repeat an approach the user said "
    "was wrong; use the correction in the next answer, and briefly state what changed. Before claiming code works, "
    "separate tests actually run from reasoning or unverified assumptions. "
    "If the user wants code you can be sure about, suggest they add `assert` tests: the app then runs the code against "
    "them in a sandbox and tries to repair it."
)


# ============================================================================
# 2. Model backends
# ============================================================================
class ChatBackend(Protocol):
    def stream(self, messages: list[dict], max_new_tokens: int, temperature: float) -> Iterator[str]: ...


class VisionBackend(Protocol):
    def describe(self, image_path: str, question: str) -> str: ...


class LockedCodeLM:
    """The EIM loop's model: lazy-loaded, and serialised with chat generation."""

    def generate(self, prompts: Sequence[str], temperature: float, max_new_tokens: int) -> list[str]:
        with _GEN_LOCK:
            return get_lm().generate(prompts, temperature, max_new_tokens)


def _remote_chat_prompt(messages: list[dict], max_chars: int, protect: int = 0) -> str:
    """Serialise role-tagged chat for APIs that currently accept a single user prompt."""
    cleaned = []
    for message in messages:
        role = str(message.get("role", "user")).upper()[:20]
        content = str(message.get("content", ""))
        cleaned.append((role, content))
    if not cleaned:
        return "You are a helpful assistant."
    # Keep the system instruction and newest question; discard oldest history first.
    system = cleaned[0] if cleaned[0][0] == "SYSTEM" else ("SYSTEM", "You are a helpful assistant.")
    remaining = max(500, max_chars - len(system[1]) - 32)
    kept = []
    for role, content in reversed(cleaned[1:] if cleaned[0][0] == "SYSTEM" else cleaned):
        if remaining <= 0:
            break
        if len(content) > remaining:
            if role == "USER" and protect > 0:
                tail = min(protect, len(content), remaining // 2)
                head = max(0, remaining - tail - 20)
                content = content[:head] + "\n[… earlier context trimmed …]\n" + content[-tail:] if tail else content[:remaining]
            else:
                content = content[-remaining:]
        kept.append((role, content))
        remaining -= len(content) + len(role) + 8
    kept.reverse()
    rows = [f"SYSTEM:\n{system[1]}"]
    rows.extend(f"{role}:\n{content}" for role, content in kept)
    rows.append("ASSISTANT:")
    return "\n\n".join(rows)[-max_chars - 64:]


class HFChat:
    """Streaming chat on a Hugging Face causal LM. Reuses the EIM model when no separate chat model is configured."""

    def __init__(self, name: str, max_context_tokens: int = 6000):
        self.name, self.max_context_tokens = name, max_context_tokens
        self._model = self._tok = None
        self._remote_client = None

    def _load(self) -> None:
        if self._model is not None or self._tok is not None or self._remote_client is not None:
            return
        if self.name == CFG.model_name:
            shared = get_lm()
        else:
            from app import CodeLM
            shared = CodeLM(self.name)
        if getattr(shared, "is_remote", False):
            self._remote_client = shared
            return
        self._model, self._tok = shared.model, shared.tokenizer

    def _length(self, messages: list[dict]) -> int:
        text = self._tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
        return len(self._tok(text, add_special_tokens=False).input_ids)

    def _fit(self, messages: list[dict], protect: int = 0, budget: int | None = None) -> list[dict]:
        """Trim the prompt to `budget` tokens (default: max_context_tokens).
        protect = number of characters at the END of the newest message that must never be cut: that is the user's own
        question (file excerpts come before it). Excerpts are shrunk first; only if the question alone is still too long
        is it shortened, and then from the middle, so its beginning and its end both survive."""
        limit = self.max_context_tokens if budget is None else budget
        msgs = list(messages)
        if len(msgs) > 2 and self._length(msgs) > limit:
            # Drop the oldest turns (keep system + newest). Same result as dropping one turn at a time, but found by
            # binary search: ~log2(n) tokenisations of the whole prompt instead of up to n - on a weak CPU that is
            # the difference between an instant and a multi-second wait before the first token.
            lo, hi = 1, len(msgs) - 2                      # d = number of oldest turns dropped after the system turn
            while lo < hi:
                mid = (lo + hi) // 2
                if self._length(msgs[:1] + msgs[1 + mid:]) <= limit:
                    hi = mid
                else:
                    lo = mid + 1
            msgs = msgs[:1] + msgs[1 + lo:]
        for _ in range(8):                                 # still too long: shrink the newest message
            if self._length(msgs) <= limit:
                break
            content = msgs[-1]["content"]
            keep = min(max(0, protect), len(content))
            body, tail = content[: len(content) - keep], content[len(content) - keep:]
            if protect and body:
                content = body[: len(body) // 2] + tail    # cut the excerpts, keep the question whole
            elif protect:
                quarter = max(1, len(tail) // 4)           # nothing left to cut but the question itself
                content = tail[:quarter] + " ... " + tail[-quarter:]
            else:
                content = content[: len(content) // 2]
            msgs[-1] = {**msgs[-1], "content": content}
        return msgs

    def stream(self, messages: list[dict], max_new_tokens: int = 900, temperature: float = 0.4,
               protect: int = 0) -> Iterator[str]:
        self._load()
        if self._remote_client is not None:
            limit = max(2000, min(24000, int(os.environ.get("EIM_REMOTE_CONTEXT_CHARS", "14000"))))
            prompt = _remote_chat_prompt(messages, limit, protect)
            answer = self._remote_client.generate([prompt], temperature, max_new_tokens)[0]
            # Inference Providers return a completed response; chunk it for the same Gradio interface contract.
            for start in range(0, len(answer), 80):
                yield answer[start:start + 80]
            if not answer:
                yield ""
            return
        import torch
        from transformers import StoppingCriteria, StoppingCriteriaList, TextIteratorStreamer

        tok, model = self._tok, self._model
        window = getattr(getattr(model, "config", None), "max_position_embeddings", None)
        budget = self.max_context_tokens
        if isinstance(window, int) and window > 0:         # prompt + answer must fit the model's own window
            budget = max(256, min(budget, window - int(max_new_tokens) - 64))
        text = tok.apply_chat_template(self._fit(messages, protect, budget), tokenize=False, add_generation_prompt=True)
        inputs = tok(text, return_tensors="pt", add_special_tokens=False).to(model.device)

        class _Stop(StoppingCriteria):
            flag = False

            def __call__(self, input_ids, scores, **kwargs):
                return torch.full((input_ids.shape[0],), self.flag, dtype=torch.bool, device=input_ids.device)

        stop = _Stop()
        streamer = TextIteratorStreamer(tok, skip_prompt=True, skip_special_tokens=True, timeout=600)
        options = dict(**inputs, streamer=streamer, max_new_tokens=int(max_new_tokens),
                       pad_token_id=tok.pad_token_id if tok.pad_token_id is not None else tok.eos_token_id,
                       stopping_criteria=StoppingCriteriaList([stop]))
        if temperature > 0:
            options.update(do_sample=True, temperature=float(temperature), top_p=0.95)
        else:
            options.update(do_sample=False)
        errors: list[BaseException] = []

        def work() -> None:
            try:
                with torch.inference_mode():
                    model.generate(**options)
            except BaseException as exc:                   # surface it instead of hanging the stream
                errors.append(exc)
                streamer.end()

        with _GEN_LOCK:
            thread = threading.Thread(target=work, daemon=True)
            thread.start()
            try:
                for piece in streamer:
                    yield piece
            finally:                                       # also runs when the user presses Stop
                # The flag stays True: this _Stop class belongs to this call only, and clearing it after a join timeout
                # would let a still-running generation continue in the background and overlap the next one.
                _Stop.flag = True
                thread.join(timeout=30)
        if errors:
            raise errors[0]


class HFVision:
    """Optional vision-language model (Qwen2-VL style). NOT loaded unless EIM_VISION_MODEL is set."""

    def __init__(self, name: str):
        self.name = name
        self._model = self._processor = None

    def _load(self) -> None:
        if self._model is not None:
            return
        import torch
        from transformers import AutoProcessor
        try:
            from transformers import AutoModelForImageTextToText as AutoVision
        except ImportError:
            from transformers import AutoModelForVision2Seq as AutoVision
        self._processor = AutoProcessor.from_pretrained(self.name)
        use_cuda = torch.cuda.is_available() or bool(os.environ.get("SPACES_ZERO_GPU"))
        self._model = AutoVision.from_pretrained(self.name, torch_dtype=torch.bfloat16 if use_cuda else torch.float32)
        if use_cuda:
            self._model.to("cuda")
        self._model.eval()

    def describe(self, image_path: str, question: str = "") -> str:
        import torch
        from PIL import Image

        self._load()
        image = Image.open(image_path).convert("RGB")
        image.thumbnail((1024, 1024))
        prompt = (f"The user asks: {question}\nAnswer using the image, and also transcribe any visible text."
                  if question.strip() else
                  "Describe this image in detail. Transcribe any visible text exactly. Mention charts, tables and code if present.")
        messages = [{"role": "user", "content": [{"type": "image"}, {"type": "text", "text": prompt}]}]
        text = self._processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
        inputs = self._processor(text=[text], images=[image], return_tensors="pt").to(self._model.device)
        with _GEN_LOCK, torch.inference_mode():
            output = self._model.generate(**inputs, max_new_tokens=500, do_sample=False)
        return self._processor.batch_decode(output[:, inputs["input_ids"].shape[1]:], skip_special_tokens=True)[0].strip()


def ocr_text(path: str) -> str:
    try:
        import pytesseract
        from PIL import Image
        with Image.open(path) as image:
            return pytesseract.image_to_string(image, lang="ara+eng").strip()
    except Exception:
        return ""


# ============================================================================
# 3. Session, options, intent
# ============================================================================
@dataclass
class Session:
    history: list[dict] = field(default_factory=list)          # what the chat model sees (plain text turns)
    files: dict[str, Attachment] = field(default_factory=dict)
    working_code: str = ""                                      # latest uploaded .py or EIM result
    workdir: str = ""
    result_path: str | None = None
    runs: int = 0


@dataclass
class Options:
    mode: str = "auto"                 # auto | chat | eim
    iterations: int = 4
    candidates: int = 3
    holdout: float = 0.25
    temperature: float = 0.4
    max_new_tokens: int = 900
    context_chars: int = 6000


@dataclass
class Intent:
    kind: str                          # chat | eim | need_tests
    task: str = ""
    tests: list[str] = field(default_factory=list)
    initial_code: str = ""
    skipped: int = 0


_FENCE = re.compile(r"```[ \t]*([\w+-]*)[ \t]*\n(.*?)```", re.S)


def split_message(text: str) -> tuple[str, list[str]]:
    """(prose without code blocks, python-ish fenced blocks)"""
    blocks = [m.group(2) for m in _FENCE.finditer(text) if m.group(1).lower() in ("", "python", "py")]
    return _FENCE.sub("", text).strip(), blocks


def _looks_like_code(block: str) -> bool:
    return bool(re.search(r"^\s*(def |class |import |from \S+ import )", block, re.M))


def _defined_names(code: str) -> set[str]:
    try:
        return {n.name for n in ast.parse(code).body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))}
    except (SyntaxError, ValueError):
        return set()


def _used_names(tests: Sequence[str]) -> set[str]:
    used: set[str] = set()
    for test in tests:
        try:
            used |= {n.id for n in ast.walk(ast.parse(test)) if isinstance(n, ast.Name)}
        except (SyntaxError, ValueError):
            pass
    return used


def detect_intent(text: str, new_atts: Sequence[Attachment], session: Session, mode: str) -> Intent:
    prose, blocks = split_message(text)
    tests: list[str] = []
    skipped = 0
    initial = ""
    for block in blocks:
        found, skip = tests_from_source(block)
        skipped += skip
        if found:
            tests += found
        elif _looks_like_code(block) and not initial:
            initial = block
    prose_lines = prose.splitlines()
    tests += [line.strip() for line in prose_lines if line.strip().startswith("assert ")]
    task = "\n".join(line for line in prose_lines if not line.strip().startswith("assert ")).strip()

    spec_parts = []
    for att in new_atts:
        if att.kind == "code" and att.name.lower().endswith(".py"):
            found, skip = tests_from_source(att.text)
            skipped += skip
            if is_test_file(att.name, att.text, found):
                tests += found
            elif not initial:
                initial = att.text
        elif att.kind in ("text", "document") and att.text:
            spec_parts.append(f"Specification from {att.name}:\n{att.text[:3000]}")

    if mode == "chat":
        return Intent("chat")
    if not tests:
        return Intent("need_tests") if mode == "eim" else Intent("chat")

    if not initial and session.working_code and _defined_names(session.working_code) & _used_names(tests):
        initial = session.working_code                      # the tests talk about code we already have
    if not task:
        task = "Fix the code so that all tests pass." if initial else "Write code that passes all the tests."
    if spec_parts:
        task += "\n\n" + "\n\n".join(spec_parts)
    return Intent("eim", task, tests, initial, skipped)


# ============================================================================
# 4. Assistant: the whole behaviour, independent of Gradio
# ============================================================================
def render_progress(steps: Sequence[Step], lang: str, running: bool = True) -> str:
    rows = ["| # | Status | Strategy | Tests | Reward |", "|---|---|---|---|---|"]
    for s in steps:
        rows.append(f"| {s.iteration} | {s.label} | {s.strategy} | {s.result.passed}/{s.result.total} | {s.score.reward:.3f} |")
    accepted = [s for s in steps if s.accepted]
    code = f"\n\n```python\n{accepted[-1].code}\n```" if accepted else ""
    return ("\n".join(rows) + code + (f"\n\n{tr('thinking', lang)}" if running else ""))


def render_final(report: Report, steps: Sequence[Step], lang: str, skipped: int) -> str:
    best = report.best
    ok = best.result.pass_rate == 1.0
    head = tr("ok", lang) if ok else tr("partial", lang, p=best.result.passed, t=best.result.total)
    facts = [
        f"**{tr('reward', lang)}:** {report.first.score.reward:.3f} → {best.score.reward:.3f}",
        f"**{tr('visible', lang)}:** {best.result.passed}/{best.result.total}",
    ]
    if report.hidden is not None:
        facts.append(f"**{tr('hidden', lang)}:** {report.hidden.passed}/{report.hidden.total}")
    facts.append(f"**{tr('iters', lang)}:** {report.iterations} · **{tr('restarts', lang)}:** {report.restarts}")
    notes = [f"- ⚠️ {w}" for w in report.warnings]
    if skipped:
        notes.append(f"- ℹ️ {tr('skipped', lang, n=skipped)}")
    parts = [f"### {head}", " · ".join(facts)]
    if notes:
        parts.append("\n".join(notes))
    parts += [f"#### {tr('final_code', lang)}", f"```python\n{best.code}\n```", f"_{tr('file_note', lang)}_",
              "<details><summary>log</summary>\n\n" + render_progress(steps, lang, running=False) + "\n\n</details>"]
    return "\n\n".join(parts)


class Assistant:
    def __init__(self, chat: ChatBackend, vision: VisionBackend | None = None, code_lm=None,
                 verifier: EUTV | None = None, memory: ExperienceMemory | None = None, cfg: Config = CFG):
        self.chat, self.vision, self.cfg = chat, vision, cfg
        self.code_lm = code_lm or LockedCodeLM()
        self.verifier = verifier or EUTV(cfg)
        self.memory = memory if memory is not None else SmartMemory(path=cfg.memory_path)
        self.corrections = CorrectionMemory()

    # -------------------------------------------------------------- public
    def handle(self, session: Session, text: str, paths: Sequence[str], opts: Options) -> Iterator[str]:
        """Yields the FULL assistant message so far, each time it grows."""
        text = (text or "").strip()
        lang = detect_lang(text) if text else "en"
        new_atts = self._ingest(session, paths)
        if not text and not new_atts:
            return
        try:
            for att in new_atts:
                if att.kind == "image":
                    yield tr("reading_image", lang)
                    att.text = self._describe(att, text, lang)
            if not text:
                text = tr("default_q", lang)
            if not paths and not text:
                return
            intent = detect_intent(text, new_atts, session, opts.mode)
            if intent.kind == "need_tests":
                yield tr("eim_needs_tests", lang)
            elif intent.kind == "eim":
                yield from self._run_eim(session, text, intent, opts, lang, new_atts)
            else:
                yield from self._run_chat(session, text, opts, lang, new_atts)
        except Exception as exc:
            yield tr("error", lang, err=f"{type(exc).__name__}: {exc}")

    # -------------------------------------------------------------- internals
    def _ingest(self, session: Session, paths: Sequence[str]) -> list[Attachment]:
        out = []
        for path in paths:
            att = read_attachment(path)
            base, ext, n = att.name, os.path.splitext(att.name)[1], 1
            while att.name in session.files:                  # same name uploaded twice: keep both
                n += 1
                att.name = f"{os.path.splitext(base)[0]} ({n}){ext}"
            session.files[att.name] = att
            if att.kind == "code" and att.name.lower().endswith(".py"):
                found, _ = tests_from_source(att.text)
                if not is_test_file(att.name, att.text, found):
                    session.working_code = att.text
            out.append(att)
        return out

    def _describe(self, att: Attachment, question: str, lang: str) -> str:
        width, height = att.image_size or (0, 0)
        if self.vision is not None:
            try:
                return f"[image {att.name}] " + self.vision.describe(att.path, question)
            except Exception as exc:
                return f"[image {att.name}: the vision model failed ({type(exc).__name__}: {exc})]"
        text = tr("no_vision", lang, name=att.name, w=width, h=height)
        ocr = ocr_text(att.path)
        return text + (f"\n{tr('ocr', lang)}\n{ocr}" if ocr else "")

    def _run_chat(self, session: Session, text: str, opts: Options, lang: str, new_atts) -> Iterator[str]:
        # Persist explicit rejections/corrections before building this turn's prompt.
        self.corrections.add(text)
        context, truncated = select_context(list(session.files.values()), text, opts.context_chars)
        parts = []
        if context:
            parts.append(f"{tr('ctx_head', lang)}\n{context}")
            if truncated:
                parts.append(tr("ctx_trunc", lang))
        parts.append(text)
        correction_context = self.corrections.prompt(text)
        system_text = SYS_CHAT + ("\n\n" + correction_context if correction_context else "")
        messages = [{"role": "system", "content": system_text}] + session.history[-40:] + \
                   [{"role": "user", "content": "\n\n".join(parts)}]
        yield tr("thinking", lang)
        answer = ""
        last = 0.0
        extra = {"protect": len(text)} if _accepts(self.chat.stream, "protect") else {}   # custom chat models may not
        for piece in self.chat.stream(messages, opts.max_new_tokens, opts.temperature, **extra):
            answer += piece
            now = time.monotonic()
            if now - last > 0.05:                              # throttle UI updates
                last = now
                yield answer
        yield answer
        names = ", ".join(a.name for a in new_atts)
        shown = text + (("\n" + tr("attached", lang, names=names)) if names else "")
        session.history += [{"role": "user", "content": shown}, {"role": "assistant", "content": answer}]

    def _run_eim(self, session: Session, text: str, intent: Intent, opts: Options, lang: str, new_atts) -> Iterator[str]:
        yield tr("eim_start", lang)
        self.corrections.add(text)
        correction_context = self.corrections.prompt(text)
        task = intent.task + ("\n\n" + correction_context if correction_context else "")
        engine = EIMPlus(self.code_lm, self.verifier, self.cfg, self.memory)
        steps: list[Step] = []
        report: Report | None = None
        for event in engine.run(task, intent.tests, intent.initial_code or None,
                                int(opts.iterations), int(opts.candidates), float(opts.holdout)):
            if isinstance(event, Report):
                report = event
                break
            steps.append(event)
            yield render_progress(steps, lang, running=True)
        assert report is not None
        final = render_final(report, steps, lang, intent.skipped)

        session.runs += 1
        if not session.workdir:
            session.workdir = tempfile.mkdtemp(prefix="eimchat_")
        path = os.path.join(session.workdir, f"solution_{session.runs}.py")
        with open(path, "w", encoding="utf-8") as handle:
            handle.write(report.best.code + "\n")
        session.result_path = path
        session.working_code = report.best.code
        yield final

        names = ", ".join(a.name for a in new_atts)
        shown = text + (("\n" + tr("attached", lang, names=names)) if names else "")
        summary = f"{tr('final_code', lang)} ({report.best.result.passed}/{report.best.result.total}):\n```python\n{report.best.code}\n```"
        session.history += [{"role": "user", "content": shown}, {"role": "assistant", "content": summary}]


# ============================================================================
# 5. Gradio UI
# ============================================================================
CSS = """
#chat .message, #chat .prose, #chat .message-content { unicode-bidi: plaintext; text-align: start; }
#chat pre, #chat code { direction: ltr; unicode-bidi: isolate; text-align: left; }
footer { display: none !important; }
"""

ACCEPTED_FILES = [".py", ".txt", ".md", ".json", ".csv", ".tsv", ".pdf", ".docx", ".xlsx", ".ipynb", ".zip",
                  ".js", ".ts", ".java", ".c", ".cpp", ".go", ".rs", ".html", ".css", ".sql", ".yaml", ".yml",
                  ".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"]

_ASSISTANT: Assistant | None = None
_ASSISTANT_LOCK = threading.Lock()


def get_assistant() -> Assistant:
    global _ASSISTANT
    with _ASSISTANT_LOCK:
        if _ASSISTANT is None:
            # prompt prefill dominates latency on a CPU: weak machines get a smaller default window (env still wins)
            default_context = "4000" if hardware_profile().name == "lean" else "6000"
            chat = HFChat(os.environ.get("EIM_CHAT_MODEL", CFG.model_name),
                          int(os.environ.get("EIM_CONTEXT_TOKENS", default_context)))
            vision_name = os.environ.get("EIM_VISION_MODEL", "")
            _ASSISTANT = Assistant(chat, HFVision(vision_name) if vision_name else None)
    return _ASSISTANT


def _paths(files) -> list[str]:
    out = []
    for item in files or []:
        path = item.get("path") if isinstance(item, dict) else getattr(item, "path", item)
        if isinstance(path, str) and path:
            out.append(path)
    return out


def _files_markdown(session: Session | None) -> str:
    if not session or not session.files:
        return "_no files yet / لا توجد ملفات بعد_"
    lines = []
    for att in session.files.values():
        extra = f" — {att.note}" if att.note else ""
        size = f"{att.size / 1024:.0f} KB"
        lines.append(f"- 📎 **{att.name}** · {att.kind} · {size}{extra}")
    return "\n".join(lines)


def _chatbot(gr):
    kwargs = dict(elem_id="chat", height=560, show_copy_button=True, render_markdown=True,
                  latex_delimiters=[{"left": "$$", "right": "$$", "display": True}])
    for attempt in (dict(type="messages", **kwargs), kwargs, dict(elem_id="chat", height=560)):
        try:
            return gr.Chatbot(**attempt)
        except TypeError:
            continue
    return gr.Chatbot()


@gpu
def respond(message, history, session, mode, iterations, candidates, holdout, temperature, max_tokens):
    import gradio as gr

    session = session or Session()
    history = list(history or [])
    text = ((message or {}).get("text") or "").strip()
    paths = _paths((message or {}).get("files"))
    if not text and not paths:
        yield history, session, gr.MultimodalTextbox(value=None, interactive=True), _files_markdown(session), session.result_path
        return

    for path in paths:                                           # show the files in the chat bubble
        history.append({"role": "user", "content": {"path": path}})
    if text:
        history.append({"role": "user", "content": text})
    history.append({"role": "assistant", "content": ""})
    box = gr.MultimodalTextbox(value=None, interactive=False)
    opts = Options(mode=str(mode).lower(), iterations=int(iterations), candidates=int(candidates),
                   holdout=float(holdout), temperature=float(temperature), max_new_tokens=int(max_tokens))
    yield history, session, box, _files_markdown(session), session.result_path
    for snapshot in get_assistant().handle(session, text, paths, opts):
        history[-1] = {"role": "assistant", "content": snapshot}
        yield history, session, box, _files_markdown(session), session.result_path
    yield history, session, box, _files_markdown(session), session.result_path


def build_ui():
    import gradio as gr

    # Gradio 6 moved theme/css from Blocks(...) to launch(...). Keep both API generations supported.
    try:
        major = int(str(getattr(gr, "__version__", "5")).split(".", 1)[0])
    except (TypeError, ValueError):
        major = 5
    if major >= 6:
        blocks = gr.Blocks(title="EIM Chat")
        launch_kwargs = {"theme": gr.themes.Soft(), "css": CSS}
    else:
        blocks = gr.Blocks(title="EIM Chat", theme=gr.themes.Soft(), css=CSS)
        launch_kwargs = {}
    setattr(blocks, "_eim_launch_kwargs", launch_kwargs)
    with blocks as demo:
        gr.Markdown("## EIM Chat\nمحادثة + ملفات + صور + كود يُتحقَّق منه ويُصلَح تلقائياً · "
                    "Chat + files + images + code that is verified and repaired automatically")
        session = gr.State(None)
        with gr.Row():
            with gr.Column(scale=4):
                chat = _chatbot(gr)
                box = gr.MultimodalTextbox(
                    file_count="multiple", file_types=ACCEPTED_FILES, show_label=False, interactive=True,
                    placeholder="اكتب رسالتك أو أرفق ملفات وصوراً… · Type, or attach files and images… "
                                "(add `assert f(2) == 4` lines to verify code)")
            with gr.Column(scale=1, min_width=260):
                mode = gr.Radio(["Auto", "Chat", "EIM"], value="Auto", label="Mode")
                with gr.Accordion("EIM", open=False):
                    iterations = gr.Slider(1, 8, value=4, step=1, label="Max iterations")
                    candidates = gr.Slider(1, 6, value=3, step=1, label="Candidates per iteration")
                    holdout = gr.Slider(0, 0.5, value=0.25, step=0.05, label="Hidden tests share (overfitting check)")
                with gr.Accordion("Chat", open=False):
                    temperature = gr.Slider(0, 1.2, value=0.4, step=0.05, label="Temperature")
                    max_tokens = gr.Slider(128, 2048, value=900, step=64, label="Max new tokens")
                files_view = gr.Markdown(_files_markdown(None))
                result = gr.File(label="solution.py", interactive=False)
                clear = gr.Button("New chat / محادثة جديدة")
        inputs = [box, chat, session, mode, iterations, candidates, holdout, temperature, max_tokens]
        outputs = [chat, session, box, files_view, result]
        box.submit(respond, inputs, outputs).then(lambda: gr.MultimodalTextbox(interactive=True), None, [box])
        clear.click(lambda: ([], None, gr.MultimodalTextbox(value=None, interactive=True), _files_markdown(None), None),
                    None, outputs)
    return demo


# ============================================================================
# 6. Self-test (no model, no GPU, no Gradio): python chat_app.py --selftest
# ============================================================================
class _FakeChat:
    def __init__(self, answer: str = "Fake answer."):
        self.answer, self.seen = answer, []

    def stream(self, messages, max_new_tokens=900, temperature=0.4):
        self.seen.append(messages)
        for i in range(0, len(self.answer), 7):
            yield self.answer[i:i + 7]


class _FakeVision:
    def describe(self, image_path, question=""):
        return "A red square on a white background with the text HELLO."


def run_selftest() -> int:
    import shutil
    from app import _ScriptedLM

    checks = 0

    def check(name: str, condition: bool) -> None:
        nonlocal checks
        checks += 1
        print(("PASS " if condition else "FAIL ") + name)
        if not condition:
            raise SystemExit(1)

    tmp = tempfile.mkdtemp(prefix="eimchat_test_")
    fast = Config(per_test_seconds=1, wall_seconds=8.0, cpu_seconds=6, memory_path="")

    def write(name: str, text: str) -> str:
        path = os.path.join(tmp, name)
        with open(path, "w", encoding="utf-8") as handle:
            handle.write(text)
        return path

    try:
        # ---- intent detection
        s = Session()
        msg = "Fix this:\n```python\ndef add(a, b):\n    return a - b\n```\nassert add(1, 2) == 3\nassert add(2, 2) == 4"
        intent = detect_intent(msg, [], s, "auto")
        check("fenced code + assert lines -> EIM from the user's code",
              intent.kind == "eim" and len(intent.tests) == 2 and "return a - b" in intent.initial_code)
        check("plain question -> chat", detect_intent("what is a decorator?", [], s, "auto").kind == "chat")
        check("mode=chat wins over tests", detect_intent(msg, [], s, "chat").kind == "chat")
        check("mode=eim without tests asks for tests", detect_intent("hello", [], s, "eim").kind == "need_tests")
        sol = read_attachment(write("solution.py", "def add(a, b):\n    return a - b\n"))
        tst = read_attachment(write("test_solution.py", "from solution import add\n\ndef test_a():\n    assert add(1, 2) == 3\n"))
        intent = detect_intent("make it pass", [sol, tst], Session(), "auto")
        check("attached solution.py + test_solution.py -> EIM",
              intent.kind == "eim" and intent.initial_code.startswith("def add") and len(intent.tests) == 1)
        s2 = Session(working_code="def add(a, b):\n    return a - b")
        check("tests about already-known code reuse it",
              detect_intent("assert add(1, 2) == 3", [], s2, "auto").initial_code.startswith("def add"))
        check("unrelated tests do NOT reuse old code",
              detect_intent("assert mul(1, 2) == 2", [], s2, "auto").initial_code == "")
        check("language detection", detect_lang("مرحبا") == "ar" and detect_lang("hello") == "en")

        # Remote API chat must not try to access .model/.tokenizer or load Transformers weights.
        class _FakeRemote:
            is_remote = True
            def generate(self, prompts, temperature, max_new_tokens):
                self.prompts = list(prompts)
                return ["remote response"]
        old_get_lm = globals()["get_lm"]
        fake_remote = _FakeRemote()
        try:
            globals()["get_lm"] = lambda: fake_remote
            remote = HFChat(CFG.model_name)
            remote_text = "".join(remote.stream([{"role": "system", "content": "Keep safe"},
                                                  {"role": "user", "content": "Hello"}], 64, 0.0))
            check("remote chat backend works without local model weights",
                  remote_text == "remote response" and "SYSTEM:" in fake_remote.prompts[0]
                  and "Hello" in fake_remote.prompts[0])
        finally:
            globals()["get_lm"] = old_get_lm

        # ---- chat with files (retrieval across turns)
        filler = "\n".join(f"row {i}: nothing relevant here at all" for i in range(700))
        needle = "The warranty period for the X200 device is 26 months."
        path = write("manual.txt", filler + "\n" + needle + "\n" + filler)
        chat = _FakeChat("The warranty is 26 months.")
        bot = Assistant(chat, None, _ScriptedLM(["x = 1"]), EUTV(fast), SmartMemory(path=""), fast)
        s = Session()
        out = list(bot.handle(s, "What is the warranty period for the X200?", [path], Options()))
        check("chat streams and ends with the full answer", out[-1] == "The warranty is 26 months.")
        check("the relevant chunk of a big file reached the model", needle in chat.seen[0][-1]["content"])
        check("history keeps the plain turn, not the file dump",
              "row 5:" not in s.history[0]["content"] and "[attached: manual.txt]" in s.history[0]["content"])
        list(bot.handle(s, "And what about the warranty again?", [], Options()))
        check("follow-up retrieves from earlier files", needle in chat.seen[1][-1]["content"] and len(chat.seen[1]) == 4)
        out = list(bot.handle(Session(), "ما هي فترة الضمان؟", [write("m2.txt", "فترة الضمان ستة وعشرون شهرا")], Options()))
        check("Arabic message gets the Arabic status line", out[0] == STR["thinking"][1])

        # ---- images
        from PIL import Image
        Image.new("RGB", (20, 10), "red").save(os.path.join(tmp, "pic.png"))
        chat = _FakeChat("It is a red square.")
        bot_v = Assistant(chat, _FakeVision(), _ScriptedLM(["x = 1"]), EUTV(fast), SmartMemory(path=""), fast)
        out = list(bot_v.handle(Session(), "What is in the picture?", [os.path.join(tmp, "pic.png")], Options()))
        check("vision description reaches the chat model", "red square on a white background" in chat.seen[0][-1]["content"])
        chat = _FakeChat("I cannot see it.")
        bot_nv = Assistant(chat, None, _ScriptedLM(["x = 1"]), EUTV(fast), SmartMemory(path=""), fast)
        list(bot_nv.handle(Session(), "What is in the picture?", [os.path.join(tmp, "pic.png")], Options()))
        check("without a vision model the prompt says the image is unknown",
              "no vision model is configured" in chat.seen[0][-1]["content"])
        out = list(bot_nv.handle(Session(), "", [os.path.join(tmp, "pic.png")], Options()))
        check("files without text still get an answer", out and out[-1] == "I cannot see it.")

        # ---- history trimming: binary search == dropping one turn at a time
        class _WordTok:
            def apply_chat_template(self, messages, tokenize=False, add_generation_prompt=True):
                return " ".join(f"<{m['role']}> {m['content']}" for m in messages)

            def __call__(self, text, add_special_tokens=False):
                return type("Enc", (), {"input_ids": text.split()})()

        def linear_fit(hf, messages):
            msgs = list(messages)
            while hf._length(msgs) > hf.max_context_tokens and len(msgs) > 2:
                del msgs[1]
            return msgs

        same = True
        for budget in (8, 40, 120, 400, 5000):
            hf = HFChat("x", budget)
            hf._tok = _WordTok()
            convo = [{"role": "system", "content": "sys prompt"}] + [
                {"role": "user" if i % 2 == 0 else "assistant", "content": "word " * (3 + i * 2)} for i in range(14)]
            expected = linear_fit(hf, convo)
            for _ in range(6):                                  # the original's shrink step, applied to both
                if hf._length(expected) <= budget:
                    break
                expected[-1] = {**expected[-1], "content": expected[-1]["content"][: len(expected[-1]["content"]) // 2]}
            same &= hf._fit(convo) == expected
        check("history trimming gives the same result as the one-by-one version", same)

        hf = HFChat("x", 60)
        hf._tok = _WordTok()
        question = "QUESTION why does the loop never end please answer"
        long_ctx = " ".join(f"ctx{i}" for i in range(400))
        convo = [{"role": "system", "content": "sys"}, {"role": "user", "content": long_ctx + "\n\n" + question}]
        cut = hf._fit(convo, protect=len(question))
        check("trimming cuts file excerpts and keeps the user's question whole",
              cut[-1]["content"].endswith(question) and hf._length(cut) <= 60)
        old_way = hf._fit(convo)
        check("without protect the old behaviour is unchanged (end is cut)", not old_way[-1]["content"].endswith(question))
        huge_q = " ".join(f"q{i}" for i in range(300))
        cut = hf._fit([{"role": "system", "content": "sys"}, {"role": "user", "content": huge_q}], protect=len(huge_q))
        check("an oversized question keeps its beginning AND its end",
              cut[-1]["content"].startswith("q0") and cut[-1]["content"].endswith("q299") and hf._length(cut) <= 60)
        check("a smaller explicit budget is honoured", hf._length(hf._fit(convo, protect=len(question), budget=30)) <= 30)

        # ---- unsupported / broken files never crash the turn
        out = list(bot.handle(Session(), "read this", [write("x.bin", "\x00\x00\x00")], Options()))
        check("a binary file does not crash the turn", out and not out[-1].startswith("⚠️ Something went wrong"))

        # ---- EIM flow
        fixed = "def add(a, b):\n    return a + b"
        bot_e = Assistant(_FakeChat(), None, _ScriptedLM([fixed]), EUTV(fast), SmartMemory(path=""), fast)
        s = Session()
        out = list(bot_e.handle(s, msg, [], Options(iterations=3, candidates=2)))
        check("EIM flow streams progress then a final report",
              "| # | Status" in out[1] and out[-1].startswith("### ✅") and "return a + b" in out[-1])
        check("solution.py is produced and holds the repaired code",
              s.result_path and "return a + b" in open(s.result_path, encoding="utf-8").read())
        check("working code and history are updated", s.working_code.strip() == fixed and "Final code" in s.history[-1]["content"])
        out = list(bot_e.handle(Session(), "اكتب دالة الجمع\nassert add(1, 2) == 3\nassert add(2, 2) == 4", [], Options()))
        check("EIM report speaks Arabic when asked in Arabic", "الاختبارات الظاهرة" in out[-1])
        out = list(bot_e.handle(Session(), "hello", [], Options(mode="eim")))
        check("EIM mode without tests explains what to add", "assert" in out[-1])
    finally:
        shutil.rmtree(tmp, ignore_errors=True)
    print(f"\nAll {checks} checks passed.")
    return 0


if __name__ == "__main__" and "--selftest" in sys.argv:
    raise SystemExit(run_selftest())

if __name__ == "__main__":
    demo = build_ui()
    demo.queue().launch(allowed_paths=[tempfile.gettempdir()], **getattr(demo, "_eim_launch_kwargs", {}))