Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions mempalace/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@
from .version import __version__


# Valid values for `mempalace mine --mode` and `--extract`. Shared with
# hooks_cli.py so the auto-mine env-var validation tracks the CLI surface.
MINE_MODES = ("projects", "convos")
MINE_EXTRACTS = ("exchange", "general")

_MEMPALACE_PROJECT_FILES = ("mempalace.yaml", "entities.json")


Expand Down Expand Up @@ -557,7 +562,7 @@ def main():
p_mine.add_argument("dir", help="Directory to mine")
p_mine.add_argument(
"--mode",
choices=["projects", "convos"],
choices=list(MINE_MODES),
default="projects",
help="Ingest mode: 'projects' for code/docs (default), 'convos' for chat exports",
)
Expand All @@ -584,7 +589,7 @@ def main():
)
p_mine.add_argument(
"--extract",
choices=["exchange", "general"],
choices=list(MINE_EXTRACTS),
default="exchange",
help="Extraction strategy for convos mode: 'exchange' (default) or 'general' (5 memory types)",
)
Expand Down
73 changes: 64 additions & 9 deletions mempalace/hooks_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,16 +138,71 @@ def _output(data: dict):
print(json.dumps(data, indent=2, ensure_ascii=False))


def _get_mine_dir(transcript_path: str = "") -> str:
"""Determine directory to mine from MEMPAL_DIR or transcript path."""
# Default wing for transcript-sourced auto-mines. Overridable via MEMPAL_WING.
_TRANSCRIPT_DEFAULT_WING = "conversations"
_AUTO_MODE = "auto"


def _get_mine_target(transcript_path: str = "") -> tuple:
"""Return (mine_dir, source) where source is 'mempal_dir', 'transcript', or ''."""
mempal_dir = os.environ.get("MEMPAL_DIR", "")
if mempal_dir and os.path.isdir(mempal_dir):
return mempal_dir
return mempal_dir, "mempal_dir"
if transcript_path:
path = Path(transcript_path).expanduser()
if path.is_file():
return str(path.parent)
return ""
return str(path.parent), "transcript"
return "", ""


def _get_mine_dir(transcript_path: str = "") -> str:
"""Legacy wrapper — returns only the mine dir. Prefer _get_mine_target."""
mine_dir, _ = _get_mine_target(transcript_path)
return mine_dir


def _build_mine_cmd(mine_dir: str, source: str) -> list:
"""Build `mempalace mine` argv, configurable via env vars.

MEMPAL_MODE — one of MINE_MODES, or 'auto' (default). Auto picks convos
for transcript-sourced mines, projects otherwise. Unknown
values fall back to auto (with a warning in hook.log).
MEMPAL_WING — explicit wing name. Transcript-sourced mines default to
_TRANSCRIPT_DEFAULT_WING when unset; MEMPAL_DIR-sourced
mines leave --wing out so the miner derives it.
MEMPAL_EXTRACT — one of MINE_EXTRACTS. Applied only in convos mode.
Unknown values are dropped with a warning.
"""
# Local import: cli.py imports run_hook from this module.
from .cli import MINE_EXTRACTS, MINE_MODES

cmd = [sys.executable, "-m", "mempalace", "mine", mine_dir]

mode = os.environ.get("MEMPAL_MODE", _AUTO_MODE).strip().lower() or _AUTO_MODE
if mode != _AUTO_MODE and mode not in MINE_MODES:
_log(
f"MEMPAL_MODE={mode!r} not in {MINE_MODES + (_AUTO_MODE,)}; falling back to auto-detect"
)
mode = _AUTO_MODE
if mode == _AUTO_MODE:
mode = "convos" if source == "transcript" else "projects"
cmd.extend(["--mode", mode])

wing = os.environ.get("MEMPAL_WING", "").strip()
if not wing and source == "transcript":
wing = _TRANSCRIPT_DEFAULT_WING
if wing:
cmd.extend(["--wing", wing])

if mode == "convos":
extract = os.environ.get("MEMPAL_EXTRACT", "").strip().lower()
if extract and extract not in MINE_EXTRACTS:
_log(f"MEMPAL_EXTRACT={extract!r} not in {MINE_EXTRACTS}; using miner default")
extract = ""
if extract:
cmd.extend(["--extract", extract])

return cmd


_MINE_PID_FILE = STATE_DIR / "mine.pid"
Expand Down Expand Up @@ -206,29 +261,29 @@ def _spawn_mine(cmd: list) -> None:

