dev-memory-cli 0.22.1 → 0.23.0

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.
@@ -16,9 +16,11 @@ Subcommands:
16
16
 
17
17
  import argparse
18
18
  import difflib
19
+ import fcntl
19
20
  import json
20
21
  import os
21
22
  import sys
23
+ from contextlib import contextmanager
22
24
  from pathlib import Path
23
25
 
24
26
  sys.path.insert(0, str(Path(__file__).resolve().parent))
@@ -27,6 +29,7 @@ from dev_memory_common import (
27
29
  AUTO_END,
28
30
  AUTO_START,
29
31
  PLACEHOLDER_MARKERS,
32
+ atomic_write_text,
30
33
  append_log_event,
31
34
  append_to_section,
32
35
  asset_paths,
@@ -34,9 +37,11 @@ from dev_memory_common import (
34
37
  collect_git_facts,
35
38
  ensure_branch_paths_exist,
36
39
  get_head_commit,
40
+ get_branch_paths,
37
41
  get_setup_completed,
38
42
  is_cross_branch_candidate,
39
43
  join_sections,
44
+ limit_markdown_entries,
40
45
  list_missing_docs,
41
46
  merged_focus_areas,
42
47
  now_iso,
@@ -101,7 +106,7 @@ def _emit_capture_log(paths, *, action, kind_label, summary, touched, extra_deta
101
106
  )
102
107
 
103
108
 
104
- def _append_with_separator(path, title, body):
109
+ def _append_with_separator(path, title, body, *, enforce_limit=True, max_entries=None):
105
110
  """Like append_to_section but always inserts a blank line between the
106
111
  existing section body and the new entry. Drops placeholder-only bodies
107
112
  instead of padding them with a blank line.
@@ -112,6 +117,7 @@ def _append_with_separator(path, title, body):
112
117
  matched = False
113
118
  updated = []
114
119
  body_stripped = body.strip()
120
+ pruned = 0
115
121
 
116
122
  def _is_placeholder_only(text):
117
123
  lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
@@ -125,13 +131,21 @@ def _append_with_separator(path, title, body):
125
131
  combined = body_stripped
126
132
  else:
127
133
  combined = existing_body.rstrip() + "\n\n" + body_stripped
134
+ if enforce_limit:
135
+ combined, pruned = limit_markdown_entries(combined, max_entries=max_entries)
128
136
  updated.append((existing_title, combined))
129
137
  matched = True
130
138
  else:
131
139
  updated.append((existing_title, existing_body))
132
140
  if not matched:
133
- updated.append((title, body_stripped))
134
- path.write_text(join_sections(prefix, updated), encoding="utf-8")
141
+ bounded, pruned = (
142
+ limit_markdown_entries(body_stripped, max_entries=max_entries)
143
+ if enforce_limit
144
+ else (body_stripped, 0)
145
+ )
146
+ updated.append((title, bounded))
147
+ atomic_write_text(path, join_sections(prefix, updated))
148
+ return pruned
135
149
 
136
150
 
137
151
  # ---------------------------------------------------------------------------
@@ -760,7 +774,7 @@ def _entry_mutation(paths, eid, *, new_text=None, delete=False):
760
774
 
761
775
  new_sections = list(sections)
762
776
  new_sections[section_idx] = (section_title, new_body)
763
- target_path.write_text(join_sections(prefix, new_sections), encoding="utf-8")
777
+ atomic_write_text(target_path, join_sections(prefix, new_sections))
764
778
  return {
765
779
  "id": eid,
766
780
  "file_key": file_key,
@@ -1090,7 +1104,7 @@ def _prune_repo_shared_section(path, section_title, *, max_entries, max_entry_ch
1090
1104
  updated.append((existing_title, new_body))
1091
1105
  if not changed:
1092
1106
  return None
1093
- path.write_text(join_sections(prefix, updated), encoding="utf-8")
1107
+ atomic_write_text(path, join_sections(prefix, updated))
1094
1108
  return result
1095
1109
 
1096
1110
 
@@ -1125,6 +1139,43 @@ def prune_repo_shared_memory(paths, *, max_entries=None, max_entry_chars=None):
1125
1139
  return touched
1126
1140
 
1127
1141
 
1142
+ def prune_bounded_memory(paths, *, max_entries=None):
1143
+ """Apply the general semantic-entry cap after a multi-operation summary."""
1144
+ max_entries = _int_env("DEV_MEMORY_MAX_ENTRIES", 200) if max_entries is None else max_entries
1145
+ targets = {
1146
+ (spec["file"], spec["section"])
1147
+ for spec in KIND_MAP.values()
1148
+ if spec.get("default_mode") == "append"
1149
+ }
1150
+ touched = []
1151
+ for file_key, section_title in sorted(targets):
1152
+ path = paths.get(file_key)
1153
+ if not path or not path.exists():
1154
+ continue
1155
+ content = path.read_text(encoding="utf-8")
1156
+ prefix, sections = split_sections(content)
1157
+ updated = []
1158
+ changed = False
1159
+ for title, body in sections:
1160
+ if title.strip() != section_title.strip():
1161
+ updated.append((title, body))
1162
+ continue
1163
+ bounded, pruned = limit_markdown_entries(body, max_entries=max_entries)
1164
+ updated.append((title, bounded))
1165
+ if pruned:
1166
+ changed = True
1167
+ touched.append({
1168
+ "file": _label(file_key),
1169
+ "section": title,
1170
+ "mode": "prune",
1171
+ "pruned": pruned,
1172
+ "max_entries": max_entries,
1173
+ })
1174
+ if changed:
1175
+ atomic_write_text(path, join_sections(prefix, updated))
1176
+ return touched
1177
+
1178
+
1128
1179
  # ---------------------------------------------------------------------------
1129
1180
  # Write primitives
1130
1181
  # ---------------------------------------------------------------------------
@@ -1140,7 +1191,7 @@ def _resolve_target(paths, kind, title_override=None):
1140
1191
  return spec["file"], target_path, section_title
1141
1192
 
1142
1193
 
1143
- def _write_one(paths, kind, body, title_override=None, *, mode_override=None):
1194
+ def _write_one(paths, kind, body, title_override=None, *, mode_override=None, enforce_limit=True):
1144
1195
  """Write a body into the kind's target file. Picks append vs upsert from
1145
1196
  KIND_MAP[kind].default_mode unless overridden. progress.md uses a
1146
1197
  dedicated upsert that preserves the auto-sync marker.
@@ -1159,13 +1210,28 @@ def _write_one(paths, kind, body, title_override=None, *, mode_override=None):
1159
1210
  body_to_write = body.strip()
1160
1211
  if not body_to_write.startswith(("- ", "* ", "#")):
1161
1212
  body_to_write = "- " + body_to_write
1162
- _append_with_separator(target_path, section_title, body_to_write)
1213
+ entry_limit = (
1214
+ _REPO_SHARED_MAX_ENTRIES
1215
+ if file_key in {"repo_decisions", "repo_glossary"}
1216
+ else _int_env("DEV_MEMORY_MAX_ENTRIES", 200)
1217
+ )
1218
+ pruned = _append_with_separator(
1219
+ target_path,
1220
+ section_title,
1221
+ body_to_write,
1222
+ enforce_limit=enforce_limit,
1223
+ max_entries=entry_limit,
1224
+ )
1163
1225
  else:
1164
1226
  if file_key == "progress":
1165
1227
  upsert_progress_section(target_path, section_title, body)
1166
1228
  else:
1167
1229
  upsert_markdown_section(target_path, section_title, body)
1168
- return {"file": _label(file_key), "section": section_title, "mode": mode}
1230
+ result = {"file": _label(file_key), "section": section_title, "mode": mode}
1231
+ if mode == "append" and pruned:
1232
+ result["pruned"] = pruned
1233
+ result["max_entries"] = entry_limit
1234
+ return result
1169
1235
 
1170
1236
 
1171
1237
  def _label(file_key):
@@ -1216,6 +1282,8 @@ def _decision_content(item):
1216
1282
  if isinstance(item, str):
1217
1283
  return item.strip()
1218
1284
  if isinstance(item, dict):
1285
+ if not _decision_summary(item):
1286
+ return ""
1219
1287
  return decision_body(item)
1220
1288
  return ""
1221
1289
 
@@ -1819,7 +1887,13 @@ def command_apply_summary_output(args):
1819
1887
  "dedup_hint": hint,
1820
1888
  })
