PMB - local-first persistent memory for Cursor (MCP, no cloud, no API keys)

Cursor forgets everything between sessions, so you keep re-explaining the same decisions, lessons and project context. I built PMB to fix that: a local-first memory that lives in SQLite on your machine and feeds the right context back to the agent over MCP. No cloud, no API keys, no LLM call on the read path.

Your project’s memory, visualized - entities and connections captured automatically as you work.

:link: GitHub: GitHub - oleksiijko/pmb: Local-first persistent memory for AI coding agents (Claude Code, Cursor, Codex) over MCP. Decisions, lessons and facts live in one SQLite file on your disk. Offline, multilingual. Β· GitHub
:link: Site: https://pmbai.dev


:sparkles: What it does

  • Remembers decisions, lessons (β€œwe use X, never Y”), personal facts, project structure, even PDFs - across restarts, model switches and agent switches.
  • Hybrid recall (BM25 + vectors + entity graph) in ~35 ms warm; writes return in under a millisecond.
  • Multilingual out of the box - a Russian query finds an English fact, no per-language setup.
  • Honest about impact: a local dashboard shows which lessons actually changed outcomes, instead of claiming β€œ+X%”.

:electric_plug: Using it with Cursor

Cursor is MCP-native, so wiring it is one command:

pip install pmb-ai
pmb connect cursor      # adds the MCP server + appends the read-first rules
pmb warmup              # preload the model so the first recall is instant

Cursor has no lifecycle hooks (unlike Claude Code), so on Cursor PMB works two ways: the agent calls recall() / prepare() itself (the rules pmb connect writes nudge it to), and a git observer captures the write side automatically:

pmb ambient-watch .     # journals your work as you edit, no manual record step
pmb dashboard           # local web UI: graph, timeline, lessons (127.0.0.1 only)

Timeline - everything the agent did and learned, newest first.

:locked: Why local-first

The whole workspace is a folder under ~/.pmb/. 100% offline, zero telemetry, secrets auto-redacted at write time. Copy it to a USB stick, sync it, or push the encrypted bundle to a public repo - your call. Apache-2.0.


Would love feedback from Cursor users - especially on the MCP-only ambient-write flow. Happy to answer anything in the thread. :star: if it saves you a re-explanation.

1 Like

The local-first part is the most interesting bit to me.

For coding agents, memory only becomes useful if it can be inspected, corrected, and moved with the project. A SQLite file plus MCP feels closer to that than a hidden cloud memory layer, especially if the dashboard can show which facts actually changed a later decision.

1 Like

This is the thesis exactly. The gamble here is that the unopenable, unfixed, unmovable memory is not actually yours – thus a simple SQLite file readable by anything, the local dashboard allows to see/fix/forget any of the facts, and pmb workspace export (git-sync) includes this memory with the codebase.

The part you left at the end of your comment is the one that interests me the most. The dashboard does not just list the facts; it shows which lessons were revealed to be important and followed by the agent, allowing to see how well a rule is doing its job (and remove rules that never made a difference). β€œDid this particular fact make a difference in future decisions” is the entire premise of my adherence chart.

1 Like

It gives me an error when trying to start the webui dashboard:

