Skip to content

Commit 3161cae

Browse files
authored
Merge pull request #1330 from mvalentsev/fix/convo-miner-skip-subagents
fix(convo-miner): skip Claude Code subagent transcripts by default (#1217)
2 parents b4345e8 + 5bc539d commit 3161cae

4 files changed

Lines changed: 154 additions & 3 deletions

File tree

mempalace/cli.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,7 @@ def cmd_mine(args):
736736
limit=args.limit,
737737
dry_run=args.dry_run,
738738
extract_mode=args.extract,
739+
include_subagents=getattr(args, "include_subagents", False),
739740
)
740741
elif args.mode == "extract":
741742
from .format_miner import mine_formats
@@ -2372,6 +2373,17 @@ def main():
23722373
f"Windows if you hit ONNX bad_alloc (#1455)."
23732374
),
23742375
)
2376+
p_mine.add_argument(
2377+
"--include-subagents",
2378+
action="store_true",
2379+
default=False,
2380+
help=(
2381+
"Also mine Claude Code subagent transcripts (subagents/ dirs). "
2382+
"Excluded by default: these are short ephemeral exchanges "
2383+
"(Explore/Plan/Grep agents) already summarized in the parent "
2384+
"session, and on typical workspaces they dominate file counts."
2385+
),
2386+
)
23752387

