File size: 29,005 Bytes
dd82460
d976178
 
ef6005f
d976178
c8f975e
 
d976178
ef6005f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dd82460
 
473cb68
 
 
 
 
 
 
 
 
d976178
 
 
 
 
5a8eee8
d976178
dd82460
 
19a5a7c
 
dd82460
d976178
 
19a5a7c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d976178
 
59f43e1
47ba524
 
dd82460
59f43e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2318a54
 
 
 
47ba524
2318a54
59f43e1
 
47ba524
d976178
47ba524
2318a54
59f43e1
 
47ba524
d976178
2318a54
 
 
eae9568
64f4a9c
 
 
 
eae9568
64f4a9c
 
 
 
 
 
 
2318a54
 
 
f156e8b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2318a54
 
f156e8b
2318a54
f156e8b
2318a54
 
 
f156e8b
2318a54
 
 
19a5a7c
 
 
 
 
 
 
 
 
 
 
 
 
 
f156e8b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19a5a7c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
473cb68
0b80cf0
 
473cb68
 
7dc4c08
 
 
66b1bc1
dd82460
 
d976178
 
 
 
 
 
 
dd82460
473cb68
 
 
 
 
dd82460
 
44d6800
 
 
 
043aa6b
44d6800
 
47ba524
44d6800
e41aa9d
44d6800
 
19a5a7c
 
 
44d6800
 
913be0b
0b80cf0
 
 
 
 
 
2318a54
0b80cf0
e41aa9d
913be0b
144132f
2318a54
dd82460
19a5a7c
 
20eda77
19a5a7c
913be0b
19a5a7c
 
7dc4c08
473cb68
 
7dc4c08
473cb68
f156e8b
 
 
 
473cb68
f156e8b
7dc4c08
473cb68
7dc4c08
913be0b
f156e8b
7dc4c08
913be0b
47ba524
913be0b
e41aa9d
913be0b
 
19a5a7c
 
 
 
 
 
0b80cf0
dd82460
19a5a7c
dd82460
 
 
d976178
 
 
 
 
dd82460
 
 
0b80cf0
 
 
20eda77
 
 
 
 
 
043aa6b
20eda77
 
 
 
 
 
 
043aa6b
20eda77
 
 
 
 
 
 
043aa6b
20eda77
 
0b80cf0
20eda77
0b80cf0
 
47ba524
0b80cf0
 
 
 
 
 
144132f
 
dd82460
2318a54
 
 
19a5a7c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2318a54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144132f
 
2318a54
 
 
 
 
 
 
19a5a7c
2318a54
 
 
 
 
 
 
 
 
 
 
 
 
 
eae9568
 
 
2318a54
 
 
 
 
 
 
 
 
473cb68
2318a54
 
 
 
 
 
 
 
 
 
 
 
473cb68
 
 
 
2318a54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
473cb68
2318a54
 
 
 
 
 
 
 
473cb68
2318a54
 
 
 
 
 
 
 
 
 
 
 
d976178
2318a54
473cb68
d976178
19a5a7c
eae9568
dd82460
2318a54
 
 
 
 
 
 
 
 
 
 
dd82460
144132f
2318a54
 
 
 
 
 
 
 
 
dd82460
2318a54
 
 
144132f
dd82460
2318a54
 
 
 
 
473cb68
2318a54
 
473cb68
2318a54
 
 
eae9568
 
 
 
 
 
 
 
 
 
 
 
 
2318a54
 
 
 
 
eae9568
2318a54
 
 
 
 
 
 
 
eae9568
2318a54
 
 
 
 
 
 
 
 
eae9568
2318a54
 
 
 
 
 
 
 
 
eae9568
2318a54
 
 
 
 
 
 
 
 
eae9568
2318a54
 
 
 
 
 
 
 
 
 
473cb68
2318a54
 
 
 
 
 
 
 
 
 
 
 
 
 
