cerebro-code-memory 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/.claude-plugin/marketplace.json +1 -1
  2. cerebro_code_memory-0.2.0/.github/workflows/publish.yml +27 -0
  3. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/PKG-INFO +23 -5
  4. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/README.md +19 -1
  5. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/plugin/.claude-plugin/plugin.json +3 -3
  6. cerebro_code_memory-0.2.0/plugin/hooks/cerebro-first.py +96 -0
  7. cerebro_code_memory-0.2.0/plugin/hooks/cerebro-mark-used.py +20 -0
  8. cerebro_code_memory-0.2.0/plugin/hooks/cerebro-session-end.py +59 -0
  9. cerebro_code_memory-0.2.0/plugin/hooks/hooks.json +59 -0
  10. cerebro_code_memory-0.2.0/plugin/hooks/verify-edit.py +141 -0
  11. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/pyproject.toml +4 -4
  12. cerebro_code_memory-0.2.0/src/cerebro/apiroutes.py +88 -0
  13. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/src/cerebro/cli.py +15 -1
  14. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/src/cerebro/server.py +22 -1
  15. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/src/cerebro/summarizer.py +26 -1
  16. cerebro_code_memory-0.2.0/tests/test_apiroutes.py +42 -0
  17. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/tests/test_summarizer.py +24 -0
  18. cerebro_code_memory-0.1.0/plugin/hooks/hooks.json +0 -25
  19. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/.gitignore +0 -0
  20. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/CLAUDE.md +0 -0
  21. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/LICENSE +0 -0
  22. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/USAGE.md +0 -0
  23. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/plugin/.mcp.json +0 -0
  24. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/plugin/agents/cerebro-architect.md +0 -0
  25. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/plugin/agents/cerebro-explorer.md +0 -0
  26. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/plugin/agents/cerebro-impact-analyst.md +0 -0
  27. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/plugin/agents/cerebro-implementer.md +0 -0
  28. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/plugin/agents/cerebro-memory-keeper.md +0 -0
  29. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/plugin/hooks/post_edit.py +0 -0
  30. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/plugin/hooks/session_start.py +0 -0
  31. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/plugin/skills/cerebro/SKILL.md +0 -0
  32. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/src/cerebro/__init__.py +0 -0
  33. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/src/cerebro/callgraph.py +0 -0
  34. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/src/cerebro/config.py +0 -0
  35. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/src/cerebro/db.py +0 -0
  36. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/src/cerebro/docaudit.py +0 -0
  37. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/src/cerebro/embeddings.py +0 -0
  38. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/src/cerebro/gitsync.py +0 -0
  39. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/src/cerebro/graph.py +0 -0
  40. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/src/cerebro/indexer.py +0 -0
  41. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/src/cerebro/insights.py +0 -0
  42. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/src/cerebro/notes.py +0 -0
  43. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/src/cerebro/summaries.py +0 -0
  44. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/src/cerebro/tsconfig.py +0 -0
  45. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/src/cerebro/views.py +0 -0
  46. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/src/cerebro/viz.py +0 -0
  47. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/tests/conftest.py +0 -0
  48. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/tests/test_callgraph.py +0 -0
  49. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/tests/test_cli.py +0 -0
  50. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/tests/test_db.py +0 -0
  51. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/tests/test_docaudit.py +0 -0
  52. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/tests/test_embeddings.py +0 -0
  53. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/tests/test_gitsync.py +0 -0
  54. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/tests/test_indexer.py +0 -0
  55. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/tests/test_insights.py +0 -0
  56. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/tests/test_notes.py +0 -0
  57. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/tests/test_server.py +0 -0
  58. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/tests/test_tsconfig.py +0 -0
  59. {cerebro_code_memory-0.1.0 → cerebro_code_memory-0.2.0}/tests/test_viz.py +0 -0
@@ -9,7 +9,7 @@
9
9
  "name": "cerebro",
10
10
  "source": "./plugin",
11
11
  "description": "MCP brain for codebases: caches structure + summaries so AI sessions query it instead of re-reading files. Bundles the MCP server, cerebro-first subagents, and session hooks.",
12
- "version": "0.1.0"
12
+ "version": "0.2.0"
13
13
  }
14
14
  ]
15
15
  }
@@ -0,0 +1,27 @@
1
+ name: Publish to PyPI
2
+
3
+ # Publishes to PyPI via Trusted Publishing (OIDC) whenever a GitHub Release is
4
+ # published. No API token needed — PyPI trusts this repo + workflow directly.
5
+ on:
6
+ release:
7
+ types: [published]
8
+
9
+ jobs:
10
+ publish:
11
+ runs-on: ubuntu-latest
12
+ permissions:
13
+ id-token: write # REQUIRED for PyPI Trusted Publishing (OIDC token exchange)
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.x"
20
+
21
+ - name: Build sdist + wheel
22
+ run: |
23
+ python -m pip install --upgrade build
24
+ python -m build
25
+
26
+ - name: Publish to PyPI
27
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cerebro-code-memory
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Persistent code-knowledge memory across AI chat sessions (MCP server)
5
- Project-URL: Homepage, https://github.com/marcodavidd020/cerebro-mcp
6
- Project-URL: Repository, https://github.com/marcodavidd020/cerebro-mcp
7
- Project-URL: Issues, https://github.com/marcodavidd020/cerebro-mcp/issues
5
+ Project-URL: Homepage, https://github.com/marcodavidd020/cerebro-code-memory
6
+ Project-URL: Repository, https://github.com/marcodavidd020/cerebro-code-memory
7
+ Project-URL: Issues, https://github.com/marcodavidd020/cerebro-code-memory/issues
8
8
  Author-email: Marco Toledo <huancacori@gmail.com>
