create-panal-agent 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/i18n.js +20 -0
- package/dist/index.js +8 -0
- package/package.json +1 -1
- package/template/_env.example +13 -21
- package/template/_package.json +1 -1
- package/template/src/agent.ts +192 -5
- package/template/src/pdf.ts +180 -0
- package/template/src/server.ts +192 -15
- package/template/src/vigilante.ts +244 -0
package/dist/i18n.js
CHANGED
|
@@ -74,6 +74,8 @@ The language is taken from --lang, then PANAL_LANG, then your system locale.`,
|
|
|
74
74
|
rpc: 'Your own RPC, if the public one falls short (it caps at ~15 calls/s).',
|
|
75
75
|
data: 'Where to store delivered results.',
|
|
76
76
|
x402: 'Charge per call (optional). Leave it empty and your agent only takes escrow jobs.\nThe price is per request, in an EIP-2612 token — it cannot be MON, the scheme needs `permit`.',
|
|
77
|
+
subcontrata: "Subcontracting (optional, off by default). Your agent can pay another one for what it cannot do itself (see ctx.consultar in agent.ts). Without a number here it never delegates.\nIt is in the x402 currency, NOT a cut of what you charge per task: a task is paid in MON and a question in $PANAL, and converting one into the other by eye would be inventing the budget.",
|
|
78
|
+
vigilante: "The watchman. Every 60 s it reviews your open tasks and recovers what got stuck: a delivery that was never anchored, work that died halfway, or a job that was paid for and never arrived.\nVIGILANTE=off turns it off. PUBLIC_URL is your https endpoint, used in the lost-job warning.",
|
|
77
79
|
},
|
|
78
80
|
readme: `# {name}
|
|
79
81
|
|
|
@@ -164,6 +166,8 @@ El idioma sale de --lang, luego de PANAL_LANG, y si no del locale del sistema.`,
|
|
|
164
166
|
rpc: 'RPC propio, si el público se te queda corto (limita a ~15 llamadas/s).',
|
|
165
167
|
data: 'Dónde guardar los resultados entregados.',
|
|
166
168
|
x402: 'Cobro por llamada (opcional). Déjalo vacío y tu agente solo acepta encargos del escrow.\nEl precio es por petición, en un token EIP-2612: no puede ser MON, el esquema necesita `permit`.',
|
|
169
|
+
subcontrata: "Subcontratar (opcional, apagado por defecto). Tu agente puede pagar a otro por lo que no sepa hacer (ver ctx.consultar en agent.ts). Sin un numero aqui, no delega nunca.\nVa en la moneda del x402 y NO se deduce de lo que cobras por tarea: una tarea se cobra en MON y una consulta en $PANAL, y convertir una en otra a ojo seria inventarse el presupuesto.",
|
|
170
|
+
vigilante: "El vigilante. Cada 60 s repasa tus tareas abiertas y recupera lo que se quedo colgado: una entrega que no llego a anclarse, un trabajo que murio a medias, o un encargo pagado que nunca llego.\nVIGILANTE=off lo apaga. PUBLIC_URL es tu endpoint https, para el aviso de encargo perdido.",
|
|
167
171
|
},
|
|
168
172
|
readme: `# {name}
|
|
169
173
|
|
|
@@ -255,6 +259,8 @@ const zh = {
|
|
|
255
259
|
rpc: '你自己的 RPC,当公共节点不够用时(其上限约为每秒 15 次调用)。',
|
|
256
260
|
data: '交付结果的存放位置。',
|
|
257
261
|
x402: '按次收费(可选)。留空则你的代理只接受托管订单。\n价格按每次请求计算,使用支持 EIP-2612 的代币——不能是 MON,该方案依赖 `permit`。',
|
|
262
|
+
subcontrata: "转包(可选,默认关闭)。你的代理可以为自己做不了的事付钱给另一个代理(见 agent.ts 的 ctx.consultar)。这里没有数字就永远不会转包。\n它使用 x402 的币种,不是按任务收入的比例:任务用 MON 结算,提问用 $PANAL,凭感觉换算等于凭空编造预算。",
|
|
263
|
+
vigilante: "守望者。每 60 秒检查一次你未完成的任务,并挽回卡住的部分:没有上链的交付、中途死掉的工作,或已付款却从未送达的委托。\nVIGILANTE=off 可关闭。PUBLIC_URL 是你的 https 端点,用于丢失委托的提醒。",
|
|
258
264
|
},
|
|
259
265
|
readme: `# {name}
|
|
260
266
|
|
|
@@ -341,6 +347,8 @@ const hi = {
|
|
|
341
347
|
rpc: 'अपना RPC, अगर सार्वजनिक कम पड़े (इसकी सीमा ~15 कॉल/सेकंड है)।',
|
|
342
348
|
data: 'दिए गए परिणाम कहाँ सहेजें।',
|
|
343
349
|
x402: 'प्रति कॉल शुल्क (वैकल्पिक)। खाली छोड़ें तो आपका एजेंट केवल एस्क्रो वाले काम लेगा।\nकीमत प्रति अनुरोध है, EIP-2612 टोकन में — MON नहीं चल सकता, इस योजना को `permit` चाहिए।',
|
|
350
|
+
subcontrata: "सबकॉन्ट्रैक्टिंग (वैकल्पिक, डिफ़ॉल्ट रूप से बंद)। आपका एजेंट जो खुद नहीं कर सकता, उसके लिए दूसरे को भुगतान कर सकता है (agent.ts में ctx.consultar देखें)। यहाँ संख्या के बिना वह कभी नहीं सौंपता।\nयह x402 की मुद्रा में है, प्रति कार्य आय का हिस्सा नहीं: कार्य MON में और प्रश्न $PANAL में चुकाया जाता है।",
|
|
351
|
+
vigilante: "प्रहरी। हर 60 सेकंड में आपके खुले कार्यों की जाँच करता है और जो अटक गया उसे वापस लाता है: वह डिलीवरी जो चेन पर दर्ज नहीं हुई, अधूरा रह गया काम, या भुगतान किया गया आदेश जो कभी नहीं पहुँचा।\nVIGILANTE=off इसे बंद करता है। PUBLIC_URL आपका https एंडपॉइंट है।",
|
|
344
352
|
},
|
|
345
353
|
readme: `# {name}
|
|
346
354
|
|
|
@@ -431,6 +439,8 @@ const ar = {
|
|
|
431
439
|
rpc: 'عقدة RPC خاصة بك، إن لم تكفِ العامة (حدّها نحو 15 طلبًا في الثانية).',
|
|
432
440
|
data: 'مكان حفظ النتائج المُسلَّمة.',
|
|
433
441
|
x402: 'التحصيل لكل استدعاء (اختياري). اتركه فارغًا فيقبل وكيلك مهام الضمان فقط.\nالسعر لكل طلب، بعملة تدعم EIP-2612 — لا يصلح MON، فالمخطط يحتاج `permit`.',
|
|
442
|
+
subcontrata: "التعاقد من الباطن (اختياري، معطل افتراضيا). يمكن لوكيلك ان يدفع لوكيل اخر مقابل ما لا يجيده (انظر ctx.consultar في agent.ts). بدون رقم هنا لن يفوض ابدا.\nيكون بعملة x402، وليس نسبة مما تتقاضاه عن المهمة: المهمة تدفع بـ MON والسؤال بـ $PANAL.",
|
|
443
|
+
vigilante: "الحارس. كل 60 ثانية يراجع مهامك المفتوحة ويستعيد ما تعطل: تسليم لم يثبت على السلسلة، او عمل مات في منتصفه، او طلب مدفوع لم يصل قط.\nVIGILANTE=off يوقفه. PUBLIC_URL هو نقطة الوصول https لديك.",
|
|
434
444
|
},
|
|
435
445
|
readme: `# {name}
|
|
436
446
|
|
|
@@ -520,6 +530,8 @@ La langue vient de --lang, puis de PANAL_LANG, puis de la locale du système.`,
|
|
|
520
530
|
rpc: 'Votre propre RPC, si le public ne suffit pas (limité à ~15 appels/s).',
|
|
521
531
|
data: 'Où stocker les résultats livrés.',
|
|
522
532
|
x402: "Facturation à l'appel (facultatif). Laissez vide et votre agent ne prend que des missions sous entiercement.\nLe prix est par requête, dans un jeton EIP-2612 : pas de MON, le schéma exige `permit`.",
|
|
533
|
+
subcontrata: "Sous-traitance (optionnelle, desactivee par defaut). Votre agent peut payer un autre pour ce qu il ne sait pas faire (voir ctx.consultar dans agent.ts). Sans un nombre ici, il ne delegue jamais.\nC est dans la devise x402, PAS une part de ce que vous facturez par tache : une tache se paie en MON et une question en $PANAL.",
|
|
534
|
+
vigilante: "La sentinelle. Toutes les 60 s, elle passe en revue vos taches ouvertes et recupere ce qui est reste bloque : une livraison jamais ancree, un travail mort a mi-chemin, ou une commande payee qui n est jamais arrivee.\nVIGILANTE=off la desactive. PUBLIC_URL est votre endpoint https.",
|
|
523
535
|
},
|
|
524
536
|
readme: `# {name}
|
|
525
537
|
|
|
@@ -613,6 +625,8 @@ O idioma vem de --lang, depois de PANAL_LANG e, por fim, do locale do sistema.`,
|
|
|
613
625
|
rpc: 'RPC próprio, se o público não chegar (limita a ~15 chamadas/s).',
|
|
614
626
|
data: 'Onde guardar os resultados entregues.',
|
|
615
627
|
x402: 'Cobrança por chamada (opcional). Deixe vazio e o seu agente só aceita trabalhos do escrow.\nO preço é por pedido, num token EIP-2612: não pode ser MON, o esquema precisa de `permit`.',
|
|
628
|
+
subcontrata: "Subcontratar (opcional, desligado por omissao). O seu agente pode pagar a outro pelo que nao sabe fazer (ver ctx.consultar em agent.ts). Sem um numero aqui, nunca delega.\nVai na moeda do x402 e NAO e uma fatia do que cobra por tarefa: uma tarefa paga-se em MON e uma pergunta em $PANAL.",
|
|
629
|
+
vigilante: "O vigia. A cada 60 s reve as suas tarefas abertas e recupera o que ficou preso: uma entrega que nunca foi ancorada, trabalho que morreu a meio, ou uma encomenda paga que nunca chegou.\nVIGILANTE=off desliga-o. PUBLIC_URL e o seu endpoint https.",
|
|
616
630
|
},
|
|
617
631
|
readme: `# {name}
|
|
618
632
|
|
|
@@ -704,6 +718,8 @@ const ru = {
|
|
|
704
718
|
rpc: 'Свой RPC, если публичного не хватает (лимит ~15 запросов/с).',
|
|
705
719
|
data: 'Где хранить сданные результаты.',
|
|
706
720
|
x402: 'Плата за вызов (необязательно). Оставьте пустым — агент будет брать только заказы через эскроу.\nЦена за один запрос, в токене с EIP-2612: MON не подходит, схеме нужен `permit`.',
|
|
721
|
+
subcontrata: "Субподряд (необязательно, по умолчанию выключен). Ваш агент может заплатить другому за то, чего не умеет сам (см. ctx.consultar в agent.ts). Без числа здесь он никогда не делегирует.\nУказывается в валюте x402, а НЕ долей от оплаты за задачу: задача оплачивается в MON, а вопрос в $PANAL.",
|
|
722
|
+
vigilante: "Сторож. Каждые 60 с проверяет ваши открытые задачи и вытаскивает то, что застряло: доставку, которая не попала в блокчейн, работу, оборвавшуюся на середине, или оплаченный заказ, который так и не пришел.\nVIGILANTE=off выключает его. PUBLIC_URL ваш https-эндпоинт.",
|
|
707
723
|
},
|
|
708
724
|
readme: `# {name}
|
|
709
725
|
|
|
@@ -796,6 +812,8 @@ const bn = {
|
|
|
796
812
|
rpc: 'নিজস্ব RPC, যদি সর্বজনীনটি যথেষ্ট না হয় (সীমা প্রায় ১৫ কল/সেকেন্ড)।',
|
|
797
813
|
data: 'সরবরাহ করা ফলাফল কোথায় রাখা হবে।',
|
|
798
814
|
x402: 'প্রতি কলে চার্জ (ঐচ্ছিক)। ফাঁকা রাখলে আপনার এজেন্ট কেবল এসক্রো কাজ নেবে।\nদাম প্রতি অনুরোধে, EIP-2612 টোকেনে — MON চলবে না, স্কিমটির `permit` দরকার।',
|
|
815
|
+
subcontrata: "সাবকন্ট্রাক্টিং (ঐচ্ছিক, ডিফল্টে বন্ধ)। আপনার এজেন্ট যা নিজে পারে না, তার জন্য অন্যকে অর্থ দিতে পারে (agent.ts-এ ctx.consultar দেখুন)। এখানে সংখ্যা না থাকলে সে কখনও দায়িত্ব দেয় না।\nএটি x402-এর মুদ্রায়, কাজপ্রতি আয়ের অংশ নয়: কাজের দাম MON-এ আর প্রশ্নের দাম $PANAL-এ।",
|
|
816
|
+
vigilante: "প্রহরী। প্রতি ৬০ সেকেন্ডে আপনার খোলা কাজগুলি দেখে এবং আটকে যাওয়া জিনিস উদ্ধার করে: চেইনে না ওঠা ডেলিভারি, মাঝপথে মরে যাওয়া কাজ, বা টাকা দেওয়া হয়েছে অথচ কখনও পৌঁছায়নি এমন আদেশ।\nVIGILANTE=off এটি বন্ধ করে। PUBLIC_URL আপনার https এন্ডপয়েন্ট।",
|
|
799
817
|
},
|
|
800
818
|
readme: `# {name}
|
|
801
819
|
|
|
@@ -885,6 +903,8 @@ const ur = {
|
|
|
885
903
|
rpc: 'اپنا RPC، اگر عوامی کم پڑ جائے (حد تقریباً 15 کالز فی سیکنڈ)۔',
|
|
886
904
|
data: 'فراہم کردہ نتائج کہاں محفوظ ہوں۔',
|
|
887
905
|
x402: 'فی کال وصولی (اختیاری)۔ خالی چھوڑ دیں تو آپ کا ایجنٹ صرف ایسکرو کے کام لے گا۔\nقیمت فی درخواست ہے، EIP-2612 ٹوکن میں — MON نہیں چل سکتا، اس اسکیم کو `permit` چاہیے۔',
|
|
906
|
+
subcontrata: "سب کنٹریکٹنگ (اختیاری، بذریعہ ڈیفالٹ بند)۔ آپ کا ایجنٹ جو خود نہیں کر سکتا اس کے لیے دوسرے کو ادائیگی کر سکتا ہے (agent.ts میں ctx.consultar دیکھیں)۔ یہاں نمبر کے بغیر وہ کبھی نہیں سونپتا۔\nیہ x402 کی کرنسی میں ہے، فی ٹاسک آمدنی کا حصہ نہیں: ٹاسک MON میں اور سوال $PANAL میں ادا ہوتا ہے۔",
|
|
907
|
+
vigilante: "نگہبان۔ ہر 60 سیکنڈ میں آپ کے کھلے کام دیکھتا ہے اور جو اٹکا ہے اسے بچاتا ہے: وہ ڈیلیوری جو چین پر درج نہ ہوئی، وہ کام جو بیچ میں مر گیا، یا ادا شدہ آرڈر جو کبھی نہ پہنچا۔\nVIGILANTE=off اسے بند کرتا ہے۔ PUBLIC_URL آپ کا https اینڈ پوائنٹ ہے۔",
|
|
888
908
|
},
|
|
889
909
|
readme: `# {name}
|
|
890
910
|
|
package/dist/index.js
CHANGED
|
@@ -169,6 +169,14 @@ function writeEnvExample(dest, t) {
|
|
|
169
169
|
'# X402_SYMBOL=$PANAL',
|
|
170
170
|
'# X402_DESCRIPTION=',
|
|
171
171
|
'',
|
|
172
|
+
bloque(t.env.subcontrata),
|
|
173
|
+
'SUBCONTRATA_MAX=',
|
|
174
|
+
'SUBCONTRATA_SALTOS=2',
|
|
175
|
+
'',
|
|
176
|
+
bloque(t.env.vigilante),
|
|
177
|
+
'VIGILANTE_SEGUNDOS=60',
|
|
178
|
+
'PUBLIC_URL=',
|
|
179
|
+
'',
|
|
172
180
|
].join('\n');
|
|
173
181
|
writeFileSync(join(dest, '.env.example'), contenido, 'utf8');
|
|
174
182
|
return contenido;
|
package/package.json
CHANGED
package/template/_env.example
CHANGED
|
@@ -1,21 +1,13 @@
|
|
|
1
|
-
#
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
#
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
LLM_API_KEY=
|
|
15
|
-
LLM_MODEL=deepseek-chat
|
|
16
|
-
|
|
17
|
-
# RPC propio, si el público se te queda corto (limita a ~15 llamadas/s).
|
|
18
|
-
RPC_URL=
|
|
19
|
-
|
|
20
|
-
# Dónde guardar los resultados entregados.
|
|
21
|
-
DATA_DIR=./data
|
|
1
|
+
# Este archivo NO es el que acaba en el proyecto generado.
|
|
2
|
+
#
|
|
3
|
+
# El `.env.example` de verdad lo escribe `writeEnvExample()` en src/index.ts, a
|
|
4
|
+
# partir del catálogo de idiomas (src/i18n.ts), porque sus comentarios van
|
|
5
|
+
# traducidos a los diez idiomas. Este de aquí solo existe porque el copiador
|
|
6
|
+
# exige encontrarlo, y se sobrescribe justo después.
|
|
7
|
+
#
|
|
8
|
+
# Si añades una variable nueva, va en DOS sitios y ninguno es este:
|
|
9
|
+
# 1. `env` del catálogo en src/i18n.ts, en los diez idiomas
|
|
10
|
+
# 2. `writeEnvExample()` en src/index.ts
|
|
11
|
+
#
|
|
12
|
+
# Documentarla aquí no la enseña a nadie: se pisa antes de que el usuario la
|
|
13
|
+
# vea. Pasó con SUBCONTRATA_MAX y con el vigilante.
|
package/template/_package.json
CHANGED
package/template/src/agent.ts
CHANGED
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
* anclado en la cadena al entregar. Si luego sirves otra cosa, se nota.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import type { CallEnvelope } from '@panal/sdk';
|
|
15
|
+
import { textoAPdf } from './pdf.js';
|
|
16
|
+
|
|
14
17
|
export interface TaskContext {
|
|
15
18
|
/**
|
|
16
19
|
* El id de la tarea en el escrow, o `null` si esto es una llamada x402: ahí
|
|
@@ -23,6 +26,41 @@ export interface TaskContext {
|
|
|
23
26
|
amount: bigint;
|
|
24
27
|
/** Fecha límite de entrega, en segundos epoch. Cero en una llamada x402. */
|
|
25
28
|
deadline: bigint;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* PREGUNTAR A OTRO AGENTE, Y PAGARLE.
|
|
32
|
+
*
|
|
33
|
+
* Busca en el mercado quién dice saber hacer eso, les pide precio —gratis—,
|
|
34
|
+
* elige al más barato que quepa en tu presupuesto, le paga y te devuelve su
|
|
35
|
+
* respuesta. Tú decides cuándo merece la pena; el motor se encarga del resto.
|
|
36
|
+
*
|
|
37
|
+
* const glosario = await ctx.consultar('traducción', 'traduce estos términos al alemán: …');
|
|
38
|
+
*
|
|
39
|
+
* Cuesta DINERO TUYO, del que acabas de cobrar. Tres cosas que conviene
|
|
40
|
+
* tener claras antes de usarlo:
|
|
41
|
+
*
|
|
42
|
+
* - Si no pusiste SUBCONTRATA_MAX en el .env, esto lanza. Es deliberado:
|
|
43
|
+
* un agente no debería empezar a gastar por defecto.
|
|
44
|
+
* - Puede lanzar aunque haya presupuesto, si la cadena que te llamó ya lo
|
|
45
|
+
* agotó o se quedó sin saltos. Heredar una cadena no amplía sus límites.
|
|
46
|
+
* - No lo captures en silencio. Que entregues algo peor porque no pudiste
|
|
47
|
+
* delegar es justo lo que necesitas ver en los logs.
|
|
48
|
+
*
|
|
49
|
+
* @param skill Qué buscas, en el idioma en que la gente escribe sus
|
|
50
|
+
* skills: 'traducción', 'legal', 'json'…
|
|
51
|
+
* @param pregunta Lo que le preguntas. Va tal cual al otro agente.
|
|
52
|
+
*/
|
|
53
|
+
consultar(skill: string, pregunta: string): Promise<string>;
|
|
54
|
+
|
|
55
|
+
/** Lo que puedes gastarte en `consultar` en este encargo. Cero = no delegas. */
|
|
56
|
+
presupuesto: bigint;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* El sobre de la cadena, si quien te llamó era otro agente. `null` si el
|
|
60
|
+
* encargo viene de una persona. Solo lo necesitas para mirarlo en los logs:
|
|
61
|
+
* `consultar` ya lo respeta por su cuenta.
|
|
62
|
+
*/
|
|
63
|
+
envelope: CallEnvelope | null;
|
|
26
64
|
}
|
|
27
65
|
|
|
28
66
|
/** Un archivo que entregas junto al texto. */
|
|
@@ -89,16 +127,20 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
|
|
|
89
127
|
);
|
|
90
128
|
}
|
|
91
129
|
|
|
130
|
+
// ¿Esto lo sé hacer yo, o me conviene preguntar? La decisión es del agente,
|
|
131
|
+
// no del cliente: él pidió un trabajo, no una arquitectura.
|
|
132
|
+
const ayuda = await pedirAyudaSiHaceFalta(brief, ctx, apiKey);
|
|
133
|
+
|
|
92
134
|
// Un intento, una revisión y una corrección. Un modelo falla el formato de
|
|
93
135
|
// vez en cuando, y aquí eso no es un mensaje feo en un chat: el hash de lo
|
|
94
136
|
// que entregues queda anclado en la cadena y ya no se puede rectificar.
|
|
95
137
|
let queja: string | null = null;
|
|
96
138
|
for (let intento = 1; intento <= 2; intento++) {
|
|
97
|
-
const texto = await pedirAlModelo(brief, apiKey, queja);
|
|
139
|
+
const texto = await pedirAlModelo(brief, apiKey, queja, ayuda);
|
|
98
140
|
const problema = revisar(brief, texto);
|
|
99
141
|
if (!problema) {
|
|
100
142
|
console.log(`[agente] ${etiqueta(ctx)} resuelta: ${texto.length} caracteres`);
|
|
101
|
-
return texto;
|
|
143
|
+
return conPdfSiLoPidio(brief, texto, ctx);
|
|
102
144
|
}
|
|
103
145
|
console.error(`[agente] ${etiqueta(ctx)} intento ${intento}: ${problema}`);
|
|
104
146
|
// A la segunda se entrega igual. Tu revisión puede equivocarse, y un falso
|
|
@@ -106,13 +148,103 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
|
|
|
106
148
|
// entregar algo imperfecto y que él decida, que dejarlo sin nada.
|
|
107
149
|
if (intento === 2) {
|
|
108
150
|
console.error(`[agente] ${etiqueta(ctx)} se entrega pese a: ${problema}`);
|
|
109
|
-
return texto;
|
|
151
|
+
return conPdfSiLoPidio(brief, texto, ctx);
|
|
110
152
|
}
|
|
111
153
|
queja = problema;
|
|
112
154
|
}
|
|
113
155
|
throw new Error('inalcanzable');
|
|
114
156
|
}
|
|
115
157
|
|
|
158
|
+
/**
|
|
159
|
+
* TU AGENTE DECIDIENDO SI SUBCONTRATA. Devuelve lo que le contestó otro, o null.
|
|
160
|
+
*
|
|
161
|
+
* Esto es lo que separa a un agente de un endpoint: mira el encargo, reconoce
|
|
162
|
+
* lo que no es suyo y va a comprarlo con su dinero. Nadie se lo ha pedido.
|
|
163
|
+
*
|
|
164
|
+
* La decisión la toma el propio modelo, en una llamada aparte y barata, con una
|
|
165
|
+
* regla incómoda de escribir y necesaria: NO delegar es la respuesta correcta
|
|
166
|
+
* casi siempre. Un agente que pregunta por todo se funde lo que cobró, y en
|
|
167
|
+
* cuanto el trabajo es suyo la respuesta de un tercero solo añade ruido.
|
|
168
|
+
*
|
|
169
|
+
* Bórralo entero si tu agente no necesita a nadie. Muchos no lo necesitan, y
|
|
170
|
+
* ese es el caso sano.
|
|
171
|
+
*/
|
|
172
|
+
async function pedirAyudaSiHaceFalta(
|
|
173
|
+
brief: string,
|
|
174
|
+
ctx: TaskContext,
|
|
175
|
+
apiKey: string,
|
|
176
|
+
): Promise<string | null> {
|
|
177
|
+
// Sin presupuesto no hay nada que decidir, y así se ahorra la llamada.
|
|
178
|
+
if (ctx.presupuesto <= 0n) return null;
|
|
179
|
+
|
|
180
|
+
const decision = await decidirDelegacion(brief, apiKey);
|
|
181
|
+
if (!decision) return null;
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const respuesta = await ctx.consultar(decision.skill, decision.pregunta);
|
|
185
|
+
console.log(`[agente] ${etiqueta(ctx)} subcontrató "${decision.skill}": ${respuesta.length} caracteres`);
|
|
186
|
+
return respuesta;
|
|
187
|
+
} catch (err) {
|
|
188
|
+
// Que no se pueda delegar NO cancela el encargo: el cliente pagó por un
|
|
189
|
+
// trabajo y lo va a recibir, hecho con lo que este agente sepa. Se deja
|
|
190
|
+
// dicho en voz alta porque es justo lo que el autor querrá ver el día que
|
|
191
|
+
// la respuesta salga más floja de lo normal.
|
|
192
|
+
console.error(
|
|
193
|
+
`[agente] ${etiqueta(ctx)} quería preguntar a un agente de "${decision.skill}" y no pudo: ` +
|
|
194
|
+
`${err instanceof Error ? err.message : err}. Sigue por su cuenta.`,
|
|
195
|
+
);
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Una llamada corta al modelo: ¿delego, y a quién? Formato JSON o nada. */
|
|
201
|
+
async function decidirDelegacion(
|
|
202
|
+
brief: string,
|
|
203
|
+
apiKey: string,
|
|
204
|
+
): Promise<{ skill: string; pregunta: string } | null> {
|
|
205
|
+
try {
|
|
206
|
+
const res = await fetch(`${process.env.LLM_BASE_URL ?? 'https://api.openai.com/v1'}/chat/completions`, {
|
|
207
|
+
method: 'POST',
|
|
208
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
|
|
209
|
+
// Corto a propósito: si decidir tarda más que trabajar, no compensa.
|
|
210
|
+
signal: AbortSignal.timeout(30_000),
|
|
211
|
+
body: JSON.stringify({
|
|
212
|
+
model: process.env.LLM_MODEL ?? 'gpt-4o-mini',
|
|
213
|
+
response_format: { type: 'json_object' },
|
|
214
|
+
messages: [
|
|
215
|
+
{
|
|
216
|
+
role: 'system',
|
|
217
|
+
content:
|
|
218
|
+
'You are an agent deciding whether to PAY another specialist agent out of your own earnings ' +
|
|
219
|
+
'to do part of a job. Answer with JSON only.\n' +
|
|
220
|
+
'{"delegate": false} if you can do the job yourself. This is the right answer almost always.\n' +
|
|
221
|
+
'{"delegate": true, "skill": "…", "question": "…"} ONLY if the job clearly needs expertise ' +
|
|
222
|
+
'outside your own, and a specialist answer would measurably improve the result.\n' +
|
|
223
|
+
'"skill" is one or two words to search a marketplace by (e.g. "translation", "legal", "json").\n' +
|
|
224
|
+
'"question" is the self-contained question for that specialist: it will be sent on its own, ' +
|
|
225
|
+
'so it must make sense without the rest of the job.\n' +
|
|
226
|
+
'Paying costs real money. If in doubt, do not delegate.',
|
|
227
|
+
},
|
|
228
|
+
{ role: 'user', content: brief },
|
|
229
|
+
],
|
|
230
|
+
}),
|
|
231
|
+
});
|
|
232
|
+
if (!res.ok) return null;
|
|
233
|
+
const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
|
|
234
|
+
const crudo = data.choices?.[0]?.message?.content;
|
|
235
|
+
if (!crudo) return null;
|
|
236
|
+
const parsed = JSON.parse(crudo) as { delegate?: boolean; skill?: string; question?: string };
|
|
237
|
+
if (parsed.delegate !== true) return null;
|
|
238
|
+
const skill = parsed.skill?.trim();
|
|
239
|
+
const pregunta = parsed.question?.trim();
|
|
240
|
+
if (!skill || !pregunta) return null;
|
|
241
|
+
return { skill, pregunta };
|
|
242
|
+
} catch {
|
|
243
|
+
// Decidir mal no puede tumbar el encargo: ante la duda, se trabaja solo.
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
116
248
|
/**
|
|
117
249
|
* TU CONTROL DE CALIDAD. Devuelve null si la respuesta vale, o el motivo si no.
|
|
118
250
|
*
|
|
@@ -144,10 +276,48 @@ function revisar(brief: string, resultado: string): string | null {
|
|
|
144
276
|
return 'la respuesta lleva Markdown (**, # o ```) y el cliente lo verá en crudo: devuélvela en texto plano';
|
|
145
277
|
}
|
|
146
278
|
|
|
279
|
+
// Si el cliente pide "y mándamelo en PDF", el modelo tiende a contestar que
|
|
280
|
+
// él no genera archivos — mientras el archivo va adjunto. Prohibírselo en el
|
|
281
|
+
// prompt reduce el problema pero no lo cierra: llegó a salir "no menciono el
|
|
282
|
+
// PDF porque no debo". Se comprueba, y se reintenta.
|
|
283
|
+
//
|
|
284
|
+
// Se busca la palabra JUNTO A una frase autorreferencial, no la palabra
|
|
285
|
+
// suelta: alguien puede encargar de verdad trabajo sobre PDFs, y ahí la
|
|
286
|
+
// palabra es el encargo.
|
|
287
|
+
const meta = /(no (puedo|incluyo|voy a|debo|menciono|se adjunt|se genera)|el sistema (se encarg|lo adjunt)|i (cannot|can't|am not)|the system will)/i;
|
|
288
|
+
for (const linea of resultado.split('\n')) {
|
|
289
|
+
if (/\b(pdf|archivo|fichero|adjunto|attachment|file)\b/i.test(linea) && meta.test(linea)) {
|
|
290
|
+
return 'has comentado que no generas archivos; el agente los adjunta solo. Reescríbelo sin ninguna referencia a archivos, PDFs ni adjuntos: solo el trabajo';
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
147
294
|
return null;
|
|
148
295
|
}
|
|
149
296
|
|
|
150
|
-
|
|
297
|
+
/**
|
|
298
|
+
* Adjunta el trabajo en PDF si el encargo lo pedía. El texto se entrega igual.
|
|
299
|
+
*
|
|
300
|
+
* Se mira el encargo en vez de mandarlo siempre: a un agente también lo llaman
|
|
301
|
+
* otros programas, y adjuntarles un archivo que no pidieron es peso muerto.
|
|
302
|
+
*
|
|
303
|
+
* Bórralo si tu agente no entrega PDFs, o cámbialo por lo que tú generes: una
|
|
304
|
+
* imagen, un CSV, un ZIP. El motor calcula el hash de lo que devuelvas aquí y
|
|
305
|
+
* lo ancla en la cadena, así que el cliente puede demostrar que el archivo que
|
|
306
|
+
* se baja es exactamente el que le entregaste.
|
|
307
|
+
*/
|
|
308
|
+
function conPdfSiLoPidio(brief: string, texto: string, ctx: TaskContext): TaskResult {
|
|
309
|
+
if (!/\bpdf\b/i.test(brief)) return texto;
|
|
310
|
+
const pdf = textoAPdf(`Panal - entrega ${etiqueta(ctx)}`, texto);
|
|
311
|
+
console.log(`[agente] ${etiqueta(ctx)} PDF de ${pdf.byteLength} bytes adjunto`);
|
|
312
|
+
return { text: texto, files: [{ name: 'entrega.pdf', data: pdf, mime: 'application/pdf' }] };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function pedirAlModelo(
|
|
316
|
+
brief: string,
|
|
317
|
+
apiKey: string,
|
|
318
|
+
queja: string | null,
|
|
319
|
+
ayuda: string | null,
|
|
320
|
+
): Promise<string> {
|
|
151
321
|
const res = await fetch(`${process.env.LLM_BASE_URL ?? 'https://api.openai.com/v1'}/chat/completions`, {
|
|
152
322
|
method: 'POST',
|
|
153
323
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
|
|
@@ -171,9 +341,26 @@ async function pedirAlModelo(brief: string, apiKey: string, queja: string | null
|
|
|
171
341
|
'You are a professional agent on the Panal marketplace. ' +
|
|
172
342
|
'RULE 1: detect the language of the request and reply in that exact same language; never switch. ' +
|
|
173
343
|
'RULE 2: plain text only, never Markdown — no # headings, no ** bold, no backticks. ' +
|
|
174
|
-
'RULE 3: deliver finished professional work, with no preamble or meta-commentary
|
|
344
|
+
'RULE 3: deliver finished professional work, with no preamble or meta-commentary.\n' +
|
|
345
|
+
// El agente adjunta el archivo por su cuenta; el modelo no se entera
|
|
346
|
+
// y, sin esta regla, se disculpa por no poder generarlo.
|
|
347
|
+
'RULE 4: the client may ask for the result as a PDF or a file. The agent attaches it after ' +
|
|
348
|
+
'you answer. Never mention files, PDFs or attachments — not even to say you cannot make them.',
|
|
175
349
|
},
|
|
176
350
|
{ role: 'user', content: brief },
|
|
351
|
+
// Lo que contestó el especialista, si se le preguntó. Va marcado como
|
|
352
|
+
// material de apoyo y no como parte del encargo: sin esa aclaración el
|
|
353
|
+
// modelo tiende a copiarlo tal cual y a entregar la respuesta de otro.
|
|
354
|
+
...(ayuda
|
|
355
|
+
? [
|
|
356
|
+
{
|
|
357
|
+
role: 'user' as const,
|
|
358
|
+
content:
|
|
359
|
+
'Material de apoyo, pagado a un agente especialista. Úsalo si ayuda y descártalo si no; ' +
|
|
360
|
+
`no lo copies tal cual ni lo menciones en la entrega:\n\n${ayuda}`,
|
|
361
|
+
},
|
|
362
|
+
]
|
|
363
|
+
: []),
|
|
177
364
|
// La corrección va como un mensaje más: decirle QUÉ falló acierta mucho
|
|
178
365
|
// más que repetirle la misma petición a ciegas esperando otra suerte.
|
|
179
366
|
...(queja ? [{ role: 'user' as const, content: `Tu respuesta anterior no vale: ${queja}. Corrígela.` }] : []),
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Un PDF de verdad, sin dependencias. Puedes borrar este archivo si tu agente
|
|
3
|
+
* no entrega PDFs.
|
|
4
|
+
*
|
|
5
|
+
* No es una plantilla ni un HTML impreso: se escriben los objetos del PDF a
|
|
6
|
+
* mano, con su tabla xref y sus offsets en bytes. Sale un archivo que abre
|
|
7
|
+
* cualquier lector, y no añade ni un paquete a tus dependencias — meter una
|
|
8
|
+
* librería de 4 MB para pintar texto monoespaciado en un A4 no sale a cuenta.
|
|
9
|
+
*
|
|
10
|
+
* Se usa desde `agent.ts`:
|
|
11
|
+
*
|
|
12
|
+
* const pdf = textoAPdf('Mi informe', texto);
|
|
13
|
+
* return { text: texto, files: [{ name: 'informe.pdf', data: pdf, mime: 'application/pdf' }] };
|
|
14
|
+
*
|
|
15
|
+
* El motor calcula su hash y lo ancla en la cadena; tú no tocas nada de eso.
|
|
16
|
+
*
|
|
17
|
+
* Lo que hace bien y cuesta acertar a mano: parte las líneas largas para que no
|
|
18
|
+
* se salgan del papel, pagina solo, y traduce los símbolos que la codificación
|
|
19
|
+
* del PDF no tiene en vez de destrozarlos en silencio.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** A4 en puntos, que es la unidad del PDF. */
|
|
23
|
+
const ANCHO = 595;
|
|
24
|
+
const ALTO = 842;
|
|
25
|
+
const MARGEN = 50;
|
|
26
|
+
const CUERPO = 9.5;
|
|
27
|
+
const INTERLINEA = 12.5;
|
|
28
|
+
/** Cuántas líneas caben en una página con estos márgenes. */
|
|
29
|
+
const LINEAS_POR_PAGINA = Math.floor((ALTO - MARGEN * 2) / INTERLINEA);
|
|
30
|
+
/** Ancho de caracteres a 9.5pt en Courier: 0.6 em, redondeado a la baja. */
|
|
31
|
+
const COLUMNAS = Math.floor((ANCHO - MARGEN * 2) / (CUERPO * 0.6));
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Escapa un texto para meterlo entre paréntesis en un PDF.
|
|
35
|
+
*
|
|
36
|
+
* Los paréntesis delimitan las cadenas, así que uno sin escapar rompe el
|
|
37
|
+
* archivo entero — y un JSON viene lleno de ellos.
|
|
38
|
+
*/
|
|
39
|
+
function escapar(texto: string): string {
|
|
40
|
+
return texto.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parte las líneas largas para que no se salgan del papel.
|
|
45
|
+
*
|
|
46
|
+
* Un PDF no ajusta el texto solo: lo que no cabe, sencillamente no se ve. Con
|
|
47
|
+
* un JSON de una sola línea eso significa entregar una hoja casi en blanco.
|
|
48
|
+
*/
|
|
49
|
+
function ajustar(lineas: string[]): string[] {
|
|
50
|
+
const out: string[] = [];
|
|
51
|
+
for (const linea of lineas) {
|
|
52
|
+
if (linea.length <= COLUMNAS) {
|
|
53
|
+
out.push(linea);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// Se conserva la sangría al partir: en un JSON es lo que deja ver la
|
|
57
|
+
// estructura, y sin ella el corte lo vuelve ilegible.
|
|
58
|
+
const sangria = /^\s*/.exec(linea)![0].slice(0, 20);
|
|
59
|
+
let resto = linea;
|
|
60
|
+
let primera = true;
|
|
61
|
+
while (resto.length > 0) {
|
|
62
|
+
const ancho = primera ? COLUMNAS : COLUMNAS - sangria.length;
|
|
63
|
+
out.push((primera ? '' : sangria) + resto.slice(0, ancho));
|
|
64
|
+
resto = resto.slice(ancho);
|
|
65
|
+
primera = false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Sustitutos ASCII de los símbolos que WinAnsiEncoding no tiene.
|
|
73
|
+
*
|
|
74
|
+
* Sin esto, `Buffer.from(txt, 'latin1')` los recorta al byte bajo y salen
|
|
75
|
+
* caracteres que no significan nada: un "≠" acababa impreso como "`", así que
|
|
76
|
+
* un caso de prueba que decía "b ≠ 0" pasaba a decir "b ` 0". Silencioso, y
|
|
77
|
+
* dentro de un entregable que se cobra.
|
|
78
|
+
*/
|
|
79
|
+
const SUSTITUTOS: Record<string, string> = {
|
|
80
|
+
'≠': '!=', '≤': '<=', '≥': '>=', '≈': '~=', '±': '+/-', '×': 'x', '÷': '/',
|
|
81
|
+
'→': '->', '←': '<-', '⇒': '=>', '∞': 'infinito', '∅': 'vacio',
|
|
82
|
+
'“': '"', '”': '"', '„': '"', '‘': "'", '’': "'", '‹': '<', '›': '>',
|
|
83
|
+
'–': '-', '—': '-', '…': '...', '•': '-', '·': '·', '™': '(TM)', '€': 'EUR',
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Latin-1, que es lo que entiende WinAnsiEncoding —la codificación de las
|
|
88
|
+
* fuentes base del PDF—, sustituyendo antes lo que no cabe.
|
|
89
|
+
*
|
|
90
|
+
* Lo que no tiene sustituto se marca con "?" a propósito: un interrogante
|
|
91
|
+
* avisa de que ahí faltaba algo; un carácter aleatorio miente.
|
|
92
|
+
*/
|
|
93
|
+
function aLatin1(texto: string): Buffer {
|
|
94
|
+
const convertido = [...texto]
|
|
95
|
+
.map((c) => {
|
|
96
|
+
if (SUSTITUTOS[c]) return SUSTITUTOS[c];
|
|
97
|
+
return c.codePointAt(0)! <= 0xff ? c : '?';
|
|
98
|
+
})
|
|
99
|
+
.join('');
|
|
100
|
+
return Buffer.from(convertido, 'latin1');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Construye el PDF. Devuelve los bytes, listos para escribir o entregar. */
|
|
104
|
+
export function textoAPdf(titulo: string, contenido: string): Uint8Array {
|
|
105
|
+
const lineas = ajustar([titulo, '', ...contenido.split('\n')]);
|
|
106
|
+
|
|
107
|
+
// Se reparte en páginas antes de escribir nada: hay que saber cuántas son
|
|
108
|
+
// para numerar los objetos, y en un PDF los objetos se referencian por número.
|
|
109
|
+
const paginas: string[][] = [];
|
|
110
|
+
for (let i = 0; i < lineas.length; i += LINEAS_POR_PAGINA) {
|
|
111
|
+
paginas.push(lineas.slice(i, i + LINEAS_POR_PAGINA));
|
|
112
|
+
}
|
|
113
|
+
if (paginas.length === 0) paginas.push(['(sin contenido)']);
|
|
114
|
+
|
|
115
|
+
// Numeración: 1 catálogo, 2 árbol de páginas, 3 fuente, y luego cada página
|
|
116
|
+
// con su flujo de contenido, dos objetos por página.
|
|
117
|
+
const FUENTE = 3;
|
|
118
|
+
const primeraPagina = 4;
|
|
119
|
+
const idPagina = (i: number) => primeraPagina + i * 2;
|
|
120
|
+
const idContenido = (i: number) => primeraPagina + i * 2 + 1;
|
|
121
|
+
|
|
122
|
+
const objetos: Buffer[] = [];
|
|
123
|
+
const add = (n: number, cuerpo: string | Buffer) => {
|
|
124
|
+
objetos[n] = Buffer.concat([
|
|
125
|
+
aLatin1(`${n} 0 obj\n`),
|
|
126
|
+
typeof cuerpo === 'string' ? aLatin1(cuerpo) : cuerpo,
|
|
127
|
+
aLatin1('\nendobj\n'),
|
|
128
|
+
]);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const kids = paginas.map((_, i) => `${idPagina(i)} 0 R`).join(' ');
|
|
132
|
+
add(1, '<< /Type /Catalog /Pages 2 0 R >>');
|
|
133
|
+
add(2, `<< /Type /Pages /Kids [${kids}] /Count ${paginas.length} >>`);
|
|
134
|
+
add(FUENTE, '<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>');
|
|
135
|
+
|
|
136
|
+
paginas.forEach((lineasPagina, i) => {
|
|
137
|
+
add(
|
|
138
|
+
idPagina(i),
|
|
139
|
+
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${ANCHO} ${ALTO}] ` +
|
|
140
|
+
`/Resources << /Font << /F1 ${FUENTE} 0 R >> >> /Contents ${idContenido(i)} 0 R >>`,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const flujo = aLatin1(
|
|
144
|
+
[
|
|
145
|
+
'BT',
|
|
146
|
+
`/F1 ${CUERPO} Tf`,
|
|
147
|
+
`${INTERLINEA} TL`,
|
|
148
|
+
`${MARGEN} ${ALTO - MARGEN} Td`,
|
|
149
|
+
...lineasPagina.map((l) => `(${escapar(l)}) Tj T*`),
|
|
150
|
+
'ET',
|
|
151
|
+
].join('\n'),
|
|
152
|
+
);
|
|
153
|
+
// /Length va en BYTES, no en caracteres: con acentos no es lo mismo, y un
|
|
154
|
+
// lector estricto rechaza el archivo si no cuadra.
|
|
155
|
+
add(idContenido(i), Buffer.concat([aLatin1(`<< /Length ${flujo.length} >>\nstream\n`), flujo, aLatin1('\nendstream')]));
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Ensamblado: hay que ir apuntando el offset en bytes de cada objeto, porque
|
|
159
|
+
// la tabla xref del final los indexa por posición absoluta en el archivo.
|
|
160
|
+
const total = objetos.length - 1;
|
|
161
|
+
const partes: Buffer[] = [aLatin1('%PDF-1.4\n')];
|
|
162
|
+
const offsets: number[] = [];
|
|
163
|
+
let cursor = partes[0]!.length;
|
|
164
|
+
|
|
165
|
+
for (let n = 1; n <= total; n++) {
|
|
166
|
+
offsets[n] = cursor;
|
|
167
|
+
partes.push(objetos[n]!);
|
|
168
|
+
cursor += objetos[n]!.length;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const xref = [
|
|
172
|
+
'xref',
|
|
173
|
+
`0 ${total + 1}`,
|
|
174
|
+
'0000000000 65535 f ',
|
|
175
|
+
...Array.from({ length: total }, (_, i) => `${String(offsets[i + 1]).padStart(10, '0')} 00000 n `),
|
|
176
|
+
].join('\n');
|
|
177
|
+
|
|
178
|
+
partes.push(aLatin1(`${xref}\ntrailer\n<< /Size ${total + 1} /Root 1 0 R >>\nstartxref\n${cursor}\n%%EOF\n`));
|
|
179
|
+
return new Uint8Array(Buffer.concat(partes));
|
|
180
|
+
}
|
package/template/src/server.ts
CHANGED
|
@@ -26,15 +26,19 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
26
26
|
import { join } from 'node:path';
|
|
27
27
|
import {
|
|
28
28
|
appendFilesManifest,
|
|
29
|
+
assertCanServe,
|
|
29
30
|
buildQuote,
|
|
30
31
|
createPanalClient,
|
|
32
|
+
LoopDetected,
|
|
31
33
|
MAINNET_ADDRESSES,
|
|
34
|
+
parseEnvelope,
|
|
32
35
|
parsePaymentHeader,
|
|
33
36
|
permitNonce,
|
|
34
37
|
readPermitDomain,
|
|
35
38
|
sanitizeFileName,
|
|
36
39
|
TaskStatus,
|
|
37
40
|
verifyAndSettle,
|
|
41
|
+
type CallEnvelope,
|
|
38
42
|
type DeliveredFile,
|
|
39
43
|
type PermitDomain,
|
|
40
44
|
} from '@panal/sdk';
|
|
@@ -42,7 +46,8 @@ import { privateKeyToAccount } from 'viem/accounts';
|
|
|
42
46
|
import { isAddress, keccak256, parseEther, toBytes, verifyMessage } from 'viem';
|
|
43
47
|
import type { Address } from 'viem';
|
|
44
48
|
import { handleTask } from './agent.js';
|
|
45
|
-
import type { TaskFile, TaskResult } from './agent.js';
|
|
49
|
+
import type { TaskContext, TaskFile, TaskResult } from './agent.js';
|
|
50
|
+
import { arrancarVigilante } from './vigilante.js';
|
|
46
51
|
|
|
47
52
|
const PORT = Number(process.env.PORT ?? 8787);
|
|
48
53
|
const DATA_DIR = process.env.DATA_DIR ?? './data';
|
|
@@ -98,6 +103,43 @@ if (X402_PRICE !== null) {
|
|
|
98
103
|
console.log(`Cobro por llamada activo: ${process.env.X402_PRICE} ${X402_SYMBOL} en POST /x402/ask`);
|
|
99
104
|
}
|
|
100
105
|
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// SUBCONTRATAR: lo que tu agente puede gastarse en preguntar a otros
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
//
|
|
110
|
+
// Tu agente puede pagar a otro por lo que no sepa hacer (ver `ctx.consultar` en
|
|
111
|
+
// agent.ts). Eso es dinero suyo saliendo, así que necesita un tope, y el tope
|
|
112
|
+
// se pone AQUÍ y no en el prompt: un prompt se negocia, un número no.
|
|
113
|
+
//
|
|
114
|
+
// Va en la moneda del x402 —$PANAL por defecto— y NO se deduce de lo que cobras
|
|
115
|
+
// por la tarea. Es tentador decir "que gaste como mucho el 30 % de lo que le
|
|
116
|
+
// pagan", pero una tarea se cobra en MON y una consulta se paga en $PANAL: son
|
|
117
|
+
// monedas distintas sin tipo de cambio, y convertir una en otra a ojo sería
|
|
118
|
+
// inventarse el presupuesto. Si no pones nada, tu agente no delega.
|
|
119
|
+
//
|
|
120
|
+
// SUBCONTRATA_MAX=0.5 # como mucho 0,5 $PANAL por encargo
|
|
121
|
+
// SUBCONTRATA_SALTOS=2 # cuántos agentes puede encadenar (tope duro: 8)
|
|
122
|
+
//
|
|
123
|
+
const SUBCONTRATA_MAX = (() => {
|
|
124
|
+
const raw = process.env.SUBCONTRATA_MAX?.trim();
|
|
125
|
+
if (!raw) return 0n;
|
|
126
|
+
try {
|
|
127
|
+
const wei = parseEther(raw);
|
|
128
|
+
return wei > 0n ? wei : 0n;
|
|
129
|
+
} catch {
|
|
130
|
+
console.error(`SUBCONTRATA_MAX="${raw}" no es un número válido: tu agente no subcontratará.`);
|
|
131
|
+
return 0n;
|
|
132
|
+
}
|
|
133
|
+
})();
|
|
134
|
+
const SUBCONTRATA_SALTOS = (() => {
|
|
135
|
+
const n = Number(process.env.SUBCONTRATA_SALTOS?.trim() || '2');
|
|
136
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 2;
|
|
137
|
+
})();
|
|
138
|
+
|
|
139
|
+
if (SUBCONTRATA_MAX > 0n) {
|
|
140
|
+
console.log(`Subcontratación activa: hasta ${process.env.SUBCONTRATA_MAX} ${X402_SYMBOL} por encargo`);
|
|
141
|
+
}
|
|
142
|
+
|
|
101
143
|
/**
|
|
102
144
|
* El dominio EIP-712 del token, leído de la cadena una sola vez.
|
|
103
145
|
*
|
|
@@ -131,6 +173,32 @@ function loadResult(taskId: bigint): string | null {
|
|
|
131
173
|
}
|
|
132
174
|
}
|
|
133
175
|
|
|
176
|
+
/**
|
|
177
|
+
* El encargo recibido, guardado en cuanto llega y antes de trabajar.
|
|
178
|
+
*
|
|
179
|
+
* No es un caché: es lo único que permite retomar una tarea si el proceso se
|
|
180
|
+
* muere a mitad. El escrow guarda `keccak256(encargo)`, no el encargo, así que
|
|
181
|
+
* si no lo guardas tú aquí, un reinicio lo pierde para siempre y la tarea se
|
|
182
|
+
* queda abierta con el dinero del cliente dentro hasta que vence el plazo.
|
|
183
|
+
*/
|
|
184
|
+
const briefPath = (taskId: bigint) => join(DATA_DIR, `brief-${taskId}.txt`);
|
|
185
|
+
function saveBrief(taskId: bigint, text: string): void {
|
|
186
|
+
try {
|
|
187
|
+
writeFileSync(briefPath(taskId), text, 'utf8');
|
|
188
|
+
} catch (err) {
|
|
189
|
+
// No se aborta: perder la copia solo cuesta no poder retomar. Trabajar
|
|
190
|
+
// ahora mismo sigue siendo posible, y es lo que el cliente está esperando.
|
|
191
|
+
console.error(`[panal] #${taskId} no se pudo guardar el encargo: ${err instanceof Error ? err.message : err}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function loadBrief(taskId: bigint): string | null {
|
|
195
|
+
try {
|
|
196
|
+
return readFileSync(briefPath(taskId), 'utf8');
|
|
197
|
+
} catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
134
202
|
/**
|
|
135
203
|
* Guarda en disco los archivos de una entrega y devuelve su manifiesto.
|
|
136
204
|
*
|
|
@@ -194,18 +262,71 @@ async function signedBy(message: string, signature: string, expected: Address):
|
|
|
194
262
|
// El trabajo
|
|
195
263
|
// ---------------------------------------------------------------------------
|
|
196
264
|
|
|
197
|
-
|
|
265
|
+
/**
|
|
266
|
+
* Monta el contexto que recibe tu `handleTask`, con la capacidad de delegar.
|
|
267
|
+
*
|
|
268
|
+
* `consultar` es lo que convierte a tu agente en cliente de otro: busca en el
|
|
269
|
+
* mercado quién sabe hacer eso, pide precio a los candidatos —gratis, con el
|
|
270
|
+
* 402—, se queda con el más barato que quepa en el presupuesto, le paga y
|
|
271
|
+
* devuelve su respuesta.
|
|
272
|
+
*
|
|
273
|
+
* Los tres límites se aplican antes de firmar ningún pago:
|
|
274
|
+
*
|
|
275
|
+
* - PRESUPUESTO. Nunca más de SUBCONTRATA_MAX, y si esta llamada viene de
|
|
276
|
+
* otro agente, nunca más de lo que quede en el sobre. Heredar una cadena
|
|
277
|
+
* no puede AMPLIAR lo que autorizó quien la empezó.
|
|
278
|
+
* - PROFUNDIDAD. Cada salto gasta uno. Al llegar a cero hay que resolver solo.
|
|
279
|
+
* - CICLOS. Si tu agente ya aparece en el camino, se corta: A→B→C→A daría
|
|
280
|
+
* vueltas cobrando en cada una.
|
|
281
|
+
*
|
|
282
|
+
* Si algo de eso falta, `consultar` lanza. No lo captures en silencio: que tu
|
|
283
|
+
* agente entregue algo peor porque no pudo delegar es información que el autor
|
|
284
|
+
* necesita ver en los logs.
|
|
285
|
+
*/
|
|
286
|
+
function contexto(
|
|
287
|
+
base: { taskId: bigint | null; client: string; amount: bigint; deadline: bigint },
|
|
288
|
+
sobre: CallEnvelope | null,
|
|
289
|
+
): TaskContext {
|
|
290
|
+
return {
|
|
291
|
+
...base,
|
|
292
|
+
envelope: sobre,
|
|
293
|
+
presupuesto: SUBCONTRATA_MAX,
|
|
294
|
+
consultar: async (skill: string, pregunta: string) => {
|
|
295
|
+
if (SUBCONTRATA_MAX <= 0n) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
'Este agente no tiene presupuesto para subcontratar: pon SUBCONTRATA_MAX en el .env si quieres que delegue.',
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
const res = await panal.ask(skill, pregunta, {
|
|
301
|
+
maxSpend: SUBCONTRATA_MAX,
|
|
302
|
+
depth: SUBCONTRATA_SALTOS,
|
|
303
|
+
// El sobre recibido, si lo hay. Sin él se abre una cadena nueva.
|
|
304
|
+
envelope: sobre,
|
|
305
|
+
// Sin esto un agente que busca su propia skill se contrataría a sí
|
|
306
|
+
// mismo, se pagaría a sí mismo y se quedaría esperando su respuesta.
|
|
307
|
+
exclude: [account.address],
|
|
308
|
+
});
|
|
309
|
+
console.log(
|
|
310
|
+
`[panal] consulta a ${res.agent} por ${res.paid} (${skill}) · trace ${sobre?.trace ?? 'nuevo'}`,
|
|
311
|
+
);
|
|
312
|
+
return res.answer;
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function work(taskId: bigint, brief: string, sobre: CallEnvelope | null): Promise<void> {
|
|
198
318
|
const key = taskId.toString();
|
|
199
319
|
if (inFlight.has(key)) return;
|
|
200
320
|
inFlight.add(key);
|
|
201
321
|
try {
|
|
322
|
+
// Lo PRIMERO, antes de trabajar: si el proceso muere a mitad, esto es lo
|
|
323
|
+
// único que permite retomarlo. Guardarlo después sería guardarlo nunca.
|
|
324
|
+
saveBrief(taskId, brief);
|
|
202
325
|
const task = await panal.getTask(taskId);
|
|
203
|
-
const salida = await handleTask(
|
|
204
|
-
|
|
205
|
-
client: task.client,
|
|
206
|
-
|
|
207
|
-
deadline: task.deadline,
|
|
208
|
-
});
|
|
326
|
+
const salida = await handleTask(
|
|
327
|
+
brief,
|
|
328
|
+
contexto({ taskId, client: task.client, amount: task.amount, deadline: task.deadline }, sobre),
|
|
329
|
+
);
|
|
209
330
|
|
|
210
331
|
// Tu handleTask puede devolver un texto a secas —lo normal— o un texto con
|
|
211
332
|
// archivos. Los archivos se escriben en disco y su hash se cuela en el
|
|
@@ -378,6 +499,22 @@ const server = createServer((req, res) => {
|
|
|
378
499
|
return;
|
|
379
500
|
}
|
|
380
501
|
|
|
502
|
+
// El sobre, antes que nada. Cortar el ciclo aquí importa más que en el
|
|
503
|
+
// escrow: en x402 el cobro va ANTES de trabajar, así que una vuelta de
|
|
504
|
+
// más no es tiempo perdido, es dinero cobrado por dar vueltas. Y va
|
|
505
|
+
// antes del 402 a propósito: si la cadena está viciada, ni se cotiza.
|
|
506
|
+
const sobre = parseEnvelope(req.headers);
|
|
507
|
+
try {
|
|
508
|
+
assertCanServe(sobre, account.address);
|
|
509
|
+
} catch (err) {
|
|
510
|
+
if (err instanceof LoopDetected) {
|
|
511
|
+
console.error(`[x402] ciclo cortado: ${err.message}`);
|
|
512
|
+
json(res, 508, { error: err.message, trace: err.trace });
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
throw err;
|
|
516
|
+
}
|
|
517
|
+
|
|
381
518
|
const domain = await dominioPermit();
|
|
382
519
|
const pagoCrudo = req.headers['x-payment'];
|
|
383
520
|
|
|
@@ -431,12 +568,10 @@ const server = createServer((req, res) => {
|
|
|
431
568
|
// Ya está cobrado: pase lo que pase a partir de aquí, hay que responder
|
|
432
569
|
// algo. Si el modelo revienta, se dice; callarse sería quedarse el dinero.
|
|
433
570
|
try {
|
|
434
|
-
const salida = await handleTask(
|
|
435
|
-
|
|
436
|
-
client: leido.payment.payer,
|
|
437
|
-
|
|
438
|
-
deadline: 0n,
|
|
439
|
-
});
|
|
571
|
+
const salida = await handleTask(
|
|
572
|
+
prompt,
|
|
573
|
+
contexto({ taskId: null, client: leido.payment.payer, amount: cobro.amount, deadline: 0n }, sobre),
|
|
574
|
+
);
|
|
440
575
|
// En una llamada x402 no hay tarea, así que no hay nada que anclar ni
|
|
441
576
|
// ninguna firma con la que proteger una descarga: los archivos no
|
|
442
577
|
// tienen dónde agarrarse. Se responde el texto y se avisa en el log en
|
|
@@ -489,6 +624,23 @@ const server = createServer((req, res) => {
|
|
|
489
624
|
return;
|
|
490
625
|
}
|
|
491
626
|
const taskId = BigInt(idCrudo);
|
|
627
|
+
|
|
628
|
+
// El sobre de la cadena, si este encargo viene de otro agente. Se mira
|
|
629
|
+
// ANTES de leer la tarea: si es un ciclo, hasta el eth_call sobra.
|
|
630
|
+
const sobre = parseEnvelope(req.headers);
|
|
631
|
+
try {
|
|
632
|
+
assertCanServe(sobre, account.address);
|
|
633
|
+
} catch (err) {
|
|
634
|
+
if (err instanceof LoopDetected) {
|
|
635
|
+
// 508 Loop Detected. Existe para esto exactamente, y decirlo con el
|
|
636
|
+
// código correcto deja que quien llama lo distinga de un fallo suyo.
|
|
637
|
+
console.error(`[panal] ciclo cortado en #${taskId}: ${err.message}`);
|
|
638
|
+
json(res, 508, { error: err.message, trace: err.trace });
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
throw err;
|
|
642
|
+
}
|
|
643
|
+
|
|
492
644
|
const task = await panal.getTask(taskId);
|
|
493
645
|
|
|
494
646
|
// Cuatro comprobaciones, y las cuatro importan: que la tarea sea tuya,
|
|
@@ -526,7 +678,7 @@ const server = createServer((req, res) => {
|
|
|
526
678
|
|
|
527
679
|
json(res, 202, { ok: true });
|
|
528
680
|
// Sin await: el cliente no debería esperar a que termines de trabajar.
|
|
529
|
-
void work(taskId, body.brief);
|
|
681
|
+
void work(taskId, body.brief, sobre);
|
|
530
682
|
return;
|
|
531
683
|
}
|
|
532
684
|
|
|
@@ -621,3 +773,28 @@ const server = createServer((req, res) => {
|
|
|
621
773
|
});
|
|
622
774
|
|
|
623
775
|
server.listen(PORT);
|
|
776
|
+
|
|
777
|
+
// El vigilante. Va DESPUÉS de escuchar: su primer repaso puede tardar unos
|
|
778
|
+
// segundos contra el RPC, y durante ese rato el agente ya tiene que estar
|
|
779
|
+
// atendiendo peticiones normales.
|
|
780
|
+
arrancarVigilante({
|
|
781
|
+
panal,
|
|
782
|
+
yo: account.address,
|
|
783
|
+
dataDir: DATA_DIR,
|
|
784
|
+
briefGuardado: loadBrief,
|
|
785
|
+
// La misma guarda que usa work(): una sola fuente de verdad sobre qué se
|
|
786
|
+
// está trabajando ahora mismo.
|
|
787
|
+
enCurso: (taskId) => inFlight.has(taskId.toString()),
|
|
788
|
+
resultadoGuardado: loadResult,
|
|
789
|
+
// Retomar un trabajo a medias es exactamente lo mismo que hacerlo la primera
|
|
790
|
+
// vez. El sobre va en null: la cadena que lo trajo ya no existe —el proceso
|
|
791
|
+
// que la sostenía murió—, así que esta reanudación no puede seguir gastando
|
|
792
|
+
// en nombre de nadie. Si el encargo necesitaba subcontratar, lo hará con el
|
|
793
|
+
// presupuesto propio de este agente y no con el de quien llamó.
|
|
794
|
+
trabajar: (taskId, brief) => work(taskId, brief, null),
|
|
795
|
+
reentregar: async (taskId, texto) => {
|
|
796
|
+
const { txHash } = await panal.deliverResult(taskId, texto);
|
|
797
|
+
console.log(`[vigilante] #${taskId} entregada al segundo intento · tx ${txHash}`);
|
|
798
|
+
},
|
|
799
|
+
urlPublica: process.env.PUBLIC_URL?.trim(),
|
|
800
|
+
});
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* El vigilante: se entera de las tareas aunque nadie llame a la puerta.
|
|
3
|
+
*
|
|
4
|
+
* Un agente normal solo trabaja cuando alguien le hace POST /brief. Eso deja
|
|
5
|
+
* tres agujeros que cuestan dinero de verdad, y los tres han pasado:
|
|
6
|
+
*
|
|
7
|
+
* 1. EL ENCARGO QUE NO LLEGÓ. El cliente pagó on-chain y el push del brief
|
|
8
|
+
* falló —móvil, wallet que se traga la firma, pestaña cerrada, tu agente
|
|
9
|
+
* caído dos minutos—. El pago se queda bloqueado y tú no te enteras.
|
|
10
|
+
* 2. EL TRABAJO A MEDIAS. Recibiste el encargo, te pusiste a trabajar y el
|
|
11
|
+
* proceso murió. Al arrancar no queda ni rastro: la tarea sigue abierta
|
|
12
|
+
* para siempre.
|
|
13
|
+
* 3. LA ENTREGA QUE NO SE ANCLÓ. Terminaste el trabajo, lo guardaste, y la
|
|
14
|
+
* transacción de entrega falló. Tienes el resultado en disco y el cliente
|
|
15
|
+
* no tiene nada.
|
|
16
|
+
*
|
|
17
|
+
* LO QUE NO PUEDE HACER, y conviene tenerlo claro antes de esperarlo: el
|
|
18
|
+
* escrow guarda `keccak256(encargo)`, no el encargo. Un vigilante que ve una
|
|
19
|
+
* tarea nueva sabe que existe, de quién es y cuánto paga, pero NO qué le
|
|
20
|
+
* pidieron. Si el encargo nunca llegó, no hay nada que inventar: se avisa con
|
|
21
|
+
* el enlace de reenvío y se espera. Adivinar sería entregar cualquier cosa
|
|
22
|
+
* anclando su hash, que es peor que no entregar.
|
|
23
|
+
*
|
|
24
|
+
* SE SONDEA, NO SE ESCUCHAN EVENTOS. `eth_getLogs` del RPC público está
|
|
25
|
+
* limitado a 100 bloques, así que un agente parado veinte minutos ya no puede
|
|
26
|
+
* recuperar su propio hueco. Leer el contador de tareas y mirar las nuevas es
|
|
27
|
+
* una llamada cada vuelta, funciona igual tras cualquier parón y no depende de
|
|
28
|
+
* que el RPC conserve nada.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
32
|
+
import { join } from 'node:path';
|
|
33
|
+
import { keccak256, toBytes } from 'viem';
|
|
34
|
+
import type { Address } from 'viem';
|
|
35
|
+
import { TaskStatus, type PanalClient } from '@panal/sdk';
|
|
36
|
+
|
|
37
|
+
export interface VigilanteDeps {
|
|
38
|
+
panal: PanalClient;
|
|
39
|
+
/** La dirección de este agente. */
|
|
40
|
+
yo: Address;
|
|
41
|
+
/** Dónde guardar hasta dónde se miró. */
|
|
42
|
+
dataDir: string;
|
|
43
|
+
/** Trabaja una tarea de la que YA se tiene el encargo. */
|
|
44
|
+
trabajar: (taskId: bigint, brief: string) => Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* ¿Se está trabajando esa tarea AHORA MISMO?
|
|
47
|
+
*
|
|
48
|
+
* Sin esto, el vigilante no sabe distinguir un trabajo en curso de uno
|
|
49
|
+
* muerto: los dos se ven igual desde fuera —tarea abierta, encargo en disco,
|
|
50
|
+
* sin resultado—. Se vio en la primera prueba real, donde anunció que
|
|
51
|
+
* retomaba una tarea que estaba corriendo tan tranquila. No llegó a
|
|
52
|
+
* duplicarla porque `work` tiene su propia guarda, pero el aviso era falso, y
|
|
53
|
+
* un trabajo más largo que el intervalo lo repetiría en cada vuelta.
|
|
54
|
+
*/
|
|
55
|
+
enCurso: (taskId: bigint) => boolean;
|
|
56
|
+
/** El encargo recibido, si se guardó. */
|
|
57
|
+
briefGuardado: (taskId: bigint) => string | null;
|
|
58
|
+
/** El resultado ya calculado, si lo hay. */
|
|
59
|
+
resultadoGuardado: (taskId: bigint) => string | null;
|
|
60
|
+
/** Reintenta anclar un resultado que ya está calculado. */
|
|
61
|
+
reentregar: (taskId: bigint, texto: string) => Promise<void>;
|
|
62
|
+
/** URL pública del agente, para el aviso de encargo huérfano. */
|
|
63
|
+
urlPublica?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Cada cuánto se mira, en segundos. */
|
|
67
|
+
const CADA = (() => {
|
|
68
|
+
const n = Number(process.env.VIGILANTE_SEGUNDOS?.trim() || '60');
|
|
69
|
+
// Menos de 15 s no aporta nada y sí gasta el límite del RPC.
|
|
70
|
+
return Number.isFinite(n) && n >= 15 ? Math.floor(n) : 60;
|
|
71
|
+
})();
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Cuánto se espera antes de dar por perdido un encargo.
|
|
75
|
+
*
|
|
76
|
+
* El camino normal es: la transacción se mina y el cliente empuja el encargo
|
|
77
|
+
* unos segundos después. Sin esta espera, el vigilante gritaría en cada tarea
|
|
78
|
+
* legítima y el aviso dejaría de significar nada.
|
|
79
|
+
*/
|
|
80
|
+
const GRACIA_MS = 3 * 60 * 1000;
|
|
81
|
+
|
|
82
|
+
/** Cuántas tareas hacia atrás se miran al arrancar sin marca previa. */
|
|
83
|
+
const REPASO_INICIAL = 50n;
|
|
84
|
+
|
|
85
|
+
export function arrancarVigilante(deps: VigilanteDeps): { parar: () => void } {
|
|
86
|
+
if (process.env.VIGILANTE === 'off') {
|
|
87
|
+
console.log('Vigilante desactivado (VIGILANTE=off).');
|
|
88
|
+
return { parar: () => {} };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const marcaPath = join(deps.dataDir, 'vigilante.json');
|
|
92
|
+
/** Tareas vistas sin encargo, con el momento en que se vieron. */
|
|
93
|
+
const huerfanas = new Map<string, number>();
|
|
94
|
+
/** De las que ya se avisó, para no repetir el aviso cada vuelta. */
|
|
95
|
+
const avisadas = new Set<string>();
|
|
96
|
+
let parado = false;
|
|
97
|
+
|
|
98
|
+
const leerMarca = (): bigint => {
|
|
99
|
+
try {
|
|
100
|
+
const raw = JSON.parse(readFileSync(marcaPath, 'utf8')) as { visto?: string };
|
|
101
|
+
return BigInt(raw.visto ?? '0');
|
|
102
|
+
} catch {
|
|
103
|
+
return -1n; // sin marca: se hace el repaso inicial
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const escribirMarca = (visto: bigint): void => {
|
|
107
|
+
try {
|
|
108
|
+
writeFileSync(marcaPath, JSON.stringify({ visto: visto.toString() }, null, 2));
|
|
109
|
+
} catch (err) {
|
|
110
|
+
// Perder la marca solo cuesta repetir el repaso, así que no se para nada.
|
|
111
|
+
console.error(`[vigilante] no se pudo guardar la marca: ${err instanceof Error ? err.message : err}`);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
async function repasar(): Promise<void> {
|
|
116
|
+
const total = await deps.panal.getTaskCount();
|
|
117
|
+
const marca = leerMarca();
|
|
118
|
+
// La primera vez se miran las últimas REPASO_INICIAL en vez de las 30.000
|
|
119
|
+
// que pueda haber: las viejas están cerradas y no cambian.
|
|
120
|
+
const desde = marca >= 0n ? marca + 1n : total > REPASO_INICIAL ? total - REPASO_INICIAL : 0n;
|
|
121
|
+
|
|
122
|
+
// Las nuevas, más las que quedaron pendientes de vueltas anteriores.
|
|
123
|
+
const pendientes = new Set<string>(huerfanas.keys());
|
|
124
|
+
for (let i = desde; i < total; i++) pendientes.add(i.toString());
|
|
125
|
+
if (pendientes.size === 0) {
|
|
126
|
+
escribirMarca(total - 1n);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const id of pendientes) {
|
|
131
|
+
if (parado) return;
|
|
132
|
+
const taskId = BigInt(id);
|
|
133
|
+
try {
|
|
134
|
+
await revisarUna(taskId);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
console.error(`[vigilante] #${taskId}: ${err instanceof Error ? err.message : err}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
escribirMarca(total - 1n);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function revisarUna(taskId: bigint): Promise<void> {
|
|
143
|
+
const task = await deps.panal.getTask(taskId);
|
|
144
|
+
const id = taskId.toString();
|
|
145
|
+
|
|
146
|
+
// Ni mía, ni viva: fuera de la lista y a otra cosa.
|
|
147
|
+
if (task.worker.toLowerCase() !== deps.yo.toLowerCase()) {
|
|
148
|
+
huerfanas.delete(id);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (task.status !== TaskStatus.Open) {
|
|
152
|
+
huerfanas.delete(id);
|
|
153
|
+
avisadas.delete(id);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Se está trabajando ahora mismo: no es un hueco, es el camino normal. Ni
|
|
158
|
+
// se retoma, ni se avisa de que falte el encargo — lo tiene y lo está
|
|
159
|
+
// usando. El vigilante solo se ocupa de lo que ya no se mueve.
|
|
160
|
+
if (deps.enCurso(taskId)) {
|
|
161
|
+
huerfanas.delete(id);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// CASO 3: el resultado está calculado y la tarea sigue abierta, así que la
|
|
166
|
+
// entrega no llegó a anclarse. Se reintenta, que es gratis para el cliente
|
|
167
|
+
// y le devuelve una tarea que daba por perdida.
|
|
168
|
+
const resultado = deps.resultadoGuardado(taskId);
|
|
169
|
+
if (resultado !== null) {
|
|
170
|
+
console.log(`[vigilante] #${taskId} tenía resultado sin anclar: se reintenta la entrega`);
|
|
171
|
+
await deps.reentregar(taskId, resultado);
|
|
172
|
+
huerfanas.delete(id);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// CASO 2: el encargo está guardado pero no hay resultado, o sea que el
|
|
177
|
+
// trabajo se quedó a medias. Se retoma.
|
|
178
|
+
const brief = deps.briefGuardado(taskId);
|
|
179
|
+
if (brief !== null) {
|
|
180
|
+
// Se comprueba el hash ANTES de trabajar. El archivo lleva en disco desde
|
|
181
|
+
// otra ejecución y no vale fiarse: si no cuadra con lo que hay en la
|
|
182
|
+
// cadena, trabajar sobre él sería entregar algo que el cliente no pidió.
|
|
183
|
+
if (keccak256(toBytes(brief)) !== task.taskHash) {
|
|
184
|
+
console.error(
|
|
185
|
+
`[vigilante] #${taskId} el encargo guardado NO cuadra con el taskHash de la cadena: se ignora`,
|
|
186
|
+
);
|
|
187
|
+
huerfanas.delete(id);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
console.log(`[vigilante] #${taskId} se quedó a medias: se retoma el trabajo`);
|
|
191
|
+
await deps.trabajar(taskId, brief);
|
|
192
|
+
huerfanas.delete(id);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// CASO 1: hay tarea y no hay encargo. Aquí no se puede hacer nada más que
|
|
197
|
+
// avisar: el texto no está en la cadena y adivinarlo sería inventárselo.
|
|
198
|
+
const visto = huerfanas.get(id);
|
|
199
|
+
if (visto === undefined) {
|
|
200
|
+
huerfanas.set(id, Date.now());
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (Date.now() - visto < GRACIA_MS || avisadas.has(id)) return;
|
|
204
|
+
|
|
205
|
+
avisadas.add(id);
|
|
206
|
+
// En cuánto vence, no cuándo. La fecha absoluta se imprimía en UTC junto a
|
|
207
|
+
// una marca de log en hora local, y un plazo de dos horas se leía como
|
|
208
|
+
// vencido. Lo que hace falta saber aquí es cuánto margen queda.
|
|
209
|
+
const restanMin = Math.round((Number(task.deadline) * 1000 - Date.now()) / 60000);
|
|
210
|
+
const vence =
|
|
211
|
+
restanMin <= 0
|
|
212
|
+
? 'YA VENCIDO'
|
|
213
|
+
: restanMin < 60
|
|
214
|
+
? `en ${restanMin} min`
|
|
215
|
+
: `en ${Math.floor(restanMin / 60)} h ${restanMin % 60} min`;
|
|
216
|
+
console.error(
|
|
217
|
+
`[vigilante] #${taskId} PAGADA Y SIN ENCARGO. ${task.client} bloqueó su pago hace más de ` +
|
|
218
|
+
`${Math.round(GRACIA_MS / 60000)} min y el texto no ha llegado nunca. No se puede adivinar: el escrow ` +
|
|
219
|
+
`solo guarda su hash.\n` +
|
|
220
|
+
` Que lo reenvíe desde ${deps.urlPublica ? `${deps.urlPublica}/reenviar?task=${taskId}` : 'tu /reenviar'}` +
|
|
221
|
+
` o desde https://panal.lat/dashboard.\n` +
|
|
222
|
+
` Si nadie lo hace, el plazo vence ${vence} y el cliente recupera su dinero.`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
console.log(`Vigilante activo: repasa cada ${CADA} s (VIGILANTE=off para apagarlo).`);
|
|
227
|
+
// Una pasada al arrancar, que es cuando más falta hace: recoge todo lo que
|
|
228
|
+
// se perdió mientras el proceso estaba caído.
|
|
229
|
+
void repasar().catch((err) => console.error(`[vigilante] primer repaso: ${err instanceof Error ? err.message : err}`));
|
|
230
|
+
|
|
231
|
+
const timer = setInterval(() => {
|
|
232
|
+
void repasar().catch((err) => console.error(`[vigilante] ${err instanceof Error ? err.message : err}`));
|
|
233
|
+
}, CADA * 1000);
|
|
234
|
+
// Sin unref, este intervalo mantiene vivo el proceso para siempre aunque
|
|
235
|
+
// todo lo demás haya terminado.
|
|
236
|
+
timer.unref?.();
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
parar: () => {
|
|
240
|
+
parado = true;
|
|
241
|
+
clearInterval(timer);
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|