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.
- package/README.md +45 -8
- package/bin/dev-memory.js +79 -1
- package/hooks/README.md +8 -2
- package/hooks/hooks.json +1 -1
- package/lib/dev_memory_capture.py +637 -84
- package/lib/dev_memory_common.py +129 -71
- package/lib/dev_memory_context.py +35 -1
- package/lib/dev_memory_graduate.py +83 -20
- package/lib/dev_memory_read.py +317 -0
- package/lib/dev_memory_setup.py +0 -2
- package/lib/dev_memory_summary.py +233 -0
- package/lib/ui-app.html +41 -0
- package/lib/ui-server.js +53 -0
- package/package.json +1 -1
- package/scripts/hooks/_common.py +670 -15
- package/scripts/hooks/session_end.py +14 -0
- package/scripts/hooks/session_start.py +17 -1
- package/suite-manifest.json +1 -0
package/scripts/hooks/_common.py
CHANGED
|
@@ -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
|
-
("
|
|
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
|
-
("
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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 = []
|