473cb68
2318a54
 
 
 
 
 
 
 
 
 
 
 
 
d976178
473cb68
2318a54
473cb68
2318a54
 
 
 
 
 
 
144132f
 
 
 
 
 
 
 
2318a54
 
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
import asyncio
import json
import os
import signal
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()

def kill_process_on_port(port):
    try:
        # Hex representation of port (e.g. 7860 -> 1EB4)
        port_hex = f"{port:04X}"
        inodes = []
        if os.path.exists("/proc/net/tcp"):
            with open("/proc/net/tcp", "r") as f:
                for line in f:
                    parts = line.split()
                    if len(parts) > 9:
                        local_addr = parts[1]
                        local_port = local_addr.split(":")[-1]
                        if local_port == port_hex:
                            inodes.append(parts[9])
        
        if inodes:
            my_pid = os.getpid()
            for pid_str in os.listdir("/proc"):
                if not pid_str.isdigit():
                    continue
                pid = int(pid_str)
                if pid == my_pid:
                    continue
                fd_dir = f"/proc/{pid_str}/fd"
                if not os.path.exists(fd_dir):
                    continue
                try:
                    for fd in os.listdir(fd_dir):
                        link = os.readlink(f"{fd_dir}/{fd}")
                        for inode in inodes:
                            if f"socket:[{inode}]" in link:
                                print(f"Self-Healing: Killing zombie process {pid} using port {port}", flush=True)
                                os.kill(pid, signal.SIGKILL)
                                break
                except Exception:
                    continue
    except Exception as e:
        print(f"Error checking/killing process on port {port}: {e}", flush=True)

# Free port 7860 before trying to launch Gradio
kill_process_on_port(7860)


import gradio as gr

# --- Gradio 5.x BugFix Monkeypatch ---
import gradio_client.utils as client_utils
_original_json_schema_to_python_type = client_utils._json_schema_to_python_type
def _safe_json_schema_to_python_type(schema, defs=None):
    if isinstance(schema, bool):
        return "Any"
    return _original_json_schema_to_python_type(schema, defs)
client_utils._json_schema_to_python_type = _safe_json_schema_to_python_type
# -------------------------------------
ROOT = Path(__file__).parent
GRAMMAR_PATH = ROOT / "grammar.gbnf"

# Keep the demo runnable while the GGUF files are still being prepared.
MOCK_MODE = os.getenv("STEP_ZERO_MOCK", "1") != "0"
NEMOTRON_MODEL_PATH = os.getenv("NEMOTRON_MODEL_PATH", "./models/step-zero-nemotron-finetuned.gguf")
MINICPM_MODEL_PATH = os.getenv("MINICPM_MODEL_PATH", "./models/minicpm-3-4b.gguf")

if not MOCK_MODE:
    import os
    from huggingface_hub import hf_hub_download
    from llama_cpp import Llama
    from llama_cpp import LlamaGrammar

    os.makedirs("./models", exist_ok=True)
    
    if not os.path.exists(NEMOTRON_MODEL_PATH):
        print(f"Downloading Nemotron model to {NEMOTRON_MODEL_PATH}...", flush=True)
        hf_hub_download(
            repo_id="tc043/step-zero-nemotron",
            filename="step-zero-nemotron-finetuned.gguf",
            local_dir="./models"
        )
        
    if not os.path.exists(MINICPM_MODEL_PATH):
        print(f"Downloading MiniCPM model to {MINICPM_MODEL_PATH}...", flush=True)
        downloaded = hf_hub_download(
            repo_id="mradermacher/MiniCPM3-4B-GGUF",
            filename="MiniCPM3-4B.Q4_K_M.gguf",
            local_dir="./models"
        )
        expected_path = os.path.abspath(MINICPM_MODEL_PATH)
        downloaded_path = os.path.abspath(downloaded)
        if downloaded_path != expected_path and os.path.exists(downloaded_path):
            os.rename(downloaded_path, expected_path)

    grammar = LlamaGrammar.from_file(str(GRAMMAR_PATH))

    # Models start as None and are loaded in background threads immediately
    nemotron = None
    minicpm = None

    import threading

    _nemotron_ready = threading.Event()
    _minicpm_ready = threading.Event()

    def _preload_nemotron():
        global nemotron
        print(f"BACKGROUND PRELOAD: Loading Nemotron from {NEMOTRON_MODEL_PATH}...", flush=True)
        nemotron = Llama(model_path=NEMOTRON_MODEL_PATH, n_ctx=1024)
        _nemotron_ready.set()
        print("BACKGROUND PRELOAD: Nemotron ready.", flush=True)

    def _preload_minicpm():
        global minicpm
        print(f"BACKGROUND PRELOAD: Loading MiniCPM from {MINICPM_MODEL_PATH}...", flush=True)
        minicpm = Llama(model_path=MINICPM_MODEL_PATH, n_ctx=1024)
        _minicpm_ready.set()
        print("BACKGROUND PRELOAD: MiniCPM ready.", flush=True)

    # Fire off both loads in parallel — Gradio server boots independently
    threading.Thread(target=_preload_nemotron, daemon=True).start()
    threading.Thread(target=_preload_minicpm, daemon=True).start()

