dev-memory-cli 0.18.2 → 0.18.3

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)
@@ -154,6 +220,71 @@ def compact_body(text, max_lines=8, max_chars=700):
154
220
  return compacted, truncated
155
221
 
156
222
 
223
+ def _split_recent_blocks(text):
224
+ """Split accumulated markdown into entries and return newest first.
225
+
226
+ Capture writes append-mode entries separated by blank lines. Older v2 files
227
+ may only be plain bullet lists, so fall back to top-level bullet boundaries.
228
+ """
229
+ normalized = "\n".join(line.rstrip() for line in text.splitlines()).strip()
230
+ if not normalized:
231
+ return []
232
+
233
+ paragraph_blocks = [
234
+ block.strip()
235
+ for block in re.split(r"\n\s*\n", normalized)
236
+ if block.strip()
237
+ ]
238
+ if len(paragraph_blocks) > 1:
239
+ return list(reversed(paragraph_blocks))
240
+
241
+ blocks = []
242
+ current = []
243
+ for line in normalized.splitlines():
244
+ if re.match(r"^\s*[-*]\s+", line) and current:
245
+ blocks.append("\n".join(current).strip())
246
+ current = [line]
247
+ else:
248
+ current.append(line)
249
+ if current:
250
+ blocks.append("\n".join(current).strip())
251
+ return list(reversed(blocks))
252
+
253
+
254
+ def compact_recent_body(text, max_lines=8, max_chars=700):
255
+ blocks = _split_recent_blocks(text)
256
+ if not blocks:
257
+ return compact_body(text, max_lines=max_lines, max_chars=max_chars)
258
+
259
+ selected = []
260
+ selected_lines = 0
261
+ truncated = False
262
+ for block in blocks:
263
+ block_lines = [line for line in block.splitlines() if line.strip()]
264
+ if not block_lines:
265
+ continue
266
+ if selected and selected_lines + len(block_lines) > max_lines:
267
+ truncated = True
268
+ break
269
+ if not selected and len(block_lines) > max_lines:
270
+ selected.append("\n".join(block_lines[:max_lines]))
271
+ selected_lines = max_lines
272
+ truncated = True
273
+ break
274
+ selected.append(block)
275
+ selected_lines += len(block_lines)
276
+
277
+ compacted = "\n\n".join(selected).strip()
278
+ if len(blocks) > len(selected):
279
+ truncated = True
280
+ if len(compacted) > max_chars:
281
+ compacted = compacted[: max_chars - 3].rstrip() + "..."
282
+ truncated = True
283
+ elif truncated and compacted and not compacted.endswith("..."):
284
+ compacted += "\n..."
285
+ return compacted, truncated
286
+
287
+
157
288
  def sync_context_for(repo_root):
