@nyx5/nyx5 0.6.0 → 0.8.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 +50 -7
- package/bin/nyx5.js +81 -2
- package/demo/edge-local.mjs +41 -0
- package/demo/x402-pagar.mjs +64 -0
- package/demo/x402-testnet.mjs +80 -0
- package/docs/ARQUITECTURA.md +354 -0
- package/docs/SPEC.md +414 -15
- package/examples/ci/nyx5-ejecutor.yml +82 -0
- package/examples/frameworks/README.md +55 -0
- package/examples/frameworks/__pycache__/crewai_nyx5.cpython-314.pyc +0 -0
- package/examples/frameworks/__pycache__/langgraph_nyx5.cpython-314.pyc +0 -0
- package/examples/frameworks/__pycache__/nyx5_http.cpython-314.pyc +0 -0
- package/examples/frameworks/claude-agent-sdk.mjs +48 -0
- package/examples/frameworks/crewai_nyx5.py +72 -0
- package/examples/frameworks/http.md +117 -0
- package/examples/frameworks/langgraph_nyx5.py +90 -0
- package/examples/frameworks/nyx5_http.py +255 -0
- package/examples/frameworks/openai-agents.mjs +41 -0
- package/examples/hola-mundo.mjs +4 -4
- package/migrations/0006_puente_remoto.sql +19 -0
- package/migrations/0007_indice_reputacion.sql +31 -0
- package/migrations/0008_notaria.sql +18 -0
- package/migrations/0009_contratos_estado.sql +4 -0
- package/package.json +4 -2
- package/server.json +2 -2
- package/src/correo/acciones.js +216 -0
- package/src/correo/adjuntos.js +185 -0
- package/src/correo/agente.js +370 -22
- package/src/correo/asistente.js +599 -0
- package/src/correo/ci.js +262 -0
- package/src/correo/cobro.js +164 -0
- package/src/correo/estafeta.js +1548 -58
- package/src/correo/ideas.js +154 -0
- package/src/correo/indice.js +178 -0
- package/src/correo/politica.js +280 -2
- package/src/correo/resolver.js +35 -11
- package/src/libro/contratos.js +116 -7
- package/src/libro/estado.js +150 -0
- package/src/libro/informe.js +147 -3
- package/src/libro/libro.js +83 -12
- package/src/libro/notaria.js +74 -0
- package/src/libro/verifica.js +239 -36
- package/src/nucleo/almacen-d1.js +143 -14
- package/src/nucleo/almacen.js +137 -13
- package/src/nucleo/boveda.js +37 -0
- package/src/nucleo/crypto.js +55 -12
- package/src/nucleo/keccak.js +118 -0
- package/src/nucleo/objetos.js +78 -0
- package/src/nucleo/secp256k1.js +228 -0
- package/src/plataformas/app-html.js +1 -1
- package/src/plataformas/home-html.js +1 -1
- package/src/plataformas/iconos.js +6 -0
- package/src/plataformas/node.js +28 -4
- package/src/plataformas/spec-html.js +1 -1
- package/src/plataformas/terms-html.js +2 -0
- package/src/plataformas/worker.js +52 -7
- package/src/puentes/ejecutor.js +118 -0
- package/src/puentes/herramientas.js +315 -0
- package/src/puentes/mcp-remoto.js +71 -0
- package/src/puentes/mcp.js +5 -99
- package/src/puentes/oauth.js +361 -0
- package/src/puentes/x402-pagador.js +248 -0
- package/src/puentes/x402.js +266 -0
- package/src/version.js +4 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Nyx5/1 — ideas@: el buzón automático de vacaciones. RECIBE, GUARDA y CONFIRMA. NUNCA EJECUTA.
|
|
2
|
+
//
|
|
3
|
+
// Nació del pedido de Nicholas del 14-sep-2026 (se va un mes): un lugar donde dejar una idea desde
|
|
4
|
+
// el teléfono y saber que quedó anotada, sin que nadie la ejecute ni la interprete. Corre desde el
|
|
5
|
+
// reloj programado de la casa, sin el Mac y sin ningún modelo de lenguaje.
|
|
6
|
+
//
|
|
7
|
+
// Límites, y son DE CÓDIGO, no de configuración:
|
|
8
|
+
// - Este módulo no importa nada que hable con la API de Anthropic ni con el Libro, y no tiene
|
|
9
|
+
// `fetch`. Lo único que sabe mandar es la confirmación a quien escribió (`agente.send` una vez,
|
|
10
|
+
// al `from` del sobre recibido). `test/ideas.test.js` lo comprueba por inspección de la fuente
|
|
11
|
+
// (lista CERRADA de lo que puede tocar de la estafeta, del almacén y del agente) y espiando las
|
|
12
|
+
// salidas de la casa durante el tick.
|
|
13
|
+
// - Sólo registra sobres FIRMADOS de quien está en la lista del buzón. La puerta (`puertaIdeas`,
|
|
14
|
+
// que `inbound` consulta para este buzón) rechaza al resto ANTES de guardar nada: ni el `intro`
|
|
15
|
+
// corto ni el aval con fianza que la política general deja pasar (§9) sirven aquí, porque este
|
|
16
|
+
// buzón no tiene a nadie que los lea. El tick vuelve a mirar la lista por si algo entró por
|
|
17
|
+
// otra puerta (un correo, un sobre anterior al alta): se cierra sin registrar y sin contestar.
|
|
18
|
+
// - Cupo diario por remitente en la puerta (revisión adversarial del 14-sep-2026: una dirección
|
|
19
|
+
// de la lista podía meter 112 MB por minuto en el buzón, que no se borra nunca): 200 ideas y
|
|
20
|
+
// 5 MB por día UTC; lo que sobra rebota con motivo desde la puerta, no desde este módulo.
|
|
21
|
+
// - El contenido no se guarda aparte: sigue cifrado en el buzón de ideas@. El registro lleva
|
|
22
|
+
// sólo el número, quién, cuándo, el hash del sobre, el hilo y el proyecto.
|
|
23
|
+
// - El texto de la confirmación es FIJO (aprobado con el pedido: «registra y confirma, nunca
|
|
24
|
+
// ejecuta») y vive en un solo lugar: `CONFIRMACION`.
|
|
25
|
+
import { parseAddress } from './resolver.js';
|
|
26
|
+
import { sha256hex, canonical } from '../nucleo/crypto.js';
|
|
27
|
+
import { proyectoDe, rolDe } from './politica.js';
|
|
28
|
+
|
|
29
|
+
export const LOCAL = 'ideas';
|
|
30
|
+
export const NS = 'ideas'; // nyx5_kv: `_n` (contador), `n:<000001>` (registro), `sobre:<id>` (idempotencia)
|
|
31
|
+
export const NS_CUPO = 'ideas-cupo'; // nyx5_kv: `<from>:<día>:n` y `<from>:<día>:bytes`, vencen al día siguiente
|
|
32
|
+
export const VIA = 'ideas'; // custody.via de la tarjeta: la casa firma y descifra en su nombre
|
|
33
|
+
export const CUPO_DIARIO = { ideas: 200, bytes: 5 * 1024 * 1024 };
|
|
34
|
+
export const PAGINA = 1000; // GET /ideas devuelve hasta esto por página, con `next`
|
|
35
|
+
const SISTEMA = new Set(['postmaster', 'libro', 'verifica', 'tareas']);
|
|
36
|
+
const TURNO_MS = 15 * 60_000;
|
|
37
|
+
const DIA_MS = 24 * 3600 * 1000;
|
|
38
|
+
|
|
39
|
+
export const idDeIdea = (n) => `IDEA-${String(n).padStart(3, '0')}`;
|
|
40
|
+
const claveDe = (n) => `n:${String(n).padStart(6, '0')}`;
|
|
41
|
+
// «2026-09-15 10:22» a partir de una fecha ISO (siempre UTC).
|
|
42
|
+
export const fechaCorta = (isoStr) => String(isoStr).slice(0, 16).replace('T', ' ');
|
|
43
|
+
export const CONFIRMACION = (id, at) => `Saved as ${id} on ${fechaCorta(at)} UTC. Nothing was executed: this mailbox only records and confirms. Nicholas reads it when he is back.`;
|
|
44
|
+
|
|
45
|
+
const minus = (x) => String(x || '').toLowerCase();
|
|
46
|
+
function enLista(lista, from) {
|
|
47
|
+
const f = minus(from);
|
|
48
|
+
let dominio = ''; try { dominio = parseAddress(f).domain; } catch { dominio = ''; }
|
|
49
|
+
return lista.some((x) => x === f || (dominio && x === dominio));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// La puerta de ideas@, consultada por `inbound` DESPUÉS de la política general y ANTES de guardar.
|
|
53
|
+
// Devuelve null si el sobre puede entrar, o `{ code, reason }` para rechazarlo (el rebote lo hace la
|
|
54
|
+
// estafeta emisora, como con cualquier rechazo de la puerta: no es una salida de este módulo).
|
|
55
|
+
// Rechaza (403, permanente) a quien no está en la lista y a quien agotó su cupo del día: un
|
|
56
|
+
// rechazo transitorio (429) dejaría los sobres reintentando en la cola de la otra casa un día entero.
|
|
57
|
+
// Lo que NO cubre: el cupo se cuenta al aceptar en la puerta, así que dos casas emisoras que
|
|
58
|
+
// entreguen a la vez pueden pasarse por uno; y sólo protege el buzón de ideas@, no el resto de la casa.
|
|
59
|
+
export async function puertaIdeas(est, env, rec, { cupo = CUPO_DIARIO, nowMs = Date.now() } = {}) {
|
|
60
|
+
const lista = (rec?.inbox?.allowlist || []).map(minus);
|
|
61
|
+
if (!enLista(lista, env.from)) return { code: 403, reason: 'the ideas mailbox records only signed envelopes from its list: not recorded' };
|
|
62
|
+
if (!est.store.kvIncrement) return null;
|
|
63
|
+
const dia = new Date(nowMs).toISOString().slice(0, 10);
|
|
64
|
+
const vence = (Math.floor(nowMs / DIA_MS) + 2) * DIA_MS;
|
|
65
|
+
const clave = `${minus(env.from)}:${dia}`;
|
|
66
|
+
const bytes = Buffer.byteLength(JSON.stringify(env));
|
|
67
|
+
// Se suma ANTES de comparar: con dos relojes contando a la vez, el que se pasa cierra el día.
|
|
68
|
+
const n = await est.store.kvIncrement(NS_CUPO, `${clave}:n`, vence, nowMs);
|
|
69
|
+
const total = await est.store.kvIncrement(NS_CUPO, `${clave}:bytes`, vence, nowMs, bytes);
|
|
70
|
+
if (n > cupo.ideas || total > cupo.bytes) return { code: 403, reason: `daily limit of the ideas mailbox reached for this sender (${cupo.ideas} ideas or ${Math.round(cupo.bytes / 1024 / 1024)} MB per UTC day): not recorded` };
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// El registro ordenado por número, para GET /ideas: hasta `limit` registros con número > `after`.
|
|
75
|
+
export async function listarIdeas(est, { after = 0, limit = PAGINA } = {}) {
|
|
76
|
+
const filas = await est.store.kvList(NS, { prefix: 'n:', after: after > 0 ? claveDe(after) : undefined, limit });
|
|
77
|
+
return filas.map((f) => f.doc).sort((a, b) => a.n - b.n);
|
|
78
|
+
}
|
|
79
|
+
// Lo que contesta GET /ideas: `total` es el último número asignado (puede superar a los registros
|
|
80
|
+
// si un reloj cayó entre asignar el número y escribir el registro), `next` es el número desde el
|
|
81
|
+
// que sigue la página siguiente, o null si ésta es la última.
|
|
82
|
+
export async function paginaDeIdeas(est, { after = 0 } = {}) {
|
|
83
|
+
const ideas = await listarIdeas(est, { after, limit: PAGINA });
|
|
84
|
+
const total = Number(await est.store.kvGet(NS, '_n')) || 0;
|
|
85
|
+
return { total, count: ideas.length, ideas, next: ideas.length === PAGINA ? ideas[ideas.length - 1].n : null };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Un tick: cada sobre pendiente del buzón se registra (número correlativo durable) y se confirma.
|
|
89
|
+
// Devuelve cuántas ideas registró. Dos relojes sobre el mismo almacén no repiten número ni
|
|
90
|
+
// confirmación: un turno por sobre (`kvPutIfAbsent`) y un número por sobre (`sobre:<id>`).
|
|
91
|
+
// `maxPorTick` acota las ideas registradas por pasada; `maxDescartesPorTick`, lo que se cierra sin
|
|
92
|
+
// registrar (correo sin firma, rebotes del postmaster, algo anterior al alta): un buzón lleno de
|
|
93
|
+
// eso no puede comerse el minuto del reloj, y tampoco puede dejar sin turno a las ideas de verdad
|
|
94
|
+
// (son cupos separados).
|
|
95
|
+
export async function atenderIdeas(est, { maxPorTick = 20, maxDescartesPorTick = 200 } = {}) {
|
|
96
|
+
if (!est.ideas?.enabled || !est.store.kvIncrement) return 0;
|
|
97
|
+
const rec = await est.store.getAgent(LOCAL);
|
|
98
|
+
if (!rec || rec.revoked || rec.custody?.via !== VIA) return 0;
|
|
99
|
+
const lista = (rec.inbox?.allowlist || []).map(minus);
|
|
100
|
+
const propia = `${LOCAL}@${est.domain}`;
|
|
101
|
+
let registradas = 0;
|
|
102
|
+
let vistos = 0, descartes = 0;
|
|
103
|
+
for (const m of await est.store.listMail(LOCAL)) {
|
|
104
|
+
if (vistos >= maxPorTick && descartes >= maxDescartesPorTick) break;
|
|
105
|
+
const e = m.envelope || {};
|
|
106
|
+
let de; try { de = parseAddress(e.from); } catch { continue; }
|
|
107
|
+
// Lo que manda la casa (un rebote, un recibo) o el propio buzón no es una idea: se cierra y sigue.
|
|
108
|
+
const deSistema = (de.domain === est.domain && SISTEMA.has(de.local)) || e.from === propia || e.type === 'receipt';
|
|
109
|
+
// Sin firma (un correo) o fuera de la lista (algo que entró antes del alta o por otra puerta): se
|
|
110
|
+
// cierra sin registrar y sin contestar. Contestarle a un desconocido sería la salida que este
|
|
111
|
+
// buzón no tiene.
|
|
112
|
+
const ajeno = !e.signature || m.from_verified === false || !enLista(lista, e.from);
|
|
113
|
+
if (deSistema || ajeno) {
|
|
114
|
+
if (descartes >= maxDescartesPorTick) continue;
|
|
115
|
+
descartes++;
|
|
116
|
+
if (!deSistema) est.log(`ideas@: sobre ${e.id} de ${e.from} descartado (${!e.signature || m.from_verified === false ? 'sin firma' : 'fuera de la lista'})`);
|
|
117
|
+
await est.store.ackMail(LOCAL, e.id);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (vistos >= maxPorTick) continue;
|
|
121
|
+
vistos++;
|
|
122
|
+
if (!(await est.store.kvPutIfAbsent('ideas-turno', e.id, { at: new Date().toISOString() }, Date.now() + TURNO_MS))) continue;
|
|
123
|
+
try {
|
|
124
|
+
const agente = await est.agenteDeBoveda(LOCAL);
|
|
125
|
+
if (!agente) throw new Error('la bóveda no tiene la llave de ideas@');
|
|
126
|
+
// Un número por sobre: si un tick anterior asignó número y no llegó a confirmar, se reutiliza.
|
|
127
|
+
let n = Number((await est.store.kvGet(NS, `sobre:${e.id}`))?.n) || 0;
|
|
128
|
+
if (!n) {
|
|
129
|
+
n = await est.store.kvIncrement(NS, '_n');
|
|
130
|
+
await est.store.kvPut(NS, `sobre:${e.id}`, { n });
|
|
131
|
+
}
|
|
132
|
+
// El registro se escribe si no está, aunque el número ya estuviera asignado: un reloj que cayó
|
|
133
|
+
// entre asignar el número y escribir el registro no puede dejar una idea confirmada y sin
|
|
134
|
+
// registro (revisión adversarial del 14-sep-2026). No depende del contenido: si el sobre no se
|
|
135
|
+
// puede abrir, se registra igual por su hash.
|
|
136
|
+
let registro = await est.store.kvGet(NS, claveDe(n));
|
|
137
|
+
if (!registro) {
|
|
138
|
+
let abierto = false;
|
|
139
|
+
try { await agente.open(e); abierto = true; } catch { abierto = false; }
|
|
140
|
+
registro = { n, id: idDeIdea(n), id_sobre: e.id, from: e.from, at: new Date().toISOString(), sha256_sobre: sha256hex(canonical(e)), thread: e.thread || e.id, project: proyectoDe(e), role: rolDe(e), opened: abierto };
|
|
141
|
+
await est.store.kvPut(NS, claveDe(n), registro);
|
|
142
|
+
}
|
|
143
|
+
// La ÚNICA salida de este módulo: la confirmación a quien escribió, en su hilo y su proyecto.
|
|
144
|
+
await agente.send({ to: e.from, body: CONFIRMACION(registro.id, registro.at), thread: e.thread || e.id, inReplyTo: e.id, project: registro.project || undefined, role: registro.role || undefined });
|
|
145
|
+
await est.store.ackMail(LOCAL, e.id);
|
|
146
|
+
registradas++;
|
|
147
|
+
await est._evento('idea_saved', e.from, { idea: registro.id, sobre: e.id });
|
|
148
|
+
} catch (err) {
|
|
149
|
+
// El turno queda tomado 15 minutos y se reintenta; el número, si ya salió, se conserva.
|
|
150
|
+
est.log(`ideas@: ${e.id} no se pudo confirmar: ${err.message}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return registradas;
|
|
154
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Nyx5/1 — búsqueda del índice federado con reputación y precio (NX-302).
|
|
2
|
+
//
|
|
3
|
+
// UNA definición para los dos almacenes: qué columnas se extraen de una tarjeta, cómo se
|
|
4
|
+
// codifica el cursor, qué filtros se aceptan y cómo se pagina. FileStore usa `buscarEnMemoria`
|
|
5
|
+
// tal cual; D1Store traduce las mismas reglas a SQL. Si las dos implementaciones divergen, el
|
|
6
|
+
// test de paginación (test/busqueda.test.js) corre sobre ambas y lo dice.
|
|
7
|
+
//
|
|
8
|
+
// Orden: (puntaje DESC, los sin puntaje al FINAL, dirección ASC). Cursor opaco = base64url de
|
|
9
|
+
// JSON { s, a, g }: el puntaje y la dirección de la última fila servida, y la GENERACIÓN del
|
|
10
|
+
// índice en la que empezó el recorrido. Nunca un offset: un offset sobre un conjunto que cambia
|
|
11
|
+
// entre páginas salta o repite filas (trampa «ventana horneada e isla»).
|
|
12
|
+
//
|
|
13
|
+
// Por qué la generación: el puntaje CAMBIA con cada rastreo, y un cursor por (puntaje,
|
|
14
|
+
// dirección) sobre un puntaje que se mueve repite al que bajó y se salta al que subió. Cada
|
|
15
|
+
// fila guarda, además del puntaje actual, un historial corto de sus cambios: `hist` =
|
|
16
|
+
// [{ g, s }] (generación en que el puntaje pasó a valer s), los últimos HIST_MAX cambios, y
|
|
17
|
+
// `first_gen`, la generación en que la dirección entró al índice. Un recorrido que empezó en
|
|
18
|
+
// la generación g ordena cada fila por el puntaje que tenía en g: la última entrada de `hist`
|
|
19
|
+
// con g' <= g. Si esa entrada ya se descartó (más de HIST_MAX cambios desde que empezó el
|
|
20
|
+
// recorrido), el puntaje congelado no existe: el recorrido se declara caduco (410) y quien
|
|
21
|
+
// recorre vuelve a empezar; nunca se sirve una página en silencio con otro orden.
|
|
22
|
+
// Lo que NO cubre: un agente que ENTRA al índice después de empezar el recorrido va al final
|
|
23
|
+
// (clave nula) y se ve sólo si el recorrido no pasó ya por los nulos; uno que SALE deja de
|
|
24
|
+
// verse; y los filtros de etiqueta/idioma/precio miran la fila ACTUAL (quien cambió de ficha a
|
|
25
|
+
// mitad del recorrido entra o sale del conjunto según su ficha de hoy).
|
|
26
|
+
|
|
27
|
+
import { puntajeArbitrado } from '../libro/libro.js';
|
|
28
|
+
|
|
29
|
+
const fallo = (status, message) => Object.assign(new Error(message), { status });
|
|
30
|
+
|
|
31
|
+
// ---------- columnas que el índice extrae de una tarjeta ----------
|
|
32
|
+
// El precio sale de `profile.services[].price.tokens` (la ficha con servicios la agrega otro
|
|
33
|
+
// frente; aquí sólo se lee si existe). Sin servicios con precio en tokens, `price_min` es null
|
|
34
|
+
// y el agente NO pasa un filtro `price_max`: no anunciar precio no es ser gratis.
|
|
35
|
+
export function precioMinimo(card) {
|
|
36
|
+
const servicios = card?.profile?.services;
|
|
37
|
+
if (!Array.isArray(servicios)) return null;
|
|
38
|
+
const precios = servicios.map((s) => s?.price?.tokens).filter((t) => Number.isInteger(t) && t >= 0);
|
|
39
|
+
return precios.length ? Math.min(...precios) : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function columnasDeIndice(card) {
|
|
43
|
+
const tags = Array.isArray(card?.profile?.tags) ? card.profile.tags.filter((t) => typeof t === 'string') : [];
|
|
44
|
+
const langs = Array.isArray(card?.profile?.languages) ? card.profile.languages.filter((l) => typeof l === 'string').map((l) => l.toLowerCase()) : [];
|
|
45
|
+
const score = typeof card?._score === 'number' && Number.isFinite(card._score) ? card._score : null;
|
|
46
|
+
const jobs_done = Number.isInteger(card?._jobs_done) ? card._jobs_done : null;
|
|
47
|
+
return { tags, langs, price_min: precioMinimo(card), score, jobs_done };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// El puntaje de un agente a partir de su historial público (§21). Sólo cuenta lo arbitrado por
|
|
51
|
+
// `verifica@` de su casa; una casa que no distingue arbitrados (versión anterior) no da puntaje.
|
|
52
|
+
// Devuelve también el motivo cuando no hay puntaje, para que el rastreo cuente su denominador.
|
|
53
|
+
export function puntajeDe(historial) {
|
|
54
|
+
if (!historial || typeof historial !== 'object') return { score: null, jobs_done: null, motivo: 'sin_historial' };
|
|
55
|
+
if (!historial.arbitrados || typeof historial.arbitrados !== 'object') return { score: null, jobs_done: null, motivo: 'sin_arbitrados' };
|
|
56
|
+
const score = puntajeArbitrado(historial.arbitrados);
|
|
57
|
+
if (score == null) return { score: null, jobs_done: 0, motivo: 'sin_historial' };
|
|
58
|
+
return { score, jobs_done: Number(historial.arbitrados.liberados?.n) || 0, motivo: null };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------- cursor ----------
|
|
62
|
+
export function codificarCursor({ s, a, g }) {
|
|
63
|
+
return Buffer.from(JSON.stringify({ s: s ?? null, a, g })).toString('base64url');
|
|
64
|
+
}
|
|
65
|
+
export function decodificarCursor(texto) {
|
|
66
|
+
const invalido = () => fallo(400, 'invalid cursor: use the next_cursor the index gave you, or none for the first page');
|
|
67
|
+
if (typeof texto !== 'string' || !/^[A-Za-z0-9_-]{1,600}$/.test(texto)) throw invalido();
|
|
68
|
+
let c;
|
|
69
|
+
try { c = JSON.parse(Buffer.from(texto, 'base64url').toString('utf8')); } catch { throw invalido(); }
|
|
70
|
+
if (!c || typeof c !== 'object' || Array.isArray(c)) throw invalido();
|
|
71
|
+
if (Object.keys(c).some((k) => !['s', 'a', 'g'].includes(k))) throw invalido();
|
|
72
|
+
if (!(c.s === null || (typeof c.s === 'number' && Number.isFinite(c.s) && c.s >= 0 && c.s <= 1))) throw invalido();
|
|
73
|
+
if (typeof c.a !== 'string' || !c.a || c.a.length > 320) throw invalido();
|
|
74
|
+
if (!Number.isSafeInteger(c.g) || c.g < 0) throw invalido();
|
|
75
|
+
return c;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------- filtros de la consulta ----------
|
|
79
|
+
// `p` son los parámetros crudos (strings de la URL o valores de la herramienta MCP). Falla con
|
|
80
|
+
// 400 ante lo que no se puede interpretar; `limit` se acota como siempre (nunca fue un 400).
|
|
81
|
+
export const FILTROS = ['q', 'tag', 'lang', 'capability', 'accepts', 'house', 'price_max', 'min_score', 'limit', 'cursor'];
|
|
82
|
+
export function validarFiltros(p = {}) {
|
|
83
|
+
const vacio = (v) => v == null || v === '';
|
|
84
|
+
if (!vacio(p.offset)) throw fallo(400, 'offset is not supported: page with cursor (next_cursor of the previous page)');
|
|
85
|
+
const f = {};
|
|
86
|
+
for (const k of ['q', 'capability', 'accepts', 'house']) {
|
|
87
|
+
if (vacio(p[k])) continue;
|
|
88
|
+
if (typeof p[k] !== 'string' || p[k].length > 200) throw fallo(400, `${k} must be a string of up to 200 characters`);
|
|
89
|
+
if (k === 'capability' && !/^[a-z0-9_-]{1,64}$/.test(p[k])) throw fallo(400, 'capability must be 1-64 lowercase letters, digits, dashes or underscores');
|
|
90
|
+
f[k] = k === 'house' ? p[k].toLowerCase() : p[k];
|
|
91
|
+
}
|
|
92
|
+
if (!vacio(p.tag)) {
|
|
93
|
+
if (typeof p.tag !== 'string' || !/^[a-z0-9-]{1,32}$/.test(p.tag)) throw fallo(400, 'tag must be 1-32 lowercase letters, digits and dashes');
|
|
94
|
+
f.tag = p.tag;
|
|
95
|
+
}
|
|
96
|
+
if (!vacio(p.lang)) {
|
|
97
|
+
const l = String(p.lang).toLowerCase();
|
|
98
|
+
if (!/^[a-z]{2,3}(-[a-z0-9]{2,8})*$/.test(l)) throw fallo(400, 'lang must be a language tag like "es" or "en-US"');
|
|
99
|
+
f.lang = l;
|
|
100
|
+
}
|
|
101
|
+
if (!vacio(p.price_max)) {
|
|
102
|
+
const n = Number(p.price_max);
|
|
103
|
+
if (!Number.isInteger(n) || n < 0) throw fallo(400, 'price_max must be a non-negative integer (tokens)');
|
|
104
|
+
f.price_max = n;
|
|
105
|
+
}
|
|
106
|
+
if (!vacio(p.min_score)) {
|
|
107
|
+
const n = Number(p.min_score);
|
|
108
|
+
if (!Number.isFinite(n) || n < 0 || n > 1) throw fallo(400, 'min_score must be a number between 0 and 1');
|
|
109
|
+
f.min_score = n;
|
|
110
|
+
}
|
|
111
|
+
const lim = Number(p.limit);
|
|
112
|
+
f.limit = Number.isInteger(lim) && lim > 0 ? Math.min(lim, 200) : 50;
|
|
113
|
+
if (!vacio(p.cursor)) f.cursor = decodificarCursor(p.cursor);
|
|
114
|
+
return f;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------- filas: historial de puntajes ----------
|
|
118
|
+
export const HIST_MAX = 16;
|
|
119
|
+
// La fila nueva de una tarjeta al reemplazar la casa en la generación `gen`, dada la fila previa
|
|
120
|
+
// de esa dirección (o null si es nueva). Sólo se anota un cambio cuando el puntaje cambia.
|
|
121
|
+
export function filaDeIndice(house, card, gen, previa) {
|
|
122
|
+
const col = columnasDeIndice(card);
|
|
123
|
+
const hist = Array.isArray(previa?.hist) ? previa.hist.slice() : [];
|
|
124
|
+
if (!hist.length || (hist[hist.length - 1].s ?? null) !== col.score) hist.push({ g: gen, s: col.score });
|
|
125
|
+
return { house, address: card.address, doc: card, ...col, gen, first_gen: previa?.first_gen ?? gen, hist: hist.slice(-HIST_MAX) };
|
|
126
|
+
}
|
|
127
|
+
// Puntaje congelado de una fila para un recorrido que empezó en g. `undefined` = caduco.
|
|
128
|
+
export function claveCongelada(r, g) {
|
|
129
|
+
if ((r.first_gen ?? 0) > g) return null;
|
|
130
|
+
const e = (r.hist || []).filter((x) => x.g <= g).at(-1);
|
|
131
|
+
if (!e) return undefined;
|
|
132
|
+
return e.s ?? null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------- la búsqueda, en memoria (FileStore y referencia para D1) ----------
|
|
136
|
+
// `filas`: [{ house, address, doc, tags, langs, price_min, score, jobs_done, gen, first_gen, hist }]
|
|
137
|
+
export function buscarEnMemoria(filas, f) {
|
|
138
|
+
const gActual = filas.reduce((m, r) => Math.max(m, r.gen || 0), 0);
|
|
139
|
+
if (f.cursor && f.cursor.g > gActual) throw fallo(400, 'invalid cursor: it names a generation of the index that does not exist');
|
|
140
|
+
const g = f.cursor ? f.cursor.g : gActual;
|
|
141
|
+
let sel = filas.filter((r) => filtraFila(r, f)).map((r) => ({ r, k: claveCongelada(r, g) }));
|
|
142
|
+
if (sel.some((x) => x.k === undefined)) throw fallo(410, CADUCO);
|
|
143
|
+
if (f.min_score != null) sel = sel.filter((x) => x.k != null && x.k >= f.min_score);
|
|
144
|
+
const total = sel.length;
|
|
145
|
+
if (f.cursor) sel = sel.filter((x) => despuesDelCursor(x.k, x.r.address, f.cursor));
|
|
146
|
+
sel.sort((x, y) => compararFilas(x.k, x.r.address, y.k, y.r.address));
|
|
147
|
+
const pagina = sel.slice(0, f.limit);
|
|
148
|
+
const ultima = pagina[pagina.length - 1];
|
|
149
|
+
const next_cursor = sel.length > f.limit ? codificarCursor({ s: ultima.k, a: ultima.r.address, g }) : null;
|
|
150
|
+
return { total, agents: pagina.map((x) => x.r.doc), next_cursor };
|
|
151
|
+
}
|
|
152
|
+
export const CADUCO = 'cursor expired: the scores this walk was ordered by are no longer kept by the index; start again without cursor';
|
|
153
|
+
|
|
154
|
+
export function filtraFila(r, f) {
|
|
155
|
+
const c = r.doc || {};
|
|
156
|
+
if (f.house && r.house !== f.house) return false;
|
|
157
|
+
if (f.capability && !c.capabilities?.[f.capability]) return false;
|
|
158
|
+
if (f.accepts && !c.capabilities?.accepts?.includes(f.accepts)) return false;
|
|
159
|
+
if (f.tag && !(r.tags || []).includes(f.tag)) return false;
|
|
160
|
+
if (f.lang && !(r.langs || []).some((l) => l === f.lang || l.startsWith(`${f.lang}-`))) return false;
|
|
161
|
+
if (f.price_max != null && !(r.price_min != null && r.price_min <= f.price_max)) return false;
|
|
162
|
+
if (f.q && !JSON.stringify(c).toLowerCase().includes(String(f.q).toLowerCase())) return false;
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Mismo orden que el SQL: puntaje DESC con nulos al final, dirección ASC (comparación binaria).
|
|
167
|
+
export function compararFilas(kx, ax, ky, ay) {
|
|
168
|
+
if (kx == null && ky == null) return ax < ay ? -1 : ax > ay ? 1 : 0;
|
|
169
|
+
if (kx == null) return 1;
|
|
170
|
+
if (ky == null) return -1;
|
|
171
|
+
if (kx !== ky) return ky - kx;
|
|
172
|
+
return ax < ay ? -1 : ax > ay ? 1 : 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function despuesDelCursor(k, address, cur) {
|
|
176
|
+
if (cur.s == null) return k == null && address > cur.a;
|
|
177
|
+
return (k != null && k < cur.s) || (k === cur.s && address > cur.a) || k == null;
|
|
178
|
+
}
|