# Per-model locks for concurrency
nemotron_lock = asyncio.Lock()
minicpm_lock = asyncio.Lock()

async def run_nemotron(prompt, **kwargs):
    async with nemotron_lock:
        if not MOCK_MODE:
            _nemotron_ready.wait()  # blocks only if still loading
        return await asyncio.to_thread(nemotron, prompt, **kwargs)

async def run_minicpm_chat(messages, **kwargs):
    async with minicpm_lock:
        if not MOCK_MODE:
            _minicpm_ready.wait()  # blocks only if still loading
        return await asyncio.to_thread(minicpm.create_chat_completion, messages=messages, **kwargs)

MAX_TRACE_EVENTS = 50
MAX_HISTORY_ITEMS = 20

def append_trace(state, event: dict) -> None:
    state["trace"].append(event)
    if len(state["trace"]) > MAX_TRACE_EVENTS:
        state["trace"] = state["trace"][-MAX_TRACE_EVENTS:]

def append_history(state, displayed_task: str, raw_task: str) -> None:
    state["history"].append(displayed_task)
    state["raw_history"].append(raw_task)
    if len(state["history"]) > MAX_HISTORY_ITEMS:
        state["history"] = state["history"][-MAX_HISTORY_ITEMS:]
    if len(state["raw_history"]) > MAX_HISTORY_ITEMS:
        state["raw_history"] = state["raw_history"][-MAX_HISTORY_ITEMS:]

def is_semantic_repeat(new_task: str, history: list) -> bool:
    if not history:
        return False
        
    stop_words = {"i", "need", "to", "a", "the", "an", "and", "or", "but", "with", "for", "of", "on", "at", "by", "start", "with"}
    
    def get_stems(task_str):
        words = task_str.lower().split()
        stems = []
        for w in words:
            w = w.strip(".,!?\"'();:")
            if w in stop_words or not w:
                continue
            # Simple suffix stemming
            if len(w) > 4:
                if w.endswith("ing"):
                    w = w[:-3]
                elif w.endswith("ies"):
                    w = w[:-3] + "y"
                elif w.endswith("es"):
                    w = w[:-2]
                elif w.endswith("ed"):
                    w = w[:-2]
                elif w.endswith("s") and not w.endswith("ss"):
                    w = w[:-1]
            stems.append(w)
        return set(stems)

    new_words = get_stems(new_task)
    if not new_words:
        return False
        
    for past in history[-3:]:
        past_words = get_stems(past)
        if not past_words:
            continue
        overlap = len(new_words & past_words) / max(len(new_words | past_words), 1)
        if overlap > 0.6:  # 60% overlap on key word stems = semantic repeat
            return True
    return False