9
9
  License: MIT
10
10
  License-File: LICENSE
@@ -72,12 +72,30 @@ Three layers of "traces", cheapest first:
72
72
  | `cerebro_callers(name)` | Call sites of a symbol (who calls it, with enclosing fn + line). |
73
73
  | `cerebro_calls(path)` | Internal functions a file calls (outgoing call edges). |
74
74
 
75
+ ## Install
76
+
77
+ **One command** (published on PyPI) — add the MCP server to Claude Code:
78
+
79
+ ```bash
80
+ claude mcp add cerebro -- uvx cerebro-code-memory
81
+ ```
82
+
83
+ Or install the full **Claude Code plugin** (MCP server + session hooks + cerebro-first subagents):
84
+
85
+ ```
86
+ /plugin marketplace add marcodavidd020/cerebro-code-memory
87
+ /plugin install cerebro@cerebro
88
+ ```
89
+
90
+ Requires Python ≥ 3.10. Point Cerebro at a repo with `CEREBRO_ROOT=/path/to/repo`; it
91
+ also auto-detects the nearest ancestor `.cerebro/` brain (handy in monorepos).
92
+
75
93
  ## Quick start
76
94
 
77
95
  One command onboards any repo — it indexes and prints the exact registration line:
78
96
 
79
97
  ```bash
80
- uv tool install --from . cerebro # installs the `cerebro` command globally
98
+ uv tool install --from . cerebro # installs the `cerebro` command globally (dev)
81
99
  cd /path/to/your/repo
82
100
  cerebro setup --summarize --embed # index (+ warm summaries / semantic index), then prints next steps
83
101
  ```
@@ -50,12 +50,30 @@ Three layers of "traces", cheapest first:
50
50
  | `cerebro_callers(name)` | Call sites of a symbol (who calls it, with enclosing fn + line). |
51
51
  | `cerebro_calls(path)` | Internal functions a file calls (outgoing call edges). |
52
52
 
53
+ ## Install
54
+
55
+ **One command** (published on PyPI) — add the MCP server to Claude Code:
56
+
57
+ ```bash
58
+ claude mcp add cerebro -- uvx cerebro-code-memory
59
+ ```
60
+
61
+ Or install the full **Claude Code plugin** (MCP server + session hooks + cerebro-first subagents):
62
+
63
+ ```
64
+ /plugin marketplace add marcodavidd020/cerebro-code-memory
65
+ /plugin install cerebro@cerebro
66
+ ```
67
+
68
+ Requires Python ≥ 3.10. Point Cerebro at a repo with `CEREBRO_ROOT=/path/to/repo`; it
69
+ also auto-detects the nearest ancestor `.cerebro/` brain (handy in monorepos).
70
+
53
71
  ## Quick start
54
72
 
55
73
  One command onboards any repo — it indexes and prints the exact registration line:
56
74
 
57
75
  ```bash
58
- uv tool install --from . cerebro # installs the `cerebro` command globally
76
+ uv tool install --from . cerebro # installs the `cerebro` command globally (dev)
59
77
  cd /path/to/your/repo
60
78
  cerebro setup --summarize --embed # index (+ warm summaries / semantic index), then prints next steps
61
79
  ```
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "cerebro",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Persistent code-knowledge memory: an MCP brain that caches a codebase's structure and summaries so AI sessions query it instead of re-reading files. Bundles the MCP server, cerebro-first subagents, and session hooks.",
5
- "author": { "name": "Marco Toledo", "url": "https://github.com/marcodavidd020/cerebro-mcp" },
6
- "homepage": "https://github.com/marcodavidd020/cerebro-mcp",
5
+ "author": { "name": "Marco Toledo", "url": "https://github.com/marcodavidd020/cerebro-code-memory" },
6
+ "homepage": "https://github.com/marcodavidd020/cerebro-code-memory",
7
7
  "license": "MIT",
8
8
  "mcpServers": "./.mcp.json",