158
289
  return json.loads(
159
290
  run_python(CONTEXT_SCRIPT, "sync", "--repo", str(repo_root), cwd=str(repo_root))
@@ -189,11 +320,11 @@ def maybe_record_head():
189
320
  # "后续继续前要注意"; decisions.md carries "关键决策与原因"; glossary.md carries
190
321
  # "当前有效上下文".
191
322
  _FULL_SECTION_KEYS = (
323
+ ("progress", "建议优先查看的目录"),
192
324
  ("overview", "当前目标"),
193
325
  ("overview", "范围边界"),
194
326
  ("overview", "当前阶段"),
195
327
  ("overview", "关键约束"),
196
- ("progress", "建议优先查看的目录"),
197
328
  ("progress", "当前进展"),
198
329
  ("risks", "阻塞与注意点"),
199
330
  ("progress", "下一步"),
@@ -202,6 +333,7 @@ _FULL_SECTION_KEYS = (
202
333
  ("risks", "后续继续前要注意"),
203
334
  ("repo_overview", "长期目标与边界"),
204
335
  ("repo_overview", "仓库级关键约束"),
336
+ ("repo_decisions", "跨分支通用决策"),
205
337
  ("repo_glossary", "共享入口"),
206
338
  )
207
339
 
@@ -213,6 +345,18 @@ _BRIEF_SECTION_KEYS = (
213
345
  )
214
346
 
215
347
 
348
+ _RECENT_FIRST_SECTIONS = {
349
+ ("decisions", "关键决策与原因"),
350
+ ("risks", "阻塞与注意点"),
351
+ ("risks", "后续继续前要注意"),
352
+ ("glossary", "当前有效上下文"),
353
+ ("glossary", "分支源资料入口"),
354
+ ("repo_decisions", "跨分支通用决策"),
355
+ ("repo_glossary", "长期有效背景"),
356
+ ("repo_glossary", "共享入口"),
357
+ }
358
+
359
+
216
360
  def _extract_sections(paths, keys):
217
361
  out = []
218
362
  for file_key, title in keys:
@@ -255,7 +399,10 @@ def _build_context_from_assets(assets, *, full=True, heading=None):
255
399
  for title, body, file_key in sections:
256
400
  if not body:
257
401
  continue
258
- compacted, truncated = compact_body(body, max_lines=max_lines, max_chars=max_chars)
402
+ if (file_key, title) in _RECENT_FIRST_SECTIONS:
403
+ compacted, truncated = compact_recent_body(body, max_lines=max_lines, max_chars=max_chars)
404
+ else:
405
+ compacted, truncated = compact_body(body, max_lines=max_lines, max_chars=max_chars)
259
406
  block = f"{title}:\n{compacted}"
260
407
  if truncated:
261
408
  file_path = paths.get(file_key)
@@ -436,6 +583,412 @@ def record_head_all_repos():
436
583
  return results
437
584
 
438
585
 
586
+ def _first_string(*values):
587
+ for value in values:
588
+ if isinstance(value, str) and value.strip():
589
+ return value.strip()
590
+ return None
591
+
592
+
593
+ def _hook_payload_value(payload, *keys):
594
+ current = payload if isinstance(payload, dict) else {}
595
+ for key in keys:
596
+ if not isinstance(current, dict):
597
+ return None
598
+ current = current.get(key)
599
+ return current
600
+
601
+
602
+ def _transcript_hints(transcript_path):
603
+ fmt = "unknown"
604
+ path = transcript_path or ""
605
+ if "/.claude/" in path:
606
+ fmt = "claude-jsonl"
607
+ elif "/.codex/" in path:
608
+ fmt = "codex-jsonl"
609
+ return {
610
+ "format": fmt,
611
+ "core_records": (
612
+ [
613
+ "top-level type=user",
614
+ "top-level type=assistant",
615
+ "assistant message text blocks; skip tool_use/tool_result blocks",
616
+ ]
617
+ if fmt == "claude-jsonl"
618
+ else [
619
+ "type=response_item payload.type=message role=user",
620
+ "type=response_item payload.type=message role=assistant",
621
+ "skip event_msg/reasoning/tool-call records by default",
622
+ ]
623
+ if fmt == "codex-jsonl"
624
+ else [
625
+ "prefer user/assistant message records",
626
+ "skip hook/tool/system records by default",
627
+ ]
628
+ ),
629
+ "tool_records": (
630
+ [
631
+ "top-level type=attachment",
632
+ "assistant content tool_use/tool_result",
633
+ "file-history-snapshot/system metadata",
634
+ ]
635
+ if fmt == "claude-jsonl"
636
+ else [
637
+ "type=event_msg",
638
+ "payload.type ending with tool/call/search/status",
639
+ "reasoning records",
640
+ ]
641
+ if fmt == "codex-jsonl"
642
+ else ["attachments", "tool calls", "system metadata"]
643
+ ),
644
+ }
645
+
646
+
647
+ def _session_job_id(repo_key, branch_name, hook_input):
648
+ transcript_path = _first_string(
649
+ hook_input.get("transcript_path"),
650
+ hook_input.get("transcriptPath"),
651
+ _hook_payload_value(hook_input, "payload", "transcript_path"),
652
+ _hook_payload_value(hook_input, "payload", "transcriptPath"),
653
+ )
654
+ session_id = _first_string(
655
+ hook_input.get("session_id"),
656
+ hook_input.get("sessionId"),
657
+ _hook_payload_value(hook_input, "payload", "session_id"),
658
+ _hook_payload_value(hook_input, "payload", "sessionId"),
659
+ )
660
+ source = session_id or transcript_path or f"unknown-{time.time_ns()}"
661
+ digest = hashlib.sha1(
662
+ f"{repo_key}|{branch_name}|{source}".encode("utf-8")
663
+ ).hexdigest()[:16]
664
+ return digest, session_id, transcript_path
665
+
666
+
667
+ def _atomic_write_json(path, payload):
668
+ path.parent.mkdir(parents=True, exist_ok=True)
669
+ tmp = path.with_name(f".{path.name}.tmp.{os.getpid()}.{time.time_ns()}")
670
+ tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
671
+ os.replace(tmp, path)
672
+
673
+
674
+ def _append_queue_event(queue_dir, event):
675
+ queue_dir.mkdir(parents=True, exist_ok=True)
676
+ events_path = queue_dir / "events.jsonl"
677
+ with events_path.open("a", encoding="utf-8") as f:
678
+ f.write(json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n")
679
+
680
+
681
+ def _load_prior_summary_job(queue_dir, job_id):
682
+ for state in ("pending", "done", "skipped", "failed"):
683
+ path = queue_dir / state / f"{job_id}.json"
684
+ if not path.exists():
685
+ continue
686
+ try:
687
+ data = json.loads(path.read_text(encoding="utf-8"))
688
+ if isinstance(data, dict):
689
+ data["_path"] = str(path)
690
+ data["_state"] = state
691
+ return data
692
+ except Exception:
693
+ return {"_path": str(path), "_state": state}
694
+ return {}
695
+
696
+
697
+ def hook_session_id(hook_input):
698
+ return _first_string(
699
+ hook_input.get("session_id") if isinstance(hook_input, dict) else None,
700
+ hook_input.get("sessionId") if isinstance(hook_input, dict) else None,
701
+ _hook_payload_value(hook_input, "payload", "session_id"),
702
+ _hook_payload_value(hook_input, "payload", "sessionId"),
703
+ )
704
+
705
+
706
+ def _hook_transcript_path(hook_input):
707
+ return _first_string(
708
+ hook_input.get("transcript_path") if isinstance(hook_input, dict) else None,
709
+ hook_input.get("transcriptPath") if isinstance(hook_input, dict) else None,
710
+ _hook_payload_value(hook_input, "payload", "transcript_path"),
711
+ _hook_payload_value(hook_input, "payload", "transcriptPath"),
712
+ )
713
+
714
+
715
+ def _session_start_source(hook_input):
716
+ return hook_session_id(hook_input) or _hook_transcript_path(hook_input)
717
+
718
+
719
+ def _session_start_marker_path(assets, source):
720
+ branch_key = assets.get("branch_key") or assets.get("branch_name") or "no-branch"
721
+ repo_key = assets.get("repo_key") or Path(assets["repo_dir"]).name
722
+ digest = hashlib.sha1(
723
+ f"{repo_key}|{branch_key}|{source}".encode("utf-8")
724
+ ).hexdigest()[:16]
725
+ return Path(assets["repo_dir"]) / "jobs" / "session-start" / "injected" / f"{digest}.json"
726
+
727
+
728
+ def session_start_already_injected(assets, hook_input):
729
+ source = _session_start_source(hook_input)
730
+ if not source:
731
+ return False
732
+ return _session_start_marker_path(assets, source).exists()
733
+
734
+
735
+ def record_session_start_injected(assets, hook_input):
736
+ source = _session_start_source(hook_input)
737
+ if not source:
738
+ return None
739
+ marker_path = _session_start_marker_path(assets, source)
740
+ payload = {
741
+ "schema_version": 1,
742
+ "event": "SessionStart",
743
+ "repo_root": str(assets.get("repo_root")),
744
+ "repo_key": assets.get("repo_key"),
745
+ "branch": assets.get("branch_name"),
746
+ "branch_key": assets.get("branch_key"),
747
+ "session_id": hook_session_id(hook_input),
748
+ "transcript_path": _hook_transcript_path(hook_input),
749
+ "injected_at": now_iso(),
750
+ }
751
+ _atomic_write_json(marker_path, payload)
752
+ return marker_path
753
+
754
+
755
+ def _transcript_state(transcript_path):
756
+ if not transcript_path:
757
+ return None
758
+ path = Path(transcript_path).expanduser()
759
+ try:
760
+ stat = path.stat()
761
+ except OSError:
762
+ return {"path": str(path), "exists": False}
763
+ return {
764
+ "path": str(path),
765
+ "exists": True,
766
+ "size": stat.st_size,
767
+ "mtime_ms": int(stat.st_mtime * 1000),
768
+ }
769
+
770
+
771
+ def _int_env(name, default):
772
+ value = os.environ.get(name, "").strip()
773
+ if not value:
774
+ return default
775
+ try:
776
+ return int(value)
777
+ except ValueError:
778
+ return default
779
+
780
+
781
+ def build_summary_input(job_path):
782
+ job = json.loads(Path(job_path).read_text(encoding="utf-8"))
783
+ return extract_core_payload(
784
+ job,
785
+ max_messages=_int_env("DEV_MEMORY_SESSION_SUMMARY_MAX_MESSAGES", 30),
786
+ max_message_chars=_int_env("DEV_MEMORY_SESSION_SUMMARY_MAX_MESSAGE_CHARS", 1600),
787
+ max_memory_chars=_int_env("DEV_MEMORY_SESSION_SUMMARY_MAX_MEMORY_CHARS", 4000),
788
+ )
789
+
790
+
791
+ def _write_summary_input(queue_dir, job_id, summary_input):
792
+ inputs_dir = Path(queue_dir) / "inputs"
793
+ inputs_dir.mkdir(parents=True, exist_ok=True)
794
+ stamp = re.sub(r"[^0-9A-Za-z_-]", "", now_iso())
795
+ path = inputs_dir / f"{job_id}-{stamp}.json"
796
+ _atomic_write_json(path, summary_input)
797
+ return path
798
+
799
+
800
+ def build_summary_prompt(job_path, summary_input=None, summary_input_path=None):
801
+ if summary_input is None:
802
+ summary_input = build_summary_input(job_path)
803
+ summary_input_json = json.dumps(summary_input, ensure_ascii=False, indent=2)
804
+ input_path_line = f"- summary input JSON: {summary_input_path}\n" if summary_input_path else ""
805
+ return f"""你是 dev-memory 的后台会话总结 worker。
806
+
807
+ 输入:
808
+ - job JSON: {job_path}
809
+ {input_path_line}- 下方 `SUMMARY_INPUT_JSON` 是 hook 已经确定性提取和拼接好的材料。
810
+
811
+ 禁止事项:
812
+ - 不要调用 `dev-memory-cli summary extract-core`。
813
+ - 不要自己全量解析 transcript。
814
+ - 不要把工具调用流水账写入记忆。
815
+
816
+ 你只需要基于 `SUMMARY_INPUT_JSON` 中的 existing_memory 与 core_messages 判断要写什么:
817
+ - existing_memory 是现有 dev-memory 摘要,已读取 progress/risks/decisions/glossary/overview/repo shared 文件。
818
+ - core_messages 已过滤掉 hook/tool/system/reasoning,只保留核心 user/assistant 文本。
819
+ - 如果 job.previous_processed 存在,用 job.transcript_state.size/mtime_ms 和 previous_processed 的 cursor 判断增量,避免同一会话多次 resume/end 后重复全量总结。
820
+
821
+ transcript 过滤(extract-core 已执行;这里是核对规则):
822
+ - Claude jsonl:关注顶层 type=user / type=assistant 的文本消息;忽略 attachment、hook 输出、system、file-history-snapshot;assistant content 里忽略 tool_use/tool_result。
823
+ - Codex jsonl:关注 type=response_item 且 payload.type=message 且 role=user/assistant;忽略 event_msg、reasoning、tool/function call、hook/status/progress 事件。
824
+ - 工具调用细节通常不写入记忆,除非工具输出暴露了稳定结论、失败根因、重要命令或用户显式要求保留。
825
+
826
+ 写入原则:
827
+ - 先结合现有记忆判断每条信息是新增、改写、删除/归档还是跳过。
828
+ - 已完成且不再影响后续工作的状态,不要追加成“已完成 XXX”;应覆盖 progress/next,或通过 rewrite-entry/tidy 删除旧条目。
829
+ - 旧结论失效时优先 rewrite-entry 或 tidy 删除,不要追加一条相反结论让两条并存。
830
+ - progress / next 是当前态,用 upsert 语义;decision / risk / glossary 是累计条目,但也要避免重复。
831
+ - 只在确有新增或更新时写入。没有有效新增时只输出 `skip_reason`,代码会把 job 标记为 skipped。
832
+
833
+ 输出要求:
834
+ - 只输出一个 summary-output JSON 对象,不要输出 markdown fence、解释文字或命令。
835
+ - summary-output 格式:
836
+ {{
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": "没有新增有效内容"
851
+ }}
852
+ - 字段可省略;不要输出空字段。若只更新 progress/next,只传这两个字段即可。
853
+ - 发现旧条目需要改写/删除时,不要追加矛盾条目。优先在 summary-output 的 rewrites/deletes 中表达。
854
+ - 不要调用任何 dev-memory-cli 命令;代码会校验 JSON、落盘、处理 dedup,并移动 job。
855
+
856
+ SUMMARY_INPUT_JSON:
857
+ ```json
858
+ {summary_input_json}
859
+ ```
860
+ """
861
+
862
+
863
+ def maybe_start_summary_agent(job_path, queue_dir=None, job_id=None):
864
+ if os.environ.get("DEV_MEMORY_DISABLE_SESSION_SUMMARY_AGENT", "").strip():
865
+ return None
866
+ command = session_summary_command()
867
+ if not command:
868
+ return None
869
+ summary_session_id = f"dev-memory-summary-{job_id or Path(job_path).stem}"
870
+ log_path = None
871
+ if queue_dir is not None and job_id:
872
+ runs_dir = Path(queue_dir) / "runs"
873
+ runs_dir.mkdir(parents=True, exist_ok=True)
874
+ stamp = re.sub(r"[^0-9A-Za-z_-]", "", now_iso())
875
+ log_path = runs_dir / f"{job_id}-{stamp}.log"
876
+ args = [
877
+ "python3",
878
+ str(SUMMARY_WORKER_SCRIPT),
879
+ "--job",
880
+ str(job_path),
881
+ "--queue-dir",
882
+ str(queue_dir or Path(job_path).parent.parent),
883
+ "--job-id",
884
+ str(job_id or Path(job_path).stem),
885
+ "--agent-command",
886
+ command,
887
+ "--summary-session-id",
888
+ summary_session_id,
889
+ "--max-attempts",
890
+ session_summary_max_attempts(),
891
+ ]
892
+ stdout_target = open(log_path, "ab") if log_path else open(os.devnull, "wb")
893
+ with open(os.devnull, "rb") as stdin, stdout_target as stdout:
894
+ stdout.write(("[dev-memory] command: " + " ".join(shlex.quote(a) for a in args) + "\n\n").encode("utf-8"))
895
+ stdout.flush()
896
+ subprocess.Popen(
897
+ args,
898
+ cwd=str(REPO_ROOT),
899
+ stdin=stdin,
900
+ stdout=stdout,
901
+ stderr=stdout,
902
+ start_new_session=True,
903
+ )
904
+ return {
905
+ "command": command.split()[0] if command.split() else "summary-worker",
906
+ "log_path": str(log_path) if log_path else None,
907
+ "summary_session_id": summary_session_id,
908
+ }
909
+
910
+
911
+ def enqueue_session_summary_job(payload, hook_input, *, event_name="SessionEnd"):
912
+ """Queue a post-session summarization job and return immediately.
913
+
914
+ The queue is per repo, under <repo_dir>/jobs/session-summary. Job filenames
915
+ are stable for the same repo+branch+session so repeated hook firings update
916
+ the same pending job instead of producing conflicting work.
917
+ """
918
+ repo_dir = Path(payload["repo_dir"])
919
+ repo_key = payload.get("repo_key") or repo_dir.name
920
+ branch_name = payload.get("branch") or "unknown"
921
+ job_id, session_id, transcript_path = _session_job_id(repo_key, branch_name, hook_input)
922
+ queue_dir = repo_dir / "jobs" / "session-summary"
923
+ pending_dir = queue_dir / "pending"
924
+ job_path = pending_dir / f"{job_id}.json"
925
+ now = now_iso()
926
+ prior = _load_prior_summary_job(queue_dir, job_id)
927
+ prior_processed = prior.get("processed") if isinstance(prior.get("processed"), dict) else None
928
+ job = {
929
+ "schema_version": 1,
930
+ "job_id": job_id,
931
+ "status": "pending",
932
+ "event": event_name,
933
+ "created_at": prior.get("created_at") or now,
934
+ "updated_at": now,
935
+ "attempts": prior.get("attempts", 0),
936
+ "repo_root": payload.get("repo_root"),
937
+ "repo_key": repo_key,
938
+ "branch": branch_name,
939
+ "storage_root": payload.get("storage_root"),
940
+ "repo_dir": str(repo_dir),
941
+ "branch_dir": payload.get("branch_dir"),
942
+ "last_seen_head": payload.get("last_seen_head"),
943
+ "session_id": session_id,
944
+ "transcript_path": transcript_path,
945
+ "transcript_state": _transcript_state(transcript_path),
946
+ "hook_input_keys": sorted(hook_input.keys()) if isinstance(hook_input, dict) else [],
947
+ "transcript_hints": _transcript_hints(transcript_path),
948
+ "previous_job": (
949
+ {
950
+ "state": prior.get("_state"),
951
+ "path": prior.get("_path"),
952
+ "updated_at": prior.get("updated_at"),
953
+ "processed": prior_processed,
954
+ }
955
+ if prior
956
+ else None
957
+ ),
958
+ "debounce": {
959
+ "stable_after_seconds": 10,
960
+ "same_session_updates_same_job": True,
961
+ "resume_end_updates_same_job": True,
962
+ },
963
+ }
964
+ _atomic_write_json(job_path, job)
965
+ started = None
966
+ try:
967
+ started = maybe_start_summary_agent(job_path, queue_dir=queue_dir, job_id=job_id)
968
+ except Exception as exc:
969
+ log(f"[dev-memory][{event_name}] summary agent launch skipped: {exc}")
970
+ _append_queue_event(queue_dir, {
971
+ "at": now,
972
+ "event": "queued",
973
+ "job_id": job_id,
974
+ "repo_key": repo_key,
975
+ "branch": branch_name,
976
+ "session_id": session_id,
977
+ "transcript_path": transcript_path,
978
+ "job_path": str(job_path),
979
+ "agent_started": started.get("command") if isinstance(started, dict) else started,
980
+ "agent_log": started.get("log_path") if isinstance(started, dict) else None,
981
+ "summary_session_id": started.get("summary_session_id") if isinstance(started, dict) else None,
982
+ })
983
+ return {
984
+ "job_id": job_id,
985
+ "job_path": str(job_path),
986
+ "agent_started": started.get("command") if isinstance(started, dict) else started,
987
+ "agent_log": started.get("log_path") if isinstance(started, dict) else None,
988
+ "summary_session_id": started.get("summary_session_id") if isinstance(started, dict) else None,
989
+ }
990
+
991
+
439
992
  def sync_working_tree_all_repos():
440
993
  """PreCompact hook helper for workspace mode. Iterates all repos."""
441
994
  results = []
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env python3
2
2
 
3
3
  from _common import (
4
+ enqueue_session_summary_job,
4
5
  is_no_git_mode,
5
6
  is_workspace_mode,
6
7
  log,
7
8
  maybe_record_head,
9
+ read_hook_input,
8
10
  record_head_all_repos,
9
11
  resolve_assets,
10
12
  )
@@ -12,6 +14,7 @@ from _common import (
12
14
 
13
15
  def main():
14
16
  try:
17
+ hook_input = read_hook_input()
15
18
  if is_no_git_mode():
16
19
  log("[dev-memory][SessionEnd] no-git mode: nothing to finalize (no HEAD)")
17
20
  return 0
@@ -19,6 +22,12 @@ def main():
19
22
  results = record_head_all_repos()
20
23
  if not results:
21
24
  log("[dev-memory][SessionEnd] workspace mode: no initialized repos finalized")
25
+ for _, payload in results:
26
+ try:
27
+ queued = enqueue_session_summary_job(payload, hook_input, event_name="SessionEnd")
28
+ log(f"[dev-memory][SessionEnd] queued summary job {queued['job_id']}")
29
+ except Exception as exc:
30
+ log(f"[dev-memory][SessionEnd] summary enqueue skipped: {exc}")
22
31
  return 0
23
32
  assets = resolve_assets()
24
33
  if not assets["branch_dir"].exists():
@@ -26,6 +35,11 @@ def main():
26
35
  return 0
27
36
  payload = maybe_record_head()
28
37
  log(f"[dev-memory][SessionEnd] finalized HEAD marker {payload['last_seen_head']} for {payload['branch']}")
38
+ try:
39
+ queued = enqueue_session_summary_job(payload, hook_input, event_name="SessionEnd")
40
+ log(f"[dev-memory][SessionEnd] queued summary job {queued['job_id']}")
41
+ except Exception as exc:
42
+ log(f"[dev-memory][SessionEnd] summary enqueue skipped: {exc}")
29
43
  except Exception as exc:
30
44
  log(f"[dev-memory][SessionEnd] skipped: {exc}")
31
45
  return 0