def clean_and_validate_task(raw_output: str, history: list, skipped_history: list) -> tuple[str, bool]:
    import re
    if not raw_output:
        return "", False
        
    # Split by sentence boundaries and take the first sentence
    sentences = re.split(r'(?<=[.!?])\s+', raw_output)
    first_sentence = sentences[0].strip() if sentences else raw_output
    
    # Strip quotes, punctuation, numbers, etc. at ends
    cleaned = first_sentence.strip('"\' ,.-1234567890)')
    if not cleaned:
        return "", False
        
    # Strip conversational prefixes
    prefixes_to_strip = [
        "i need to ", "i should ", "i have to ", "let's ", "we should ",
        "please ", "you need to ", "you should "
    ]
    cleaned_lower = cleaned.lower()
    for prefix in prefixes_to_strip:
        if cleaned_lower.startswith(prefix):
            cleaned = cleaned[len(prefix):]
            cleaned_lower = cleaned.lower()
            
    # Capitalize the first letter again
    if cleaned:
        cleaned = cleaned[0].upper() + cleaned[1:]
        
    # Check for prompt leakage and scaffolding words
    invalid_keywords = [
        "completed tasks", "failures", "system", "user", "assistant", 
        "next step", "done", "fail", "gbnf", "pacemaker", "extra_id_",
        "instruction", "output", "goal:", "failures:"
    ]
    contains_scaffold = any(k in cleaned.lower() for k in invalid_keywords)
    
    # Check for reasonable length (under 12 words)
    word_count = len(cleaned.split())
    if word_count > 12:
        return cleaned, False
        
    if contains_scaffold:
        return cleaned, False
        
    if is_semantic_repeat(cleaned, history + skipped_history):
        return cleaned, False
        
    return cleaned, True

