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
15 changes: 15 additions & 0 deletions mempalace/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,21 @@ def max_backups(self) -> int:
)
return DEFAULT_MAX_BACKUPS if coerced is None else coerced

@property
def hook_transcript_wing(self) -> str:
"""Destination policy for hook-driven transcript mining.

``sessions`` preserves the historical shared wing. ``project`` derives a
stable per-project wing from the transcript JSONL cwd/path. Invalid values
fail safe to ``sessions`` so upgrades cannot silently relocate memories.
"""

env_val = os.environ.get("MEMPALACE_HOOK_TRANSCRIPT_WING")
hooks = self._file_config.get("hooks", {})
raw = env_val if env_val is not None else hooks.get("transcript_wing", "sessions")
value = str(raw or "").strip().lower()
return value if value in {"sessions", "project"} else "sessions"

@property
def hook_silent_save(self):
"""Whether the stop hook saves directly (True) or blocks for MCP calls (False)."""
Expand Down
33 changes: 28 additions & 5 deletions mempalace/hooks_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -531,11 +531,18 @@ def _hooks_daemon_enabled() -> bool:
return False


def _daemon_mine_dedupe_key(source: str, mode: str) -> str:
def _daemon_mine_dedupe_key(
source: str,
mode: str,
wing: Optional[str] = None,
) -> str:
try:
source_key = str(Path(source).expanduser().resolve())
except OSError:
source_key = str(Path(source).expanduser())
wing_key = str(wing or "").strip()
if wing_key:
return f"hook:mine:{mode}:{wing_key}:{source_key}"
return f"hook:mine:{mode}:{source_key}"


Expand Down Expand Up @@ -1025,6 +1032,16 @@ def _save_diary_direct(
return {"count": 0}


def _transcript_ingest_wing(path: Path, config: MempalaceConfig) -> str:
"""Resolve the hook transcript destination without breaking old installs."""

policy = str(getattr(config, "hook_transcript_wing", "sessions") or "").strip().lower()
if policy != "project":
return "sessions"
derived = _wing_from_transcript_path(str(path))
return derived if derived != "wing_sessions" else "sessions"


def _ingest_transcript(transcript_path: str):
"""Mine a Claude Code session transcript into the palace as a conversation."""
path = _validate_transcript_path(transcript_path)
Expand All @@ -1037,7 +1054,7 @@ def _ingest_transcript(transcript_path: str):
return

try:
MempalaceConfig() # validate config loads
config = MempalaceConfig()
except Exception:
return

Expand All @@ -1046,6 +1063,8 @@ def _ingest_transcript(transcript_path: str):
_log_hook_write_blocked(routing, "transcript ingest")
return

transcript_wing = _transcript_ingest_wing(path, config)

try:
if routing.use_daemon:
try:
Expand All @@ -1054,10 +1073,14 @@ def _ingest_transcript(transcript_path: str):
{
"source": str(path.parent),
"mode": "convos",
"wing": "sessions",
"wing": transcript_wing,
"agent": "mempalace",
},
dedupe_key=_daemon_mine_dedupe_key(str(path.parent), "convos"),
dedupe_key=_daemon_mine_dedupe_key(
str(path.parent),
"convos",
transcript_wing,
),
wait=False,
)
_log(f"Transcript ingest submitted to daemon: {path.name}")
Expand All @@ -1079,7 +1102,7 @@ def _ingest_transcript(transcript_path: str):
"--mode",
"convos",
"--wing",
"sessions",
transcript_wing,
]
)
_log(f"Transcript ingest started: {path.name}")
Expand Down
106 changes: 105 additions & 1 deletion tests/test_hooks_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@
from hypothesis import strategies as st