1821
1889
  return
1822
- rec = _write_one(paths, kind, body, mode_override=mode_override)
1890
+ rec = _write_one(
1891
+ paths,
1892
+ kind,
1893
+ body,
1894
+ mode_override=mode_override,
1895
+ enforce_limit=False,
1896
+ )
1823
1897
  touched.append(rec)
1824
1898
  _maybe_worktree_writeback(
1825
1899
  worktree_writeback,
@@ -1894,6 +1968,51 @@ def command_apply_summary_output(args):
1894
1968
  "reason": item.get("reason"),
1895
1969
  })
1896
1970
 
1971
+ # Repair schema placeholder prose left by malformed historical summary
1972
+ # output. Valid entries are top-level bullets; these exact unbulleted
1973
+ # words are generator scaffolding and should never survive in memory.
1974
+ placeholder_words = {
1975
+ "decision", "summary", "reason", "impact", "risk", "mitigation",
1976
+ "term", "definition", "name", "url", "note", "label", "path", "content",
1977
+ }
1978
+ for file_key in ("decisions", "risks", "glossary", "repo_glossary"):
1979
+ path = paths[file_key]
1980
+ if not path.exists():
1981
+ continue
1982
+ content = path.read_text(encoding="utf-8")
1983
+ prefix, sections = split_sections(content)
1984
+ updated = []
1985
+ removed = 0
1986
+ for section_title, section_body in sections:
1987
+ kept_lines = []
1988
+ section_removed = 0
1989
+ for line in section_body.splitlines():
1990
+ stripped = line.strip().strip("::").lower()
1991
+ is_bullet = line.lstrip().startswith(("- ", "* ", "+ "))
1992
+ if stripped in placeholder_words and not is_bullet:
1993
+ section_removed += 1
1994
+ continue
1995
+ kept_lines.append(line)
1996
+ if section_removed:
1997
+ rec = {
1998
+ "file": _label(file_key),
1999
+ "section": section_title,
2000
+ "mode": "remove-placeholder-orphans",
2001
+ "removed": section_removed,
2002
+ }
2003
+ touched.append(rec)
2004
+ actions.append({"op": "remove-placeholder-orphans", **rec})
2005
+ removed += section_removed
2006
+ updated.append((section_title, "\n".join(kept_lines).strip()))
2007
+ if removed:
2008
+ atomic_write_text(path, join_sections(prefix, updated))
2009
+
2010
+ bounded = prune_bounded_memory(paths)
2011
+ if bounded:
2012
+ touched.extend(bounded)
2013
+ for rec in bounded:
2014
+ actions.append({"op": "prune-memory", **rec})
2015
+
1897
2016
  pruned = prune_repo_shared_memory(paths)