9
9
  "hooks": "./hooks/hooks.json"
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse nudge: enforce 'cerebro-first' the deterministic way.
3
+
4
+ In a project that HAS a Cerebro brain, if the model reaches for raw code search
5
+ (grep/rg/find, or the native Grep/Glob tools) before consulting Cerebro, block the
6
+ call ONCE per session with a reminder to use cerebro_search / cerebro_get first
7
+ (far cheaper in tokens). Strictly bounded:
8
+ - only where a `.cerebro/brain.db` exists upward from cwd,
9
+ - at most ONE nudge per session,
10
+ - never nudges once Cerebro has been used this session,
11
+ - the command is never permanently blocked: re-running it passes.
12
+
13
+ Best-effort: any error → allow silently (a guardrail you trust beats one that
14
+ gets in the way).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import re
21
+ import sys
22
+ import tempfile
23
+ from pathlib import Path
24
+
25
+ STATE_DIR = Path(tempfile.gettempdir()) / "cerebro-hook-state"
26
+
27
+ # Raw code-search tools Cerebro can usually answer cheaper. Kept narrow on purpose
28
+ # (no cat/ls/head — too generic) to avoid noisy false nudges.
29
+ _EXPLORE = re.compile(r"\b(grep|rg|find|ack|ag)\b")
30
+
31
+
32
+ def find_brain(start: str):
33
+ try:
34
+ p = Path(start).resolve()
35
+ except Exception:
36
+ return None
37
+ p = p if p.is_dir() else p.parent
38
+ for d in (p, *p.parents):
39
+ if (d / ".cerebro" / "brain.db").exists():
40
+ return d
41
+ return None
42
+
43
+
44
+ def main() -> None:
45
+ try:
46
+ data = json.load(sys.stdin)
47
+ except Exception:
48
+ sys.exit(0)
49
+
50
+ tool = data.get("tool_name", "")
51
+ ti = data.get("tool_input", {}) or {}
52
+ sid = data.get("session_id") or "nosession"
53
+ cwd = data.get("cwd") or os.getcwd()
54
+
55
+ # Only act where Cerebro is actually set up.
56
+ if find_brain(cwd) is None:
57
+ sys.exit(0)
58
+
59
+ # Is this raw exploration?
60
+ if tool in ("Grep", "Glob"):
61
+ exploratory = True
62
+ elif tool == "Bash":
63
+ exploratory = bool(_EXPLORE.search(ti.get("command", "") or ""))
64
+ else:
65
+ exploratory = False
66
+ if not exploratory:
67
+ sys.exit(0)
68
+
69
+ try:
70
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
71
+ except Exception:
72
+ sys.exit(0)
73
+ used = STATE_DIR / f"{sid}.cerebro-used"
74
+ nudged = STATE_DIR / f"{sid}.nudged"
75
+
76
+ # Already used Cerebro, or already nudged once → let it through.
77
+ if used.exists() or nudged.exists():
78
+ sys.exit(0)
79
+
80
+ try:
81
+ nudged.write_text("1")
82
+ except Exception:
83
+ pass
84
+
85
+ sys.stderr.write(
86
+ "💡 This project has a Cerebro brain — querying it is far cheaper than raw "
87
+ "search.\nBefore grep/find, try:\n"
88
+ " • cerebro_search(\"<what you're looking for>\") → locates code at path:line\n"
89
+ " • cerebro_get(\"<path>\") → a file's summary + symbols + deps, without reading it\n"
90
+ "One-time reminder. If Cerebro doesn't have what you need, just re-run your command.\n"
91
+ )
92
+ sys.exit(2)
93
+
94
+
95
+ if __name__ == "__main__":
96
+ main()
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env python3
2
+ """PostToolUse: record that Cerebro was used this session, so the cerebro-first
3
+ nudge (cerebro-first.py) stays quiet for the rest of the session. Best-effort."""
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import sys
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ STATE_DIR = Path(tempfile.gettempdir()) / "cerebro-hook-state"
12
+
13
+ try:
14
+ data = json.load(sys.stdin)
15
+ sid = data.get("session_id") or "nosession"
16
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
17
+ (STATE_DIR / f"{sid}.cerebro-used").write_text("1")
18
+ except Exception:
19
+ pass
20
+ sys.exit(0)
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env python3
2
+ """SessionEnd hook: auto-record.
3
+
4
+ When CEREBRO_AUTORECORD=N (a positive int) is set, re-summarize up to N files whose
5
+ cached summary went STALE during the session — so the NEXT session reuses fresh
6
+ traces instead of re-reading the files you changed. Detached (never blocks exit)
7
+ and OFF by default (it spends a little via `claude -p`; set the env var to opt in).
8
+ Mirrors the spawn pattern of session_start.py's autosummarize.
9
+ """
10
+ import json
11
+ import os
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+
16
+
17
+ def find_brain_root(start: str):
18
+ p = Path(start).resolve()
19
+ p = p if p.is_dir() else p.parent
20
+ for d in (p, *p.parents):
21
+ if (d / ".cerebro" / "brain.db").exists():
22
+ return d
23
+ return None
24
+
25
+
26
+ def main() -> None:
27
+ n = os.environ.get("CEREBRO_AUTORECORD", "").strip()
28
+ if not (n.isdigit() and int(n) > 0):
29
+ return # opt-in: set CEREBRO_AUTORECORD=N to enable
30
+
31
+ try:
32
+ data = json.load(sys.stdin)
33
+ except Exception:
34
+ data = {}
35
+ cwd = data.get("cwd") or os.getcwd()
36
+ root = find_brain_root(cwd)
37
+ if root is None:
38
+ return # not a Cerebro project
39
+
40
+ env = {**os.environ, "CEREBRO_ROOT": str(root)}
41
+ home = os.environ.get("CEREBRO_HOME")
42
+ uv = os.environ.get("CEREBRO_UV", "uv")
43
+ cmd = (
44
+ [uv, "run", "--directory", home, "cerebro", "summarize", "--stale", "--limit", n]
45
+ if home
46
+ else ["cerebro", "summarize", "--stale", "--limit", n]
47
+ )
48
+ try:
49
+ subprocess.Popen(
50
+ cmd, env=env,
51
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
52
+ start_new_session=True,
53
+ )
54
+ except Exception:
55
+ pass # best-effort; never block session exit
56
+
57
+
58
+ if __name__ == "__main__":
59
+ main()
@@ -0,0 +1,59 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py"
9
+ }
10
+ ]
11
+ }
12
+ ],
13
+ "SessionEnd": [
14
+ {
15
+ "hooks": [
16
+ {
17
+ "type": "command",
18
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/cerebro-session-end.py"
19
+ }
20
+ ]
21
+ }
22
+ ],
23
+ "PreToolUse": [
24
+ {
25
+ "matcher": "Bash|Grep|Glob",
26
+ "hooks": [
27
+ {
28
+ "type": "command",
29
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/cerebro-first.py"
30
+ }
31
+ ]
32
+ }
33
+ ],
34
+ "PostToolUse": [
35
+ {
36
+ "matcher": "Edit|Write|MultiEdit",
37
+ "hooks": [
38
+ {
39
+ "type": "command",
40
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/post_edit.py"
41
+ },
42
+ {
43
+ "type": "command",
44
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/verify-edit.py"
45
+ }
46
+ ]
47
+ },
48
+ {
49
+ "matcher": "mcp__cerebro__.*",
50
+ "hooks": [
51
+ {
52
+ "type": "command",
53
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/cerebro-mark-used.py"
54
+ }
55
+ ]
56
+ }
57
+ ]
58
+ }
59
+ }
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env python3
2
+ """Hook determinista anti-alucinación.
3
+
4
+ Tras cada Edit/Write/MultiEdit, verifica el archivo editado con las herramientas
5
+ REALES disponibles (sintaxis + análisis). No puede alucinar: o el archivo pasa, o
6
+ no. Si encuentra errores reales, escribe el detalle en stderr y sale con código 2,
7
+ así Claude recibe el error y lo corrige (la edición YA se aplicó; esto no la borra).
8
+
9
+ Filosofía: NUNCA bloquear por falta de tooling. Si una herramienta no está
10
+ instalada o falla por motivos de entorno (no por el código), se salta en silencio.
11
+ Un guardarraíl en el que se confía y queda encendido vale más que uno potente que
12
+ se termina apagando por ruidoso.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import glob
17
+ import json
18
+ import os
19
+ import shutil
20
+ import subprocess
21
+ import sys
22
+
23
+ # Ubicaciones comunes de herramientas que el PATH con que Claude Code lanza los
24
+ # hooks puede NO incluir (p.ej. ~/flutter/bin lo agrega .zshrc; node vive en nvm).
25
+ # Sin esto, el hook se saltaría en silencio dart/node/ruff = falsa seguridad.
26
+ _EXTRA_PATHS = [
27
+ os.path.expanduser("~/flutter/bin"),
28
+ os.path.expanduser("~/fvm/default/bin"),
29
+ os.path.expanduser("~/.pub-cache/bin"),
30
+ os.path.expanduser("~/.local/bin"), # uv / uvx
31
+ "/opt/homebrew/bin",
32
+ "/usr/local/bin",
33
+ "/usr/bin",
34
+ "/bin",
35
+ ] + sorted(glob.glob(os.path.expanduser("~/.nvm/versions/node/*/bin")), reverse=True)
36
+
37
+
38
+ def _enriched_path():
39
+ extra = [p for p in _EXTRA_PATHS if os.path.isdir(p)]
40
+ return os.pathsep.join(extra + [os.environ.get("PATH", "")])
41
+
42
+
43
+ def run(cmd, cwd=None, timeout=90):
44
+ """Devuelve (returncode, salida). returncode None = herramienta ausente/timeout.
45
+ Resuelve cmd[0] a ruta absoluta vía un PATH enriquecido para no depender del
46
+ PATH heredado (que al ejecutar hooks suele venir mínimo)."""
47
+ path = _enriched_path()
48
+ exe = shutil.which(cmd[0], path=path)
49
+ if exe is None:
50
+ return None, "" # herramienta ausente -> saltar (nunca bloquear por tooling)
51
+ env = dict(os.environ)
52
+ env["PATH"] = path
53
+ try:
54
+ p = subprocess.run([exe] + cmd[1:], cwd=cwd, capture_output=True,
55
+ text=True, timeout=timeout, env=env)
56
+ return p.returncode, (p.stdout + p.stderr).strip()
57
+ except (FileNotFoundError, subprocess.TimeoutExpired):
58
+ return None, ""
59
+
60
+
61
+ def find_up(start, name):
62
+ d = os.path.abspath(start)
63
+ while True:
64
+ if os.path.exists(os.path.join(d, name)):
65
+ return d
66
+ parent = os.path.dirname(d)
67
+ if parent == d:
68
+ return None
69
+ d = parent
70
+
71
+
72
+ def check(path):
73
+ """Devuelve lista de mensajes de error (vacía = ok)."""
74
+ ext = os.path.splitext(path)[1].lower()
75
+ d = os.path.dirname(path)
76
+ errors = []
77
+
78
+ if ext in (".py", ".pyi"):
79
+ # 1) Sintaxis — stdlib, siempre disponible, cero falsos positivos.
80
+ rc, out = run([sys.executable, "-m", "py_compile", path])
81
+ if rc not in (0, None):
82
+ errors.append("SyntaxError (py_compile):\n" + out)
83
+ else:
84
+ # 2) Nombres indefinidos / imports rotos (lo que delata alucinaciones).
85
+ # ruff vía uvx: rc==1 => hallazgos; rc None/2 => uv ausente o problema
86
+ # de entorno (run() resuelve uvx por PATH enriquecido; si falta, salta).
87
+ rc, out = run([
88
+ "uvx", "ruff", "check", "--quiet", "--output-format", "concise",
89
+ "--select", "F821,F811,F823", path,
90
+ ], timeout=120)
91
+ if rc == 1 and ".py:" in out:
92
+ errors.append("ruff (nombres indefinidos / imports):\n" + out)
93
+
94
+ elif ext in (".js", ".jsx", ".mjs", ".cjs"):
95
+ rc, out = run(["node", "--check", path])
96
+ if rc not in (0, None):
97
+ errors.append("SyntaxError (node --check):\n" + out)
98
+
99
+ elif ext in (".ts", ".tsx"):
100
+ root = find_up(d, "node_modules")
101
+ eslint = os.path.join(root, "node_modules", ".bin", "eslint") if root else None
102
+ if eslint and os.path.exists(eslint):
103
+ rc, out = run([eslint, "--no-error-on-unmatched-pattern", path], cwd=root, timeout=120)
104
+ if rc == 1 and out:
105
+ errors.append("eslint:\n" + out)
106
+
107
+ elif ext == ".dart":
108
+ # --no-fatal-warnings: rc!=0 solo ante ERRORES reales (infos/warnings no bloquean;
109
+ # los infos ya son no-fatales por defecto). "Usage:" => error de CLI, no de código: saltar.
110
+ rc, out = run(["dart", "analyze", "--no-fatal-warnings", path], timeout=150)
111
+ if rc not in (0, None) and out and "Usage:" not in out:
112
+ errors.append("dart analyze:\n" + out)
113
+
114
+ return errors
115
+
116
+
117
+ def main():
118
+ try:
119
+ data = json.load(sys.stdin)
120
+ except Exception:
121
+ sys.exit(0)
122
+
123
+ ti = data.get("tool_input", {}) or {}
124
+ path = ti.get("file_path") or ti.get("path")
125
+ if not path or not os.path.isfile(path):
126
+ sys.exit(0)
127
+
128
+ errors = check(path)
129
+ if errors:
130
+ sys.stderr.write(
131
+ "⛔ Verificación determinista falló en " + path + ":\n\n"
132
+ + "\n\n".join(errors)
133
+ + "\n\nEstos son errores reales del código recién escrito. "
134
+ "Corrígelos antes de continuar.\n"
135
+ )
136
+ sys.exit(2)
137
+ sys.exit(0)
138
+
139
+
140
+ if __name__ == "__main__":
141
+ main()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cerebro-code-memory"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Persistent code-knowledge memory across AI chat sessions (MCP server)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -28,9 +28,9 @@ cerebro-graph = "cerebro.viz:graph_main"
28
28
  cerebro-export-obsidian = "cerebro.viz:obsidian_main"
29
29
 
30
30
  [project.urls]
31
- Homepage = "https://github.com/marcodavidd020/cerebro-mcp"
32
- Repository = "https://github.com/marcodavidd020/cerebro-mcp"
33
- Issues = "https://github.com/marcodavidd020/cerebro-mcp/issues"
31
+ Homepage = "https://github.com/marcodavidd020/cerebro-code-memory"
32
+ Repository = "https://github.com/marcodavidd020/cerebro-code-memory"
33
+ Issues = "https://github.com/marcodavidd020/cerebro-code-memory/issues"
34
34
 
35
35
  [project.optional-dependencies]
36
36
  semantic = ["model2vec>=0.3", "numpy>=1.26"]
@@ -0,0 +1,88 @@
1
+ """Backend HTTP endpoint extraction — slice 1 of API-call tracing (front↔back).
2
+
3
+ Cerebro's import/call graphs miss the network boundary: a storefront calling a
4
+ backend endpoint over HTTP is a real dependency invisible to `import` edges. This
5
+ extracts the BACKEND side — the routes a server exposes — so a session can answer
6
+ "where is POST /carts/lines handled?" cheaply, and so a later slice can match
7
+ frontend HTTP calls to these routes (the actual HTTP_CALLS edges).
8
+
9
+ v1 targets NestJS controllers (the Fenix backend): `@Controller('base')` plus the
10
+ method decorators `@Get/@Post/@Put/@Patch/@Delete/@All`. Best-effort line scan —
11
+ robust for the conventional one-controller-per-file layout. Read-only; computed on
12
+ demand from `*.controller.ts` files (a small subset), so no schema change.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import re
17
+
18
+ _CONTROLLER = re.compile(r"@Controller\(\s*['\"`]([^'\"`]*)['\"`]")
19
+ _CONTROLLER_BARE = re.compile(r"@Controller\(\s*\)")
20
+ _METHOD = re.compile(r"@(Get|Post|Put|Patch|Delete|All)\(\s*(?:['\"`]([^'\"`]*)['\"`])?")
21
+ # A method declaration line: optional modifiers then `name(`. Used to attach a
22
+ # handler name to the decorator above it.
23
+ _HANDLER = re.compile(r"^\s*(?:public\s+|private\s+|protected\s+|async\s+|static\s+)*"
24
+ r"([A-Za-z_]\w*)\s*\(")
25
+
26
+ _HTTP = ("GET", "POST", "PUT", "PATCH", "DELETE", "ALL")
27
+
28
+
29
+ def _join(base: str | None, path: str | None) -> str:
30
+ parts = [p.strip("/") for p in (base or "", path or "") if p and p.strip("/")]
31
+ return "/" + "/".join(parts)
32
+
33
+
34
+ def extract_file(rel: str, text: str) -> list[dict]:
35
+ """Endpoints declared in one controller source. Each: method, path, file,
36
+ line, handler."""
37
+ base: str | None = None
38
+ out: list[dict] = []
39
+ lines = text.splitlines()
40
+ for i, line in enumerate(lines):
41
+ mc = _CONTROLLER.search(line)
42
+ if mc:
43
+ base = mc.group(1)
44
+ continue
45
+ if _CONTROLLER_BARE.search(line):
46
+ base = ""
47
+ continue
48
+ mm = _METHOD.search(line)
49
+ if not mm:
50
+ continue
51
+ method = mm.group(1).upper()
52
+ path = mm.group(2) or ""
53
+ handler = None
54
+ for j in range(i + 1, min(i + 6, len(lines))):
55
+ stripped = lines[j].lstrip()
56
+ if stripped.startswith("@"):
57
+ continue # another decorator (e.g. @UseGuards) — keep looking
58
+ mh = _HANDLER.match(lines[j])
59
+ if mh:
60
+ handler = mh.group(1)
61
+ break
62
+ out.append({"method": method, "path": _join(base, path),
63
+ "file": rel, "line": i + 1, "handler": handler})
64
+ return out
65
+
66
+
67
+ def find(config, conn, query: str = "") -> list[dict]:
68
+ """All backend endpoints (optionally filtered by `query`), across the indexed
69
+ NestJS controllers. Sorted by path then method."""
70
+ rows = conn.execute(
71
+ "SELECT path FROM files WHERE path LIKE '%.controller.ts' ORDER BY path"
72
+ ).fetchall()
73
+ eps: list[dict] = []
74
+ for r in rows:
75
+ rel = r["path"]
76
+ try:
77
+ text = (config.root / rel).read_text(encoding="utf-8", errors="ignore")
78
+ except OSError:
79
+ continue
80
+ eps.extend(extract_file(rel, text))
81
+ if query:
82
+ q = query.lower()
83
+ eps = [
84
+ e for e in eps
85
+ if q in f"{e['method']} {e['path']} {e['handler'] or ''} {e['file']}".lower()
86
+ ]
87
+ eps.sort(key=lambda e: (e["path"], e["method"]))
88
+ return eps
@@ -93,6 +93,11 @@ def cmd_calls(args):
93
93
  print(_bind_server(config, conn).cerebro_calls(args.path))
