memdebug 0.2.0__py3-none-any.whl

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.
memdebug/agents.py ADDED
@@ -0,0 +1,128 @@
1
+ """Which agents are on this computer, and where each keeps what it remembers.
2
+
3
+ Each entry below was checked against the agent's own documentation. Presence is judged only by whether a known folder exists
4
+ (`lstat`; nothing is opened or read, links and junctions are ignored). Where an agent keeps memory in one file inside a folder
5
+ that also holds settings or credentials (`~/.gemini`, `~/.codex`, `~/.claude`), only that file is offered, never the folder.
6
+
7
+ Assistants that keep their memory in the provider's cloud (ChatGPT, Claude's apps, Gemini, Copilot) have nothing on this computer
8
+ to watch, and memdebug says so instead of pretending: it cannot tell whether those apps are installed, and it could not read
9
+ their memory if they were.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import stat
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+
18
+ from .adapters.markdown_git import _is_reparse_point
19
+ from .stores import Candidate, discover
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Place:
24
+ parts: tuple[str, ...] # folder, relative to the home folder
25
+ files: tuple[str, ...] = () # if set: only these files in that folder, nothing else
26
+ name: str = "" # suggested store name
27
+ what: str = ""
28
+ always: bool = False # offer even before any note exists (the agent creates the folder itself)
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Agent:
33
+ key: str
34
+ name: str
35
+ sign: tuple[str, ...] # a folder under home whose existence means the agent is probably installed
36
+ note: str
37
+ places: tuple[Place, ...] = ()
38
+
39
+
40
+ AGENTS: tuple[Agent, ...] = (
41
+ Agent("claude-code", "Claude Code", (".claude",),
42
+ "keeps notes in CLAUDE.md files and a memory folder per project",
43
+ (Place((".claude",), ("CLAUDE.md",), "claude-code-global", "your global CLAUDE.md instructions"),)),
44
+ Agent("openclaw", "OpenClaw", (".openclaw",),
45
+ "keeps its memory as markdown in a workspace folder (MEMORY.md, memory/, USER.md); its documentation suggests keeping it in git",
46
+ (Place((".openclaw", "workspace"), (), "openclaw", "the OpenClaw workspace (memory, identity and instruction files)", always=True),)),
47
+ Agent("gemini-cli", "Gemini CLI", (".gemini",),
48
+ "saves what it is asked to remember in ~/.gemini/GEMINI.md",
49
+ (Place((".gemini",), ("GEMINI.md",), "gemini-cli", "GEMINI.md, where its memory tool saves facts", always=True),)),
50
+ Agent("codex-cli", "Codex CLI", (".codex",),
51
+ "has no memory of its own; it follows the instructions in ~/.codex/AGENTS.md, which is why planted text there matters",
52
+ (Place((".codex",), ("AGENTS.md", "AGENTS.override.md"), "codex-cli", "its AGENTS.md instruction files"),)),
53
+ Agent("windsurf", "Windsurf", (".codeium", "windsurf"),
54
+ "stores its automatically generated memories on this computer only",
55
+ (Place((".codeium", "windsurf", "memories"), (), "windsurf", "Cascade's memories (the markdown files in it)"),)),
56
+ )
57
+
58
+ CLOUD_NOTE = ("ChatGPT, Claude (claude.ai and its desktop and phone apps), Gemini and Copilot keep their memory in the provider's cloud. "
59
+ "memdebug cannot see it, and cannot tell whether those apps are installed. Review or clear it in each app's settings; "
60
+ "to keep a record of how it changes, copy it into a markdown file now and then, in a folder you add to memdebug.")
61
+
62
+
63
+ @dataclass
64
+ class FoundAgent:
65
+ agent: Agent
66
+ candidates: list[Candidate] = field(default_factory=list)
67
+
68
+
69
+ def _real_folder(path: Path) -> bool:
70
+ try:
71
+ info = os.lstat(path)
72
+ except OSError:
73
+ return False
74
+ return stat.S_ISDIR(info.st_mode) and not stat.S_ISLNK(info.st_mode) and not _is_reparse_point(info)
75
+
76
+
77
+ def _has_markdown(path: Path) -> bool:
78
+ try:
79
+ return any(name.lower().endswith(".md") for name in os.listdir(path)[:2000])
80
+ except OSError:
81
+ return False
82
+
83
+
84
+ def _is_plain_file(path: Path) -> bool:
85
+ try:
86
+ info = os.lstat(path)
87
+ except OSError:
88
+ return False
89
+ return stat.S_ISREG(info.st_mode) and not _is_reparse_point(info)
90
+
91
+
92
+ def scan_agents(home: Path | None = None) -> list[FoundAgent]:
93
+ """The known agents that appear to be installed, with what could be watched for each. Reads no file contents."""
94
+ root = home or Path.home()
95
+ found: list[FoundAgent] = []
96
+ for agent in AGENTS:
97
+ if not _real_folder(root.joinpath(*agent.sign)):
98
+ continue
99
+ entry = FoundAgent(agent)
100
+ for place in agent.places:
101
+ folder = root.joinpath(*place.parts)
102
+ if not _real_folder(folder):
103
+ continue
104
+ if place.files:
105
+ present = [name for name in place.files if _is_plain_file(folder / name)]
106
+ if not present and not place.always:
107
+ continue
108
+ entry.candidates.append(Candidate("folder", place.name, folder, f"{agent.name}: {place.what}", files=place.files))
109
+ elif place.always or _has_markdown(folder):
110
+ entry.candidates.append(Candidate("folder", place.name, folder, f"{agent.name}: {place.what}"))
111
+ if agent.key == "claude-code": # its per-project memory folders are found by name, as before
112
+ entry.candidates += [Candidate(c.kind, c.name, c.path, f"{agent.name}: {c.why}") for c in discover(root)]
113
+ found.append(entry)
114
+ return found
115
+
116
+
117
+ def summary_lines(found: list[FoundAgent], docker_names: list[str] | None = None) -> list[str]:
118
+ lines = []
119
+ for item in found:
120
+ watchable = f"{len(item.candidates)} place(s) memdebug can watch" if item.candidates else "nothing to watch yet"
121
+ lines.append(f" {item.agent.name}: {item.agent.note} ({watchable})")
122
+ for container in docker_names or []:
123
+ lines.append(f" Open WebUI (running in Docker as '{container}'): keeps its memory in a database (1 place memdebug can watch)")
124
+ if not lines:
125
+ lines.append(" None of the agents memdebug knows were found.")
126
+ lines.append("")
127
+ lines.append(f" {CLOUD_NOTE}")
128
+ return lines
memdebug/backends.py ADDED
@@ -0,0 +1,29 @@
1
+ """Small facts about store types that the viewer and the command line both need."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ from .textsafe import safe_text
7
+
8
+ # Stores that keep no change history of their own: memdebug can only record what it sees between two looks, so it cannot say
9
+ # who made a change or whether it bypassed anything.
10
+ HISTORYLESS = frozenset({"folder", "openwebui"})
11
+
12
+ _OPAQUE = re.compile(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}|[0-9a-fA-F]{32}|[0-9a-fA-F]{40}|[0-9a-fA-F]{64}")
13
+
14
+
15
+ def is_opaque_id(memory_id: str) -> bool:
16
+ """A random identifier (a UUID or a hash) that tells a person nothing, as opposed to a file name or a short id."""
17
+ return _OPAQUE.fullmatch(memory_id) is not None
18
+
19
+
20
+ def short_id(memory_id: str) -> str:
21
+ return memory_id[:8] + "…" if is_opaque_id(memory_id) else memory_id
22
+
23
+
24
+ def friendly_id(memory_id: str, text: str | None, width: int = 40) -> str:
25
+ """For an opaque id: its short form and the start of what the memory says. Otherwise the id itself, which is already meaningful."""
26
+ if not is_opaque_id(memory_id):
27
+ return safe_text(memory_id, 60)
28
+ words = safe_text(" ".join((text or "").split()), width)
29
+ return f'{memory_id[:8]}… "{words}"' if words else f"{memory_id[:8]}…"