dev-memory-cli 0.18.3 → 0.19.2
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 +18 -12
- 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 +141 -39
- 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> [...]
|
|
@@ -677,11 +677,8 @@ KIND_MAP = {
|
|
|
677
677
|
"glossary": {"file": "glossary", "section": "当前有效上下文", "default_mode": "append"},
|
|
678
678
|
"source": {"file": "glossary", "section": "分支源资料入口", "default_mode": "append"},
|
|
679
679
|
# 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
680
|
"overview": {"file": "overview", "section": "当前目标", "default_mode": "upsert"},
|
|
683
681
|
"scope": {"file": "overview", "section": "范围边界", "default_mode": "upsert"},
|
|
684
|
-
"stage": {"file": "overview", "section": "当前阶段", "default_mode": "upsert"},
|
|
685
682
|
"constraint": {"file": "overview", "section": "关键约束", "default_mode": "upsert"},
|
|
686
683
|
# repo-shared: decisions/context/source accumulate, overview/constraint snapshot
|
|
687
684
|
"shared-decision": {"file": "repo_decisions", "section": "跨分支通用决策", "default_mode": "append"},
|
|
@@ -689,6 +686,8 @@ KIND_MAP = {
|
|
|
689
686
|
"shared-source": {"file": "repo_glossary", "section": "共享入口", "default_mode": "append"},
|
|
690
687
|
"shared-overview": {"file": "repo_overview", "section": "长期目标与边界", "default_mode": "upsert"},
|
|
691
688
|
"shared-constraint": {"file": "repo_overview", "section": "仓库级关键约束", "default_mode": "upsert"},
|
|
689
|
+
# semantic file map: agent outputs merged mapping each session
|
|
690
|
+
"filemap": {"file": "progress", "section": "功能文件索引", "default_mode": "upsert"},
|
|
692
691
|
# fallback bins always accumulate
|
|
693
692
|
"unsorted": {"file": "unsorted", "section": "待分类", "default_mode": "append"},
|
|
694
693
|
"pending": {"file": "pending_promotion", "section": "候选条目", "default_mode": "append"},
|
|
@@ -840,9 +839,6 @@ def _finalize_worktree_writeback(ctx, repo_root, repo_key, storage_root):
|
|
|
840
839
|
# same kind; that's fine — the write layer upserts.
|
|
841
840
|
SESSION_PAYLOAD_MAP = [
|
|
842
841
|
("overview_summary", "overview", None),
|
|
843
|
-
("implementation_notes", "progress", None),
|
|
844
|
-
("changes", "progress", None),
|
|
845
|
-
("next_steps", "next", None),
|
|
846
842
|
("risks", "risk", None),
|
|
847
843
|
("memory", "glossary", None),
|
|
848
844
|
("context_updates", "glossary", None),
|
|
@@ -854,10 +850,7 @@ SESSION_PAYLOAD_MAP = [
|
|
|
854
850
|
("source_updates", "shared-source", None),
|
|
855
851
|
]
|
|
856
852
|
|
|
857
|
-
SESSION_DIRECT_PAYLOAD_MAP = [
|
|
858
|
-
("progress", "progress"),
|
|
859
|
-
("next", "next"),
|
|
860
|
-
]
|
|
853
|
+
SESSION_DIRECT_PAYLOAD_MAP = []
|
|
861
854
|
|
|
862
855
|
SESSION_EXTRA_PAYLOAD_MAP = [
|
|
863
856
|
("glossary", "glossary", None),
|
|
@@ -1550,8 +1543,6 @@ def command_apply_summary_output(args):
|
|
|
1550
1543
|
|
|
1551
1544
|
# Convenience summary-output fields. These mirror --summary-json but keep
|
|
1552
1545
|
# 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
1546
|
for item in payload.get("decisions") or []:
|
|
1556
1547
|
add_write("decision", _decision_content(item), source="decisions")
|
|
1557
1548
|
for item in payload.get("risks") or []:
|
|
@@ -1565,6 +1556,21 @@ def command_apply_summary_output(args):
|
|
|
1565
1556
|
for item in payload.get("shared_sources") or []:
|
|
1566
1557
|
add_write("shared-source", item, source="shared_sources")
|
|
1567
1558
|
|
|
1559
|
+
file_map = payload.get("file_map")
|
|
1560
|
+
if isinstance(file_map, list) and file_map:
|
|
1561
|
+
lines = []
|
|
1562
|
+
for entry in file_map:
|
|
1563
|
+
if not isinstance(entry, dict):
|
|
1564
|
+
continue
|
|
1565
|
+
label = entry.get("label", "").strip()
|
|
1566
|
+
paths_val = entry.get("paths") or ([entry["path"]] if entry.get("path") else [])
|
|
1567
|
+
if not label or not paths_val:
|
|
1568
|
+
continue
|
|
1569
|
+
joined = ", ".join(f"`{p}`" for p in paths_val)
|
|
1570
|
+
lines.append(f"- {label}: {joined}")
|
|
1571
|
+
if lines:
|
|
1572
|
+
add_write("filemap", "\n".join(lines), source="file_map")
|
|
1573
|
+
|
|
1568
1574
|
# Explicit patch operations.
|
|
1569
1575
|
for item in payload.get("upserts") or []:
|
|
1570
1576
|
add_write(item.get("kind"), item.get("content"), mode_override="upsert", source="upserts")
|
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
|
+
("glossary", "分支源资料入口"),
|
|
345
|
+
("glossary", "当前有效上下文"),
|
|
346
|
+
("progress", "功能文件索引"),
|
|
323
347
|
("progress", "建议优先查看的目录"),
|
|
324
348
|
("overview", "当前目标"),
|
|
325
349
|
("overview", "范围边界"),
|
|
326
|
-
("overview", "当前阶段"),
|
|
327
350
|
("overview", "关键约束"),
|
|
328
|
-
("progress", "当前进展"),
|
|
329
351
|
("risks", "阻塞与注意点"),
|
|
330
|
-
("progress", "下一步"),
|
|
331
|
-
("glossary", "当前有效上下文"),
|
|
332
352
|
("decisions", "关键决策与原因"),
|
|
333
353
|
("risks", "后续继续前要注意"),
|
|
334
|
-
("repo_overview",
|
|
335
|
-
("
|
|
336
|
-
("
|
|
337
|
-
("repo_glossary", "共享入口"),
|
|
354
|
+
("repo_overview", None),
|
|
355
|
+
("repo_decisions", None),
|
|
356
|
+
("repo_glossary", None),
|
|
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,41 @@ _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
|
+
_REPO_MAX_LINES = 32
|
|
393
|
+
_REPO_MAX_CHARS = 3000
|
|
394
|
+
|
|
359
395
|
|
|
360
396
|
def _extract_sections(paths, keys):
|
|
361
397
|
out = []
|
|
362
398
|
for file_key, title in keys:
|
|
363
|
-
|
|
364
|
-
|
|
399
|
+
if title is None:
|
|
400
|
+
body = extract_repo_file_body(paths[file_key])
|
|
401
|
+
display_title = _REPO_DISPLAY_TITLES.get(file_key, paths[file_key].stem)
|
|
402
|
+
else:
|
|
403
|
+
body = extract_section(paths[file_key], title)
|
|
404
|
+
display_title = title
|
|
405
|
+
out.append((display_title, body, file_key))
|
|
365
406
|
return out
|
|
366
407
|
|
|
367
408
|
|
|
@@ -399,11 +440,20 @@ def _build_context_from_assets(assets, *, full=True, heading=None):
|
|
|
399
440
|
for title, body, file_key in sections:
|
|
400
441
|
if not body:
|
|
401
442
|
continue
|
|
443
|
+
if full and file_key in _REPO_LEVEL_KEYS:
|
|
444
|
+
eff_lines, eff_chars = _REPO_MAX_LINES, _REPO_MAX_CHARS
|
|
445
|
+
elif full and (file_key, title) in _BRANCH_REFERENCE_SECTIONS:
|
|
446
|
+
eff_lines, eff_chars = _BRANCH_REF_MAX_LINES, _BRANCH_REF_MAX_CHARS
|
|
447
|
+
else:
|
|
448
|
+
eff_lines, eff_chars = max_lines, max_chars
|
|
402
449
|
if (file_key, title) in _RECENT_FIRST_SECTIONS:
|
|
403
|
-
compacted, truncated = compact_recent_body(body, max_lines=
|
|
450
|
+
compacted, truncated = compact_recent_body(body, max_lines=eff_lines, max_chars=eff_chars)
|
|
404
451
|
else:
|
|
405
|
-
compacted, truncated = compact_body(body, max_lines=
|
|
406
|
-
|
|
452
|
+
compacted, truncated = compact_body(body, max_lines=eff_lines, max_chars=eff_chars)
|
|
453
|
+
if full and file_key in _REPO_LEVEL_KEYS:
|
|
454
|
+
block = compacted
|
|
455
|
+
else:
|
|
456
|
+
block = f"{title}:\n{compacted}"
|
|
407
457
|
if truncated:
|
|
408
458
|
file_path = paths.get(file_key)
|
|
409
459
|
if file_path is not None:
|
|
@@ -825,31 +875,39 @@ transcript 过滤(extract-core 已执行;这里是核对规则):
|
|
|
825
875
|
|
|
826
876
|
写入原则:
|
|
827
877
|
- 先结合现有记忆判断每条信息是新增、改写、删除/归档还是跳过。
|
|
828
|
-
-
|
|
878
|
+
- 已完成且不再影响后续工作的状态,不要追加——通过 rewrite-entry/tidy 删除旧条目。
|
|
829
879
|
- 旧结论失效时优先 rewrite-entry 或 tidy 删除,不要追加一条相反结论让两条并存。
|
|
830
|
-
-
|
|
880
|
+
- decision / risk / glossary 是累计条目,注意避免重复。
|
|
831
881
|
- 只在确有新增或更新时写入。没有有效新增时只输出 `skip_reason`,代码会把 job 标记为 skipped。
|
|
882
|
+
- 不要写”当前进展”、”下一步”、”当前阶段”等时效性状态——这些天然会过期;记忆应聚焦稳定的决策、约束、上下文和参考信息。
|
|
883
|
+
|
|
884
|
+
file_map(功能文件索引):
|
|
885
|
+
- 用途:让后续 agent 听到”改下操作弹窗”就能直接定位文件,不用搜索。
|
|
886
|
+
- 每条 label 是业务语义名(页面/弹窗/侧拉/组件/表单等),paths 是对应文件的仓库相对路径。
|
|
887
|
+
- 粒度到组件/功能即可,不要写具体逻辑,agent 定位到文件后自己读。
|
|
888
|
+
- 增量 merge:读 existing_memory 里已有的功能文件索引,结合本次会话改动判断——新涉及的组件加入、已有条目路径变了就更新、描述不准的修正、分支里已删除的组件移除。
|
|
889
|
+
- 输出合并后的**完整映射**(不是增量 diff),代码侧直接覆盖整个 section。
|
|
890
|
+
- 没有文件变更或映射无变化时省略此字段。
|
|
832
891
|
|
|
833
892
|
输出要求:
|
|
834
893
|
- 只输出一个 summary-output JSON 对象,不要输出 markdown fence、解释文字或命令。
|
|
835
894
|
- summary-output 格式:
|
|
836
895
|
{{
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
"skip_reason": "没有新增有效内容"
|
|
896
|
+
“title”: “简短标题”,
|
|
897
|
+
“file_map”: [{{“label”: “功能/组件名”, “paths”: [“相对路径”]}}],
|
|
898
|
+
“decisions”: [{{“summary”: “结论”, “reason”: “为什么”, “impact”: “影响范围”}}],
|
|
899
|
+
“risks”: [“风险/坑/阻塞”],
|
|
900
|
+
“glossary”: [“术语/上下文/命令/外部系统入口”],
|
|
901
|
+
“shared_decisions”: [{{“summary”: “跨分支规则”, “reason”: “为什么”, “impact”: “适用范围”}}],
|
|
902
|
+
“shared_context”: [“仓库级长期背景”],
|
|
903
|
+
“shared_sources”: [“仓库级共享入口”],
|
|
904
|
+
“upserts”: [{{“kind”: “overview”, “content”: “覆盖某个 kind”}}],
|
|
905
|
+
“appends”: [{{“kind”: “decision”, “content”: “追加某个 kind”}}],
|
|
906
|
+
“rewrites”: [{{“id”: “decisions::0::2”, “content”: “新条目”, “reason”: “旧结论失效”}}],
|
|
907
|
+
“deletes”: [{{“id”: “risks::0::1”, “reason”: “风险已解除”}}],
|
|
908
|
+
“skip_reason”: “没有新增有效内容”
|
|
851
909
|
}}
|
|
852
|
-
-
|
|
910
|
+
- 字段可省略;不要输出空字段。
|
|
853
911
|
- 发现旧条目需要改写/删除时,不要追加矛盾条目。优先在 summary-output 的 rewrites/deletes 中表达。
|
|
854
912
|
- 不要调用任何 dev-memory-cli 命令;代码会校验 JSON、落盘、处理 dedup,并移动 job。
|
|
855
913
|
|
|
@@ -860,13 +918,57 @@ SUMMARY_INPUT_JSON:
|
|
|
860
918
|
"""
|
|
861
919
|
|
|
862
920
|
|
|
921
|
+
def _is_pid_alive(pid):
|
|
922
|
+
try:
|
|
923
|
+
os.kill(pid, 0)
|
|
924
|
+
return True
|
|
925
|
+
except (OSError, ProcessLookupError):
|
|
926
|
+
return False
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _check_worker_lock(queue_dir, job_id):
|
|
930
|
+
"""Return True if a live worker already holds the lock for this job."""
|
|
931
|
+
lock_path = Path(queue_dir) / "locks" / f"{job_id}.lock"
|
|
932
|
+
if not lock_path.exists():
|
|
933
|
+
return False
|
|
934
|
+
try:
|
|
935
|
+
content = lock_path.read_text(encoding="utf-8").strip()
|
|
936
|
+
pid = int(content)
|
|
937
|
+
if _is_pid_alive(pid):
|
|
938
|
+
return True
|
|
939
|
+
lock_path.unlink(missing_ok=True)
|
|
940
|
+
except (ValueError, OSError):
|
|
941
|
+
lock_path.unlink(missing_ok=True)
|
|
942
|
+
return False
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def _is_job_already_terminal(queue_dir, job_id):
|
|
946
|
+
"""Return the terminal state if this job is already done/skipped, else None."""
|
|
947
|
+
for state in ("done", "skipped"):
|
|
948
|
+
if (Path(queue_dir) / state / f"{job_id}.json").exists():
|
|
949
|
+
return state
|
|
950
|
+
return None
|
|
951
|
+
|
|
952
|
+
|
|
863
953
|
def maybe_start_summary_agent(job_path, queue_dir=None, job_id=None):
|
|
864
954
|
if os.environ.get("DEV_MEMORY_DISABLE_SESSION_SUMMARY_AGENT", "").strip():
|
|
865
955
|
return None
|
|
866
956
|
command = session_summary_command()
|
|
867
957
|
if not command:
|
|
868
958
|
return None
|
|
869
|
-
|
|
959
|
+
effective_queue_dir = queue_dir or Path(job_path).parent.parent
|
|
960
|
+
effective_job_id = job_id or Path(job_path).stem
|
|
961
|
+
|
|
962
|
+
terminal = _is_job_already_terminal(effective_queue_dir, effective_job_id)
|
|
963
|
+
if terminal:
|
|
964
|
+
log(f"[dev-memory] summary agent skipped: job {effective_job_id} already {terminal}")
|
|
965
|
+
return None
|
|
966
|
+
|
|
967
|
+
if _check_worker_lock(effective_queue_dir, effective_job_id):
|
|
968
|
+
log(f"[dev-memory] summary agent skipped: worker already running for {effective_job_id}")
|
|
969
|
+
return None
|
|
970
|
+
|
|
971
|
+
summary_session_id = f"dev-memory-summary-{effective_job_id}"
|
|
870
972
|
log_path = None
|
|
871
973
|
if queue_dir is not None and job_id:
|
|
872
974
|
runs_dir = Path(queue_dir) / "runs"
|
|
@@ -879,9 +981,9 @@ def maybe_start_summary_agent(job_path, queue_dir=None, job_id=None):
|
|
|
879
981
|
"--job",
|
|
880
982
|
str(job_path),
|
|
881
983
|
"--queue-dir",
|
|
882
|
-
str(
|
|
984
|
+
str(effective_queue_dir),
|
|
883
985
|
"--job-id",
|
|
884
|
-
str(
|
|
986
|
+
str(effective_job_id),
|
|
885
987
|
"--agent-command",
|
|
886
988
|
command,
|
|
887
989
|
"--summary-session-id",
|