94
94
 
95
95
 
96
+ def cmd_endpoints(args):
97
+ config, conn = _ctx()
98
+ print(_bind_server(config, conn).cerebro_endpoints(" ".join(args.query)))
99
+
100
+
96
101
  def cmd_recall(args):
97
102
  from . import views
98
103
  config, conn = _ctx()
@@ -153,7 +158,13 @@ def cmd_obsidian(args):
153
158
  def cmd_summarize(args):
154
159
  from . import summarizer
155
160
  config, conn = _ctx()
156
- rels = summarizer.select_central_missing(conn, args.limit, args.prefix)
161
+ if getattr(args, "stale", False):
162
+ rels = summarizer.select_stale(conn, args.limit, args.prefix)
163
+ seen = set(rels)
164
+ rels += [r for r in summarizer.select_central_missing(conn, args.limit - len(rels), args.prefix)
165
+ if r not in seen]
166
+ else:
167
+ rels = summarizer.select_central_missing(conn, args.limit, args.prefix)
157
168
  print(json.dumps(summarizer.run(config, conn, rels, workers=args.workers)))
158
169
 
159
170
 
@@ -288,6 +299,7 @@ _COMMANDS = {
288
299
  "embed": cmd_embed, "impact": cmd_impact, "cycles": cmd_cycles, "orphans": cmd_orphans,
289
300
  "dead-symbols": cmd_orphans_symbols,
290
301
  "callers": cmd_callers, "calls": cmd_calls, "recall": cmd_recall,
302
+ "endpoints": cmd_endpoints,
291
303
  }
