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.
- package/lib/dev_memory_capture.py +135 -2
- package/package.json +1 -1
- package/scripts/hooks/_common.py +30 -13
|
@@ -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 = [
|
|
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",
|
|
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
package/scripts/hooks/_common.py
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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”:
|
|
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”: “旧结论失效”}}],
|