dev-memory-cli 0.19.2 → 0.20.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.
@@ -162,6 +162,20 @@ SUPERSEDES_KEYWORDS = (
162
162
  _SIM_PREVIEW_LEN = 80
163
163
 
164
164
 
165
+ def _int_env(name, default):
166
+ value = os.environ.get(name, "").strip()
167
+ if not value:
168
+ return default
169
+ try:
170
+ return int(value)
171
+ except ValueError:
172
+ return default
173
+
174
+
175
+ _REPO_SHARED_MAX_ENTRIES = _int_env("DEV_MEMORY_REPO_SHARED_MAX_ENTRIES", 20)
176
+ _REPO_SHARED_MAX_ENTRY_CHARS = _int_env("DEV_MEMORY_REPO_SHARED_MAX_ENTRY_CHARS", 260)
177
+
178
+
165
179
  def _first_nonempty_line(text):
166
180
  """Return the first non-blank line of `text`, stripped. Empty string if
167
181
  `text` is all blank. Used as the comparison anchor for similarity_check
@@ -892,6 +906,110 @@ def decision_body(item):
892
906
  return "\n".join(parts)
893
907
 
894
908
 
909
+ def compact_decision_body(item):
910
+ """Render a repo-shared decision as one short bullet.
911
+
912
+ Branch decisions can afford reason/impact substructure because they are
913
+ read on demand. Repo shared decisions are injected into every branch, so
914
+ each item must be a compact standalone rule.
915
+ """
916
+ summary = _decision_summary(item)
917
+ if not isinstance(item, dict):
918
+ return f"- {summary}"
919
+ extras = []
920
+ reason = str(item.get("reason") or "").strip()
921
+ impact = str(item.get("impact") or "").strip()
922
+ if reason:
923
+ extras.append(f"原因: {reason}")
924
+ if impact:
925
+ extras.append(f"适用: {impact}")
926
+ suffix = f"({';'.join(extras)})" if extras else ""
927
+ return f"- {summary}{suffix}"
928
+
929
+
930
+ def _compact_repo_shared_entry(entry, max_chars):
931
+ text = (entry or "").strip()
932
+ if not text:
933
+ return ""
934
+ if len(text) <= max_chars:
935
+ return text
936
+ first = _strip_bullet_prefix(_first_nonempty_line(text))
937
+ if len(first) > max_chars:
938
+ first = first[: max_chars - 1].rstrip() + "…"
939
+ return f"- {first}"
940
+
941
+
942
+ def _prune_repo_shared_section(path, section_title, *, max_entries, max_entry_chars):
943
+ if max_entries <= 0:
944
+ return None
945
+ content = path.read_text(encoding="utf-8") if path.exists() else ""
946
+ prefix, sections = split_sections(content)
947
+ changed = False
948
+ updated = []
949
+ result = None
950
+ for existing_title, existing_body in sections:
951
+ if existing_title.strip() != section_title.strip():
952
+ updated.append((existing_title, existing_body))
953
+ continue
954
+ entries = [entry for _idx, entry in _section_top_level_entries(existing_body)]
955
+ if not entries:
956
+ updated.append((existing_title, existing_body))
957
+ continue
958
+ kept = entries[-max_entries:]
959
+ compacted = [_compact_repo_shared_entry(entry, max_entry_chars) for entry in kept]
960
+ compacted = [entry for entry in compacted if entry]
961
+ new_body = "\n\n".join(compacted)
962
+ pruned_count = max(0, len(entries) - len(kept))
963
+ if new_body.strip() != existing_body.strip():
964
+ changed = True
965
+ result = {
966
+ "file": "repo/decisions.md" if path.name == "decisions.md" else "repo/glossary.md",
967
+ "section": existing_title,
968
+ "mode": "prune",
969
+ "before": len(entries),
970
+ "after": len(compacted),
971
+ "pruned": pruned_count,
972
+ "max_entries": max_entries,
973
+ "max_entry_chars": max_entry_chars,
974
+ }
975
+ updated.append((existing_title, new_body))
976
+ if not changed:
977
+ return None
978
+ path.write_text(join_sections(prefix, updated), encoding="utf-8")
979
+ return result
980
+
981
+
982
+ def prune_repo_shared_memory(paths, *, max_entries=None, max_entry_chars=None):
983
+ """Keep repo-shared memory small enough for SessionStart injection.
984
+
985
+ This is intentionally deterministic and conservative: newest entries win,
986
+ each entry is capped to its leading claim, and only repo shared sections are
987
+ touched. Deeper semantic cleanup still belongs to tidy/graduate.
988
+ """
989
+ max_entries = _REPO_SHARED_MAX_ENTRIES if max_entries is None else max_entries
990
+ max_entry_chars = _REPO_SHARED_MAX_ENTRY_CHARS if max_entry_chars is None else max_entry_chars
991
+ targets = (
992
+ ("repo_decisions", "跨分支通用决策"),
993
+ ("repo_glossary", "长期有效背景"),
994
+ ("repo_glossary", "共享入口"),
995
+ ("repo_glossary", "共享注意点"),
996
+ )
997
+ touched = []
998
+ for file_key, section in targets:
999
+ path = paths.get(file_key)
1000
+ if not path:
1001
+ continue
1002
+ rec = _prune_repo_shared_section(
1003
+ path,
1004
+ section,
1005
+ max_entries=max_entries,
1006
+ max_entry_chars=max_entry_chars,
1007
+ )
1008
+ if rec:
1009
+ touched.append(rec)
1010
+ return touched
1011
+
1012
+
895
1013
  # ---------------------------------------------------------------------------
896
1014
  # Write primitives
897
1015
  # ---------------------------------------------------------------------------
@@ -1215,7 +1333,7 @@ def command_record(args):
1215
1333
  shared_decision_payload_items = [
1216
1334
  item for item in (payload.get("shared_decisions") or []) if _decision_summary(item)
1217
1335
  ]
1218
- shared_decision_items = [decision_body(item) for item in shared_decision_payload_items]
1336
+ shared_decision_items = [compact_decision_body(item) for item in shared_decision_payload_items]
1219
1337
  if shared_decision_items:
1220
1338
  body = "\n\n".join(shared_decision_items)
1221
1339
  rec = _write_or_block("shared-decision", body, source_label="payload[shared_decisions]")
@@ -1550,7 +1668,7 @@ def command_apply_summary_output(args):
1550
1668
  for item in payload.get("glossary") or []:
1551
1669
  add_write("glossary", item, source="glossary")
1552
1670
  for item in payload.get("shared_decisions") or []:
1553
- add_write("shared-decision", _decision_content(item), source="shared_decisions")
1671
+ add_write("shared-decision", compact_decision_body(item), source="shared_decisions")
1554
1672
  for item in payload.get("shared_context") or []:
1555
1673
  add_write("shared-context", item, source="shared_context")
1556
1674
  for item in payload.get("shared_sources") or []:
@@ -1599,6 +1717,21 @@ def command_apply_summary_output(args):
1599
1717
  "reason": item.get("reason"),
1600
1718
  })
1601
1719
 
1720
+ pruned = prune_repo_shared_memory(paths)
1721
+ if pruned:
1722
+ touched.extend(pruned)
1723
+ for rec in pruned:
1724
+ actions.append({
1725
+ "op": "prune-repo-shared",
1726
+ "file": rec["file"],
1727
+ "section": rec["section"],
1728
+ "before": rec["before"],
1729
+ "after": rec["after"],
1730
+ "pruned": rec["pruned"],
1731
+ "max_entries": rec["max_entries"],
1732
+ "max_entry_chars": rec["max_entry_chars"],
1733
+ })
1734
+
1602
1735
  updated_at = now_iso()
1603
1736
 
1604
1737
  if touched:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dev-memory-cli",
3
- "version": "0.19.2",
3
+ "version": "0.20.0",
4
4
  "description": "CLI for dev-memory hooks and repo-local setup (formerly @xluos/dev-assets-cli)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -341,19 +341,19 @@ def maybe_record_head():
341
341
  # "后续继续前要注意"; decisions.md carries "关键决策与原因"; glossary.md carries
342
342
  # "当前有效上下文".
343
343
  _FULL_SECTION_KEYS = (
344
- ("glossary", "分支源资料入口"),
345
- ("glossary", "当前有效上下文"),
346
344
  ("progress", "功能文件索引"),
347
345
  ("progress", "建议优先查看的目录"),
346
+ ("repo_decisions", None),
347
+ ("repo_glossary", None),
348
+ ("glossary", "分支源资料入口"),
348
349
  ("overview", "当前目标"),
349
350
  ("overview", "范围边界"),
350
351
  ("overview", "关键约束"),
352
+ ("repo_overview", None),
353
+ ("glossary", "当前有效上下文"),
351
354
  ("risks", "阻塞与注意点"),
352
355
  ("decisions", "关键决策与原因"),
353
356
  ("risks", "后续继续前要注意"),
354
- ("repo_overview", None),
355
- ("repo_decisions", None),
356
- ("repo_glossary", None),
357
357
  )
358
358
 
359
359
  _BRIEF_SECTION_KEYS = (
@@ -389,6 +389,12 @@ _REPO_DISPLAY_TITLES = {
389
389
  "repo_decisions": "跨分支通用决策",
390
390
  "repo_glossary": "仓库共享术语与入口",
391
391
  }
392
+ _HIGH_PRIORITY_WRAPPERS = {
393
+ ("progress", "功能文件索引"): "file_map",
394
+ ("progress", "建议优先查看的目录"): "read_first_dirs",
395
+ ("repo_decisions", None): "shared_decisions",
396
+ ("repo_glossary", None): "long_term_context",
397
+ }
392
398
  _REPO_MAX_LINES = 32
393
399
  _REPO_MAX_CHARS = 3000
394
400
 
@@ -402,7 +408,8 @@ def _extract_sections(paths, keys):
402
408
  else:
403
409
  body = extract_section(paths[file_key], title)
404
410
  display_title = title
405
- out.append((display_title, body, file_key))
411
+ wrapper = _HIGH_PRIORITY_WRAPPERS.get((file_key, title))
412
+ out.append((display_title, body, file_key, title, wrapper))
406
413
  return out
407
414
 
408
415
 
@@ -437,20 +444,22 @@ def _build_context_from_assets(assets, *, full=True, heading=None):
437
444
  # the footer's directory header (.../branches/<branch>/).
438
445
 
439
446
  any_truncated = False
440
- for title, body, file_key in sections:
447
+ for title, body, file_key, source_title, wrapper in sections:
441
448
  if not body:
442
449
  continue
443
450
  if full and file_key in _REPO_LEVEL_KEYS:
444
451
  eff_lines, eff_chars = _REPO_MAX_LINES, _REPO_MAX_CHARS
445
- elif full and (file_key, title) in _BRANCH_REFERENCE_SECTIONS:
452
+ elif full and (file_key, source_title) in _BRANCH_REFERENCE_SECTIONS:
446
453
  eff_lines, eff_chars = _BRANCH_REF_MAX_LINES, _BRANCH_REF_MAX_CHARS
447
454
  else:
448
455
  eff_lines, eff_chars = max_lines, max_chars
449
- if (file_key, title) in _RECENT_FIRST_SECTIONS:
456
+ if (file_key, source_title) in _RECENT_FIRST_SECTIONS:
450
457
  compacted, truncated = compact_recent_body(body, max_lines=eff_lines, max_chars=eff_chars)
451
458
  else:
452
459
  compacted, truncated = compact_body(body, max_lines=eff_lines, max_chars=eff_chars)
453
- if full and file_key in _REPO_LEVEL_KEYS:
460
+ if wrapper:
461
+ block = f"<{wrapper}>\n{compacted}\n</{wrapper}>"
462
+ elif full and file_key in _REPO_LEVEL_KEYS:
454
463
  block = compacted
455
464
  else:
456
465
  block = f"{title}:\n{compacted}"
@@ -881,6 +890,14 @@ transcript 过滤(extract-core 已执行;这里是核对规则):
881
890
  - 只在确有新增或更新时写入。没有有效新增时只输出 `skip_reason`,代码会把 job 标记为 skipped。
882
891
  - 不要写”当前进展”、”下一步”、”当前阶段”等时效性状态——这些天然会过期;记忆应聚焦稳定的决策、约束、上下文和参考信息。
883
892
 
893
+ repo 共享层淘汰规则:
894
+ - `shared_decisions` / `shared_context` / `shared_sources` 是跨分支长期记忆,不是会话归档,也不是分支任务备忘。
895
+ - 只有“以后所有分支都需要优先知道”的规则、背景、入口才写 shared;普通业务细节、一次性调试结论、某分支当前状态写 branch 层或跳过。
896
+ - repo 共享层默认只保留最重要的少量条目(约 10-20 条);新增 shared 前先看 existing_memory,若旧条目已过时、重复、太长,优先输出 `rewrites` / `deletes` 压缩旧内容。
897
+ - `shared_decisions` 的 summary 必须是一条短完整规则;除非必要,不要填 reason/impact。不要把“结论/原因/影响范围”拆成三条长期条目。
898
+ - `shared_context` / `shared_sources` 每条尽量不超过 1 句;不要把交接说明、模块清单、会议纪要整段塞入 repo 共享层。
899
+ - 代码落盘后还会自动对 repo shared section 做确定性修剪:保留最近约 20 条,单条过长会压成首句。因此你应主动写得精炼,避免重要信息被截断。
900
+
884
901
  file_map(功能文件索引):
885
902
  - 用途:让后续 agent 听到”改下操作弹窗”就能直接定位文件,不用搜索。
886
903
  - 每条 label 是业务语义名(页面/弹窗/侧拉/组件/表单等),paths 是对应文件的仓库相对路径。
@@ -898,9 +915,9 @@ file_map(功能文件索引):
898
915
  “decisions”: [{{“summary”: “结论”, “reason”: “为什么”, “impact”: “影响范围”}}],
899
916
  “risks”: [“风险/坑/阻塞”],
900
917
  “glossary”: [“术语/上下文/命令/外部系统入口”],
901
- “shared_decisions”: [{{“summary”: “跨分支规则”, “reason”: “为什么”, “impact”: “适用范围”}}],
902
- “shared_context”: [“仓库级长期背景”],
903
- “shared_sources”: [“仓库级共享入口”],
918
+ “shared_decisions”: [{{“summary”: “短句跨分支规则”}}],
919
+ “shared_context”: [“一句话仓库级长期背景”],
920
+ “shared_sources”: [“一句话仓库级共享入口”],
904
921
  “upserts”: [{{“kind”: “overview”, “content”: “覆盖某个 kind”}}],
905
922
  “appends”: [{{“kind”: “decision”, “content”: “追加某个 kind”}}],
906
923
  “rewrites”: [{{“id”: “decisions::0::2”, “content”: “新条目”, “reason”: “旧结论失效”}}],