292
304
 
293
305
 
@@ -320,6 +332,7 @@ def _build_parser() -> argparse.ArgumentParser:
320
332
  s = sub.add_parser("obsidian", help="export an Obsidian vault"); s.add_argument("-o", "--out")
321
333
  s = sub.add_parser("summarize", help="warm summaries via claude -p")
322
334
  s.add_argument("--limit", type=int, default=20); s.add_argument("--prefix"); s.add_argument("--workers", type=int, default=4)
335
+ s.add_argument("--stale", action="store_true", help="also re-summarize stale summaries (edited files)")
323
336
  sub.add_parser("embed", help="build the semantic index (needs --extra semantic)")
324
337
  s = sub.add_parser("impact", help="transitive blast radius of a file"); s.add_argument("path")
325
338
  sub.add_parser("cycles", help="circular-import groups")
@@ -328,6 +341,7 @@ def _build_parser() -> argparse.ArgumentParser:
328
341
  s.add_argument("--prefix", default="")
329
342
  s = sub.add_parser("callers", help="call sites of a symbol"); s.add_argument("name")
330
343
  s = sub.add_parser("calls", help="internal calls a file makes"); s.add_argument("path")
344
+ s = sub.add_parser("endpoints", help="backend HTTP endpoints (NestJS routes)"); s.add_argument("query", nargs="*")
331
345
  s = sub.add_parser("recall", help="recall recorded decisions"); s.add_argument("query", nargs="*")
