dev-memory-cli 0.18.2 → 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.
@@ -1,10 +1,13 @@
1
1
  #!/usr/bin/env python3
2
2
 
3
3
  import json
4
+ import hashlib
4
5
  import os
5
6
  import re
7
+ import shlex
6
8
  import subprocess
7
9
  import sys
10
+ import time
8
11
  from pathlib import Path
9
12
 
10
13
 
@@ -27,13 +30,17 @@ from dev_memory_common import (
27
30
  detect_workspace_mode,
28
31
  get_branch_paths,
29
32
  list_repos_in_workspace,
33
+ now_iso,
30
34
  )
35
+ from dev_memory_summary import extract_core_payload
31
36
 
32
37
 
33
38
  CONTEXT_SCRIPT = PACKAGE_ROOT / "lib" / "dev_memory_context.py"
34
39
  # v2: sync/update merged into capture. All auto-block refresh and
35
40
  # record-head calls now go through the capture script.
36
41
  CAPTURE_SCRIPT = PACKAGE_ROOT / "lib" / "dev_memory_capture.py"
42
+ SUMMARY_WORKER_SCRIPT = PACKAGE_ROOT / "scripts" / "hooks" / "session_summary_worker.py"
43
+ DEFAULT_CONFIG_PATH = Path(os.environ.get("DEV_MEMORY_CONFIG_PATH", "~/.dev-memory/config.json")).expanduser()
37
44
 
38
45
 
39
46
  def run_python(script_path, *args, cwd=None):
@@ -54,6 +61,65 @@ def log(message):
54
61
  print(message, file=sys.stderr)
55
62
 
56
63
 
64
+ def load_dev_memory_config():
65
+ try:
66
+ if not DEFAULT_CONFIG_PATH.exists():
67
+ return {}
68
+ data = json.loads(DEFAULT_CONFIG_PATH.read_text(encoding="utf-8"))
69
+ return data if isinstance(data, dict) else {}
70
+ except Exception:
71
+ return {}
72
+
73
+
74
+ def session_summary_config():
75
+ config = load_dev_memory_config()
76
+ section = config.get("session_summary")
77
+ return section if isinstance(section, dict) else {}
78
+
79
+
80
+ def session_summary_command():
81
+ # Env remains a deliberate override for one-off debugging, but hooks should
82
+ # normally use ~/.dev-memory/config.json so hook templates stay portable.
83
+ env_command = os.environ.get("DEV_MEMORY_SESSION_SUMMARY_CMD", "").strip()
84
+ if env_command:
85
+ return env_command
86
+ command = session_summary_config().get("command")
87
+ return command.strip() if isinstance(command, str) else ""
88
+
89
+
90
+ def session_summary_max_attempts():
91
+ env_value = os.environ.get("DEV_MEMORY_SESSION_SUMMARY_MAX_ATTEMPTS", "").strip()
92
+ if env_value:
93
+ return env_value
94
+ value = session_summary_config().get("max_attempts", 3)
95
+ try:
96
+ return str(max(1, int(value)))
97
+ except Exception:
98
+ return "3"
99
+
100
+
101
+ def read_hook_input():
102
+ """Best-effort lifecycle hook input reader.
103
+
104
+ Claude/Codex pass hook metadata on stdin when running under their hook
105
+ runtime. Manual terminal invocations have a TTY stdin; don't block there.
106
+ """
107
+ try:
108
+ if sys.stdin is None or sys.stdin.closed or sys.stdin.isatty():
109
+ return {}
110
+ raw = sys.stdin.read()
111
+ except Exception:
112
+ return {}
113
+ raw = (raw or "").strip()
114
+ if not raw:
115
+ return {}
116
+ try:
117
+ payload = json.loads(raw)
118
+ return payload if isinstance(payload, dict) else {"raw": payload}
119
+ except Exception:
120
+ return {"raw": raw[:4000]}
121
+
122
+
57
123
  def resolve_assets_for(repo_root):
58
124
  """Resolve asset paths for an explicit repo root (workspace-mode friendly)."""
59
125
  repo_root_str = str(repo_root)
