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 CHANGED
@@ -6,18 +6,19 @@
6
6
 
7
7
  ![套件总览](docs/diagrams/overview.png)
8
8
 
9
- ## v2 架构:4 个 Skill + branch CLI
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 起再取消 `dev-memory-context` 读取 skill —— SessionStart hook 注入末尾直接列出权威记忆文件的绝对路径,AI 需要详情时 Read 对应文件即可,不再需要 skill 中转。当前套件 4 skill(全部偏写入 / 流程 / 整理)。
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 等摘要 + 完整文件路径;后续直接 Read 文件,或跑 CLI `dev-memory-cli context show` / `context sync` paths / 重新刷新(命令保留,只是不再以 skill 形式暴露)。
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
  ![capture 写入流程](docs/diagrams/capture.png)
@@ -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 capture <record\|rewrite-entry\|delete-entry\|apply-summary-output\|show\|suggest-kind\|...>` | 统一写入入口(含 dedup、结构化 patch executor) |
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")
@@ -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
- "当前进展(no-git 模式)",
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("当前进展", [header] + progress_sections),
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. Files at repo root use
1342
- their own file path as the focus key instead of '.', everything else uses
1343
- the POSIX-style parent dir string."""
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 Path(path_str).as_posix()
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
- buckets = Counter(_initial_parent(p) for p in paths)
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
- return [k for k, _ in ranked[:limit]]
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
- new_buckets[_initial_parent(p)] += 1
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
- return [d for d, _ in selected[:limit]]
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
- else:
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())
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dev-memory-cli",
3
- "version": "0.18.3",
3
+ "version": "0.19.2",
4
4
  "description": "CLI for dev-memory hooks and repo-local setup (formerly @xluos/dev-assets-cli)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- ("repo_overview", "仓库级关键约束"),
336
- ("repo_decisions", "跨分支通用决策"),
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
- ("overview", "当前阶段"),
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
- body = extract_section(paths[file_key], title)
364
- out.append((title, body, file_key))
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=max_lines, max_chars=max_chars)
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=max_lines, max_chars=max_chars)
406
- block = f"{title}:\n{compacted}"
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
- - 已完成且不再影响后续工作的状态,不要追加成“已完成 XXX”;应覆盖 progress/next,或通过 rewrite-entry/tidy 删除旧条目。
878
+ - 已完成且不再影响后续工作的状态,不要追加——通过 rewrite-entry/tidy 删除旧条目。
829
879
  - 旧结论失效时优先 rewrite-entry 或 tidy 删除,不要追加一条相反结论让两条并存。
830
- - progress / next 是当前态,用 upsert 语义;decision / risk / glossary 是累计条目,但也要避免重复。
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
- "title": "简短标题",
838
- "progress": "当前进展,覆盖 progress.md 的当前进展 section",
839
- "next": "下一步,覆盖 progress.md 的下一步 section",
840
- "decisions": [{{"summary": "结论", "reason": "为什么", "impact": "影响范围"}}],
841
- "risks": ["风险/坑/阻塞"],
842
- "glossary": ["术语/上下文/命令/外部系统入口"],
843
- "shared_decisions": [{{"summary": "跨分支规则", "reason": "为什么", "impact": "适用范围"}}],
844
- "shared_context": ["仓库级长期背景"],
845
- "shared_sources": ["仓库级共享入口"],
846
- "upserts": [{{"kind": "progress", "content": "显式覆盖某个 kind"}}],
847
- "appends": [{{"kind": "decision", "content": "显式追加某个 kind"}}],
848
- "rewrites": [{{"id": "decisions::0::2", "content": "新条目", "reason": "旧结论失效"}}],
849
- "deletes": [{{"id": "risks::0::1", "reason": "风险已解除"}}],
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
- - 字段可省略;不要输出空字段。若只更新 progress/next,只传这两个字段即可。
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
- summary_session_id = f"dev-memory-summary-{job_id or Path(job_path).stem}"
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(queue_dir or Path(job_path).parent.parent),
984
+ str(effective_queue_dir),
883
985
  "--job-id",
884
- str(job_id or Path(job_path).stem),
986
+ str(effective_job_id),
885
987
  "--agent-command",
886
988
  command,
887
989
  "--summary-session-id",
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "suite_name": "dev-memory-skill-suite",
3
3
  "skills": [
4
+ "dev-memory-read",
4
5
  "dev-memory-setup",
5
6
  "dev-memory-capture",
6
7
  "dev-memory-graduate",