async def generate_atomic_task(goal: str, previous_failures: int, history: list = None, skipped_history: list = None, rejected_task: str = None) -> str:
    if history is None:
        history = []
    if skipped_history is None:
        skipped_history = []
    
    recent_history = history[-3:] if history else []
    history_str = "\n".join([f"- {t}" for t in recent_history]) if recent_history else "None"
    
    if MOCK_MODE:
        await asyncio.sleep(1) # Simulate inference latency
        demo_steps = [
            "Open a new browser tab.",
            "Create a blank document.",
            "Write the first sentence.",
            "Save the file.",
        ]
        if previous_failures == 1:
            return "Move your mouse to the browser icon."
        if previous_failures == 2:
            return "Put your hand on the mouse."
            
        total_steps = len(history) + len(skipped_history)
        return demo_steps[min(total_steps, len(demo_steps) - 1)]
            
    else:
        # If a task was rejected as too hard, route to MiniCPM to break it down further
        if previous_failures > 0 and rejected_task:
            print(f"TASK WAS REJECTED ('{rejected_task}'). ROUTING TO MINICPM FOR SUB-STEP BREAKDOWN...", flush=True)
            messages = [
                {"role": "system", "content": "You are a cognitive pacemaker. When a task is too hard, break it down into a single, even simpler, tiny physical starting action under 8 words. Return ONLY the starting action. CRITICAL: Focus strictly on the physical movement. Do NOT suggest thinking, planning, or remembering."},
                {"role": "user", "content": f"The task '{rejected_task}' was too hard. Break it down into a single, even simpler physical starting action."}
            ]
            fallback_res = await run_minicpm_chat(
                messages=messages,
                max_tokens=64, 
                temperature=0.1
            )
            raw_fallback = fallback_res['choices'][0]['message']['content'].strip()
            print(f"MINICPM BREAKDOWN OUT: {raw_fallback}", flush=True)
            content, _ = clean_and_validate_task(raw_fallback, [], [])
            return content or "Focus on the screen."

        # REAL INFERENCE using the exact string template used during fine-tuning
        system_msg = "You are a cognitive pacemaker. Break down goals into extremely tiny, atomic physical actions under 8 words."
        
        prompt = f"<extra_id_0>System\n{system_msg}\n\n"
        prompt += f"<extra_id_1>User\nGoal: {goal}\nCompleted Tasks: {history_str}\nFailures: {previous_failures}\nOutput the NEXT step.\n"
        prompt += f"<extra_id_1>Assistant\n"
        
        response = await run_nemotron(
            prompt,
            max_tokens=64, 
            temperature=0.3,
            stop=["\n", "<extra_id_1>", "Completed Tasks", "Goal:", "User:", "Assistant:", "<extra_id_"],
            grammar=grammar # Enforcing grammar here!
        )
        raw_content = response['choices'][0]['text'].strip()
        print(f"NEMOTRON RAW OUT: {raw_content}", flush=True)
        
        content, is_valid = clean_and_validate_task(raw_content, history, skipped_history)
        
        if not is_valid:
            print(f"NEMOTRON OUTPUT INVALID OR REPETITION ('{raw_content}'). FALLING BACK TO MINICPM...", flush=True)
            last_task = history[-1] if history else ""
            last_skipped = skipped_history[-1] if skipped_history else ""
            
            user_content = f"Goal: {goal}\nCompleted Tasks: {history_str}\nFailures: {previous_failures}\n"
            if last_skipped:
                user_content += (
                    f"CRITICAL: The user explicitly skipped '{last_skipped}'. Do NOT output it again. "
                    f"Stay on the current sub-topic or context of the last step, but generate an ALTERNATIVE physical starting action."
                )
            elif last_task:
                user_content += f"CRITICAL: Do NOT output '{last_task}'. Stay on the same sub-topic or context, but output the strictly NEXT new physical step.\n"
            else:
                user_content += "Output the NEXT step.\n"
            
            messages = [
                {"role": "system", "content": "You are a cognitive pacemaker. Focus strictly on the physical goal and break it down into an extremely tiny, atomic physical action under 8 words. Stay contextually coherent with the user's completed tasks, do NOT jump to unrelated sub-tasks, and output ONLY the single physical action step."},
                {"role": "user", "content": user_content}
            ]
            fallback_res = await run_minicpm_chat(
                messages=messages,
                max_tokens=64, 
                temperature=0.1
            )
            raw_fallback = fallback_res['choices'][0]['message']['content'].strip()
            print(f"MINICPM FALLBACK OUT: {raw_fallback}", flush=True)
            content, _ = clean_and_validate_task(raw_fallback, [], [])
            if not content:
                content = raw_fallback.strip('"\' ,.-1234567890)')
        
        return content or "Focus on the screen."


async def apply_activation_style(task: str, style="direct") -> str:
    if MOCK_MODE:
        await asyncio.sleep(0.5)
        if style == "calm":
            return f"When you are ready, {task[0].lower()}{task[1:]}"
        if style == "encouraging":
            return f"You can do this: {task}"
        return f"{task} Now."
        
    else:
        # REAL INFERENCE
        if style == "direct":
            return f"{task.capitalize()}."
            
        if style == "encouraging":
            system_prompt = (
                "You are a cognitive pacemaker. Rewrite the given task into an encouraging, supportive tone.\n"
                "RULES:\n"
                "1. Keep it as an action/command the user MUST do now. Start or end with an encouraging phrase like 'You got this!', 'Let's do it!', or 'Go ahead and...'.\n"
                "2. Do NOT write in the past tense, do NOT congratulate the user, and do NOT treat the task as already completed.\n"
                "3. Limit your response to EXACTLY ONE short sentence (under 12 words) and output ONLY the final rewritten task."
            )
        elif style == "calm":
            system_prompt = (
                "You are a cognitive pacemaker. Rewrite the given task into a calm, gentle tone.\n"
                "RULES:\n"
                "1. Keep it as an action/command. Use gentle prefixes like 'When you are ready, ...' or 'Take your time and ...'.\n"
                "2. Do NOT write in the past tense and do NOT congratulate the user.\n"
                "3. Limit your response to EXACTLY ONE short sentence (under 12 words) and output ONLY the final rewritten task."
            )
        else:
            system_prompt = (
                f"You are a cognitive pacemaker. Rewrite the given task into a {style} tone.\n"
                "RULES:\n"
                "1. Keep it as an action/command the user MUST do now.\n"
                "2. Do NOT write in the past tense, do NOT congratulate the user, and do NOT treat the task as already completed.\n"
                "3. Limit your response to EXACTLY ONE short sentence (under 12 words) and output ONLY the final rewritten task."
            )

        messages = [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": f"Task: {task}"}
        ]
        response = await run_minicpm_chat(
            messages=messages,
            max_tokens=40,
            temperature=0.2
        )
        content = response['choices'][0]['message']['content'].strip()
        print(f"MINICPM RAW OUT: {content}", flush=True)
        cleaned_content, is_valid = clean_and_validate_task(content, [], [])
        return cleaned_content or content.strip('"\' ,.-1234567890)')

