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.
@@ -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 = 5
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 the POSIX-style parent dir string."""
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 wins, then
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 the last `count` non-merge commits on HEAD."""
1410
- out = git_lines(
1411
- [
1412
- "log",
1413
- f"-n{count}",
1414
- "--no-merges",
1415
- "--name-only",
1416
- "--pretty=format:",
1417
- ],
1418
- cwd=repo_root,
1419
- check=False,
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, stale entries kept ranked low until limit prunes them"):
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 its specific descendant (e.g. `apps/x/y/prompts`),
1463
- drop the wider parent. The wider parent is almost always a leftover
1464
- from an earlier roll-up that swallowed too much; the specific
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 (0 means stale, e.g. `.vscode` after a one-shot
1468
- IDE write but we keep it in the ranking, only the final limit
1469
- truncation prunes it from the bottom).
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`. Stale 0-weight entries
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 = list(dict.fromkeys(items)) # dedupe preserving order
1491
- return [d for d in items if not any(other != d and _is_under(other, d) for other in items)]
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
- uncovered = [p for p in new_paths if not any(_is_under(p, d) for d in deduped_existing)]
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 deduped_existing:
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(existing_weights))
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
- ((d, w) for d, w in existing_weights.items() if w > 0),
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
- final = []
1590
+ selected = []
1588
1591
  seen = set()
1589
- for d, _ in existing_take + new_take:
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
- final.append(d)
1601
- return final[:limit]
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"- `git log --oneline -n {RECENT_COMMITS_FOR_FOCUS} --no-merges`\n"
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 command_apply(args):
134
- if detect_no_git_mode(args.repo):
135
- print(json.dumps({"error": "no-git mode: graduate has nothing to archive"}, ensure_ascii=False))
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
- print(json.dumps({"error": f"harvest file empty or missing: {args.harvest_file}"}, ensure_ascii=False))
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
- print(
164
- json.dumps(
165
- {"error": f"unknown harvest key(s): {unknown_keys}{legacy_hint}"},
166
- ensure_ascii=False,
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
- return 1
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())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dev-memory-cli",
3
- "version": "0.18.2",
3
+ "version": "0.18.3",
4
4
  "description": "CLI for dev-memory hooks and repo-local setup (formerly @xluos/dev-assets-cli)",
5
5
  "license": "MIT",
6
6
  "repository": {