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
43 changes: 41 additions & 2 deletions mempalace/dynamics.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
massed.

This module is pure. No I/O, no DB, no chromadb. It operates on plain
dicts (hall records, tunnel records); the math lives here in one place so
both connection kinds share identical semantics.
dicts (hall records, tunnel records, drawer metadata records) and mutates
them in place; the math lives here in one place so connection kinds and
drawer salience share identical semantics.

``hallways.py`` and ``palace_graph.py`` currently call only
``initialize_dynamics_fields``: hall and tunnel records get these fields
Expand Down Expand Up @@ -110,6 +111,44 @@ def initialize_dynamics_fields(connection: dict, *, now: Optional[datetime] = No
return connection


def initialize_drawer_dynamics_fields(
drawer_metadata: dict, *, now: Optional[datetime] = None
) -> dict:
"""Populate dynamics fields on drawer metadata, using ``filed_at`` as creation time.

Drawer metadata historically has ``filed_at`` rather than connection-style
``created_at``. This adapter keeps the shared dynamics math unchanged while
preserving the drawer metadata shape: ``created_at`` is only supplied as a
temporary fallback and is not left behind when absent from the input.
"""

missing_created_at = "created_at" not in drawer_metadata
if missing_created_at and drawer_metadata.get("filed_at"):
drawer_metadata["created_at"] = drawer_metadata["filed_at"]

initialize_dynamics_fields(drawer_metadata, now=now)

if missing_created_at:
drawer_metadata.pop("created_at", None)

return drawer_metadata


def drawer_salience(drawer_metadata: dict, *, now: Optional[datetime] = None) -> dict:
"""Return lazy-decayed salience for drawer metadata without mutating input."""

record = dict(drawer_metadata or {})
initialize_drawer_dynamics_fields(record, now=now)
if _parse_iso(record.get("last_activated")) is not None:
apply_decay(record, now=now)
return {
"strength": float(record.get("strength", DEFAULT_STRENGTH)),
"stability": float(record.get("stability", DEFAULT_STABILITY)),
"last_activated": record.get("last_activated"),
"access_count": int(record.get("access_count", 0)),
}


# ─────────────────────────────────────────────────────────────────────────────
# Hebbian potentiation — strengthen on co-access
# ─────────────────────────────────────────────────────────────────────────────
Expand Down
6 changes: 6 additions & 0 deletions mempalace/mcp_server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@
)
from ..backends import BackendMismatchError, PalaceRef, detect_backend_for_path # noqa: E402
from ..date_window import filed_at_in_window, parse_date_bound # noqa: E402
from ..dynamics import ( # noqa: E402
apply_decay,
drawer_salience,
initialize_drawer_dynamics_fields,
potentiate,
)
from ..query_sanitizer import sanitize_query # noqa: E402
from ..source_identity import identity_metadata # noqa: E402
from ..searcher import ( # noqa: E402
Expand Down
22 changes: 22 additions & 0 deletions mempalace/mcp_server/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,28 @@
},
"handler": tool_list_drawers,
},
"mempalace_drawer_salience": {
"description": "List lazy-decayed per-drawer salience at logical drawer granularity. Chunked drawers are deduped by parent_drawer_id.",
"input_schema": {
"type": "object",
"properties": {
"wing": {"type": "string", "description": "Filter by wing (optional)"},
"room": {"type": "string", "description": "Filter by room (optional)"},
"limit": {
"type": "integer",
"description": "Max drawers to return (default 100, max 100)",
"minimum": 1,
"maximum": 100,
},
"order_by": {
"type": "string",
"enum": ["strength", "access_count", "last_activated"],
"description": "Sort field (default strength)",
},
},
},
"handler": tool_drawer_salience,
},
"mempalace_update_drawer": {
"description": "Update an existing drawer's content and/or metadata (wing, room). Fetches existing drawer first; returns error if not found.",
"input_schema": {
Expand Down
66 changes: 66 additions & 0 deletions mempalace/mcp_server/tools_read.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@

# ==================== READ TOOLS ====================

_SALIENCE_POTENTIATE_ENV = "MEMPALACE_SALIENCE_POTENTIATE"


def _now() -> datetime:
return datetime.now(timezone.utc)


def _tool_status_via_sqlite() -> dict:
"""Pure-sqlite status reader for the #1222 fallback path.
Expand Down Expand Up @@ -702,9 +708,69 @@ def tool_search(
}
if context:
result["context_received"] = True
if "results" in result:
_maybe_potentiate_search_results(result["results"])
for hit in result["results"]:
hit.pop("_parent_drawer_id", None)
return result


def _can_potentiate_on_search() -> bool:
if not _truthy_env(_SALIENCE_POTENTIATE_ENV):
return False
if _READ_ONLY:
return False
if _MCP_WRITER_LOCK_CM is not None:
return True
ok, _reason = _acquire_mcp_writer_lock()
return ok and _MCP_WRITER_LOCK_CM is not None


def _logical_ids_from_search_hits(hits: list[dict]) -> list[str]:
ids = []
seen = set()
for hit in hits:
drawer_id = hit.get("_parent_drawer_id") or hit.get("id") or hit.get("drawer_id")
if not drawer_id or drawer_id in seen:
continue
seen.add(drawer_id)
ids.append(drawer_id)
return ids


def _maybe_potentiate_search_results(hits: list[dict]) -> None:
"""Best-effort opt-in salience write for logical drawers surfaced by search."""
global _metadata_cache

if not hits or not _can_potentiate_on_search():
return

col = _get_collection()
if not col:
return

now = _now()
for drawer_id in _logical_ids_from_search_hits(hits):
try:
record = _logical_drawer_record(col, drawer_id)
if record is None:
continue
base_meta = dict(_safe_meta(record["metadata"]))
initialize_drawer_dynamics_fields(base_meta, now=now)
apply_decay(base_meta, now=now)
potentiate(base_meta, now=now)
update_metas = []
for old_meta in record["metadatas"]:
merged = dict(_safe_meta(old_meta))
for key in ("strength", "stability", "last_activated", "access_count"):
merged[key] = base_meta[key]
update_metas.append(merged)
col.update(ids=record["ids"], metadatas=update_metas)
_metadata_cache = None
except Exception:
logger.debug("drawer salience potentiation failed for %s", drawer_id, exc_info=True)


def tool_check_duplicate(content: str, threshold: float = 0.9):
_refresh_vector_disabled_flag()
if _vector_disabled:
Expand Down
85 changes: 73 additions & 12 deletions mempalace/mcp_server/tools_write.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ def _drawer_payload(record):
"content": record["content"],
"wing": safe_meta.get("wing", ""),
"room": safe_meta.get("room", ""),
"salience": drawer_salience(record["metadata"], now=_now()),
"metadata": safe_meta,
}

Expand All @@ -244,6 +245,19 @@ def _drawer_payload(record):
return payload


def _metadata_where_filter(wing: str = None, room: str = None):
conditions = []
if wing:
conditions.append({"wing": wing})
if room:
conditions.append({"room": room})
if len(conditions) == 1:
return conditions[0]
if len(conditions) > 1:
return {"$and": conditions}
return None


def _fetch_drawer_rows(col, where=None, page_size: int = 1000, include=None):
include = include or ["documents", "metadatas"]
ids = []
Expand Down Expand Up @@ -1258,18 +1272,7 @@ def tool_list_drawers(
return {"error": str(e)}

try:
where = None
conditions = []

if wing:
conditions.append({"wing": wing})
if room:
conditions.append({"room": room})

if len(conditions) == 1:
where = conditions[0]
elif len(conditions) > 1:
where = {"$and": conditions}
where = _metadata_where_filter(wing=wing, room=room)

listed = None
if _is_chroma_backend() and _config.palace_path:
Expand Down Expand Up @@ -1316,6 +1319,64 @@ def tool_list_drawers(
return {"error": str(e)}


def tool_drawer_salience(
wing: str = None,
room: str = None,
limit: int = 100,
order_by: str = "strength",
):
"""List lazy-decayed drawer salience at logical drawer granularity."""
limit = max(1, min(limit, _MAX_RESULTS))
if order_by not in {"strength", "access_count", "last_activated"}:
return {"error": "order_by must be one of: strength, access_count, last_activated"}

try:
wing = _sanitize_optional_name(wing, "wing")
room = _sanitize_optional_name(room, "room")
except ValueError as e:
return {"error": str(e)}

col = _get_collection()
if not col:
return _collection_error_or_no_palace()

try:
ids, documents, metadatas = _fetch_drawer_rows(
col, where=_metadata_where_filter(wing=wing, room=room)
)
drawers = _collapse_drawer_rows(ids, documents, metadatas)
rows = []
now = _now()
for drawer in drawers:
rows.append(
{
"id": drawer["drawer_id"],
"wing": drawer.get("wing", ""),
"room": drawer.get("room", ""),
**drawer_salience(drawer.get("metadata", {}), now=now),
}
)

def sort_key(item):
value = item.get(order_by)
if order_by == "last_activated":
parsed = datetime.min.replace(tzinfo=timezone.utc)
try:
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
except (TypeError, ValueError):
pass
return (parsed, item["id"])
return (value, item["id"])

rows.sort(key=sort_key, reverse=True)
return {"drawers": rows[:limit]}
except Exception as e:
logger.exception("tool_drawer_salience failed")
return {"error": str(e)}


def tool_update_drawer(drawer_id: str, content: str = None, wing: str = None, room: str = None):
"""Update an existing logical drawer's content and/or metadata."""
global _metadata_cache
Expand Down
1 change: 1 addition & 0 deletions mempalace/searcher/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from ..backends._inproc_sqlite import open_reader as open_palace_reader
from ..config import MempalaceConfig
from ..date_window import filed_at_in_window, parse_window
from ..dynamics import drawer_salience
from ..i18n import _canonical_lang, get_stopwords
from ..palace import (
_open_collection_or_explain,
Expand Down
1 change: 1 addition & 0 deletions mempalace/searcher/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@ def search_memories(
"effective_distance": round(effective_dist, 4),
"closet_boost": round(boost, 3),
"matched_via": matched_via,
"salience": drawer_salience(meta),
# Internal: retain the full source_file path + chunk_index so the
# enrichment step below doesn't have to reverse-lookup via
# basename-suffix matching (which silently collides when two
Expand Down
1 change: 1 addition & 0 deletions mempalace/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ def _wrapped(*args, **kwargs):
"mempalace_get_drawer",
"mempalace_get_drawers",
"mempalace_list_drawers",
"mempalace_drawer_salience",
"mempalace_diary_read",
"mempalace_kg_query",
"mempalace_kg_stats",
Expand Down
Loading
Loading