create-panal-agent 0.10.0 → 0.11.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/README.md CHANGED
@@ -64,6 +64,24 @@ import { createPanalClient } from '@panal/sdk';
64
64
  await createPanalClient({ account }).withdraw();
65
65
  ```
66
66
 
67
+ ### Archivos, en las dos direcciones
68
+
69
+ Tu agente **entrega** archivos devolviendo `{ text, files }` desde `handleTask`. El motor calcula el hash de cada uno y lo cuela en el texto antes de anclarlo, así que el cliente puede demostrar que lo que se baja es exactamente lo que entregaste. Un enlace a secas no daría eso.
70
+
71
+ ```ts
72
+ return { text: 'Aquí tienes el informe.', files: [{ name: 'informe.pdf', data: pdf, mime: 'application/pdf' }] };
73
+ ```
74
+
75
+ Y **recibe** los que el cliente adjunte, en `ctx.adjuntos`. Llegan verificados: el encargo anunció el hash de cada uno antes de que se pagara, así que si alguien hubiera cambiado uno por el camino no llegaría hasta tu código. Las imágenes se le pasan solas al modelo; el resto lo tienes en crudo.
76
+
77
+ La regla que gobierna la entrada: **solo se escribe lo que el encargo anunció**. El número de una tarea es público, y sin esa guarda tu agente sería un almacén gratis.
78
+
79
+ ### El modelo lo eliges tú
80
+
81
+ `LLM_PROVIDER=claude | gemini | kimi | grok | glm | deepseek | groq | openai | mistral | ollama`, y su clave en `LLM_API_KEY`. Nada más. `LLM_MODEL` manda sobre el sugerido, y un proveedor que no esté en la lista solo necesita `LLM_BASE_URL`.
82
+
83
+ Para que tu agente **mire** las fotos que le mandan, el modelo tiene que ser multimodal. DeepSeek, que viene por defecto, no lo es.
84
+
67
85
  ## Lo que conviene tener claro
68
86
 
69
87
  **La wallet del agente vive en un servidor.** Por eso el generador te crea una nueva en vez de pedirte una: no pongas ahí la clave de tu MetaMask personal. Dale solo lo que necesita para el gas y retira lo que cobres.
package/dist/i18n.js CHANGED
@@ -70,7 +70,7 @@ The language is taken from --lang, then PANAL_LANG, then your system locale.`,
70
70
  env: {
71
71
  key: 'Private key of your agent\'s DEDICATED wallet.\nIt is the one that gets paid and signs deliveries. Do not use your personal\nwallet: this one lives on a server. It only needs a little MON for gas.',
72
72
  port: 'Server port. Many hosts inject it themselves.',
73
- model: 'Your model, if your agent uses one. Any OpenAI-compatible API.',
73
+ model: 'Your model, if your agent uses one. Pick a provider by name and put its key below.\nLLM_MODEL overrides the suggested one; LLM_BASE_URL is for any provider not on the list.\nTo read images a client attaches, the model has to be a multimodal one.',
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`.',
@@ -165,7 +165,7 @@ El idioma sale de --lang, luego de PANAL_LANG, y si no del locale del sistema.`,
165
165
  env: {
166
166
  key: 'Clave privada de la wallet DEDICADA de tu agente.\nEs la que cobra y la que firma las entregas. No uses tu wallet personal:\nesta vive en un servidor. Solo necesita un poco de MON para el gas.',
167
167
  port: 'Puerto del servidor. Muchos hostings lo inyectan solos.',
168
- model: 'Tu modelo, si tu agente usa uno. Cualquier API compatible con OpenAI.',
168
+ model: 'Tu modelo, si tu agente usa uno. Elige proveedor por su nombre y pon su clave abajo.\nLLM_MODEL manda sobre el sugerido; LLM_BASE_URL es para cualquiera que no esté en la lista.\nPara leer las imágenes que adjunte un cliente, el modelo tiene que ser multimodal.',
169
169
  rpc: 'RPC propio, si el público se te queda corto (limita a ~15 llamadas/s).',
170
170
  data: 'Dónde guardar los resultados entregados.',
171
171
  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`.',
@@ -261,7 +261,7 @@ const zh = {
261
261
  env: {
262
262
  key: '你的代理【专用】钱包的私钥。\n它负责收款并为交付签名。不要使用你的个人钱包:这个私钥要放在服务器上。\n它只需要少量 MON 用于支付 gas。',
263
263
  port: '服务端口。很多托管平台会自动注入。',
264
- model: '你的模型(如果代理需要)。任何兼容 OpenAI 的 API 均可。',
264
+ model: '你的模型(如果代理需要)。按名称选择服务商,并在下面填入其密钥。\nLLM_MODEL 优先于建议的模型;LLM_BASE_URL 用于列表之外的任何服务商。\n若要读取客户附带的图片,模型必须支持多模态。',
265
265
  rpc: '你自己的 RPC,当公共节点不够用时(其上限约为每秒 15 次调用)。',
266
266
  data: '交付结果的存放位置。',
267
267
  x402: '按次收费(可选)。留空则你的代理只接受托管订单。\n价格按每次请求计算,使用支持 EIP-2612 的代币——不能是 MON,该方案依赖 `permit`。',
@@ -352,7 +352,7 @@ const hi = {
352
352
  env: {
353
353
  key: 'आपके एजेंट के लिए समर्पित (DEDICATED) वॉलेट की निजी कुंजी।\nयही भुगतान पाती है और डिलीवरी पर हस्ताक्षर करती है। अपना निजी वॉलेट न लगाएँ:\nयह कुंजी सर्वर पर रहती है। इसे गैस के लिए बस थोड़ा MON चाहिए।',
354
354
  port: 'सर्वर पोर्ट। कई होस्टिंग इसे खुद भेजते हैं।',
355
- model: 'आपका मॉडल, अगर एजेंट किसी का उपयोग करता है। OpenAI-संगत कोई भी API।',
355
+ model: 'आपका मॉडल, अगर एजेंट किसी का उपयोग करता है। नाम से प्रदाता चुनें और नीचे उसकी कुंजी डालें।\nLLM_MODEL सुझाए गए मॉडल पर भारी पड़ता है; सूची से बाहर के किसी भी प्रदाता के लिए LLM_BASE_URL है।\nक्लाइंट द्वारा भेजी गई छवियाँ पढ़ने के लिए मॉडल मल्टीमॉडल होना चाहिए।',
356
356
  rpc: 'अपना RPC, अगर सार्वजनिक कम पड़े (इसकी सीमा ~15 कॉल/सेकंड है)।',
357
357
  data: 'दिए गए परिणाम कहाँ सहेजें।',
358
358
  x402: 'प्रति कॉल शुल्क (वैकल्पिक)। खाली छोड़ें तो आपका एजेंट केवल एस्क्रो वाले काम लेगा।\nकीमत प्रति अनुरोध है, EIP-2612 टोकन में — MON नहीं चल सकता, इस योजना को `permit` चाहिए।',
@@ -447,7 +447,7 @@ const ar = {
447
447
  env: {
448
448
  key: 'المفتاح الخاص بمحفظة وكيلك المخصّصة.\nهي التي تتقاضى الأجر وتوقّع عمليات التسليم. لا تستخدم محفظتك الشخصية:\nهذا المفتاح يعيش على خادم. يكفيه قليل من MON لدفع الرسوم.',
449
449
  port: 'منفذ الخادم. كثير من الاستضافات تضبطه تلقائيًا.',
450
- model: 'نموذجك، إن كان وكيلك يستخدم واحدًا. أي واجهة متوافقة مع OpenAI.',
450
+ model: 'نموذجك، إن كان وكيلك يستخدم واحدًا. اختر المزوّد بالاسم وضع مفتاحه أدناه.\nمتغيّر LLM_MODEL يتقدّم على النموذج المقترح، و LLM_BASE_URL لأي مزوّد ليس في القائمة.\nلقراءة الصور التي يرفقها العميل، يجب أن يكون النموذج متعدّد الوسائط.',
451
451
  rpc: 'عقدة RPC خاصة بك، إن لم تكفِ العامة (حدّها نحو 15 طلبًا في الثانية).',
452
452
  data: 'مكان حفظ النتائج المُسلَّمة.',
453
453
  x402: 'التحصيل لكل استدعاء (اختياري). اتركه فارغًا فيقبل وكيلك مهام الضمان فقط.\nالسعر لكل طلب، بعملة تدعم EIP-2612 — لا يصلح MON، فالمخطط يحتاج `permit`.',
@@ -541,7 +541,7 @@ La langue vient de --lang, puis de PANAL_LANG, puis de la locale du système.`,
541
541
  env: {
542
542
  key: 'Clé privée du portefeuille DÉDIÉ de votre agent.\nC\'est elle qui encaisse et qui signe les livraisons. N\'utilisez pas votre\nportefeuille personnel : celle-ci vit sur un serveur. Un peu de MON suffit.',
543
543
  port: 'Port du serveur. Beaucoup d\'hébergeurs l\'injectent eux-mêmes.',
544
- model: 'Votre modèle, si votre agent en utilise un. Toute API compatible OpenAI.',
544
+ model: 'Votre modèle, si votre agent en utilise un. Choisissez un fournisseur par son nom et mettez sa clé ci-dessous.\nLLM_MODEL prime sur le modèle suggéré ; LLM_BASE_URL sert pour tout fournisseur absent de la liste.\nPour lire les images jointes par un client, le modèle doit être multimodal.',
545
545
  rpc: 'Votre propre RPC, si le public ne suffit pas (limité à ~15 appels/s).',
546
546
  data: 'Où stocker les résultats livrés.',
547
547
  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`.",
@@ -639,7 +639,7 @@ O idioma vem de --lang, depois de PANAL_LANG e, por fim, do locale do sistema.`,
639
639
  env: {
640
640
  key: 'Chave privada da carteira DEDICADA do seu agente.\nÉ ela que recebe e que assina as entregas. Não use a sua carteira pessoal:\nesta vive num servidor. Só precisa de um pouco de MON para o gas.',
641
641
  port: 'Porta do servidor. Muitos alojamentos injetam-na sozinhos.',
642
- model: 'O seu modelo, se o agente usar um. Qualquer API compatível com OpenAI.',
642
+ model: 'O seu modelo, se o agente usar um. Escolha um fornecedor pelo nome e ponha a sua chave abaixo.\nLLM_MODEL manda sobre o sugerido; LLM_BASE_URL serve para qualquer fornecedor fora da lista.\nPara ler as imagens que um cliente anexe, o modelo tem de ser multimodal.',
643
643
  rpc: 'RPC próprio, se o público não chegar (limita a ~15 chamadas/s).',
644
644
  data: 'Onde guardar os resultados entregues.',
645
645
  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`.',
@@ -735,7 +735,7 @@ const ru = {
735
735
  env: {
736
736
  key: 'Приватный ключ ВЫДЕЛЕННОГО кошелька вашего агента.\nИменно он получает оплату и подписывает сдачу работы. Не используйте личный\nкошелёк: этот ключ живёт на сервере. Ему нужно немного MON только на газ.',
737
737
  port: 'Порт сервера. Многие хостинги подставляют его сами.',
738
- model: 'Ваша модель, если агент её использует. Любой API, совместимый с OpenAI.',
738
+ model: 'Ваша модель, если агент её использует. Выберите провайдера по имени и укажите его ключ ниже.\nLLM_MODEL важнее предложенной модели; LLM_BASE_URL — для любого провайдера не из списка.\nЧтобы читать изображения, приложенные клиентом, модель должна быть мультимодальной.',
739
739
  rpc: 'Свой RPC, если публичного не хватает (лимит ~15 запросов/с).',
740
740
  data: 'Где хранить сданные результаты.',
741
741
  x402: 'Плата за вызов (необязательно). Оставьте пустым — агент будет брать только заказы через эскроу.\nЦена за один запрос, в токене с EIP-2612: MON не подходит, схеме нужен `permit`.',
@@ -832,7 +832,7 @@ const bn = {
832
832
  env: {
833
833
  key: 'আপনার এজেন্টের নিবেদিত (DEDICATED) ওয়ালেটের প্রাইভেট কী।\nএটিই অর্থ গ্রহণ করে এবং ডেলিভারিতে স্বাক্ষর করে। ব্যক্তিগত ওয়ালেট ব্যবহার\nকরবেন না: এটি সার্ভারে থাকে। গ্যাসের জন্য সামান্য MON হলেই চলে।',
834
834
  port: 'সার্ভারের পোর্ট। অনেক হোস্টিং নিজেই এটি দেয়।',
835
- model: 'আপনার মডেল, যদি এজেন্ট ব্যবহার করে। OpenAI-সঙ্গতিপূর্ণ যেকোনো API।',
835
+ model: 'আপনার মডেল, যদি এজেন্ট ব্যবহার করে। নাম দিয়ে প্রদানকারী বেছে নিন এবং নিচে তার কী দিন।\nLLM_MODEL প্রস্তাবিত মডেলের উপরে প্রাধান্য পায়; তালিকার বাইরের যেকোনো প্রদানকারীর জন্য LLM_BASE_URL।\nক্লায়েন্টের পাঠানো ছবি পড়তে হলে মডেলটিকে মাল্টিমোডাল হতে হবে।',
836
836
  rpc: 'নিজস্ব RPC, যদি সর্বজনীনটি যথেষ্ট না হয় (সীমা প্রায় ১৫ কল/সেকেন্ড)।',
837
837
  data: 'সরবরাহ করা ফলাফল কোথায় রাখা হবে।',
838
838
  x402: 'প্রতি কলে চার্জ (ঐচ্ছিক)। ফাঁকা রাখলে আপনার এজেন্ট কেবল এসক্রো কাজ নেবে।\nদাম প্রতি অনুরোধে, EIP-2612 টোকেনে — MON চলবে না, স্কিমটির `permit` দরকার।',
@@ -926,7 +926,7 @@ const ur = {
926
926
  env: {
927
927
  key: 'آپ کے ایجنٹ کے مخصوص (DEDICATED) والیٹ کی نجی کلید۔\nیہی ادائیگی وصول کرتی ہے اور ڈیلیوری پر دستخط کرتی ہے۔ اپنا ذاتی والیٹ نہ\nاستعمال کریں: یہ کلید سرور پر رہتی ہے۔ اسے گیس کے لیے تھوڑا سا MON چاہیے۔',
928
928
  port: 'سرور کا پورٹ۔ بہت سی ہوسٹنگ خود ہی دے دیتی ہیں۔',
929
- model: 'آپ کا ماڈل، اگر ایجنٹ استعمال کرے۔ OpenAI سے ہم آہنگ کوئی بھی API۔',
929
+ model: 'آپ کا ماڈل، اگر ایجنٹ استعمال کرے۔ نام سے فراہم کنندہ چنیں اور نیچے اس کی کلید ڈالیں۔\nLLM_MODEL تجویز کردہ ماڈل پر مقدم ہے؛ فہرست سے باہر کسی بھی فراہم کنندہ کے لیے LLM_BASE_URL ہے۔\nکلائنٹ کی بھیجی ہوئی تصاویر پڑھنے کے لیے ماڈل کا ملٹی موڈل ہونا ضروری ہے۔',
930
930
  rpc: 'اپنا RPC، اگر عوامی کم پڑ جائے (حد تقریباً 15 کالز فی سیکنڈ)۔',
931
931
  data: 'فراہم کردہ نتائج کہاں محفوظ ہوں۔',
932
932
  x402: 'فی کال وصولی (اختیاری)۔ خالی چھوڑ دیں تو آپ کا ایجنٹ صرف ایسکرو کے کام لے گا۔\nقیمت فی درخواست ہے، EIP-2612 ٹوکن میں — MON نہیں چل سکتا، اس اسکیم کو `permit` چاہیے۔',
package/dist/index.js CHANGED
@@ -150,12 +150,11 @@ function writeEnvExample(dest, t) {
150
150
  'PORT=8787',
151
151
  '',
152
152
  bloque(t.env.model),
153
- '# OpenAI https://api.openai.com/v1 gpt-4o-mini',
154
- '# DeepSeek https://api.deepseek.com/v1 deepseek-chat',
155
- '# Groq https://api.groq.com/openai/v1 llama-3.3-70b-versatile',
156
- 'LLM_BASE_URL=https://api.deepseek.com/v1',
153
+ '# claude · gemini · kimi · grok · glm · deepseek · groq · openai · mistral · ollama',
154
+ 'LLM_PROVIDER=deepseek',
157
155
  'LLM_API_KEY=',
158
- 'LLM_MODEL=deepseek-chat',
156
+ '# LLM_MODEL=',
157
+ '# LLM_BASE_URL=',
159
158
  '',
160
159
  bloque(t.env.rpc),
161
160
  'RPC_URL=',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-panal-agent",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Crea un agente de IA para Panal, funcionando y cobrando on-chain, en cinco minutos",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -10,7 +10,7 @@
10
10
  "typecheck": "tsc --noEmit"
11
11
  },
12
12
  "dependencies": {
13
- "@panal/sdk": "^0.11.0",
13
+ "@panal/sdk": "^0.12.0",
14
14
  "dotenv": "^17.0.0",
15
15
  "tsx": "^4.19.0",
16
16
  "viem": "^2.21.0"
@@ -11,9 +11,19 @@
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';
14
+ import { esImagenSoportada, llmChat, resolverLlm, type CallEnvelope, type LlmConfig } from '@panal/sdk';
15
15
  import { textoAPdf } from './pdf.js';
16
16
 
17
+ /** Un archivo que el CLIENTE te mandó con el encargo. */
18
+ export interface AdjuntoRecibido {
19
+ /** Nombre ya limpio, tal y como lo anunció el encargo. */
20
+ name: string;
21
+ /** Tipo MIME, si lo declaró: `image/png`, `application/pdf`… */
22
+ mime?: string;
23
+ /** El contenido. Su hash ya se comprobó contra lo que la cadena cubre. */
24
+ bytes: Uint8Array;
25
+ }
26
+
17
27
  export interface TaskContext {
18
28
  /**
19
29
  * El id de la tarea en el escrow, o `null` si esto es una llamada x402: ahí
@@ -27,6 +37,20 @@ export interface TaskContext {
27
37
  /** Fecha límite de entrega, en segundos epoch. Cero en una llamada x402. */
28
38
  deadline: bigint;
29
39
 
40
+ /**
41
+ * LO QUE EL CLIENTE TE ADJUNTÓ: una foto, un PDF que revisar, un CSV.
42
+ *
43
+ * Llegan verificados. El encargo anunció el hash de cada uno ANTES de que se
44
+ * pagara, así que estos bytes son exactamente los que la cadena cubre — si
45
+ * alguien hubiera cambiado uno por el camino, no habría llegado hasta aquí.
46
+ *
47
+ * Las imágenes se le pasan solas al modelo, si el tuyo sabe mirarlas. El
48
+ * resto lo tienes aquí en crudo para hacer lo que sepas hacer con ello.
49
+ *
50
+ * Vacío en una llamada x402: ahí no hay tarea donde anclar un adjunto.
51
+ */
52
+ adjuntos: AdjuntoRecibido[];
53
+
30
54
  /**
31
55
  * PREGUNTAR A OTRO AGENTE, Y PAGARLE.
32
56
  *
@@ -115,10 +139,15 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
115
139
  // código, consultar una API, lo que quieras. No tiene por qué usar un modelo.
116
140
  // ──────────────────────────────────────────────────────────────────────────
117
141
 
118
- const apiKey = process.env.LLM_API_KEY;
119
- if (!apiKey) {
142
+ // El modelo, sea cual sea. `LLM_PROVIDER=claude|kimi|grok|glm|gemini|…` o
143
+ // `LLM_BASE_URL` a pelo para cualquiera que no esté en la lista.
144
+ let cfg: LlmConfig;
145
+ try {
146
+ cfg = resolverLlm(process.env);
147
+ } catch (err) {
120
148
  // Sin modelo configurado se entrega algo honesto en vez de fallar: el
121
149
  // cliente ya pagó, y dejarlo sin nada le cuesta el plazo entero.
150
+ console.error(`[agente] ${etiqueta(ctx)} sin modelo: ${err instanceof Error ? err.message : err}`);
122
151
  return (
123
152
  `No puedo completar este encargo ahora mismo: al agente le falta configurar su modelo.\n\n` +
124
153
  `Lo que pediste:\n${brief}\n\n` +
@@ -127,16 +156,28 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
127
156
  );
128
157
  }
129
158
 
159
+ // Lo que el cliente adjuntó y un modelo puede MIRAR. El resto de adjuntos
160
+ // sigue en `ctx.adjuntos` para que hagas con ellos lo que sepas hacer.
161
+ const imagenes = ctx.adjuntos
162
+ .filter((a) => esImagenSoportada(a.mime))
163
+ .map((a) => ({ mime: a.mime!, bytes: a.bytes }));
164
+ if (ctx.adjuntos.length > 0) {
165
+ console.log(
166
+ `[agente] ${etiqueta(ctx)} ${ctx.adjuntos.length} adjunto(s), ${imagenes.length} para el modelo: ` +
167
+ ctx.adjuntos.map((a) => a.name).join(', '),
168
+ );
169
+ }
170
+
130
171
  // ¿Esto lo sé hacer yo, o me conviene preguntar? La decisión es del agente,
131
172
  // no del cliente: él pidió un trabajo, no una arquitectura.
132
- const ayuda = await pedirAyudaSiHaceFalta(brief, ctx, apiKey);
173
+ const ayuda = await pedirAyudaSiHaceFalta(brief, ctx, cfg);
133
174
 
134
175
  // Un intento, una revisión y una corrección. Un modelo falla el formato de
135
176
  // vez en cuando, y aquí eso no es un mensaje feo en un chat: el hash de lo
136
177
  // que entregues queda anclado en la cadena y ya no se puede rectificar.
137
178
  let queja: string | null = null;
138
179
  for (let intento = 1; intento <= 2; intento++) {
139
- const texto = await pedirAlModelo(brief, apiKey, queja, ayuda);
180
+ const texto = await pedirAlModelo(brief, cfg, queja, ayuda, imagenes, ctx.adjuntos);
140
181
  const problema = revisar(brief, texto);
141
182
  if (!problema) {
142
183
  console.log(`[agente] ${etiqueta(ctx)} resuelta: ${texto.length} caracteres`);
@@ -172,12 +213,12 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
172
213
  async function pedirAyudaSiHaceFalta(
173
214
  brief: string,
174
215
  ctx: TaskContext,
175
- apiKey: string,
216
+ cfg: LlmConfig,
176
217
  ): Promise<string | null> {
177
218
  // Sin presupuesto no hay nada que decidir, y así se ahorra la llamada.
178
219
  if (ctx.presupuesto <= 0n) return null;
179
220
 
180
- const decision = await decidirDelegacion(brief, apiKey);
221
+ const decision = await decidirDelegacion(brief, cfg);
181
222
  if (!decision) return null;
182
223
 
183
224
  try {
@@ -197,44 +238,52 @@ async function pedirAyudaSiHaceFalta(
197
238
  }
198
239
  }
199
240
 
241
+ /**
242
+ * Saca el JSON de una respuesta, aunque venga envuelto.
243
+ *
244
+ * `response_format: json_object` sólo lo entiende parte del mercado, y este
245
+ * agente puede correr contra cualquiera. Se pide JSON en el prompt y se busca
246
+ * el objeto a la vuelta: unos lo envuelven en ```json y otros le ponen una
247
+ * frase delante, y las dos cosas son fáciles de perdonar.
248
+ */
249
+ function extraerJson(crudo: string): unknown {
250
+ const sinValla = crudo.replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '');
251
+ const ini = sinValla.indexOf('{');
252
+ const fin = sinValla.lastIndexOf('}');
253
+ if (ini === -1 || fin <= ini) return null;
254
+ try {
255
+ return JSON.parse(sinValla.slice(ini, fin + 1));
256
+ } catch {
257
+ return null;
258
+ }
259
+ }
260
+
200
261
  /** Una llamada corta al modelo: ¿delego, y a quién? Formato JSON o nada. */
201
262
  async function decidirDelegacion(
202
263
  brief: string,
203
- apiKey: string,
264
+ cfg: LlmConfig,
204
265
  ): Promise<{ skill: string; pregunta: string } | null> {
205
266
  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;
267
+ const crudo = await llmChat(
268
+ // Corto a propósito, y sin reintentos: si decidir tarda más que trabajar
269
+ // —o cuesta más—, no compensa. Ante la duda se trabaja solo.
270
+ { ...cfg, timeoutMs: 30_000, maxRetries: 0, maxTokens: 400 },
271
+ {
272
+ system:
273
+ 'You are an agent deciding whether to PAY another specialist agent out of your own earnings ' +
274
+ 'to do part of a job. Answer with JSON only, no prose and no code fences.\n' +
275
+ '{"delegate": false} if you can do the job yourself. This is the right answer almost always.\n' +
276
+ '{"delegate": true, "skill": "…", "question": "…"} ONLY if the job clearly needs expertise ' +
277
+ 'outside your own, and a specialist answer would measurably improve the result.\n' +
278
+ '"skill" is one or two words to search a marketplace by (e.g. "translation", "legal", "json").\n' +
279
+ '"question" is the self-contained question for that specialist: it will be sent on its own, ' +
280
+ 'so it must make sense without the rest of the job.\n' +
281
+ 'Paying costs real money. If in doubt, do not delegate.',
282
+ user: brief,
283
+ },
284
+ );
285
+ const parsed = extraerJson(crudo) as { delegate?: boolean; skill?: string; question?: string } | null;
286
+ if (!parsed || parsed.delegate !== true) return null;
238
287
  const skill = parsed.skill?.trim();
239
288
  const pregunta = parsed.question?.trim();
240
289
  if (!skill || !pregunta) return null;
@@ -314,71 +363,80 @@ function conPdfSiLoPidio(brief: string, texto: string, ctx: TaskContext): TaskRe
314
363
 
315
364
  async function pedirAlModelo(
316
365
  brief: string,
317
- apiKey: string,
366
+ cfg: LlmConfig,
318
367
  queja: string | null,
319
368
  ayuda: string | null,
369
+ imagenes: { mime: string; bytes: Uint8Array }[],
370
+ adjuntos: AdjuntoRecibido[],
320
371
  ): Promise<string> {
321
- const res = await fetch(`${process.env.LLM_BASE_URL ?? 'https://api.openai.com/v1'}/chat/completions`, {
322
- method: 'POST',
323
- headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
372
+ // Todo va en un solo turno de usuario, con cada parte etiquetada. Los tres
373
+ // dialectos aceptan varios turnos, pero cada uno los cuenta a su manera, y
374
+ // lo que aquí importa no es de quién es cada mensaje: es que el modelo no
375
+ // confunda el material de apoyo con el encargo.
376
+ const partes = [brief];
377
+
378
+ // Los adjuntos que el modelo NO puede mirar. Se nombran para que sepa que
379
+ // existen: sin esto contesta como si el cliente no hubiera mandado nada, y
380
+ // el cliente ve una respuesta que ignora la mitad de lo que pidió.
381
+ const noMirables = adjuntos.filter((a) => !imagenes.some((i) => i.bytes === a.bytes));
382
+ if (noMirables.length > 0) {
383
+ partes.push(
384
+ `[El cliente adjuntó estos archivos, que no puedes abrir: ${noMirables
385
+ .map((a) => `${a.name}${a.mime ? ` (${a.mime})` : ''}`)
386
+ .join(', ')}. El agente los tiene y los procesa aparte.]`,
387
+ );
388
+ }
389
+
390
+ // Lo que contestó el especialista, si se le preguntó. Va marcado como
391
+ // material de apoyo y no como parte del encargo: sin esa aclaración el
392
+ // modelo tiende a copiarlo tal cual y a entregar la respuesta de otro.
393
+ if (ayuda) {
394
+ partes.push(
395
+ 'Material de apoyo, pagado a un agente especialista. Úsalo si ayuda y descártalo si no; ' +
396
+ `no lo copies tal cual ni lo menciones en la entrega:\n\n${ayuda}`,
397
+ );
398
+ }
399
+
400
+ // La corrección va como una parte más: decirle QUÉ falló acierta mucho más
401
+ // que repetirle la misma petición a ciegas esperando otra suerte.
402
+ if (queja) partes.push(`Tu respuesta anterior no vale: ${queja}. Corrígela.`);
403
+
404
+ return llmChat(
324
405
  // Sin este tope, un modelo que se cuelga deja la tarea colgada para
325
406
  // siempre: el cliente ni cobra el resultado ni recupera su dinero hasta
326
407
  // que vence el plazo. Pasó de verdad, en mainnet.
327
- signal: AbortSignal.timeout(120_000),
328
- body: JSON.stringify({
329
- model: process.env.LLM_MODEL ?? 'gpt-4o-mini',
330
- messages: [
331
- {
332
- role: 'system',
333
- content:
334
- // Estas tres reglas cuestan de acertar a mano y se pagan caras:
335
- // 1. Sin la del idioma, el modelo contesta en el suyo aunque el
336
- // cliente escriba en otro, y el cliente recibe algo inservible.
337
- // 2. Sin la del formato, entrega Markdown y el cliente ve `**esto**`
338
- // en crudo, porque ni el dashboard ni Telegram lo renderizan.
339
- // 3. Sin la del registro, envuelve el trabajo en "¡Claro! Aquí
340
- // tienes…" y el entregable parece un chat, no un producto.
341
- 'You are a professional agent on the Panal marketplace. ' +
342
- // El "never fall back to English" y lo de los títulos no son
343
- // adorno: en producción, una petición en portugués volvió entera en
344
- // inglés porque el prompt listaba los títulos de sección en inglés y
345
- // el modelo los copiaba; y otra en chino devolvió las claves en
346
- // inglés. Los dos fallos con la regla del idioma ya puesta.
347
- 'RULE 1, before anything else: detect the language of the request and reply in that exact same ' +
348
- 'language; never switch part-way, and never fall back to English because the request is not in ' +
349
- 'English. If the instructions below name sections, headings or field names, translate those too: ' +
350
- 'they are written in one language only because these instructions are. ' +
351
- 'RULE 2: plain text only, never Markdown no # headings, no ** bold, no backticks. ' +
352
- 'RULE 3: deliver finished professional work, with no preamble or meta-commentary.\n' +
353
- // El agente adjunta el archivo por su cuenta; el modelo no se entera
354
- // y, sin esta regla, se disculpa por no poder generarlo.
355
- 'RULE 4: the client may ask for the result as a PDF or a file. The agent attaches it after ' +
356
- 'you answer. Never mention files, PDFs or attachments not even to say you cannot make them.',
357
- },
358
- { role: 'user', content: brief },
359
- // Lo que contestó el especialista, si se le preguntó. Va marcado como
360
- // material de apoyo y no como parte del encargo: sin esa aclaración el
361
- // modelo tiende a copiarlo tal cual y a entregar la respuesta de otro.
362
- ...(ayuda
363
- ? [
364
- {
365
- role: 'user' as const,
366
- content:
367
- 'Material de apoyo, pagado a un agente especialista. Úsalo si ayuda y descártalo si no; ' +
368
- `no lo copies tal cual ni lo menciones en la entrega:\n\n${ayuda}`,
369
- },
370
- ]
371
- : []),
372
- // La corrección va como un mensaje más: decirle QUÉ falló acierta mucho
373
- // más que repetirle la misma petición a ciegas esperando otra suerte.
374
- ...(queja ? [{ role: 'user' as const, content: `Tu respuesta anterior no vale: ${queja}. Corrígela.` }] : []),
375
- ],
376
- }),
377
- });
378
-
379
- if (!res.ok) throw new Error(`El modelo respondió ${res.status}`);
380
- const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
381
- const text = data.choices?.[0]?.message?.content?.trim();
382
- if (!text) throw new Error('El modelo devolvió una respuesta vacía.');
383
- return text;
408
+ { ...cfg, timeoutMs: cfg.timeoutMs ?? 120_000 },
409
+ {
410
+ system:
411
+ // Estas cuatro reglas cuestan de acertar a mano y se pagan caras:
412
+ // 1. Sin la del idioma, el modelo contesta en el suyo aunque el
413
+ // cliente escriba en otro, y el cliente recibe algo inservible.
414
+ // 2. Sin la del formato, entrega Markdown y el cliente ve `**esto**`
415
+ // en crudo, porque ni el dashboard ni Telegram lo renderizan.
416
+ // 3. Sin la del registro, envuelve el trabajo en "¡Claro! Aquí
417
+ // tienes…" y el entregable parece un chat, no un producto.
418
+ 'You are a professional agent on the Panal marketplace. ' +
419
+ // El "never fall back to English" y lo de los títulos no son
420
+ // adorno: en producción, una petición en portugués volvió entera en
421
+ // inglés porque el prompt listaba los títulos de sección en inglés y
422
+ // el modelo los copiaba; y otra en chino devolvió las claves en
423
+ // inglés. Los dos fallos con la regla del idioma ya puesta.
424
+ 'RULE 1, before anything else: detect the language of the request and reply in that exact same ' +
425
+ 'language; never switch part-way, and never fall back to English because the request is not in ' +
426
+ 'English. If the instructions below name sections, headings or field names, translate those too: ' +
427
+ 'they are written in one language only because these instructions are. ' +
428
+ 'RULE 2: plain text only, never Markdown no # headings, no ** bold, no backticks. ' +
429
+ 'RULE 3: deliver finished professional work, with no preamble or meta-commentary.\n' +
430
+ // El agente adjunta el archivo por su cuenta; el modelo no se entera
431
+ // y, sin esta regla, se disculpa por no poder generarlo. Ojo al
432
+ // matiz: prohibirle hablar de archivos A SECAS le hacía callar
433
+ // también sobre la foto que le acababan de mandar.
434
+ 'RULE 4: the client may ask for the result as a PDF or a file. The agent attaches it after ' +
435
+ 'you answer. Never mention files, PDFs or attachments you would have to produce — not even to ' +
436
+ 'say you cannot make them. This does NOT apply to images the client sent you: those you can ' +
437
+ 'and should refer to, because the client knows they sent them.',
438
+ user: partes.join('\n\n'),
439
+ ...(imagenes.length > 0 ? { imagenes } : {}),
440
+ },
441
+ );
384
442
  }
@@ -25,6 +25,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:ht
25
25
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
26
26
  import { join } from 'node:path';
27
27
  import {
28
+ MAX_FILE_BYTES,
28
29
  appendFilesManifest,
29
30
  assertCanServe,
30
31
  buildQuote,
@@ -32,6 +33,8 @@ import {
32
33
  LoopDetected,
33
34
  MAINNET_ADDRESSES,
34
35
  monad,
36
+ matchAttachment,
37
+ parseAttachmentsManifest,
35
38
  parseEnvelope,
36
39
  parsePaymentHeader,
37
40
  permitNonce,
@@ -39,6 +42,7 @@ import {
39
42
  sanitizeFileName,
40
43
  TaskStatus,
41
44
  verifyAndSettle,
45
+ type AttachedFile,
42
46
  type CallEnvelope,
43
47
  type DeliveredFile,
44
48
  type PermitDomain,
@@ -47,7 +51,7 @@ import { privateKeyToAccount } from 'viem/accounts';
47
51
  import { isAddress, keccak256, parseEther, toBytes, verifyMessage } from 'viem';
48
52
  import type { Address } from 'viem';
49
53
  import { handleTask } from './agent.js';
50
- import type { TaskContext, TaskFile, TaskResult } from './agent.js';
54
+ import type { AdjuntoRecibido, TaskContext, TaskFile, TaskResult } from './agent.js';
51
55
  import { arrancarVigilante } from './vigilante.js';
52
56
 
53
57
  const PORT = Number(process.env.PORT ?? 8787);
@@ -193,6 +197,14 @@ mkdirSync(DATA_DIR, { recursive: true });
193
197
  const resultPath = (taskId: bigint) => join(DATA_DIR, `result-${taskId}.txt`);
194
198
  /** Carpeta de los archivos de una tarea. Una por tarea, para no mezclarlas. */
195
199
  const filesDir = (taskId: bigint) => join(DATA_DIR, 'files', taskId.toString());
200
+ /**
201
+ * Carpeta de lo que MANDA el cliente, separada de lo que entrega el agente.
202
+ *
203
+ * Mezclarlas sería servir por `/files/:id/:name` un archivo que subió el
204
+ * cliente como si fuera parte de la entrega, con su hash anclado y todo. No lo
205
+ * es: son las dos direcciones del mismo mecanismo y no se tocan.
206
+ */
207
+ const inboxDir = (taskId: bigint) => join(DATA_DIR, 'inbox', taskId.toString());
196
208
 
197
209
  function saveResult(taskId: bigint, text: string): void {
198
210
  writeFileSync(resultPath(taskId), text, 'utf8');
@@ -271,6 +283,76 @@ function normalizarSalida(salida: TaskResult): { text: string; files: TaskFile[]
271
283
  return { text: salida.text, files: salida.files ?? [] };
272
284
  }
273
285
 
286
+ // ---------------------------------------------------------------------------
287
+ // Adjuntos: lo que el cliente manda CON el encargo
288
+ // ---------------------------------------------------------------------------
289
+ //
290
+ // El brief queda cerrado al contratar —el escrow ancla su keccak256 y más
291
+ // abajo se rechaza cualquier texto que no lo dé—, así que una foto no puede
292
+ // viajar dentro. Lo que viaja dentro es su HASH, anunciado en un bloque
293
+ // `[panal-attach/1]`. Los bytes suben después, por `POST /upload/:taskId`.
294
+ //
295
+ // De ahí sale la única regla que hay que recordar aquí: SÓLO SE ESCRIBE LO QUE
296
+ // EL ENCARGO ANUNCIÓ. Cualquier otro byte se rechaza sin llegar al disco. El
297
+ // número de una tarea es público, y sin esa guarda tu agente sería un almacén
298
+ // gratis para cualquiera que sepa contar.
299
+
300
+ const adjuntoPath = (taskId: bigint, nombre: string) => join(inboxDir(taskId), nombre);
301
+
302
+ /**
303
+ * Repasa qué adjuntos anunciados están ya en disco y cuáles faltan.
304
+ *
305
+ * El hash se comprueba AL LEER y no sólo al escribir. Entre las dos cosas hay
306
+ * un disco, a veces un reinicio y a veces un volumen que se vuelve a montar; y
307
+ * un trabajo hecho a partir de un archivo corrupto es peor que un trabajo sin
308
+ * hacer, porque se entrega y se ancla.
309
+ */
310
+ function repasarAdjuntos(
311
+ taskId: bigint,
312
+ brief: string,
313
+ ): { recibidos: AdjuntoRecibido[]; faltan: AttachedFile[] } {
314
+ const recibidos: AdjuntoRecibido[] = [];
315
+ const faltan: AttachedFile[] = [];
316
+
317
+ for (const anunciado of parseAttachmentsManifest(brief)) {
318
+ let bytes: Buffer;
319
+ try {
320
+ bytes = readFileSync(adjuntoPath(taskId, anunciado.name));
321
+ } catch {
322
+ faltan.push(anunciado);
323
+ continue;
324
+ }
325
+ if (!matchAttachment([anunciado], bytes, anunciado.name)) {
326
+ console.error(`[panal] #${taskId} el adjunto "${anunciado.name}" en disco no da su hash: se pide de nuevo`);
327
+ faltan.push(anunciado);
328
+ continue;
329
+ }
330
+ recibidos.push({
331
+ name: anunciado.name,
332
+ ...(anunciado.mime ? { mime: anunciado.mime } : {}),
333
+ bytes: new Uint8Array(bytes),
334
+ });
335
+ }
336
+ return { recibidos, faltan };
337
+ }
338
+
339
+ /** Escribe un adjunto ya verificado. */
340
+ function guardarAdjunto(taskId: bigint, nombre: string, bytes: Uint8Array): void {
341
+ mkdirSync(inboxDir(taskId), { recursive: true });
342
+ writeFileSync(adjuntoPath(taskId, nombre), bytes);
343
+ }
344
+
345
+ /**
346
+ * El sobre de una tarea que espera adjuntos.
347
+ *
348
+ * Cuando el encargo viene de otro agente y trae adjuntos, entre el brief y la
349
+ * última subida hay un rato en el que no se puede trabajar. El sobre lleva el
350
+ * presupuesto y el camino de la cadena, y perderlo significaría reanudar sin
351
+ * ellos. En memoria a propósito: si el proceso muere, la cadena que lo trajo
352
+ * murió con él, y reanudar sin sobre es exactamente lo que hace el vigilante.
353
+ */
354
+ const sobrePendiente = new Map<string, CallEnvelope>();
355
+
274
356
  /** Tareas que se están procesando ahora mismo: evita trabajar dos veces. */
275
357
  const inFlight = new Set<string>();
276
358
 
@@ -415,7 +497,13 @@ async function credencialValida(
415
497
  * necesita ver en los logs.
416
498
  */
417
499
  function contexto(
418
- base: { taskId: bigint | null; client: string; amount: bigint; deadline: bigint },
500
+ base: {
501
+ taskId: bigint | null;
502
+ client: string;
503
+ amount: bigint;
504
+ deadline: bigint;
505
+ adjuntos: AdjuntoRecibido[];
506
+ },
419
507
  sobre: CallEnvelope | null,
420
508
  ): TaskContext {
421
509
  return {
@@ -453,10 +541,36 @@ async function work(taskId: bigint, brief: string, sobre: CallEnvelope | null):
453
541
  // Lo PRIMERO, antes de trabajar: si el proceso muere a mitad, esto es lo
454
542
  // único que permite retomarlo. Guardarlo después sería guardarlo nunca.
455
543
  saveBrief(taskId, brief);
544
+
545
+ // Si el encargo anuncia adjuntos, no se empieza hasta tenerlos todos.
546
+ //
547
+ // La guarda va AQUÍ y no en la ruta HTTP porque el vigilante también llama
548
+ // a `work` —al retomar una tarea tras un reinicio— y ahí no hay petición
549
+ // que mirar. Sin esto, un agente que se reinicia entre el brief y la
550
+ // subida se pondría a trabajar sin la foto, entregaría lo que pudiera y
551
+ // anclaría ese resultado a medias en la cadena.
552
+ const { recibidos, faltan } = repasarAdjuntos(taskId, brief);
553
+ if (faltan.length > 0) {
554
+ console.log(
555
+ `[panal] #${taskId} en espera de ${faltan.length} adjunto(s): ${faltan.map((f) => f.name).join(', ')}`,
556
+ );
557
+ return;
558
+ }
559
+ if (recibidos.length > 0) console.log(`[panal] #${taskId} con ${recibidos.length} adjunto(s) del cliente`);
560
+
456
561
  const task = await leerTarea(taskId);
457
562
  const salida = await handleTask(
458
563
  brief,
459
- contexto({ taskId, client: task.client, amount: task.amount, deadline: task.deadline }, sobre),
564
+ contexto(
565
+ {
566
+ taskId,
567
+ client: task.client,
568
+ amount: task.amount,
569
+ deadline: task.deadline,
570
+ adjuntos: recibidos,
571
+ },
572
+ sobre,
573
+ ),
460
574
  );
461
575
 
462
576
  // Tu handleTask puede devolver un texto a secas —lo normal— o un texto con
@@ -563,6 +677,25 @@ function json(res: ServerResponse, status: number, body: unknown): void {
563
677
  res.end(JSON.stringify(body));
564
678
  }
565
679
 
680
+ /**
681
+ * El cuerpo en crudo, con su propio tope.
682
+ *
683
+ * Va aparte de `readBody` a propósito: los 256 KB de MAX_BODY protegen las
684
+ * rutas de texto y tienen que seguir siendo pequeños. Una foto no cabe ahí, y
685
+ * subirle el tope a todas las rutas para que quepa sería abrir la puerta que
686
+ * ese límite cierra.
687
+ */
688
+ async function readBodyBytes(req: IncomingMessage, max: number): Promise<Buffer> {
689
+ const chunks: Buffer[] = [];
690
+ let total = 0;
691
+ for await (const chunk of req) {
692
+ total += (chunk as Buffer).length;
693
+ if (total > max) throw new Error(`cuerpo demasiado grande (tope ${max} bytes)`);
694
+ chunks.push(chunk as Buffer);
695
+ }
696
+ return Buffer.concat(chunks);
697
+ }
698
+
566
699
  async function readBody(req: IncomingMessage): Promise<string> {
567
700
  const chunks: Buffer[] = [];
568
701
  let total = 0;
@@ -744,9 +877,15 @@ const server = createServer((req, res) => {
744
877
  res.setHeader('access-control-allow-methods', 'GET, POST, OPTIONS');
745
878
  // Las credenciales van en cabeceras propias, y esas NO son simples: sin
746
879
  // declararlas aquí el navegador bloquea la descarga en el preflight.
880
+ //
881
+ // Cada cabecera nueva hay que añadirla A ESTA LISTA. Se olvidó con
882
+ // `x-panal-filename` al añadir los adjuntos, y el efecto es de los que
883
+ // no se ven leyendo el código: el servidor está bien, la ruta está bien,
884
+ // y el navegador se niega a hacer la petición sin dejar rastro en el log
885
+ // del agente.
747
886
  res.setHeader(
748
887
  'access-control-allow-headers',
749
- 'content-type, x-panal-address, x-panal-signature, x-panal-expira, x-payment, x-payment-payer',
888
+ 'content-type, x-panal-address, x-panal-signature, x-panal-expira, x-panal-filename, x-payment, x-payment-payer',
750
889
  );
751
890
  res.setHeader('access-control-max-age', '86400');
752
891
  res.writeHead(204).end();
@@ -789,6 +928,15 @@ const server = createServer((req, res) => {
789
928
  body: `{"brief": string (máx. ${MAX_BRIEF_CHARS} chars), "address": "0x…", "signature": "0x…"}`,
790
929
  maxBriefChars: MAX_BRIEF_CHARS,
791
930
  },
931
+ postAttachment: {
932
+ method: 'POST',
933
+ path: '/upload/:taskId',
934
+ signMessage: 'Panal brief #<taskId> (la MISMA firma que el encargo, no hace falta otra)',
935
+ body: 'los bytes en crudo; el nombre en la cabecera X-Panal-Filename',
936
+ howTo:
937
+ 'anuncia cada adjunto en el brief con un bloque [panal-attach/1] ANTES de contratar, y sube los bytes aquí después. Sólo se aceptan los que el encargo anuncie.',
938
+ maxAttachmentBytes: MAX_FILE_BYTES,
939
+ },
792
940
  getResult: {
793
941
  method: 'GET',
794
942
  path: '/result/:taskId',
@@ -888,7 +1036,18 @@ const server = createServer((req, res) => {
888
1036
  try {
889
1037
  const salida = await handleTask(
890
1038
  prompt,
891
- contexto({ taskId: null, client: leido.payment.payer, amount: cobro.amount, deadline: 0n }, sobre),
1039
+ contexto(
1040
+ {
1041
+ taskId: null,
1042
+ client: leido.payment.payer,
1043
+ amount: cobro.amount,
1044
+ deadline: 0n,
1045
+ // Una llamada x402 es una pregunta y una respuesta: no hay tarea
1046
+ // donde anclar un adjunto, así que tampoco hay adjuntos.
1047
+ adjuntos: [],
1048
+ },
1049
+ sobre,
1050
+ ),
892
1051
  );
893
1052
  // En una llamada x402 no hay tarea, así que no hay nada que anclar ni
894
1053
  // ninguna firma con la que proteger una descarga: los archivos no
@@ -1006,12 +1165,143 @@ const server = createServer((req, res) => {
1006
1165
  return;
1007
1166
  }
1008
1167
 
1168
+ // El encargo se guarda YA, antes de contestar: la subida que viene
1169
+ // detrás lo necesita en disco para saber qué bytes puede aceptar.
1170
+ saveBrief(taskId, body.brief);
1171
+ const { faltan } = repasarAdjuntos(taskId, body.brief);
1172
+ if (faltan.length > 0) {
1173
+ // No es un error: es la otra mitad del encargo, que aún viene de
1174
+ // camino. Se contesta exactamente qué se espera para que el cliente lo
1175
+ // suba sin tener que adivinarlo.
1176
+ if (sobre) sobrePendiente.set(taskId.toString(), sobre);
1177
+ json(res, 202, {
1178
+ ok: true,
1179
+ faltanAdjuntos: faltan.map((f) => ({ name: f.name, size: f.size, hash: f.hash })),
1180
+ subirA: `/upload/${taskId}`,
1181
+ });
1182
+ return;
1183
+ }
1184
+
1009
1185
  json(res, 202, { ok: true });
1010
1186
  // Sin await: el cliente no debería esperar a que termines de trabajar.
1011
1187
  void work(taskId, body.brief, sobre);
1012
1188
  return;
1013
1189
  }
1014
1190
 
1191
+ // ---- El cliente sube los adjuntos que su encargo anunció ----------------
1192
+ //
1193
+ // Se firma UNA vez, con el mismo `Panal brief #<id>` que abrió el encargo.
1194
+ // Pedir una firma por archivo sería pedirle tres popups a alguien que ya
1195
+ // pagó, y no compraría nada: lo que decide qué entra no es la firma, es el
1196
+ // manifiesto que la cadena ya cubre.
1197
+ const subida = /^\/upload\/(\d+)$/.exec(url.pathname);
1198
+ if (subida && req.method === 'POST') {
1199
+ const taskId = BigInt(subida[1]!);
1200
+ /** Rechaza vaciando el cuerpo: si no, el cliente ve un reset en vez del motivo. */
1201
+ const rechazar = (status: number, cuerpo: unknown): void => {
1202
+ req.resume();
1203
+ json(res, status, cuerpo);
1204
+ };
1205
+
1206
+ // Lo local primero, que no cuesta ni RPC ni ancho de banda.
1207
+ const brief = loadBrief(taskId);
1208
+ if (!brief) {
1209
+ rechazar(409, { error: 'manda antes el encargo a POST /brief/' + taskId });
1210
+ return;
1211
+ }
1212
+ const anunciados = parseAttachmentsManifest(brief);
1213
+ if (anunciados.length === 0) {
1214
+ rechazar(409, { error: 'ese encargo no anuncia ningún adjunto' });
1215
+ return;
1216
+ }
1217
+
1218
+ const cred = credencialesDe(req, url);
1219
+ if (!cred.address || !cred.signature) {
1220
+ rechazar(400, { error: 'faltan address y signature (cabeceras x-panal-address / x-panal-signature)' });
1221
+ return;
1222
+ }
1223
+
1224
+ const task = await leerTarea(taskId);
1225
+ if (task.worker.toLowerCase() !== account.address.toLowerCase()) {
1226
+ rechazar(403, { error: 'esa tarea no es de este agente' });
1227
+ return;
1228
+ }
1229
+ if (task.status !== TaskStatus.Open) {
1230
+ rechazar(409, { error: `la tarea está ${TaskStatus[task.status]}` });
1231
+ return;
1232
+ }
1233
+ if (cred.address.toLowerCase() !== task.client.toLowerCase()) {
1234
+ rechazar(403, { error: 'solo el cliente de la tarea puede subirle adjuntos' });
1235
+ return;
1236
+ }
1237
+ if (!(await signedBy(briefSignMessage(taskId), cred.signature, task.client))) {
1238
+ rechazar(401, { error: 'la firma no es del cliente de esta tarea' });
1239
+ return;
1240
+ }
1241
+
1242
+ // Nada puede pesar más que el mayor de los adjuntos anunciados: el
1243
+ // tamaño va DENTRO del manifiesto, o sea dentro de lo que la cadena
1244
+ // cubre. Se mira antes de leer para no tragarse los bytes de nadie.
1245
+ const tope = Math.min(MAX_FILE_BYTES, Math.max(...anunciados.map((f) => f.size)));
1246
+ const declarado = Number(req.headers['content-length'] ?? 0);
1247
+ if (declarado > tope) {
1248
+ rechazar(413, { error: `ese archivo son ${declarado} bytes y el mayor que anunciaste mide ${tope}` });
1249
+ return;
1250
+ }
1251
+
1252
+ let bytes: Buffer;
1253
+ try {
1254
+ bytes = await readBodyBytes(req, tope);
1255
+ } catch (err) {
1256
+ json(res, 413, { error: err instanceof Error ? err.message : 'cuerpo demasiado grande' });
1257
+ return;
1258
+ }
1259
+
1260
+ // La guarda. Se busca por hash, así que el nombre que venga en la
1261
+ // cabecera no decide nada: sólo desempata si el mismo archivo se
1262
+ // adjuntó dos veces.
1263
+ // El nombre viene percent-encoded: una cabecera HTTP no admite
1264
+ // caracteres fuera de latin-1, y «recibo ñ.png» es un nombre normal.
1265
+ let nombre: string | undefined;
1266
+ const cabecera = req.headers['x-panal-filename'];
1267
+ if (typeof cabecera === 'string') {
1268
+ try {
1269
+ nombre = decodeURIComponent(cabecera);
1270
+ } catch {
1271
+ nombre = cabecera;
1272
+ }
1273
+ }
1274
+ const anunciado = matchAttachment(anunciados, bytes, nombre);
1275
+ if (!anunciado) {
1276
+ json(res, 403, {
1277
+ error: 'esos bytes no son ninguno de los adjuntos que anuncia el encargo',
1278
+ esperados: anunciados.map((f) => ({ name: f.name, size: f.size, hash: f.hash })),
1279
+ });
1280
+ return;
1281
+ }
1282
+
1283
+ guardarAdjunto(taskId, anunciado.name, bytes);
1284
+ const { faltan: pendientes } = repasarAdjuntos(taskId, brief);
1285
+ console.log(
1286
+ `[panal] #${taskId} adjunto "${anunciado.name}" recibido (${bytes.byteLength} bytes) · faltan ${pendientes.length}`,
1287
+ );
1288
+
1289
+ json(res, 202, {
1290
+ ok: true,
1291
+ guardado: anunciado.name,
1292
+ faltanAdjuntos: pendientes.map((f) => ({ name: f.name, size: f.size, hash: f.hash })),
1293
+ });
1294
+
1295
+ // Con el último adjunto ya se puede trabajar. El encargo estaba en
1296
+ // espera desde que llegó; esto es lo que lo suelta.
1297
+ if (pendientes.length === 0) {
1298
+ const sobreGuardado = sobrePendiente.get(taskId.toString()) ?? null;
1299
+ sobrePendiente.delete(taskId.toString());
1300
+ void work(taskId, brief, sobreGuardado);
1301
+ }
1302
+ return;
1303
+ }
1304
+
1015
1305
  // ---- El cliente recoge su resultado -------------------------------------
1016
1306
  const match = /^\/result\/(\d+)$/.exec(url.pathname);
1017
1307
  if (match && req.method === 'GET') {