@@ -134,6 +200,27 @@ def extract_section(path, title):
134
200
  return None if is_placeholder(body) else body
135
201
 
136
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
+
137
224
  def compact_body(text, max_lines=8, max_chars=700):
138
225
  """Compact a section body. Returns (compacted_text, was_truncated). The
139
226
  caller uses `was_truncated` to decide whether to append a "see full file"
@@ -154,6 +241,71 @@ def compact_body(text, max_lines=8, max_chars=700):
154
241
  return compacted, truncated
155
242
 
156
243
 
244
+ def _split_recent_blocks(text):
245
+ """Split accumulated markdown into entries and return newest first.
246
+
247
+ Capture writes append-mode entries separated by blank lines. Older v2 files
248
+ may only be plain bullet lists, so fall back to top-level bullet boundaries.
249
+ """
250
+ normalized = "\n".join(line.rstrip() for line in text.splitlines()).strip()
251
+ if not normalized:
252
+ return []
253
+
254
+ paragraph_blocks = [
255
+ block.strip()
256
+ for block in re.split(r"\n\s*\n", normalized)
257
+ if block.strip()
258
+ ]
259
+ if len(paragraph_blocks) > 1:
260
+ return list(reversed(paragraph_blocks))
261
+
262
+ blocks = []
263
+ current = []
264
+ for line in normalized.splitlines():
265
+ if re.match(r"^\s*[-*]\s+", line) and current:
266
+ blocks.append("\n".join(current).strip())
267
+ current = [line]
268
+ else:
269
+ current.append(line)
270
+ if current:
271
+ blocks.append("\n".join(current).strip())
272
+ return list(reversed(blocks))
273
+
274
+
275
+ def compact_recent_body(text, max_lines=8, max_chars=700):
276
+ blocks = _split_recent_blocks(text)
277
+ if not blocks:
278
+ return compact_body(text, max_lines=max_lines, max_chars=max_chars)
279
+
280
+ selected = []
281
+ selected_lines = 0
282
+ truncated = False
283
+ for block in blocks:
284
+ block_lines = [line for line in block.splitlines() if line.strip()]
285
+ if not block_lines:
286
+ continue
287
+ if selected and selected_lines + len(block_lines) > max_lines:
288
+ truncated = True
289
+ break
290
+ if not selected and len(block_lines) > max_lines:
291
+ selected.append("\n".join(block_lines[:max_lines]))
292
+ selected_lines = max_lines
293
+ truncated = True
294
+ break
295
+ selected.append(block)
296
+ selected_lines += len(block_lines)
297
+
298
+ compacted = "\n\n".join(selected).strip()
299
+ if len(blocks) > len(selected):
300
+ truncated = True
301
+ if len(compacted) > max_chars:
302
+ compacted = compacted[: max_chars - 3].rstrip() + "..."
303
+ truncated = True
304
+ elif truncated and compacted and not compacted.endswith("..."):
305
+ compacted += "\n..."
306
+ return compacted, truncated
307
+
308
+
157
309
  def sync_context_for(repo_root):
158
310
  return json.loads(
159
311
  run_python(CONTEXT_SCRIPT, "sync", "--repo", str(repo_root), cwd=str(repo_root))
@@ -189,35 +341,68 @@ def maybe_record_head():
189
341
  # "后续继续前要注意"; decisions.md carries "关键决策与原因"; glossary.md carries
190
342
  # "当前有效上下文".
191
343
  _FULL_SECTION_KEYS = (
344
+ ("glossary", "分支源资料入口"),
345
+ ("glossary", "当前有效上下文"),
346
+ ("progress", "功能文件索引"),
347
+ ("progress", "建议优先查看的目录"),
192
348
  ("overview", "当前目标"),
193
349
  ("overview", "范围边界"),
194
- ("overview", "当前阶段"),
195
350
  ("overview", "关键约束"),
196
- ("progress", "建议优先查看的目录"),
197
- ("progress", "当前进展"),
198
351
  ("risks", "阻塞与注意点"),
199
- ("progress", "下一步"),
200
- ("glossary", "当前有效上下文"),
201
352
  ("decisions", "关键决策与原因"),
202
353
  ("risks", "后续继续前要注意"),
203
- ("repo_overview", "长期目标与边界"),
204
- ("repo_overview", "仓库级关键约束"),
205
- ("repo_glossary", "共享入口"),
354
+ ("repo_overview", None),
355
+ ("repo_decisions", None),
356
+ ("repo_glossary", None),
206
357
  )
207
358
 
208
359
  _BRIEF_SECTION_KEYS = (
209
360
  ("overview", "当前目标"),
210
- ("overview", "当前阶段"),
211
- ("progress", "当前进展"),
212
- ("progress", "下一步"),
361
+ ("glossary", "当前有效上下文"),
213
362
  )
214
363
 
215
364
 
365
+ _RECENT_FIRST_SECTIONS = {
366
+ ("decisions", "关键决策与原因"),
367
+ ("risks", "阻塞与注意点"),
368
+ ("risks", "后续继续前要注意"),
369
+ ("glossary", "当前有效上下文"),
370
+ ("glossary", "分支源资料入口"),
371
+ }
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
+
395
+
216
396
  def _extract_sections(paths, keys):
217
397
  out = []
218
398
  for file_key, title in keys:
219
- body = extract_section(paths[file_key], title)
220
- 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))
221
406
  return out
222
407
 
223
408
 
@@ -255,8 +440,20 @@ def _build_context_from_assets(assets, *, full=True, heading=None):
255
440
  for title, body, file_key in sections:
256
441
  if not body:
257
442
  continue
258
- compacted, truncated = compact_body(body, max_lines=max_lines, max_chars=max_chars)
259
- block = f"{title}:\n{compacted}"
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
449
+ if (file_key, title) in _RECENT_FIRST_SECTIONS:
450
+ compacted, truncated = compact_recent_body(body, max_lines=eff_lines, max_chars=eff_chars)
451
+ else:
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}"
260
457
  if truncated:
261
458
  file_path = paths.get(file_key)
262
459
  if file_path is not None:
@@ -436,6 +633,464 @@ def record_head_all_repos():
436
633
  return results
437
634
 
438
635
 
636
+ def _first_string(*values):
637
+ for value in values:
638
+ if isinstance(value, str) and value.strip():
639
+ return value.strip()
640
+ return None
641
+
642
+
643
+ def _hook_payload_value(payload, *keys):
644
+ current = payload if isinstance(payload, dict) else {}
645
+ for key in keys:
646
+ if not isinstance(current, dict):
647
+ return None
648
+ current = current.get(key)
649
+ return current
650
+
651
+
652
+ def _transcript_hints(transcript_path):
653
+ fmt = "unknown"
654
+ path = transcript_path or ""
655
+ if "/.claude/" in path:
656
+ fmt = "claude-jsonl"
657
+ elif "/.codex/" in path:
658
+ fmt = "codex-jsonl"
659
+ return {
660
+ "format": fmt,
661
+ "core_records": (
662
+ [
663
+ "top-level type=user",
664
+ "top-level type=assistant",
665
+ "assistant message text blocks; skip tool_use/tool_result blocks",
666
+ ]
667
+ if fmt == "claude-jsonl"
668
+ else [
669
+ "type=response_item payload.type=message role=user",
670
+ "type=response_item payload.type=message role=assistant",
671
+ "skip event_msg/reasoning/tool-call records by default",
672
+ ]
673
+ if fmt == "codex-jsonl"
674
+ else [
675
+ "prefer user/assistant message records",
676
+ "skip hook/tool/system records by default",
677
+ ]
678
+ ),
679
+ "tool_records": (
680
+ [
681
+ "top-level type=attachment",
682
+ "assistant content tool_use/tool_result",
683
+ "file-history-snapshot/system metadata",
684
+ ]
685
+ if fmt == "claude-jsonl"
686
+ else [
687
+ "type=event_msg",
688
+ "payload.type ending with tool/call/search/status",
689
+ "reasoning records",
690
+ ]
691
+ if fmt == "codex-jsonl"
692
+ else ["attachments", "tool calls", "system metadata"]
693
+ ),
694
+ }
695
+
696
+
697
+ def _session_job_id(repo_key, branch_name, hook_input):
698
+ transcript_path = _first_string(
699
+ hook_input.get("transcript_path"),
700
+ hook_input.get("transcriptPath"),
701
+ _hook_payload_value(hook_input, "payload", "transcript_path"),
702
+ _hook_payload_value(hook_input, "payload", "transcriptPath"),
703
+ )
704
+ session_id = _first_string(
705
+ hook_input.get("session_id"),
706
+ hook_input.get("sessionId"),
707
+ _hook_payload_value(hook_input, "payload", "session_id"),
708
+ _hook_payload_value(hook_input, "payload", "sessionId"),
709
+ )
710
+ source = session_id or transcript_path or f"unknown-{time.time_ns()}"
711
+ digest = hashlib.sha1(
712
+ f"{repo_key}|{branch_name}|{source}".encode("utf-8")
713
+ ).hexdigest()[:16]
714
+ return digest, session_id, transcript_path
715
+
716
+
717
+ def _atomic_write_json(path, payload):
718
+ path.parent.mkdir(parents=True, exist_ok=True)
719
+ tmp = path.with_name(f".{path.name}.tmp.{os.getpid()}.{time.time_ns()}")
720
+ tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
721
+ os.replace(tmp, path)
722
+
723
+
724
+ def _append_queue_event(queue_dir, event):
725
+ queue_dir.mkdir(parents=True, exist_ok=True)
726
+ events_path = queue_dir / "events.jsonl"
727
+ with events_path.open("a", encoding="utf-8") as f:
728
+ f.write(json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n")
729
+
730
+
731
+ def _load_prior_summary_job(queue_dir, job_id):
732
+ for state in ("pending", "done", "skipped", "failed"):
733
+ path = queue_dir / state / f"{job_id}.json"
734
+ if not path.exists():
735
+ continue
736
+ try:
737
+ data = json.loads(path.read_text(encoding="utf-8"))
738
+ if isinstance(data, dict):
739
+ data["_path"] = str(path)
740
+ data["_state"] = state
741
+ return data
742
+ except Exception:
743
+ return {"_path": str(path), "_state": state}
744
+ return {}
745
+
746
+
747
+ def hook_session_id(hook_input):
748
+ return _first_string(
749
+ hook_input.get("session_id") if isinstance(hook_input, dict) else None,
750
+ hook_input.get("sessionId") if isinstance(hook_input, dict) else None,
751
+ _hook_payload_value(hook_input, "payload", "session_id"),
752
+ _hook_payload_value(hook_input, "payload", "sessionId"),
753
+ )
754
+
755
+
756
+ def _hook_transcript_path(hook_input):
757
+ return _first_string(
758
+ hook_input.get("transcript_path") if isinstance(hook_input, dict) else None,
759
+ hook_input.get("transcriptPath") if isinstance(hook_input, dict) else None,
760
+ _hook_payload_value(hook_input, "payload", "transcript_path"),
761
+ _hook_payload_value(hook_input, "payload", "transcriptPath"),
762
+ )
763
+
764
+
765
+ def _session_start_source(hook_input):
766
+ return hook_session_id(hook_input) or _hook_transcript_path(hook_input)
767
+
768
+
769
+ def _session_start_marker_path(assets, source):
770
+ branch_key = assets.get("branch_key") or assets.get("branch_name") or "no-branch"
771
+ repo_key = assets.get("repo_key") or Path(assets["repo_dir"]).name
772
+ digest = hashlib.sha1(
773
+ f"{repo_key}|{branch_key}|{source}".encode("utf-8")
774
+ ).hexdigest()[:16]
775
+ return Path(assets["repo_dir"]) / "jobs" / "session-start" / "injected" / f"{digest}.json"
776
+
777
+
778
+ def session_start_already_injected(assets, hook_input):
779
+ source = _session_start_source(hook_input)
780
+ if not source:
781
+ return False
782
+ return _session_start_marker_path(assets, source).exists()
783
+
784
+
785
+ def record_session_start_injected(assets, hook_input):
786
+ source = _session_start_source(hook_input)
787
+ if not source:
788
+ return None
789
+ marker_path = _session_start_marker_path(assets, source)
790
+ payload = {
791
+ "schema_version": 1,
792
+ "event": "SessionStart",
793
+ "repo_root": str(assets.get("repo_root")),
794
+ "repo_key": assets.get("repo_key"),
795
+ "branch": assets.get("branch_name"),
796
+ "branch_key": assets.get("branch_key"),
797
+ "session_id": hook_session_id(hook_input),
798
+ "transcript_path": _hook_transcript_path(hook_input),
799
+ "injected_at": now_iso(),
800
+ }
801
+ _atomic_write_json(marker_path, payload)
802
+ return marker_path
803
+
804
+
805
+ def _transcript_state(transcript_path):
806
+ if not transcript_path:
807
+ return None
808
+ path = Path(transcript_path).expanduser()
809
+ try:
810
+ stat = path.stat()
811
+ except OSError:
812
+ return {"path": str(path), "exists": False}
813
+ return {
814
+ "path": str(path),
815
+ "exists": True,
816
+ "size": stat.st_size,
817
+ "mtime_ms": int(stat.st_mtime * 1000),
818
+ }
819
+
820
+
821
+ def _int_env(name, default):
822
+ value = os.environ.get(name, "").strip()
823
+ if not value:
824
+ return default
825
+ try:
826
+ return int(value)
827
+ except ValueError:
828
+ return default
829
+
830
+
831
+ def build_summary_input(job_path):
832
+ job = json.loads(Path(job_path).read_text(encoding="utf-8"))
833
+ return extract_core_payload(
834
+ job,
835
+ max_messages=_int_env("DEV_MEMORY_SESSION_SUMMARY_MAX_MESSAGES", 30),
836
+ max_message_chars=_int_env("DEV_MEMORY_SESSION_SUMMARY_MAX_MESSAGE_CHARS", 1600),
837
+ max_memory_chars=_int_env("DEV_MEMORY_SESSION_SUMMARY_MAX_MEMORY_CHARS", 4000),
838
+ )
839
+
840
+
841
+ def _write_summary_input(queue_dir, job_id, summary_input):
842
+ inputs_dir = Path(queue_dir) / "inputs"
843
+ inputs_dir.mkdir(parents=True, exist_ok=True)
844
+ stamp = re.sub(r"[^0-9A-Za-z_-]", "", now_iso())
845
+ path = inputs_dir / f"{job_id}-{stamp}.json"
846
+ _atomic_write_json(path, summary_input)
847
+ return path
848
+
849
+
850
+ def build_summary_prompt(job_path, summary_input=None, summary_input_path=None):
851
+ if summary_input is None:
852
+ summary_input = build_summary_input(job_path)
853
+ summary_input_json = json.dumps(summary_input, ensure_ascii=False, indent=2)
854
+ input_path_line = f"- summary input JSON: {summary_input_path}\n" if summary_input_path else ""
855
+ return f"""你是 dev-memory 的后台会话总结 worker。
856
+
857
+ 输入:
858
+ - job JSON: {job_path}
859
+ {input_path_line}- 下方 `SUMMARY_INPUT_JSON` 是 hook 已经确定性提取和拼接好的材料。
860
+
861
+ 禁止事项:
862
+ - 不要调用 `dev-memory-cli summary extract-core`。
863
+ - 不要自己全量解析 transcript。
864
+ - 不要把工具调用流水账写入记忆。
865
+
866
+ 你只需要基于 `SUMMARY_INPUT_JSON` 中的 existing_memory 与 core_messages 判断要写什么:
867
+ - existing_memory 是现有 dev-memory 摘要,已读取 progress/risks/decisions/glossary/overview/repo shared 文件。
868
+ - core_messages 已过滤掉 hook/tool/system/reasoning,只保留核心 user/assistant 文本。
869
+ - 如果 job.previous_processed 存在,用 job.transcript_state.size/mtime_ms 和 previous_processed 的 cursor 判断增量,避免同一会话多次 resume/end 后重复全量总结。
870
+
871
+ transcript 过滤(extract-core 已执行;这里是核对规则):
872
+ - Claude jsonl:关注顶层 type=user / type=assistant 的文本消息;忽略 attachment、hook 输出、system、file-history-snapshot;assistant content 里忽略 tool_use/tool_result。
873
+ - Codex jsonl:关注 type=response_item 且 payload.type=message 且 role=user/assistant;忽略 event_msg、reasoning、tool/function call、hook/status/progress 事件。
874
+ - 工具调用细节通常不写入记忆,除非工具输出暴露了稳定结论、失败根因、重要命令或用户显式要求保留。
875
+
876
+ 写入原则:
877
+ - 先结合现有记忆判断每条信息是新增、改写、删除/归档还是跳过。
878
+ - 已完成且不再影响后续工作的状态,不要追加——通过 rewrite-entry/tidy 删除旧条目。
879
+ - 旧结论失效时优先 rewrite-entry 或 tidy 删除,不要追加一条相反结论让两条并存。
880
+ - decision / risk / glossary 是累计条目,注意避免重复。
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
+ - 没有文件变更或映射无变化时省略此字段。
891
+
892
+ 输出要求:
893
+ - 只输出一个 summary-output JSON 对象,不要输出 markdown fence、解释文字或命令。
894
+ - summary-output 格式:
895
+ {{
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”: “没有新增有效内容”
909
+ }}
910
+ - 字段可省略;不要输出空字段。
911
+ - 发现旧条目需要改写/删除时,不要追加矛盾条目。优先在 summary-output 的 rewrites/deletes 中表达。
912
+ - 不要调用任何 dev-memory-cli 命令;代码会校验 JSON、落盘、处理 dedup,并移动 job。
913
+
914
+ SUMMARY_INPUT_JSON:
915
+ ```json
916
+ {summary_input_json}
917
+ ```
918
+ """
919
+
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
+
953
+ def maybe_start_summary_agent(job_path, queue_dir=None, job_id=None):
954
+ if os.environ.get("DEV_MEMORY_DISABLE_SESSION_SUMMARY_AGENT", "").strip():
955
+ return None
956
+ command = session_summary_command()
957
+ if not command:
958
+ return None
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}"
972
+ log_path = None
973
+ if queue_dir is not None and job_id:
974
+ runs_dir = Path(queue_dir) / "runs"
975
+ runs_dir.mkdir(parents=True, exist_ok=True)
976
+ stamp = re.sub(r"[^0-9A-Za-z_-]", "", now_iso())
977
+ log_path = runs_dir / f"{job_id}-{stamp}.log"
978
+ args = [
979
+ "python3",
980
+ str(SUMMARY_WORKER_SCRIPT),
981
+ "--job",
982
+ str(job_path),
983
+ "--queue-dir",
984
+ str(effective_queue_dir),
985
+ "--job-id",
986
+ str(effective_job_id),
987
+ "--agent-command",
988
+ command,
989
+ "--summary-session-id",
990
+ summary_session_id,
991
+ "--max-attempts",
992
+ session_summary_max_attempts(),
993
+ ]
994
+ stdout_target = open(log_path, "ab") if log_path else open(os.devnull, "wb")
995
+ with open(os.devnull, "rb") as stdin, stdout_target as stdout:
996
+ stdout.write(("[dev-memory] command: " + " ".join(shlex.quote(a) for a in args) + "\n\n").encode("utf-8"))
997
+ stdout.flush()
998
+ subprocess.Popen(
999
+ args,
1000
+ cwd=str(REPO_ROOT),
1001
+ stdin=stdin,
1002
+ stdout=stdout,
1003
+ stderr=stdout,
1004
+ start_new_session=True,
1005
+ )
1006
+ return {
1007
+ "command": command.split()[0] if command.split() else "summary-worker",
1008
+ "log_path": str(log_path) if log_path else None,
1009
+ "summary_session_id": summary_session_id,
1010
+ }
1011
+
1012
+
1013
+ def enqueue_session_summary_job(payload, hook_input, *, event_name="SessionEnd"):
1014
+ """Queue a post-session summarization job and return immediately.
1015
+
1016
+ The queue is per repo, under <repo_dir>/jobs/session-summary. Job filenames
1017
+ are stable for the same repo+branch+session so repeated hook firings update
1018
+ the same pending job instead of producing conflicting work.
1019
+ """
1020
+ repo_dir = Path(payload["repo_dir"])
1021
+ repo_key = payload.get("repo_key") or repo_dir.name
1022
+ branch_name = payload.get("branch") or "unknown"
1023
+ job_id, session_id, transcript_path = _session_job_id(repo_key, branch_name, hook_input)
1024
+ queue_dir = repo_dir / "jobs" / "session-summary"
1025
+ pending_dir = queue_dir / "pending"
1026
+ job_path = pending_dir / f"{job_id}.json"
1027
+ now = now_iso()
1028
+ prior = _load_prior_summary_job(queue_dir, job_id)
1029
+ prior_processed = prior.get("processed") if isinstance(prior.get("processed"), dict) else None
1030
+ job = {
1031
+ "schema_version": 1,
1032
+ "job_id": job_id,
1033
+ "status": "pending",
1034
+ "event": event_name,
1035
+ "created_at": prior.get("created_at") or now,
1036
+ "updated_at": now,
1037
+ "attempts": prior.get("attempts", 0),
1038
+ "repo_root": payload.get("repo_root"),
1039
+ "repo_key": repo_key,
1040
+ "branch": branch_name,
1041
+ "storage_root": payload.get("storage_root"),
1042
+ "repo_dir": str(repo_dir),
1043
+ "branch_dir": payload.get("branch_dir"),
1044
+ "last_seen_head": payload.get("last_seen_head"),
1045
+ "session_id": session_id,
1046
+ "transcript_path": transcript_path,
1047
+ "transcript_state": _transcript_state(transcript_path),
1048
+ "hook_input_keys": sorted(hook_input.keys()) if isinstance(hook_input, dict) else [],
1049
+ "transcript_hints": _transcript_hints(transcript_path),
1050
+ "previous_job": (
1051
+ {
1052
+ "state": prior.get("_state"),
1053
+ "path": prior.get("_path"),
1054
+ "updated_at": prior.get("updated_at"),
1055
+ "processed": prior_processed,
1056
+ }
1057
+ if prior
1058
+ else None
1059
+ ),
1060
+ "debounce": {
1061
+ "stable_after_seconds": 10,
1062
+ "same_session_updates_same_job": True,
1063
+ "resume_end_updates_same_job": True,
1064
+ },
1065
+ }
1066
+ _atomic_write_json(job_path, job)
1067
+ started = None
1068
+ try:
1069
+ started = maybe_start_summary_agent(job_path, queue_dir=queue_dir, job_id=job_id)
1070
+ except Exception as exc:
1071
+ log(f"[dev-memory][{event_name}] summary agent launch skipped: {exc}")
1072
+ _append_queue_event(queue_dir, {
1073
+ "at": now,
1074
+ "event": "queued",
1075
+ "job_id": job_id,
1076
+ "repo_key": repo_key,
1077
+ "branch": branch_name,
1078
+ "session_id": session_id,
1079
+ "transcript_path": transcript_path,
1080
+ "job_path": str(job_path),
1081
+ "agent_started": started.get("command") if isinstance(started, dict) else started,
1082
+ "agent_log": started.get("log_path") if isinstance(started, dict) else None,
1083
+ "summary_session_id": started.get("summary_session_id") if isinstance(started, dict) else None,
1084
+ })
1085
+ return {
1086
+ "job_id": job_id,
1087
+ "job_path": str(job_path),
1088
+ "agent_started": started.get("command") if isinstance(started, dict) else started,
1089
+ "agent_log": started.get("log_path") if isinstance(started, dict) else None,
1090
+ "summary_session_id": started.get("summary_session_id") if isinstance(started, dict) else None,
1091
+ }
1092
+
1093
+
439
1094
  def sync_working_tree_all_repos():
440
1095
  """PreCompact hook helper for workspace mode. Iterates all repos."""
441
1096
  results = []