dev-memory-cli 0.18.3 → 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/README.md +19 -4
- package/bin/dev-memory.js +2 -0
- package/lib/dev_memory_capture.py +153 -14
- package/lib/dev_memory_common.py +74 -17
- package/lib/dev_memory_context.py +35 -1
- package/lib/dev_memory_read.py +317 -0
- package/lib/dev_memory_setup.py +0 -2
- package/lib/ui-app.html +41 -0
- package/lib/ui-server.js +53 -0
- package/package.json +1 -1
- package/scripts/hooks/_common.py +160 -41
- package/suite-manifest.json +1 -0
package/README.md
CHANGED
|
@@ -6,18 +6,19 @@
|
|
|
6
6
|
|
|
7
7
|

|
|
8
8
|
|
|
9
|
-
## v2 架构:
|
|
9
|
+
## v2 架构:5 个 Skill + branch CLI
|
|
10
10
|
|
|
11
|
-
v2 把旧的 sync + update 合并成统一的 capture,并把 setup 从前置门禁改为 merge 动作、加 lazy init、加 v1→v2 自动迁移。0.16 起取消 `using-dev-memory` 路由总入口;0.17
|
|
11
|
+
v2 把旧的 sync + update 合并成统一的 capture,并把 setup 从前置门禁改为 merge 动作、加 lazy init、加 v1→v2 自动迁移。0.16 起取消 `using-dev-memory` 路由总入口;0.17 起取消旧 `dev-memory-context` 读取 skill;现在补回更窄的 `dev-memory-read` 主动读取入口:它只负责精确定位当前 repo+branch 的权威记忆文件和关键词检索,不做 context 注入,也不写入。当前套件 5 个 skill(1 个读取入口 + 4 个写入 / 流程 / 整理入口)。
|
|
12
12
|
|
|
13
13
|
| Skill | 定位 | 典型触发 |
|
|
14
14
|
| --- | --- | --- |
|
|
15
|
+
| `dev-memory-read` | **主动读取入口**:定位当前 repo+branch 记忆目录、关键词搜索、返回可直接 Read 的文件路径 | 用户说"重新读一下记忆 / 之前记的 todo / 按记忆最新口径",或 agent 需要主动查记忆 |
|
|
15
16
|
| `dev-memory-capture` | **统一写入入口**(合并 sync + update) | 本轮产生稳定结论 / checkpoint / 用户手动记一笔 / 改写旧条目 |
|
|
16
17
|
| `dev-memory-setup` | 整理 unsorted.md + 补元信息 + 标 setup_completed(不再是前置门禁) | unsorted.md 累积、用户明确说"整理一下" |
|
|
17
18
|
| `dev-memory-tidy` | **定期校准入口**:已结构化条目漂移时(陈旧 / 重复 / 模板残留),agent 聚合 proposal → 浏览器审 → apply 落盘 + 自动备份 | 用户说"整理一下记忆 / 清下过期的 / 看看哪些还成立" |
|
|
18
19
|
| `dev-memory-graduate` | 分支收尾:从 pending-promotion 提炼上提 + 归档 branch | 用户显式说"归档 / 分支收尾 / merge 完清一下" |
|
|
19
20
|
|
|
20
|
-
> 读取路径:SessionStart hook 自动注入 progress / risks / decisions 等摘要 +
|
|
21
|
+
> 读取路径:SessionStart hook 自动注入 progress / risks / decisions 等摘要 + 完整文件路径;用户或 agent 主动要求重新查记忆时,走 `dev-memory-read` / `dev-memory-cli read show|search`。`context show` / `context sync` 仍保留给 hook 和脚本场景。
|
|
21
22
|
|
|
22
23
|
详细设计与语义见:
|
|
23
24
|
|
|
@@ -28,6 +29,17 @@ v2 把旧的 sync + update 合并成统一的 capture,并把 setup 从前置
|
|
|
28
29
|
|
|
29
30
|
v2 里 capture 写入永远先 lazy init(骨架不存在就自动建),不再需要前置 setup。setup 的新职责:扫 `unsorted.md` 把未分类条目按用户选择 merge 到 decisions/progress/risks/glossary,再标 `manifest.setup_completed = true`。setup 之前 / 之后的区别是 capture 的 heuristic 兜底策略:之前"不确定 → unsorted",之后"不确定 → progress"。
|
|
30
31
|
|
|
32
|
+
### 主动读取:dev-memory-read
|
|
33
|
+
|
|
34
|
+
`dev-memory-read` 是只读 skill,避免 agent 在错误目录里盲搜。它不 lazy-init,不迁移,不写任何记忆文件。
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
dev-memory-cli read show --repo <repo-path>
|
|
38
|
+
dev-memory-cli read search --repo <repo-path> --query "作者信息" --query "头像"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`read show` 输出 `repo_dir`、`branch_dir`、`recommended_read_order`、`existing_branches`;`read search` 默认只查当前 branch + repo 共享层,需要扩大范围时再显式传 `--scope all-branches` 或 `--scope archived`。这能覆盖“当前分支记忆没命中,但旧分支/归档里可能有之前 TODO”的场景,同时不会扫描整个 `~/.dev-memory`。
|
|
42
|
+
|
|
31
43
|
### Capture 的写入路由
|
|
32
44
|
|
|
33
45
|