# --- GRADIO UI ---

custom_css = """
:root, .dark {
    --body-background-fill: #0a0a0a !important;
    --background-fill-primary: #0a0a0a !important;
    --background-fill-secondary: #121212 !important;
    --border-color-primary: #1f2937 !important;
    --border-color-secondary: #374151 !important;
    --text-color-primary: #ffffff !important;
    --text-color-secondary: #d1d5db !important;
    --input-background-fill: #121212 !important;
    --input-border-width: 1px !important;
    --input-border-color: #1f2937 !important;
}

body {
    background-color: #0a0a0a !important;
    color: #ffffff !important;
    font-family: 'Helvetica Neue', sans-serif !important;
}

.gradio-container {
    background-color: #0a0a0a !important;
    border: none !important;
}

footer { display: none !important; }
.fog { opacity: 0.3; filter: blur(2px); transition: all 0.5s ease; }
.fade-in { animation: fadeIn 0.5s ease-in forwards; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }

/* Goal Input Styling */
#goal-input textarea {
    background: transparent !important;
    border: none !important;
    border-bottom: 2px solid #4b5563 !important;
    color: white !important;
    font-size: 1.5rem !important;
    text-align: center !important;
    box-shadow: none !important;
    border-radius: 0 !important;
}
#goal-input textarea:focus { border-bottom: 2px solid white !important; }

/* Buttons */
#btn-start { background-color: #16a34a !important; color: white !important; font-weight: bold; font-size: 1.25rem !important; border: none !important; }
#btn-start:hover { background-color: #15803d !important; }
#btn-done { background-color: #16a34a !important; color: white !important; font-weight: bold; font-size: 1.25rem !important; }
#btn-skip { background-color: #4b5563 !important; color: white !important; font-weight: bold; font-size: 1.25rem !important; }
#btn-hard { background-color: #1f2937 !important; color: #d1d5db !important; font-weight: bold; font-size: 1.25rem !important; }
#btn-trace { background: transparent !important; border: none !important; color: #4b5563 !important; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.75rem !important; }
#btn-trace:hover { color: #d1d5db !important; }

/* Big Task Text */
#current-task { text-align: center; font-size: 3rem; font-weight: 800; min-height: 150px; display: flex; align-items: center; justify-content: center; color: #ffffff !important; }
@media (min-width: 768px) { #current-task { font-size: 4.5rem; } }
"""

def generate_breadcrumbs_html(history: list) -> str:
    html = "<div class='fog text-sm space-y-2'>"
    for t in history:
        html += f"<div>✓ {t}</div>"
    html += "</div>"
    return html

def generate_task_html(task: str, animate=True) -> str:
    class_str = "fade-in" if animate else ""
    return f"<div id='current-task' class='{class_str}'>{task}</div>"