332
346
  return p
333
347
 
@@ -13,7 +13,7 @@ from pathlib import Path
13
13
  from mcp.server.fastmcp import FastMCP
14
14
 
15
15
  from . import config as cfg
16
- from . import callgraph, db, embeddings, gitsync, graph, indexer, insights, notes, summaries, views
16
+ from . import apiroutes, callgraph, db, embeddings, gitsync, graph, indexer, insights, notes, summaries, views
17
17
 
18
18
  mcp = FastMCP("cerebro")
19
19
 
@@ -357,6 +357,27 @@ def cerebro_dead_symbols(prefix: str = "") -> str:
357
357
  return "\n".join(out)
358
358
 
359
359
 
360
+ @mcp.tool()
361
+ def cerebro_endpoints(query: str = "") -> str:
362
+ """Backend HTTP endpoints (NestJS routes) the project exposes — the front↔back
363
+ boundary that `import` edges miss. Search by path / method / handler (e.g.
364
+ 'POST carts', 'promotions', 'findActive') to answer 'where is this endpoint
365
+ handled?' without grepping decorators."""
366
+ config, conn = _ctx()
367
+ eps = apiroutes.find(config, conn, query)
368
+ if not eps:
369
+ scope = f" matching '{query}'" if query else ""
370
+ return f"No backend endpoints{scope} found (scans *.controller.ts NestJS routes)."
371
+ cap = 60
372
+ out = [f"{len(eps)} endpoint(s)" + (f" matching '{query}'" if query else "") + ":"]
373
+ for e in eps[:cap]:
374
+ h = f" ({e['handler']})" if e["handler"] else ""
375
+ out.append(f" {e['method']:6} {e['path']} → {e['file']}:{e['line']}{h}")
376
+ if len(eps) > cap:
377
+ out.append(f" … (+{len(eps) - cap} more)")
378
+ return "\n".join(out)
379
+
380
+
360
381
  def map_main():