23762388
# sweep
23772389
p_sweep = sub.add_parser(

mempalace/convo_miner.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -412,12 +412,21 @@ def detect_convo_room(content: str) -> str:
412412
# =============================================================================
413413

414414

415-
def scan_convos(convo_dir: str) -> list:
415+
def scan_convos(convo_dir: str, include_subagents: bool = False) -> list:
416416
"""Find all potential conversation files.
417417
418418
Skips symlinks and oversized files. Each skipped symlink is logged to
419419
``sys.stderr`` with a `` SKIP: <relative-path> (symlink)`` line so the
420420
caller can tell why an apparent conversation directory yielded no files.
421+
422+
By default, directories named ``subagents`` are skipped: Claude Code
423+
records Explore/Plan/Grep subagent transcripts there, and on typical
424+
workspaces they outnumber main session files by one to two orders of
425+
magnitude. Pass ``include_subagents=True`` to mine them anyway.
426+
427+
The match is case-insensitive on the directory name only (``subagents``
428+
or ``Subagents``), so directories like ``mysubagents`` or
429+
``subagentsbackup`` are not affected.
421430
"""
422431
# A direct conversation file is a valid source. For a file, feed only
423432
# its basename through the existing directory validation loop.
@@ -429,7 +438,11 @@ def scan_convos(convo_dir: str) -> list:
429438
)
430439
files = []
431440
for root, dirs, filenames in scan_entries:
432-
dirs[:] = [d for d in dirs if d not in CONVO_SKIP_DIRS]
441+
dirs[:] = [
442+
d
443+
for d in dirs
444+
if d not in CONVO_SKIP_DIRS and (include_subagents or d.lower() != "subagents")
445+
]
433446
for filename in filenames:
434447
if filename.endswith(".meta.json"):
435448
continue
@@ -739,12 +752,16 @@ def mine_convos(
739752
limit: int = 0,
740753
dry_run: bool = False,
741754
extract_mode: str = "exchange",
755+
include_subagents: bool = False,
742756
):
743757
"""Mine a directory of conversation files into the palace.
744758
745759
extract_mode:
746760
"exchange" — default exchange-pair chunking (Q+A = one unit)
747761
"general" — general extractor: decisions, preferences, milestones, problems, emotions
762+
include_subagents:
763+
False (default) — skip Claude Code ``subagents/`` directories
764+
True — also mine subagent transcripts
748765
749766
The real work is in :func:`_mine_convos_impl`; this wrapper holds the
750767
per-palace flock around it so two concurrent ``mempalace mine --mode
@@ -771,6 +788,7 @@ def mine_convos(
771788
limit=limit,
772789
dry_run=dry_run,
773790
extract_mode=extract_mode,
791+
include_subagents=include_subagents,
774792
)
775793

776794
with mine_palace_lock(palace_path):
@@ -782,6 +800,7 @@ def mine_convos(
782800
limit=limit,
783801
dry_run=dry_run,
784802
extract_mode=extract_mode,
803+
include_subagents=include_subagents,
785804
)
786805

787806

@@ -867,6 +886,7 @@ def _mine_convos_impl(
867886
limit: int = 0,
868887
dry_run: bool = False,
869888
extract_mode: str = "exchange",
889+
include_subagents: bool = False,
870890
):
871891
from .config import MempalaceConfig
872892

@@ -886,7 +906,7 @@ def _mine_convos_impl(
886906
convo_path = Path(convo_dir).expanduser().resolve()
887907
wing = _resolve_wing(convo_path, wing)
888908

889-
files = scan_convos(convo_dir)
909+
files = scan_convos(convo_dir, include_subagents=include_subagents)
890910

891911
print(f"\n{'=' * 55}")
892912
print(" MemPalace Mine -- Conversations")

tests/test_cli.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,7 @@ def test_cmd_mine_convos_mode(mock_config_cls):
593593
no_gitignore=False,
594594
include_ignored=[],
595595
extract="general",
596+
include_subagents=False,
596597
)
597598
with patch("mempalace.convo_miner.mine_convos") as mock_mine:
598599
cmd_mine(args)
@@ -604,9 +605,32 @@ def test_cmd_mine_convos_mode(mock_config_cls):
604605
limit=10,
605606
dry_run=True,
606607
extract_mode="general",
608+
include_subagents=False,
607609
)
608610

609611

612+
@patch("mempalace.cli.MempalaceConfig")
613+
def test_cmd_mine_convos_mode_threads_include_subagents_flag(mock_config_cls):
614+
mock_config_cls.return_value.palace_path = "/fake/palace"
615+
args = argparse.Namespace(
616+
dir="/chats",
617+
palace=None,
618+
mode="convos",
619+
wing="mywing",
620+
agent="me",
621+
limit=10,
622+
dry_run=True,
623+
no_gitignore=False,
624+
include_ignored=[],
625+
extract="exchange",
626+
include_subagents=True,
627+
)
628+
with patch("mempalace.convo_miner.mine_convos") as mock_mine:
629+
cmd_mine(args)
630+
kwargs = mock_mine.call_args.kwargs
631+
assert kwargs["include_subagents"] is True
632+
633+
610634
@patch("mempalace.cli.MempalaceConfig")
611635
def test_cmd_mine_include_ignored_comma_split(mock_config_cls):
612636
mock_config_cls.return_value.palace_path = "/fake/palace"

tests/test_convo_miner_unit.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,101 @@ def selective_stat(self, *args, **kwargs):
510510
assert "SKIP: unreadable.txt" in err
511511
assert "stat error" in err
512512

513+
def test_scan_skips_subagent_dirs_by_default(self, tmp_path):
514+
# Mimic Claude Code layout: ~/.claude/projects/<slug>/<session>/subagents/agent-*.jsonl
515+
session_dir = tmp_path / "session-abc"
516+
session_dir.mkdir()
517+
(session_dir / "main.jsonl").write_text('{"type":"user"}\n', encoding="utf-8")
518+
subagents_dir = session_dir / "subagents"
519+
subagents_dir.mkdir()
520+
(subagents_dir / "agent-abc.jsonl").write_text('{"type":"user"}\n', encoding="utf-8")
521+
(subagents_dir / "agent-def.jsonl").write_text('{"type":"user"}\n', encoding="utf-8")
522+
523+
files = scan_convos(str(tmp_path))
524+
names = [f.name for f in files]
525+
526+
assert "main.jsonl" in names
527+
assert "agent-abc.jsonl" not in names
528+
assert "agent-def.jsonl" not in names
529+
530+
def test_scan_includes_subagent_dirs_when_opted_in(self, tmp_path):
531+
session_dir = tmp_path / "session-abc"
532+
session_dir.mkdir()
533+
(session_dir / "main.jsonl").write_text('{"type":"user"}\n', encoding="utf-8")
534+
subagents_dir = session_dir / "subagents"
535+
subagents_dir.mkdir()
536+
(subagents_dir / "agent-abc.jsonl").write_text('{"type":"user"}\n', encoding="utf-8")
537+
538+
files = scan_convos(str(tmp_path), include_subagents=True)
539+
names = [f.name for f in files]
540+
541+
assert "main.jsonl" in names
542+
assert "agent-abc.jsonl" in names
543+
544+
def test_scan_skips_subagent_dirs_at_any_depth(self, tmp_path):
545+
# The "subagents" name match is by directory name, not by depth: verify
546+
# both shallow (top-level) and nested subagents/ get skipped.
547+
(tmp_path / "subagents").mkdir()
548+
(tmp_path / "subagents" / "agent-top.jsonl").write_text("{}", encoding="utf-8")
549+
nested = tmp_path / "session" / "subagents"
550+
nested.mkdir(parents=True)
551+
(nested / "agent-deep.jsonl").write_text("{}", encoding="utf-8")
552+
(tmp_path / "session" / "main.jsonl").write_text("{}", encoding="utf-8")
553+
554+
files = scan_convos(str(tmp_path))
555+
names = [f.name for f in files]
556+
557+
assert "main.jsonl" in names
558+
assert "agent-top.jsonl" not in names
559+
assert "agent-deep.jsonl" not in names
560+
561+
def test_scan_does_not_skip_suffix_named_dirs(self, tmp_path):
562+
# Exact name match only: 'mysubagents' or 'subagentsbackup' must still
563+
# be mined. Guards against future regression to substring/regex match.
564+
for dir_name in ("mysubagents", "subagentsbackup", "subagent"):
565+
d = tmp_path / dir_name
566+
d.mkdir()
567+
(d / f"{dir_name}.jsonl").write_text("{}", encoding="utf-8")
568+
569+
files = scan_convos(str(tmp_path))
570+
names = {f.name for f in files}
571+
572+
assert "mysubagents.jsonl" in names
573+
assert "subagentsbackup.jsonl" in names
574+
assert "subagent.jsonl" in names
575+
576+
def test_scan_skips_subagents_case_insensitive(self, tmp_path):
577+
# On Windows + macOS APFS the filesystem is case-preserving; if Claude
578+
# Code or a plugin ever emits 'Subagents' (capitalized), the filter
579+
# must still match. Only one variant per tmp_path because case-
580+
# insensitive filesystems collapse 'Subagents' and 'SUBAGENTS'.
581+
d = tmp_path / "Subagents"
582+
d.mkdir()
583+
(d / "agent.jsonl").write_text("{}", encoding="utf-8")
584+
(tmp_path / "main.jsonl").write_text("{}", encoding="utf-8")
585+
586+
files = scan_convos(str(tmp_path))
587+
names = {f.name for f in files}
588+
589+
assert "main.jsonl" in names
590+
assert "agent.jsonl" not in names
591+
592+
def test_scan_mines_an_explicitly_named_file_inside_subagents(self, tmp_path):
593+
# The skip is directory pruning, so it cannot reach a caller who names
594+
# one file: that path feeds a single synthetic entry with no directories
595+
# to prune. The split is deliberate -- --include-subagents governs what a
596+
# directory walk sweeps up, while naming a path is an explicit request
597+
# and stays honored. Pinned because the two behaviours were written
598+
# independently and nothing else exercises them together.
599+
subagents_dir = tmp_path / "session-abc" / "subagents"
600+
subagents_dir.mkdir(parents=True)
601+
target = subagents_dir / "agent-abc.jsonl"
602+
target.write_text('{"type":"user"}\n', encoding="utf-8")
603+
604+
files = scan_convos(str(target))
605+
606+
assert [f.name for f in files] == ["agent-abc.jsonl"]
607+
513608

514609
class TestFileChunksLocked:
515610
def test_uses_bounded_upsert_batches(self, monkeypatch):

0 commit comments

Comments
 (0)