Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- run: pip install -r requirements.txt pytest
- run: pip install -e ".[dev]"
- run: python -m pytest tests/ -v

lint:
Expand Down
19 changes: 11 additions & 8 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
[build-system]
requires = ["setuptools>=64"]
build-backend = "setuptools.build_meta"

[project]
name = "mempalace"
version = "3.0.0"
Expand Down Expand Up @@ -38,14 +34,21 @@ Homepage = "https://github.com/milla-jovovich/mempalace"
Repository = "https://github.com/milla-jovovich/mempalace"
"Bug Tracker" = "https://github.com/milla-jovovich/mempalace/issues"

[tool.setuptools.packages.find]
include = ["mempalace*"]

[project.scripts]
mempalace = "mempalace:main"

[project.optional-dependencies]
dev = ["pytest>=7.0", "build>=1.0", "twine>=4.0"]
dev = ["pytest>=7.0", "ruff>=0.4.0"]

[dependency-groups]
dev = ["pytest>=7.0", "ruff>=0.4.0"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Comment on lines 37 to +48

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropping [project.optional-dependencies].dev in favor of [dependency-groups].dev breaks the documented contributor install command pip install -e ".[dev]" (see CONTRIBUTING.md:10). Consider either updating the docs or keeping a dev extra (possibly mirroring the dependency-group) so non-uv workflows still work.

Copilot uses AI. Check for mistakes.

[tool.hatch.build.targets.wheel]
packages = ["mempalace"]

[tool.ruff]
line-length = 100
Expand Down
2 changes: 0 additions & 2 deletions requirements.txt

This file was deleted.

169 changes: 169 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
"""
conftest.py — Shared fixtures for MemPalace tests.

Provides isolated palace and knowledge graph instances so tests never
touch the user's real data or leak temp files on failure.

HOME is redirected to a temp directory at module load time — before any
mempalace imports — so that module-level initialisations (e.g.
``_kg = KnowledgeGraph()`` in mcp_server) write to a throwaway location
instead of the real user profile.
"""

import os
import shutil
import tempfile

# ── Isolate HOME before any mempalace imports ──────────────────────────
_original_env = {}
_session_tmp = tempfile.mkdtemp(prefix="mempalace_session_")

for _var in ("HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"):
_original_env[_var] = os.environ.get(_var)

os.environ["HOME"] = _session_tmp
os.environ["USERPROFILE"] = _session_tmp
os.environ["HOMEDRIVE"] = os.path.splitdrive(_session_tmp)[0] or "C:"
os.environ["HOMEPATH"] = os.path.splitdrive(_session_tmp)[1] or _session_tmp

# Now it is safe to import mempalace modules that trigger initialisation.
import chromadb # noqa: E402
import pytest # noqa: E402

from mempalace.config import MempalaceConfig # noqa: E402
from mempalace.knowledge_graph import KnowledgeGraph # noqa: E402


Comment on lines +32 to +36

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests currently don’t globally isolate ~/HOME. Since parts of the library (e.g., KnowledgeGraph() default path via os.path.expanduser('~/.mempalace/...')) can create files on import/use, consider adding an autouse=True fixture here that sets HOME (and Windows equivalents) to a temp dir for the duration of the test session to guarantee no writes to a real user profile.

Suggested change
from mempalace.config import MempalaceConfig
from mempalace.knowledge_graph import KnowledgeGraph
_TEST_HOME_DIR = tempfile.mkdtemp(prefix="mempalace_home_")
_ORIGINAL_HOME_ENV = {
"HOME": os.environ.get("HOME"),
"USERPROFILE": os.environ.get("USERPROFILE"),
"HOMEDRIVE": os.environ.get("HOMEDRIVE"),
"HOMEPATH": os.environ.get("HOMEPATH"),
}
os.environ["HOME"] = _TEST_HOME_DIR
os.environ["USERPROFILE"] = _TEST_HOME_DIR
drive, path = os.path.splitdrive(_TEST_HOME_DIR)
os.environ["HOMEDRIVE"] = drive or ""
os.environ["HOMEPATH"] = path or _TEST_HOME_DIR
from mempalace.config import MempalaceConfig
from mempalace.knowledge_graph import KnowledgeGraph
@pytest.fixture(scope="session", autouse=True)
def isolated_home_dir():
"""Redirect user-home expansion to a temp directory for the test session."""
yield _TEST_HOME_DIR
for env_name, original_value in _ORIGINAL_HOME_ENV.items():
if original_value is None:
os.environ.pop(env_name, None)
else:
os.environ[env_name] = original_value
shutil.rmtree(_TEST_HOME_DIR, ignore_errors=True)

Copilot uses AI. Check for mistakes.
@pytest.fixture(scope="session", autouse=True)
def _isolate_home(tmp_path_factory):
"""Ensure HOME points to a temp dir for the entire test session.

The env vars were already set at module level (above) so that
module-level initialisations are captured. This fixture simply
restores the originals on teardown and cleans up the temp dir.
"""
yield
for var, orig in _original_env.items():
if orig is None:
os.environ.pop(var, None)
else:
os.environ[var] = orig
shutil.rmtree(_session_tmp, ignore_errors=True)


@pytest.fixture
def tmp_dir():
"""Create and auto-cleanup a temporary directory."""
d = tempfile.mkdtemp(prefix="mempalace_test_")
yield d
shutil.rmtree(d, ignore_errors=True)


@pytest.fixture
def palace_path(tmp_dir):
"""Path to an empty palace directory inside tmp_dir."""
p = os.path.join(tmp_dir, "palace")
os.makedirs(p)
return p


@pytest.fixture
def config(tmp_dir, palace_path):
"""A MempalaceConfig pointing at the temp palace."""
cfg_dir = os.path.join(tmp_dir, "config")
os.makedirs(cfg_dir)
import json

with open(os.path.join(cfg_dir, "config.json"), "w") as f:
json.dump({"palace_path": palace_path}, f)
return MempalaceConfig(config_dir=cfg_dir)


@pytest.fixture
def collection(palace_path):
"""A ChromaDB collection pre-seeded in the temp palace."""
client = chromadb.PersistentClient(path=palace_path)
col = client.get_or_create_collection("mempalace_drawers")
return col


@pytest.fixture
def seeded_collection(collection):
"""Collection with a handful of representative drawers."""
collection.add(
ids=[
"drawer_proj_backend_aaa",
"drawer_proj_backend_bbb",
"drawer_proj_frontend_ccc",
"drawer_notes_planning_ddd",
],
documents=[
"The authentication module uses JWT tokens for session management. "
"Tokens expire after 24 hours. Refresh tokens are stored in HttpOnly cookies.",
"Database migrations are handled by Alembic. We use PostgreSQL 15 "
"with connection pooling via pgbouncer.",
"The React frontend uses TanStack Query for server state management. "
"All API calls go through a centralized fetch wrapper.",
"Sprint planning: migrate auth to passkeys by Q3. "
"Evaluate ChromaDB alternatives for vector search.",
],
metadatas=[
{
"wing": "project",
"room": "backend",
"source_file": "auth.py",
"chunk_index": 0,
"added_by": "miner",
"filed_at": "2026-01-01T00:00:00",
},
{
"wing": "project",
"room": "backend",
"source_file": "db.py",
"chunk_index": 0,
"added_by": "miner",
"filed_at": "2026-01-02T00:00:00",
},
{
"wing": "project",
"room": "frontend",
"source_file": "App.tsx",
"chunk_index": 0,
"added_by": "miner",
"filed_at": "2026-01-03T00:00:00",
},
{
"wing": "notes",
"room": "planning",
"source_file": "sprint.md",
"chunk_index": 0,
"added_by": "miner",
"filed_at": "2026-01-04T00:00:00",
},
],
)
return collection


@pytest.fixture
def kg(tmp_dir):
"""An isolated KnowledgeGraph using a temp SQLite file."""
db_path = os.path.join(tmp_dir, "test_kg.sqlite3")
return KnowledgeGraph(db_path=db_path)


@pytest.fixture
def seeded_kg(kg):
"""KnowledgeGraph pre-loaded with sample triples."""
kg.add_entity("Alice", entity_type="person")
kg.add_entity("Max", entity_type="person")
kg.add_entity("swimming", entity_type="activity")
kg.add_entity("chess", entity_type="activity")

kg.add_triple("Alice", "parent_of", "Max", valid_from="2015-04-01")
kg.add_triple("Max", "does", "swimming", valid_from="2025-01-01")
kg.add_triple("Max", "does", "chess", valid_from="2024-06-01")
kg.add_triple("Alice", "works_at", "Acme Corp", valid_from="2020-01-01", valid_to="2024-12-31")
kg.add_triple("Alice", "works_at", "NewCo", valid_from="2025-01-01")

return kg
157 changes: 157 additions & 0 deletions tests/test_dialect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"""
test_dialect.py — Tests for the AAAK Dialect compression system.

Covers plain text compression, entity detection, emotion detection,
topic extraction, key sentence extraction, zettel encoding, and stats.
"""

from mempalace.dialect import Dialect


class TestPlainTextCompression:
def test_compress_basic(self):
d = Dialect()
result = d.compress("We decided to use GraphQL instead of REST for the API layer.")
assert isinstance(result, str)
assert len(result) > 0
# AAAK format uses pipe-separated fields
assert "|" in result

def test_compress_with_metadata(self):
d = Dialect()
result = d.compress(
"Authentication now uses JWT tokens.",
metadata={"wing": "project", "room": "backend", "source_file": "auth.py"},
)
assert "project" in result
assert "backend" in result

def test_compress_produces_entity_codes(self):
d = Dialect(entities={"Alice": "ALC", "Bob": "BOB"})
result = d.compress("Alice told Bob about the new deployment strategy.")
assert "ALC" in result or "BOB" in result

def test_compress_empty_text(self):
d = Dialect()
result = d.compress("")
assert isinstance(result, str)


class TestEntityDetection:
def test_known_entities(self):
d = Dialect(entities={"Alice": "ALC"})
found = d._detect_entities_in_text("Alice went to the store.")
assert "ALC" in found

def test_auto_code_unknown_entities(self):
d = Dialect()
found = d._detect_entities_in_text("I spoke with Bernardo about the project today.")
assert any(code for code in found if len(code) == 3)

def test_skip_names(self):
d = Dialect(entities={"Gandalf": "GAN"}, skip_names=["Gandalf"])
code = d.encode_entity("Gandalf")
assert code is None


class TestEmotionDetection:
def test_detect_emotions(self):
d = Dialect()
emotions = d._detect_emotions("I'm really excited and happy about this breakthrough!")
assert len(emotions) > 0

def test_max_three_emotions(self):
d = Dialect()
text = "I feel scared, happy, angry, surprised, disgusted, and confused."
emotions = d._detect_emotions(text)
assert len(emotions) <= 3


class TestTopicExtraction:
def test_extract_topics(self):
d = Dialect()
topics = d._extract_topics(
"The Python authentication server uses PostgreSQL for storage "
"and Redis for caching sessions."
)
assert len(topics) > 0
assert len(topics) <= 3

def test_boosts_technical_terms(self):
d = Dialect()
topics = d._extract_topics("GraphQL vs REST: we chose GraphQL for the new API endpoint.")
# "graphql" should appear since it's mentioned twice + capitalized
topic_lower = [t.lower() for t in topics]
assert "graphql" in topic_lower


class TestKeySentenceExtraction:
def test_extract_key_sentence(self):
d = Dialect()
text = (
"The server runs on port 3000. "
"We decided to use PostgreSQL instead of MongoDB. "
"The config file needs updating."
)
key = d._extract_key_sentence(text)
assert "decided" in key.lower() or "instead" in key.lower()

def test_truncates_long_sentences(self):
d = Dialect()
text = "a " * 100 # very long
key = d._extract_key_sentence(text)
assert len(key) <= 55


class TestCompressionStats:
def test_stats(self):
d = Dialect()
original = "We decided to use GraphQL instead of REST. " * 10
compressed = d.compress(original)
stats = d.compression_stats(original, compressed)
assert stats["ratio"] > 1
assert stats["original_chars"] > stats["compressed_chars"]

def test_count_tokens(self):
assert Dialect.count_tokens("hello world") == len("hello world") // 3


class TestZettelEncoding:
def test_encode_zettel(self):
d = Dialect(entities={"Alice": "ALC"})
zettel = {
"id": "zettel-001",
"people": ["Alice"],
"topics": ["memory", "ai"],
"content": 'She said "I want to remember everything"',
"emotional_weight": 0.9,
"emotional_tone": ["joy"],
"origin_moment": False,
"sensitivity": "",
"notes": "",
"origin_label": "",
"title": "Test - Memory Discussion",
}
result = d.encode_zettel(zettel)
assert "ALC" in result
assert "memory" in result

def test_encode_tunnel(self):
d = Dialect()
tunnel = {"from": "zettel-001", "to": "zettel-002", "label": "follows: temporal"}
result = d.encode_tunnel(tunnel)
assert "T:" in result
assert "001" in result
assert "002" in result


class TestDecode:
def test_decode_roundtrip(self):
d = Dialect()
encoded = (
'001|ALC+BOB|2025-01-01|test_title\nARC:journey\n001:ALC|memory_ai|"test quote"|0.9|joy'
)
decoded = d.decode(encoded)
assert decoded["header"]["file"] == "001"
assert decoded["arc"] == "journey"
assert len(decoded["zettels"]) == 1
Loading