1898
2017
  if pruned:
1899
2018
  touched.extend(pruned)
@@ -2103,6 +2222,20 @@ def _add_common_args(parser):
2103
2222
  parser.add_argument("--branch", help="Branch name. Defaults to the current checked-out branch")
2104
2223
 
2105
2224
 
2225
+ @contextmanager
2226
+ def _repo_command_lock(args):
2227
+ """Serialize capture commands per repo so read-modify-write stays coherent."""
2228
+ _, _, _, _, _, repo_dir, _ = get_branch_paths(args.repo, args.context_dir, args.branch)
2229
+ lock_path = repo_dir / "jobs" / "capture.lock"
2230
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
2231
+ with lock_path.open("a+", encoding="utf-8") as lock_stream:
2232
+ fcntl.flock(lock_stream.fileno(), fcntl.LOCK_EX)
2233
+ try:
2234
+ yield
2235
+ finally:
2236
+ fcntl.flock(lock_stream.fileno(), fcntl.LOCK_UN)
2237
+
2238
+
2106
2239
  def main():
2107
2240
  parser = argparse.ArgumentParser(
2108
2241
  description="Unified write entrypoint for dev-memory repo+branch memory (merges old sync + update).",
@@ -2115,7 +2248,11 @@ def main():
2115
2248
  suggest = subparsers.add_parser("suggest-kind", help="Dry-run classify a content string")
2116
2249
  suggest.add_argument("--content", help="Inline content to classify")
2117
2250
  suggest.add_argument("--content-file", help="File containing content to classify")
2118
- suggest.add_argument("--already-setup", action="store_true", help="Hint that setup is completed (shifts default from unsorted to progress)")
2251
+ suggest.add_argument(
2252
+ "--already-setup",
2253
+ action="store_true",
2254
+ help="Accepted for compatibility; ambiguous content remains unsorted",
2255
+ )
2119
2256
  suggest.add_argument("--branch-name", help="Branch name for cross-branch candidate check")
2120
2257
 
2121
2258
  classify = subparsers.add_parser("classify", help="Classify content against the current branch context")
@@ -2219,7 +2356,11 @@ def main():
2219
2356
  "sync-working-tree": command_sync_working_tree,
2220
2357
  "record-head": command_record_head,
2221
2358
  }