async def process_step(state, action: str, goal: str = None, style: str = None, last_task: str = None):
    if not state:
        state = {}
    # Initialization
    if action == "start":
        state["goal"] = goal
        state["style"] = style
        state["current_task"] = ""
        state["current_raw_task"] = ""
        state["too_hard_count"] = 0
        state["history"] = []
        state["raw_history"] = []
        state["skipped_history"] = []
        state["trace"] = [{"event": "start", "goal": state["goal"], "style": state["style"]}]
        
    elif action == "too_hard":
        state["too_hard_count"] += 1
        append_trace(state, {
            "event": "too_hard",
            "task": state["current_task"],
            "too_hard_count": state["too_hard_count"],
        })
        
    elif action == "skip":
        append_trace(state, {"event": "skip", "task": state["current_task"]})
        if "skipped_history" not in state:
            state["skipped_history"] = []
        if state.get("current_raw_task"):
            state["skipped_history"].append(state["current_raw_task"])
        # Note: Do not increment too_hard_count
        
    elif action == "done":
        state["too_hard_count"] = 0
        if last_task:
            raw_task_done = state.get("current_raw_task") or last_task
            append_history(state, last_task, raw_task_done)
            append_trace(state, {"event": "done", "task": last_task})
    
    # HARD CIRCUIT BREAKER
    if state["too_hard_count"] >= 3:
        breaker_task = "You are out of activation energy. Step away from the screen for 3 minutes. I will be here."
        state["current_task"] = breaker_task
        append_trace(state, {"event": "circuit_breaker", "task": breaker_task})
        state["too_hard_count"] = 0 # Reset after breaking
        return state, generate_task_html(breaker_task), generate_breadcrumbs_html(state["history"][-3:]), gr.Row(visible=False)

    # Standard Loop
    rejected_task = state["current_task"] if state["too_hard_count"] > 0 else None
    
    raw_task = await generate_atomic_task(
        state["goal"],
        state["too_hard_count"],
        history=state["raw_history"],
        skipped_history=state.get("skipped_history", []),
        rejected_task=rejected_task,
    )
    styled_task = await apply_activation_style(raw_task, state["style"])
    state["current_task"] = styled_task
    state["current_raw_task"] = raw_task
    
    append_trace(state, {
        "event": "generated_step",
        "raw_task": raw_task,
        "styled_task": styled_task,
        "style": state["style"],
        "too_hard_count": state["too_hard_count"],
    })
    
    return state, generate_task_html(styled_task), generate_breadcrumbs_html(state["history"][-3:]), gr.Row(visible=True)

