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 +113 -0
- memd/cli.py +196 -0
- memd/config.py +164 -0
- memd/curator.py +251 -0
- memd/digest.py +378 -0
- memd/errors.py +28 -0
- memd/hooks.py +117 -0
- memd/inbox.py +80 -0
- memd/memory.py +491 -0
- memd/sweep.py +201 -0
- memd/sync.py +195 -0
- memd-0.3.0.dist-info/METADATA +353 -0
- memd-0.3.0.dist-info/RECORD +17 -0
- memd-0.3.0.dist-info/WHEEL +5 -0
- memd-0.3.0.dist-info/entry_points.txt +2 -0
- memd-0.3.0.dist-info/licenses/LICENSE +21 -0
- memd-0.3.0.dist-info/top_level.txt +1 -0
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()
|