2222
- return handlers[args.command](args)
2359
+ handler = handlers[args.command]
2360
+ if args.command == "suggest-kind":
2361
+ return handler(args)
2362
+ with _repo_command_lock(args):
2363
+ return handler(args)
2223
2364
  except Exception as exc:
2224
2365
  print(json.dumps({"error": str(exc)}, ensure_ascii=False), file=sys.stderr)
2225
2366
  return 1
@@ -6,6 +6,7 @@ import os
6
6
  import re
7
7
  import shutil
8
8
  import subprocess
9
+ import tempfile
9
10
  import time
10
11
  import uuid
11
12
  from collections import Counter
@@ -511,11 +512,29 @@ def asset_paths(repo_dir, branch_dir):
511
512
 
512
513
  def ensure_file(path, content):
513
514
  if not path.exists():
514
- path.write_text(content, encoding="utf-8")
515
+ atomic_write_text(path, content)
516
+
517
+
518
+ def atomic_write_text(path, content):
519
+ """Atomically replace a UTF-8 text file without exposing partial bytes."""
520
+ path = Path(path)
521
+ path.parent.mkdir(parents=True, exist_ok=True)
522
+ mode = (path.stat().st_mode & 0o777) if path.exists() else 0o644
523
+ fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
524
+ tmp_path = Path(tmp_name)
525
+ try:
526
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
527
+ stream.write(content)
528
+ stream.flush()
529
+ os.fsync(stream.fileno())
530
+ os.chmod(tmp_path, mode)
531
+ os.replace(tmp_path, path)
532
+ finally:
533
+ tmp_path.unlink(missing_ok=True)
515
534
 
516
535
 
517
536
  def write_json(path, payload):
