diff --git a/mempalace/cli.py b/mempalace/cli.py index 6f8edda..3035e51 100644 --- a/mempalace/cli.py +++ b/mempalace/cli.py @@ -1832,7 +1832,9 @@ def main(): # mine p_mine = sub.add_parser("mine", help="Mine files into the palace") - p_mine.add_argument("dir", help="Directory to mine") + p_mine.add_argument( + "dir", help="Directory to mine, or one conversation file with --mode convos" + ) p_mine.add_argument( "--backend", default=None, diff --git a/mempalace/convo_miner.py b/mempalace/convo_miner.py index b6b714f..137fb31 100644 --- a/mempalace/convo_miner.py +++ b/mempalace/convo_miner.py @@ -418,9 +418,16 @@ def scan_convos(convo_dir: str) -> list: ``sys.stderr`` with a `` SKIP: (symlink)`` line so the caller can tell why an apparent conversation directory yielded no files. """ - convo_path = Path(convo_dir).expanduser().resolve() + # A direct conversation file is a valid source. For a file, feed only + # its basename through the existing directory validation loop. + requested_path = Path(convo_dir).expanduser() + single_file = requested_path.is_file() + convo_path = (requested_path.parent if single_file else requested_path).resolve() + scan_entries = ( + [(str(convo_path), [], [requested_path.name])] if single_file else os.walk(convo_path) + ) files = [] - for root, dirs, filenames in os.walk(convo_path): + for root, dirs, filenames in scan_entries: dirs[:] = [d for d in dirs if d not in CONVO_SKIP_DIRS] for filename in filenames: if filename.endswith(".meta.json"): diff --git a/mempalace/hooks_cli.py b/mempalace/hooks_cli.py index d4ac300..d8a89fc 100644 --- a/mempalace/hooks_cli.py +++ b/mempalace/hooks_cli.py @@ -1090,12 +1090,12 @@ def _ingest_transcript(transcript_path: str): _submit_daemon_job( "mine", { - "source": str(path.parent), + "source": str(path), "mode": "convos", "wing": "sessions", "agent": "mempalace", }, - dedupe_key=_daemon_mine_dedupe_key(str(path.parent), "convos"), + dedupe_key=_daemon_mine_dedupe_key(str(path), "convos"), wait=False, ) _log(f"Transcript ingest submitted to daemon: {path.name}") @@ -1113,7 +1113,7 @@ def _ingest_transcript(transcript_path: str): "-m", "mempalace", "mine", - str(path.parent), + str(path), "--mode", "convos", "--wing", diff --git a/tests/test_convo_miner.py b/tests/test_convo_miner.py index 634e1d1..0bb0fac 100644 --- a/tests/test_convo_miner.py +++ b/tests/test_convo_miner.py @@ -834,3 +834,41 @@ def test_register_file_sentinel_includes_source_mtime(): assert abs(mined[str(tiny_file)] - os.path.getmtime(tiny_file)) < 0.001 finally: shutil.rmtree(tmpdir, ignore_errors=True) + + +def test_mine_convos_dry_run_single_file_does_not_scan_siblings( + tmp_path, + capsys, +): + selected = tmp_path / "selected.txt" + sibling = tmp_path / "sibling.txt" + + selected.write_text( + "> Which transcript should be mined?\n" + "SELECTED_ONLY_MARKER belongs to the active transcript.\n\n" + "> Should sibling files be included?\n" + "No. Only the selected transcript should be scanned.\n", + encoding="utf-8", + ) + sibling.write_text( + "> Should this sibling be mined?\n" + "SIBLING_SHOULD_NOT_BE_MINED by the single-file invocation.\n\n" + "> Is that important?\n" + "Yes. It keeps hook-triggered mining narrowly scoped.\n", + encoding="utf-8", + ) + + palace_path = tmp_path / "palace" + + mine_convos( + str(selected), + str(palace_path), + wing="sessions", + dry_run=True, + ) + output = capsys.readouterr().out + + assert "Files: 1" in output + assert "[DRY RUN] selected.txt" in output + assert "sibling.txt" not in output + assert not palace_path.exists() diff --git a/tests/test_convo_miner_unit.py b/tests/test_convo_miner_unit.py index d30d7e9..ccaf6b1 100644 --- a/tests/test_convo_miner_unit.py +++ b/tests/test_convo_miner_unit.py @@ -713,3 +713,21 @@ class TestExtractAuthoredAt: f = tmp_path / "session.jsonl" f.write_text('{"timestamp": 1}\n{"timestamp": false}\n') assert _extract_authored_at(f) is None + + +def test_scan_convos_accepts_one_file_without_scanning_siblings( + tmp_path, +): + selected = tmp_path / "selected.jsonl" + sibling = tmp_path / "sibling.jsonl" + + selected.write_text( + '{"type": "user"}\n', + encoding="utf-8", + ) + sibling.write_text( + '{"type": "user"}\n', + encoding="utf-8", + ) + + assert scan_convos(str(selected)) == [selected.resolve()] diff --git a/tests/test_hooks_cli.py b/tests/test_hooks_cli.py index c714882..605526e 100644 --- a/tests/test_hooks_cli.py +++ b/tests/test_hooks_cli.py @@ -1378,7 +1378,7 @@ def test_ingest_transcript_daemon_opt_in_submits_job(tmp_path): mock_popen.assert_not_called() mock_submit.assert_called_once() payload = mock_submit.call_args.args[1] - assert payload["source"] == str(tmp_path) + assert payload["source"] == str(transcript.resolve()) assert payload["mode"] == "convos" assert payload["wing"] == "sessions" @@ -1398,7 +1398,7 @@ def test_ingest_transcript_skips_when_target_running(tmp_path): "-m", "mempalace", "mine", - str(transcript.parent), + str(transcript.resolve()), "--mode", "convos", "--wing", @@ -1763,7 +1763,7 @@ def test_precompact_with_timeout(tmp_path): assert result == {} -def test_precompact_mines_transcript_dir(tmp_path, monkeypatch): +def test_precompact_mines_only_active_transcript(tmp_path, monkeypatch): """Precompact ingests the active transcript via _ingest_transcript. With no MEMPAL_DIR, _mine_sync is a no-op; the transcript ingest is @@ -1787,8 +1787,8 @@ def test_precompact_mines_transcript_dir(tmp_path, monkeypatch): mock_run.assert_not_called() mock_popen.assert_called_once() cmd = mock_popen.call_args[0][0] - # Mines the transcript's parent dir as convos, into wing "sessions". - assert str(tmp_path) in cmd + # Mines only the active transcript as convos, into wing "sessions". + assert str(transcript.resolve()) in cmd assert cmd[cmd.index("--mode") + 1] == "convos" assert cmd[cmd.index("--wing") + 1] == "sessions"