import mempalace.hooks_cli as hooks_cli_mod
from mempalace.config import sanitize_name
from mempalace.config import MempalaceConfig, sanitize_name
from mempalace.hooks_cli import (
SAVE_INTERVAL,
_count_human_messages,
_diary_agent_for_harness,
_extract_recent_messages,
_get_mine_targets,
_hooks_daemon_enabled,
_ingest_transcript,
_log,
_maybe_auto_ingest,
_mempalace_python,
Expand Down Expand Up @@ -512,6 +513,109 @@ def test_hooks_daemon_enabled_requires_explicit_true():
assert _hooks_daemon_enabled() is True


def _write_ingest_transcript(path: Path, *, cwd: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
entries = [
{"type": "user", "cwd": cwd, "content": "x" * 80},
{"message": {"role": "user", "content": "y" * 80}},
]
path.write_text(
"".join(json.dumps(entry) + "\n" for entry in entries),
encoding="utf-8",
)


def test_hook_transcript_wing_defaults_to_sessions(tmp_path, monkeypatch):
monkeypatch.delenv("MEMPALACE_HOOK_TRANSCRIPT_WING", raising=False)
assert MempalaceConfig(config_dir=tmp_path).hook_transcript_wing == "sessions"


def test_hook_transcript_wing_reads_project_config(tmp_path, monkeypatch):
monkeypatch.delenv("MEMPALACE_HOOK_TRANSCRIPT_WING", raising=False)
(tmp_path / "config.json").write_text(
json.dumps({"hooks": {"transcript_wing": "project"}}),
encoding="utf-8",
)
assert MempalaceConfig(config_dir=tmp_path).hook_transcript_wing == "project"


def test_hook_transcript_wing_env_overrides_config(tmp_path, monkeypatch):
(tmp_path / "config.json").write_text(
json.dumps({"hooks": {"transcript_wing": "sessions"}}),
encoding="utf-8",
)
monkeypatch.setenv("MEMPALACE_HOOK_TRANSCRIPT_WING", "project")
assert MempalaceConfig(config_dir=tmp_path).hook_transcript_wing == "project"


def test_hook_transcript_wing_invalid_value_fails_safe_to_sessions(tmp_path, monkeypatch):
monkeypatch.setenv("MEMPALACE_HOOK_TRANSCRIPT_WING", "surprise")
assert MempalaceConfig(config_dir=tmp_path).hook_transcript_wing == "sessions"


def test_ingest_transcript_keeps_sessions_by_default(tmp_path):
transcript = tmp_path / "project" / "session.jsonl"
_write_ingest_transcript(transcript, cwd="/Users/eddie/git/Starframe-PC-Control")
config = MagicMock()
config.hook_transcript_wing = "sessions"
routing = MagicMock(blocked=False, use_daemon=False)

with (
patch("mempalace.hooks_cli.MempalaceConfig", return_value=config),
patch("mempalace.hooks_cli._current_hook_write_routing", return_value=routing),
patch("mempalace.hooks_cli._mempalace_python", return_value="/venv/python"),
patch("mempalace.hooks_cli._spawn_mine") as spawn,
):
_ingest_transcript(str(transcript))

cmd = spawn.call_args.args[0]
assert cmd[cmd.index("--wing") + 1] == "sessions"


def test_ingest_transcript_project_mode_uses_stable_cwd_wing(tmp_path):
transcript = (
tmp_path / ".claude" / "projects" / "-Users-eddie-Starframe-PC-Control" / "session.jsonl"
)
_write_ingest_transcript(transcript, cwd="/Users/eddie/git/Starframe-PC-Control")
config = MagicMock()
config.hook_transcript_wing = "project"
routing = MagicMock(blocked=False, use_daemon=False)

with (
patch("mempalace.hooks_cli.MempalaceConfig", return_value=config),
patch("mempalace.hooks_cli._current_hook_write_routing", return_value=routing),
patch("mempalace.hooks_cli._mempalace_python", return_value="/venv/python"),
patch("mempalace.hooks_cli._spawn_mine") as spawn,
):
_ingest_transcript(str(transcript))

cmd = spawn.call_args.args[0]
assert cmd[cmd.index("--wing") + 1] == "wing_starframe_pc_control"


def test_ingest_transcript_project_mode_routes_daemon_and_dedupe_by_wing(tmp_path):
transcript = (
tmp_path / ".claude" / "projects" / "-Users-eddie-Starframe-PC-Control" / "session.jsonl"
)
_write_ingest_transcript(transcript, cwd="/Users/eddie/git/Starframe-PC-Control")
config = MagicMock()
config.hook_transcript_wing = "project"
routing = MagicMock(blocked=False, use_daemon=True)

with (
patch("mempalace.hooks_cli.MempalaceConfig", return_value=config),
patch("mempalace.hooks_cli._current_hook_write_routing", return_value=routing),
patch("mempalace.hooks_cli._submit_daemon_job") as submit,
):
_ingest_transcript(str(transcript))

submit.assert_called_once()
payload = submit.call_args.args[1]
assert payload["wing"] == "wing_starframe_pc_control"
dedupe_key = submit.call_args.kwargs["dedupe_key"]
assert ":convos:wing_starframe_pc_control:" in dedupe_key


# --- hook_session_start ---


Expand Down