518
- path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
537
+ atomic_write_text(path, json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
519
538
 
520
539
 
521
540
  def read_json(path):
@@ -1234,8 +1253,7 @@ def ensure_branch_paths_exist(repo, context_dir=None, branch=None):
1234
1253
  # Heuristic classifier (capture routing)
1235
1254
  # ---------------------------------------------------------------------------
1236
1255
 
1237
- # Order matters: the first pattern that matches wins. Keep decisions/risks
1238
- # ahead of progress since their signals are more specific.
1256
+ # Order matters: the first pattern that matches wins.
1239
1257
  _CLASSIFY_PATTERNS = [
1240
1258
  ("decision", re.compile(r"结论[::]|决[定议][::]|不再|改为|采用|废弃|选择.+?不选|abandoned|adopt")),
1241
1259
  ("risk", re.compile(r"阻塞|注意|坑|失败|风险|卡住|gotcha|caveat|warning")),
@@ -1244,19 +1262,18 @@ _CLASSIFY_PATTERNS = [
1244
1262
 
1245
1263
 
1246
1264
  def classify_content(text, *, already_setup=False):
1247
- """Classify free-form content into one of decisions/progress/risks/
1248
- glossary/unsorted. Used by capture when the caller doesn't pass --kind.
1265
+ """Classify free-form content into decision/risk/glossary/unsorted.
1249
1266
 
1250
- Before setup, ambiguous content falls to `unsorted` so the user can sort
1251
- it later via setup merge. After setup, the default shifts to `progress`
1252
- because the user has signaled they want aggressive categorization.
1267
+ ``already_setup`` is retained for callers that still pass the setup state,
1268
+ but ambiguous content always stays in ``unsorted``. Ephemeral progress is
1269
+ intentionally derived from Git instead of being a semantic capture kind.
1253
1270
  """
1254
1271
  if not text or not text.strip():
1255
1272
  return "unsorted"
1256
1273
  for label, pattern in _CLASSIFY_PATTERNS:
1257
1274
  if pattern.search(text):
1258
1275
  return label
1259
- return "progress" if already_setup else "unsorted"
1276
+ return "unsorted"
1260
1277
 
1261
1278
 
1262
1279
  # Branch-name tokens that appear in generic technical content too often to
@@ -1707,7 +1724,7 @@ def ensure_progress_auto_block(path):
1707
1724
  else:
1708
1725
  updated = content.rstrip() + auto_section
1709
1726
 
1710
- path.write_text(updated + ("" if updated.endswith("\n") else "\n"), encoding="utf-8")
1727
+ atomic_write_text(path, updated + ("" if updated.endswith("\n") else "\n"))
1711
1728
  return path.read_text(encoding="utf-8")
1712
1729
 
1713
1730
 
@@ -1764,7 +1781,7 @@ def upsert_markdown_section(path, title, body):
1764
1781
  updated.append((existing_title, existing_body))
1765
1782
  if not replaced:
1766
1783
  updated.append((title, body))
1767
- path.write_text(join_sections(prefix, updated), encoding="utf-8")
1784
+ atomic_write_text(path, join_sections(prefix, updated))
1768
1785
 
1769
1786
 
1770
1787
  def _section_is_placeholder_only(text):
@@ -1774,7 +1791,37 @@ def _section_is_placeholder_only(text):
1774
1791
  return all(any(marker in line for marker in PLACEHOLDER_MARKERS) for line in lines)
1775
1792
 
1776
1793
 
1777
- def append_to_section(path, title, body):
1794
+ def memory_max_entries():
1795
+ value = os.environ.get("DEV_MEMORY_MAX_ENTRIES", "200").strip()
1796
+ try:
1797
+ return max(1, int(value))
1798
+ except ValueError:
1799
+ return 200
1800
+
1801
+
1802
+ def limit_markdown_entries(body, max_entries=None):
1803
+ """Bound an accumulated markdown section while retaining newest entries.
1804
+
1805
+ Accumulation sections are stored oldest-to-newest so entry ids remain
1806
+ stable. Only top-level bullet blocks count as entries; continuation lines
1807
+ stay attached to their bullet.
1808
+ """
1809
+ limit = memory_max_entries() if max_entries is None else max(1, int(max_entries))
1810
+ lines = (body or "").strip().splitlines()
1811
+ starts = [idx for idx, line in enumerate(lines) if re.match(r"^[-*]\s+", line)]
1812
+ if len(starts) <= limit:
1813
+ return (body or "").strip(), 0
1814
+ blocks = []
1815
+ for pos, start in enumerate(starts):
1816
+ end = starts[pos + 1] if pos + 1 < len(starts) else len(lines)
1817
+ blocks.append("\n".join(lines[start:end]).strip())
1818
+ preamble = "\n".join(lines[:starts[0]]).strip()
1819
+ kept = blocks[-limit:]
1820
+ bounded = "\n\n".join(([preamble] if preamble else []) + kept).strip()
1821
+ return bounded, len(blocks) - len(kept)
1822
+
1823
+
1824
+ def append_to_section(path, title, body, *, max_entries=None):
1778
1825
  content = path.read_text(encoding="utf-8") if path.exists() else ""
1779
1826
  prefix, sections = split_sections(content)
1780
1827
  target = title.strip()
@@ -1786,13 +1833,15 @@ def append_to_section(path, title, body):
1786
1833
  combined = body.strip()
1787
1834
  else:
1788
1835
  combined = (existing_body.rstrip() + "\n" + body.strip()).strip()
1836
+ combined, _pruned = limit_markdown_entries(combined, max_entries=max_entries)
1789
1837
  updated.append((existing_title, combined))
1790
1838
  matched = True
1791
1839
  else:
1792
1840
  updated.append((existing_title, existing_body))
1793
1841
  if not matched:
1794
- updated.append((title, body.strip()))
1795
- path.write_text(join_sections(prefix, updated), encoding="utf-8")
1842
+ bounded, _pruned = limit_markdown_entries(body.strip(), max_entries=max_entries)
1843
+ updated.append((title, bounded))
1844
+ atomic_write_text(path, join_sections(prefix, updated))
1796
1845
 
1797
1846
 
1798
1847
  def upsert_progress_section(path, title, body):
@@ -1819,7 +1868,7 @@ def upsert_progress_section(path, title, body):
1819
1868
  if not replaced:
1820
1869
  updated.append((title, body))
1821
1870
  rewritten = join_sections(prefix, updated).rstrip() + "\n\n" + marker + after
1822
- path.write_text(rewritten, encoding="utf-8")
1871
+ atomic_write_text(path, rewritten)
1823
1872
 
1824
1873
 
1825
1874
  def sync_progress(paths, facts):
@@ -1834,7 +1883,7 @@ def sync_progress(paths, facts):
1834
1883
  )
1835
1884
  current = ensure_progress_auto_block(paths["progress"])
1836
1885
  updated = replace_auto_block(current, build_auto_block(facts))
1837
- paths["progress"].write_text(updated, encoding="utf-8")
1886
+ atomic_write_text(paths["progress"], updated)
1838
1887
 
1839
1888
 
1840
1889
  # ---------------------------------------------------------------------------
@@ -1888,7 +1937,7 @@ def append_log_event(log_path, action, *, kind=None, summary=None, details=None)
1888
1937
  return
1889
1938
  if not log_path.exists():
1890
1939
  log_path.parent.mkdir(parents=True, exist_ok=True)
1891
- log_path.write_text(template_log("unknown"), encoding="utf-8")
1940
+ atomic_write_text(log_path, template_log("unknown"))
1892
1941
 
1893
1942
  header_parts = [f"## [{now_iso()}] {action}"]
1894
1943
  if kind:
@@ -1908,7 +1957,7 @@ def append_log_event(log_path, action, *, kind=None, summary=None, details=None)
1908
1957
  existing = log_path.read_text(encoding="utf-8")
1909
1958
  if not existing.endswith("\n"):
1910
1959
  existing += "\n"
1911
- log_path.write_text(existing + "\n" + block, encoding="utf-8")
1960
+ atomic_write_text(log_path, existing + "\n" + block)
1912
1961
 
1913
1962
 
1914
1963
  # ---------------------------------------------------------------------------
@@ -123,7 +123,7 @@ def _apply_entries(target_path, entries):
123
123
  if not section:
124
124
  raise ValueError(f"harvest entry missing 'section': {entry}")
125
125
  if mode == "append":
126
- append_to_section(target_path, section, body)
126
+ append_to_section(target_path, section, body, max_entries=20)
127
127
  elif mode == "replace":
128
128
  upsert_markdown_section(target_path, section, body)
129
129
  else: