Metadata-Version: 2.4
Name: devmemomcp
Version: 0.1.0
Summary: Memoria volátil de pendientes de codificación: grafo en RAM + vectores, expuesto como servidor MCP con la convención triádica proMCP
Author-email: "@alexlqi" <alexlqi@users.noreply.github.com>
License-Expression: Apache-2.0
Project-URL: Repository, https://github.com/alexlqi/devmemomcp
Keywords: mcp,model-context-protocol,promcp,fastmcp,agent-memory,working-memory,knowledge-graph,llm
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: promcp[transport]>=0.5.1
Provides-Extra: dev
Requires-Dist: pytest>=8.2; extra == "dev"
Requires-Dist: pytest-cov>=5.0; extra == "dev"
Requires-Dist: mypy>=1.10; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"
Provides-Extra: semantic
Requires-Dist: sentence-transformers>=2.2; extra == "semantic"
Dynamic: license-file

# devmemomcp

[![CI](https://github.com/alexlqi/devmemomcp/actions/workflows/ci.yml/badge.svg)](https://github.com/alexlqi/devmemomcp/actions/workflows/ci.yml)

Memoria de trabajo **volátil** para desarrollo asistido por LLM, expuesta como
servidor MCP. Mantiene en RAM —como grafo con textos vectorizados— todo lo que
queda pendiente de codificar entre sesiones, para que el modelo no tenga que
recordarlo en contexto ni escribirlo en archivos de notas.

Construido sobre [proMCP **0.5.1**](https://github.com/alexlqi/promcp)
(convención triádica `can_do` / `read_*` / `do_*`) con FastMCP como transporte.

```bash
pip install -e .
python -m devmemomcp          # servidor stdio
```

> **Sobre el BREAKING de proMCP 0.5.0.** Renombra `TMCPError` → `ProMCPError` y
> `__tmcp_*` → `__promcp_*`. No afecta a este servidor: solo consume los
> decoradores, `Registry`, `ProMCPServer` y `lint_registry`, y ninguno cambió de
> nombre. Comprobado con la suite completa contra `v0.5.1` en un venv limpio.

---

## Por qué volátil

La memoria muere con el proceso, a propósito. No es un registro permanente:
es memoria **de trabajo**. Lo que deba sobrevivir a la sesión se escribe en el
repo. Lo que vive aquí es el estado mental de la codificación —qué estabas
haciendo, qué te bloqueaba, qué decidiste y por qué— que hoy se pierde entre
una sesión de LLM y la siguiente.

Consecuencia de diseño que atraviesa todo el código: una memoria vacía se
reporta con `quality: degraded`, no `good`. Vacío puede significar "reinició el
servidor", y eso **no** es lo mismo que "no queda trabajo pendiente".

---

## Los 8 tools

| Tool | Categoría | Para qué |
|---|---|---|
| `can_do` | precondición | ¿es viable esta intención ahora, y qué la bloquea? |
| `read_pending` | observación | qué está abierto, ordenado por énfasis |
| `read_related` | observación | qué sé ya sobre este tema (vectores + grafo) |
| `read_item` | observación | un item con vecinos, bloqueadores e historial |
| `do_remember` | mutación | grabar un pendiente y auto-enlazarlo |
| `do_update_item` | mutación | cambiar estado/prioridad/texto, reforzar énfasis |
| `do_link` | mutación | declarar dependencias y bloqueos entre items |
| `do_forget` | mutación | borrar un item de verdad (no solo archivarlo) |

Los ocho que proMCP recomienda como máximo por servidor. `do_forget` ocupa el
slot que se dejó libre a propósito en la primera versión de este servidor —
ver `FUN-1` en `TODO.md` para el porqué de esa elección sobre las otras
candidatas.

### Protocolo de sesión

```
1. Al abrir la sesión          → read_pending      recupera el énfasis anterior
2. Antes de codificar algo     → read_related      decisiones y bloqueos del tema
3. Al detectar un pendiente    → do_remember       grábalo en cuanto aparezca
4. Al empezar / terminar       → do_update_item    status=active / status=done
5. Antes de cerrar algo dudoso → can_do            ¿quedan bloqueadores?
```

---

## Arquitectura

Outside-in, con el transporte en la frontera exterior. Cada capa solo conoce
a la de adentro:

```
devmemomcp/
├── domain/          invariantes puras · no sabe que MCP existe
│   ├── models.py      MemoryNode, Edge, salience
│   ├── embedding.py   Embedder (Protocol) + HashingEmbedder
│   ├── graph.py       grafo dirigido en RAM, difusión por saltos
│   ├── store.py       casos de uso, RLock, idempotencia
│   └── errors.py
├── surface/         superficie triádica · conoce proMCP, no FastMCP
│   ├── reads.py       read_pending, read_related, read_item
│   ├── mutations.py   do_remember, do_update_item, do_link
│   └── feasibility.py can_do
└── transport/       única frontera con FastMCP
    └── server.py      create_server()
```

`transport/server.py` es el único archivo que menciona el transporte. Cambiar
de FastMCP a otra cosa toca un archivo, no cuarenta.

### El grafo

Nodos de seis tipos (`task`, `blocker`, `decision`, `question`, `insight`,
`artifact`) y aristas dirigidas de seis tipos (`blocks`, `depends_on`,
`subtask_of`, `relates_to`, `supersedes`, `touches`). Las adyacencias se
mantienen en espejo (entrantes y salientes) para que recorrer el vecindario
sea O(grado); al leer hacia atrás se reporta la **inversa semántica** —
`blocks` se lee como `blocked_by`— para que el modelo entienda la relación tal
como aplica al nodo que consultó.

### Los vectores

`HashingEmbedder` por defecto: hashing vectorizer con unigramas y bigramas,
`tf` sublineal y normalización L2. Sin modelo, sin red, sin descarga,
determinista entre procesos. Vectores **dispersos** (`dict[int, float]`)
porque un título produce ~20 dimensiones no nulas de 4096, y el coseno disperso
es O(min(|a|,|b|)) en Python puro.

No entiende sinónimos. Para eso hay un embedder denso opcional
(`pip install devmemomcp[semantic]`), o se implementa el `Protocol`
`Embedder` propio y se pasa al store:

```python
from devmemomcp.domain.dense_embedder import SentenceTransformerEmbedder

create_server(embedder=SentenceTransformerEmbedder())
```

Nada más en el codebase cambia. **Umbral de auto-enlace por embedder, no
global**: `AUTOLINK_THRESHOLD = 0.15` está afinado para el embedder léxico
(pares relacionados 0.17–0.59, no relacionados exactamente 0.0). Un modelo
denso da coseno > 0 a casi todo par, así que ese mismo umbral saturaría el
grafo de aristas falsas. Por eso cada `Embedder` declara su propio
`autolink_threshold` (`HashingEmbedder` usa 0.15,
`SentenceTransformerEmbedder` usa 0.35 — ver el docstring de
`devmemomcp/domain/dense_embedder.py` para cómo se calibró ese 0.35) y
`MemoryStore` lo toma como default; se puede seguir forzando uno explícito
con `MemoryStore(embedder=..., autolink_threshold=...)`.

### El énfasis (`salience`)

Es lo que hace que la memoria "mantenga en énfasis" lo pendiente en lugar de
devolver una lista plana:

```
salience = peso_estado × (0.45·prioridad + 0.35·recencia + 0.20·centralidad + boost)
```

La **recencia** decae exponencialmente (vida media 24 h por defecto) desde el
último toque: lo que nadie mira se apaga solo. La **centralidad** hace subir a
los items muy conectados. `done` y `dropped` colapsan a 0 — siguen en el grafo
como contexto histórico, pero dejan de competir por atención. `bump=true`
refuerza manualmente (+0.1, tope 0.5).

### Los tres canales de `can_do`

proMCP separa desde 0.4.0 tres respuestas que antes se mezclaban, y la distinción no
es cosmética:

| Canal | Significa | Ejemplo aquí |
|---|---|---|
| `candidates[]` | hay ruta y es transitable | "puedes grabar el pendiente" |
| `blocked[]` | **la ruta existe** y no es transitable ahora | cerrar un item con bloqueadores abiertos |
| `unroutable[]` | no hay ruta al resultado — no hay capacidad que nombrar | el item que citas ya no existe |

Antes de adoptarlo, este servidor cometía los dos abusos que el spec nombra:
metía un item inexistente en `blocked[]` —inventando una ruta viva— y bloqueaba
los `read_*` con la memoria vacía, cuando esas lecturas **sí** son transitables
(devuelven cero items con `quality: degraded`, que ya es la señal correcta). La
memoria fría es ahora contexto, no un bloqueo.

Los tres motivos de `unroutable` están cubiertos: `requirements_unmet` (el item
no existe), `malformed_request` (el intent no declara nada que enrutar — antes
devolvía las seis capacidades con confianza 0.5, ruido disfrazado de respuesta)
y `no_producer` (se pidió algo que este servidor no hace, que antes salía como
un `feasible: false` sin explicar).

**Invariante que impone el builder:** `unroutable[]` no vacío obliga a
`feasible: false`. Por eso, cuando el resultado pedido es inalcanzable no se
emite ningún candidato — ofrecer `do_remember` a quien preguntó "¿puedo cerrar
el item X?" es ruido, no ayuda.

### Recuperación híbrida

`read_related` no es solo búsqueda vectorial. Vectoriza la consulta, saca
semillas por coseno, y las **difunde por el grafo** con decaimiento por salto.
Un nodo alcanzable por varios caminos se queda con el mejor, no con la suma —
sumar premiaría a los hubs por ser hubs, no por ser relevantes. El puntaje final
mezcla semántica (0.55), proximidad en el grafo (0.25) y salience (0.20). Así
aparece lo que está conectado a lo que estás mirando aunque su texto no se
parezca en nada.

---

## Decisiones que conviene no re-descubrir

**Las lecturas no mutan nada.** Ni `touched_at`, ni contadores de acceso. Es lo
que hace que los `read_*` sean paralelizables y cacheables de verdad, como
promete proMCP. Refrescar la atención es un acto explícito:
`do_update_item(bump=true)`. Hay un test que lo vigila.

**El store serializa con `RLock`.** FastMCP 3.x corre los tools en hilos de
worker (`run_in_thread=True` por defecto), así que el estado en RAM se toca
concurrentemente. El lock vive en `MemoryStore` y ninguna capa de arriba se
entera.

**Nada de `from __future__ import annotations` en `surface/`** *(vivo: el fix
está propuesto pero no ha entrado en 0.4.0, 0.4.1, 0.5.0 ni 0.5.1 — ver `promcp-pr/`)*. Bajo PEP 563
las anotaciones quedan como strings y `promcp._infer_input_schema` —que compara
contra los tipos reales— emite un `inputSchema` vacío, lo que hace fallar al
propio linter de proMCP (I002/I003). FastMCP resuelve los strings por su cuenta,
así que el síntoma solo aparece del lado del registry y es fácil de perseguir.

**`valid_until` por bandas, no por constante plana** (§11.3). Enrutar una
intención genérica depende solo del catálogo, que no cambia → banda estática,
240 s. Responder sobre un item concreto depende de un estado que cualquier `do_*`
invalida → banda dinámica, 20 s.

**Las descripciones de los `do_*` explican cómo construir el `idempotency_key`**
(AP-9). Importa especialmente aquí: el store deduplica por clave durante 24 h, así
que una clave derivada del *tipo* de operación en vez de la *instancia* haría que
el segundo `do_remember` devolviera el nodo del primero y nunca grabara nada —
sin error visible, con el servidor comportándose correctamente.

**`Registry` explícito, no el singleton de módulo.** Permite dos servidores en
el mismo proceso (tests, multi-tenant) sin disparar `CanDoSingletonError`.

**Los `do_*` fallidos devuelven un `compensation_hint` no nulo.** proMCP exige
el campo cuando `compensable=True`, incluso en el camino de error; devolver
`None` dispara un `ContractViolation` que enmascara el error real.

---

## Verificación

```bash
pip install -e ".[dev]"

pytest -q                                              # tests: dominio, contratos, linter y transporte real
pytest --cov=devmemomcp --cov-report=term-missing -q   # cobertura, umbral 85% (CAL-7)
ruff format --check devmemomcp tests                   # formato (CAL-6)
ruff check devmemomcp tests                             # lint (CAL-6)
mypy --strict devmemomcp                                # tipos (CAL-5)
```

El servidor pasa el linter de proMCP 0.5.1 con **0 errores y 0 warnings**, y hay
tests que lo comprueban en CI en lugar de confiar en que siga siendo cierto.
El test end-to-end recorre el patrón canónico completo — `can_do` → `read_*`
→ `do_*` → `read_*` — incluyendo que `can_do` **impida** cerrar un item que
todavía tiene bloqueadores abiertos.

`tests/test_stdio.py` (`CAL-8`) es el único test que no usa el cliente
in-memory de FastMCP: lanza `python -m devmemomcp` como proceso real, habla
MCP por stdin/stdout con el SDK cliente oficial (`mcp`), completa el
handshake y ejecuta un `do_remember` + `read_pending` de verdad — la única
forma de detectar algo que escriba basura en stdout y corrompa el protocolo.

Los cuatro pasos de arriba, más `promcp-lint` sobre el registry en tiempo de
ejecución, corren en CI (`.github/workflows/ci.yml`) en cada push y PR sobre
Python 3.10, 3.11 y 3.12 — ver el badge al principio de este README.

---

## Configuración en un cliente MCP

Instalación previa en el entorno donde vive el intérprete que se invoque
abajo (una vez por proyecto, o global si prefieres tenerlo siempre disponible):

```bash
pip install -e /ruta/a/devmemomcp   # o: pip install -e . estando dentro del repo
```

El bloque de configuración es el mismo para cualquier cliente MCP por stdio —
lo que cambia es dónde se pega. `command`/`args` es literalmente
`python -m devmemomcp`, tal como lo arranca `CAL-8` en el test de proceso
real:

```json
{
  "mcpServers": {
    "devmemomcp": {
      "command": "python",
      "args": ["-m", "devmemomcp"]
    }
  }
}
```

### Claude Code

**Verificado en este mismo entorno** (Windows, Claude Code 2.1.234) con el
comando real, no de memoria:

```bash
claude mcp add devmemomcp -s local -- python -m devmemomcp
```

- `-s local` lo registra solo para este proyecto (el scope por defecto);
  usa `-s user` para tenerlo disponible en todos tus proyectos, o `-s project`
  para versionarlo en `.mcp.json` y compartirlo con quien clone el repo.
- Todo lo que va después de `--` es el comando literal que Claude Code
  ejecuta por stdio: exactamente `python -m devmemomcp`.

Comprobado con `claude mcp get devmemomcp` inmediatamente después de añadirlo:

```
devmemomcp:
  Scope: Local config (private to you in this project)
  Status: ✔ Connected
  Type: stdio
  Command: python
  Args: -m devmemomcp
```

`claude mcp list` lo muestra `✔ Connected` junto al resto de servidores MCP
configurados, confirmando el handshake — el mismo handshake que
`tests/test_stdio.py` (`CAL-8`) automatiza contra un proceso real. Tras
verificarlo se retiró con `claude mcp remove devmemomcp -s local` para no
dejar un servidor de más en la configuración de quien corrió esta
verificación; repite el `add` de arriba para instalarlo de verdad.

Para confirmar que salen los 8 tools dentro de una sesión de Claude Code,
usa `/mcp` (o pregúntale a Claude "qué tools expone devmemomcp") una vez
conectado.

### Claude Desktop

**No verificado en este entorno** — no hay forma de abrir Claude Desktop
(aplicación de escritorio) desde una sesión de Claude Code headless en esta
máquina, así que esto son instrucciones de edición de archivo, no una
captura de pantalla ni una sesión probada:

1. Localiza `claude_desktop_config.json`:
   - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
   - Windows: `%APPDATA%\Claude\claude_desktop_config.json`
2. Añade la entrada `devmemomcp` dentro de `mcpServers` (créalo si el
   archivo está vacío o no existe todavía):

   ```json
   {
     "mcpServers": {
       "devmemomcp": {
         "command": "python",
         "args": ["-m", "devmemomcp"]
       }
     }
   }
   ```
3. Reinicia Claude Desktop por completo (no basta con cerrar la ventana).
4. Los 8 tools deberían aparecer bajo el icono de herramientas (🔨) de la
   conversación, con el prefijo `devmemomcp` delante de cada nombre.

Si `python` no resuelve al intérprete correcto (varias instalaciones, un
venv específico), sustituye `"command": "python"` por la ruta absoluta al
intérprete donde se instaló el paquete — Claude Desktop no hereda el `PATH`
de una shell interactiva en todos los sistemas.

---

## Decisiones de diseño

Elecciones que condicionan el diseño y que no conviene reabrir sin un motivo
nuevo. Cada una fue evaluada, no asumida.

### ¿Persistencia? No, por defecto — y tampoco un export/import a mano

La memoria es volátil **por diseño**, no por descuido: muere con el proceso a
propósito, y esa decisión atraviesa el código entero, desde el `RLock` en
`MemoryStore` hasta el `quality: degraded` que reporta una memoria vacía (que
puede significar "reinició el servidor", no "no queda trabajo pendiente"). Se
evaluó el punto intermedio obvio — un `export`/`import` explícito, invocado a
mano por el usuario, nunca autoguardado — y se descarta también, no solo el
autoguardado:

- No resuelve ningún problema que no resuelva ya el repo. Lo que debe
  sobrevivir a la sesión se escribe donde ya vive el resto del proyecto: un
  commit, un `TODO.md`, un comentario. Un export en un formato propio de
  devmemomcp es una copia paralela de eso, no una mejora sobre eso.
- Cambia lo que es este proyecto. En cuanto existe un archivo que un usuario
  puede reabrir la semana siguiente, deja de ser memoria de trabajo y pasa a
  ser una base de datos de tareas con pasos extra — justo lo que el diseño
  evita a propósito (ver "Por qué volátil" arriba).
- No es gratis mantenerlo. Un formato de export es superficie a versionar y
  a la que hay que darle compatibilidad hacia atrás, por una función que
  nadie pidió resolviendo un problema que no existe: si un LLM necesita que
  algo persista, ya tiene un tool para eso — escribir en el repo.

Cerrado: no se añade ni autoguardado ni export/import manual. Si esto se
replantea, que sea porque cambió el problema (por ejemplo, sesiones que
necesitan sobrevivir a un *crash*, no a un cierre limpio), no porque
"persistir siempre es mejor".

### El peso del `body` en el vector: se truncó, un cambio de una línea

`searchable_text()` repetía el título para pesar el doble y concatenaba el
`body` completo (hasta `MAX_BODY_BYTES`, 8 KB). Con un `body` largo esa
repetición no basta: unos pocos miles de caracteres de prosa dominan por
volumen a un título de un puñado de palabras en un hashing vectorizer, que no
sabe de posición ni de importancia estructural — solo cuenta n‑gramas.

No hay corpus real en este entorno para medir la mejora de recall
empíricamente, así que la decisión se tomó por el argumento de diseño, no por
benchmark, y se dejó lo más barata y reversible posible:

- **Se truncó el `body` a `SEARCHABLE_BODY_CHARS` (500 caracteres) solo para
  el texto que ve el embedder.** El `body` almacenado y el que devuelven
  `read_item`/`read_pending`/`read_related` no cambian ni un carácter — el
  corte vive únicamente dentro de `MemoryNode.searchable_text()`.
- Se truncó por caracteres al principio, no por una ventana ni un resumen,
  porque el principio de un `body` suele concentrar la señal (qué es, por
  qué importa) y truncar es una operación sin estado, determinista y de
  coste cero — cualquier alternativa (TF‑IDF sobre el body para quedarse con
  los términos más informativos, un resumen) es más código para un problema
  que 500 caracteres ya mitiga.
- 500 es una elección razonada, no calibrada: dos o tres frases suelen bastar
  para que el hashing vectorizer capture el tema; comparado con un título de
  ≤200 caracteres repetido dos veces (~400 caracteres de peso), deja al body
  con un peso comparable al del título en vez de aplastarlo por longitud.
  **No está medido contra un corpus real** — si se observa en uso que 500 es
  demasiado poco (temas que solo se distinguen entrada varios párrafos) o
  demasiado (ruido que sigue colando temas ajenos), es el primer número a
  mover, y un buen candidato para volver a medir cuando `FUN-3` traiga un
  embedder denso con semántica distinta a la de n‑gramas.
- Alternativa considerada y descartada por ahora: ponderar el body aparte en
  vez de truncarlo (dos vectores, uno de título y otro de body, combinados
  con pesos distintos en el coseno). Es estrictamente más flexible, pero es
  más superficie — dos vectores por nodo, una fórmula de combinación que
  calibrar — para un problema que truncar ya resuelve razonablemente. Si el
  truncado demuestra ser insuficiente, esa es la vía natural de seguir.

Test: `test_searchable_text_trunca_el_body_pero_to_dict_lo_devuelve_completo`
en `tests/test_domain.py`.

### El grafo como recurso MCP (`devmemo://graph`): no, por ahora

MCP separa `tools` de `resources`. Un recurso `devmemo://graph` leería el
grafo (o un digest) sin gastar una llamada a tool, y en abstracto encaja: el
grafo es un dato que un cliente podría querer leer directamente. Se descarta
por ahora, no por principio:

- No hay ningún cliente MCP en uso que consuma `resources` frente a este
  servidor — es superficie sin consumidor. Construir un recurso que nadie lee
  no valida nada; valida que el código compila.
- Ya existe una vía de lectura completa y sin efectos secundarios:
  `read_pending`, `read_related` y `read_item` cubren "todo lo abierto",
  "lo relacionado con X" y "un nodo con su vecindario" respectivamente. Un
  recurso `devmemo://graph` sería un cuarto camino a la misma información,
  con su propio formato de serialización que mantener sincronizado con los
  otros tres.
- Añade una segunda superficie a la que `transport/server.py` — hoy el único
  archivo que menciona FastMCP — tendría que declarar y versionar junto a los
  8 tools, por un ahorro (una llamada a tool menos) que no se ha demostrado
  que importe: todas las lecturas hoy están medidas en milisegundos incluso a
  2000 nodos (ver `TODO.md`, bloque ESC).

Cerrado. Se reabre el día que exista un cliente MCP real hablando con este
servidor que sí distinga `resources` de `tools` en su UX y para el que ese
ahorro de una llamada importe.

### Multi-proyecto: (a) un servidor por proyecto, no un campo `project`

Hoy un servidor MCP = una memoria. Con tres repos abiertos, la alternativa a
levantar tres servidores sería (b) un campo `project` en cada nodo y filtrado
en todos los `read_*`. Se elige (a):

- El aislamiento por proceso ya es completo y gratis: cada servidor tiene su
  propio `MemoryStore`, su propio grafo, su propio índice de vectores. No hay
  ningún caso en que la memoria de un proyecto se filtre a otro, porque no
  comparten proceso, y eso vale sin escribir una línea.
- (b) no compra aislamiento — ya existe — compra la posibilidad de ver varios
  proyectos *a la vez* desde un único servidor. Nadie ha pedido eso, y
  encaja mal con lo que ya existe: `session` ya distingue "sesión de trabajo
  dentro de un proyecto"; añadir `project` como una segunda dimensión de
  filtrado obligaría a tocar los cuatro `read_*` y `can_do` (que agrega sobre
  el store entero en su `context`) para que ninguno se salte el filtro — el
  mismo tipo de superficie nueva que sí importaría vigilar con tests, por un
  caso de uso hipotético.
- Los clientes MCP (Claude Desktop, Claude Code) ya soportan de forma nativa
  varios servidores declarados a la vez con nombres distintos — es
  exactamente el mecanismo de (a), no algo que este proyecto tenga que
  inventar.

Aislamiento por proceso basta; no se justifica (b). Documentado como la forma
correcta de usarlo: un `devmemomcp` por proyecto, cada uno con su propia
entrada en la configuración del cliente MCP (ver "Configuración en un cliente
MCP" arriba), con un `command`/`args`/`cwd` distinto por repo si se necesita
que cada proceso arranque en su propio directorio.

### `start_*` + `read_status_*` para operaciones largas: no aplica hoy

Es el patrón de proMCP para mutaciones que no terminan en la misma llamada.
Hoy no aplica: con `HashingEmbedder`, todo — vectorizar, auto-enlazar,
recorrer el grafo — está medido en milisegundos incluso a 2000 nodos (ver
`TODO.md`, bloque ESC). Introducir `start_do_remember` + `read_status_*` para
una operación de 1–3 ms sería puro ceremonial: dos tools más ocupando el
límite de 8, y un cliente que tendría que hacer *polling* de algo que ya
terminó cuando le llega la respuesta del `start_*`.

**Condición de reapertura, ya anotada:** si `FUN-3` (embedder denso opcional)
llega a producción, vectorizar puede pasar de milisegundos a segundos por
llamada a un modelo. Ese es el momento de medir de nuevo `do_remember` con el
embedder denso puesto y decidir si cruza el umbral en el que vale la pena
`start_*`/`read_status_*` — no antes.

### `stream_*` para observación continua: no

Es el patrón de proMCP para telemetría en vivo — "avísame cuando cambie el
énfasis". Se descarta: un LLM conversando por turnos no está escuchando un
canal entre turnos; lo primero que hace al volver es leer el estado actual
con `read_pending`/`read_related`, no reproducir un histórico de eventos que
se perdió mientras no había ningún turno en curso. Un `stream_*` de cambios
de `salience` serviría a un observador humano con un dashboard abierto, que
no es el consumidor que tiene este servidor. Cerrado sin condición de
reapertura explícita: reabrir esto exigiría que apareciera un consumidor con
esa forma de trabajar, no una mejora incremental del servidor actual.

### ¿Publicar en PyPI? No, todavía — bloqueado por una dependencia externa

Hoy `devmemomcp` solo se instala desde git (`pip install -e .` sobre un
clon, o `pip install git+https://github.com/alexlqi/devmemomcp`). Publicarlo
en PyPI **no es una decisión de diseño de este repo, es un bloqueo técnico**:

- `pyproject.toml` fija `promcp` como
  `promcp[transport] @ git+https://github.com/alexlqi/promcp.git@v0.5.1` — una
  dependencia por URL de git, no por nombre de paquete de índice.
- PyPI **rechaza** subir un paquete que declare una dependencia por URL git
  en `[project.dependencies]` (lo bloquea `twine upload` / el propio índice,
  no es una regla de estilo que se pueda saltar). No hay forma de publicar
  `devmemomcp` en PyPI mientras `promcp` no esté también en PyPI.
- `promcp` en sí mismo tampoco está publicado en PyPI hoy — solo existe como
  repo de GitHub con tags (`v0.3.0` → `v0.5.1`, ver el historial de este
  mismo README y `CHANGELOG.md`).

**Decisión:** no se persigue publicar en PyPI hasta que `promcp` se publique
primero ahí. Es una dependencia externa bloqueante, no algo que este repo
pueda resolver por su cuenta (cambiar a una copia vendorizada de `promcp`
para poder publicar sería peor que no publicar: divergiría en silencio de
la fuente real). Si `promcp` llega a PyPI, esta tarea se reabre reemplazando
el `@ git+...` por un rango de versión normal (`promcp[transport]>=0.5.1`) y
es, a partir de ahí, un `python -m build && twine upload` estándar — nada
más de este repo tiene que cambiar para que sea publicable.
