> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
atuq §6.3: la búsqueda semántica, VERDE en la jaula — y la barra lateral tiene las dos preguntas
Los cuatro guardianes pasan sobre los artefactos sellados (atuq bfc14c92, puriy-costura 3f31233e,
ia-modelo-embeddings 2c0c4258):
semántico 0.6252 gato / 0.2471 red / 0.0973 pan → y la otra pregunta gana la otra página
control «no hay modelo de embeddings en …» y NINGÚN orden inventado
ia el modelo contesta y el motor se va con el navegador (0 vivos)
foco los tres estados, y el estado intacto tras la sesión
La barra lateral ahora tiene dos botones: «Al modelo» (genera texto) y «A mis páginas» (ordena lo que
ya leíste). No se mezclan a propósito — una inventa y la otra recuerda, y juntas sería imposible
saber cuál contestó. Los resultados van EN ORDEN y sin porcentaje: el puntaje es un coseno y leerlo
como «85 % de acierto» sería inventarle un significado.
⚠ Y el guardián nació midiendo NADA: metía las tres páginas en `<iframe>` y archivó cero, porque el
§6.3 ignora lo que no es marco principal. Encadenadas como navegación de verdad entran las tres; y
las páginas de tránsito van sin texto visible para que el archivo las descarte y la evidencia no
liste coincidencias sin título.
Dos cosas más del camino, las dos medidas: el `cp` del install buscaba el nombre de upstream y el tar
—nuestro— lleva el fichero con nombre corto; y el build murió dos veces por DISCO LLENO (0 bytes en
/mnt/vvv), no por el lock. `scripts/poda-fuentes.sh --horas 6` liberó 4,6 G, que es exactamente para
lo que existe.
diff --git a/docs/26-atuq-envoltorio-gecko.md b/docs/26-atuq-envoltorio-gecko.md
index 85948ed4..4052c8ab 100644
--- a/docs/26-atuq-envoltorio-gecko.md
+++ b/docs/26-atuq-envoltorio-gecko.md
@@ -716,18 +716,27 @@ siempre la más reciente— pasaría sin haber entendido nada. Y las preguntas *
con el texto de su página; si las compartieran, la búsqueda literal también acertaría y esto no
probaría nada semántico.
-⚠ **Y lo que todavía NO corrió** (2026-09-12): ese guardián espera el artefacto sellado del modelo, y
-el build lleva 45 min en la cola del `flock` detrás de otro agente. Lo que sí está medido con el
-modelo de verdad es el camino completo `archive.add` → índice → `archive.ask` **fuera de la jaula**,
-contra el binario del host: 3/3, con los puntajes de abajo. Esta línea se borra cuando el guardián de
-la jaula dé verde.
+**MEDIDO en la jaula, sobre los artefactos sellados** (`atuq bfc14c92`, `puriy-costura 3f31233e`,
+`ia-modelo-embeddings 2c0c4258`):
```
-«¿dónde se echó a dormir el gato?» 0.6466 gato · 0.2194 red · 0.1463 pan
-«cómo bloqueo internet a un programa» 0.4082 red · 0.1408 pan · 0.0954 gato
-«qué lleva el pan» 0.2393 pan · 0.2028 gato · 0.1331 red
+ARCHIVADA … /gato.html ARCHIVADA … /red.html ARCHIVADA … /pan.html
+«¿dónde se echó a dormir el gato?» 0.6252 gato · 0.2471 red · 0.0973 pan
+«cómo bloqueo internet a un programa» 0.4039 red · 0.1398 pan · 0.1066 gato
+por la barra lateral 0.6252 /gato.html …
```
+Y el control negativo: sin modelo, `no hay modelo de embeddings en /usr/share/takana/ia/embeddings.gguf:
+esta imagen no trae ninguno pineado` — **y ningún orden inventado**, que es el único fallo inaceptable
+en una búsqueda.
+
+⚠ **El guardián nació midiendo nada, y lo dijo el propio arnés.** La primera versión metía las tres
+páginas en `<iframe>` para archivarlas en una sola carga, y archivó **cero**: el §6.3 ignora a
+propósito lo que no es el marco principal —un iframe de publicidad no es una página que alguien
+leyó—. Encadenadas como navegación de verdad (cada página lleva a la siguiente con un `meta
+refresh`), las tres entran. De paso: las dos páginas de tránsito van **sin texto visible**, así el
+archivo las descarta y la evidencia no lista dos coincidencias sin título.
+
#### 6.3.quater El modelo que elegimos NO SERVÍA, y el síntoma no se parecía a la causa (2026-09-12)
Esto es la unidad de trabajo más caras de esta tanda y vale escribirla entera, porque la clase de
diff --git a/recipes/atuq/README.md b/recipes/atuq/README.md
index 19c8dd12..e0281861 100644
--- a/recipes/atuq/README.md
+++ b/recipes/atuq/README.md
@@ -252,7 +252,13 @@ sobrevive y medio giga de modelo en RAM no.
El puerto nativo vive en el **fondo**, no en la página del panel: si viviera en la página, cerrar la
barra lateral se llevaría el proceso del host y el modelo cargado con ella. `panel.html?q=…` abre el
-panel con la pregunta ya hecha.
+panel con la pregunta ya hecha, y `?p=…` la manda **a tus páginas** en vez de al modelo.
+
+**Dos preguntas, dos botones, y no se mezclan a propósito.** «Al modelo» genera texto; «A mis
+páginas» ordena por parecido lo que ya leíste (§6.3, `archive.ask`) y muestra los títulos **en
+orden**, sin porcentaje: el puntaje es un coseno y leerlo como «85 % de acierto» sería inventarle un
+significado. Presentar las dos juntas haría imposible saber cuál contestó — una inventa y la otra
+recuerda.
Cuando algo falta, el panel **muestra la causa** («no hay modelo en …», «no hay motor de inferencia
en …»). Una IA que ante un fallo contesta algo genérico es peor que una que no contesta: el usuario
diff --git a/recipes/atuq/extensions/ia/fondo.js b/recipes/atuq/extensions/ia/fondo.js
index 73750088..938927e9 100644
--- a/recipes/atuq/extensions/ia/fondo.js
+++ b/recipes/atuq/extensions/ia/fondo.js
@@ -36,11 +36,17 @@ function conectar() {
const pendiente = r && r.id ? enVuelo.get(r.id) : null;
if (!pendiente) return;
enVuelo.delete(r.id);
- if (r.ok) {
+ if (!r.ok) {
+ di("ERROR " + (r.error || "sin causa"));
+ } else if (r.verb === "archive.ask") {
+ di("ARCHIVO " + (r.matches || []).length + " de " + r.archived +
+ " (indexadas " + r.indexed + ")");
+ for (const m of r.matches || []) {
+ di("COINCIDENCIA " + m.score.toFixed(4) + " " + m.page.url);
+ }
+ } else {
di("RESPUESTA tokens=" + (r.tokens === undefined ? "?" : r.tokens) +
" modelo=" + (r.model || "?") + " texto=" + JSON.stringify(r.answer || ""));
- } else {
- di("ERROR " + (r.error || "sin causa"));
}
pendiente(r);
});
@@ -59,26 +65,30 @@ function conectar() {
return true;
}
-function preguntar(texto) {
+// El mismo puerto sirve las dos preguntas del panel, y no es casualidad: las dos las
+// contesta el MISMO host con el MISMO motor local — una con el modelo de chat y la otra
+// con el de embeddings sobre lo que ya leíste (§6.3).
+function pedir(verbo, campo, texto) {
if (!puerto && !conectar()) {
return Promise.resolve({ ok: false, error: "no hay host nativo en esta imagen" });
}
const id = siguiente++;
- di("PREGUNTA id=" + id + " " + JSON.stringify(texto));
+ di("PIDE " + verbo + " id=" + id + " " + JSON.stringify(texto));
return new Promise((resolver) => {
enVuelo.set(id, resolver);
try {
- puerto.postMessage({ id, verb: "ai.ask", prompt: texto });
+ puerto.postMessage({ id, verb: verbo, [campo]: texto });
} catch (e) {
enVuelo.delete(id);
- resolver({ ok: false, error: "no pude enviar la pregunta: " + e });
+ resolver({ ok: false, error: "no pude enviar: " + e });
}
});
}
browser.runtime.onMessage.addListener((msg) => {
- if (!msg || msg.tipo !== "preguntar" || typeof msg.texto !== "string") return;
- return preguntar(msg.texto);
+ if (!msg || typeof msg.texto !== "string") return;
+ if (msg.tipo === "preguntar") return pedir("ai.ask", "prompt", msg.texto);
+ if (msg.tipo === "archivo") return pedir("archive.ask", "query", msg.texto);
});
di("LISTA v1 el modelo corre en esta máquina; no sale nada a la red");
diff --git a/recipes/atuq/extensions/ia/panel.css b/recipes/atuq/extensions/ia/panel.css
index 74a3eebe..8d65aaf3 100644
--- a/recipes/atuq/extensions/ia/panel.css
+++ b/recipes/atuq/extensions/ia/panel.css
@@ -8,3 +8,6 @@ main { display: flex; flex-direction: column; height: 100vh; box-sizing: border-
.turno.error { color: #b3261e; }
form { display: flex; gap: 6px; }
textarea { flex: 1; resize: vertical; font: inherit; }
+.botones { display: flex; flex-direction: column; gap: 4px; }
+.fuente { display: block; margin-top: 2px; opacity: .8; }
+.fuente a { color: inherit; }
diff --git a/recipes/atuq/extensions/ia/panel.html b/recipes/atuq/extensions/ia/panel.html
index cc56f647..fa92e03e 100644
--- a/recipes/atuq/extensions/ia/panel.html
+++ b/recipes/atuq/extensions/ia/panel.html
@@ -7,7 +7,10 @@
<div id="hilo"></div>
<form id="forma">
<textarea id="texto" rows="3" placeholder="Preguntá algo…"></textarea>
- <button id="enviar" type="submit">Preguntar</button>
+ <div class="botones">
+ <button id="enviar" type="submit">Al modelo</button>
+ <button id="archivo" type="button">A mis páginas</button>
+ </div>
</form>
</main>
<script src="panel.js"></script>
diff --git a/recipes/atuq/extensions/ia/panel.js b/recipes/atuq/extensions/ia/panel.js
index 23b7df5f..166a6443 100644
--- a/recipes/atuq/extensions/ia/panel.js
+++ b/recipes/atuq/extensions/ia/panel.js
@@ -38,6 +38,44 @@ async function preguntar(texto) {
esperando.textContent = r.answer;
}
+// Las páginas ya leídas (§6.3): la misma caja, otra pregunta. No se mezcla con la del
+// modelo a propósito — una INVENTA texto y la otra devuelve lo que vos leíste, y presentarlas
+// juntas haría imposible saber cuál de las dos contestó.
+async function buscarEnMisPaginas(texto) {
+ if (!texto.trim()) return;
+ agregar("yo", texto);
+ const esperando = agregar("", "buscando en lo que leíste…");
+ let r;
+ try {
+ r = await browser.runtime.sendMessage({ tipo: "archivo", texto });
+ } catch (e) {
+ r = { ok: false, error: String(e) };
+ }
+ if (!r || !r.ok) {
+ esperando.className = "turno error";
+ esperando.textContent = (r && r.error) || "el host no contestó";
+ return;
+ }
+ const m = r.matches || [];
+ if (!m.length) {
+ esperando.textContent = `no encontré nada entre tus ${r.archived} páginas`;
+ return;
+ }
+ // Se muestran EN ORDEN y sin porcentaje: el puntaje es un coseno y leerlo como
+ // «85 % de acierto» sería inventarle un significado que no tiene.
+ esperando.textContent = `de tus ${r.archived} páginas, en orden de parecido:`;
+ for (const c of m.slice(0, 8)) {
+ const d = document.createElement("div");
+ d.className = "fuente";
+ const a = document.createElement("a");
+ a.href = c.page.url;
+ a.textContent = c.page.title || c.page.url;
+ a.title = c.page.url;
+ d.appendChild(a);
+ esperando.appendChild(d);
+ }
+}
+
document.getElementById("forma").addEventListener("submit", (ev) => {
ev.preventDefault();
const caja = document.getElementById("texto");
@@ -46,5 +84,17 @@ document.getElementById("forma").addEventListener("submit", (ev) => {
preguntar(texto);
});
-const q = new URLSearchParams(location.search).get("q");
+document.getElementById("archivo").addEventListener("click", () => {
+ const caja = document.getElementById("texto");
+ const texto = caja.value;
+ caja.value = "";
+ buscarEnMisPaginas(texto);
+});
+
+// `?q=` pregunta al modelo; `?p=` busca en las páginas. Las dos son la puerta por la que
+// entran las preguntas que no se tipean acá (y las que usan los guardianes).
+const params = new URLSearchParams(location.search);
+const q = params.get("q");
if (q) preguntar(q);
+const pag = params.get("p");
+if (pag) buscarEnMisPaginas(pag);
diff --git a/recipes/ia-modelo-embeddings.toml b/recipes/ia-modelo-embeddings.toml
index 89891acc..2a360766 100644
--- a/recipes/ia-modelo-embeddings.toml
+++ b/recipes/ia-modelo-embeddings.toml
@@ -65,7 +65,11 @@ compile = "true"
install = '''
set -e
mkdir -p /out/usr/share/takana/ia
-cp Qwen3-Embedding-0.6B-Q8_0.gguf /out/usr/share/takana/ia/embeddings.gguf
+# ⚠ El fichero DENTRO del tar se llama `qwen3-emb-q8.gguf` y no como en upstream: el tar lo
+# armamos nosotros y quedó con el nombre corto con el que se bajó. Se deja así —renombrarlo
+# significaría reempaquetar y volver a subir 610 MB— pero queda dicho acá, porque el bloque de
+# procedencia de arriba sí nombra el fichero de upstream y las dos cosas tienen que cuadrar.
+cp qwen3-emb-q8.gguf /out/usr/share/takana/ia/embeddings.gguf
M=/out/usr/share/takana/ia/embeddings.gguf
# ── GUARDIÁN 1: QUE SEA UN GGUF ──────────────────────────────────────────────────────────────
diff --git a/recipes/puriy-costura.toml b/recipes/puriy-costura.toml
index e285666a..6b2ec638 100644
--- a/recipes/puriy-costura.toml
+++ b/recipes/puriy-costura.toml
@@ -43,7 +43,7 @@ license = "MIT OR Apache-2.0"
[source]
repo = "gitea@git.tawasuyu.net:tawasuyu/tawasuyu.git"
-commit = "8415e5d12a879026a7518fb2f626bce1bcdac00b"
+commit = "43f9e09f476f159635c5b9ea209aeb854b92606c"
# ⚠ SIN ESTO EL BUILD MUERE EN UN SITIO QUE NO NOMBRA LA CAUSA — medido acá el 2026-09-10.
# tawasuyu COMMITEA su propio `vendor/`: adentro tiene un `smithay` parcheado a mano (tearing/flip
# async para el compositor), y lo enchufa con `[patch.crates-io] smithay = { path = "vendor/smithay" }`.
diff --git a/scripts/test-atuq-archivo-semantico.py b/scripts/test-atuq-archivo-semantico.py
index b108d8bb..7f990c12 100644
--- a/scripts/test-atuq-archivo-semantico.py
+++ b/scripts/test-atuq-archivo-semantico.py
@@ -87,15 +87,26 @@ class Servidor(http.server.SimpleHTTPRequestHandler):
ruta = self.path.split("?")[0]
if ruta in PAGINAS:
titulo, texto = PAGINAS[ruta]
+ # Cada página lleva a la siguiente: tres cargas de PRIMER NIVEL con un solo arranque.
+ orden = list(PAGINAS)
+ i = orden.index(ruta)
+ siguiente = orden[i + 1] if i + 1 < len(orden) else "/fin.html"
datos = (f'<!doctype html><html><head><meta charset="utf-8"><title>{titulo}</title>'
+ f'<meta http-equiv="refresh" content="3;url={siguiente}">'
f"</head><body><h1>{titulo}</h1><p>{texto}</p></body></html>").encode()
elif ruta == "/ir.html":
- # Una página que pasea por las tres: el guión de contenido archiva cada una al cargar.
- saltos = "".join(f'<iframe src="{r}" width="10" height="10"></iframe>' for r in PAGINAS)
- datos = (f'<!doctype html><html><head><meta charset="utf-8"></head><body>{saltos}'
- "</body></html>").encode()
+ # ⚠ La primera versión de esto metía las tres páginas en `<iframe>` y NO archivaba
+ # ninguna: el archivo ignora a propósito lo que no es el marco principal (un iframe de
+ # publicidad no es una página que alguien leyó). Ahora se ENCADENAN como navegación de
+ # verdad, que es lo que hace una persona.
+ datos = (b'<!doctype html><html><head><meta charset="utf-8">'
+ b'<meta http-equiv="refresh" content="1;url=/gato.html">'
+ # Sin texto visible: el archivo descarta lo que no tiene nada que buscar, así
+ # que esta página de tránsito no ensucia la evidencia con una entrada sin título.
+ b"</head><body></body></html>")
else:
- datos = b"<html><body>nada</body></html>"
+ # Igual que `/ir.html`: sin texto visible, para que el final de la cadena no se archive.
+ datos = b"<html><body></body></html>"
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(datos)))
@@ -192,6 +203,8 @@ def main():
correr(capas if not negativo else [atuq, costura, llama], salida, guion)
log = open(os.path.join(salida, "consola.log"), errors="replace").read()
+ base = next((l[len("IA BASE "):].strip() for l in log.splitlines()
+ if l.startswith("IA BASE ")), None)
lineas = [l[len("ARCHIVO "):] for l in log.splitlines() if l.startswith("ARCHIVO ")]
archivadas = [l for l in lineas if l.startswith("ARCHIVADA ")]
for l in archivadas:
@@ -230,9 +243,40 @@ def main():
fatal(f"«{q}» puso primera a {m[0]['page']['url']} y esperaba {esperada}"
" — el orden no sigue al SIGNIFICADO")
+ # ── Y LA BARRA LATERAL, que es por donde lo usa una persona ──────────────────────
+ # Lo de arriba mide el host; esto mide que la extensión llegue hasta él y muestre el
+ # orden. Sin esta mitad, la función existiría y no habría cómo usarla.
+ if not base:
+ fatal("la extensión de IA no dijo su URL base: no puedo abrir el panel")
+ q, esperada = PREGUNTAS[0]
+ panel = f"{base}panel.html?p={q.replace(' ', '%20').replace('¿', '%C2%BF')}"
+ guion2 = f"""
+ mkdir -p /root /salida/hogar
+ [ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id
+ export HOME=/salida/hogar
+ timeout 90 /usr/bin/atuq --profile /salida/perfil --no-remote --headless '{panel}' \
+ > /salida/panel.log 2>&1
+ exit 0
+ """
+ correr(capas, salida, guion2)
+ plog = open(os.path.join(salida, "panel.log"), errors="replace").read()
+ coincidencias = [l[len("IA COINCIDENCIA "):] for l in plog.splitlines()
+ if l.startswith("IA COINCIDENCIA ")]
+ print("\n por la barra lateral:")
+ for c in coincidencias[:4]:
+ print(" " + c)
+ if not coincidencias:
+ destino = os.path.join(ROOT, "work", "atuq-panel-sem.log")
+ os.makedirs(os.path.dirname(destino), exist_ok=True)
+ shutil.copy(os.path.join(salida, "panel.log"), destino)
+ fatal(f"el panel no mostró ninguna coincidencia — log en {destino}")
+ if esperada not in coincidencias[0]:
+ fatal(f"el panel puso primera a «{coincidencias[0]}» y esperaba {esperada}")
+
print("\n✓ el archivo contesta por PARECIDO: cada pregunta pone primera a su página")
print("✓ y son dos preguntas con ganadores distintos — un ranking constante no pasaría")
print("✓ todo sin navegador: el archivo quedó en disco y se consulta por el cable")
+ print("✓ y por la barra lateral también: la extensión llega al host y muestra el orden")
return 0
finally:
servidor.shutdown()