Repository navigation
Expand file tree
/
Copy pathScript.lua
More file actions
1491 lines (1400 loc) · 63.5 KB
/
Copy pathScript.lua
File metadata and controls
1491 lines (1400 loc) · 63.5 KB
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
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
--[[
VAGABUNDO IA · Roblox (v6)
- NUEVA pestaña "📌 Recordar": notas permanentes (solo se borran si tú quieres)
- Escribe @ en cualquier caja de texto y sale la lista de jugadores del servidor
(Recordar, Situación, Ubicación, Decir, Memoria y "Modificar")
- Pellizca el panel con dos dedos para hacerlo más pequeño o grande
- Anti-repetición + memoria con ASUNTOS ABIERTOS / CERRADOS
- Filtro previo (izquierda) y respuestas pendientes (derecha)
Tecla PC: RightShift = mostrar/ocultar panel
]]
local Players = game:GetService("Players")
local HttpService = game:GetService("HttpService")
local UIS = game:GetService("UserInputService")
local GuiService = game:GetService("GuiService")
local TextChatService = game:GetService("TextChatService")
local RS = game:GetService("ReplicatedStorage")
local LP = Players.LocalPlayer
local DEFAULT_KEY = "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"
local CONFIG = {
MODEL = "openai/gpt-oss-20b",
URL = "https://api.groq.com/openai/v1/chat/completions",
RADIUS = 20,
MAX_CHARS = 150,
MAX_LIST = 10,
MSG_WINDOW = 12,
SUMMARY_AFTER = 12,
SUMMARY_CHUNK = 6,
TIMELINE = 8,
PREV_CTX = 3,
STALE = 20,
ASK_EVERY = 4,
MAX_TOKENS = 500,
NOTES_CHARS = 1400, -- máximo de texto de notas que se manda a la IA por respuesta
MAX_NOTE = 300, -- largo máximo de cada nota
MIN_SCALE = 0.55,
MAX_SCALE = 1.8,
DATA_VERSION = 5, -- igual que antes: tu memoria actual NO se borra
FILE = "VagabundoIA_memoria.json",
}
local env = (getgenv and getgenv()) or _G
if env.__VagabundoIA then pcall(env.__VagabundoIA) end
--// ============ ESTADO ============
local S = {
enabled = true, approval = true, gate = true, sinceAsk = 0,
apiKey = DEFAULT_KEY, effort = "low",
scales = { main = 1, gate = 1, reply = 1 },
sit = {}, loc = {}, users = {}, timeline = {}, notes = {},
}
local alive, dirty = true, false
local conns, queue = {}, {}
local usedTokens = 0
local wipedAtStart = false
local addFeed = function() end
local createCard, createGateCard
local function now() return os.time() end
local function trim(s) return (s:gsub("^%s*(.-)%s*$", "%1")) end
local function rel(t)
local s = now() - (t or now())
if s < 60 then return "hace segundos" end
if s < 3600 then return "hace " .. math.floor(s / 60) .. " min" end
if s < 86400 then return "hace " .. math.floor(s / 3600) .. " h" end
return "hace " .. math.floor(s / 86400) .. " d"
end
local function maskKey(k) return k:sub(1, 8) .. "…" .. k:sub(-4) end
--// ============ PERSISTENCIA ============
local canFS = (writefile and readfile and isfile) and true or false
local function markDirty() dirty = true end
local function saveNow()
if not canFS then return end
pcall(function()
writefile(CONFIG.FILE, HttpService:JSONEncode({
v = CONFIG.DATA_VERSION,
sit = S.sit, loc = S.loc, users = S.users, timeline = S.timeline, notes = S.notes,
sinceAsk = S.sinceAsk, approval = S.approval, gate = S.gate,
apiKey = S.apiKey, effort = S.effort, scales = S.scales,
}))
end)
dirty = false
end
if canFS then
pcall(function()
if isfile(CONFIG.FILE) then
local d = HttpService:JSONDecode(readfile(CONFIG.FILE))
-- Ajustes y NOTAS: siempre se conservan
S.approval = (d.approval ~= false)
S.gate = (d.gate ~= false)
S.apiKey = (type(d.apiKey) == "string" and d.apiKey ~= "") and d.apiKey or DEFAULT_KEY
S.effort = (d.effort == "medium") and "medium" or "low"
S.notes = (type(d.notes) == "table") and d.notes or {}
if type(d.scales) == "table" then
for k, v in pairs(d.scales) do
if type(v) == "number" then S.scales[k] = math.clamp(v, CONFIG.MIN_SCALE, CONFIG.MAX_SCALE) end
end
end
-- Memoria de conversaciones: solo si es de esta versión de datos
if d.v == CONFIG.DATA_VERSION then
S.sit, S.loc = d.sit or {}, d.loc or {}
S.users, S.timeline = d.users or {}, d.timeline or {}
S.sinceAsk = d.sinceAsk or 0
else
wipedAtStart = true
end
end
end)
if wipedAtStart then saveNow() end
end
--// ============ UTILIDADES DE MUNDO ============
local function hrp(p)
local c = p and p.Character
return c and c:FindFirstChild("HumanoidRootPart")
end
local function distTo(p)
local a, b = hrp(LP), hrp(p)
if a and b then return (a.Position - b.Position).Magnitude end
end
local function nearby()
local list = {}
for _, p in ipairs(Players:GetPlayers()) do
if p ~= LP then
local d = distTo(p)
if d and d <= CONFIG.RADIUS then table.insert(list, { p = p, d = d }) end
end
end
table.sort(list, function(a, b) return a.d < b.d end)
return list
end
local function pushTimeline(kind, who, text)
table.insert(S.timeline, { k = kind, w = who or "", t = text, ts = now() })
while #S.timeline > CONFIG.TIMELINE do table.remove(S.timeline, 1) end
markDirty()
end
local function getUser(p)
local k = tostring(p.UserId)
local u = S.users[k]
if not u then
u = { name = p.Name, display = p.DisplayName, first = now(), last = now(), n = 0, summary = "", msgs = {} }
S.users[k] = u
end
u.name, u.display = p.Name, p.DisplayName
u.msgs = u.msgs or {}
u.summary = u.summary or ""
u.n = u.n or 0
return u, k
end
-- @menciones: busca al jugador por su nombre de usuario (en el servidor o en la memoria)
local function resolveName(name)
local ln = name:lower()
for _, p in ipairs(Players:GetPlayers()) do
if p.Name:lower() == ln then return { name = p.Name, display = p.DisplayName, uid = p.UserId } end
end
for k, u in pairs(S.users) do
if u.name and u.name:lower() == ln then
return { name = u.name, display = u.display or u.name, uid = tonumber(k) }
end
end
return nil
end
-- "@pedro123" -> "Pedro (@pedro123)" para que la IA sepa quién es
local function expandMentions(text)
return (text:gsub("@([%w_]+)", function(n)
local r = resolveName(n)
if not r then return nil end
if r.display == r.name then return "@" .. r.name end
return r.display .. " (@" .. r.name .. ")"
end))
end
-- Para texto que se dice en el chat: "@pedro123" -> "Pedro"
local function mentionsToDisplay(text)
return (text:gsub("@([%w_]+)", function(n)
local r = resolveName(n)
return r and r.display or nil
end))
end
--// ============ CHAT (enviar) ============
local function clip(s, n)
local len = utf8.len(s)
if not len then return s:sub(1, n) end
if len <= n then return s end
local cut = s:sub(1, utf8.offset(s, n + 1) - 1)
local sp = cut:match("^(.*)%s%S*$")
if sp and (utf8.len(sp) or 0) > n * 0.5 then return sp end
return cut
end
local function sendChat(text)
text = clip((text:gsub("^/+", "")), 195)
if text == "" then return false end
local ok = false
if TextChatService.ChatVersion == Enum.ChatVersion.TextChatService then
local chs = TextChatService:FindFirstChild("TextChannels")
local ch = chs and chs:FindFirstChild("RBXGeneral")
if ch then ok = pcall(function() ch:SendAsync(text) end) end
else
local ev = RS:FindFirstChild("DefaultChatSystemChatEvents")
local say = ev and ev:FindFirstChild("SayMessageRequest")
if say then ok = pcall(function() say:FireServer(text, "All") end) end
end
return ok
end
--// ============ API ============
local httpRequest = (syn and syn.request) or (http and http.request) or http_request or request or (fluxus and fluxus.request)
local function callAPI(messages, opts)
opts = opts or {}
if not httpRequest then return nil, "Tu ejecutor no soporta HTTP (request)" end
local body = HttpService:JSONEncode({
model = CONFIG.MODEL, messages = messages,
temperature = opts.temp or 0.7, top_p = 0.95,
max_completion_tokens = opts.max or CONFIG.MAX_TOKENS,
reasoning_effort = opts.effort or S.effort,
})
for attempt = 1, 2 do
local ok, res = pcall(httpRequest, {
Url = CONFIG.URL, Method = "POST",
Headers = { ["Content-Type"] = "application/json", ["Authorization"] = "Bearer " .. S.apiKey },
Body = body,
})
if not ok or not res then return nil, "Fallo de red" end
if res.StatusCode == 429 and attempt == 1 then
task.wait(4)
elseif res.StatusCode == 401 then
return nil, "Clave API inválida (401)"
elseif res.StatusCode ~= 200 then
return nil, "HTTP " .. tostring(res.StatusCode)
else
local okd, j = pcall(HttpService.JSONDecode, HttpService, res.Body)
if okd and j.usage and j.usage.total_tokens then usedTokens = usedTokens + j.usage.total_tokens end
local txt = okd and j.choices and j.choices[1] and j.choices[1].message and j.choices[1].message.content
if txt and txt ~= "" then return trim(txt) end
return nil, "Respuesta vacía"
end
end
return nil, "Límite de uso (429)"
end
local function cleanReply(s)
s = s:gsub("[\r\n]+", " ")
s = s:gsub("^%[[^%]]*%]:?%s*", "")
s = s:gsub("%*%*", "*")
s = trim(s)
s = s:gsub('^"(.*)"$', "%1")
return s
end
local function looksLikeAsk(s)
s = s:lower()
for _, w in ipairs({ "lana", "dinero", "moneda", "plata", "limosna", "feria", "billete", "centavo", "robux", "algo de comer", "comida", "un cambio" }) do
if s:find(w, 1, true) then return true end
end
return false
end
-- Pista gratis (sin tokens): qué tipo de mensaje es
local function hint(t)
local s = t:lower()
local function has(...)
for _, w in ipairs({ ... }) do if s:find(w, 1, true) then return true end end
return false
end
if has("te puedo", "te ayudo", "te ofrezco", "te propongo", "trabajo", "pobreza", "quieres ganar", "te invito", "un trato") then
return "Es una OFERTA o propuesta: muestra interés o desconfianza y pregunta de qué se trata o cuál es el truco. NO pidas dinero."
end
if has("toma ", "te regalo", "te doy", "aquí tienes", "para ti") then
return "Te ofrece o da algo: reacciona a eso y agradece a tu manera."
end
if has("apestas", "lárgate", "largate", "vete ", "asqueroso", "inútil", "muérete", "sucio") then
return "Es un insulto o rechazo: reacciona con orgullo, ironía o queja."
end
if has("sígueme", "sigueme", "ven ", "acompáñame", "acompaname", "vamos") then
return "Te pide que lo acompañes o vayas: decide si aceptas y da un motivo."
end
if s:find("?", 1, true) or has("qué ", "que ", "cómo", "como ", "dónde", "donde", "cuándo", "quién", "quien", "por qué", "cuánto") then
return "Es una pregunta: respóndela de verdad y con datos de tu situación."
end
if has("hola", "buenas", "qué tal", "hey", "ey ") then
return "Es un saludo: saluda corto y con carácter."
end
return nil
end
--// ============ ANTI-REPETICIÓN (gratis, sin tokens) ============
local STOP = {}
for w in ("porque aunque entonces también tengo tienes tiene estoy estás están estaba hacer hacia puedo puedes quiero quieres sobre ahora aquí nada todo todos mucho mucha bueno buena parece después antes siempre nunca había hasta desde entre donde cuando quién cuál cómo vamos claro pues"):gmatch("%S+") do
STOP[w] = true
end
local function words(s)
local t = {}
for w in s:lower():gmatch("[%w\128-\255]+") do
if #w >= 5 and not STOP[w] then t[w] = true end
end
return t
end
local function recentAI(n)
local list = {}
for i = #S.timeline, 1, -1 do
local e = S.timeline[i]
if e.k == "ai" then
table.insert(list, e.t)
if #list >= n then break end
end
end
return list
end
local function bannedWords(playerText)
local rec = recentAI(3)
if #rec < 2 then return {} end
local count, pw = {}, words(playerText)
for _, r in ipairs(rec) do
for w in pairs(words(r)) do count[w] = (count[w] or 0) + 1 end
end
local out = {}
for w, c in pairs(count) do
if c >= 2 and not pw[w] then table.insert(out, w) end
end
table.sort(out)
while #out > 5 do table.remove(out) end
return out
end
local function usesBanned(out, banned)
local ow, hit = words(out), {}
for _, w in ipairs(banned) do if ow[w] then table.insert(hit, w) end end
return hit
end
--// ============ PROMPT (compacto) ============
local PERSONA = [[
Eres un VAGABUNDO callejero en un juego de rol. Hablas como una persona real, nunca como asistente.
PERSONAJE: ropa rota, voz ronca, desconfiado, humor ácido, corazón noble. Jerga callejera suave, sin groserías fuertes. Solo pides dinero o comida si el PERMISO del final dice SÍ. Si te dan algo, lo agradeces y lo recuerdas.
REGLAS:
1. Responde al CONTENIDO concreto del último mensaje: pregunta -> la respondes; insulto -> reaccionas; saludo -> saludo corto; regalo -> agradeces; petición u orden -> aceptas, dudas o te niegas con un motivo; oferta, trato o ayuda -> interés o desconfianza y preguntas de qué se trata o cuál es el truco (NUNCA pidas dinero ante una oferta).
2. Añade algo concreto que haga avanzar la charla (una duda, una condición, un dato de tu vida). Nada de frases vacías ni fuera de lugar.
3. SITUACIÓN y UBICACIÓN se toman LITERALMENTE. La ubicación solo dice dónde estás: NO implica que estés enfermo, herido o asustado (en un hospital caminando eres un vagabundo que anda por ahí). Tu estado solo cambia si la situación lo dice expresamente. Si es grave (secuestro, pelea, policía, herido) manda sobre tu rutina y no pides dinero. La situación condiciona tu actitud, pero NO la menciones en cada respuesta: solo cuando venga al caso. Las "anteriores" ya pasaron. Si no puedes hablar (amordazado), solo sonidos o acciones.
4. MEMORIA: úsala solo cuando venga al caso. Lo de ABIERTO solo lo mencionas si el jugador toca el tema; lo resuelto o dejado atrás está CERRADO y no se retoma. Si el jugador cambia de tema, cambias con él: nunca vuelvas por tu cuenta a un tema anterior ni preguntes otra vez por algo ya tratado. Si pasó tiempo desde la última charla, lo anterior quedó atrás.
5. VARIEDAD: no repitas palabras, muletillas, preguntas ni estructura de tus respuestas recientes, y no empieces igual. Si FOCO prohíbe palabras, no las uses.
6. No inventes personas, objetos ni hechos que no consten; lo que ya dijiste es verdad. No saludes de nuevo si hablaron hace poco.
7. Contesta solo al jugador que te habla ahora. Los mensajes [NARRADOR] son órdenes o datos del mundo: obedécelos, no los respondas.
8. Nunca menciones IA, Roblox, narrador, memoria ni contexto.
9. NOTAS PERMANENTES: son hechos fijos del mundo. Tómalas como verdad, úsalas solo cuando vengan al caso y nunca las contradigas ni las recites sin motivo. Un nombre como Pedro (@pedro123) es el jugador de Roblox con ese nombre de usuario.
FORMATO: acciones entre *asteriscos*, diálogo sin comillas, sin emojis ni listas. 1 o 2 frases, máximo {MAX} caracteres. Si el jugador termina su mensaje con "/" es fuera de rol: responde breve, sin personaje, y termina con "/". Idioma del jugador (por defecto español).
ESTILO (no copiar):
- "Te puedo sacar de la pobreza" -> *te mira de arriba abajo* ¿Ah sí? ¿Y cuál es el truco? ¿Qué tendría que hacer?
- "Toma 50 robux" -> *abre los ojos como platos* ¡Dios te lo pague, no olvido a quien me ayuda!
- "¿Qué haces aquí?" -> Descansando, jefe, la calle no tiene horario. ¿Y tú, de paseo?
]]
-- Notas permanentes relevantes: generales + las que etiquetan a quien habla, a quien está cerca
-- o a quien se nombra en el mensaje. Así no se gastan tokens en notas que no vienen al caso.
local function notesBlock(plr, near, msgText)
if #S.notes == 0 then return "" end
local msgL = (msgText or ""):lower()
local presence = {}
if plr then presence[plr.UserId] = 3 end
for _, e in ipairs(near or {}) do
if not presence[e.p.UserId] then presence[e.p.UserId] = 2 end
end
local scored = {}
for idx, n in ipairs(S.notes) do
local ids, msgHit = {}, false
for _, id in ipairs(n.uids or {}) do ids[id] = true end
for nm in n.text:gmatch("@([%w_]+)") do
local r = resolveName(nm)
if r then
ids[r.uid] = true
if msgL:find(r.name:lower(), 1, true) or msgL:find((r.display or ""):lower(), 1, true) then
msgHit = true
end
end
end
local pr = 0
if not next(ids) then
pr = 1 -- nota general (sin etiquetas válidas): siempre
else
for id in pairs(ids) do
local v = presence[id]
if v and v > pr then pr = v end
end
if msgHit and pr < 2 then pr = 2 end
end
if pr > 0 then table.insert(scored, { n = n, pr = pr, i = idx }) end
end
table.sort(scored, function(a, b)
if a.pr ~= b.pr then return a.pr > b.pr end
return a.i > b.i
end)
local lines, used = {}, 0
for _, s in ipairs(scored) do
local line = "- " .. expandMentions(s.n.text)
if used + #line > CONFIG.NOTES_CHARS then break end
used = used + #line
table.insert(lines, line)
end
if #lines == 0 then return "" end
return "NOTAS PERMANENTES (verdades fijas, siempre vigentes):\n" .. table.concat(lines, "\n")
end
local function buildSystem(plr, u, near, allowAsk, hintText, banned, msgText)
local function curLine(a, empty)
local x = a[1]
if not x or x.ended then return empty end
return expandMentions(x.text) .. " (puesta " .. rel(x.ts) .. ")"
end
local function prevLine(a, from)
local t = {}
for i = from, math.min(#a, from + CONFIG.PREV_CTX - 1) do table.insert(t, expandMentions(a[i].text)) end
return #t > 0 and (" | ya pasaron: " .. table.concat(t, " | ")) or ""
end
local names = {}
for _, e in ipairs(near) do
table.insert(names, e.p.DisplayName .. " (@" .. e.p.Name .. ") " .. math.floor(e.d) .. " studs")
end
local tl = {}
for _, e in ipairs(S.timeline) do
if e.k == "say" then table.insert(tl, e.w .. ": " .. e.t)
elseif e.k == "ai" then table.insert(tl, "TÚ a " .. e.w .. ": " .. e.t)
elseif e.k == "sit" then table.insert(tl, "[NARRADOR] nueva situación: " .. expandMentions(e.t))
elseif e.k == "end" then table.insert(tl, "[NARRADOR] terminó la situación: " .. expandMentions(e.t))
else table.insert(tl, "[NARRADOR] nueva ubicación: " .. expandMentions(e.t)) end
end
local who = ""
if plr and u then
who = "HABLA AHORA: " .. u.display .. " (@" .. u.name .. ", a " .. math.floor(distTo(plr) or 0) .. " studs). "
who = who .. (u.n == 0 and "Es la primera vez que hablan."
or ("Han hablado " .. u.n .. " veces, la última " .. rel(u.last) .. "."))
if u.n > 0 and now() - u.last > 300 then
who = who .. " Ha pasado tiempo: lo de antes quedó atrás, no lo retomes salvo que él lo haga."
end
if u.summary ~= "" then who = who .. "\nFICHA DE ESTA PERSONA:\n" .. u.summary end
end
local sitFrom = (S.sit[1] and S.sit[1].ended) and 1 or 2
local world = "MUNDO:\n- Situación actual: " .. curLine(S.sit, "ninguna especial") .. prevLine(S.sit, sitFrom)
.. "\n- Ubicación actual: " .. curLine(S.loc, "una calle cualquiera") .. prevLine(S.loc, 2)
.. "\n- Cerca: " .. (#names > 0 and table.concat(names, ", ") or "nadie")
if #tl > 0 then world = world .. "\nHACE POCO:\n" .. table.concat(tl, "\n") end
local nb = notesBlock(plr, near, msgText)
if nb ~= "" then world = world .. "\n\n" .. nb end
local tail = "\n\n" .. who
local focus = {}
if hintText then table.insert(focus, hintText) end
if banned and #banned > 0 then
table.insert(focus, "NO uses estas palabras en esta respuesta (ya las repetiste): " .. table.concat(banned, ", ") .. ". Habla solo de lo que dijo el jugador.")
end
if #focus > 0 then tail = tail .. "\nFOCO: " .. table.concat(focus, " ") end
tail = tail .. "\nPERMISO PARA PEDIR DINERO/COMIDA: " .. (allowAsk and "SÍ, solo si encaja con lo que dicen" or "NO")
return (PERSONA:gsub("{MAX}", tostring(CONFIG.MAX_CHARS))) .. "\n" .. world .. tail
end
--// ============ RESUMEN → FICHA (memoria larga por usuario) ============
local summarizing = {}
local function maybeSummarize(u)
if summarizing[u] or #u.msgs <= CONFIG.SUMMARY_AFTER then return end
summarizing[u] = true
task.spawn(function()
local lines, rest = {}, {}
for i = 1, CONFIG.SUMMARY_CHUNK do
local m = u.msgs[i]
if m then table.insert(lines, (m.r == "u" and u.display or "Vagabundo") .. ": " .. m.t) end
end
for i = CONFIG.SUMMARY_CHUNK + 1, math.min(#u.msgs, CONFIG.SUMMARY_CHUNK + 6) do
local m = u.msgs[i]
table.insert(rest, (m.r == "u" and u.display or "Vagabundo") .. ": " .. m.t)
end
local out = callAPI({
{ role = "system", content = "Archivista de un juego de rol. Actualiza la ficha de UNA persona que habla con un vagabundo. Formato exacto, máximo 750 caracteres:\nHECHOS: viñetas con lo duradero (nombre o apodo, dinero o comida dados o pedidos con cantidades, deudas, promesas cumplidas o rotas, datos que contó de sí, eventos importantes, lugares).\nRELACIÓN: una línea (cómo se tratan: amable, hostil, desconfianza...).\nABIERTO: solo asuntos realmente pendientes y vigentes (ofertas sin responder, citas, deudas por pagar). Si algo se resolvió, se rechazó, se dejó de mencionar o ya no importa, BÓRRALO de ABIERTO. Si no hay nada, escribe 'nada'.\nResponde solo con la ficha." },
{ role = "user", content = "PERSONA: " .. u.display .. "\nFICHA ACTUAL:\n" .. (u.summary ~= "" and u.summary or "(vacía)") .. "\n\nNUEVOS MENSAJES A INCORPORAR:\n" .. table.concat(lines, "\n") .. "\n\nCONVERSACIÓN POSTERIOR (ya no se resume; úsala para saber qué sigue vigente):\n" .. (#rest > 0 and table.concat(rest, "\n") or "(nada)") .. "\n\nFicha actualizada:" },
}, { temp = 0.2, max = 450, effort = "low" })
if out then
u.summary = out:sub(1, 1000)
for _ = 1, CONFIG.SUMMARY_CHUNK do table.remove(u.msgs, 1) end
markDirty()
end
summarizing[u] = nil
end)
end
--// ============ GENERAR / ENVIAR RESPUESTAS ============
local PURPLE = Color3.fromRGB(196, 181, 253)
local function chatMessages(plr, u, text)
local allow = (S.sinceAsk or 0) >= CONFIG.ASK_EVERY
local msgs = { { role = "system", content = buildSystem(plr, u, nearby(), allow, hint(text), bannedWords(text), text) } }
local from = math.max(1, #u.msgs - CONFIG.MSG_WINDOW + 1)
for i = from, #u.msgs do
local m = u.msgs[i]
if m.r == "u" then
table.insert(msgs, { role = "user", content = "[" .. u.display .. "]: " .. m.t })
else
table.insert(msgs, { role = "assistant", content = m.t })
end
end
table.insert(msgs, { role = "user", content = "[" .. u.display .. "]: " .. text })
return msgs
end
-- SOLO aquí se guarda en memoria (cuando tú envías). Lo rechazado nunca llega aquí.
local function commitReply(plr, text, out)
local u = getUser(plr)
sendChat(out)
addFeed("🧥 " .. out, PURPLE)
u.n = u.n + 1
u.last = now()
table.insert(u.msgs, { r = "u", t = text })
table.insert(u.msgs, { r = "a", t = out })
pushTimeline("say", u.display, text)
pushTimeline("ai", u.display, out)
S.sinceAsk = looksLikeAsk(out) and 0 or ((S.sinceAsk or 0) + 1)
markDirty()
maybeSummarize(u)
end
local function regenerate(plr, text, prevOut, instruction)
local u = getUser(plr)
local msgs = chatMessages(plr, u, text)
table.insert(msgs, { role = "assistant", content = prevOut })
table.insert(msgs, { role = "user", content = "[NARRADOR · Corrección]: Reescribe tu última respuesta aplicando este cambio: " .. instruction .. ". Sigue en personaje, mismo formato, máximo " .. CONFIG.MAX_CHARS .. " caracteres. Responde solo con la nueva respuesta." })
local out, err = callAPI(msgs)
if not out then return nil, err end
out = cleanReply(out)
if out == "" then return nil, "Respuesta vacía" end
return out
end
local function handleChat(item)
if not S.enabled then return end
local plr, text = item.p, item.t
local u = getUser(plr)
local out, err = callAPI(chatMessages(plr, u, text))
if not out then addFeed("⚠ " .. tostring(err), Color3.fromRGB(255, 138, 155)) return end
out = cleanReply(out)
if out == "" then return end
local hit = usesBanned(out, bannedWords(text))
if #hit > 0 then
local out2 = regenerate(plr, text, out, "no uses las palabras: " .. table.concat(hit, ", ") .. ". Responde solo a lo que acaba de decir el jugador, sin retomar temas anteriores")
if out2 then out = out2 end
end
if S.approval then
createCard(plr, text, out)
else
task.wait(math.min(#out * 0.02, 2))
commitReply(plr, text, out)
end
end
local function handleSay(item)
local raw = expandMentions(item.t)
local msgs = {
{ role = "system", content = buildSystem(nil, nil, nearby(), true, nil, bannedWords(""), raw) },
{ role = "user", content = "[NARRADOR · Orden]: Tu personaje dice ahora en voz alta lo siguiente, a su manera de hablar, sin cambiar el significado, en 1 o 2 frases: " .. raw },
}
local out, err = callAPI(msgs)
if not out then addFeed("⚠ " .. tostring(err), Color3.fromRGB(255, 138, 155)) return end
out = cleanReply(out)
if out == "" then return end
sendChat(out)
addFeed("🧥 " .. out, PURPLE)
pushTimeline("ai", "todos", out)
end
local function enqueue(item)
item.ts = now()
table.insert(queue, item)
while #queue > 6 do table.remove(queue, 1) end
end
task.spawn(function()
while alive do
local item = table.remove(queue, 1)
if item then
if now() - item.ts <= CONFIG.STALE then
pcall(item.kind == "say" and handleSay or handleChat, item)
end
task.wait(1.3)
else
task.wait(0.2)
end
end
end)
local lastSeen = {}
local function onChat(plr, text)
if plr == LP or not S.enabled or not text then return end
text = trim(text)
if #text < 2 or text:sub(1, 1) == "/" then return end
local d = distTo(plr)
if not d or d > CONFIG.RADIUS then return end
local ls = lastSeen[plr.UserId]
if ls and ls.t == text and now() - ls.ts < 15 then return end
lastSeen[plr.UserId] = { t = text, ts = now() }
addFeed(plr.DisplayName .. ": " .. text)
if S.gate then
createGateCard(plr, text)
else
enqueue({ kind = "chat", p = plr, t = text })
end
end
if TextChatService.ChatVersion == Enum.ChatVersion.TextChatService then
table.insert(conns, TextChatService.MessageReceived:Connect(function(m)
local src = m.TextSource
if not src then return end
local plr = Players:GetPlayerByUserId(src.UserId)
if plr then onChat(plr, m.Text) end
end))
else
local function hook(p) table.insert(conns, p.Chatted:Connect(function(msg) onChat(p, msg) end)) end
for _, p in ipairs(Players:GetPlayers()) do hook(p) end
table.insert(conns, Players.PlayerAdded:Connect(hook))
end
--// ============ GUI: utilidades ============
local C = {
bg = Color3.fromRGB(11, 13, 18), panel = Color3.fromRGB(18, 21, 28), panel2 = Color3.fromRGB(28, 32, 44),
line = Color3.fromRGB(40, 46, 62), txt = Color3.fromRGB(232, 234, 240), mute = Color3.fromRGB(139, 147, 167),
acc = Color3.fromRGB(124, 92, 255), acc2 = Color3.fromRGB(79, 209, 197),
warn = Color3.fromRGB(251, 191, 36), red = Color3.fromRGB(255, 138, 155),
}
local function new(class, props, parent)
local o = Instance.new(class)
for k, v in pairs(props or {}) do o[k] = v end
if parent then o.Parent = parent end
return o
end
local function corner(o, r) new("UICorner", { CornerRadius = UDim.new(0, r or 8) }, o) return o end
local function stroke(o, c) new("UIStroke", { Color = c or C.line, Thickness = 1, ApplyStrokeMode = Enum.ApplyStrokeMode.Border }, o) return o end
local function frame(parent, props)
local p = { BackgroundColor3 = C.panel, BorderSizePixel = 0 }
for k, v in pairs(props or {}) do p[k] = v end
return new("Frame", p, parent)
end
local function label(parent, text, props)
local p = { Text = text, BackgroundTransparency = 1, Font = Enum.Font.Gotham, TextSize = 12, TextColor3 = C.mute, TextXAlignment = Enum.TextXAlignment.Left, TextWrapped = true }
for k, v in pairs(props or {}) do p[k] = v end
return new("TextLabel", p, parent)
end
local function btn(parent, text, props, cb)
local p = { Text = text, Font = Enum.Font.GothamBold, TextSize = 12, TextColor3 = C.txt, BackgroundColor3 = C.panel2, BorderSizePixel = 0, AutoButtonColor = true }
for k, v in pairs(props or {}) do p[k] = v end
local b = corner(new("TextButton", p, parent), 8)
if cb then b.Activated:Connect(cb) end
return b
end
local function textbox(parent, ph, props)
local p = { PlaceholderText = ph, Text = "", ClearTextOnFocus = false, Font = Enum.Font.Gotham, TextSize = 13, TextColor3 = C.txt, PlaceholderColor3 = C.mute, BackgroundColor3 = C.bg, BorderSizePixel = 0, TextXAlignment = Enum.TextXAlignment.Left, ClipsDescendants = true }
for k, v in pairs(props or {}) do p[k] = v end
local t = stroke(corner(new("TextBox", p, parent), 8))
new("UIPadding", { PaddingLeft = UDim.new(0, 8), PaddingRight = UDim.new(0, 8) }, t)
return t
end
local function scroller(parent, props)
local p = { BackgroundColor3 = C.bg, BorderSizePixel = 0, ScrollBarThickness = 3, CanvasSize = UDim2.new(), AutomaticCanvasSize = Enum.AutomaticSize.Y }
for k, v in pairs(props or {}) do p[k] = v end
local s = corner(new("ScrollingFrame", p, parent), 8)
new("UIListLayout", { Padding = UDim.new(0, 4), SortOrder = Enum.SortOrder.LayoutOrder }, s)
new("UIPadding", { PaddingTop = UDim.new(0, 4), PaddingLeft = UDim.new(0, 4), PaddingRight = UDim.new(0, 4), PaddingBottom = UDim.new(0, 4) }, s)
return s
end
local function guiParent()
local ok, h = pcall(function() return gethui and gethui() end)
if ok and h then return h end
local ok2, cg = pcall(function() return game:GetService("CoreGui") end)
if ok2 and cg then return cg end
return LP:WaitForChild("PlayerGui")
end
local gui = new("ScreenGui", { Name = "VagabundoIA", ResetOnSpawn = false, IgnoreGuiInset = true, DisplayOrder = 999, ZIndexBehavior = Enum.ZIndexBehavior.Sibling })
pcall(function() if syn and syn.protect_gui then syn.protect_gui(gui) end end)
gui.Parent = guiParent()
-- Posicionar usando Scale (así UIScale no descuadra las posiciones)
local function placeAbs(target, x, y)
local vs = gui.AbsoluteSize
if vs.X <= 0 or vs.Y <= 0 then return end
target.Position = UDim2.fromScale(x / vs.X, y / vs.Y)
end
local function keepOnScreen(root)
local vs, sz, p = gui.AbsoluteSize, root.AbsoluteSize, root.AbsolutePosition
placeAbs(root, math.clamp(p.X, 0, math.max(0, vs.X - sz.X)), math.clamp(p.Y, 0, math.max(0, vs.Y - sz.Y)))
end
local function makeDraggable(handle, target, onTap)
local dragging, moved, start, startAbs = false, false, nil, nil
handle.InputBegan:Connect(function(i)
if i.UserInputType == Enum.UserInputType.MouseButton1 or i.UserInputType == Enum.UserInputType.Touch then
dragging, moved = true, false
start, startAbs = i.Position, target.AbsolutePosition
i.Changed:Connect(function()
if i.UserInputState == Enum.UserInputState.End then
dragging = false
if not moved and onTap then onTap() end
end
end)
end
end)
table.insert(conns, UIS.InputChanged:Connect(function(i)
if dragging and (i.UserInputType == Enum.UserInputType.MouseMovement or i.UserInputType == Enum.UserInputType.Touch) then
local d = i.Position - start
if d.Magnitude > 6 then moved = true end
local vs, sz = gui.AbsoluteSize, target.AbsoluteSize
local x = math.clamp(startAbs.X + d.X, 0, math.max(0, vs.X - sz.X))
local y = math.clamp(startAbs.Y + d.Y, 0, math.max(0, vs.Y - sz.Y))
placeAbs(target, x, y)
end
end))
end
--// ============ @MENCIONES (lista de jugadores al escribir @) ============
local popup = stroke(corner(frame(gui, { Name = "Menciones", BackgroundColor3 = C.panel, Visible = false, ZIndex = 200, Size = UDim2.fromOffset(220, 40) }), 10), C.acc)
local popList = scroller(popup, { Size = UDim2.new(1, 0, 1, 0), BackgroundColor3 = C.panel })
local activeBox
local function hidePopup()
popup.Visible = false
activeBox = nil
end
local function candidates(q)
local ql = q:lower()
local out, seen = {}, {}
for _, p in ipairs(Players:GetPlayers()) do
local nl, dl = p.Name:lower(), p.DisplayName:lower()
if ql == "" or nl:find(ql, 1, true) or dl:find(ql, 1, true) then
seen[p.UserId] = true
local d = 1e9
if p == LP then d = 1.5e9 else d = distTo(p) or 1e9 end
table.insert(out, { name = p.Name, display = p.DisplayName, online = true, me = (p == LP), d = d })
end
end
for k, u in pairs(S.users) do
local id = tonumber(k)
if id and not seen[id] and u.name then
local nl, dl = u.name:lower(), (u.display or ""):lower()
if ql == "" or nl:find(ql, 1, true) or dl:find(ql, 1, true) then
table.insert(out, { name = u.name, display = u.display or u.name, online = false, d = 2e9 })
end
end
end
table.sort(out, function(a, b)
if a.d ~= b.d then return a.d < b.d end
return a.name:lower() < b.name:lower()
end)
return out
end
local function insertMention(box, name)
local text, cp = box.Text, box.CursorPosition
local before, after = text, ""
if cp and cp > 0 then before, after = text:sub(1, cp - 1), text:sub(cp) end
local q = before:match("@([%w_]*)$") or ""
before = before:sub(1, #before - #q - 1) .. "@" .. name .. " "
local newCp = #before + 1
box.Text = before .. after
hidePopup()
box:CaptureFocus()
box.CursorPosition = math.min(newCp, #box.Text + 1)
end
local function updatePopup(box)
local before, cp = box.Text, box.CursorPosition
if cp and cp > 0 then before = box.Text:sub(1, cp - 1) end
local q = before:match("@([%w_]*)$")
if not q then hidePopup() return end
local list = candidates(q)
if #list == 0 then hidePopup() return end
activeBox = box
for _, c in ipairs(popList:GetChildren()) do if c:IsA("TextButton") then c:Destroy() end end
local shown = math.min(#list, 8)
for i = 1, shown do
local it = list[i]
local b = btn(popList, "@" .. it.name .. " · " .. it.display .. (it.me and " (tú)" or (it.online and "" or " · fuera")), {
Size = UDim2.new(1, -4, 0, 28), LayoutOrder = i, Font = Enum.Font.Gotham, TextSize = 12,
TextXAlignment = Enum.TextXAlignment.Left, TextTruncate = Enum.TextTruncate.AtEnd,
TextColor3 = it.online and C.txt or C.mute,
}, function() insertMention(box, it.name) end)
new("UIPadding", { PaddingLeft = UDim.new(0, 8) }, b)
end
local ap, as, vs = box.AbsolutePosition, box.AbsoluteSize, gui.AbsoluteSize
local h = math.min(shown * 32 + 10, 214)
local w = math.max(as.X, 210)
local x = math.clamp(ap.X, 0, math.max(0, vs.X - w))
local y = ap.Y + as.Y + 2
if y + h > vs.Y then y = math.max(0, ap.Y - h - 2) end
popup.Size = UDim2.fromOffset(w, h)
popup.Position = UDim2.fromOffset(x, y)
popup.Visible = true
end
local function withMentions(box)
box:GetPropertyChangedSignal("Text"):Connect(function() updatePopup(box) end)
box:GetPropertyChangedSignal("CursorPosition"):Connect(function() if box:IsFocused() then updatePopup(box) end end)
box.FocusLost:Connect(function()
task.delay(0.6, function()
if activeBox == box and not box:IsFocused() then hidePopup() end
end)
end)
return box
end
-- Paneles que se pueden escalar (pellizco)
local scalables = {}
local function registerScalable(root, key)
local us = new("UIScale", { Scale = S.scales[key] or 1 }, root)
table.insert(scalables, { root = root, us = us, key = key })
return us
end
local function applyScale(key, v)
for _, sc in ipairs(scalables) do
if sc.key == key then
sc.us.Scale = math.clamp(v, CONFIG.MIN_SCALE, CONFIG.MAX_SCALE)
S.scales[key] = sc.us.Scale
keepOnScreen(sc.root)
markDirty()
end
end
end
--// ============ GUI: panel principal ============
local FULL, MINI = UDim2.fromOffset(330, 420), UDim2.fromOffset(330, 38)
local main = stroke(corner(frame(gui, { Name = "Main", Size = FULL, Position = UDim2.new(0.5, -165, 0, 100), BackgroundColor3 = C.bg, ClipsDescendants = true }), 14))
registerScalable(main, "main")
local bar = corner(frame(main, { Size = UDim2.new(1, 0, 0, 38), Active = true }), 14)
frame(bar, { Position = UDim2.new(0, 0, 1, -14), Size = UDim2.new(1, 0, 0, 14) })
label(bar, "🧥 Vagabundo IA", { Font = Enum.Font.GothamBold, TextSize = 14, TextColor3 = C.txt, Position = UDim2.fromOffset(12, 0), Size = UDim2.new(1, -120, 1, 0), TextWrapped = false })
local body = frame(main, { Position = UDim2.fromOffset(0, 38), Size = UDim2.new(1, 0, 1, -38), BackgroundTransparency = 1 })
makeDraggable(bar, main)
local bubble = stroke(corner(new("TextButton", { Text = "🧥", TextSize = 22, Size = UDim2.fromOffset(46, 46), Position = UDim2.fromOffset(16, 100), BackgroundColor3 = C.panel, TextColor3 = C.txt, BorderSizePixel = 0, AutoButtonColor = false, Visible = false }, gui), 23))
local function showMain() main.Visible, bubble.Visible = true, false end
local function hideMain()
bubble.Position = UDim2.fromOffset(main.AbsolutePosition.X, main.AbsolutePosition.Y)
main.Visible, bubble.Visible = false, true
hidePopup()
end
makeDraggable(bubble, bubble, showMain)
local minimized = false
btn(bar, "▾", { Size = UDim2.fromOffset(30, 26), Position = UDim2.new(1, -102, 0.5, -13), TextSize = 13 }, function()
minimized = not minimized
body.Visible = not minimized
main.Size = minimized and MINI or FULL
hidePopup()
end)
btn(bar, "◉", { Size = UDim2.fromOffset(30, 26), Position = UDim2.new(1, -68, 0.5, -13), TextSize = 13 }, hideMain)
local function cleanup()
alive = false
for _, c in ipairs(conns) do pcall(function() c:Disconnect() end) end
saveNow()
pcall(function() gui:Destroy() end)
env.__VagabundoIA = nil
end
env.__VagabundoIA = cleanup
btn(bar, "✕", { Size = UDim2.fromOffset(30, 26), Position = UDim2.new(1, -34, 0.5, -13), TextSize = 13, TextColor3 = C.red }, cleanup)
table.insert(conns, UIS.InputBegan:Connect(function(i, gp)
if not gp and i.KeyCode == Enum.KeyCode.RightShift then
if main.Visible then hideMain() else showMain() end
end
end))
-- Pestañas (la barra se desliza de lado)
local pages, tabBtns = {}, {}
local TABS = {
{ "home", "Inicio" }, { "sit", "⚡ Situación" }, { "loc", "📍 Ubicación" }, { "say", "💬 Decir" },
{ "mem", "🧠 Memoria" }, { "rec", "📌 Recordar" }, { "cfg", "⚙ Ajustes" },
}
local renderMem
local tabRow = new("ScrollingFrame", {
BackgroundTransparency = 1, BorderSizePixel = 0, Position = UDim2.fromOffset(6, 4), Size = UDim2.new(1, -12, 0, 30),
ScrollBarThickness = 0, ScrollingDirection = Enum.ScrollingDirection.X,
CanvasSize = UDim2.new(), AutomaticCanvasSize = Enum.AutomaticSize.X,
}, body)
new("UIListLayout", { FillDirection = Enum.FillDirection.Horizontal, Padding = UDim.new(0, 4), SortOrder = Enum.SortOrder.LayoutOrder }, tabRow)
local function showPage(name)
for n, p in pairs(pages) do p.Visible = (n == name) end
for n, b in pairs(tabBtns) do
b.BackgroundColor3 = (n == name) and C.acc or C.panel2
b.TextColor3 = (n == name) and Color3.new(1, 1, 1) or C.mute
end
local b = tabBtns[name]
if b then
local sc = S.scales.main or 1
local left = b.AbsolutePosition.X - tabRow.AbsolutePosition.X
local right = left + b.AbsoluteSize.X
local view = tabRow.AbsoluteSize.X
if left < 0 then
tabRow.CanvasPosition = Vector2.new(math.max(0, tabRow.CanvasPosition.X + left / sc - 6), 0)
elseif right > view then
tabRow.CanvasPosition = Vector2.new(tabRow.CanvasPosition.X + (right - view) / sc + 6, 0)
end
end
if name == "mem" then renderMem() end
hidePopup()
end
for i, t in ipairs(TABS) do
local b = btn(tabRow, t[2], { Size = UDim2.fromOffset(0, 28), AutomaticSize = Enum.AutomaticSize.X, TextSize = 11, LayoutOrder = i }, function() showPage(t[1]) end)
new("UIPadding", { PaddingLeft = UDim.new(0, 10), PaddingRight = UDim.new(0, 10) }, b)
tabBtns[t[1]] = b
pages[t[1]] = frame(body, { BackgroundTransparency = 1, Position = UDim2.fromOffset(8, 40), Size = UDim2.new(1, -16, 1, -48), Visible = false })
end
-- ---------- INICIO ----------
local home = pages.home
local function makeToggle(text, y, key, onT, offT)
label(home, text, { Font = Enum.Font.GothamBold, TextSize = 12, TextColor3 = C.txt, Position = UDim2.fromOffset(0, y), Size = UDim2.new(1, -100, 0, 28) })
local b
local function paint()
b.Text = S[key] and onT or offT
b.BackgroundColor3 = S[key] and C.acc2 or C.panel2
b.TextColor3 = S[key] and C.bg or C.mute
end
b = btn(home, "", { Size = UDim2.fromOffset(92, 26), Position = UDim2.new(1, -92, 0, y + 1), TextSize = 11 }, function()
S[key] = not S[key]
paint()
if key == "enabled" and not S.enabled then table.clear(queue) end
markDirty()
end)
paint()
end
makeToggle("IA del vagabundo", 0, "enabled", "ACTIVA", "PAUSADA")
makeToggle("Aprobar respuestas (derecha)", 30, "approval", "SÍ", "NO (auto)")
makeToggle("Filtro previo (izquierda)", 60, "gate", "SÍ", "NO (auto)")
local nearLabel = label(home, "", { Position = UDim2.fromOffset(0, 92), Size = UDim2.new(1, 0, 0, 34), TextSize = 11 })
local feedSF = scroller(home, { Position = UDim2.fromOffset(0, 130), Size = UDim2.new(1, 0, 1, -168) })
btn(home, "Cerrar script", { Position = UDim2.new(0, 0, 1, -30), Size = UDim2.new(1, 0, 0, 30), TextColor3 = C.red }, cleanup)
local feedCount = 0
addFeed = function(text, color)
feedCount = feedCount + 1
label(feedSF, text, { TextColor3 = color or C.txt, Size = UDim2.new(1, -6, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, LayoutOrder = feedCount })
local kids = {}
for _, c in ipairs(feedSF:GetChildren()) do if c:IsA("TextLabel") then table.insert(kids, c) end end
if #kids > 40 then
table.sort(kids, function(a, b) return a.LayoutOrder < b.LayoutOrder end)
kids[1]:Destroy()
end
task.delay(0.05, function() if alive then feedSF.CanvasPosition = Vector2.new(0, 1e6) end end)
end
-- ---------- SITUACIÓN / UBICACIÓN ----------
local boxes, lists = {}, {}
local function renderList(kind)
local sf = lists[kind]
for _, c in ipairs(sf:GetChildren()) do if c:IsA("TextButton") then c:Destroy() end end
for i, it in ipairs(S[kind]) do
local current = (i == 1 and not it.ended)
local b = btn(sf, (current and "● " or (it.ended and "✔ " or "")) .. it.text, {
Size = UDim2.new(1, -4, 0, 30), LayoutOrder = i, Font = Enum.Font.Gotham, TextSize = 12,
TextXAlignment = Enum.TextXAlignment.Left, TextTruncate = Enum.TextTruncate.AtEnd,
TextColor3 = current and C.acc2 or (it.ended and C.mute or C.txt),
}, function() boxes[kind].Text = it.text end)
new("UIPadding", { PaddingLeft = UDim.new(0, 8), PaddingRight = UDim.new(0, 8) }, b)
end
end
local function addContext(kind, text)
text = trim(text)
if text == "" then return end
table.insert(S[kind], 1, { text = text, ts = now() })
while #S[kind] > CONFIG.MAX_LIST do table.remove(S[kind]) end
pushTimeline(kind, "", text)
addFeed((kind == "sit" and "⚡ Situación: " or "📍 Ubicación: ") .. text, C.warn)
renderList(kind)
end
local function endSituation()
local cur = S.sit[1]
if not cur or cur.ended then return end
cur.ended = true
pushTimeline("end", "", cur.text)
addFeed("🏁 Situación terminada: " .. cur.text, C.warn)
renderList("sit")
markDirty()
end
local function listPage(kind, ph, info, canEnd)
local p = pages[kind]
local y0 = canEnd and 32 or 0
label(p, info, { Size = UDim2.new(1, 0, 0, 30), TextSize = 11 })
boxes[kind] = withMentions(textbox(p, ph, { Position = UDim2.fromOffset(0, 34), Size = UDim2.new(1, -70, 0, 32) }))
btn(p, "Enviar", { Position = UDim2.new(1, -64, 0, 34), Size = UDim2.fromOffset(64, 32), BackgroundColor3 = C.acc }, function()