C:\Windows\System32>pmb dashboard
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ Traceback (most recent call last) ─────────────────────────────────────────┐
β”‚ C:\Users\XXXX\AppData\Local\Programs\Python\Python312\Lib\site-packages\pmb\cli\commands\capture.py:74 in       β”‚
β”‚ dashboard                                                                                                           β”‚
β”‚                                                                                                                     β”‚
β”‚    71 β”‚   β”‚   import threading                                                                                      β”‚
β”‚    72 β”‚   β”‚   import webbrowser                                                                                     β”‚
β”‚    73 β”‚   β”‚   threading.Timer(1.2, lambda: webbrowser.open(f"http://{host}:{port}")).start()                        β”‚
β”‚ >  74 β”‚   run_dashboard(eng, host=host, port=port)                                                                  β”‚
β”‚    75                                                                                                               β”‚
β”‚    76                                                                                                               β”‚
β”‚    77 @app.command()                                                                                                β”‚
β”‚                                                                                                                     β”‚
β”‚ C:\Users\XXXX\AppData\Local\Programs\Python\Python312\Lib\site-packages\pmb\dashboard\server.py:778 in          β”‚
β”‚ run_dashboard                                                                                                       β”‚
β”‚                                                                                                                     β”‚
β”‚   775 def run_dashboard(engine, host: str = "127.0.0.1", port: int = 8765) -> None:                                 β”‚
β”‚   776 β”‚   """Blocking - runs until KeyboardInterrupt."""                                                            β”‚
β”‚   777 β”‚   handler = make_handler(engine)                                                                            β”‚
β”‚ > 778 β”‚   server = ThreadingHTTPServer((host, port), handler)                                                       β”‚
β”‚   779 β”‚   print(f"PMB dashboard at http://{host}:{port}")                                                           β”‚
β”‚   780 β”‚   print(f"Workspace: {engine.workspace.name} ({engine.workspace.id})")                                      β”‚
β”‚   781 β”‚   print("Press Ctrl+C to stop.")                                                                            β”‚
β”‚                                                                                                                     β”‚
β”‚ C:\Users\XXXX\AppData\Local\Programs\Python\Python312\Lib\socketserver.py:457 in __init__                       β”‚
β”‚                                                                                                                     β”‚
β”‚   454 β”‚   β”‚   β”‚   β”‚   β”‚   β”‚   β”‚   β”‚   β”‚   self.socket_type)                                                         β”‚
β”‚   455 β”‚   β”‚   if bind_and_activate:                                                                                 β”‚
β”‚   456 β”‚   β”‚   β”‚   try:                                                                                              β”‚
β”‚ > 457 β”‚   β”‚   β”‚   β”‚   self.server_bind()                                                                            β”‚
β”‚   458 β”‚   β”‚   β”‚   β”‚   self.server_activate()                                                                        β”‚
β”‚   459 β”‚   β”‚   β”‚   except:                                                                                           β”‚
β”‚   460 β”‚   β”‚   β”‚   β”‚   self.server_close()                                                                           β”‚
β”‚                                                                                                                     β”‚
β”‚ C:\Users\XXXX\AppData\Local\Programs\Python\Python312\Lib\http\server.py:136 in server_bind                     β”‚
β”‚                                                                                                                     β”‚
β”‚    133 β”‚                                                                                                            β”‚
β”‚    134 β”‚   def server_bind(self):                                                                                   β”‚
β”‚    135 β”‚   β”‚   """Override server_bind to store the server name."""                                                 β”‚
β”‚ >  136 β”‚   β”‚   socketserver.TCPServer.server_bind(self)                                                             β”‚
β”‚    137 β”‚   β”‚   host, port = self.server_address[:2]                                                                 β”‚
β”‚    138 β”‚   β”‚   self.server_name = socket.getfqdn(host)                                                              β”‚
β”‚    139 β”‚   β”‚   self.server_port = port                                                                              β”‚
β”‚                                                                                                                     β”‚
β”‚ C:\Users\XXXX\AppData\Local\Programs\Python\Python312\Lib\socketserver.py:478 in server_bind                    β”‚
β”‚                                                                                                                     β”‚
β”‚   475 β”‚   β”‚   β”‚   and self.address_family in (socket.AF_INET, socket.AF_INET6)                                      β”‚
β”‚   476 β”‚   β”‚   ):                                                                                                    β”‚
β”‚   477 β”‚   β”‚   β”‚   self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)                                 β”‚
β”‚ > 478 β”‚   β”‚   self.socket.bind(self.server_address)                                                                 β”‚
β”‚   479 β”‚   β”‚   self.server_address = self.socket.getsockname()                                                       β”‚
β”‚   480 β”‚                                                                                                             β”‚
β”‚   481 β”‚   def server_activate(self):                                                                                β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
PermissionError: [WinError 10013] An attempt was made to access a socket in a way forbidden by its access permissions

C:\Windows\System32>

Would have been nice with some built-in way to configure β€œpmb ambient-watch .” targets and run those in one command rather then spawning multiple command windows (and inevitably forgetting some of them on restart)

Hey, try to start on other port like pmb dashboard --port 4444

And overall thanks for idea and bug, will add to my list !

Yeah that seem to start the UI, but there are no events or any data in it from the looks of it. I have a watch running that have observed changes, but still nothing anywhere.

Seems interesting, but this tool is really not for me in the current state I think, I just want to run max 1 command to have things running, or alternatively just double click an icon.

Thanks for trying it and for the honest feedback. That’s fair.

If the UI starts but shows no events while watch says it observed changes, that’s exactly the kind of onboarding gap I need to fix. PMB should make it obvious which workspace is active, what is being watched, how many events were captured, and why the dashboard is empty if nothing was stored.

And I agree with the β€œone command” point. The goal should be closer to pmb start: daemon, watch, and dashboard running together, with a clear status screen. A desktop/icon flow would also make sense later.

Really appreciate you calling this out. The memory idea only works if the first-run experience is boringly simple.