with gr.Blocks(css=custom_css, js="() => { document.documentElement.classList.add('dark'); }", title="Step-Zero") as app:
    session_state = gr.State()
    
    with gr.Column(visible=True) as screen_start:
        gr.HTML("<h1 class='text-4xl font-bold mb-8 text-center mt-20'>What is paralyzing you?</h1>")
        goal_input = gr.Textbox(elem_id="goal-input", show_label=False, placeholder="e.g. Write my thesis...", lines=1)
        
        with gr.Row(elem_classes="justify-center mt-8"):
            style_radio = gr.Radio(
                choices=["direct", "calm", "encouraging"], 
                value="direct", 
                show_label=False,
                container=False
            )
            
        start_btn = gr.Button("Start", variant="primary", elem_classes="mt-8 mx-auto w-48", elem_id="btn-start")
        
    with gr.Column(visible=False) as screen_task:
        breadcrumbs_display = gr.HTML(elem_id="breadcrumbs-container")
        task_display = gr.HTML(elem_id="task-container")
        
        with gr.Row(elem_id="controls-container") as controls_row:
            btn_done = gr.Button("I DID THIS", elem_id="btn-done")
            btn_skip = gr.Button("SKIP", elem_id="btn-skip")
            btn_hard = gr.Button("TOO HARD", elem_id="btn-hard")
            
        with gr.Row(elem_classes="justify-center mt-4 gap-4"):
            btn_trace = gr.Button("Export Trace", elem_id="btn-trace")
            btn_push = gr.Button("Push to Hub", elem_id="btn-trace")
            btn_reset = gr.Button("Start Over", elem_id="btn-trace")
            
        trace_download = gr.File(visible=False)
        hub_status = gr.HTML(visible=False, elem_classes="text-center text-sm text-gray-400 mt-2")

    # Temporary Loading State Function
    def show_loading():
        return gr.Column(visible=False), gr.Column(visible=True), generate_task_html("<span class='text-gray-600 animate-pulse'>Calculating constraint...</span>", animate=False), gr.Row(visible=False)

    def show_loading_step():
        return generate_task_html("<span class='text-gray-600 animate-pulse'>Loading next step...</span>", animate=False), gr.Row(visible=False)
        
    # --- Event Handlers ---
    
    async def handle_start(state, goal, style):
        return await process_step(state, "start", goal=goal, style=style)
        
    async def handle_done(state):
        if not state: state = {}
        return await process_step(state, "done", last_task=state.get("current_task"))
        
    async def handle_skip(state):
        return await process_step(state, "skip")
        
    async def handle_too_hard(state):
        return await process_step(state, "too_hard")
    
    # Start Session
    start_btn.click(
        fn=show_loading,
        outputs=[screen_start, screen_task, task_display, controls_row]
    ).then(
        fn=handle_start,
        inputs=[session_state, goal_input, style_radio],
        outputs=[session_state, task_display, breadcrumbs_display, controls_row]
    )

    goal_input.submit(
        fn=show_loading,
        outputs=[screen_start, screen_task, task_display, controls_row]
    ).then(
        fn=handle_start,
        inputs=[session_state, goal_input, style_radio],
        outputs=[session_state, task_display, breadcrumbs_display, controls_row]
    )

    # Mark Done
    btn_done.click(
        fn=show_loading_step,
        outputs=[task_display, controls_row]
    ).then(
        fn=handle_done,
        inputs=[session_state],
        outputs=[session_state, task_display, breadcrumbs_display, controls_row]
    )

    # Mark Skip
    btn_skip.click(
        fn=show_loading_step,
        outputs=[task_display, controls_row]
    ).then(
        fn=handle_skip,
        inputs=[session_state],
        outputs=[session_state, task_display, breadcrumbs_display, controls_row]
    )

    # Mark Too Hard
    btn_hard.click(
        fn=show_loading_step,
        outputs=[task_display, controls_row]
    ).then(
        fn=handle_too_hard,
        inputs=[session_state],
        outputs=[session_state, task_display, breadcrumbs_display, controls_row]
    )

    # Trace Export
    def export_trace_fn(state):
        import tempfile
        with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f:
            json.dump(state.get("trace", []), f, indent=2)
            filepath = f.name
        return gr.File(value=filepath, visible=True)
        
    btn_trace.click(
        fn=export_trace_fn,
        inputs=[session_state],
        outputs=[trace_download]
    )

    # Push to Hub
    def push_to_hub_fn(state):
        import tempfile
        import time
        from huggingface_hub import HfApi
        
        try:
            if not state: state = {}
            with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f:
                json.dump(state.get("trace", []), f, indent=2)
                filepath = f.name
                
            api = HfApi()
            # Push to the user's dataset repo (tc043/step-zero-dataset as seen in README)
            # Using timestamp to avoid overwriting traces
            timestamp = int(time.time())
            api.upload_file(
                path_or_fileobj=filepath,
                path_in_repo=f"traces/trace_{timestamp}.json",
                repo_id="tc043/step-zero-dataset",
                repo_type="dataset"
            )
            return gr.HTML(value="<span class='text-green-500'>✓ Successfully pushed trace to HF Hub!</span>", visible=True)
        except Exception as e:
            return gr.HTML(value=f"<span class='text-red-500'>Error pushing to Hub: {str(e)}</span>", visible=True)

    btn_push.click(
        fn=push_to_hub_fn,
        inputs=[session_state],
        outputs=[hub_status]
    )

    def handle_reset():
        return {}, gr.Column(visible=True), gr.Column(visible=False), ""

    btn_reset.click(
        fn=handle_reset,
        outputs=[session_state, screen_start, screen_task, goal_input]
    )

if __name__ == "__main__":
    app.launch(server_name="0.0.0.0", server_port=7860)