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.
- package/README.md +27 -5
- package/bin/dev-memory.js +77 -1
- package/hooks/README.md +8 -2
- package/hooks/hooks.json +1 -1
- package/lib/dev_memory_capture.py +625 -78
- package/lib/dev_memory_common.py +59 -58
- package/lib/dev_memory_graduate.py +83 -20
- package/lib/dev_memory_summary.py +233 -0
- package/package.json +1 -1
- package/scripts/hooks/_common.py +555 -2
- package/scripts/hooks/session_end.py +14 -0
- package/scripts/hooks/session_start.py +17 -1
package/lib/dev_memory_common.py
CHANGED
|
@@ -54,7 +54,7 @@ LEGACY_V1_FILES = ("development.md", "context.md", "sources.md")
|
|
|
54
54
|
# Bottom-up clustering of changed-file paths into a small set of "focus
|
|
55
55
|
# directories". The cluster never grows larger than this many entries; a
|
|
56
56
|
# higher number gives finer granularity at the cost of a longer hint list.
|
|
57
|
-
FOCUS_AREA_LIMIT =
|
|
57
|
+
FOCUS_AREA_LIMIT = 10
|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
def now_iso():
|
|
@@ -1339,10 +1339,11 @@ def summarize_scopes(paths):
|
|
|
1339
1339
|
|
|
1340
1340
|
def _initial_parent(path_str):
|
|
1341
1341
|
"""File path → its immediate parent directory key. Files at repo root use
|
|
1342
|
-
'.', everything else uses
|
|
1342
|
+
their own file path as the focus key instead of '.', everything else uses
|
|
1343
|
+
the POSIX-style parent dir string."""
|
|
1343
1344
|
parent = Path(path_str).parent
|
|
1344
1345
|
if str(parent) in ("", "."):
|
|
1345
|
-
return
|
|
1346
|
+
return Path(path_str).as_posix()
|
|
1346
1347
|
return parent.as_posix()
|
|
1347
1348
|
|
|
1348
1349
|
|
|
@@ -1368,8 +1369,8 @@ def summarize_focus_areas(paths, limit=None):
|
|
|
1368
1369
|
count (the "dominant cluster" — most files concentrated under a single
|
|
1369
1370
|
deeper subtree), and merge only those originals into it. Buckets that
|
|
1370
1371
|
were already at depth 1 or '.' are left untouched, so isolated shallow
|
|
1371
|
-
directories survive. Tie-break: deeper rolled-up key
|
|
1372
|
-
lexicographic.
|
|
1372
|
+
directories and root-level files survive. Tie-break: deeper rolled-up key
|
|
1373
|
+
wins, then lexicographic.
|
|
1373
1374
|
"""
|
|
1374
1375
|
if limit is None:
|
|
1375
1376
|
limit = FOCUS_AREA_LIMIT
|
|
@@ -1405,19 +1406,22 @@ def summarize_focus_areas(paths, limit=None):
|
|
|
1405
1406
|
RECENT_COMMITS_FOR_FOCUS = 10
|
|
1406
1407
|
|
|
1407
1408
|
|
|
1408
|
-
def _files_in_recent_commits(repo_root, count):
|
|
1409
|
-
"""Files touched by
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1409
|
+
def _files_in_recent_commits(repo_root, count, base_ref=None):
|
|
1410
|
+
"""Files touched by recent non-merge commits on the current branch.
|
|
1411
|
+
|
|
1412
|
+
When a default base is known, bound the range to ``base..HEAD`` so noisy
|
|
1413
|
+
commits already merged into the base branch do not pollute focus areas.
|
|
1414
|
+
"""
|
|
1415
|
+
cmd = [
|
|
1416
|
+
"log",
|
|
1417
|
+
f"-n{count}",
|
|
1418
|
+
"--no-merges",
|
|
1419
|
+
"--name-only",
|
|
1420
|
+
"--pretty=format:",
|
|
1421
|
+
]
|
|
1422
|
+
if base_ref:
|
|
1423
|
+
cmd.append(f"{base_ref}..HEAD")
|
|
1424
|
+
out = git_lines(cmd, cwd=repo_root, check=False)
|
|
1421
1425
|
seen = []
|
|
1422
1426
|
seen_set = set()
|
|
1423
1427
|
for line in out:
|
|
@@ -1432,9 +1436,8 @@ def collect_git_facts(repo_root, branch_name, _storage_root=None):
|
|
|
1432
1436
|
working_tree_files = git_lines(["diff", "--name-only"], cwd=repo_root)
|
|
1433
1437
|
staged_files = git_lines(["diff", "--cached", "--name-only"], cwd=repo_root)
|
|
1434
1438
|
untracked_files = git_lines(["ls-files", "--others", "--exclude-standard"], cwd=repo_root)
|
|
1435
|
-
recent_commit_files = _files_in_recent_commits(repo_root, RECENT_COMMITS_FOR_FOCUS)
|
|
1436
|
-
|
|
1437
1439
|
default_base = detect_default_base(repo_root)
|
|
1440
|
+
recent_commit_files = _files_in_recent_commits(repo_root, RECENT_COMMITS_FOR_FOCUS, default_base)
|
|
1438
1441
|
|
|
1439
1442
|
all_paths = sorted(set(working_tree_files + staged_files + untracked_files + recent_commit_files))
|
|
1440
1443
|
return {
|
|
@@ -1456,25 +1459,24 @@ def merged_focus_areas(new_paths, existing, limit=None):
|
|
|
1456
1459
|
tree signal.
|
|
1457
1460
|
|
|
1458
1461
|
Algorithm (matches user's mental model — "specific signals over wide
|
|
1459
|
-
parents
|
|
1462
|
+
parents; stale entries disappear once current git facts no longer cover
|
|
1463
|
+
them"):
|
|
1460
1464
|
|
|
1461
1465
|
1. Internally dedupe `existing`: when a wider parent dir (e.g. `apps`)
|
|
1462
|
-
coexists with
|
|
1463
|
-
drop the wider parent. The wider
|
|
1464
|
-
from an earlier roll-up that
|
|
1465
|
-
descendants carry the real signal.
|
|
1466
|
+
coexists with either an existing or freshly-derived specific
|
|
1467
|
+
descendant (e.g. `apps/x/y/prompts`), drop the wider parent. The wider
|
|
1468
|
+
parent is almost always a leftover from an earlier roll-up that
|
|
1469
|
+
swallowed too much; the specific descendants carry the real signal.
|
|
1466
1470
|
2. Score every surviving existing dir by how many `new_paths` it
|
|
1467
|
-
actually covers
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
3. Compute `uncovered` = paths not under any surviving existing dir.
|
|
1471
|
+
actually covers. 0 means stale, e.g. `.vscode` after a one-shot IDE
|
|
1472
|
+
write; stale entries are not carried forward.
|
|
1473
|
+
3. Compute `uncovered` = paths not under any active existing dir.
|
|
1471
1474
|
4. Roll up the uncovered paths' immediate parents into ≤ remaining
|
|
1472
1475
|
budget buckets, **forbidding roll-up into any ancestor of an
|
|
1473
1476
|
existing dir** so the output cannot regenerate the over-wide
|
|
1474
1477
|
parent we just removed in step 1.
|
|
1475
|
-
5. Merge existing weights with the rolled-up uncovered buckets,
|
|
1476
|
-
sort by weight desc, truncate to `limit`.
|
|
1477
|
-
naturally sit at the bottom and get culled when buckets > limit.
|
|
1478
|
+
5. Merge active existing weights with the rolled-up uncovered buckets,
|
|
1479
|
+
sort by weight desc, truncate to `limit`.
|
|
1478
1480
|
"""
|
|
1479
1481
|
if limit is None:
|
|
1480
1482
|
limit = FOCUS_AREA_LIMIT
|
|
@@ -1486,24 +1488,31 @@ def merged_focus_areas(new_paths, existing, limit=None):
|
|
|
1486
1488
|
return "/" not in path
|
|
1487
1489
|
return path == dir_ or path.startswith(dir_ + "/")
|
|
1488
1490
|
|
|
1491
|
+
fresh_focus = summarize_focus_areas(new_paths, limit=limit)
|
|
1492
|
+
|
|
1489
1493
|
def _drop_wider_parents(items):
|
|
1490
|
-
items =
|
|
1491
|
-
|
|
1494
|
+
items = [d for d in dict.fromkeys(items) if d != "."] # dedupe preserving order
|
|
1495
|
+
comparison_items = items + fresh_focus
|
|
1496
|
+
return [d for d in items if not any(other != d and _is_under(other, d) for other in comparison_items)]
|
|
1492
1497
|
|
|
1493
1498
|
# (1) Drop wider parent entries inside existing.
|
|
1494
1499
|
deduped_existing = _drop_wider_parents(existing)
|
|
1495
1500
|
|
|
1496
|
-
# (2) Real coverage weight per surviving existing dir.
|
|
1501
|
+
# (2) Real coverage weight per surviving existing dir. Drop stale existing
|
|
1502
|
+
# dirs before computing uncovered paths or slot budget; otherwise stale
|
|
1503
|
+
# manifest entries still force new hot spots to roll up too far.
|
|
1497
1504
|
existing_weights = {d: sum(1 for p in new_paths if _is_under(p, d)) for d in deduped_existing}
|
|
1505
|
+
active_existing_weights = {d: w for d, w in existing_weights.items() if w > 0}
|
|
1498
1506
|
|
|
1499
|
-
# (3) Paths not yet covered by any existing dir.
|
|
1500
|
-
|
|
1507
|
+
# (3) Paths not yet covered by any active existing dir.
|
|
1508
|
+
active_existing_dirs = set(active_existing_weights)
|
|
1509
|
+
uncovered = [p for p in new_paths if not any(_is_under(p, d) for d in active_existing_dirs)]
|
|
1501
1510
|
|
|
1502
1511
|
# (4) Forbidden roll-up targets = any ancestor of a surviving existing
|
|
1503
1512
|
# dir. Without this guard the roll-up of `uncovered` could produce a
|
|
1504
1513
|
# parent like `apps` and we'd be back where we started.
|
|
1505
1514
|
forbidden_ancestors = set()
|
|
1506
|
-
for d in
|
|
1515
|
+
for d in active_existing_dirs:
|
|
1507
1516
|
if d in ("", "."):
|
|
1508
1517
|
continue
|
|
1509
1518
|
parent = Path(d).parent
|
|
@@ -1517,7 +1526,7 @@ def merged_focus_areas(new_paths, existing, limit=None):
|
|
|
1517
1526
|
parent = parent.parent
|
|
1518
1527
|
|
|
1519
1528
|
# Reserve slots for existing entries; uncovered fills the rest.
|
|
1520
|
-
remaining_budget = max(1, limit - len(
|
|
1529
|
+
remaining_budget = max(1, limit - len(active_existing_weights))
|
|
1521
1530
|
|
|
1522
1531
|
new_buckets = Counter()
|
|
1523
1532
|
for p in uncovered:
|
|
@@ -1558,16 +1567,10 @@ def merged_focus_areas(new_paths, existing, limit=None):
|
|
|
1558
1567
|
# - new_quota = floor(limit/2) → reserved for fresh buckets
|
|
1559
1568
|
# - either side can borrow unused slots from the other (so we never
|
|
1560
1569
|
# leave a slot empty just to enforce the partition).
|
|
1561
|
-
# - stale existing (weight=0) is a fallback that only fills slots left
|
|
1562
|
-
# after both quotas are settled — they ride at the bottom and get
|
|
1563
|
-
# pruned naturally.
|
|
1564
1570
|
existing_active = sorted(
|
|
1565
|
-
|
|
1571
|
+
active_existing_weights.items(),
|
|
1566
1572
|
key=lambda kv: (-kv[1], kv[0]),
|
|
1567
1573
|
)
|
|
1568
|
-
existing_stale = sorted(
|
|
1569
|
-
(d for d, w in existing_weights.items() if w == 0),
|
|
1570
|
-
)
|
|
1571
1574
|
new_items = sorted(new_buckets.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
1572
1575
|
|
|
1573
1576
|
existing_quota = (limit + 1) // 2 # ceil(limit/2)
|
|
@@ -1584,26 +1587,24 @@ def merged_focus_areas(new_paths, existing, limit=None):
|
|
|
1584
1587
|
if new_overflow > 0:
|
|
1585
1588
|
existing_take.extend(existing_active[existing_quota:existing_quota + new_overflow])
|
|
1586
1589
|
|
|
1587
|
-
|
|
1590
|
+
selected = []
|
|
1588
1591
|
seen = set()
|
|
1589
|
-
for d,
|
|
1590
|
-
if d in seen:
|
|
1591
|
-
continue
|
|
1592
|
-
seen.add(d)
|
|
1593
|
-
final.append(d)
|
|
1594
|
-
for d in existing_stale:
|
|
1595
|
-
if len(final) >= limit:
|
|
1596
|
-
break
|
|
1592
|
+
for d, weight in existing_take + new_take:
|
|
1597
1593
|
if d in seen:
|
|
1598
1594
|
continue
|
|
1599
1595
|
seen.add(d)
|
|
1600
|
-
|
|
1601
|
-
|
|
1596
|
+
selected.append((d, weight))
|
|
1597
|
+
selected.sort(key=lambda kv: (-kv[1], kv[0]))
|
|
1598
|
+
return [d for d, _ in selected[:limit]]
|
|
1602
1599
|
|
|
1603
1600
|
|
|
1604
1601
|
def build_auto_block(facts):
|
|
1605
1602
|
base_line = facts["default_base"] or "未检测到 origin/HEAD"
|
|
1606
1603
|
head_line = facts["last_seen_head"] or "尚未检测到 HEAD"
|
|
1604
|
+
if facts["default_base"]:
|
|
1605
|
+
history_cmd = f"git log --oneline -n {RECENT_COMMITS_FOR_FOCUS} --no-merges {facts['default_base']}..HEAD"
|
|
1606
|
+
else:
|
|
1607
|
+
history_cmd = f"git log --oneline -n {RECENT_COMMITS_FOR_FOCUS} --no-merges"
|
|
1607
1608
|
focus_lines = render_bullets(facts["focus_areas"], empty_text="- 当前未检测到改动目录", wrap_code=True)
|
|
1608
1609
|
scope_lines = render_bullets(
|
|
1609
1610
|
[f"{item['scope']} ({item['files']} files)" for item in facts["scope_summary"]],
|
|
@@ -1620,7 +1621,7 @@ def build_auto_block(facts):
|
|
|
1620
1621
|
"#### 顶层改动范围\n\n"
|
|
1621
1622
|
f"{scope_lines}\n\n"
|
|
1622
1623
|
"#### 按需查看提交历史\n\n"
|
|
1623
|
-
f"- `
|
|
1624
|
+
f"- `{history_cmd}`\n"
|
|
1624
1625
|
"- `git diff --name-only`\n"
|
|
1625
1626
|
)
|
|
1626
1627
|
|
|
@@ -10,6 +10,8 @@ the heuristic missed, but the primary signal is pending-promotion.
|
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
12
|
import argparse
|
|
13
|
+
import contextlib
|
|
14
|
+
import fcntl
|
|
13
15
|
import json
|
|
14
16
|
import sys
|
|
15
17
|
from datetime import datetime, timezone
|
|
@@ -130,21 +132,11 @@ def _apply_entries(target_path, entries):
|
|
|
130
132
|
return n
|
|
131
133
|
|
|
132
134
|
|
|
133
|
-
def
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
return 1
|
|
137
|
-
|
|
138
|
-
repo_root, branch_name, branch_key, _, repo_key, repo_dir, branch_dir = get_branch_paths(args.repo, args.context_dir, args.branch)
|
|
139
|
-
|
|
140
|
-
if not branch_dir.exists():
|
|
141
|
-
print(json.dumps({"error": f"branch memory not initialized: {branch_dir}"}, ensure_ascii=False))
|
|
142
|
-
return 1
|
|
143
|
-
|
|
144
|
-
harvest = read_json(Path(args.harvest_file))
|
|
135
|
+
def _load_harvest(harvest_file):
|
|
136
|
+
harvest_path = Path(harvest_file)
|
|
137
|
+
harvest = read_json(harvest_path)
|
|
145
138
|
if not harvest:
|
|
146
|
-
|
|
147
|
-
return 1
|
|
139
|
+
raise ValueError(f"harvest file empty or missing: {harvest_file}")
|
|
148
140
|
|
|
149
141
|
# Reject unknown top-level keys explicitly. The most common failure mode
|
|
150
142
|
# is a pre-v2 harvest.json still using repo_context / repo_sources —
|
|
@@ -160,13 +152,74 @@ def command_apply(args):
|
|
|
160
152
|
" (v1 schema? repo_context → repo_decisions or repo_glossary; "
|
|
161
153
|
"repo_sources → repo_glossary; see references/harvest-schema.md)"
|
|
162
154
|
)
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
155
|
+
raise ValueError(f"unknown harvest key(s): {unknown_keys}{legacy_hint}")
|
|
156
|
+
return harvest
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _resolve_apply_context(args):
|
|
160
|
+
if detect_no_git_mode(args.repo):
|
|
161
|
+
raise ValueError("no-git mode: graduate has nothing to archive")
|
|
162
|
+
|
|
163
|
+
repo_root, branch_name, branch_key, _, repo_key, repo_dir, branch_dir = get_branch_paths(
|
|
164
|
+
args.repo,
|
|
165
|
+
args.context_dir,
|
|
166
|
+
args.branch,
|
|
167
|
+
)
|
|
168
|
+
if not branch_dir.exists():
|
|
169
|
+
raise ValueError(f"branch memory not initialized: {branch_dir}")
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
"repo_root": repo_root,
|
|
173
|
+
"branch_name": branch_name,
|
|
174
|
+
"branch_key": branch_key,
|
|
175
|
+
"repo_key": repo_key,
|
|
176
|
+
"repo_dir": repo_dir,
|
|
177
|
+
"branch_dir": branch_dir,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _graduate_apply_lock_path(repo_dir):
|
|
182
|
+
return Path(repo_dir) / "jobs" / "graduate-apply.lock"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@contextlib.contextmanager
|
|
186
|
+
def _graduate_apply_queue(repo_dir):
|
|
187
|
+
"""Serialize graduate apply per repo memory directory.
|
|
188
|
+
|
|
189
|
+
Pre-flight validation happens before this context. The lock only protects
|
|
190
|
+
repo-shared writes + branch archive moves, so independent invocations can
|
|
191
|
+
still fail fast on bad harvest files before waiting behind a long apply.
|
|
192
|
+
"""
|
|
193
|
+
lock_path = _graduate_apply_lock_path(repo_dir)
|
|
194
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
with lock_path.open("a+", encoding="utf-8") as lock_fh:
|
|
196
|
+
waited = False
|
|
197
|
+
try:
|
|
198
|
+
fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
199
|
+
except BlockingIOError:
|
|
200
|
+
waited = True
|
|
201
|
+
print(
|
|
202
|
+
f"[dev-memory][graduate] another apply is running; queued on {lock_path}",
|
|
203
|
+
file=sys.stderr,
|
|
167
204
|
)
|
|
168
|
-
|
|
169
|
-
|
|
205
|
+
fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX)
|
|
206
|
+
try:
|
|
207
|
+
yield waited
|
|
208
|
+
finally:
|
|
209
|
+
fcntl.flock(lock_fh.fileno(), fcntl.LOCK_UN)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _apply_validated(context, harvest, *, queue_waited=False):
|
|
213
|
+
repo_root = context["repo_root"]
|
|
214
|
+
branch_name = context["branch_name"]
|
|
215
|
+
branch_key = context["branch_key"]
|
|
216
|
+
repo_dir = context["repo_dir"]
|
|
217
|
+
branch_dir = context["branch_dir"]
|
|
218
|
+
|
|
219
|
+
# The branch may have been archived by an earlier queued apply while this
|
|
220
|
+
# process was waiting. Re-check inside the critical section before writing.
|
|
221
|
+
if not branch_dir.exists():
|
|
222
|
+
raise ValueError(f"branch memory not initialized: {branch_dir}")
|
|
170
223
|
|
|
171
224
|
paths = asset_paths(repo_dir, branch_dir)
|
|
172
225
|
|
|
@@ -226,10 +279,20 @@ def command_apply(args):
|
|
|
226
279
|
"harvested_total": total,
|
|
227
280
|
"archive_summary": str((archive_dst or branch_dir) / "archive_summary.md"),
|
|
228
281
|
"archived_to": str(archive_dst) if archive_dst else None,
|
|
282
|
+
"queue_waited": queue_waited,
|
|
229
283
|
}, ensure_ascii=False, indent=2))
|
|
230
284
|
return 0
|
|
231
285
|
|
|
232
286
|
|
|
287
|
+
def command_apply(args):
|
|
288
|
+
# Pre-flight first: bad input should return immediately even if another
|
|
289
|
+
# session is currently applying a valid graduate harvest.
|
|
290
|
+
context = _resolve_apply_context(args)
|
|
291
|
+
harvest = _load_harvest(args.harvest_file)
|
|
292
|
+
with _graduate_apply_queue(context["repo_dir"]) as waited:
|
|
293
|
+
return _apply_validated(context, harvest, queue_waited=waited)
|
|
294
|
+
|
|
295
|
+
|
|
233
296
|
def command_index(args):
|
|
234
297
|
if detect_no_git_mode(args.repo):
|
|
235
298
|
print(json.dumps({"error": "no-git mode: no archive index"}, ensure_ascii=False))
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _read_json(path):
|
|
10
|
+
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _text_from_content(content):
|
|
14
|
+
if isinstance(content, str):
|
|
15
|
+
return content
|
|
16
|
+
if not isinstance(content, list):
|
|
17
|
+
return ""
|
|
18
|
+
parts = []
|
|
19
|
+
for item in content:
|
|
20
|
+
if not isinstance(item, dict):
|
|
21
|
+
continue
|
|
22
|
+
typ = item.get("type")
|
|
23
|
+
if typ in ("tool_use", "tool_result", "function_call", "function_call_output"):
|
|
24
|
+
continue
|
|
25
|
+
text = item.get("text")
|
|
26
|
+
if isinstance(text, str):
|
|
27
|
+
parts.append(text)
|
|
28
|
+
return "\n".join(parts).strip()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _extract_claude(obj):
|
|
32
|
+
typ = obj.get("type")
|
|
33
|
+
if typ not in ("user", "assistant"):
|
|
34
|
+
return None
|
|
35
|
+
msg = obj.get("message") if isinstance(obj.get("message"), dict) else {}
|
|
36
|
+
role = msg.get("role") or typ
|
|
37
|
+
text = _text_from_content(msg.get("content"))
|
|
38
|
+
if not text:
|
|
39
|
+
return None
|
|
40
|
+
return {
|
|
41
|
+
"source": "claude",
|
|
42
|
+
"role": role,
|
|
43
|
+
"timestamp": obj.get("timestamp"),
|
|
44
|
+
"uuid": obj.get("uuid"),
|
|
45
|
+
"text": text,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _extract_codex(obj):
|
|
50
|
+
if obj.get("type") != "response_item":
|
|
51
|
+
return None
|
|
52
|
+
payload = obj.get("payload")
|
|
53
|
+
if not isinstance(payload, dict):
|
|
54
|
+
return None
|
|
55
|
+
if payload.get("type") != "message":
|
|
56
|
+
return None
|
|
57
|
+
role = payload.get("role")
|
|
58
|
+
if role not in ("user", "assistant"):
|
|
59
|
+
return None
|
|
60
|
+
text = _text_from_content(payload.get("content"))
|
|
61
|
+
if not text:
|
|
62
|
+
return None
|
|
63
|
+
return {
|
|
64
|
+
"source": "codex",
|
|
65
|
+
"role": role,
|
|
66
|
+
"timestamp": obj.get("timestamp"),
|
|
67
|
+
"text": text,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _iter_core_messages(transcript_path):
|
|
72
|
+
if not transcript_path:
|
|
73
|
+
return []
|
|
74
|
+
path = Path(transcript_path).expanduser()
|
|
75
|
+
if not path.exists():
|
|
76
|
+
return []
|
|
77
|
+
out = []
|
|
78
|
+
with path.open(encoding="utf-8") as f:
|
|
79
|
+
for line in f:
|
|
80
|
+
line = line.strip()
|
|
81
|
+
if not line:
|
|
82
|
+
continue
|
|
83
|
+
try:
|
|
84
|
+
obj = json.loads(line)
|
|
85
|
+
except Exception:
|
|
86
|
+
continue
|
|
87
|
+
msg = _extract_claude(obj) or _extract_codex(obj)
|
|
88
|
+
if msg:
|
|
89
|
+
out.append(msg)
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _truncate(text, max_chars):
|
|
94
|
+
text = (text or "").strip()
|
|
95
|
+
if len(text) <= max_chars:
|
|
96
|
+
return text
|
|
97
|
+
return text[: max_chars - 3].rstrip() + "..."
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _is_nonsemantic_user_text(text):
|
|
101
|
+
stripped = (text or "").strip()
|
|
102
|
+
if not stripped:
|
|
103
|
+
return True
|
|
104
|
+
markers = (
|
|
105
|
+
"<local-command-caveat>",
|
|
106
|
+
"<command-name>",
|
|
107
|
+
"<command-message>",
|
|
108
|
+
"<command-args>",
|
|
109
|
+
"Your tool call was malformed",
|
|
110
|
+
)
|
|
111
|
+
return any(marker in stripped for marker in markers)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _memory_file(path, max_chars, name=None):
|
|
115
|
+
p = Path(path)
|
|
116
|
+
if not p.exists():
|
|
117
|
+
return None
|
|
118
|
+
text = p.read_text(encoding="utf-8")
|
|
119
|
+
item = {
|
|
120
|
+
"name": name or p.name,
|
|
121
|
+
"content": _truncate(text, max_chars),
|
|
122
|
+
}
|
|
123
|
+
if len(text) > max_chars:
|
|
124
|
+
item["truncated"] = True
|
|
125
|
+
return item
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _summary_job(job):
|
|
129
|
+
transcript_state = job.get("transcript_state") if isinstance(job.get("transcript_state"), dict) else {}
|
|
130
|
+
previous_job = job.get("previous_job") if isinstance(job.get("previous_job"), dict) else {}
|
|
131
|
+
return {
|
|
132
|
+
"repo_root": job.get("repo_root"),
|
|
133
|
+
"transcript_state": {
|
|
134
|
+
key: transcript_state.get(key)
|
|
135
|
+
for key in ("size", "mtime_ms")
|
|
136
|
+
if transcript_state.get(key) is not None
|
|
137
|
+
},
|
|
138
|
+
"previous_processed": previous_job.get("processed") if isinstance(previous_job.get("processed"), dict) else None,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def extract_core_payload(
|
|
143
|
+
job,
|
|
144
|
+
*,
|
|
145
|
+
max_messages=40,
|
|
146
|
+
max_message_chars=2000,
|
|
147
|
+
max_memory_chars=6000,
|
|
148
|
+
since_size=0,
|
|
149
|
+
include_message_metadata=False,
|
|
150
|
+
):
|
|
151
|
+
branch_dir = Path(job["branch_dir"])
|
|
152
|
+
repo_dir = Path(job["repo_dir"])
|
|
153
|
+
memory_paths = [
|
|
154
|
+
("branch/progress.md", branch_dir / "progress.md"),
|
|
155
|
+
("branch/risks.md", branch_dir / "risks.md"),
|
|
156
|
+
("branch/decisions.md", branch_dir / "decisions.md"),
|
|
157
|
+
("branch/glossary.md", branch_dir / "glossary.md"),
|
|
158
|
+
("branch/overview.md", branch_dir / "overview.md"),
|
|
159
|
+
("repo/decisions.md", repo_dir / "repo" / "decisions.md"),
|
|
160
|
+
("repo/glossary.md", repo_dir / "repo" / "glossary.md"),
|
|
161
|
+
]
|
|
162
|
+
messages = _iter_core_messages(job.get("transcript_path"))
|
|
163
|
+
if since_size:
|
|
164
|
+
# JSONL is line-oriented but byte offsets can land mid-line. Keep this
|
|
165
|
+
# option conservative for now: expose cursor metadata and let workers
|
|
166
|
+
# use recent-tail until a precise offset reader is needed.
|
|
167
|
+
pass
|
|
168
|
+
messages = [m for m in messages if not _is_nonsemantic_user_text(m["text"])]
|
|
169
|
+
recent = messages[-max_messages:]
|
|
170
|
+
core_messages = []
|
|
171
|
+
for m in recent:
|
|
172
|
+
item = {
|
|
173
|
+
"role": m["role"],
|
|
174
|
+
"text": _truncate(m["text"], max_message_chars),
|
|
175
|
+
}
|
|
176
|
+
if include_message_metadata:
|
|
177
|
+
item = {**m, "text": item["text"]}
|
|
178
|
+
core_messages.append(item)
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
"job": _summary_job(job),
|
|
182
|
+
"existing_memory": [
|
|
183
|
+
item
|
|
184
|
+
for item in (
|
|
185
|
+
_memory_file(path, max_memory_chars, name=name)
|
|
186
|
+
for name, path in memory_paths
|
|
187
|
+
)
|
|
188
|
+
if item is not None
|
|
189
|
+
],
|
|
190
|
+
"core_messages": core_messages,
|
|
191
|
+
"stats": {
|
|
192
|
+
"core_message_count": len(messages),
|
|
193
|
+
"returned_core_message_count": len(recent),
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def command_extract_core(args):
|
|
199
|
+
job = _read_json(args.job)
|
|
200
|
+
payload = extract_core_payload(
|
|
201
|
+
job,
|
|
202
|
+
max_messages=args.max_messages,
|
|
203
|
+
max_message_chars=args.max_message_chars,
|
|
204
|
+
max_memory_chars=args.max_memory_chars,
|
|
205
|
+
since_size=args.since_size,
|
|
206
|
+
include_message_metadata=args.include_message_metadata,
|
|
207
|
+
)
|
|
208
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def main():
|
|
212
|
+
parser = argparse.ArgumentParser(description="Session summary helper commands.")
|
|
213
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
214
|
+
p = sub.add_parser("extract-core", help="Extract core transcript messages and current memory for a summary job")
|
|
215
|
+
p.add_argument("job", help="Path to a session-summary pending job JSON")
|
|
216
|
+
p.add_argument("--max-messages", type=int, default=40)
|
|
217
|
+
p.add_argument("--max-message-chars", type=int, default=2000)
|
|
218
|
+
p.add_argument("--max-memory-chars", type=int, default=6000)
|
|
219
|
+
p.add_argument("--since-size", type=int, default=0, help="Reserved cursor hint for future byte-offset extraction")
|
|
220
|
+
p.add_argument(
|
|
221
|
+
"--include-message-metadata",
|
|
222
|
+
action="store_true",
|
|
223
|
+
help="Include source/timestamp/uuid fields in core_messages for debugging",
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
args = parser.parse_args()
|
|
227
|
+
if args.command == "extract-core":
|
|
228
|
+
command_extract_core(args)
|
|
229
|
+
return 0
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
if __name__ == "__main__":
|
|
233
|
+
raise SystemExit(main())
|