|
|
@@ -301,7 +313,8 @@ CLI 暴露的子命令:
|
|
|
301
313
|
| 安装助手 | `dev-memory-cli install-hooks <codex\|claude\|--all>` | 把 hook 模板合并到目标配置 |
|
|
302
314
|
| 浏览器 UI | `dev-memory-cli ui [--port N] [--read-only]` | 启动本地浏览器界面看/编辑记忆 |
|
|
303
315
|
| 分支生命周期 | `dev-memory-cli branch [list\|inspect\|rename\|fork\|delete\|init]` | 分支记忆迁移 / 副本 / 重置(无参数 = 交互式 type-ahead)|
|
|
304
|
-
| Skill 工作流(agent 在 SKILL.md 里调用,也能手动跑)| `dev-memory-cli
|
|
316
|
+
| Skill 工作流(agent 在 SKILL.md 里调用,也能手动跑)| `dev-memory-cli read <show\|search>` | 主动读取入口:精确定位当前 repo+branch 记忆路径,按关键词只搜当前 repo 的记忆 |
|
|
317
|
+
| | `dev-memory-cli capture <record\|rewrite-entry\|delete-entry\|apply-summary-output\|show\|suggest-kind\|...>` | 统一写入入口(含 dedup、结构化 patch executor) |
|
|
305
318
|
| | `dev-memory-cli setup <init\|merge-unsorted\|mark-completed>` | 整理 unsorted |
|
|
306
319
|
| | `dev-memory-cli tidy <prepare\|apply>` | 浏览器化的批量 review + 落盘(0.18 起含 annotated md + delete-block)|
|
|
307
320
|
| | `dev-memory-cli graduate <dry-run\|apply\|index>` | 分支归档 + 跨分支知识上提 |
|
|
@@ -320,6 +333,7 @@ hooks/
|
|
|
320
333
|
README.md
|
|
321
334
|
lib/ # 所有 Python 业务逻辑都集中在这里(npm 包发布时随包带)
|
|
322
335
|
dev_memory_common.py # 共享公共库(path 解析、template、git facts、merge helpers ...)
|
|
336
|
+
dev_memory_read.py # `dev-memory-cli read` 子命令实现(只读定位 + 搜索)
|
|
323
337
|
dev_memory_context.py # `dev-memory-cli context` 子命令实现
|
|
324
338
|
dev_memory_capture.py # `dev-memory-cli capture` 子命令实现
|
|
325
339
|
dev_memory_setup.py # `dev-memory-cli setup` 子命令实现
|
|
@@ -334,6 +348,7 @@ scripts/
|
|
|
334
348
|
install_suite.py # 本地开发用的 symlink 安装器
|
|
335
349
|
npm/ # 打包 check/build 助手
|
|
336
350
|
skills/ # SKILL.md + agents/openai.yaml + references/ 三件套;不再带 scripts/
|
|
351
|
+
dev-memory-read/
|
|
337
352
|
dev-memory-capture/
|
|
338
353
|
dev-memory-setup/
|
|
339
354
|
dev-memory-tidy/
|
package/bin/dev-memory.js
CHANGED
|
@@ -285,6 +285,7 @@ function commandUi(_positional, options) {
|
|
|
285
285
|
// Their CLIs (subcommands, flags) are owned by argparse on the Python
|
|
286
286
|
// side, so we deliberately bypass parseArgs and forward raw argv.
|
|
287
287
|
const PY_SUBCOMMAND_SCRIPTS = {
|
|
288
|
+
read: "dev_memory_read.py",
|
|
288
289
|
context: "dev_memory_context.py",
|
|
289
290
|
capture: "dev_memory_capture.py",
|
|
290
291
|
setup: "dev_memory_setup.py",
|
|
@@ -616,6 +617,7 @@ function printHelp() {
|
|
|
616
617
|
dev-memory-cli install-hooks <codex|claude> [--repo PATH] [--global]
|
|
617
618
|
dev-memory-cli install-hooks --all [--repo PATH] [--global]
|
|
618
619
|
dev-memory-cli ui [--port N] [--host HOST] [--no-open] [--read-only]
|
|
620
|
+
dev-memory-cli read <show|search> [...]
|
|
619
621
|
dev-memory-cli context <show|...> [...]
|
|
620
622
|
dev-memory-cli capture <record|show|sync-working-tree|record-head|suggest-kind|classify> [...]
|
|
621
623
|
dev-memory-cli setup <init|merge-unsorted|mark-completed> [...]
|
|
@@ -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
|
|
@@ -677,11 +691,8 @@ KIND_MAP = {
|
|
|
677
691
|
"glossary": {"file": "glossary", "section": "当前有效上下文", "default_mode": "append"},
|
|
678
692
|
"source": {"file": "glossary", "section": "分支源资料入口", "default_mode": "append"},
|
|
679
693
|
# snapshot (section always reflects the latest state; new write replaces)
|
|
680
|
-
"progress": {"file": "progress", "section": "当前进展", "default_mode": "upsert"},
|
|
681
|
-
"next": {"file": "progress", "section": "下一步", "default_mode": "upsert"},
|
|
682
694
|
"overview": {"file": "overview", "section": "当前目标", "default_mode": "upsert"},
|
|
683
695
|
"scope": {"file": "overview", "section": "范围边界", "default_mode": "upsert"},
|
|
684
|
-
"stage": {"file": "overview", "section": "当前阶段", "default_mode": "upsert"},
|
|
685
696
|
"constraint": {"file": "overview", "section": "关键约束", "default_mode": "upsert"},
|
|
686
697
|
# repo-shared: decisions/context/source accumulate, overview/constraint snapshot
|
|
687
698
|
"shared-decision": {"file": "repo_decisions", "section": "跨分支通用决策", "default_mode": "append"},
|
|
@@ -689,6 +700,8 @@ KIND_MAP = {
|
|
|
689
700
|
"shared-source": {"file": "repo_glossary", "section": "共享入口", "default_mode": "append"},
|
|
690
701
|
"shared-overview": {"file": "repo_overview", "section": "长期目标与边界", "default_mode": "upsert"},
|
|
691
702
|
"shared-constraint": {"file": "repo_overview", "section": "仓库级关键约束", "default_mode": "upsert"},
|
|
703
|
+
# semantic file map: agent outputs merged mapping each session
|
|
704
|
+
"filemap": {"file": "progress", "section": "功能文件索引", "default_mode": "upsert"},
|
|
692
705
|
# fallback bins always accumulate
|
|
693
706
|
"unsorted": {"file": "unsorted", "section": "待分类", "default_mode": "append"},
|
|
694
707
|
"pending": {"file": "pending_promotion", "section": "候选条目", "default_mode": "append"},
|
|
@@ -840,9 +853,6 @@ def _finalize_worktree_writeback(ctx, repo_root, repo_key, storage_root):
|
|
|
840
853
|
# same kind; that's fine — the write layer upserts.
|
|
841
854
|
SESSION_PAYLOAD_MAP = [
|
|
842
855
|
("overview_summary", "overview", None),
|
|
843
|
-
("implementation_notes", "progress", None),
|
|
844
|
-
("changes", "progress", None),
|
|
845
|
-
("next_steps", "next", None),
|
|
846
856
|
("risks", "risk", None),
|
|
847
857
|
("memory", "glossary", None),
|
|
848
858
|
("context_updates", "glossary", None),
|
|
@@ -854,10 +864,7 @@ SESSION_PAYLOAD_MAP = [
|
|
|
854
864
|
("source_updates", "shared-source", None),
|
|
855
865
|
]
|
|
856
866
|
|
|
857
|
-
SESSION_DIRECT_PAYLOAD_MAP = [
|
|
858
|
-
("progress", "progress"),
|
|
859
|
-
("next", "next"),
|
|
860
|
-
]
|
|
867
|
+
SESSION_DIRECT_PAYLOAD_MAP = []
|
|
861
868
|
|
|
862
869
|
SESSION_EXTRA_PAYLOAD_MAP = [
|
|
863
870
|
("glossary", "glossary", None),
|
|
@@ -899,6 +906,110 @@ def decision_body(item):
|
|
|
899
906
|
return "\n".join(parts)
|
|
900
907
|
|
|
901
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
|
+
|
|
902
1013
|
# ---------------------------------------------------------------------------
|
|
903
1014
|
# Write primitives
|
|
904
1015
|
# ---------------------------------------------------------------------------
|
|
@@ -1222,7 +1333,7 @@ def command_record(args):
|
|
|
1222
1333
|
shared_decision_payload_items = [
|
|
1223
1334
|
item for item in (payload.get("shared_decisions") or []) if _decision_summary(item)
|
|
1224
1335
|
]
|
|
1225
|
-
shared_decision_items = [
|
|
1336
|
+
shared_decision_items = [compact_decision_body(item) for item in shared_decision_payload_items]
|
|
1226
1337
|
if shared_decision_items:
|
|
1227
1338
|
body = "\n\n".join(shared_decision_items)
|
|
1228
1339
|
rec = _write_or_block("shared-decision", body, source_label="payload[shared_decisions]")
|
|
@@ -1550,8 +1661,6 @@ def command_apply_summary_output(args):
|
|
|
1550
1661
|
|
|
1551
1662
|
# Convenience summary-output fields. These mirror --summary-json but keep
|
|
1552
1663
|
# execution in code instead of making the agent compose CLI calls.
|
|
1553
|
-
add_write("progress", payload.get("progress"), source="progress")
|
|
1554
|
-
add_write("next", payload.get("next"), source="next")
|
|
1555
1664
|
for item in payload.get("decisions") or []:
|
|
1556
1665
|
add_write("decision", _decision_content(item), source="decisions")
|
|
1557
1666
|
for item in payload.get("risks") or []:
|
|
@@ -1559,12 +1668,27 @@ def command_apply_summary_output(args):
|
|
|
1559
1668
|
for item in payload.get("glossary") or []:
|
|
1560
1669
|
add_write("glossary", item, source="glossary")
|
|
1561
1670
|
for item in payload.get("shared_decisions") or []:
|
|
1562
|
-
add_write("shared-decision",
|
|
1671
|
+
add_write("shared-decision", compact_decision_body(item), source="shared_decisions")
|
|
1563
1672
|
for item in payload.get("shared_context") or []:
|
|
1564
1673
|
add_write("shared-context", item, source="shared_context")
|
|
1565
1674
|
for item in payload.get("shared_sources") or []:
|
|
1566
1675
|
add_write("shared-source", item, source="shared_sources")
|
|
1567
1676
|
|
|
1677
|
+
file_map = payload.get("file_map")
|
|
1678
|
+
if isinstance(file_map, list) and file_map:
|
|
1679
|
+
lines = []
|
|
1680
|
+
for entry in file_map:
|
|
1681
|
+
if not isinstance(entry, dict):
|
|
1682
|
+
continue
|
|
1683
|
+
label = entry.get("label", "").strip()
|
|
1684
|
+
paths_val = entry.get("paths") or ([entry["path"]] if entry.get("path") else [])
|
|
1685
|
+
if not label or not paths_val:
|
|
1686
|
+
continue
|
|
1687
|
+
joined = ", ".join(f"`{p}`" for p in paths_val)
|
|
1688
|
+
lines.append(f"- {label}: {joined}")
|
|
1689
|
+
if lines:
|
|
1690
|
+
add_write("filemap", "\n".join(lines), source="file_map")
|
|
1691
|
+
|
|
1568
1692
|
# Explicit patch operations.
|
|
1569
1693
|
for item in payload.get("upserts") or []:
|
|
1570
1694
|
add_write(item.get("kind"), item.get("content"), mode_override="upsert", source="upserts")
|
|
@@ -1593,6 +1717,21 @@ def command_apply_summary_output(args):
|
|
|
1593
1717
|
"reason": item.get("reason"),
|
|
1594
1718
|
})
|
|
1595
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
|
+
|
|
1596
1735
|
updated_at = now_iso()
|
|
1597
1736
|
|
|
1598
1737
|
if touched:
|
package/lib/dev_memory_common.py
CHANGED
|
@@ -581,7 +581,6 @@ def template_overview(branch_name):
|
|
|
581
581
|
("分支", f"- {branch_name}"),
|
|
582
582
|
("当前目标", "- 待补充"),
|
|
583
583
|
("范围边界", "- 待补充"),
|
|
584
|
-
("当前阶段", "- 待补充"),
|
|
585
584
|
("关键约束", "- 待补充"),
|
|
586
585
|
],
|
|
587
586
|
)
|
|
@@ -599,12 +598,10 @@ def template_decisions(branch_name):
|
|
|
599
598
|
|
|
600
599
|
def template_progress(branch_name):
|
|
601
600
|
return render_title_doc(
|
|
602
|
-
"
|
|
601
|
+
"自动索引",
|
|
603
602
|
[
|
|
604
603
|
("分支", f"- {branch_name}"),
|
|
605
604
|
("建议优先查看的目录", "- 待刷新"),
|
|
606
|
-
("当前进展", "- 待补充"),
|
|
607
|
-
("下一步", "- 待补充"),
|
|
608
605
|
(
|
|
609
606
|
"自动同步区",
|
|
610
607
|
"本区由 `dev-memory-cli capture sync-working-tree` / SessionStart hook 自动刷新,请不要手工编辑。\n\n"
|
|
@@ -684,11 +681,9 @@ def template_repo_log(repo_name):
|
|
|
684
681
|
|
|
685
682
|
def template_progress_no_git(project_name):
|
|
686
683
|
return render_title_doc(
|
|
687
|
-
"
|
|
684
|
+
"自动索引(no-git 模式)",
|
|
688
685
|
[
|
|
689
686
|
("项目", f"- {project_name}"),
|
|
690
|
-
("当前进展", "- 待补充"),
|
|
691
|
-
("下一步", "- 待补充"),
|
|
692
687
|
(
|
|
693
688
|
"自动同步区",
|
|
694
689
|
"本区由 capture / context 刷新。no-git 模式下无 git facts,保持最小骨架。\n\n"
|
|
@@ -950,6 +945,8 @@ def migrate_v1_to_v2_branch(branch_dir, branch_name):
|
|
|
950
945
|
written = []
|
|
951
946
|
|
|
952
947
|
progress_sections = list(merged_buckets.get("progress", []))
|
|
948
|
+
progress_sections = [(t, b) for t, b in progress_sections
|
|
949
|
+
if t not in ("当前进展", "下一步")]
|
|
953
950
|
if auto_block is not None:
|
|
954
951
|
progress_sections.append((
|
|
955
952
|
"自动同步区",
|
|
@@ -959,7 +956,7 @@ def migrate_v1_to_v2_branch(branch_dir, branch_name):
|
|
|
959
956
|
if progress_sections:
|
|
960
957
|
target = branch_dir / "progress.md"
|
|
961
958
|
target.write_text(
|
|
962
|
-
render_title_doc("
|
|
959
|
+
render_title_doc("自动索引", [header] + progress_sections),
|
|
963
960
|
encoding="utf-8",
|
|
964
961
|
)
|
|
965
962
|
written.append("progress.md")
|
|
@@ -1243,7 +1240,6 @@ _CLASSIFY_PATTERNS = [
|
|
|
1243
1240
|
("decision", re.compile(r"结论[::]|决[定议][::]|不再|改为|采用|废弃|选择.+?不选|abandoned|adopt")),
|
|
1244
1241
|
("risk", re.compile(r"阻塞|注意|坑|失败|风险|卡住|gotcha|caveat|warning")),
|
|
1245
1242
|
("glossary", re.compile(r"即[::]|\s即\s|指的是|对应|链接|https?://|api\s*=|缩写|术语|简称|别名")),
|
|
1246
|
-
("progress", re.compile(r"当前|已完成|下一步|commit|提交|实现|进展|todo|wip")),
|
|
1247
1243
|
]
|
|
1248
1244
|
|
|
1249
1245
|
|
|
@@ -1337,13 +1333,38 @@ def summarize_scopes(paths):
|
|
|
1337
1333
|
return [{"scope": scope, "files": count} for scope, count in sorted(counter.items())]
|
|
1338
1334
|
|
|
1339
1335
|
|
|
1336
|
+
_FOCUS_EXCLUDED_PREFIXES = (
|
|
1337
|
+
".claude/", ".vscode/", ".idea/", ".git/",
|
|
1338
|
+
"node_modules/", "__pycache__/", ".venv/",
|
|
1339
|
+
)
|
|
1340
|
+
|
|
1341
|
+
_FOCUS_EXCLUDED_ROOT_FILES = {
|
|
1342
|
+
"go.mod", "go.sum", "go.work", "go.work.sum",
|
|
1343
|
+
"package.json", "package-lock.json", "pnpm-lock.yaml", "bun.lockb",
|
|
1344
|
+
"tsconfig.json", "tsconfig.base.json",
|
|
1345
|
+
"main.go", "main.py",
|
|
1346
|
+
".gitignore", ".editorconfig", ".prettierrc",
|
|
1347
|
+
"skills-lock.json", "eden.monorepo.json",
|
|
1348
|
+
"Makefile", "Dockerfile",
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def _should_exclude_path(path_str):
|
|
1353
|
+
if any(path_str.startswith(prefix) or path_str == prefix.rstrip("/")
|
|
1354
|
+
for prefix in _FOCUS_EXCLUDED_PREFIXES):
|
|
1355
|
+
return True
|
|
1356
|
+
if "/" not in path_str and path_str in _FOCUS_EXCLUDED_ROOT_FILES:
|
|
1357
|
+
return True
|
|
1358
|
+
return False
|
|
1359
|
+
|
|
1360
|
+
|
|
1340
1361
|
def _initial_parent(path_str):
|
|
1341
|
-
"""File path → its immediate parent directory key.
|
|
1342
|
-
|
|
1343
|
-
|
|
1362
|
+
"""File path → its immediate parent directory key. Root-level files map to
|
|
1363
|
+
None (skipped by callers) since individual files are not useful focus
|
|
1364
|
+
directories."""
|
|
1344
1365
|
parent = Path(path_str).parent
|
|
1345
1366
|
if str(parent) in ("", "."):
|
|
1346
|
-
return
|
|
1367
|
+
return None
|
|
1347
1368
|
return parent.as_posix()
|
|
1348
1369
|
|
|
1349
1370
|
|
|
@@ -1360,6 +1381,27 @@ def _rolled_up(key):
|
|
|
1360
1381
|
return Path(key).parent.as_posix()
|
|
1361
1382
|
|
|
1362
1383
|
|
|
1384
|
+
def _dedup_parent_child(dirs):
|
|
1385
|
+
"""Remove wider parents when a more specific child exists."""
|
|
1386
|
+
result = []
|
|
1387
|
+
for d in dirs:
|
|
1388
|
+
if any(other != d and other.startswith(d + "/") for other in dirs):
|
|
1389
|
+
continue
|
|
1390
|
+
result.append(d)
|
|
1391
|
+
return result
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
def _drop_low_weight(ranked):
|
|
1395
|
+
"""Drop weight-1 entries when higher-weight entries exist, since a single
|
|
1396
|
+
file change doesn't carry meaningful focus signal."""
|
|
1397
|
+
if not ranked:
|
|
1398
|
+
return ranked
|
|
1399
|
+
max_weight = ranked[0][1]
|
|
1400
|
+
if max_weight < 3:
|
|
1401
|
+
return ranked
|
|
1402
|
+
return [(k, w) for k, w in ranked if w >= 2]
|
|
1403
|
+
|
|
1404
|
+
|
|
1363
1405
|
def summarize_focus_areas(paths, limit=None):
|
|
1364
1406
|
"""Cluster changed-file paths into ≤ `limit` focus directories.
|
|
1365
1407
|
|
|
@@ -1374,7 +1416,14 @@ def summarize_focus_areas(paths, limit=None):
|
|
|
1374
1416
|
"""
|
|
1375
1417
|
if limit is None:
|
|
1376
1418
|
limit = FOCUS_AREA_LIMIT
|
|
1377
|
-
|
|
1419
|
+
filtered = [p for p in paths if not _should_exclude_path(p)]
|
|
1420
|
+
buckets = Counter()
|
|
1421
|
+
for p in filtered:
|
|
1422
|
+
key = _initial_parent(p)
|
|
1423
|
+
if key is not None:
|
|
1424
|
+
buckets[key] += 1
|
|
1425
|
+
if not buckets:
|
|
1426
|
+
return []
|
|
1378
1427
|
while len(buckets) > limit:
|
|
1379
1428
|
proposals = {} # rolled_key -> [sum_count, [original_keys...]]
|
|
1380
1429
|
for key, count in buckets.items():
|
|
@@ -1395,7 +1444,9 @@ def summarize_focus_areas(paths, limit=None):
|
|
|
1395
1444
|
buckets.pop(k)
|
|
1396
1445
|
buckets[winner_key] = buckets.get(winner_key, 0) + winner_count
|
|
1397
1446
|
ranked = sorted(buckets.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
1398
|
-
|
|
1447
|
+
ranked = _drop_low_weight(ranked)
|
|
1448
|
+
result = [k for k, _ in ranked[:limit]]
|
|
1449
|
+
return _dedup_parent_child(result)
|
|
1399
1450
|
|
|
1400
1451
|
|
|
1401
1452
|
# Number of recent commits whose touched files contribute to the "focus area"
|
|
@@ -1480,6 +1531,8 @@ def merged_focus_areas(new_paths, existing, limit=None):
|
|
|
1480
1531
|
"""
|
|
1481
1532
|
if limit is None:
|
|
1482
1533
|
limit = FOCUS_AREA_LIMIT
|
|
1534
|
+
existing = [d for d in existing
|
|
1535
|
+
if "/" in d and not _should_exclude_path(d)]
|
|
1483
1536
|
if not existing:
|
|
1484
1537
|
return summarize_focus_areas(new_paths, limit=limit)
|
|
1485
1538
|
|
|
@@ -1530,7 +1583,9 @@ def merged_focus_areas(new_paths, existing, limit=None):
|
|
|
1530
1583
|
|
|
1531
1584
|
new_buckets = Counter()
|
|
1532
1585
|
for p in uncovered:
|
|
1533
|
-
|
|
1586
|
+
key = _initial_parent(p)
|
|
1587
|
+
if key is not None and not _should_exclude_path(p):
|
|
1588
|
+
new_buckets[key] += 1
|
|
1534
1589
|
|
|
1535
1590
|
while len(new_buckets) > remaining_budget:
|
|
1536
1591
|
proposals = {}
|
|
@@ -1595,7 +1650,9 @@ def merged_focus_areas(new_paths, existing, limit=None):
|
|
|
1595
1650
|
seen.add(d)
|
|
1596
1651
|
selected.append((d, weight))
|
|
1597
1652
|
selected.sort(key=lambda kv: (-kv[1], kv[0]))
|
|
1598
|
-
|
|
1653
|
+
selected = _drop_low_weight(selected)
|
|
1654
|
+
result = [d for d, _ in selected[:limit]]
|
|
1655
|
+
return _dedup_parent_child(result)
|
|
1599
1656
|
|
|
1600
1657
|
|
|
1601
1658
|
def build_auto_block(facts):
|
|
@@ -103,6 +103,33 @@ def command_sync(args):
|
|
|
103
103
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
104
104
|
|
|
105
105
|
|
|
106
|
+
def command_injection_preview(args):
|
|
107
|
+
"""Render the SessionStart injection context for a given repo_key + branch,
|
|
108
|
+
without needing an actual git working tree. Used by the UI panel."""
|
|
109
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts" / "hooks"))
|
|
110
|
+
from _common import _build_context_from_assets # noqa: E402
|
|
111
|
+
from dev_memory_common import asset_paths as _asset_paths # noqa: E402
|
|
112
|
+
|
|
113
|
+
storage_root = Path(args.context_dir) if args.context_dir else Path.home() / ".dev-memory" / "repos"
|
|
114
|
+
repo_dir = storage_root / args.repo_key
|
|
115
|
+
branch_dir = repo_dir / "branches" / args.branch.replace("/", "__")
|
|
116
|
+
|
|
117
|
+
if not branch_dir.exists():
|
|
118
|
+
print(json.dumps({"error": f"branch dir not found: {branch_dir}"}, ensure_ascii=False))
|
|
119
|
+
return 1
|
|
120
|
+
|
|
121
|
+
paths = _asset_paths(repo_dir, branch_dir)
|
|
122
|
+
assets = {
|
|
123
|
+
"repo_dir": repo_dir,
|
|
124
|
+
"branch_dir": branch_dir,
|
|
125
|
+
"branch_name": args.branch,
|
|
126
|
+
"paths": paths,
|
|
127
|
+
}
|
|
128
|
+
context = _build_context_from_assets(assets, full=True)
|
|
129
|
+
print(json.dumps({"context": context or ""}, ensure_ascii=False))
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
106
133
|
def main():
|
|
107
134
|
parser = argparse.ArgumentParser(description="Read or refresh repo+branch development assets.")
|
|
108
135
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
@@ -113,12 +140,19 @@ def main():
|
|
|
113
140
|
sub.add_argument("--context-dir", help="User-home storage root. Defaults to ~/.dev-memory/repos")
|
|
114
141
|
sub.add_argument("--branch", help="Branch name. Defaults to the current checked-out branch")
|
|
115
142
|
|
|
143
|
+
preview_sub = subparsers.add_parser("injection-preview")
|
|
144
|
+
preview_sub.add_argument("--repo-key", required=True, help="Repo key (directory name under storage root)")
|
|
145
|
+
preview_sub.add_argument("--branch", required=True, help="Branch name (original, with slashes)")
|
|
146
|
+
preview_sub.add_argument("--context-dir", help="Storage root. Defaults to ~/.dev-memory/repos")
|
|
147
|
+
|
|
116
148
|
args = parser.parse_args()
|
|
117
149
|
try:
|
|
118
150
|
if args.command == "show":
|
|
119
151
|
command_show(args)
|
|
120
|
-
|
|
152
|
+
elif args.command == "sync":
|
|
121
153
|
command_sync(args)
|
|
154
|
+
elif args.command == "injection-preview":
|
|
155
|
+
return command_injection_preview(args)
|
|
122
156
|
except Exception as exc:
|
|
123
157
|
print(f"ERROR: {exc}", file=sys.stderr)
|
|
124
158
|
return 1
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
10
|
+
|
|
11
|
+
from dev_memory_common import ( # noqa: E402
|
|
12
|
+
DEV_MEMORY_ID_FILE,
|
|
13
|
+
LEGACY_ID_FILE,
|
|
14
|
+
MANAGED_FILES,
|
|
15
|
+
asset_paths,
|
|
16
|
+
detect_no_git_mode,
|
|
17
|
+
get_branch_paths,
|
|
18
|
+
read_json,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
BRANCH_FILE_KEYS = (
|
|
23
|
+
"overview",
|
|
24
|
+
"progress",
|
|
25
|
+
"decisions",
|
|
26
|
+
"risks",
|
|
27
|
+
"glossary",
|
|
28
|
+
"unsorted",
|
|
29
|
+
"pending_promotion",
|
|
30
|
+
"log",
|
|
31
|
+
"manifest",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
REPO_FILE_KEYS = (
|
|
35
|
+
"repo_overview",
|
|
36
|
+
"repo_decisions",
|
|
37
|
+
"repo_glossary",
|
|
38
|
+
"repo_log",
|
|
39
|
+
"repo_manifest",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
READ_ORDER = (
|
|
43
|
+
"glossary",
|
|
44
|
+
"decisions",
|
|
45
|
+
"risks",
|
|
46
|
+
"overview",
|
|
47
|
+
"repo_decisions",
|
|
48
|
+
"repo_glossary",
|
|
49
|
+
"repo_overview",
|
|
50
|
+
"unsorted",
|
|
51
|
+
"pending_promotion",
|
|
52
|
+
"log",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _resolve_paths(repo, context_dir=None, branch=None):
|
|
57
|
+
repo_path = Path(repo).expanduser().resolve()
|
|
58
|
+
if detect_no_git_mode(repo_path) and not (
|
|
59
|
+
(repo_path / DEV_MEMORY_ID_FILE).exists()
|
|
60
|
+
or (repo_path / LEGACY_ID_FILE).exists()
|
|
61
|
+
):
|
|
62
|
+
raise RuntimeError(
|
|
63
|
+
"no-git memory is not initialized; read refuses to create a .dev-memory-id. "
|
|
64
|
+
"Initialize by writing via capture/setup first, or pass a Git repo path."
|
|
65
|
+
)
|
|
66
|
+
repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir = get_branch_paths(
|
|
67
|
+
repo, context_dir, branch
|
|
68
|
+
)
|
|
69
|
+
paths = asset_paths(repo_dir, branch_dir)
|
|
70
|
+
return {
|
|
71
|
+
"repo_root": repo_root,
|
|
72
|
+
"branch_name": branch_name,
|
|
73
|
+
"branch_key": branch_key,
|
|
74
|
+
"storage_root": storage_root,
|
|
75
|
+
"repo_key": repo_key,
|
|
76
|
+
"repo_dir": repo_dir,
|
|
77
|
+
"branch_dir": branch_dir,
|
|
78
|
+
"paths": paths,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _existing_branch_dirs(repo_dir):
|
|
83
|
+
branches_dir = repo_dir / "branches"
|
|
84
|
+
if not branches_dir.exists():
|
|
85
|
+
return []
|
|
86
|
+
result = []
|
|
87
|
+
for path in sorted(branches_dir.iterdir(), key=lambda p: p.name):
|
|
88
|
+
if not path.is_dir() or path.name == "_archived":
|
|
89
|
+
continue
|
|
90
|
+
manifest = read_json(path / "manifest.json")
|
|
91
|
+
result.append(
|
|
92
|
+
{
|
|
93
|
+
"branch_key": path.name,
|
|
94
|
+
"branch": manifest.get("branch") or path.name.replace("__", "/"),
|
|
95
|
+
"path": str(path),
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _existing_files(paths, keys):
|
|
102
|
+
files = {}
|
|
103
|
+
for key in keys:
|
|
104
|
+
path = paths[key]
|
|
105
|
+
files[key] = {
|
|
106
|
+
"path": str(path),
|
|
107
|
+
"exists": path.exists(),
|
|
108
|
+
}
|
|
109
|
+
return files
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _memory_files_for_dir(memory_dir):
|
|
113
|
+
for file_name in MANAGED_FILES:
|
|
114
|
+
path = memory_dir / file_name
|
|
115
|
+
if path.exists() and path.is_file():
|
|
116
|
+
yield path
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _dedupe_paths(paths):
|
|
120
|
+
seen = set()
|
|
121
|
+
result = []
|
|
122
|
+
for path in paths:
|
|
123
|
+
resolved = path.resolve()
|
|
124
|
+
if resolved in seen:
|
|
125
|
+
continue
|
|
126
|
+
seen.add(resolved)
|
|
127
|
+
result.append(path)
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _scope_files(resolved, scope):
|
|
132
|
+
paths = resolved["paths"]
|
|
133
|
+
repo_dir = resolved["repo_dir"]
|
|
134
|
+
branch_dir = resolved["branch_dir"]
|
|
135
|
+
files = []
|
|
136
|
+
|
|
137
|
+
if scope in ("branch", "current"):
|
|
138
|
+
files.extend(paths[key] for key in BRANCH_FILE_KEYS)
|
|
139
|
+
if scope in ("repo", "current"):
|
|
140
|
+
files.extend(paths[key] for key in REPO_FILE_KEYS)
|
|
141
|
+
if scope == "all-branches":
|
|
142
|
+
files.extend(paths[key] for key in REPO_FILE_KEYS)
|
|
143
|
+
branches_dir = repo_dir / "branches"
|
|
144
|
+
if branches_dir.exists():
|
|
145
|
+
for child in sorted(branches_dir.iterdir(), key=lambda p: p.name):
|
|
146
|
+
if child.is_dir() and child.name != "_archived":
|
|
147
|
+
files.extend(_memory_files_for_dir(child))
|
|
148
|
+
if scope == "archived":
|
|
149
|
+
archived_dir = repo_dir / "branches" / "_archived"
|
|
150
|
+
if archived_dir.exists():
|
|
151
|
+
for child in sorted(archived_dir.iterdir(), key=lambda p: p.name):
|
|
152
|
+
if child.is_dir():
|
|
153
|
+
files.extend(_memory_files_for_dir(child))
|
|
154
|
+
if scope == "all":
|
|
155
|
+
files.extend(paths[key] for key in REPO_FILE_KEYS)
|
|
156
|
+
branches_dir = repo_dir / "branches"
|
|
157
|
+
if branches_dir.exists():
|
|
158
|
+
for child in sorted(branches_dir.rglob("*"), key=lambda p: p.as_posix()):
|
|
159
|
+
if child.is_dir() and child.name != "_archived":
|
|
160
|
+
files.extend(_memory_files_for_dir(child))
|
|
161
|
+
|
|
162
|
+
return [p for p in _dedupe_paths(files) if p.exists() and p.is_file()]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _make_matchers(queries, *, regex=False, case_sensitive=False):
|
|
166
|
+
if not queries:
|
|
167
|
+
raise ValueError("pass at least one --query")
|
|
168
|
+
if regex:
|
|
169
|
+
flags = 0 if case_sensitive else re.IGNORECASE
|
|
170
|
+
return [(q, re.compile(q, flags)) for q in queries]
|
|
171
|
+
if case_sensitive:
|
|
172
|
+
return [(q, q) for q in queries]
|
|
173
|
+
return [(q, q.lower()) for q in queries]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _line_matches(line, matchers, *, regex=False, case_sensitive=False):
|
|
177
|
+
haystack = line if case_sensitive else line.lower()
|
|
178
|
+
matched = []
|
|
179
|
+
for raw, matcher in matchers:
|
|
180
|
+
if regex:
|
|
181
|
+
if matcher.search(line):
|
|
182
|
+
matched.append(raw)
|
|
183
|
+
elif matcher in haystack:
|
|
184
|
+
matched.append(raw)
|
|
185
|
+
return matched
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _context_lines(lines, line_no, radius):
|
|
189
|
+
if radius <= 0:
|
|
190
|
+
return []
|
|
191
|
+
start = max(1, line_no - radius)
|
|
192
|
+
end = min(len(lines), line_no + radius)
|
|
193
|
+
result = []
|
|
194
|
+
for current in range(start, end + 1):
|
|
195
|
+
if current == line_no:
|
|
196
|
+
continue
|
|
197
|
+
result.append({"line": current, "text": lines[current - 1]})
|
|
198
|
+
return result
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def command_show(args):
|
|
202
|
+
resolved = _resolve_paths(args.repo, args.context_dir, args.branch)
|
|
203
|
+
paths = resolved["paths"]
|
|
204
|
+
payload = {
|
|
205
|
+
"repo_root": str(resolved["repo_root"]),
|
|
206
|
+
"repo_key": resolved["repo_key"],
|
|
207
|
+
"branch": resolved["branch_name"],
|
|
208
|
+
"branch_key": resolved["branch_key"],
|
|
209
|
+
"storage_root": str(resolved["storage_root"]),
|
|
210
|
+
"repo_dir": str(resolved["repo_dir"]),
|
|
211
|
+
"branch_dir": str(resolved["branch_dir"]),
|
|
212
|
+
"repo_exists": resolved["repo_dir"].exists(),
|
|
213
|
+
"branch_exists": resolved["branch_dir"].exists(),
|
|
214
|
+
"recommended_read_order": [
|
|
215
|
+
{"key": key, "path": str(paths[key]), "exists": paths[key].exists()}
|
|
216
|
+
for key in READ_ORDER
|
|
217
|
+
if key in paths
|
|
218
|
+
],
|
|
219
|
+
"branch_files": _existing_files(paths, BRANCH_FILE_KEYS),
|
|
220
|
+
"repo_files": _existing_files(paths, REPO_FILE_KEYS),
|
|
221
|
+
"existing_branches": _existing_branch_dirs(resolved["repo_dir"]),
|
|
222
|
+
}
|
|
223
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def command_search(args):
|
|
227
|
+
resolved = _resolve_paths(args.repo, args.context_dir, args.branch)
|
|
228
|
+
matchers = _make_matchers(args.query, regex=args.regex, case_sensitive=args.case_sensitive)
|
|
229
|
+
files = _scope_files(resolved, args.scope)
|
|
230
|
+
hits = []
|
|
231
|
+
|
|
232
|
+
for path in files:
|
|
233
|
+
try:
|
|
234
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
235
|
+
except UnicodeDecodeError:
|
|
236
|
+
continue
|
|
237
|
+
for idx, line in enumerate(lines, start=1):
|
|
238
|
+
matched_queries = _line_matches(
|
|
239
|
+
line,
|
|
240
|
+
matchers,
|
|
241
|
+
regex=args.regex,
|
|
242
|
+
case_sensitive=args.case_sensitive,
|
|
243
|
+
)
|
|
244
|
+
if not matched_queries:
|
|
245
|
+
continue
|
|
246
|
+
hits.append(
|
|
247
|
+
{
|
|
248
|
+
"path": str(path),
|
|
249
|
+
"line": idx,
|
|
250
|
+
"text": line,
|
|
251
|
+
"matched_queries": matched_queries,
|
|
252
|
+
"context": _context_lines(lines, idx, args.context_lines),
|
|
253
|
+
}
|
|
254
|
+
)
|
|
255
|
+
if len(hits) >= args.max_hits:
|
|
256
|
+
break
|
|
257
|
+
if len(hits) >= args.max_hits:
|
|
258
|
+
break
|
|
259
|
+
|
|
260
|
+
payload = {
|
|
261
|
+
"repo_root": str(resolved["repo_root"]),
|
|
262
|
+
"repo_key": resolved["repo_key"],
|
|
263
|
+
"branch": resolved["branch_name"],
|
|
264
|
+
"branch_key": resolved["branch_key"],
|
|
265
|
+
"repo_dir": str(resolved["repo_dir"]),
|
|
266
|
+
"branch_dir": str(resolved["branch_dir"]),
|
|
267
|
+
"scope": args.scope,
|
|
268
|
+
"queries": args.query,
|
|
269
|
+
"regex": args.regex,
|
|
270
|
+
"searched_files": [str(p) for p in files],
|
|
271
|
+
"hit_count": len(hits),
|
|
272
|
+
"hits": hits,
|
|
273
|
+
}
|
|
274
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def main():
|
|
278
|
+
parser = argparse.ArgumentParser(
|
|
279
|
+
description="Locate and search dev-memory files for the current repo/branch without scanning global memory."
|
|
280
|
+
)
|
|
281
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
282
|
+
|
|
283
|
+
show = subparsers.add_parser("show", help="Show authoritative memory paths for a repo/branch.")
|
|
284
|
+
show.add_argument("--repo", default=".", help="Path inside the target Git repository")
|
|
285
|
+
show.add_argument("--context-dir", help="User-home storage root. Defaults to ~/.dev-memory/repos")
|
|
286
|
+
show.add_argument("--branch", help="Branch name. Defaults to the current checked-out branch")
|
|
287
|
+
|
|
288
|
+
search = subparsers.add_parser("search", help="Search memory files under the resolved repo memory directory.")
|
|
289
|
+
search.add_argument("--repo", default=".", help="Path inside the target Git repository")
|
|
290
|
+
search.add_argument("--context-dir", help="User-home storage root. Defaults to ~/.dev-memory/repos")
|
|
291
|
+
search.add_argument("--branch", help="Branch name. Defaults to the current checked-out branch")
|
|
292
|
+
search.add_argument(
|
|
293
|
+
"--scope",
|
|
294
|
+
choices=("current", "branch", "repo", "all-branches", "archived", "all"),
|
|
295
|
+
default="current",
|
|
296
|
+
help="Which memory files to search. Defaults to current branch + repo shared layer.",
|
|
297
|
+
)
|
|
298
|
+
search.add_argument("--query", action="append", required=True, help="Literal query. Repeat for OR matching.")
|
|
299
|
+
search.add_argument("--regex", action="store_true", help="Treat --query values as Python regex patterns.")
|
|
300
|
+
search.add_argument("--case-sensitive", action="store_true")
|
|
301
|
+
search.add_argument("--context-lines", type=int, default=1, help="Neighboring lines to include around each hit.")
|
|
302
|
+
search.add_argument("--max-hits", type=int, default=80)
|
|
303
|
+
|
|
304
|
+
args = parser.parse_args()
|
|
305
|
+
try:
|
|
306
|
+
if args.command == "show":
|
|
307
|
+
command_show(args)
|
|
308
|
+
else:
|
|
309
|
+
command_search(args)
|
|
310
|
+
except Exception as exc:
|
|
311
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
312
|
+
return 1
|
|
313
|
+
return 0
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
if __name__ == "__main__":
|
|
317
|
+
raise SystemExit(main())
|
package/lib/dev_memory_setup.py
CHANGED
|
@@ -102,8 +102,6 @@ def command_init(args):
|
|
|
102
102
|
|
|
103
103
|
_SETUP_KIND_TO_TARGET = {
|
|
104
104
|
"decision": ("decisions", "关键决策与原因"),
|
|
105
|
-
"progress": ("progress", "当前进展"),
|
|
106
|
-
"next": ("progress", "下一步"),
|
|
107
105
|
"risk": ("risks", "阻塞与注意点"),
|
|
108
106
|
"glossary": ("glossary", "当前有效上下文"),
|
|
109
107
|
"source": ("glossary", "分支源资料入口"),
|
package/lib/ui-app.html
CHANGED
|
@@ -840,6 +840,10 @@ function renderContent() {
|
|
|
840
840
|
content.appendChild(bs);
|
|
841
841
|
}
|
|
842
842
|
|
|
843
|
+
var previewBtn = el("button", { class: "icon-btn", style: "margin-bottom: 16px;" }, "👁 注入预览");
|
|
844
|
+
previewBtn.onclick = function () { openInjectionPreview(repo.key, cur.name); };
|
|
845
|
+
content.appendChild(previewBtn);
|
|
846
|
+
|
|
843
847
|
var branchMdFiles = cur.files.filter(function (f) { return f.name.endsWith(".md"); });
|
|
844
848
|
if (branchMdFiles.length === 0) {
|
|
845
849
|
content.appendChild(el("div", { class: "empty-state" }, "该分支暂无记忆文件"));
|
|
@@ -989,6 +993,43 @@ function closeModal() {
|
|
|
989
993
|
modalState.editing = false;
|
|
990
994
|
}
|
|
991
995
|
|
|
996
|
+
function openInjectionPreview(repoKey, branchName) {
|
|
997
|
+
modalState.relPath = null;
|
|
998
|
+
modalState.text = "";
|
|
999
|
+
modalState.editing = false;
|
|
1000
|
+
q("#modalTitle").textContent = "注入预览 — " + branchName;
|
|
1001
|
+
q("#modalEdit").hidden = true;
|
|
1002
|
+
q("#modalSave").hidden = true;
|
|
1003
|
+
q("#modalCancel").hidden = true;
|
|
1004
|
+
setModalStatus("");
|
|
1005
|
+
var body = q("#modalBody");
|
|
1006
|
+
body.innerHTML = "";
|
|
1007
|
+
body.appendChild(el("span", { class: "loading" }, "生成注入预览…"));
|
|
1008
|
+
q("#modal").classList.add("open");
|
|
1009
|
+
|
|
1010
|
+
fetch("/api/injection-preview?repo=" + encodeURIComponent(repoKey) + "&branch=" + encodeURIComponent(branchName))
|
|
1011
|
+
.then(function (r) { return r.json(); })
|
|
1012
|
+
.then(function (data) {
|
|
1013
|
+
body.innerHTML = "";
|
|
1014
|
+
if (data.error) {
|
|
1015
|
+
body.appendChild(el("pre", { class: "raw" }, "错误:" + data.error));
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
var text = data.context || "(空)";
|
|
1019
|
+
var pre = document.createElement("pre");
|
|
1020
|
+
pre.className = "raw";
|
|
1021
|
+
pre.style.whiteSpace = "pre-wrap";
|
|
1022
|
+
pre.style.wordBreak = "break-word";
|
|
1023
|
+
pre.textContent = text;
|
|
1024
|
+
body.appendChild(pre);
|
|
1025
|
+
setModalStatus(text.length + " 字符", "success");
|
|
1026
|
+
})
|
|
1027
|
+
.catch(function (err) {
|
|
1028
|
+
body.innerHTML = "";
|
|
1029
|
+
body.appendChild(el("pre", { class: "raw" }, "请求失败:" + err.message));
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
|
|
992
1033
|
function loadTree() {
|
|
993
1034
|
q("#meta").textContent = "加载中…";
|
|
994
1035
|
q("#repoList").innerHTML = '<div class="loading">加载中…</div>';
|
package/lib/ui-server.js
CHANGED
|
@@ -201,6 +201,42 @@ function writeFileSafely(fullPath, content) {
|
|
|
201
201
|
};
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
const CONTEXT_SCRIPT = path.join(__dirname, "dev_memory_context.py");
|
|
205
|
+
|
|
206
|
+
function runInjectionPreview(repoKey, branch) {
|
|
207
|
+
return new Promise((resolve, reject) => {
|
|
208
|
+
const args = [
|
|
209
|
+
CONTEXT_SCRIPT,
|
|
210
|
+
"injection-preview",
|
|
211
|
+
"--repo-key", repoKey,
|
|
212
|
+
"--branch", branch,
|
|
213
|
+
"--context-dir", getStorageRoot(),
|
|
214
|
+
];
|
|
215
|
+
const child = spawn(process.env.DEV_MEMORY_PYTHON || "python3", args, {
|
|
216
|
+
cwd: __dirname,
|
|
217
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
218
|
+
timeout: 15000,
|
|
219
|
+
});
|
|
220
|
+
const chunks = [];
|
|
221
|
+
child.stdout.on("data", (d) => chunks.push(d));
|
|
222
|
+
let stderr = "";
|
|
223
|
+
child.stderr.on("data", (d) => { stderr += d.toString(); });
|
|
224
|
+
child.on("close", (code) => {
|
|
225
|
+
const stdout = Buffer.concat(chunks).toString("utf8");
|
|
226
|
+
if (code !== 0) {
|
|
227
|
+
reject(new Error(stderr || `exit ${code}`));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
resolve(JSON.parse(stdout));
|
|
232
|
+
} catch (e) {
|
|
233
|
+
reject(new Error(`invalid JSON: ${e.message}`));
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
child.on("error", reject);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
204
240
|
function readAppHtml() {
|
|
205
241
|
return fs.readFileSync(APP_HTML_PATH, "utf8");
|
|
206
242
|
}
|
|
@@ -263,6 +299,23 @@ function start({ host = "127.0.0.1", port = 0, openBrowserFlag = true, readOnly
|
|
|
263
299
|
res.end(JSON.stringify(tree));
|
|
264
300
|
return;
|
|
265
301
|
}
|
|
302
|
+
if (url.pathname === "/api/injection-preview" && isRead) {
|
|
303
|
+
const repoKey = url.searchParams.get("repo") || "";
|
|
304
|
+
const branch = url.searchParams.get("branch") || "";
|
|
305
|
+
if (!repoKey || !branch) {
|
|
306
|
+
res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
|
|
307
|
+
res.end(JSON.stringify({ error: "repo and branch required" }));
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
runInjectionPreview(repoKey, branch).then((data) => {
|
|
311
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
312
|
+
res.end(JSON.stringify(data));
|
|
313
|
+
}).catch((err) => {
|
|
314
|
+
res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
|
|
315
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
316
|
+
});
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
266
319
|
if (url.pathname === "/api/file" && isRead) {
|
|
267
320
|
const rel = url.searchParams.get("path") || "";
|
|
268
321
|
const full = resolveSafePath(rel);
|
package/package.json
CHANGED
package/scripts/hooks/_common.py
CHANGED
|
@@ -200,6 +200,27 @@ def extract_section(path, title):
|
|
|
200
200
|
return None if is_placeholder(body) else body
|
|
201
201
|
|
|
202
202
|
|
|
203
|
+
def extract_repo_file_body(path):
|
|
204
|
+
"""Extract full body of a repo-level file, skipping the H1 title and
|
|
205
|
+
``## 仓库`` metadata section. Placeholder-only sections within the file
|
|
206
|
+
are dropped individually so that real content in sibling sections is
|
|
207
|
+
preserved. Returns None when nothing substantive remains."""
|
|
208
|
+
if not path.exists():
|
|
209
|
+
return None
|
|
210
|
+
content = path.read_text(encoding="utf-8")
|
|
211
|
+
content = re.sub(r"^## 仓库\n\n.*?(?=^## |\Z)", "", content, count=1,
|
|
212
|
+
flags=re.MULTILINE | re.DOTALL)
|
|
213
|
+
content = re.sub(r"^# .+\n*", "", content, count=1)
|
|
214
|
+
sections = re.split(r"(?=^## )", content, flags=re.MULTILINE)
|
|
215
|
+
kept = []
|
|
216
|
+
for sec in sections:
|
|
217
|
+
sec_body = strip_managed_markers(sec).strip()
|
|
218
|
+
if sec_body and not is_placeholder(sec_body):
|
|
219
|
+
kept.append(sec_body)
|
|
220
|
+
body = "\n\n".join(kept).strip()
|
|
221
|
+
return body or None
|
|
222
|
+
|
|
223
|
+
|
|
203
224
|
def compact_body(text, max_lines=8, max_chars=700):
|
|
204
225
|
"""Compact a section body. Returns (compacted_text, was_truncated). The
|
|
205
226
|
caller uses `was_truncated` to decide whether to append a "see full file"
|
|
@@ -320,28 +341,24 @@ def maybe_record_head():
|
|
|
320
341
|
# "后续继续前要注意"; decisions.md carries "关键决策与原因"; glossary.md carries
|
|
321
342
|
# "当前有效上下文".
|
|
322
343
|
_FULL_SECTION_KEYS = (
|
|
344
|
+
("progress", "功能文件索引"),
|
|
323
345
|
("progress", "建议优先查看的目录"),
|
|
346
|
+
("repo_decisions", None),
|
|
347
|
+
("repo_glossary", None),
|
|
348
|
+
("glossary", "分支源资料入口"),
|
|
324
349
|
("overview", "当前目标"),
|
|
325
350
|
("overview", "范围边界"),
|
|
326
|
-
("overview", "当前阶段"),
|
|
327
351
|
("overview", "关键约束"),
|
|
328
|
-
("
|
|
329
|
-
("risks", "阻塞与注意点"),
|
|
330
|
-
("progress", "下一步"),
|
|
352
|
+
("repo_overview", None),
|
|
331
353
|
("glossary", "当前有效上下文"),
|
|
354
|
+
("risks", "阻塞与注意点"),
|
|
332
355
|
("decisions", "关键决策与原因"),
|
|
333
356
|
("risks", "后续继续前要注意"),
|
|
334
|
-
("repo_overview", "长期目标与边界"),
|
|
335
|
-
("repo_overview", "仓库级关键约束"),
|
|
336
|
-
("repo_decisions", "跨分支通用决策"),
|
|
337
|
-
("repo_glossary", "共享入口"),
|
|
338
357
|
)
|
|
339
358
|
|
|
340
359
|
_BRIEF_SECTION_KEYS = (
|
|
341
360
|
("overview", "当前目标"),
|
|
342
|
-
("
|
|
343
|
-
("progress", "当前进展"),
|
|
344
|
-
("progress", "下一步"),
|
|
361
|
+
("glossary", "当前有效上下文"),
|
|
345
362
|
)
|
|
346
363
|
|
|
347
364
|
|
|
@@ -351,17 +368,48 @@ _RECENT_FIRST_SECTIONS = {
|
|
|
351
368
|
("risks", "后续继续前要注意"),
|
|
352
369
|
("glossary", "当前有效上下文"),
|
|
353
370
|
("glossary", "分支源资料入口"),
|
|
354
|
-
("repo_decisions", "跨分支通用决策"),
|
|
355
|
-
("repo_glossary", "长期有效背景"),
|
|
356
|
-
("repo_glossary", "共享入口"),
|
|
357
371
|
}
|
|
358
372
|
|
|
373
|
+
_BRANCH_REFERENCE_SECTIONS = {
|
|
374
|
+
("glossary", "当前有效上下文"),
|
|
375
|
+
("glossary", "分支源资料入口"),
|
|
376
|
+
("progress", "功能文件索引"),
|
|
377
|
+
}
|
|
378
|
+
_BRANCH_REF_MAX_LINES = 128
|
|
379
|
+
_BRANCH_REF_MAX_CHARS = 12000
|
|
380
|
+
|
|
381
|
+
_REPO_LEVEL_KEYS = {
|
|
382
|
+
"repo_overview",
|
|
383
|
+
"repo_decisions",
|
|
384
|
+
"repo_glossary",
|
|
385
|
+
"repo_log",
|
|
386
|
+
}
|
|
387
|
+
_REPO_DISPLAY_TITLES = {
|
|
388
|
+
"repo_overview": "仓库概览",
|
|
389
|
+
"repo_decisions": "跨分支通用决策",
|
|
390
|
+
"repo_glossary": "仓库共享术语与入口",
|
|
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
|
+
}
|
|
398
|
+
_REPO_MAX_LINES = 32
|
|
399
|
+
_REPO_MAX_CHARS = 3000
|
|
400
|
+
|
|
359
401
|
|
|
360
402
|
def _extract_sections(paths, keys):
|
|
361
403
|
out = []
|
|
362
404
|
for file_key, title in keys:
|
|
363
|
-
|
|
364
|
-
|
|
405
|
+
if title is None:
|
|
406
|
+
body = extract_repo_file_body(paths[file_key])
|
|
407
|
+
display_title = _REPO_DISPLAY_TITLES.get(file_key, paths[file_key].stem)
|
|
408
|
+
else:
|
|
409
|
+
body = extract_section(paths[file_key], title)
|
|
410
|
+
display_title = title
|
|
411
|
+
wrapper = _HIGH_PRIORITY_WRAPPERS.get((file_key, title))
|
|
412
|
+
out.append((display_title, body, file_key, title, wrapper))
|
|
365
413
|
return out
|
|
366
414
|
|
|
367
415
|
|
|
@@ -396,14 +444,25 @@ def _build_context_from_assets(assets, *, full=True, heading=None):
|
|
|
396
444
|
# the footer's directory header (.../branches/<branch>/).
|
|
397
445
|
|
|
398
446
|
any_truncated = False
|
|
399
|
-
for title, body, file_key in sections:
|
|
447
|
+
for title, body, file_key, source_title, wrapper in sections:
|
|
400
448
|
if not body:
|
|
401
449
|
continue
|
|
402
|
-
if
|
|
403
|
-
|
|
450
|
+
if full and file_key in _REPO_LEVEL_KEYS:
|
|
451
|
+
eff_lines, eff_chars = _REPO_MAX_LINES, _REPO_MAX_CHARS
|
|
452
|
+
elif full and (file_key, source_title) in _BRANCH_REFERENCE_SECTIONS:
|
|
453
|
+
eff_lines, eff_chars = _BRANCH_REF_MAX_LINES, _BRANCH_REF_MAX_CHARS
|
|
454
|
+
else:
|
|
455
|
+
eff_lines, eff_chars = max_lines, max_chars
|
|
456
|
+
if (file_key, source_title) in _RECENT_FIRST_SECTIONS:
|
|
457
|
+
compacted, truncated = compact_recent_body(body, max_lines=eff_lines, max_chars=eff_chars)
|
|
458
|
+
else:
|
|
459
|
+
compacted, truncated = compact_body(body, max_lines=eff_lines, max_chars=eff_chars)
|
|
460
|
+
if wrapper:
|
|
461
|
+
block = f"<{wrapper}>\n{compacted}\n</{wrapper}>"
|
|
462
|
+
elif full and file_key in _REPO_LEVEL_KEYS:
|
|
463
|
+
block = compacted
|
|
404
464
|
else:
|
|
405
|
-
|
|
406
|
-
block = f"{title}:\n{compacted}"
|
|
465
|
+
block = f"{title}:\n{compacted}"
|
|
407
466
|
if truncated:
|
|
408
467
|
file_path = paths.get(file_key)
|
|
409
468
|
if file_path is not None:
|
|
@@ -825,31 +884,47 @@ transcript 过滤(extract-core 已执行;这里是核对规则):
|
|
|
825
884
|
|
|
826
885
|
写入原则:
|
|
827
886
|
- 先结合现有记忆判断每条信息是新增、改写、删除/归档还是跳过。
|
|
828
|
-
-
|
|
887
|
+
- 已完成且不再影响后续工作的状态,不要追加——通过 rewrite-entry/tidy 删除旧条目。
|
|
829
888
|
- 旧结论失效时优先 rewrite-entry 或 tidy 删除,不要追加一条相反结论让两条并存。
|
|
830
|
-
-
|
|
889
|
+
- decision / risk / glossary 是累计条目,注意避免重复。
|
|
831
890
|
- 只在确有新增或更新时写入。没有有效新增时只输出 `skip_reason`,代码会把 job 标记为 skipped。
|
|
891
|
+
- 不要写”当前进展”、”下一步”、”当前阶段”等时效性状态——这些天然会过期;记忆应聚焦稳定的决策、约束、上下文和参考信息。
|
|
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
|
+
|
|
901
|
+
file_map(功能文件索引):
|
|
902
|
+
- 用途:让后续 agent 听到”改下操作弹窗”就能直接定位文件,不用搜索。
|
|
903
|
+
- 每条 label 是业务语义名(页面/弹窗/侧拉/组件/表单等),paths 是对应文件的仓库相对路径。
|
|
904
|
+
- 粒度到组件/功能即可,不要写具体逻辑,agent 定位到文件后自己读。
|
|
905
|
+
- 增量 merge:读 existing_memory 里已有的功能文件索引,结合本次会话改动判断——新涉及的组件加入、已有条目路径变了就更新、描述不准的修正、分支里已删除的组件移除。
|
|
906
|
+
- 输出合并后的**完整映射**(不是增量 diff),代码侧直接覆盖整个 section。
|
|
907
|
+
- 没有文件变更或映射无变化时省略此字段。
|
|
832
908
|
|
|
833
909
|
输出要求:
|
|
834
910
|
- 只输出一个 summary-output JSON 对象,不要输出 markdown fence、解释文字或命令。
|
|
835
911
|
- summary-output 格式:
|
|
836
912
|
{{
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
"skip_reason": "没有新增有效内容"
|
|
913
|
+
“title”: “简短标题”,
|
|
914
|
+
“file_map”: [{{“label”: “功能/组件名”, “paths”: [“相对路径”]}}],
|
|
915
|
+
“decisions”: [{{“summary”: “结论”, “reason”: “为什么”, “impact”: “影响范围”}}],
|
|
916
|
+
“risks”: [“风险/坑/阻塞”],
|
|
917
|
+
“glossary”: [“术语/上下文/命令/外部系统入口”],
|
|
918
|
+
“shared_decisions”: [{{“summary”: “短句跨分支规则”}}],
|
|
919
|
+
“shared_context”: [“一句话仓库级长期背景”],
|
|
920
|
+
“shared_sources”: [“一句话仓库级共享入口”],
|
|
921
|
+
“upserts”: [{{“kind”: “overview”, “content”: “覆盖某个 kind”}}],
|
|
922
|
+
“appends”: [{{“kind”: “decision”, “content”: “追加某个 kind”}}],
|
|
923
|
+
“rewrites”: [{{“id”: “decisions::0::2”, “content”: “新条目”, “reason”: “旧结论失效”}}],
|
|
924
|
+
“deletes”: [{{“id”: “risks::0::1”, “reason”: “风险已解除”}}],
|
|
925
|
+
“skip_reason”: “没有新增有效内容”
|
|
851
926
|
}}
|
|
852
|
-
-
|
|
927
|
+
- 字段可省略;不要输出空字段。
|
|
853
928
|
- 发现旧条目需要改写/删除时,不要追加矛盾条目。优先在 summary-output 的 rewrites/deletes 中表达。
|
|
854
929
|
- 不要调用任何 dev-memory-cli 命令;代码会校验 JSON、落盘、处理 dedup,并移动 job。
|
|
855
930
|
|
|
@@ -860,13 +935,57 @@ SUMMARY_INPUT_JSON:
|
|
|
860
935
|
"""
|
|
861
936
|
|
|
862
937
|
|
|
938
|
+
def _is_pid_alive(pid):
|
|
939
|
+
try:
|
|
940
|
+
os.kill(pid, 0)
|
|
941
|
+
return True
|
|
942
|
+
except (OSError, ProcessLookupError):
|
|
943
|
+
return False
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def _check_worker_lock(queue_dir, job_id):
|
|
947
|
+
"""Return True if a live worker already holds the lock for this job."""
|
|
948
|
+
lock_path = Path(queue_dir) / "locks" / f"{job_id}.lock"
|
|
949
|
+
if not lock_path.exists():
|
|
950
|
+
return False
|
|
951
|
+
try:
|
|
952
|
+
content = lock_path.read_text(encoding="utf-8").strip()
|
|
953
|
+
pid = int(content)
|
|
954
|
+
if _is_pid_alive(pid):
|
|
955
|
+
return True
|
|
956
|
+
lock_path.unlink(missing_ok=True)
|
|
957
|
+
except (ValueError, OSError):
|
|
958
|
+
lock_path.unlink(missing_ok=True)
|
|
959
|
+
return False
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _is_job_already_terminal(queue_dir, job_id):
|
|
963
|
+
"""Return the terminal state if this job is already done/skipped, else None."""
|
|
964
|
+
for state in ("done", "skipped"):
|
|
965
|
+
if (Path(queue_dir) / state / f"{job_id}.json").exists():
|
|
966
|
+
return state
|
|
967
|
+
return None
|
|
968
|
+
|
|
969
|
+
|
|
863
970
|
def maybe_start_summary_agent(job_path, queue_dir=None, job_id=None):
|
|
864
971
|
if os.environ.get("DEV_MEMORY_DISABLE_SESSION_SUMMARY_AGENT", "").strip():
|
|
865
972
|
return None
|
|
866
973
|
command = session_summary_command()
|
|
867
974
|
if not command:
|
|
868
975
|
return None
|
|
869
|
-
|
|
976
|
+
effective_queue_dir = queue_dir or Path(job_path).parent.parent
|
|
977
|
+
effective_job_id = job_id or Path(job_path).stem
|
|
978
|
+
|
|
979
|
+
terminal = _is_job_already_terminal(effective_queue_dir, effective_job_id)
|
|
980
|
+
if terminal:
|
|
981
|
+
log(f"[dev-memory] summary agent skipped: job {effective_job_id} already {terminal}")
|
|
982
|
+
return None
|
|
983
|
+
|
|
984
|
+
if _check_worker_lock(effective_queue_dir, effective_job_id):
|
|
985
|
+
log(f"[dev-memory] summary agent skipped: worker already running for {effective_job_id}")
|
|
986
|
+
return None
|
|
987
|
+
|
|
988
|
+
summary_session_id = f"dev-memory-summary-{effective_job_id}"
|
|
870
989
|
log_path = None
|
|
871
990
|
if queue_dir is not None and job_id:
|
|
872
991
|
runs_dir = Path(queue_dir) / "runs"
|
|
@@ -879,9 +998,9 @@ def maybe_start_summary_agent(job_path, queue_dir=None, job_id=None):
|
|
|
879
998
|
"--job",
|
|
880
999
|
str(job_path),
|
|
881
1000
|
"--queue-dir",
|
|
882
|
-
str(
|
|
1001
|
+
str(effective_queue_dir),
|
|
883
1002
|
"--job-id",
|
|
884
|
-
str(
|
|
1003
|
+
str(effective_job_id),
|
|
885
1004
|
"--agent-command",
|
|
886
1005
|
command,
|
|
887
1006
|
"--summary-session-id",
|