361
382
  """`cerebro-map` entry point: print the project map (read-only). Used by the
362
383
  Claude Code session-start hook to inject the overview into a new session."""
@@ -67,6 +67,23 @@ def select_central_missing(conn, limit: int, prefix: str | None = None) -> list[
67
67
  return out
68
68
 
69
69
 
70
+ def select_stale(conn, limit: int, prefix: str | None = None) -> list[str]:
71
+ """Files whose cached summary went STALE (source changed since it was written).
72
+ Re-warming these is the 'auto-record' path: edits during a session leave their
73
+ summaries outdated, and select_central_missing skips them (they HAVE a summary).
74
+ Bounded by `limit`."""
75
+ out = []
76
+ for path in summaries.stale_summaries(conn):
77
+ if cfg.Config.lang_for(path) is None:
78
+ continue
79
+ if prefix and not path.startswith(prefix):
80
+ continue
81
+ out.append(path)
82
+ if len(out) >= limit:
83
+ break
84
+ return out
85
+
86
+
70
87
  def run(config, conn, rels: list[str], model: str = DEFAULT_MODEL, workers: int = 4) -> dict:
71
88
  """Summarize files in parallel (claude -p subprocesses), then record serially
72
89
  (one sqlite writer). Returns a count of what was produced."""
@@ -91,11 +108,19 @@ def main(): # `cerebro-summarize` entry point
91
108
  ap.add_argument("--model", default=DEFAULT_MODEL)
92
109
  ap.add_argument("--prefix", default=None, help="only files under this path prefix")
93
110
  ap.add_argument("--workers", type=int, default=4)
111
+ ap.add_argument("--stale", action="store_true",
112
+ help="re-summarize summaries gone stale (file changed), then fill with missing")
94
113
  args = ap.parse_args()
95
114
 
96
115
  config = cfg.Config.load()
97
116
  conn = db.connect(config.db_path)
98
- rels = select_central_missing(conn, args.limit, args.prefix)
117
+ if args.stale:
118
+ rels = select_stale(conn, args.limit, args.prefix)
119
+ seen = set(rels)
120
+ rels += [r for r in select_central_missing(conn, args.limit - len(rels), args.prefix)
121
+ if r not in seen]
122
+ else:
123
+ rels = select_central_missing(conn, args.limit, args.prefix)
99
124
  if not rels:
100
125
  print(json.dumps({"summarized": 0, "note": "nothing missing in scope"}))
101
126
  return
@@ -0,0 +1,42 @@
1
+ from cerebro import apiroutes
2
+
3
+ SAMPLE = """
4
+ @Controller('carts')
5
+ export class CartController {
6
+ @Get()
7
+ findAll() {}
8
+
9
+ @Get(':id')
10
+ findOne(@Param('id') id: string) {}
11
+
12
+ @Post('lines')
13
+ @UseGuards(AuthGuard)
14
+ async addLine(@Body() dto: Dto) {}
15
+
16
+ @Delete(':id')
17
+ remove(@Param('id') id: string) {}
18
+ }
19
+ """
20
+
21
+
22
+ def test_extract_nestjs_routes():
23
+ eps = apiroutes.extract_file("cart.controller.ts", SAMPLE)
24
+ by = {(e["method"], e["path"]): e for e in eps}
25
+ assert ("GET", "/carts") in by
26
+ assert ("GET", "/carts/:id") in by
27
+ assert ("POST", "/carts/lines") in by
28
+ assert ("DELETE", "/carts/:id") in by
29
+ # handler attaches to the method even past an intervening decorator
30
+ assert by[("POST", "/carts/lines")]["handler"] == "addLine"
31
+ assert by[("GET", "/carts")]["handler"] == "findAll"
32
+
33
+
34
+ def test_bare_controller_and_empty_path():
35
+ eps = apiroutes.extract_file("x.controller.ts", "@Controller()\nclass X {\n @Get()\n ping() {}\n}\n")
36
+ assert eps == [
37
+ {"method": "GET", "path": "/", "file": "x.controller.ts", "line": 3, "handler": "ping"}
38
+ ]
39
+
40
+
41
+ def test_no_controller_no_routes():
42
+ assert apiroutes.extract_file("plain.ts", "export const x = 1;\n") == []
@@ -39,3 +39,27 @@ def test_run_records_generated_summaries(tmp_path, project, monkeypatch):
39
39
  res = summarizer.run(config, conn, ["a.py"], workers=1)
40
40
  assert res["summarized"] == 1
41
41
  assert summaries.get(conn, "a.py")["summary_en"] == "summary of a.py"
42
+
43
+
44
+ def test_select_stale_finds_changed_summaries(tmp_path, project):
45
+ """A summary whose source file changed (edit + reindex) surfaces as a stale
46
+ candidate, so the SessionEnd auto-record can re-warm it."""
47
+ config, conn = project
48
+ write(tmp_path, "x.py", "def a():\n return 1\n")
49
+ indexer.reindex(config, conn)
50
+ summaries.record(conn, "x.py", "does a")
51
+ assert summarizer.select_stale(conn, 10) == [] # fresh — not stale
52
+
53
+ write(tmp_path, "x.py", "def a():\n return 2 # changed\n")
54
+ indexer.reindex(config, conn)
55
+ assert summarizer.select_stale(conn, 10) == ["x.py"] # now stale
56
+
57
+
58
+ def test_select_stale_skips_non_source(tmp_path, project):
59
+ config, conn = project
60
+ write(tmp_path, "README.md", "# hi\n")
61
+ indexer.reindex(config, conn)
62
+ summaries.record(conn, "README.md", "readme")
63
+ write(tmp_path, "README.md", "# hi changed\n")
64
+ indexer.reindex(config, conn)
65
+ assert summarizer.select_stale(conn, 10) == [] # .md has no language -> skipped
@@ -1,25 +0,0 @@
1
- {
2
- "hooks": {
3
- "SessionStart": [
4
- {
5
- "hooks": [
6
- {
7
- "type": "command",
8
- "command": "${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py"
9
- }
10
- ]
11
- }
12
- ],
13
- "PostToolUse": [
14
- {
15
- "matcher": "Edit|Write|MultiEdit",
16
- "hooks": [
17
- {
18
- "type": "command",
19
- "command": "${CLAUDE_PLUGIN_ROOT}/hooks/post_edit.py"
20
- }
21
- ]
22
- }
23
- ]
24
- }
25
- }