memd 0.3.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.
memd/__init__.py ADDED
@@ -0,0 +1,113 @@
1
+ """memd — agent-driven project memory curator.
2
+
3
+ Maintains ./.memory/{state,decisions,mistakes,todo}.md (+ archive/, inbox/)
4
+ across projects, CLIs (claude-code, antigravity, others), and agent swarms.
5
+
6
+ Triggers:
7
+ - claude-code hooks: SessionStart (context brief), SessionEnd / PreCompact
8
+ (background distill of the session transcript)
9
+ - systemd user timer: `memd sweep` catches missed sessions, inbox notes
10
+ from other CLIs/agents, pruning, and auto-detects new projects.
11
+
12
+ Distillation brain: headless `claude -p` (haiku by default, sonnet for large
13
+ end-of-session distills) with a curator charter prompt. memd itself enforces
14
+ the invariants the model must not be trusted with: frontmatter, append-only
15
+ mistakes.md, shrink guard, size budgets, archive overflow, git audit commits.
16
+ """
17
+
18
+ __version__ = "0.3.0"
19
+
20
+ from memd.errors import ConfigError, CuratorError, DigestError, MemdError
21
+ from memd.config import (
22
+ AG_INDEX_PATH,
23
+ CLAUDE_PROJECTS_DIR,
24
+ CLAUDE_SETTINGS,
25
+ CONFIG_DIR,
26
+ CONFIG_PATH,
27
+ CURSORS_PATH,
28
+ DEFAULT_CONFIG,
29
+ HOME,
30
+ LOCK_DIR,
31
+ LOG_PATH,
32
+ MEMORY_FILES,
33
+ META_PATH,
34
+ STATE_DIR,
35
+ STATE_LOCK_PATH,
36
+ XDG_CONFIG_HOME,
37
+ XDG_STATE_HOME,
38
+ atomic_write,
39
+ git_toplevel,
40
+ load_config,
41
+ load_json,
42
+ log,
43
+ save_config,
44
+ save_json,
45
+ today,
46
+ update_json,
47
+ )
48
+ from memd.memory import (
49
+ BRIEF_NOTE,
50
+ FM_RE,
51
+ MODEL_STUB,
52
+ SKELETONS,
53
+ append_archive,
54
+ archive_path,
55
+ brief_sections,
56
+ enforce_budget_mistakes,
57
+ find_project,
58
+ git_commit_memory,
59
+ global_slice,
60
+ make_brief,
61
+ read_memory,
62
+ register,
63
+ render_frontmatter,
64
+ scaffold,
65
+ section_date,
66
+ split_frontmatter,
67
+ split_sections,
68
+ write_memory_file,
69
+ )
70
+ from memd.digest import (
71
+ AG_KNOWN_STEP_TYPES,
72
+ AG_STEP_ASSISTANT,
73
+ AG_STEP_ERROR,
74
+ AG_STEP_TOOL,
75
+ AG_STEP_USER,
76
+ REDACT_PATTERNS,
77
+ REDACT_PEM_RE,
78
+ REDACT_RE,
79
+ _pb_strings,
80
+ ag_conversations_dir,
81
+ ag_max_idx,
82
+ ag_workspace,
83
+ baseline_cursor,
84
+ cap_digest,
85
+ digest_ag_db,
86
+ digest_jsonl,
87
+ digest_source,
88
+ project_ag_dbs,
89
+ redact,
90
+ )
91
+ from memd.inbox import collect_inbox, write_inbox_note
92
+ from memd.curator import CHARTER, build_prompt, call_curator, validate
93
+ from memd.sync import (
94
+ encode_claude_dir,
95
+ project_lock,
96
+ source_pending,
97
+ sync_project,
98
+ transcript_files,
99
+ )
100
+ from memd.sweep import (
101
+ cmd_sweep,
102
+ detect_new_projects,
103
+ ensure_global,
104
+ write_global_index,
105
+ )
106
+ from memd.hooks import (
107
+ HOOK_DEFS,
108
+ cmd_hook,
109
+ cmd_install_hooks,
110
+ detach,
111
+ self_invocation,
112
+ )
113
+ from memd.cli import cmd_note, cmd_status, main
memd/cli.py ADDED
@@ -0,0 +1,196 @@
1
+ """CLI argument parsing and command dispatch."""
2
+
3
+ import argparse
4
+ import os
5
+ import shutil
6
+ import sys
7
+
8
+ from memd import __doc__, __version__
9
+ from memd.config import (CURSORS_PATH, META_PATH, git_toplevel, load_config,
10
+ load_json, save_config, update_json)
11
+ from memd.digest import baseline_cursor
12
+ from memd.hooks import cmd_hook, cmd_install_hooks
13
+ from memd.inbox import collect_inbox, write_inbox_note
14
+ from memd.memory import (find_project, make_brief, register, scaffold,
15
+ init_memory_repo, main_worktree_root, migrate_parent_untrack,
16
+ memory_checkout_clean)
17
+ from memd.sweep import cmd_sweep, ensure_global
18
+ from memd.sync import source_pending, sync_project, transcript_files
19
+
20
+ # --------------------------------------------------------------------------
21
+ # status / CLI plumbing
22
+ # --------------------------------------------------------------------------
23
+
24
+
25
+ def cmd_status(cfg):
26
+ ensure_global(cfg)
27
+ meta = load_json(META_PATH, {})
28
+ cursors = load_json(CURSORS_PATH, {})
29
+ if not cfg["projects"]:
30
+ print("no projects registered (run: memd init [path])")
31
+ return
32
+ for path, entry in sorted(cfg["projects"].items()):
33
+ m = meta.get(path, {})
34
+ srcs = transcript_files(cfg, path)
35
+ pending = [s for s in srcs if source_pending(s, cursors)]
36
+ ag = sum(1 for s in srcs if s.endswith(".db"))
37
+ inbox = len(collect_inbox(path)[1])
38
+ print(f"{entry['name']} ({path})")
39
+ print(f" last distill : {m.get('last_sync', 'never')}"
40
+ + (f" [{m.get('trigger')}/{m.get('model')}]" if m else ""))
41
+ if m.get("summary"):
42
+ print(f" summary : {m['summary']}")
43
+ print(f" sources : {len(srcs)} ({ag} antigravity), "
44
+ f"{len(pending)} with pending content, {inbox} inbox note(s)")
45
+
46
+
47
+ def cmd_note(cfg, args):
48
+ text = args.message if args.message is not None else sys.stdin.read()
49
+ text = text.strip()
50
+ if not text:
51
+ print("empty note; nothing written", file=sys.stderr)
52
+ return 2
53
+ if args.is_global:
54
+ root = ensure_global(cfg)
55
+ if not root:
56
+ print("no global_root configured", file=sys.stderr)
57
+ return 2
58
+ else:
59
+ path = os.path.realpath(args.project)
60
+ root = find_project(cfg, path) or git_toplevel(path) or path
61
+ print(write_inbox_note(os.path.realpath(root), text, source=args.source))
62
+ return 0
63
+
64
+
65
+ def main():
66
+ ap = argparse.ArgumentParser(prog="memd", description=__doc__.splitlines()[0])
67
+ ap.add_argument("--version", action="version", version=f"memd {__version__}")
68
+ sub = ap.add_subparsers(dest="cmd", required=True)
69
+
70
+ p = sub.add_parser("init", help="scaffold .memory/ and register a project")
71
+ p.add_argument("path", nargs="?", default=".")
72
+ p.add_argument("--name")
73
+ p.add_argument("--global", dest="is_global", action="store_true",
74
+ help="seed the global memory root (~/.memory) instead of a project")
75
+
76
+ p = sub.add_parser("sync", help="distill new session content into memory")
77
+ p.add_argument("--project", default=".")
78
+ p.add_argument("--transcript")
79
+ p.add_argument("--trigger", default="manual")
80
+ p.add_argument("--dry-run", action="store_true")
81
+
82
+ p = sub.add_parser("sweep",
83
+ help="timer entry: catch up all projects, prune, detect")
84
+ p.add_argument("--jobs", type=int,
85
+ help="parallel project syncs (default: config sweep_jobs, 4)")
86
+ sub.add_parser("status", help="show registry, backlog, last distills")
87
+ sub.add_parser("install-hooks", help="wire memd into ~/.claude/settings.json")
88
+
89
+ p = sub.add_parser("brief", help="print session-start memory brief")
90
+ p.add_argument("path", nargs="?", default=".")
91
+ p.add_argument("--max-chars", type=int,
92
+ help="hard cap on total brief output for this run")
93
+ p.add_argument("--topic",
94
+ help="only content sections mentioning this keyword "
95
+ "(case-insensitive)")
96
+
97
+ p = sub.add_parser("hook", help="claude-code hook entry (reads JSON on stdin)")
98
+ p.add_argument("event", choices=["session-start", "session-end", "pre-compact"])
99
+
100
+ p = sub.add_parser("note", help="append a collision-safe note to a curator inbox")
101
+ p.add_argument("--project", default=".")
102
+ p.add_argument("--global", dest="is_global", action="store_true",
103
+ help="write to the global memory inbox instead of a project")
104
+ p.add_argument("-m", "--message", help="note text (default: read from stdin)")
105
+ p.add_argument("--source", help="optional writer label recorded in the note")
106
+
107
+ p = sub.add_parser("exclude", help="never auto-manage a path")
108
+ p.add_argument("path")
109
+
110
+ args = ap.parse_args()
111
+ cfg = load_config()
112
+
113
+ if args.cmd == "init" and getattr(args, "is_global", False):
114
+ gr = ensure_global(cfg)
115
+ print(f"global memory root ready at {os.path.join(gr, '.memory')}" if gr
116
+ else "no global_root configured")
117
+ elif args.cmd == "init" and os.path.realpath(args.path) == os.path.realpath(cfg.get("global_root") or "\0"):
118
+ # Don't let a bare `memd init ~` register the global root as a normal
119
+ # project (model stub in $HOME, lost "global" flag). Route to --global.
120
+ print("that path is the global memory root; use: memd init --global",
121
+ file=sys.stderr)
122
+ sys.exit(2) # config error
123
+ elif args.cmd == "init":
124
+ path = os.path.realpath(args.path)
125
+ mem = os.path.join(path, ".memory")
126
+ mem_pre = os.path.exists(mem) # real .memory here before we scaffold?
127
+ # A pre-existing .memory is safe to relink only if it is a clean, tracked
128
+ # checkout (content already in git); check before scaffold dirties it.
129
+ mem_pre_clean = mem_pre and memory_checkout_clean(path)
130
+ created = scaffold(path, args.name)
131
+
132
+ if cfg.get("memory_own_repo") and cfg.get("git_commit"):
133
+ migrate_parent_untrack(path)
134
+ main_root = main_worktree_root(path)
135
+ if main_root != path:
136
+ # Linked worktree: share the main worktree's single store via a
137
+ # symlink so worktrees can't diverge — but never destroy unsaved
138
+ # memory (memd must not lose memory).
139
+ main_mem = os.path.join(main_root, ".memory")
140
+ created.extend(scaffold(main_root, name=args.name))
141
+ init_memory_repo(main_mem, "memd: seed project memory root")
142
+ if os.path.islink(mem):
143
+ pass # already linked (idempotent)
144
+ elif (not mem_pre) or mem_pre_clean:
145
+ # Only the templates we just scaffolded, or a clean checkout
146
+ # whose content already lives in git and the shared store.
147
+ shutil.rmtree(mem)
148
+ os.symlink(main_mem, mem)
149
+ else:
150
+ print(f"warning: {mem} holds unsaved memory; not replacing it "
151
+ f"with a symlink to {main_mem} (resolve manually)", file=sys.stderr)
152
+ else:
153
+ init_memory_repo(mem, "memd: seed project memory root")
154
+
155
+ register(cfg, path, args.name)
156
+ # Baseline: start memory from now. Pre-existing transcripts are
157
+ # presumed covered by existing memory (or not worth back-filling).
158
+ skipped = 0
159
+ def _baseline(cursors):
160
+ nonlocal skipped
161
+ for src in transcript_files(cfg, path):
162
+ if src not in cursors:
163
+ cursors[src] = baseline_cursor(src)
164
+ skipped += 1
165
+ update_json(CURSORS_PATH, _baseline)
166
+ print(f"registered {path}"
167
+ + (f", created {len(created)} file(s)" if created else "")
168
+ + (f", baselined {skipped} existing transcript(s)" if skipped else ""))
169
+ elif args.cmd == "sync":
170
+ ok = sync_project(cfg, args.project, trigger=args.trigger,
171
+ transcript=args.transcript, dry_run=args.dry_run)
172
+ # exit contract: 0 = ok/no-op, 3 = curator/distill failure
173
+ sys.exit(3 if ok is False else 0)
174
+ elif args.cmd == "sweep":
175
+ # 1 = known error (failed distills)
176
+ sys.exit(1 if cmd_sweep(cfg, jobs=args.jobs) else 0)
177
+ elif args.cmd == "status":
178
+ cmd_status(cfg)
179
+ elif args.cmd == "install-hooks":
180
+ cmd_install_hooks()
181
+ elif args.cmd == "brief":
182
+ brief = make_brief(cfg, args.path, max_chars=args.max_chars,
183
+ topic=args.topic)
184
+ print(brief or "(no .memory directory here)")
185
+ elif args.cmd == "note":
186
+ sys.exit(cmd_note(cfg, args))
187
+ elif args.cmd == "hook":
188
+ sys.exit(cmd_hook(cfg, args.event))
189
+ elif args.cmd == "exclude":
190
+ path = os.path.realpath(args.path)
191
+ if path not in cfg["exclude"]:
192
+ cfg["exclude"].append(path)
193
+ cfg["projects"].pop(path, None)
194
+ save_config(cfg)
195
+ print(f"excluded {path}")
196
+
memd/config.py ADDED
@@ -0,0 +1,164 @@
1
+ """Config loading, defaults, path constants, and shared low-level utilities
2
+ (logging, atomic JSON, locked registry updates)."""
3
+
4
+ import datetime as dt
5
+ import fcntl
6
+ import json
7
+ import os
8
+ import subprocess
9
+
10
+ HOME = os.path.expanduser("~")
11
+ XDG_CONFIG_HOME = os.environ.get("XDG_CONFIG_HOME") or os.path.join(HOME, ".config")
12
+ XDG_STATE_HOME = os.environ.get("XDG_STATE_HOME") or os.path.join(HOME, ".local", "state")
13
+ CONFIG_DIR = os.path.join(XDG_CONFIG_HOME, "memd")
14
+ CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json")
15
+ STATE_DIR = os.path.join(XDG_STATE_HOME, "memd")
16
+ CURSORS_PATH = os.path.join(STATE_DIR, "cursors.json")
17
+ AG_INDEX_PATH = os.path.join(STATE_DIR, "ag_index.json")
18
+ META_PATH = os.path.join(STATE_DIR, "meta.json")
19
+ LOCK_DIR = os.path.join(STATE_DIR, "locks")
20
+ STATE_LOCK_PATH = os.path.join(STATE_DIR, "state.lock")
21
+ LOG_PATH = os.path.join(STATE_DIR, "memd.log")
22
+ CLAUDE_PROJECTS_DIR = os.path.join(HOME, ".claude", "projects")
23
+ CLAUDE_SETTINGS = os.path.join(HOME, ".claude", "settings.json")
24
+
25
+ MEMORY_FILES = ("state.md", "decisions.md", "mistakes.md", "todo.md")
26
+
27
+ DEFAULT_CONFIG = {
28
+ "claude_bin": "claude",
29
+ "curator_cmd": [], # override distill backend: argv list, prompt on
30
+ # stdin, "{model}" substituted; output must
31
+ # contain one JSON object (fences/prose ok).
32
+ # Empty -> claude_bin headless invocation.
33
+ "antigravity_dir": os.path.join(HOME, ".gemini", "antigravity-cli"),
34
+ "model_small": "haiku",
35
+ "model_large": "sonnet",
36
+ "escalate_chars": 15000, # session-end digests above this go to model_large
37
+ "digest_cap_chars": 60000, # max transcript digest fed to the model
38
+ "quiet_seconds": 600, # sweep skips transcripts modified more recently
39
+ "sweep_jobs": 4, # parallel project syncs per sweep (threads;
40
+ # per-project flock still serializes any
41
+ # single project). CLI --jobs overrides.
42
+ "auto_scaffold": True, # scaffold .memory/ in detected git-root projects
43
+ "git_commit": True, # commit .memory/ changes after each distill
44
+ "memory_own_repo": True, # use a standalone git repo for .memory/ to decouple history
45
+ "budgets": { # active-file size budgets (chars of body)
46
+ "state.md": 10000,
47
+ "decisions.md": 12000,
48
+ "todo.md": 10000,
49
+ "mistakes.md": 22000,
50
+ },
51
+ "REDACT_EXTRA_PATTERNS": [], # extra raw regex strings redacted from
52
+ # digests/inbox notes on top of the
53
+ # built-in REDACT_PATTERNS
54
+ "exclude": [], # absolute paths never auto-managed
55
+ "projects": {}, # path -> {"name": str, "extra_sources": [globs]}
56
+ "global_root": HOME, # project path whose .memory holds system/user/
57
+ # cross-project truth (store: ~/.memory). The
58
+ # fallback when a session belongs to no project.
59
+ "global_brief_chars": 800, # cap for the global state.md excerpt layered
60
+ # into a project session's brief. 0 = pointer
61
+ # only (no excerpt); <0 = omit the global slice.
62
+ "brief_chars": 2500, # budget for the brief's content sections
63
+ # (todos / decisions / state / mistakes) —
64
+ # the fixed BRIEF_NOTE + metadata overhead
65
+ # (~300 chars) is not counted against it.
66
+ "brief_decisions_days": 30, # recency window for decisions.md sections
67
+ # surfaced in the brief.
68
+ }
69
+
70
+ # --------------------------------------------------------------------------
71
+ # small utilities
72
+ # --------------------------------------------------------------------------
73
+
74
+
75
+ LOG_BACKUPS = 3 # rotated copies kept: memd.log.1 (newest) .. memd.log.3
76
+
77
+
78
+ def log(msg):
79
+ os.makedirs(STATE_DIR, exist_ok=True)
80
+ try:
81
+ if os.path.exists(LOG_PATH) and os.path.getsize(LOG_PATH) > 1_000_000:
82
+ for i in range(LOG_BACKUPS - 1, 0, -1):
83
+ if os.path.exists(f"{LOG_PATH}.{i}"):
84
+ os.replace(f"{LOG_PATH}.{i}", f"{LOG_PATH}.{i + 1}")
85
+ os.replace(LOG_PATH, LOG_PATH + ".1")
86
+ except OSError:
87
+ pass
88
+ stamp = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
89
+ with open(LOG_PATH, "a") as f:
90
+ f.write(f"{stamp} {msg}\n")
91
+
92
+
93
+ def load_json(path, default):
94
+ try:
95
+ with open(path) as f:
96
+ return json.load(f)
97
+ except (OSError, json.JSONDecodeError):
98
+ return default
99
+
100
+
101
+ def save_json(path, data):
102
+ os.makedirs(os.path.dirname(path), exist_ok=True)
103
+ tmp = path + ".tmp"
104
+ with open(tmp, "w") as f:
105
+ json.dump(data, f, indent=2, sort_keys=True)
106
+ f.write("\n")
107
+ os.replace(tmp, path)
108
+
109
+
110
+ def atomic_write(path, text):
111
+ tmp = path + ".tmp"
112
+ with open(tmp, "w") as f:
113
+ f.write(text)
114
+ os.replace(tmp, path)
115
+
116
+
117
+ def update_json(path, mutate):
118
+ """Read-modify-write a shared registry JSON file (cursors, meta) under a
119
+ process-wide lock, so concurrent project syncs can't lose each other's
120
+ updates. The on-disk value is re-read inside the lock and only the caller's
121
+ delta is merged in — never a stale in-memory snapshot. The lock is held only
122
+ for the merge, never across a curator call."""
123
+ os.makedirs(STATE_DIR, exist_ok=True)
124
+ fd = open(STATE_LOCK_PATH, "w")
125
+ try:
126
+ fcntl.flock(fd, fcntl.LOCK_EX)
127
+ data = load_json(path, {})
128
+ mutate(data)
129
+ save_json(path, data)
130
+ return data
131
+ finally:
132
+ fcntl.flock(fd, fcntl.LOCK_UN)
133
+ fd.close()
134
+
135
+
136
+ def load_config():
137
+ cfg = dict(DEFAULT_CONFIG)
138
+ on_disk = load_json(CONFIG_PATH, {})
139
+ for k, v in on_disk.items():
140
+ if k == "budgets":
141
+ cfg["budgets"] = {**DEFAULT_CONFIG["budgets"], **v}
142
+ else:
143
+ cfg[k] = v
144
+ return cfg
145
+
146
+
147
+ def save_config(cfg):
148
+ save_json(CONFIG_PATH, cfg)
149
+
150
+
151
+ def git_toplevel(path):
152
+ try:
153
+ out = subprocess.run(
154
+ ["git", "-C", path, "rev-parse", "--show-toplevel"],
155
+ capture_output=True, text=True, timeout=10,
156
+ )
157
+ if out.returncode == 0:
158
+ return out.stdout.strip()
159
+ except (OSError, subprocess.TimeoutExpired):
160
+ pass
161
+ return None
162
+
163
+ def today():
164
+ return dt.date.today().isoformat()