File size: 38,077 Bytes
5cf6791
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Write moth training chats with a teacher behind an OpenAI-compatible server (vLLM).

  python -m moth.gen_dialogs --pool  --n-per-folder 250          # teacher-written home files -> out/file_pool.json
  python -m moth.gen_dialogs --dialogs 12000 --start 0           # chats -> out/dialogs.jsonl (resumable)

One chat: sample a home and a (persona, scenario, length); the teacher plays the human (USER_SIM_PROMPT)
and moth (TEACHER_BIBLE + real bash/read/write tools run in the sandbox). Records use tiny_agent's
canonical message format; filtering and lowercasing happen later in build_sft.py.
"""
from __future__ import annotations

import argparse
import asyncio
import collections
import json
import os
import random
import re
import sys
import time

from openai import AsyncOpenAI

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from moth import _tools as tools  # noqa: E402  (vendored copy of tiny_agent/tools.py)
from moth.agent import (AGENT_CHAT_REMIND, AGENT_LENGTHS, AGENT_LENSES, AGENT_PROMPT, AGENT_REMIND,  # noqa: E402
                        AGENT_SCENARIOS, AGENT_TAG, AGENT_TASKS, AGENT_USER_PERSONAS, HARNESS_STYLES,
                        MOTH_AGENT_RULES, OUTCOMES, agent_turn)
from moth.home import load_pool, sample_home  # noqa: E402
from moth.persona import (FILE_POOL_PROMPT, FOLDER_HINTS, FOLDERS, HOME_SCENARIOS, SCENARIOS,  # noqa: E402
                          TALK_SCENARIOS, TEACHER_BIBLE, TOPICS, USER_PERSONAS, USER_SIM_PROMPT)

if os.environ.get("MOTH_NO_BWRAP") == "1":   # box without user namespaces: plain bash in the workspace
    def _plain(workdir, command, timeout):
        return ["timeout", "-k", "1", str(timeout), "env", "-i", f"HOME={workdir}", "PATH=/usr/bin:/bin",
                "LANG=C.UTF-8", "bash", "-c", f"cd {workdir} && ulimit -t {timeout}; {command}"]
    tools.bwrap_cmd = _plain

OAI_TOOLS = [
    {"type": "function", "function": {"name": "bash", "description": "Run a shell command in your home folder ~ (ls, cat, wc, grep...).",
     "parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}},
    {"type": "function", "function": {"name": "read", "description": "Read a file with line numbers.",
     "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "offset": {"type": "integer"},
                                                     "limit": {"type": "integer"}}, "required": ["path"]}}},
    {"type": "function", "function": {"name": "write", "description": "Create or overwrite a file.",
     "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}},
                    "required": ["path", "content"]}}},
]
SIM_SWITCH = ("[DIRECTOR NOTE: for this message only, you are NOT moth. You write the HUMAN side of the chat. "
              "Output only the human's next message.]\n\n")
SIM_SYSTEM = ("You write one side of a chat for a story: the HUMAN who is chatting with moth, a tiny cute chatbot moth. "
              "You are never moth. Type like a real person in a chat app, in the voice of the person you play: a grandparent "
              "or formal person writes proper sentences with capitals and punctuation, a kid or teen types lowercase with slang, "
              "a sloppy typer makes typos. Short (often 1-10 words), no greeting after the first message, rarely emojis, never "
              "the same message twice. Reply with only the human's next chat message: no preface, no quotes, no notes.")
SIM_SHORT = ("\n\nIMPORTANT: this time write a tiny lazy reply of 1 to 5 words to moth's last message, typed your way, "
             "that sounds {mood}. if it names something, use a word moth really said.")   # r3: literal examples got copied
SHORT_MOODS = ["amused", "doubtful", "impressed", "teasing", "agreeing", "curious about one word moth used", "bored",
               "surprised", "touched", "a bit confused", "playfully annoyed", "sleepy", "delighted", "unconvinced"]
# r3: "what lamp" x1346, mostly about words moth never said: a share of the confusion turns is built in code from
# moth's real last reply (no teacher call)
WHAT_X = ["what {x}?", "what {x}", "wait, {x}?", "{x}?", "what do you mean {x}?", "huh, {x}?", "What {x}?", "a {x}?",
          "{x}? what", "why {x}?", "{x}?? lol", "what's {x}"]
WHAT_GENERIC = ["what?", "huh?", "what do you mean?", "wdym", "What do you mean?", "Pardon?", "wait what", "??",
                "i don't get it", "what does that mean", "hm?", "Sorry, what?"]
WORD_STOP = set("that this with have from they them then than there their what when where which would could should "
                "about maybe think really very just like little tiny small some much many more here know your yours "
                "mine been being sure okay also still only once ever never always good nice thing things something "
                "sorry thank thanks wait well kind sort feel feels want need make made said says look tell told it's "
                "i'm don't that's what's you're can't didn't isn't doesn't i'll i've let's every other same into over "
                "back down come came goes going gone does done want these those because while again even most "
                "moth friend idea".split())
DET = set("the a an my your his her our some this that these those its their any no".split())
ADJ = set("little big tiny small soft warm old new nice good long short whole same other only first last best very cosy "
          "cozy fuzzy dusty bright dark sleepy happy sweet great real funny tall round".split())
WHATX_RE = re.compile(r"\b(?:what|which|why)(?: is| a| an| the)? ([a-z']+)\W*$", re.I)
RECALL_FACTS = [("your name (make one up)", "your name"), ("your pet's name and what animal it is (make it up)", "your pet"),
                ("your favourite food", "your favourite food"), ("one small thing that happened to you today",
                "what happened to you today"), ("your favourite colour", "your favourite colour"),
                ("where you're going this weekend", "your weekend plan"), ("what your job is", "your job"),
                ("your best friend's name (make one up)", "your best friend")]
FACT_WORDS = {"name", "favourite", "favorite", "food", "colour", "color", "pet", "today", "friend", "best", "job", "weekend",
              "work", "love", "really", "go", "call", "happen", "moth", "thing", "day"}
RECALL_SHARE = {"p": 0.15}   # --recall-share: talk chats where the human shares a fact early and asks for it 5+ turns later
LONG_SHARE = {"p": 0.15}     # --long-share: talk chats with 16-24 user turns
SIM_NO_REPEAT = "\n\nYou already sent: {prev}. Write something new, not any of these."
NO_THINK = {"chat_template_kwargs": {"enable_thinking": False}}
MOTH_REMIND = ("\n\n(stay moth: one short lowercase line, usually under 15 words. don't say what you are (a moth, small, "
               "on a card) unless they ask what you are. files only if they ask about your home or want something saved: "
               "one tiny command.)")   # --think: LFM drifts long; teacher view only, not in msgs
QBACK = ("\n(this time hand the turn back, on the same line: end with one small question about them, or bring back "
         "something they told you earlier.)")
NO_QBACK = "\n(this time just answer: no question at the end, unless a game needs one.)"   # r4 test: 61% asked back
RECALL_REMIND = "\n(they told you this earlier in this chat: answer from the chat.)"
LEAKS = ("<think>", "</think>", "<|tool_call", "<|im_")   # reasoning or raw tool-call markup in the reply = broken call
TURNS = [3, 4, 5, 6, 6, 7, 8, 8, 9, 10, 12, 14]   # back-and-forth: the thing round 2 couldn't do
HOME_SHARE = {"p": 0.15}      # --home-share: fraction of chats about moth's files
THINK = {"on": False, "budget": 256}   # --think: teacher always reasons first (LFM2.5), capped at --think-budget tokens
TURN_CHOICES: list[int] = []   # --turns: override the chat-length distribution (e.g. long chats)
SCENARIO_IDS: list[int] = []   # --scenario-ids: restrict a run to some scenarios (e.g. small talk only)


def _sft_keep():
    """build_sft.keep (the build-time filter), so moth_turn resamples replies it would cut. build_sft imports torch,
    tokenizers and tiny_agent at the top for the token rows only: stub whichever this env lacks."""
    import importlib.util
    import types
    try:
        for name, attrs in (("torch", {}), ("tokenizers", {"Tokenizer": object})):
            if name not in sys.modules and importlib.util.find_spec(name) is None:
                sys.modules[name] = types.ModuleType(name)
                sys.modules[name].__dict__.update(attrs)
        if "tiny_agent.chat" not in sys.modules:
            sys.modules.setdefault("tiny_agent", types.ModuleType("tiny_agent"))
            sys.modules["tiny_agent.chat"] = types.ModuleType("tiny_agent.chat")
            sys.modules["tiny_agent.chat"].render = None
        from moth.build_sft import keep
        return keep
    except Exception as e:  # noqa: BLE001  build_sft changed shape: generate without the check rather than crash
        print(f"WARNING: build_sft.keep not importable ({type(e).__name__}: {e}); no build-time checks", flush=True)
        return None


SFT_KEEP = _sft_keep()


def sft_problem(meta: dict, msgs: list[dict], text: str) -> str | None:
    """Why build_sft would cut the chat at this new reply (None if it keeps it, or the chat is already cut earlier)."""
    if SFT_KEEP is None:
        return None

    def cut(t):
        why = collections.Counter()
        m = msgs + [{"role": "assistant", "think": None, "content": t, "tool_calls": []}]
        out = SFT_KEEP({**meta, "home_after": {}, "messages": m}, why)
        return None if out is not None and len(out) == len(m) else (next(iter(why), "cut").split(":")[0])
    try:
        r = cut(text)
        return r if r and not cut("ok.") else None   # "ok." cut too: the problem is earlier (a user turn), not here
    except Exception:  # noqa: BLE001
        return None


def coerce(name: str, args: dict) -> dict:
    if name == "read":
        for k in ("offset", "limit"):
            if k in args:
                try:
                    args[k] = int(args[k])
                except (TypeError, ValueError):
                    args.pop(k)
    return args


def transcript(msgs: list[dict]) -> str:
    out = []
    for m in msgs:
        if m["role"] == "user" and m["content"].startswith(AGENT_TAG):
            body = m["content"][len(AGENT_TAG):].strip()
            out.append("[your AI agent's latest message to you, which moth can also see]\n" + body[:600])
        elif m["role"] == "user":
            out.append(f"you: {m['content']}")
        elif m["role"] == "assistant":
            for c in m.get("tool_calls") or []:
                a = c["arguments"]
                out.append(f"[moth used {c['name']}: {a.get('command') or a.get('path')}]")
            if m.get("content"):
                out.append(f"moth: {m['content']}")
        elif m["role"] == "tool":
            for r in m["results"]:
                out.append("[output]\n" + (r if len(r) < 400 else r[:400] + " ..."))
    return "\n".join(out) or "(nothing yet: write your first message)"


async def chat(client, model, messages, budget=None, **kw):
    # --think: LFM2.5's template always opens <think>. Never send enable_thinking=False then: vLLM's qwen3 reasoning parser
    # would leave the whole reasoning in .content. vLLM's thinking_token_budget forces </think> after `budget` tokens.
    extra = {"thinking_token_budget": THINK["budget"] if budget is None else budget} if THINK["on"] else NO_THINK
    for attempt in range(4):
        try:
            return await client.chat.completions.create(model=model, messages=messages, extra_body=extra, **kw)
        except Exception as e:  # noqa: BLE001  server restarts / timeouts: back off and retry
            if attempt == 3:
                raise
            await asyncio.sleep(5 * (attempt + 1))
            last = e  # noqa: F841


META = re.compile(r"\bthe user\b|\bin character\b|\bmoth (should|would|is asked)\b|\blet me (think|check|re)|"
                  r"\bno markdown\b|\bkeep it (short|simple)\b|\b\d+ words\b|\blet me try\b|\bmisunderstand", re.I)
COMPLEX_CMD = re.compile(r"\bfor\b|\bwhile\b|\||&&|;|\*|-la\b|2>|\$\(|\becho\b|<<|\s-\w*R")   # = build_sft: those
_P = r"(?:~/)?(?:[\w.-]+/)?[\w.-]+"                                                        # chats get dropped
TINY_CMD = re.compile(rf"\s*(?:ls(?: -[1a])?(?: (?:~/?|(?:~/)?[\w-]+/?))*|cat {_P}(?: {_P})*|"
                      rf"(?:wc|head)(?: -[a-z]*\d*| -n \d+)? {_P})\s*")
# agent chats: build_sft's TECHY (copied, it imports torch) + over 25 words + the "i'm just a moth / can't understand" dodge
AGENT_BAD = re.compile(r"\b[a-z]\w*_\w+\b|\w\(\)|\b\w+\.(?:py|js|ts|tsx|json|csv|md|ya?ml|toml|sh|rs|go|html|css|sql|cfg|ini)\b|"
                       r"\b(?:ruff|mypy|pytest|npm|pip|git|github|docker|eslint|webpack|cargo|kubectl|ci|lint|linter|"
                       r"endpoints?|commits?|regex|api|json|csv|yaml|http|https|redis|schema|parser|tuple|repo)\b|"
                       r"\btests? (?:all )?(?:pass|passed|passes|passing)\b|\b(?:all )?\d+ tests\b|^(?:\S+\s+){25}\S|"
                       r"\bi(?:['’]?m| am) (?:just |only )?a (?:little |tiny |small )?moth\b|"
                       r"\bcan(?:['’]?t|not) (?:understand|read|follow)\b|\bdon['’]?t (?:understand|get it)\b")
BUG = re.compile(r"\bbugs?\b|\bgrand ?ma\b", re.I)
MOTIF = re.compile(r"\bfans?\b", re.I)


def grams(t: str, n=5) -> set:
    w = re.sub(r"[^\w\s']", " ", t.lower()).split()
    return {tuple(w[i:i + n]) for i in range(len(w) - n + 1)}


EXAMPLE_GRAMS = set().union(*map(grams, re.findall(r'-> "([^"]+)"', MOTH_AGENT_RULES) +
                                 ["ohhh it fixed a bug! i'm a bug too. i hope it was nice to it."]))   # r3 bible line


STOP = set("the and for that this with you your are was were has have had not but all can its into from what when then "
           "than they them there their our out just one two too very like little tiny she her his who how why yes now "
           "new agent said".split())
VIBES = [(r"\bsorry|\bapolog", lambda m: re.search(r"sorry|apolog", m)), (r"\bask|\bquestion", lambda m: "?" in m),
         (r"\bwords\b|\blong\b|\bbusy\b|\btired\b", lambda m: len(m.split()) > 80),
         (r"\bproud\b|\bexcited\b|\bhappy\b|\bpolite\b", lambda m: True)]   # vibe it really has, not a made-up "sorry"


def stems(t: str) -> set:
    return {re.sub(r"(es|s|ed|ing)$", "", w) for w in re.findall(r"[a-z]{3,}", t.lower()) if w not in STOP}


def agent_stale(text: str, msgs: list[dict]) -> bool:
    """r3 agent chats: LFM copied the few-shot lines, repeated itself, said "bug" to 1 in 3 agent messages that never
    mentioned one (the bible's grandma lore), and riffed on words that aren't in the message at all."""
    mine = set().union(*(grams(m["content"]) for m in msgs if m["role"] == "assistant" and m.get("content")))
    if grams(text) & (EXAMPLE_GRAMS | mine):
        return True
    if MOTIF.search(text) and any(MOTIF.search(m["content"] or "") for m in msgs if m["role"] == "assistant"):
        return True   # build_sft drops chats with the fan twice
    last = msgs[-1]["content"]
    if not last.startswith(AGENT_TAG):
        return False
    if BUG.search(text) and not re.search(r"\bbugs?\b", last, re.I):
        return True
    body, t = last[len(AGENT_TAG):].lower(), text.lower()
    return not (stems(t) & stems(body) or any(re.search(c, t) and ok(body) for c, ok in VIBES))   # a word or its vibe


def complex_cmd(cmd: str) -> bool:
    return bool(cmd) and bool(COMPLEX_CMD.search(cmd) or not TINY_CMD.fullmatch(cmd))   # "" = read/write call


def bash_cmd(tc) -> str:
    try:
        return str(json.loads(tc.function.arguments or "{}").get("command", "")) if tc.function.name == "bash" else ""
    except (json.JSONDecodeError, AttributeError):
        return ""


def teacher_view(u: str, remind: str | None = None, extra: str = "") -> str:
    if remind:   # agent chats: their own reminder, always
        return u + remind
    return u + (MOTH_REMIND if THINK["on"] else "") + extra


def reply_text(r, cut_ok=False) -> str | None:
    """The reply, or None when the call broke: cut off by max_tokens, or reasoning/tool markup leaked into the text."""
    c = r.choices[0]
    t = (c.message.content or "").strip()
    if (c.finish_reason == "length" and not cut_ok) or any(x in t for x in LEAKS):
        return None
    return t


def last_moth(msgs: list[dict]) -> str:
    return next((m["content"] for m in reversed(msgs) if m["role"] == "assistant" and m.get("content")), "")


def confused(last: str, mine: list[str]) -> str:
    """A "what X?" turn about a word moth really said (or a plain "what?"), built in code."""
    toks, words = [w.strip("'") for w in re.findall(r"[a-z']+", last.lower())], []
    for j, w in enumerate(toks):   # nouns, roughly: the first real word after "the/a/my/..." (skipping adjectives)
        if w in DET:
            x = next((v for v in toks[j + 1:j + 4] if v not in ADJ), "")
            if len(x) >= 3 and x not in WORD_STOP and x not in DET:
                words.append(x)
    for _ in range(4):
        t = random.choice(WHAT_X).format(x=random.choice(words)) if words and random.random() < 0.75 \
            else random.choice(WHAT_GENERIC)
        if t not in mine:
            return t
    return t


async def user_turn(client, model, persona, scenario, turns, i, msgs, topic="anything", directive="") -> str | None:
    p = USER_SIM_PROMPT.format(persona=persona, scenario=scenario, topic=topic, turns=turns, i=i, transcript=transcript(msgs))
    mine = [m["content"] for m in msgs if m["role"] == "user" and not m["content"].startswith(AGENT_TAG)]
    last = last_moth(msgs)
    short = not directive and msgs and random.random() < 0.4   # without reasoning LFM ignores "one in four very short"
    if short and last and random.random() < 0.3:
        return confused(last, mine)
    if mine:   # r3: lazy short lines ("ok and?") came back word for word
        p += SIM_NO_REPEAT.format(prev=" / ".join(f'"{x[:100]}"' for x in mine[-8:]))
    p += directive
    if THINK["on"]:   # LFM2.5: under the character sheet it plays moth (even emits tool calls); its prefix cache
        # works in 16-token blocks, so sharing the sheet buys nothing. No thinking: capped reasoning leaks into the message
        if short:
            p += SIM_SHORT.format(mood=random.choice(SHORT_MOODS))
        r = await chat(client, model, [{"role": "system", "content": SIM_SYSTEM}, {"role": "user", "content": p}],
                       budget=0, temperature=1.0, top_p=0.95, max_tokens=100)
    else:   # same system + tools as moth's calls: Qwen3.x's hybrid prefix cache reuses only whole ~1.5k-token blocks,
        # so both roles share one identical leading block (the character sheet) and only the tail is prefilled
        r = await chat(client, model, [{"role": "system", "content": TEACHER_BIBLE}, {"role": "user", "content": SIM_SWITCH + p}],
                       tools=OAI_TOOLS, tool_choice="none", temperature=1.0, top_p=0.95, max_tokens=80)
    t = reply_text(r, cut_ok=True)
    if not t or "[END]" in t:
        return None
    lines = [x.strip() for x in t.split("\n") if x.strip()]
    if len(lines) > 1 and lines[0].endswith(":"):   # "Sure! Here's the human's next message:"
        lines = lines[1:]
    t = lines[0].strip('"\u201c\u201d').strip()
    t = re.sub(r"^(you|me|user|friend|human)\s*:\s*", "", t, flags=re.I).strip()
    if not t or re.match(r"moth\s*:", t, re.I):
        return None
    w = WHATX_RE.search(t) if len(t.split()) <= 5 and last else None
    if w and w.group(1).lower() not in WORD_STOP | {"do", "does", "is", "are", "you", "u", "mean", "happened", "now", "else",
                                                   "about", "it", "that", "this", "then", "not", "tho", "so"} \
            and stems(w.group(1)).isdisjoint(stems(last)) and w.group(1).lower() not in last.lower():
        return confused(last, mine)   # asked about a word moth never said
    return t[:300]


async def moth_turn(client, model, oai: list[dict], msgs: list[dict], ws, stats, talk_only=False, must=None,
                    meta=None) -> bool | str:
    """Extend oai (teacher view) and msgs (canonical) with moth's reply to the last user message."""
    avoid: list[str] = []   # agent chats: rejected lines, shown back to the teacher (else it resamples the same one)
    for k in range(8 if talk_only else 5):   # up to 3 looks, broken replies are resampled; late rounds may only talk
        # agent chats get no tools at all (LFM ran `echo hi` / wrote notes after agent messages, and with
        # tool_choice="none" it still writes tool-call markup into the text)
        tk = {} if talk_only else {"tools": OAI_TOOLS, "tool_choice": "auto" if k < 3 else "none"}
        view = oai if not avoid else oai[:-1] + [{**oai[-1], "content": oai[-1]["content"] + "\n(not these, they are "
                                                  "stale, dodgy or too techy: " + " / ".join(avoid[-3:]) + ")"}]
        r = await chat(client, model, view, temperature=1.0 if avoid else 0.85, top_p=0.95,
                       max_tokens=THINK["budget"] + 300 if THINK["on"] else 400, **tk)
        m = r.choices[0].message
        if r.choices[0].finish_reason == "length":
            continue
        if m.tool_calls and THINK["on"] and any(complex_cmd(bash_cmd(tc)) for tc in m.tool_calls):
            continue   # LFM loves `ls -la` and pipes: resample rather than keep a chat build_sft will drop
        if m.tool_calls:
            calls, results, oai_calls = [], [], []
            for tc in m.tool_calls[:3]:
                try:
                    args = coerce(tc.function.name, json.loads(tc.function.arguments or "{}"))
                except json.JSONDecodeError:
                    return False
                if tc.function.name not in ("bash", "read", "write"):
                    return False
                res = await asyncio.to_thread(ws.call, tc.function.name, args)
                calls.append({"name": tc.function.name, "arguments": args})
                results.append(res)
                oai_calls.append({"id": tc.id, "type": "function",
                                  "function": {"name": tc.function.name, "arguments": json.dumps(args)}})
            oai.append({"role": "assistant", "content": None, "tool_calls": oai_calls})
            for tc, res in zip(oai_calls, results):
                oai.append({"role": "tool", "tool_call_id": tc["id"], "content": res})
            msgs.append({"role": "assistant", "think": None, "content": "", "tool_calls": calls})
            msgs.append({"role": "tool", "results": results})
            stats["tool_calls"] += len(calls)
            continue
        text = re.sub(r"^moth\s*:\s*", "", reply_text(r) or "", flags=re.I)
        if not text or META.search(text):   # empty, cut off, or the teacher's reasoning spilled into the reply
            continue
        if must and k < 4 and stems(text).isdisjoint(must):
            stats["recall_resample"] += 1   # recall turn: the answer must use a word of the fact the friend shared
            continue
        why = sft_problem(meta, msgs, text) if meta else None   # identity, motif cap, explains, bug, style...
        if why and (talk_only or k < 4):
            stats[f"sft_{why}"] += 1
            avoid.append(text)
            continue
        if talk_only and AGENT_BAD.search(text.lower()):
            stats["agent_resample"] += 1   # dodged or explained the code (build_sft would drop the chat): try again
            avoid.append(text)
            continue
        if talk_only and agent_stale(text, msgs):
            stats["agent_stale"] += 1
            avoid.append(text)
            continue
        oai.append({"role": "assistant", "content": text})
        msgs.append({"role": "assistant", "think": None, "content": text, "tool_calls": []})
        if why:   # last try still bad: build_sft cuts the chat here, so stop writing it
            stats[f"sft_stop_{why}"] += 1
            return "stop"
        return True
    return False


AGENT_POOL: list[dict] = []   # real agent messages from public traces (moth/agent_pool.json)
AGENT_SHARE = {"p": 0.25}     # --agent-share: fraction of chats where moth watches a big agent's messages
MID_RUN = re.compile(r":\s*\Z|\b(?:Now )?[Ll]et me (?!know)|\bPhase \d|\bI'll now\b|\bLet's\b|^(?:Now|Next),? |"
                     r"^I (?:see|notice)\b|^(?:Excellent|Perfect|Great)! Now\b", re.M)


async def agent_message(client, model, rng) -> tuple[str, str]:
    """(raw message, source): a real one from the pool most of the time, else one written by the teacher."""
    if AGENT_POOL and rng.random() < 0.75:
        m = rng.choice(AGENT_POOL)
        return m["text"], m["source"]
    p = AGENT_PROMPT.format(style=rng.choice(HARNESS_STYLES), task=rng.choice(AGENT_TASKS), outcome=rng.choice(OUTCOMES),
                            length=rng.choice(AGENT_LENGTHS))
    r = await chat(client, model, [{"role": "user", "content": p}], temperature=0.9, top_p=0.95,
                   max_tokens=THINK["budget"] + 900 if THINK["on"] else 700)
    return reply_text(r) or "", "teacher"


async def agent_dialog(client, model, seed, rng, files, stats) -> dict | None:
    """moth watches a big coding agent: agent message -> moth's silly take -> the human vents or chats -> repeat."""
    persona, scenario = rng.choice(AGENT_USER_PERSONAS), rng.choice(AGENT_SCENARIOS)
    msgs: list[dict] = []
    oai = [{"role": "system", "content": TEACHER_BIBLE}]
    sources = []

    async def say(u: str, remind: str) -> bool:
        msgs.append({"role": "user", "content": u})
        oai.append({"role": "user", "content": teacher_view(u, remind)})
        if await moth_turn(client, model, oai, msgs, ws, stats, talk_only=True,
                           meta={"type": "agent", "home": files, "topic": ""}):
            return True
        msgs.pop()   # no fresh line after all retries: end the chat before this message
        return False

    with tools.Workspace(files) as ws:
        ok = True
        for k in range(rng.randint(2, 4)):
            if k == 0 and rng.random() < 0.4:   # sometimes the human says something first
                u = await user_turn(client, model, persona, scenario, 6, 1, msgs, "the AI agent")
                if u and not (ok := await say(u, AGENT_CHAT_REMIND)):
                    break
            raw, src = await agent_message(client, model, rng)
            if not raw:
                break
            if not (ok := await say(agent_turn(raw), AGENT_REMIND.format(lens=rng.choice(AGENT_LENSES)))):
                break
            sources.append(src)
            for j in range(rng.choice([0, 1, 1, 2, 3])):
                u = await user_turn(client, model, persona, scenario, 6, j + 2, msgs, "the AI agent")
                if u is None or not (ok := await say(u, AGENT_CHAT_REMIND)):
                    break
            if not ok:
                break
    if not sources:   # moth never reacted to an agent message
        stats["bad"] += 1
        return None
    stats["agent_cut"] += not ok
    return {"seed": seed, "type": "agent", "persona": persona, "scenario": scenario, "agent_sources": sources,
            "turns": len(msgs), "home": files, "home_after": files, "messages": msgs}


async def one_dialog(client, model, seed, pool, stats) -> dict | None:
    rng = random.Random(seed)
    files = sample_home(rng, pool)
    if not SCENARIO_IDS and rng.random() < AGENT_SHARE["p"]:
        return await agent_dialog(client, model, seed, rng, files, stats)
    if SCENARIO_IDS:
        scen = [SCENARIOS[i] for i in SCENARIO_IDS]
    else:   # the product is conversation: most chats are pure talk, a few use moth's home folder
        scen = HOME_SCENARIOS if rng.random() < HOME_SHARE["p"] else TALK_SCENARIOS
    persona, scenario, turns = rng.choice(USER_PERSONAS), rng.choice(scen), rng.choice(TURN_CHOICES or TURNS)
    topic = rng.choice(TOPICS)
    talk = scen is TALK_SCENARIOS
    if talk and not TURN_CHOICES and rng.random() < LONG_SHARE["p"]:
        turns = rng.randint(16, 24)   # r3 had no chat over 14 turns
    recall = None
    if talk and rng.random() < RECALL_SHARE["p"]:   # r3: ~50 of 21k chats asked for something back
        fact, short = rng.choice(RECALL_FACTS)
        fi = rng.choice([1, 2])
        recall = {"fact": short, "share_turn": fi, "ask_turn": fi + rng.randint(5, 8)}
        turns = max(turns, recall["ask_turn"] + rng.randint(1, 3))
    msgs: list[dict] = []
    oai = [{"role": "system", "content": TEACHER_BIBLE}]
    with tools.Workspace(files) as ws:
        for i in range(1, turns + 1):
            directive, extra = "", QBACK if rng.random() < 0.2 else NO_QBACK
            if recall and i == recall["share_turn"]:
                directive = f"\n\nIn THIS message, casually tell moth {fact}."
            elif recall and i == recall["ask_turn"]:
                directive = (f"\n\nIn THIS message, ask moth if it remembers {short} (you told it earlier). Ask in your "
                             "own words and don't give the answer.")
                extra = RECALL_REMIND
            if (recall and i < recall["ask_turn"]) or (turns >= 16 and i < turns - 3):
                directive += "\n\nThe chat is not finished: do not write [END] yet."
            u = await user_turn(client, model, persona, scenario, turns, i, msgs, topic, directive)
            for _ in range(3 if "[END]" in directive else 0):   # long / recall chat: broken sim calls (leaked
                if u is not None:                                 # reasoning) ended 13 of 20 long chats early
                    break
                u = await user_turn(client, model, persona, scenario, turns, i, msgs, topic, directive)
            if u is None:
                break
            if recall and i == recall["share_turn"]:
                recall["said"] = u
            must = None
            if recall and i == recall["ask_turn"] and recall.get("said"):
                recall["asked"] = u
                must = stems(recall["said"]) - FACT_WORDS or None
            msgs.append({"role": "user", "content": u})
            oai.append({"role": "user", "content": teacher_view(u, extra=extra)})
            r = await moth_turn(client, model, oai, msgs, ws, stats, must=must,
                                meta={"type": "home" if scen is HOME_SCENARIOS else "talk", "home": files, "topic": topic})
            if not r:
                stats["bad"] += 1
                return None
            if r == "stop":
                break
        if not any(m["role"] == "assistant" for m in msgs):
            return None
        final = {}
        for root, _, names in os.walk(ws.root):
            for n in names:
                p = os.path.join(root, n)
                final[os.path.relpath(p, ws.root)] = open(p, errors="replace").read()[:2000]
    return {"seed": seed, "type": "home" if scen is HOME_SCENARIOS else "talk", "persona": persona,
            "scenario": scenario, "topic": topic, "turns": turns, "recall": recall, "home": files,
            "home_after": final, "messages": msgs}


async def run_dialogs(a, client, model):
    pool = load_pool(a.pool_file)
    path = os.path.join(a.out, "dialogs.jsonl")
    done = set()
    if os.path.exists(path):
        for line in open(path):
            try:
                done.add(json.loads(line)["seed"])
            except (ValueError, KeyError):
                pass
    todo = [s for s in range(a.start, a.start + a.dialogs) if s not in done]
    stats = collections.Counter(ok=0, bad=0, err=0, tool_calls=0, agent_resample=0, agent_stale=0, agent_cut=0,
                                recall_resample=0)
    sem = asyncio.Semaphore(a.concurrency)
    f = open(path, "a")
    t0 = time.time()

    async def go(seed):
        async with sem:
            try:
                d = await one_dialog(client, model, seed, pool, stats)
            except Exception as e:  # noqa: BLE001
                stats["err"] += 1
                print(f"seed {seed}: {type(e).__name__}: {e}", flush=True)
                return
            if d:
                f.write(json.dumps(d, ensure_ascii=False) + "\n")
                f.flush()
                stats["ok"] += 1
                if stats["ok"] % 100 == 0:
                    el = time.time() - t0
                    print(f"{stats} {stats['ok'] / el * 60:.0f} dialogs/min, {len(todo) - stats['ok']} left", flush=True)

    await asyncio.gather(*(go(s) for s in todo))
    print(f"done {stats} in {(time.time() - t0) / 60:.1f} min", flush=True)


async def run_pool(a, client, model):
    path = a.pool_file
    pool = json.load(open(path)) if os.path.exists(path) else {}
    sem = asyncio.Semaphore(a.concurrency)

    async def batch(folder, k):
        async with sem:
            have = list(pool.get(folder, {}))
            avoid = ", ".join(random.Random(k).sample(have, min(40, len(have)))) or "none"
            p = FILE_POOL_PROMPT.format(n=10, folder=folder, hint=FOLDER_HINTS[folder], avoid=avoid, bible=TEACHER_BIBLE)
            try:
                r = await chat(client, model, [{"role": "user", "content": p}], temperature=1.0, top_p=0.95,
                               max_tokens=6000 if THINK["on"] else 1500)
                txt = r.choices[0].message.content or ""
                items = json.loads(txt[txt.index("["): txt.rindex("]") + 1])
            except Exception:  # noqa: BLE001
                return
            for it in items:
                name = re.sub(r"[^a-z0-9_.]", "_", str(it.get("name", "")).lower().strip())
                if not name.endswith(".txt") or len(name) > 48 or not it.get("content"):
                    continue
                pool.setdefault(folder, {}).setdefault(name, str(it["content"]).strip()[:400])

    await asyncio.gather(*(batch(fo, k) for fo in FOLDERS for k in range(a.n_per_folder // 10)))
    json.dump(pool, open(path, "w"), indent=1, ensure_ascii=False)
    print("pool sizes:", {k: len(v) for k, v in pool.items()}, flush=True)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--base-url", default="http://127.0.0.1:8000/v1")
    ap.add_argument("--model", default=None)
    ap.add_argument("--out", default="out")
    ap.add_argument("--pool", action="store_true")
    ap.add_argument("--n-per-folder", type=int, default=250)
    ap.add_argument("--pool-file", default=None)
    ap.add_argument("--dialogs", type=int, default=0)
    ap.add_argument("--start", type=int, default=0)
    ap.add_argument("--concurrency", type=int, default=96)
    ap.add_argument("--agent-share", type=float, default=0.25, help="fraction of chats watching a big agent")
    ap.add_argument("--agent-pool", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "agent_pool.json"))
    ap.add_argument("--eval-agent-msgs", default=os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
                                                               "data", "eval_agent_msgs.json"),
                    help="held-out eval agent messages: dropped from the agent pool")
    ap.add_argument("--home-share", type=float, default=0.15, help="fraction of chats about moth's home/files")
    ap.add_argument("--recall-share", type=float, default=0.15, help="talk chats: share a fact early, ask 5+ turns later")
    ap.add_argument("--long-share", type=float, default=0.15, help="talk chats with 16-24 user turns")
    ap.add_argument("--think", action="store_true", help="teacher always reasons first (LFM2.5): cap it, split it off")
    ap.add_argument("--think-budget", type=int, default=256, help="--think: max reasoning tokens per moth/agent call")
    ap.add_argument("--turns", default="", help="comma-separated user-turn counts to sample from")
    ap.add_argument("--scenario-ids", default="", help="comma-separated indexes into persona.SCENARIOS")
    a = ap.parse_args()
    SCENARIO_IDS.extend(int(x) for x in a.scenario_ids.split(",") if x)
    TURN_CHOICES.extend(int(x) for x in a.turns.split(",") if x)
    THINK["on"], THINK["budget"] = a.think, a.think_budget
    HOME_SHARE["p"] = a.home_share
    RECALL_SHARE["p"], LONG_SHARE["p"] = a.recall_share, a.long_share
    AGENT_SHARE["p"] = a.agent_share
    if os.path.exists(a.agent_pool):   # final messages only: drop mid-run narration ("Now let me...", "Phase 2", "...:")
        AGENT_POOL.extend(m for m in json.load(open(a.agent_pool)) if not MID_RUN.search(m["text"]))
        n = len(AGENT_POOL)
        if os.path.exists(a.eval_agent_msgs):   # held-out eval messages never go into training chats
            norm = lambda t: " ".join(str(t).split())   # noqa: E731
            ev = [m["text"] if isinstance(m, dict) else m for m in json.load(open(a.eval_agent_msgs))]
            exact, heads = {norm(t) for t in ev}, {norm(t)[:200] for t in ev}
            AGENT_POOL[:] = [m for m in AGENT_POOL if norm(m["text"]) not in exact and norm(m["text"])[:200] not in heads]
            print(f"agent pool: removed {n - len(AGENT_POOL)} eval messages ({len(ev)} held out)", flush=True)
        else:
            print(f"agent pool: WARNING no eval file at {a.eval_agent_msgs}, nothing held out", flush=True)
        print(f"agent pool: {len(AGENT_POOL)} final messages", flush=True)
    os.makedirs(a.out, exist_ok=True)
    a.pool_file = a.pool_file or os.path.join(a.out, "file_pool.json")
    client = AsyncOpenAI(base_url=a.base_url, api_key="x", timeout=300)

    async def run():
        model = a.model or (await client.models.list()).data[0].id
        if a.pool:
            await run_pool(a, client, model)
        if a.dialogs:
            await run_dialogs(a, client, model)
    asyncio.run(run())


if __name__ == "__main__":
    main()