El segundo cerebro es una bóveda markdown que Faustus crea y mantiene por su
cuenta bajo DATA_DIR/brain/vault/<owner>/ (o brain_vault_dir si se fija):
memorias, notas personales y entidades tipadas espejadas en ficheros
editables a mano, más notas libres, un grafo de notas y relaciones de entidad
con ventanas de validez. Implementación: src/brain/ (db.py,
frontmatter.py, wikilinks.py, render.py, vault.py, notes.py,
temporal.py, entities.py, extract.py, wiki.py). Esta página describe
las tres superficies que exponen esas mismas funciones: las rutas HTTP
(routes/brain_routes.py), el servidor MCP brain
(mcp_servers/brain_server.py) y la herramienta de agente brain
(src/agent_tools/brain_tools.py). Ver FAUSTUS.md §176 para el porqué y la
verificación en vivo.
Autenticación. Como el resto de los datos propios de una persona: toda
ruta exige require_user y trabaja sobre la bóveda de quien llama
(owner = effective_user(request)), salvo PUT /settings, que cambia un
ajuste de instalación (admin, require_admin). Un ValueError de
src.brain.* (una ruta inválida, un id de entidad o de papelera
desconocido, una fecha mal formada) se traduce a 400; una nota inexistente,
a 404.
Prefijo /api/brain en todas.
| Método | Ruta | Parámetros | Respuesta |
|---|---|---|---|
GET |
/status |
— | {enabled, vault_dir, notes, entities, relations, last_sync, extraction:{pending, llm_enabled}, wiki:{enabled}} |
POST |
/sync |
— | el informe de vault.sync (ver abajo) |
last_sync es el último informe de vault.sync guardado (vault.last_sync)
o null si nunca se sincronizó. El informe de sincronización, en ambas
rutas:
{"at": "2026-09-23T10:00:00Z", "exported": 12, "imported": 3, "created": 1,
"suppressed": 0, "conflicts": [], "guard_tripped": false, "errors": [],
"duration_ms": 134, "notes": []}conflicts y errors son listas de objetos/cadenas descriptivas; notes
es una lista de anotaciones de la propia pasada (p. ej. que el guardián de
borrado saltó); guard_tripped: true significa que una sincronización tenía
más ficheros espejados desaparecidos de golpe de los que
brain_vault_delete_guard_ratio permite, y no actuó sobre ninguno de ellos
en esa pasada.
| Método | Ruta | Parámetros | Respuesta |
|---|---|---|---|
GET |
/tree |
— | {"folders": [str], "notes": [{"path","title","kind","source","updated_at","size"}]} |
GET |
/note |
path (vault-relativa) |
notes.read_note (ver forma abajo) |
PUT |
/note |
body {"path","content"} |
notes.write_note → {"note": <read_note>, "applied": {...}} |
POST |
/note |
body {"title","folder"?,"content"?} |
read_note de la nota creada (folder por defecto "Notes") |
POST |
/note/rename |
body {"path","new_title","update_links"?} |
{"note": <read_note>, "updated_links": int} |
DELETE |
/note |
path |
{"trash_id", "effect"} (effect: suppressed|removed|hidden) |
read_note (forma compartida por GET /note, PUT /note, POST /note,
GET /daily y la restauración de papelera):
{"path": "Notes/Ideas de café.md", "title": "Ideas de café", "kind": "note",
"source": null, "frontmatter": {"tags": ["cafe"]},
"user_zone": "texto editable…", "generated": "", "content": "texto completo del fichero",
"links": [{"target": "Otra nota", "path": "Notes/Otra nota.md", "resolved": true, "label": null}],
"backlinks": [{"path": "Daily/2026-09-23.md", "title": "2026-09-23", "context": "…"}],
"tags": ["cafe"], "updated_at": "2026-09-23T10:00:00Z", "editable": true}Una nota espejada (kind memory/personal/entity/project/
objective/concept/daily/home, source no nulo) tiene generated
relleno con las secciones que la última sincronización escribió; PUT /note
sólo puede cambiar la zona editable — el título de una nota mem: deriva de
su propio texto y renombrarla se rechaza (notes.rename_note lanza
ValueError, traducido a 400) salvo cambiando el propio texto.
| Método | Ruta | Parámetros | Respuesta |
|---|---|---|---|
GET |
/trash |
— | {"items": [{"id","path","title","source","effect","deleted_at"}]} |
POST |
/trash/{trash_id}/restore |
— | read_note de la nota restaurada |
Restaurar deshace el efecto según la fuente: una memoria vuelve a estar
suppressed=False, una entrada personal se vuelve a añadir a memory.json,
una entidad se vuelve a mostrar (hidden=False) y una nota libre borrada se
vuelve a escribir tal cual estaba.
| Método | Ruta | Parámetros | Respuesta |
|---|---|---|---|
GET |
/search |
q, limit (por defecto 20, tope 200) |
{"results": [{"path","title","kind","snippet","score"}]} |
GET |
/graph |
center?, depth (0-5, por defecto 1), kinds? (coma), scope (notes|entities, por defecto notes) |
{"nodes":[…], "edges":[…]} |
GET |
/tags |
— | {"tags": [{"tag","count"}]} |
GET |
/unresolved |
— | {"links": [{"target","from":[paths]}]} |
GET /search usa FTS5 (bm25 + impulso por título), insensible a acentos.
Con scope=entities, /graph devuelve entities.graph(owner) (ignora
center/depth/kinds); con scope=notes (por defecto), el grafo local o
global de notas — center es una ruta de nota, depth cuántos saltos desde
ella (0 = sólo el nodo), y un enlace [[destino]] que no resuelve a ninguna
nota aparece como un nodo de tipo "unresolved" en vez de desaparecer.
| Método | Ruta | Parámetros | Respuesta |
|---|---|---|---|
GET |
/daily |
date? (YYYY-MM-DD; hoy si se omite) |
read_note (se crea si no existía) |
| Método | Ruta | Parámetros | Respuesta |
|---|---|---|---|
GET |
/entities |
q?, type? |
{"entities": [EntitySummary, …]} (tope 200) |
GET |
/entities/{entity_id} |
as_of? (ISO) |
profile(entity_id, as_of) + path (ver abajo) |
PATCH |
/entities/{entity_id} |
body: cualquiera de name, type, aliases, summary, hidden |
la entidad actualizada, enriquecida |
POST |
/entities/merge |
body {"keep","merge"} |
la entidad resultante (keep), enriquecida |
Cada fila de /entities y la entidad devuelta por GET /entities/{id},
PATCH y merge llevan mentions (lista de source_ref) y relations
(las vigentes, include_closed=False) además de los campos propios de
entities.get_entity. GET /entities/{id} añade además "path": la ruta
de la nota de la bóveda que espeja esa entidad (Entities/<Tipo>/<Nombre>.md)
o null si aún no se ha exportado. profile(id, as_of):
{"entity": {"id","name","type","aliases",…},
"facts": [{"source_ref","text","valid_from","valid_until","valid_now","created_at"}],
"relations": [{"rel","dst_name","valid_from","valid_until","valid_at": true, …}],
"history": [ /* relaciones cerradas, con sus ventanas */ ],
"timeline": [{"at","kind","text","source_ref"}],
"summary": "…", "summary_sources": ["mem:ab12cd34", …]}PATCH/merge re-exportan la nota de la entidad afectada a la bóveda de
inmediato (best-effort — un fallo de exportación no rompe la respuesta).
| Método | Ruta | Parámetros | Respuesta |
|---|---|---|---|
GET |
/timeline |
entity?, q?, limit (por defecto 200, tope 1000) |
{"events": […]} |
Con entity (un id), la línea de tiempo es la de profile(entity)["timeline"]
recortada a limit y en orden descendente; sin entity, la línea de tiempo
cruzada de temporal.timeline(owner, query=q, limit=limit) — memorias
creadas, validadas desde/hasta, corregidas u olvidadas, y cambios de
relación, con q filtrando por texto.
| Método | Ruta | Parámetros | Respuesta |
|---|---|---|---|
POST |
/extract |
body {"limit"?} |
{"entities","relations","checked","llm_used","llm_skipped","errors": [str]} |
POST |
/wiki/refresh |
body {"entity_id"?} |
{"refreshed","skipped","errors": [str], ...} |
POST /extract corre primero entities.revalidate_if_needed (silenciosa
ante error) y luego extract.extract_pending(owner, limit=limit); el errors
de la capa de datos es un entero — la ruta lo traduce a la lista de cadenas
que espera el adaptador de Studio (["N item(s) failed to extract"] o []).
POST /wiki/refresh con entity_id refresca sólo esa entidad
(wiki.refresh_entity, un resumen: refreshed: 1 si updated/fallback,
skipped: 1 si locked/unchanged/disabled/deferred); sin entity_id,
wiki.refresh_stale(owner) sobre todas las que lo necesiten, con checked y
deferred (cuántas se saltaron porque el modelo de utilidad estaba ocupado o
no residente — background_llm_gate, nunca carga ni descarga un modelo).
| Método | Ruta | Body | Respuesta |
|---|---|---|---|
GET |
/settings |
— | instantánea de los ajustes de abajo |
PUT |
/settings (admin) |
cualquiera de los ajustes de abajo | la instantánea actualizada |
{"memory_temporal_parse": true, "memory_temporal_supersede": true,
"brain_enabled": true, "brain_vault_dir": "", "brain_vault_sync_seconds": 120,
"brain_entity_extraction": true, "brain_llm_extraction": true,
"brain_wiki_summaries": true, "brain_context_source": true,
"owner_display_name": ""}brain_vault_delete_guard_ratio (0.3) y brain_llm_extraction_batch (12)
existen como ajustes (src/settings.py) pero no se leen ni escriben por
estas rutas — se cambian con el mecanismo genérico de ajustes.
mcp_servers/brain_server.py. La bóveda sobre la que trabaja la fija la
variable de entorno ODYSSEUS_MCP_BRAIN_OWNER (o ODYSSEUS_BRAIN_OWNER);
sin ella, las herramientas de lectura caen a nivel de instalación pero
brain_write_note, brain_append_note y brain_sync se rechazan en vez de
escribir en la bóveda de nadie en particular.
| Herramienta | Argumentos | Qué hace |
|---|---|---|
brain_search |
query, limit? (1-50) |
Notas libres + entidades que casan el texto. Sólo lectura. |
brain_read_note |
path |
Una nota completa: frontmatter, etiquetas, enlaces, backlinks. Sólo lectura. |
brain_write_note |
path?, title?, folder?, content |
Con path, reemplaza la zona editable de una nota que existe (la sección generada se conserva); sin path, crea una con title bajo folder (por defecto Notes). |
brain_append_note |
path, content |
Añade texto al final de la zona editable de una nota, sin borrar lo que ya había. |
brain_entity |
entity_id? | name?, as_of? |
El perfil completo de una entidad (hechos, relaciones, línea de tiempo). Sólo lectura. |
brain_timeline |
entity_id? | name?, query?, limit? (1-500) |
La historia de una entidad, o la línea de tiempo cruzada si no se da entidad. Sólo lectura. |
brain_graph_neighbors |
path?, depth? (1-4), scope? (notes|entities) |
El grafo local alrededor de una nota, o el grafo entero de entidades si no se da path. Sólo lectura. |
brain_sync |
— | Sincroniza la bóveda ahora mismo (importa ediciones manuales, reexporta, reindexa). |
brain_entity/brain_timeline aceptan name como alternativa a
entity_id: se resuelve con la primera coincidencia de
entities.list_entities(owner, q=name, limit=1), y si no hay ninguna la
herramienta devuelve un mensaje de error nombrado (no entity matches '<name>') en vez de un perfil vacío.
src/agent_tools/brain_tools.py. Una sola herramienta, action como
discriminador; el dueño viene siempre del contexto de ejecución del turno,
nunca de un argumento del modelo (misma disciplina que context_recall y
manage_memory).
action |
Argumentos | Resultado |
|---|---|---|
search |
query, limit? |
notas + entidades que casan |
read |
path |
una nota, en output su zona editable |
write |
path?, title?, folder?, content |
crea o edita una nota |
append |
path, content |
añade sin borrar lo que había |
entity |
entity_id? | name?, as_of? |
el perfil de una entidad |
timeline |
entity_id? | name? | query?, limit? |
la historia de una entidad o la cruzada |
neighbors |
path?, scope?, depth? |
el grafo local de notas o el de entidades |
daily |
date? |
la nota diaria de hoy o de date |
Las acciones de sólo lectura (search, read, entity, timeline,
neighbors) pasan la puerta de contexto externo (src/context_tool_gate.py,
tabla de reglas de §175) sin pedir tarjeta de aprobación; write, append y
daily sí la piden, con el mismo trato que manage_memory. Editar una nota
espejada por write/append sólo toca su zona editable — la sección
generada de la última sincronización se conserva igual que en las rutas HTTP
y en el servidor MCP.
src/context_engine/adapters/brain.py (BrainSource, source_id="brain",
sección retrieved_memory) no es una superficie que se llame directamente:
compone, para el paquete de contexto de un turno, una tarjeta por cada
entidad mencionada por nombre o alias en el mensaje
(entities.entities_in_text) y las mejores notas libres para la consulta
(notes.search). Apagada del todo bajo allow_personal_memory=False
(Incógnito) y, además, tras su propio ajuste brain_context_source
(independiente de brain_enabled). source_ref: ent:<entity_id> o
note:<ruta>.