def _maybe_auto_ingest(transcript_path: str = ""):
"""Run mempalace mine in background if a mine directory is available."""
mine_dir = _get_mine_dir(transcript_path)
mine_dir, source = _get_mine_target(transcript_path)
if not mine_dir:
return
if _mine_already_running():
_log("Skipping auto-ingest: mine already running")
return
try:
_spawn_mine([sys.executable, "-m", "mempalace", "mine", mine_dir])
_spawn_mine(_build_mine_cmd(mine_dir, source))
except OSError:
pass


def _mine_sync(transcript_path: str = ""):
"""Run mempalace mine synchronously (for precompact -- data must land first)."""
mine_dir = _get_mine_dir(transcript_path)
mine_dir, source = _get_mine_target(transcript_path)
if not mine_dir:
return
try:
STATE_DIR.mkdir(parents=True, exist_ok=True)
log_path = STATE_DIR / "hook.log"
with open(log_path, "a") as log_f:
subprocess.run(
[sys.executable, "-m", "mempalace", "mine", mine_dir],
_build_mine_cmd(mine_dir, source),
stdout=log_f,
stderr=log_f,
timeout=60,
Expand Down
162 changes: 160 additions & 2 deletions tests/test_hooks_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
from mempalace.hooks_cli import (
SAVE_INTERVAL,
STOP_BLOCK_REASON,
_build_mine_cmd,
_count_human_messages,
_get_mine_dir,
_get_mine_target,
_log,
_maybe_auto_ingest,
_mine_already_running,
Expand Down Expand Up @@ -477,9 +479,12 @@ def test_precompact_mines_transcript_dir(tmp_path, monkeypatch):
)
assert result == {}
mock_run.assert_called_once()
# Verify mine dir is the transcript's parent
# Verify mine dir is the transcript's parent. The command is
# `<python> -m mempalace mine <dir> [flags]`, so the mine dir is always
# the argument immediately after "mine".
call_args = mock_run.call_args[0][0]
assert str(tmp_path) in call_args[-1]
mine_idx = call_args.index("mine")
assert call_args[mine_idx + 1] == str(tmp_path)


# --- run_hook ---
Expand Down Expand Up @@ -622,3 +627,156 @@ def test_stop_hook_rejects_injected_stop_hook_active(tmp_path):
# The injected value is not "true"/"1"/"yes", so the hook should NOT pass through
# It should count messages and block at the interval
assert result["decision"] == "block"


# --- _get_mine_target ---


def test_get_mine_target_mempal_dir(tmp_path):
"""MEMPAL_DIR pointing at a valid directory wins over transcript_path."""
mempal_dir = tmp_path / "project"
mempal_dir.mkdir()
transcript = tmp_path / "t.jsonl"
transcript.write_text("")
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
assert _get_mine_target(str(transcript)) == (str(mempal_dir), "mempal_dir")


def test_get_mine_target_transcript_fallback(tmp_path):
"""Falls back to transcript parent dir with source='transcript'."""
transcript = tmp_path / "t.jsonl"
transcript.write_text("")
with patch.dict("os.environ", {}, clear=True):
assert _get_mine_target(str(transcript)) == (str(tmp_path), "transcript")


def test_get_mine_target_empty():
"""Returns ('', '') when nothing is available."""
with patch.dict("os.environ", {}, clear=True):
assert _get_mine_target("") == ("", "")


def test_get_mine_target_invalid_mempal_dir_falls_back(tmp_path):
"""Non-existent MEMPAL_DIR should not short-circuit; fall back to transcript."""
transcript = tmp_path / "t.jsonl"
transcript.write_text("")
with patch.dict("os.environ", {"MEMPAL_DIR": "/nonexistent/path"}):
assert _get_mine_target(str(transcript)) == (str(tmp_path), "transcript")


# --- _build_mine_cmd ---


def _assert_flag(cmd: list, flag: str, value: str):
"""Assert that `flag value` appears as adjacent items in cmd."""
assert flag in cmd, f"expected {flag} in {cmd}"
assert cmd[cmd.index(flag) + 1] == value, f"expected {flag}={value} in {cmd}"


def test_build_mine_cmd_transcript_defaults_to_convos_and_conversations_wing():
"""Transcript-sourced mines default to --mode convos + --wing conversations."""
import sys as _sys

with patch.dict("os.environ", {}, clear=True):
cmd = _build_mine_cmd("/tmp/somedir", "transcript")
assert cmd[:5] == [_sys.executable, "-m", "mempalace", "mine", "/tmp/somedir"]
_assert_flag(cmd, "--mode", "convos")
_assert_flag(cmd, "--wing", "conversations")


def test_build_mine_cmd_mempal_dir_keeps_projects_mode_and_no_wing():
"""MEMPAL_DIR-sourced mines default to --mode projects with no --wing."""
with patch.dict("os.environ", {}, clear=True):
cmd = _build_mine_cmd("/tmp/projdir", "mempal_dir")
_assert_flag(cmd, "--mode", "projects")
assert "--wing" not in cmd


def test_build_mine_cmd_explicit_mode_overrides_auto():
"""MEMPAL_MODE=projects forces projects mode even on transcript source."""
with patch.dict("os.environ", {"MEMPAL_MODE": "projects"}):
cmd = _build_mine_cmd("/tmp/somedir", "transcript")
_assert_flag(cmd, "--mode", "projects")


def test_build_mine_cmd_explicit_wing_overrides_default():
"""MEMPAL_WING overrides the transcript default wing."""
with patch.dict("os.environ", {"MEMPAL_WING": "my-project"}):
cmd = _build_mine_cmd("/tmp/somedir", "transcript")
_assert_flag(cmd, "--wing", "my-project")


def test_build_mine_cmd_extract_applied_only_in_convos_mode():
"""MEMPAL_EXTRACT=general is forwarded in convos mode."""
with patch.dict("os.environ", {"MEMPAL_EXTRACT": "general"}):
cmd = _build_mine_cmd("/tmp/somedir", "transcript")
_assert_flag(cmd, "--extract", "general")


def test_build_mine_cmd_extract_ignored_in_projects_mode():
"""MEMPAL_EXTRACT is silently ignored when mode resolves to projects."""
with patch.dict("os.environ", {"MEMPAL_EXTRACT": "general"}):
cmd = _build_mine_cmd("/tmp/somedir", "mempal_dir")
assert "--extract" not in cmd


def test_build_mine_cmd_invalid_mode_falls_back_to_auto_detect(tmp_path):
"""Unknown MEMPAL_MODE falls back to auto-detect, NOT silent drop.

Silently dropping --mode would let the CLI's 'projects' default re-apply,
reintroducing the transcript-as-projects bug. Invalid values must trigger
auto-detect instead, and log a warning for debuggability.
"""
with patch.dict("os.environ", {"MEMPAL_MODE": "convoss"}):
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
cmd = _build_mine_cmd("/tmp/somedir", "transcript")
# Auto-detect from source=transcript → convos, even though MEMPAL_MODE
# was invalid.
_assert_flag(cmd, "--mode", "convos")
# Warning must be logged so typos surface in ~/.mempalace/hook_state/hook.log.
log_content = (tmp_path / "hook.log").read_text()
assert "MEMPAL_MODE" in log_content
assert "'convoss'" in log_content
assert "auto" in log_content


def test_build_mine_cmd_invalid_extract_is_ignored_with_warning(tmp_path):
"""Unknown MEMPAL_EXTRACT is dropped (miner uses its default) with a warning."""
with patch.dict("os.environ", {"MEMPAL_EXTRACT": "garbled"}):
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
cmd = _build_mine_cmd("/tmp/somedir", "transcript")
assert "--extract" not in cmd
log_content = (tmp_path / "hook.log").read_text()
assert "MEMPAL_EXTRACT" in log_content
assert "'garbled'" in log_content


# --- _maybe_auto_ingest end-to-end command shape ---


def test_maybe_auto_ingest_transcript_uses_convos_mode(tmp_path):
"""Auto-mine on a transcript path spawns mempalace mine --mode convos --wing conversations."""
transcript = tmp_path / "session.jsonl"
transcript.write_text("")
with patch.dict("os.environ", {}, clear=True):
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
with patch("mempalace.hooks_cli._MINE_PID_FILE", tmp_path / "mine.pid"):
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
_maybe_auto_ingest(str(transcript))
cmd = mock_popen.call_args[0][0]
assert "--mode" in cmd and cmd[cmd.index("--mode") + 1] == "convos"
assert "--wing" in cmd and cmd[cmd.index("--wing") + 1] == "conversations"


def test_maybe_auto_ingest_mempal_dir_uses_projects_mode(tmp_path):
"""Auto-mine via MEMPAL_DIR stays in projects mode with no forced wing."""
mempal_dir = tmp_path / "project"
mempal_dir.mkdir()
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
with patch("mempalace.hooks_cli._MINE_PID_FILE", tmp_path / "mine.pid"):
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
_maybe_auto_ingest()
cmd = mock_popen.call_args[0][0]
assert "--mode" in cmd and cmd[cmd.index("--mode") + 1] == "projects"
assert "--wing" not in cmd