diff --git a/docs/capabilities/INDEX.md b/docs/capabilities/INDEX.md
index 0ba8cfc3..37a5320d 100644
--- a/docs/capabilities/INDEX.md
+++ b/docs/capabilities/INDEX.md
@@ -26,6 +26,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [web-proxy](web-proxy.md) | 5 | Captura de trafico HTTP/HTTPS liviana (mitmproxy): proxy con rotacion, navegador proxeado, consulta de capturas, tee del SSE de claude. Alternativa ligera a ZAP/Burp |
| [flow-replay](flow-replay.md) | 3 | Guardar un flujo web (login, reiniciar server, formulario) como funcion reproducible: destila un HAR a call specs y lo reproduce sin navegador (HTTP puro), con fallback a chromium headless/visible. Consume las capturas de web-proxy |
| [hoppscotch](hoppscotch.md) | 7 | Operar Hoppscotch SELF-HOSTED (docker en selfhost/) via API GraphQL: login (magic link headless via mailpit), CRUD de requests (create/update/delete/list), set_environment (idempotente, resuelve secretos pass:). El agente crea/edita y el humano lo ve en vivo en su GUI (subscriptions). build es helper interno de serializacion. Modo .json local ELIMINADO |
+| [dav](dav.md) | 9 | Cliente CardDAV/CalDAV (Python, solo stdlib) para Xandikos: parte un .vcf/.ics export de Google en recursos individuales (split puro), extrae/sintetiza UID, sube por HTTP PUT con Basic auth, lista (PROPFIND) y descarga (GET) recursos. Dos pipelines de import (vcf->carddav, ics->caldav). Formaliza la migracion ad-hoc de contactos/calendario |
| [metabase](metabase.md) | 106 | Operar Metabase via API REST: auth, cards, dashboards, collections, snippets, permissions |
| [doctor](doctor.md) | 11 | Diagnostico read-only del registry: artefactos, servicios, drift, funciones huerfanas |
| [notebook](notebook.md) | 5 | Operar Jupyter Lab colaborativo (discover/read/exec/write/kernel) |
@@ -50,7 +51,8 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [mesh-3d](mesh-3d.md) | 3 | Carga y upload a GPU de meshes 3D (OBJ, GLB/glTF 2.0): loaders CPU + mesh_gpu_upload OpenGL |
| [terminal-capture](terminal-capture.md) | 6 | Automatizar y capturar el texto de una CLI/TUI interactiva via PTY headless: spawn+input scripteado (one-shot y streaming), render del layout 2D (emulador VT), strip ANSI, delta por prefijo, y parseo de la TUI de claude a datos |
| [claude-direct](claude-direct.md) | 3 | Hablar directamente con la API de Anthropic Messages usando el token OAuth de Claude Code (Claude Max): leer token, stream SSE, bucle agentico de tool-use |
-| [obsidian](obsidian.md) | 14 | CRUD headless de vaults y notas Obsidian como Markdown plano (frontmatter YAML + wikilinks): parse/format, read/create/update/delete/list/search notas, list/create vaults, slugify/embeds/resolve. Sin app GUI |
+| [obsidian](obsidian.md) | 16 | CRUD headless de vaults y notas Obsidian como Markdown plano (frontmatter YAML + wikilinks): parse/format, read/create/update/delete/list/search notas, list/create vaults, slugify/embeds/resolve, render tabla Markdown + bloques sentinel gestionados. Sin app GUI |
+| [duckdb](duckdb.md) | 5 | Operar bases DuckDB: open (Go), query read-only segura (Python, tipos JSON-safe), CSV->Parquet, dedup por hash, carga OHLCV. Base del patron BD-fuente-de-verdad + Obsidian-vista (app osint_db) |
| [osint-passive](osint-passive.md) | 8 | Recoleccion OSINT pasiva (fuentes publicas, no intrusiva): EXIF/PDF metadata, whois RDAP, DNS, subdominios crt.sh, guess emails, username enumeration, search dorks |
| [osint-enrich](osint-enrich.md) | 3 | Orquestadores de enriquecimiento OSINT: componen osint-passive para aumentar datapoints de personas (emails/usernames/dorks), orgs (whois+dns+subdominios) y metadatos de attachments |
diff --git a/docs/capabilities/obsidian.md b/docs/capabilities/obsidian.md
index 9a3176bc..7189e030 100644
--- a/docs/capabilities/obsidian.md
+++ b/docs/capabilities/obsidian.md
@@ -19,6 +19,12 @@ Los vaults de Obsidian del usuario viven en `/home/enmanuel/Obsidian/` y estan e
| `search_obsidian_notes_py_obsidian` | `search_obsidian_notes(vault_dir, query, in_body=True, in_frontmatter=True) -> list` | Busca substring (case-insensitive) en las notas. Devuelve `[{path, matches:[{line, text}]}]`. |
| `list_obsidian_vaults_py_obsidian` | `list_obsidian_vaults(base_dir: str) -> list` | Lista los vaults (subdirs con `.obsidian/`) bajo `base_dir`. `[{name, path}]`. |
| `create_obsidian_vault_py_obsidian` | `create_obsidian_vault(parent_dir, name) -> str` | Crea un vault nuevo: carpeta + `.obsidian/app.json` minimo. Error si ya existe. |
+| `slugify_obsidian_name_py_obsidian` | `slugify_obsidian_name(name: str) -> str` | **Pure.** Nombre/titulo -> slug kebab-case estable (translitera acentos, ñ->n). Estabiliza ids de nodo y nombres de archivo. |
+| `extract_obsidian_embeds_py_obsidian` | `extract_obsidian_embeds(body: str) -> list` | **Pure.** Solo los embeds `![[...]]` (attachments incrustados), ignorando wikilinks normales. Dedup preservando orden. |
+| `resolve_obsidian_embed_py_obsidian` | `resolve_obsidian_embed(vault_dir, embed_name) -> str` | Resuelve un embed `![[foto.jpg]]` a su path absoluto real (busca por basename unico en el vault). Cadena vacia si no existe. |
+| `build_obsidian_graph_py_obsidian` | `build_obsidian_graph(vault_dir, include_dangling=True) -> {"nodes":[...], "edges":[...]}` | **Grafo agregado** del vault: cada nota = nodo tipado (`id`=slug, `label`, `tipo`, `frontmatter`); cada wikilink `[[...]]` = arista con `kind` por seccion. Wikilinks rotos -> nodos fantasma `dangling`. |
+| `render_markdown_table_py_core` | `render_markdown_table(rows: list[dict], columns=None, max_rows=0) -> str` | **Pure** (vive en `core`). Lista de dicts -> tabla Markdown GFM. Escapa pipes, saltos de linea -> `
`, truncado opcional con pie `... N de M filas`. Base del render BD -> nota. |
+| `upsert_sentinel_block_py_core` | `upsert_sentinel_block(text, block_id, content, marker="osintdb") -> str` | **Pure** (vive en `core`). Inserta o reemplaza un bloque gestionado entre sentinels `` / `` dentro del body de una nota. Idempotente; ValueError si el bloque esta corrupto. |
## Ejemplo canonico
@@ -71,7 +77,7 @@ Para una sola operacion con un id conocido, `fn run` tambien sirve:
- **No habla con la app GUI** (no usa el URI scheme `obsidian://`, no abre notas en la interfaz, no dispara plugins). Si la app esta abierta, escribir en disco puede chocar con sus locks/cache — cerrar la app o refrescar manualmente.
- **No resuelve wikilinks a paths** automaticamente (devuelve los targets crudos). Resolver `[[nota]]` -> archivo real es responsabilidad del caller (busqueda por nombre en el vault).
- **No renderiza Markdown** ni evalua Dataview/templating. Trata las notas como texto + frontmatter.
-- **No indexa el grafo** de enlaces entre notas (solo extrae links por nota). Para grafo agregado, componer sobre `list_obsidian_notes` + `extract_obsidian_wikilinks`.
+- **El grafo agregado** del vault ya lo cubre `build_obsidian_graph_py_obsidian` (nodos tipados + aristas con `kind` + nodos fantasma `dangling`). Es la base de la vista grafo (sigma.js) de la app `osint_web`. Lo que sigue fuera del grupo es el *layout* visual del grafo (force-directed) — eso vive en el frontend.
## Gotchas
diff --git a/python/functions/core/render_markdown_table.md b/python/functions/core/render_markdown_table.md
new file mode 100644
index 00000000..acebcfb3
--- /dev/null
+++ b/python/functions/core/render_markdown_table.md
@@ -0,0 +1,52 @@
+---
+name: render_markdown_table
+kind: function
+lang: py
+domain: core
+version: "1.0.0"
+purity: pure
+signature: "def render_markdown_table(rows: list, columns: list = None, max_rows: int = 0) -> str"
+description: "Renderiza una lista de diccionarios como tabla Markdown GitHub-flavored. Resuelve columnas desde las claves del primer dict o desde un orden explicito, normaliza None/bool, escapa pipes, convierte saltos de linea a
y permite truncar filas."
+tags: [markdown, table, render, obsidian]
+uses_functions: []
+uses_types: []
+returns: []
+returns_optional: false
+error_type: ""
+imports: []
+params:
+ - name: rows
+ desc: "lista de diccionarios, uno por fila; cada dict mapea nombre de columna a valor de celda"
+ - name: columns
+ desc: "orden explicito de columnas; si None usa las claves del primer dict preservando orden de insercion; con rows vacio y None devuelve string vacio"
+ - name: max_rows
+ desc: "numero maximo de filas a renderizar; 0 = todas; si N>0 y hay mas de N filas trunca a N y anade una linea final '_... N de M filas_' fuera de la tabla"
+output: "string con la tabla Markdown (header + separador |---| + filas); string vacio cuando no hay filas ni columnas explicitas"
+tested: true
+tests: ["caso normal", "rows vacio", "rows vacio con columns", "columns explicitas ordenan", "escape de pipes", "none bool y saltos de linea", "max rows truncado", "max rows sin truncar"]
+test_file_path: "python/functions/core/render_markdown_table_test.py"
+file_path: "python/functions/core/render_markdown_table.py"
+---
+
+## Ejemplo
+
+```python
+rows = [
+ {"nombre": "Alice", "edad": 30, "activo": True},
+ {"nombre": "Bob", "edad": 25, "activo": False},
+]
+md = render_markdown_table(rows)
+# | nombre | edad | activo |
+# |---|---|---|
+# | Alice | 30 | true |
+# | Bob | 25 | false |
+
+# Con columnas explicitas y truncado:
+muchas = [{"n": i} for i in range(100)]
+md2 = render_markdown_table(muchas, columns=["n"], max_rows=10)
+# Renderiza solo 10 filas y termina con: _... 10 de 100 filas_
+```
+
+## Cuando usarla
+
+Cuando tengas una lista de registros (`list[dict]`) y quieras volcarla a una tabla Markdown lista para Obsidian, un README o un comentario de PR. Util para resumir consultas o datasets en notas: pasa `columns` para fijar el orden y `max_rows` para no saturar la nota con cientos de filas.
diff --git a/python/functions/core/render_markdown_table.py b/python/functions/core/render_markdown_table.py
new file mode 100644
index 00000000..bb7c3e27
--- /dev/null
+++ b/python/functions/core/render_markdown_table.py
@@ -0,0 +1,67 @@
+"""Render a list of dict rows as a GitHub-flavored Markdown table."""
+
+
+def render_markdown_table(rows: list, columns: list = None, max_rows: int = 0) -> str:
+ """Render a list of dict rows as a GitHub-flavored Markdown table.
+
+ Args:
+ rows: List of dictionaries, one per row. Each dictionary maps a column
+ name to its cell value.
+ columns: Explicit column order. When None, the keys of the first row are
+ used preserving insertion order. When rows is empty and columns is
+ None, the result is an empty string.
+ max_rows: Maximum number of rows to render. 0 means all rows. When N > 0
+ and the number of rows exceeds N, the table is truncated to N rows
+ and a trailing line `\n_... N de M filas_` is appended after the
+ table indicating how many of the total rows are shown.
+
+ Returns:
+ str: The Markdown table as a string. Returns an empty string when there
+ are no rows and no explicit columns.
+ """
+
+ def render_cell(value) -> str:
+ """Convert a cell value to its Markdown-safe string representation."""
+ if value is None:
+ text = ""
+ elif isinstance(value, bool):
+ text = "true" if value else "false"
+ else:
+ text = str(value)
+ # Escape pipe characters so they do not break the table layout and turn
+ # newlines into
tags to keep each row on a single Markdown line.
+ text = text.replace("|", "\\|")
+ text = text.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "
")
+ return text
+
+ # Resolve the column order.
+ if columns is None:
+ if not rows:
+ return ""
+ cols = list(rows[0].keys())
+ else:
+ cols = list(columns)
+
+ # Decide how many rows to render and whether truncation applies.
+ total = len(rows)
+ if max_rows > 0 and total > max_rows:
+ visible = rows[:max_rows]
+ truncated = True
+ else:
+ visible = rows
+ truncated = False
+
+ header = "| " + " | ".join(render_cell(col) for col in cols) + " |"
+ separator = "|" + "---|" * len(cols)
+
+ lines = [header, separator]
+ for row in visible:
+ cells = [render_cell(row.get(col)) for col in cols]
+ lines.append("| " + " | ".join(cells) + " |")
+
+ table = "\n".join(lines)
+
+ if truncated:
+ table += f"\n_... {max_rows} de {total} filas_"
+
+ return table
diff --git a/python/functions/core/render_markdown_table_test.py b/python/functions/core/render_markdown_table_test.py
new file mode 100644
index 00000000..79597e97
--- /dev/null
+++ b/python/functions/core/render_markdown_table_test.py
@@ -0,0 +1,79 @@
+"""Tests para render_markdown_table."""
+
+import sys
+import os
+
+sys.path.insert(0, os.path.dirname(__file__))
+from render_markdown_table import render_markdown_table
+
+
+def test_caso_normal():
+ rows = [
+ {"nombre": "Alice", "edad": 30},
+ {"nombre": "Bob", "edad": 25},
+ ]
+ result = render_markdown_table(rows)
+ lines = result.split("\n")
+ assert lines[0] == "| nombre | edad |"
+ assert lines[1] == "|---|---|"
+ assert lines[2] == "| Alice | 30 |"
+ assert lines[3] == "| Bob | 25 |"
+ assert len(lines) == 4
+
+
+def test_rows_vacio():
+ assert render_markdown_table([]) == ""
+
+
+def test_rows_vacio_con_columns():
+ result = render_markdown_table([], columns=["a", "b"])
+ assert result == "| a | b |\n|---|---|"
+
+
+def test_columns_explicitas_ordenan():
+ rows = [{"b": 2, "a": 1}]
+ result = render_markdown_table(rows, columns=["a", "b"])
+ lines = result.split("\n")
+ assert lines[0] == "| a | b |"
+ assert lines[2] == "| 1 | 2 |"
+
+
+def test_escape_de_pipes():
+ rows = [{"col": "x|y|z"}]
+ result = render_markdown_table(rows)
+ assert "x\\|y\\|z" in result
+
+
+def test_none_bool_y_saltos_de_linea():
+ rows = [{"a": None, "b": True, "c": False, "d": "line1\nline2"}]
+ result = render_markdown_table(rows)
+ data_line = result.split("\n")[2]
+ assert data_line == "| | true | false | line1
line2 |"
+
+
+def test_max_rows_truncado():
+ rows = [{"n": i} for i in range(5)]
+ result = render_markdown_table(rows, max_rows=2)
+ lines = result.split("\n")
+ # header + separator + 2 data rows + truncation note
+ assert len(lines) == 5
+ assert lines[-1] == "_... 2 de 5 filas_"
+
+
+def test_max_rows_sin_truncar():
+ rows = [{"n": 1}, {"n": 2}]
+ result = render_markdown_table(rows, max_rows=5)
+ assert "filas_" not in result
+ assert len(result.split("\n")) == 4
+
+
+if __name__ == "__main__":
+ test_caso_normal()
+ test_rows_vacio()
+ test_rows_vacio_con_columns()
+ test_columns_explicitas_ordenan()
+ test_escape_de_pipes()
+ test_none_bool_y_saltos_de_linea()
+ test_max_rows_truncado()
+ test_max_rows_sin_truncar()
+ print("All tests passed.")
diff --git a/python/functions/core/upsert_sentinel_block.md b/python/functions/core/upsert_sentinel_block.md
new file mode 100644
index 00000000..8f25d49d
--- /dev/null
+++ b/python/functions/core/upsert_sentinel_block.md
@@ -0,0 +1,74 @@
+---
+name: upsert_sentinel_block
+kind: function
+lang: py
+domain: core
+version: "1.0.0"
+purity: pure
+signature: "def upsert_sentinel_block(text: str, block_id: str, content: str, marker: str = \"osintdb\") -> str"
+description: "Inserta o reemplaza de forma idempotente un bloque gestionado delimitado por sentinels HTML-comment ( ... ) dentro de un texto. Util para mantener secciones auto-generadas en notas Markdown de Obsidian sin pisar el resto del cuerpo."
+tags: [markdown, sentinel, managed-block, obsidian]
+uses_functions: []
+uses_types: []
+returns: []
+returns_optional: false
+error_type: ""
+imports: [re]
+params:
+ - name: text
+ desc: "Texto sobre el que operar (tipicamente el body de una nota Markdown de Obsidian). Puede contener otros bloques gestionados con ids distintos, que no se tocan."
+ - name: block_id
+ desc: "Identificador unico del bloque dentro del texto. Se usa literal en la regex (se escapa con re.escape), asi que puede contener caracteres especiales."
+ - name: content
+ desc: "Contenido a colocar entre los sentinels. NO debe contener los propios sentinels o se corromperia el bloque."
+ - name: marker
+ desc: "Prefijo del namespace de los comentarios sentinel. Default 'osintdb'. Se usa literal en la regex (se escapa con re.escape)."
+output: "El texto resultante con el bloque insertado al final (si no existia) o con su contenido reemplazado entre sentinels (si ya existia). Los sentinels siempre se conservan."
+tested: true
+tests:
+ - "insercion nueva anade bloque al final"
+ - "reemplazo existente sustituye solo el contenido"
+ - "idempotencia dos aplicaciones mismo resultado"
+ - "bloque corrupto solo inicio lanza valueerror"
+ - "bloque corrupto solo fin lanza valueerror"
+ - "multiples bloques ids distintos en mismo texto"
+ - "marker personalizado"
+test_file_path: "python/functions/core/upsert_sentinel_block_test.py"
+file_path: "python/functions/core/upsert_sentinel_block.py"
+---
+
+## Ejemplo
+
+```python
+from core.upsert_sentinel_block import upsert_sentinel_block
+
+nota = "# Dossier OSINT\n\nNotas manuales del investigador."
+
+# Primera vez: inserta el bloque al final.
+nota = upsert_sentinel_block(nota, "persons", "- Alice\n- Bob")
+
+# Segunda vez con nuevo contenido: reemplaza solo lo que hay entre sentinels.
+nota = upsert_sentinel_block(nota, "persons", "- Alice\n- Bob\n- Carol")
+
+print(nota)
+# # Dossier OSINT
+#
+# Notas manuales del investigador.
+#
+#
+# - Alice
+# - Bob
+# - Carol
+#
+```
+
+## Cuando usarla
+
+Cuando necesites mantener una seccion auto-generada dentro de un documento de texto (tipicamente una nota Markdown de Obsidian) sin sobreescribir lo que el usuario haya escrito a mano. Llamala cada vez que regeneres los datos: el primer uso inserta el bloque y los siguientes solo refrescan su contenido, dejando intacto el resto de la nota y otros bloques gestionados con ids distintos.
+
+## Gotchas
+
+- Si en `text` aparece solo uno de los dos sentinels del `block_id` (bloque corrupto, por ejemplo si alguien borro el sentinel de fin a mano) la funcion lanza `ValueError` con un mensaje que indica cuantos sentinels de inicio y de fin se encontraron. Lo mismo si aparece mas de un par de sentinels para el mismo `block_id` (bloque duplicado).
+- `content` NO debe contener los propios sentinels (`` / ``). Si los contiene, el siguiente upsert vera sentinels de sobra y fallara o reemplazara de forma incorrecta.
+- Es funcion pura: no escribe en disco. Lee/devuelve strings; la persistencia de la nota (lectura del fichero, escritura del resultado) corre por cuenta del llamante.
+- `block_id` y `marker` se escapan con `re.escape`, por lo que es seguro usar valores con caracteres especiales de regex. Aun asi, conviene mantenerlos en formato simple (alfanumerico, guiones) para que los comentarios HTML sean legibles en la nota.
diff --git a/python/functions/core/upsert_sentinel_block.py b/python/functions/core/upsert_sentinel_block.py
new file mode 100644
index 00000000..d9799265
--- /dev/null
+++ b/python/functions/core/upsert_sentinel_block.py
@@ -0,0 +1,74 @@
+"""Gestion idempotente de bloques delimitados por sentinels HTML-comment dentro de un texto."""
+
+import re
+
+
+def upsert_sentinel_block(
+ text: str, block_id: str, content: str, marker: str = "osintdb"
+) -> str:
+ """Inserta o reemplaza un bloque gestionado delimitado por sentinels HTML-comment.
+
+ Un bloque gestionado queda envuelto entre dos comentarios HTML que actuan como
+ sentinels:
+
+
+ ...contenido gestionado...
+
+
+ Si el bloque (ambos sentinels con ese block_id) ya existe en el texto, su contenido
+ se reemplaza por el nuevo manteniendo los sentinels. Si no existe, el bloque se anade
+ al final del texto. La operacion es idempotente: aplicarla dos veces con el mismo
+ content produce el mismo resultado.
+
+ Args:
+ text: Texto sobre el que operar (tipicamente el body de una nota Markdown de
+ Obsidian). Puede contener otros bloques gestionados con ids distintos.
+ block_id: Identificador unico del bloque dentro del texto. Se usa literal en
+ la expresion regular, asi que se escapa con re.escape.
+ content: Contenido a colocar entre los sentinels. No debe contener los propios
+ sentinels.
+ marker: Prefijo del namespace de los comentarios sentinel. Default "osintdb".
+ Se usa literal en la expresion regular, asi que se escapa con re.escape.
+
+ Returns:
+ El texto resultante con el bloque insertado o reemplazado.
+
+ Raises:
+ ValueError: Si aparece solo uno de los dos sentinels del block_id (bloque
+ corrupto) o si aparecen sentinels duplicados.
+ """
+ begin = f""
+ end = f""
+
+ begin_re = re.escape(begin)
+ end_re = re.escape(end)
+
+ n_begin = len(re.findall(begin_re, text))
+ n_end = len(re.findall(end_re, text))
+
+ if n_begin != n_end:
+ raise ValueError(
+ f"Bloque corrupto para block_id={block_id!r} con marker={marker!r}: "
+ f"se encontraron {n_begin} sentinel(s) de inicio y {n_end} de fin "
+ f"(deben coincidir)."
+ )
+
+ if n_begin > 1:
+ raise ValueError(
+ f"Bloque duplicado para block_id={block_id!r} con marker={marker!r}: "
+ f"se encontraron {n_begin} pares de sentinels (debe haber como maximo uno)."
+ )
+
+ if n_begin == 1:
+ # El bloque existe: reemplazar todo lo que hay entre los sentinels.
+ pattern = re.compile(begin_re + r".*?" + end_re, re.DOTALL)
+ replacement = f"{begin}\n{content}\n{end}"
+ return pattern.sub(lambda _: replacement, text, count=1)
+
+ # El bloque no existe: anadirlo al final.
+ block = f"{begin}\n{content}\n{end}\n"
+ if text == "":
+ return block
+ if text.endswith("\n"):
+ return f"{text}\n{block}"
+ return f"{text}\n\n{block}"
diff --git a/python/functions/core/upsert_sentinel_block_test.py b/python/functions/core/upsert_sentinel_block_test.py
new file mode 100644
index 00000000..b82c13cd
--- /dev/null
+++ b/python/functions/core/upsert_sentinel_block_test.py
@@ -0,0 +1,113 @@
+"""Tests para upsert_sentinel_block."""
+
+import sys
+import os
+
+sys.path.insert(0, os.path.dirname(__file__))
+
+from upsert_sentinel_block import upsert_sentinel_block
+
+
+def test_insercion_nueva_anade_bloque_al_final():
+ text = "# Nota\n\nTexto previo del usuario."
+ result = upsert_sentinel_block(text, "persons", "- Alice\n- Bob")
+
+ assert "" in result
+ assert "" in result
+ assert "- Alice\n- Bob" in result
+ # El texto previo se conserva intacto al inicio.
+ assert result.startswith("# Nota\n\nTexto previo del usuario.")
+ # El bloque va al final.
+ assert result.index("Texto previo") < result.index("osintdb:begin")
+
+
+def test_reemplazo_existente_sustituye_solo_el_contenido():
+ text = (
+ "Intro\n\n"
+ "\n"
+ "contenido viejo\n"
+ "\n"
+ )
+ result = upsert_sentinel_block(text, "persons", "contenido nuevo")
+
+ assert "contenido viejo" not in result
+ assert "contenido nuevo" in result
+ # Los sentinels siguen presentes una sola vez.
+ assert result.count("") == 1
+ assert result.count("") == 1
+ # La intro se conserva.
+ assert result.startswith("Intro\n\n")
+
+
+def test_idempotencia_dos_aplicaciones_mismo_resultado():
+ text = "# Nota\n\nCuerpo."
+ once = upsert_sentinel_block(text, "persons", "- Alice\n- Bob")
+ twice = upsert_sentinel_block(once, "persons", "- Alice\n- Bob")
+
+ assert once == twice
+ assert twice.count("") == 1
+ assert twice.count("") == 1
+
+
+def test_bloque_corrupto_solo_inicio_lanza_valueerror():
+ text = "Texto\n\nhuerfano\n"
+ try:
+ upsert_sentinel_block(text, "persons", "x")
+ except ValueError as e:
+ assert "persons" in str(e)
+ assert "corrupto" in str(e)
+ else:
+ raise AssertionError("Esperaba ValueError por bloque corrupto (solo inicio)")
+
+
+def test_bloque_corrupto_solo_fin_lanza_valueerror():
+ text = "Texto\n\nhuerfano\n"
+ try:
+ upsert_sentinel_block(text, "persons", "x")
+ except ValueError as e:
+ assert "persons" in str(e)
+ assert "corrupto" in str(e)
+ else:
+ raise AssertionError("Esperaba ValueError por bloque corrupto (solo fin)")
+
+
+def test_multiples_bloques_ids_distintos_en_mismo_texto():
+ text = "# Dossier\n\nResumen."
+ # Insertar dos bloques con ids distintos.
+ text = upsert_sentinel_block(text, "persons", "- Alice")
+ text = upsert_sentinel_block(text, "places", "- Madrid")
+
+ assert text.count("") == 1
+ assert text.count("") == 1
+ assert "- Alice" in text
+ assert "- Madrid" in text
+
+ # Reemplazar solo el bloque persons no afecta al de places.
+ updated = upsert_sentinel_block(text, "persons", "- Alice\n- Bob")
+ assert "- Alice\n- Bob" in updated
+ assert "- Madrid" in updated
+ assert updated.count("") == 1
+ # El contenido de places sigue intacto.
+ assert "places -->\n- Madrid\n" in updated
+
+
+def test_marker_personalizado():
+ text = "Cuerpo."
+ result = upsert_sentinel_block(text, "tags", "- osint", marker="mydb")
+
+ assert "" in result
+ assert "" in result
+ # Reemplazo idempotente con el mismo marker.
+ again = upsert_sentinel_block(result, "tags", "- osint", marker="mydb")
+ assert again == result
+
+
+if __name__ == "__main__":
+ test_insercion_nueva_anade_bloque_al_final()
+ test_reemplazo_existente_sustituye_solo_el_contenido()
+ test_idempotencia_dos_aplicaciones_mismo_resultado()
+ test_bloque_corrupto_solo_inicio_lanza_valueerror()
+ test_bloque_corrupto_solo_fin_lanza_valueerror()
+ test_multiples_bloques_ids_distintos_en_mismo_texto()
+ test_marker_personalizado()
+ print("All tests passed.")
diff --git a/python/functions/infra/duckdb_query_readonly.md b/python/functions/infra/duckdb_query_readonly.md
new file mode 100644
index 00000000..e4df7805
--- /dev/null
+++ b/python/functions/infra/duckdb_query_readonly.md
@@ -0,0 +1,91 @@
+---
+name: duckdb_query_readonly
+kind: function
+lang: py
+domain: infra
+version: "1.0.0"
+purity: impure
+signature: "def duckdb_query_readonly(db_path: str, sql: str, params: list = None, max_rows: int = 10000) -> dict"
+description: "Ejecuta una query SELECT contra una base DuckDB abierta en modo solo lectura (duckdb.connect(db_path, read_only=True)), de modo que nunca crea ni modifica la base. La conexion se cierra siempre en try/finally. Usa parametros posicionales con el marcador '?'. Devuelve un dict sin lanzar (estilo del grupo dav): {status:'ok', columns, rows, row_count, truncated} en exito y {status:'error', error} en fallo. Las filas son list[dict]. Trunca a max_rows para proteger memoria. Convierte valores no serializables: date/datetime/time a isoformat(), Decimal a float, bytes a base64, UUID a str. Depende del paquete duckdb (1.5.2 en python/.venv)."
+tags: [duckdb, sql, query, readonly]
+uses_functions: []
+uses_types: []
+returns: []
+returns_optional: false
+error_type: "error_py_core"
+imports: [base64, datetime, decimal, uuid, duckdb]
+params:
+ - name: db_path
+ desc: "ruta al archivo DuckDB. Debe existir: el modo read_only NO crea la base. Un path inexistente devuelve {status:'error'}."
+ - name: sql
+ desc: "sentencia SQL a ejecutar (pensada para SELECT). Usa el marcador '?' para parametros posicionales."
+ - name: params
+ desc: "lista de parametros posicionales para el SQL en orden. None (default) significa sin parametros. Pasar valores aqui en vez de interpolarlos en el SQL evita inyeccion."
+ - name: max_rows
+ desc: "numero maximo de filas a materializar en memoria. Default 10000. Si la query produce mas, el resultado se trunca y truncated queda en True."
+output: "dict. En exito: {status:'ok', columns:[str,...], rows:[{col:val,...},...], row_count:int, truncated:bool}. En error (sin lanzar): {status:'error', error:str}. Los valores de las filas estan normalizados a tipos JSON-serializables."
+tested: true
+tests:
+ - "test_query_ok_devuelve_filas_como_dicts"
+ - "test_query_con_params_posicionales"
+ - "test_sql_invalido_devuelve_status_error"
+ - "test_db_inexistente_devuelve_status_error"
+ - "test_truncado_a_max_rows"
+ - "test_valores_no_serializables_se_convierten"
+test_file_path: "python/functions/infra/duckdb_query_readonly_test.py"
+file_path: "python/functions/infra/duckdb_query_readonly.py"
+---
+
+## Ejemplo
+
+```python
+import sys
+sys.path.insert(0, "python/functions")
+import duckdb
+from infra.duckdb_query_readonly import duckdb_query_readonly
+
+# Preparamos una base de ejemplo (esto seria un proceso separado en la realidad).
+db = "/tmp/ventas.duckdb"
+con = duckdb.connect(db)
+con.execute("CREATE TABLE ventas (id INTEGER, region VARCHAR, total DECIMAL(10,2))")
+con.execute("INSERT INTO ventas VALUES (1, 'norte', 120.50), (2, 'sur', 80.00), (3, 'norte', 45.25)")
+con.close()
+
+# Lectura solo-lectura con parametro posicional.
+res = duckdb_query_readonly(
+ db,
+ "SELECT region, SUM(total) AS total FROM ventas WHERE region = ? GROUP BY region",
+ params=["norte"],
+)
+print(res["status"]) # ok
+print(res["columns"]) # ['region', 'total']
+print(res["rows"]) # [{'region': 'norte', 'total': 165.75}]
+print(res["truncated"]) # False
+```
+
+## Cuando usarla
+
+Cuando necesitas leer datos de un archivo DuckDB sin riesgo de modificarlo:
+inspeccionar una base materializada, validar el resultado de un pipeline,
+alimentar un dashboard o un report, o consultar tablas/Parquet exportados por
+otra funcion del registry. El modo read_only garantiza que la consulta nunca
+crea ni altera la base, y el dict de salida es directamente serializable a JSON
+para pasarlo al siguiente paso de una composicion.
+
+## Gotchas
+
+- Lectura real de un archivo en disco (impura). El modo `read_only=True` exige
+ que el archivo **ya exista**: a diferencia del modo escritura, no crea la base.
+ Si `db_path` no existe, devuelve `{status:'error', error:...}`.
+- Conflicto de lock: si otro proceso tiene la misma base abierta en escritura
+ con una version de DuckDB distinta, la apertura puede fallar (DuckDB no permite
+ abrir un archivo bloqueado por otra version del motor). El error se devuelve
+ como `{status:'error', ...}`, no se lanza.
+- `max_rows` protege la memoria: una query que devuelve millones de filas se
+ trunca a `max_rows` y marca `truncated=True`. Si necesitas todas las filas,
+ pagina con LIMIT/OFFSET en el SQL o sube `max_rows` conscientemente.
+- Los parametros van en `params` con el marcador `?`, nunca interpolados en el
+ string del SQL (previene inyeccion).
+- Valores no JSON-serializables se normalizan en la salida: date/datetime/time a
+ `isoformat()`, Decimal a float (puede haber perdida de precision frente al
+ decimal exacto), bytes a base64 y UUID a str.