tawasuyu

sergio/takana

> 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…

TOML 1572Shell 203Rust 109Markdown 993728 Definiciones

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.

4a2801331696245af7df3cccbbac5da68360bf98 · por Sergio · (hace 1 mes) · padre 097579546 · ver el árbol en este commit

Delta semántico · 7

recipes/atuq/extensions/ia/fondo.js

recipes/atuq/extensions/ia/panel.js

scripts/test-atuq-archivo-semantico.py

9 archivos · +156 −27

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()