route

@dataclass
class MemoryConfig:
    """Configuration for hybrid memory system."""
    embedding_model: str = "sentence-transformers/all-MiniLM-L6-v2"
    query_dim: int = 512
    key_dim: int = 64
    value_dim: int = 64
    num_heads: int = 4
    device: Optional[str] = None

class MemoryRouter:
    """Routes queries to appropriate memory systems."""

    def __init__(self):
        self.code_indicators = {
            "def ", "class ", "import ", "return", "print(",
            "for ", "while ", "if ", "else:", "try:",
            ".py", "```python"
        }

    def route(self, text: str) -> str:
        """Route text to appropriate memory system."""
        if self._is_code_related(text):
            return "product_key"
        if self._is_natural_language(text):
            return "episodic"
        return "hybrid"

    def _is_code_related(self, text: str) -> bool:
        """Check if text appears to be code-related."""
        text = text.lower()
        return any(indicator in text for indicator in self.code_indicators)

    def _is_natural_language(self, text: str) -> bool:
        """Check if text appears to be natural language."""
        # Simple heuristic: longer sentences, fewer special characters
        words = text.split()
        if not words:
            return False
        avg_word_len = sum(len(w) for w in words) / len(words)
        special_char_ratio = sum(1 for c in text if not c.isalnum()) / len(text)
        return avg_word_len > 3 and special_char_ratio < 0.2

    def merge_results(
        self,
        episodic_results: List[Dict[str, Any]],
        product_key_results: List[Dict[str, Any]],


    device: Optional[str] = None

class MemoryRouter:
    """Routes queries to appropriate memory systems."""

    def __init__(self):
        self.code_indicators = {
            "def ", "class ", "import ", "return", "print(",
            "for ", "while ", "if ", "else:", "try:",
            ".py", "```python"
        }

    def route(self, text: str) -> str:
        """Route text to appropriate memory system."""
        if self._is_code_related(text):
            return "product_key"
        if self._is_natural_language(text):
            return "episodic"
        return "hybrid"

    def _is_code_related(self, text: str) -> bool:
        """Check if text appears to be code-related."""
        text = text.lower()
        return any(indicator in text for indicator in self.code_indicators)

    def _is_natural_language(self, text: str) -> bool:
        """Check if text appears to be natural language."""
        # Simple heuristic: longer sentences, fewer special characters
        words = text.split()
        if not words:
            return False
        avg_word_len = sum(len(w) for w in words) / len(words)
        special_char_ratio = sum(1 for c in text if not c.isalnum()) / len(text)
        return avg_word_len > 3 and special_char_ratio < 0.2

    def merge_results(
        self,
        episodic_results: List[Dict[str, Any]],
        product_key_results: List[Dict[str, Any]],
        weights: Optional[Dict[str, float]] = None
    ) -> List[Dict[str, Any]]:
        """Merge results from different memory systems."""
        if weights is None:
            weights = {"episodic": 0.5, "product_key": 0.5}


    def __init__(self):
        self.code_indicators = {
            "def ", "class ", "import ", "return", "print(",
            "for ", "while ", "if ", "else:", "try:",
            ".py", "```python"
        }

    def route(self, text: str) -> str:
        """Route text to appropriate memory system."""
        if self._is_code_related(text):
            return "product_key"
        if self._is_natural_language(text):
            return "episodic"
        return "hybrid"

    def _is_code_related(self, text: str) -> bool:
        """Check if text appears to be code-related."""
        text = text.lower()
        return any(indicator in text for indicator in self.code_indicators)

    def _is_natural_language(self, text: str) -> bool:
        """Check if text appears to be natural language."""
        # Simple heuristic: longer sentences, fewer special characters
        words = text.split()
        if not words:
            return False
        avg_word_len = sum(len(w) for w in words) / len(words)
        special_char_ratio = sum(1 for c in text if not c.isalnum()) / len(text)
        return avg_word_len > 3 and special_char_ratio < 0.2

    def merge_results(
        self,
        episodic_results: List[Dict[str, Any]],
        product_key_results: List[Dict[str, Any]],
        weights: Optional[Dict[str, float]] = None
    ) -> List[Dict[str, Any]]:
        """Merge results from different memory systems."""
        if weights is None:
            weights = {"episodic": 0.5, "product_key": 0.5}

        # Normalize scores within each system
        def normalize_scores(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
            if not results:
                return results
            max_score = max(r["score"] for r in results)
            min_score = min(r["score"] for r in results)
            score_range = max_score - min_score
            if score_range == 0:
                score_range = 1


            return "product_key"
        if self._is_natural_language(text):
            return "episodic"
        return "hybrid"

    def _is_code_related(self, text: str) -> bool:
        """Check if text appears to be code-related."""
        text = text.lower()
        return any(indicator in text for indicator in self.code_indicators)

    def _is_natural_language(self, text: str) -> bool:
        """Check if text appears to be natural language."""
        # Simple heuristic: longer sentences, fewer special characters
        words = text.split()
        if not words:
            return False
        avg_word_len = sum(len(w) for w in words) / len(words)
        special_char_ratio = sum(1 for c in text if not c.isalnum()) / len(text)
        return avg_word_len > 3 and special_char_ratio < 0.2

    def merge_results(
        self,
        episodic_results: List[Dict[str, Any]],
        product_key_results: List[Dict[str, Any]],
        weights: Optional[Dict[str, float]] = None
    ) -> List[Dict[str, Any]]:
        """Merge results from different memory systems."""
        if weights is None:
            weights = {"episodic": 0.5, "product_key": 0.5}

        # Normalize scores within each system
        def normalize_scores(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
            if not results:
                return results
            max_score = max(r["score"] for r in results)
            min_score = min(r["score"] for r in results)
            score_range = max_score - min_score
            if score_range == 0:
                score_range = 1
            for r in results:
                r["score"] = (r["score"] - min_score) / score_range
            return results

        episodic_results = normalize_scores(episodic_results)
        product_key_results = normalize_scores(product_key_results)

        # Apply weights
        for r in episodic_results:
            r["score"] *= weights["episodic"]
        for r in product_key_results:
            r["score"] *= weights["product_key"]

        # Merge and sort
        all_results = episodic_results + product_key_results
        return sorted(all_results, key=lambda x: x["score"], reverse=True)

class HybridMemory:
    """Combines episodic and product key memories with intelligent routing."""

    def __init__(self, config: Optional[MemoryConfig] = None):
        if config is None:
            config = MemoryConfig()

        self.config = config
        self.episodic = EpisodicMemory(
            model_name=config.embedding_model,
            device=config.device
        )
        self.product_key = ProductKeyMemory(
            query_dim=config.query_dim,
            key_dim=config.key_dim,
            value_dim=config.value_dim,
            num_heads=config.num_heads
        )
        self.router = MemoryRouter()


add

class HybridMemory:
    """Combines episodic and product key memories with intelligent routing."""

    def __init__(self, config: Optional[MemoryConfig] = None):
        if config is None:
            config = MemoryConfig()

        self.config = config
        self.episodic = EpisodicMemory(
            model_name=config.embedding_model,
            device=config.device
        )
        self.product_key = ProductKeyMemory(
            query_dim=config.query_dim,
            key_dim=config.key_dim,
            value_dim=config.value_dim,
            num_heads=config.num_heads
        )
        self.router = MemoryRouter()

    def add(
        self,
        uid: str,
        text: str,
        memory_type: str = "auto",
        tags: Optional[List[str]] = None,
        quality: float = 0.5,
        **kwargs
    ) -> None:
        """Add item to appropriate memory system."""
        if memory_type == "auto":
            memory_type = self.router.route(text)

        if memory_type in ("episodic", "hybrid"):
            self.episodic.add(
                uid=uid,
                text=text,
                tags=tags,
                quality=quality,
                **kwargs
            )

        if memory_type in ("product_key", "hybrid"):
            # Convert to tensor format for product key memory
            self.product_key.store(
                text,
                quality=quality,
                metadata={"uid": uid, "tags": tags}
            )

    def query(
        self,
        text: str,
        memory_type: str = "auto",
        k: int = 5,
        quality_weight: float = 0.3,
        **kwargs
    ) -> List[Dict[str, Any]]:
        """Query from appropriate memory system."""
        if memory_type == "auto":
            memory_type = self.router.route(text)

        if memory_type == "episodic":
            return self.episodic.query(
                text,
                k=k,
                quality_weight=quality_weight,
                **kwargs
            )


query

        if memory_type == "auto":
            memory_type = self.router.route(text)

        if memory_type in ("episodic", "hybrid"):
            self.episodic.add(
                uid=uid,
                text=text,
                tags=tags,
                quality=quality,
                **kwargs
            )

        if memory_type in ("product_key", "hybrid"):
            # Convert to tensor format for product key memory
            self.product_key.store(
                text,
                quality=quality,
                metadata={"uid": uid, "tags": tags}
            )

    def query(
        self,
        text: str,
        memory_type: str = "auto",
        k: int = 5,
        quality_weight: float = 0.3,
        **kwargs
    ) -> List[Dict[str, Any]]:
        """Query from appropriate memory system."""
        if memory_type == "auto":
            memory_type = self.router.route(text)

        if memory_type == "episodic":
            return self.episodic.query(
                text,
                k=k,
                quality_weight=quality_weight,
                **kwargs
            )

        if memory_type == "product_key":
            return self.product_key.query(
                text,
                k=k,
                **kwargs
            )

        # Hybrid: query both and merge results
        episodic_results = self.episodic.query(
            text,
            k=k,
            quality_weight=quality_weight,
            **kwargs
        )
        product_key_results = self.product_key.query(
            text,
            k=k,
            **kwargs
        )

        return self.router.merge_results(
            episodic_results,
            product_key_results
        )

    def save(self, out_dir: str) -> None:
        """Save both memory systems."""
        self.episodic.save(f"{out_dir}/episodic")
        self.product_key.save(f"{out_dir}/product_key")

    @classmethod
    def load(cls, in_dir: str, config: Optional[MemoryConfig] = None) -> "HybridMemory":
        """Load both memory systems."""
        mem = cls(config)
        mem.episodic = EpisodicMemory.load(f"{in_dir}/episodic")
        mem.product_key = ProductKeyMemory.load(f"{in_dir}/product_key")
        return mem


save

            )

        # Hybrid: query both and merge results
        episodic_results = self.episodic.query(
            text,
            k=k,
            quality_weight=quality_weight,
            **kwargs
        )
        product_key_results = self.product_key.query(
            text,
            k=k,
            **kwargs
        )

        return self.router.merge_results(
            episodic_results,
            product_key_results
        )

    def save(self, out_dir: str) -> None:
        """Save both memory systems."""
        self.episodic.save(f"{out_dir}/episodic")
        self.product_key.save(f"{out_dir}/product_key")

    @classmethod
    def load(cls, in_dir: str, config: Optional[MemoryConfig] = None) -> "HybridMemory":
        """Load both memory systems."""
        mem = cls(config)
        mem.episodic = EpisodicMemory.load(f"{in_dir}/episodic")
        mem.product_key = ProductKeyMemory.load(f"{in_dir}/product_key")
        return mem


load

            quality_weight=quality_weight,
            **kwargs
        )
        product_key_results = self.product_key.query(
            text,
            k=k,
            **kwargs
        )

        return self.router.merge_results(
            episodic_results,
            product_key_results
        )

    def save(self, out_dir: str) -> None:
        """Save both memory systems."""
        self.episodic.save(f"{out_dir}/episodic")
        self.product_key.save(f"{out_dir}/product_key")

    @classmethod
    def load(cls, in_dir: str, config: Optional[MemoryConfig] = None) -> "HybridMemory":
        """Load both memory systems."""
        mem = cls(config)
        mem.episodic = EpisodicMemory.load(f"{in_dir}/episodic")
        mem.product_key = ProductKeyMemory.load(f"{in_dir}/product_key")
        return mem


        """Check if text appears to be natural language."""
        # Simple heuristic: longer sentences, fewer special characters
        words = text.split()
        if not words:
            return False
        avg_word_len = sum(len(w) for w in words) / len(words)
        special_char_ratio = sum(1 for c in text if not c.isalnum()) / len(text)
        return avg_word_len > 3 and special_char_ratio < 0.2

    def merge_results(
        self,
        episodic_results: List[Dict[str, Any]],
        product_key_results: List[Dict[str, Any]],
        weights: Optional[Dict[str, float]] = None
    ) -> List[Dict[str, Any]]:
        """Merge results from different memory systems."""
        if weights is None:
            weights = {"episodic": 0.5, "product_key": 0.5}

        # Normalize scores within each system
        def normalize_scores(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
            if not results:
                return results
            max_score = max(r["score"] for r in results)
            min_score = min(r["score"] for r in results)
            score_range = max_score - min_score
            if score_range == 0:
                score_range = 1
            for r in results:
                r["score"] = (r["score"] - min_score) / score_range
            return results

        episodic_results = normalize_scores(episodic_results)
        product_key_results = normalize_scores(product_key_results)

        # Apply weights
        for r in episodic_results:
            r["score"] *= weights["episodic"]
        for r in product_key_results:
            r["score"] *= weights["product_key"]

        # Merge and sort
        all_results = episodic_results + product_key_results
        return sorted(all_results, key=lambda x: x["score"], reverse=True)

class HybridMemory:
    """Combines episodic and product key memories with intelligent routing."""

    def __init__(self, config: Optional[MemoryConfig] = None):
        if config is None:
            config = MemoryConfig()


from __future__ import annotations
import logging

import json
import os
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Tuple

import numpy as np

def _try_imports():
	try:
		import faiss  # type: ignore
	except Exception as e:
		raise RuntimeError("faiss is required. pip install faiss-cpu") from e
	try:
		from sentence_transformers import SentenceTransformer  # type: ignore
	except Exception as e:
		raise RuntimeError("sentence-transformers is required. pip install sentence-transformers") from e
	return faiss, SentenceTransformer

@dataclass
class MemoryItem:
	uid: str
	text: str
	tags: List[str] | None = None
	ts_epoch: float | None = None
	quality: float = 0.5

class EpisodicMemory:
	"""
	SentenceTransformers embeddings + FAISS (inner product) index.
	Cosine similarity via normalized embeddings.
	"""

	def __init__(self, model_name: str = "sentence-transformers/all-MiniLM-L6-v2", device: str | None = None) -> None:
		faiss, SentenceTransformer = _try_imports()
		self.faiss = faiss
		self.model_name = model_name
		self.model = SentenceTransformer(model_name, device=device)
		self.index = None  # type: ignore[var-annotated]
		self.items: List[MemoryItem] = []

	@staticmethod
	def _row_to_text(obj: Dict[str, Any]) -> Tuple[str, str]:
		uid = str(obj.get("task_id") or obj.get("id") or obj.get("uid") or "")
		# Concise canonical text from trace fields
		obs = obj.get("obs", {})
		plan = obj.get("plan", {})
		action = obj.get("action", {})
		result = obj.get("result", {})
		parts: List[str] = []
		if isinstance(obs, dict):
			parts.append(str(obs.get("content", "")))
		if isinstance(plan, dict):
			subgoals = plan.get("subgoals")
			if isinstance(subgoals, list):
				parts.extend([str(s) for s in subgoals])
		if isinstance(action, dict):
			parts.append(json.dumps(action, ensure_ascii=False))
		if isinstance(result, dict):
			parts.append(json.dumps(result, ensure_ascii=False))
		text = " \n ".join([p for p in parts if p]).strip()
		return uid, text

	@staticmethod
	def _extract_ts_and_quality(obj: Dict[str, Any]) -> Tuple[float | None, float]:
		ts_epoch: float | None = None
		# Try multiple timestamp locations
		for key in ("ts", "timestamp", "time"):
			try:
				val = obj.get(key)
				if isinstance(val, (int, float)):
					ts_epoch = float(val)
					break
				if isinstance(val, str) and val:
					# Support ISO8601 with 'Z'
					s = val.strip().replace("Z", "+00:00")
					try:
						dt = datetime.fromisoformat(s)
						if dt.tzinfo is None:
							dt = dt.replace(tzinfo=timezone.utc)
						ts_epoch = dt.timestamp()
						break
					except Exception:
						pass
			except Exception:
				continue
		# Quality heuristic: prefer successful, low-risk items
		qual = 0.5
		try:
			res = obj.get("result", {}) if isinstance(obj, dict) else {}
			status = str(res.get("status", "")).lower()
			ok = 1.0 if status == "ok" else 0.0
			crit = obj.get("critique", {}) if isinstance(obj, dict) else {}
			risk = float(crit.get("risk", 0.5)) if isinstance(crit, dict) else 0.5
			qual = max(0.0, min(1.0, 0.7 * ok + 0.3 * (1.0 - risk)))
		except Exception:
			qual = 0.5
		return ts_epoch, qual

	def _embed(self, texts: List[str]) -> np.ndarray:
		embs = self.model.encode(texts, batch_size=64, show_progress_bar=False, normalize_embeddings=True)
		if isinstance(embs, list):
			embs = np.array(embs, dtype=np.float32)
		return embs.astype(np.float32)

	def fit_from_jsonl(self, files: List[str]) -> int:
		rows: List[MemoryItem] = []
		for fp in files:
			p = Path(fp)
			if not p.exists():
				continue
			with p.open("r", encoding="utf-8") as f:
				for line in f:
					line = line.strip()
					if not line:
						continue
					try:
						obj = json.loads(line)
					except Exception:
						continue
					uid, text = self._row_to_text(obj)
					if not text:
						continue
					if not uid:
						uid = f"auto-{len(rows)}"
					# Optional tag from kind/domain
					kind = None
					try:
						kind = (obj.get("obs") or {}).get("kind")
					except Exception:
						kind = None
					tag_list = [str(kind)] if kind else None
					ts_epoch, qual = self._extract_ts_and_quality(obj)
					rows.append(MemoryItem(uid=uid, text=text, tags=tag_list, ts_epoch=ts_epoch, quality=qual))
		# Dedupe by content hash while preferring later entries (recency bias)
		if rows:
			seen: set[str] = set()
			dedup: List[MemoryItem] = []
			for it in reversed(rows):
				h = str(hash(it.text))
				if h in seen:
					continue
				seen.add(h)
				dedup.append(it)
			rows = list(reversed(dedup))
		if not rows:
			self.items = []
			self.index = None
			return 0
		self.items = rows
		texts = [it.text for it in rows]
		embs = self._embed(texts)
		d = int(embs.shape[1])
		index = self.faiss.IndexFlatIP(d)
		index.add(embs)
		self.index = index
		return len(rows)

	def add(self, uid: str, text: str, tags: List[str] | None = None, ts_epoch: float | None = None, quality: float = 0.5) -> None:
		if not text:
			return
		it = MemoryItem(uid=uid, text=text, tags=tags, ts_epoch=ts_epoch, quality=max(0.0, min(1.0, float(quality))))
		vec = self._embed([text])
		if self.index is None:
			d = int(vec.shape[1])
			self.index = self.faiss.IndexFlatIP(d)
		self.index.add(vec)
		self.items.append(it)

	def query(
		self,
		text: str,
		k: int = 5,
		recency_weight: float = 0.0,
		include_tags: List[str] | None = None,
		exclude_tags: List[str] | None = None,
		quality_weight: float = 0.0,
		max_age_days: float | None = None,
	) -> List[Dict[str, Any]]:
		if self.index is None or not self.items:
			return []
		q = self._embed([text])
		# Retrieve top-2k to allow light recency reweighting, then cut to k
		topn = min(max(2 * k, k), len(self.items))
		scores, idxs = self.index.search(q, topn)
		out: List[Dict[str, Any]] = []
		now_ts = datetime.now(timezone.utc).timestamp()
		for score, idx in zip(scores[0], idxs[0]):
			if idx < 0 or idx >= len(self.items):
				continue
			it = self.items[int(idx)]
			# Tag filter
			if include_tags:
				tags = set([t for t in (it.tags or []) if t])
				if not tags.intersection(include_tags):
					continue
			if exclude_tags:
				tags = set([t for t in (it.tags or []) if t])
				if tags.intersection(exclude_tags):
					continue
			# TTL filter
			if max_age_days is not None and it.ts_epoch is not None:
				age_days = (now_ts - float(it.ts_epoch)) / 86400.0
				if age_days > float(max_age_days):
					continue
			adj_score = float(score)
			if quality_weight:
				adj_score = float(adj_score + float(quality_weight) * (float(it.quality) - 0.5))
			out.append({
				"uid": it.uid,
				"score": float(adj_score),
				"text": it.text,
				"pos": int(idx),
				"tags": it.tags or [],
				"ts": (float(it.ts_epoch) if it.ts_epoch is not None else None),
				"quality": float(it.quality),
			})
		# Apply a simple linear recency bonus: newer items (higher pos) get a small boost
		if recency_weight and out:
			n = float(len(self.items))
			for r in out:
				pos = float(r.get("pos", 0))
				rec = (pos / max(1.0, n))
				r["score"] = float(r["score"] + recency_weight * rec)
			out.sort(key=lambda x: x.get("score", 0.0), reverse=True)
		return out[:k]

	def save(self, out_dir: str) -> None:
		if self.index is None:
			raise RuntimeError("memory index is empty; build before saving")
		faiss, _ = _try_imports()
		od = Path(out_dir)
		od.mkdir(parents=True, exist_ok=True)
		faiss.write_index(self.index, str(od / "index.faiss"))
		with (od / "items.jsonl").open("w", encoding="utf-8") as f:
			for it in self.items:
				f.write(json.dumps({
					"uid": it.uid,
					"text": it.text,
					"tags": it.tags or [],
					"ts": (float(it.ts_epoch) if it.ts_epoch is not None else None),
					"quality": float(it.quality),
				}, ensure_ascii=False) + "\n")
		with (od / "meta.json").open("w", encoding="utf-8") as f:
			json.dump({"model_name": self.model_name}, f)

	@classmethod
	def load(cls, in_dir: str, device: str | None = None) -> "EpisodicMemory":
		faiss, SentenceTransformer = _try_imports()
		od = Path(in_dir)
		meta = json.loads((od / "meta.json").read_text(encoding="utf-8"))
		mem = EpisodicMemory(model_name=str(meta.get("model_name", "sentence-transformers/all-MiniLM-L6-v2")), device=device)
		mem.index = faiss.read_index(str(od / "index.faiss"))
		items: List[MemoryItem] = []
		with (od / "items.jsonl").open("r", encoding="utf-8") as f:
			for line in f:
				obj = json.loads(line)
				items.append(MemoryItem(
					uid=str(obj.get("uid", "")),
					text=str(obj.get("text", "")),
					tags=list(obj.get("tags", [])),
					ts_epoch=(float(obj.get("ts")) if obj.get("ts") is not None else None),
					quality=float(obj.get("quality", 0.5)),
				))
		mem.items = items
		return mem


from __future__ import annotations
import logging

import json
import os
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Tuple

import numpy as np

def _try_imports():
	try:
		import faiss  # type: ignore
	except Exception as e:
		raise RuntimeError("faiss is required. pip install faiss-cpu") from e
	try:
		from sentence_transformers import SentenceTransformer  # type: ignore
	except Exception as e:
		raise RuntimeError("sentence-transformers is required. pip install sentence-transformers") from e
	return faiss, SentenceTransformer

@dataclass
class MemoryItem:
	uid: str
	text: str
	tags: List[str] | None = None
	ts_epoch: float | None = None
	quality: float = 0.5

class EpisodicMemory:
	"""
	SentenceTransformers embeddings + FAISS (inner product) index.
	Cosine similarity via normalized embeddings.
	"""

	def __init__(self, model_name: str = "sentence-transformers/all-MiniLM-L6-v2", device: str | None = None) -> None:
		faiss, SentenceTransformer = _try_imports()
		self.faiss = faiss


MemoryItem

from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Tuple

import numpy as np

def _try_imports():
	try:
		import faiss  # type: ignore
	except Exception as e:
		raise RuntimeError("faiss is required. pip install faiss-cpu") from e
	try:
		from sentence_transformers import SentenceTransformer  # type: ignore
	except Exception as e:
		raise RuntimeError("sentence-transformers is required. pip install sentence-transformers") from e
	return faiss, SentenceTransformer

@dataclass
class MemoryItem:
	uid: str
	text: str
	tags: List[str] | None = None
	ts_epoch: float | None = None
	quality: float = 0.5

class EpisodicMemory:
	"""
	SentenceTransformers embeddings + FAISS (inner product) index.
	Cosine similarity via normalized embeddings.
	"""

	def __init__(self, model_name: str = "sentence-transformers/all-MiniLM-L6-v2", device: str | None = None) -> None:
		faiss, SentenceTransformer = _try_imports()
		self.faiss = faiss
		self.model_name = model_name
		self.model = SentenceTransformer(model_name, device=device)
		self.index = None  # type: ignore[var-annotated]
		self.items: List[MemoryItem] = []

	@staticmethod
	def _row_to_text(obj: Dict[str, Any]) -> Tuple[str, str]:
		uid = str(obj.get("task_id") or obj.get("id") or obj.get("uid") or "")
		# Concise canonical text from trace fields


EpisodicMemory

	try:
		import faiss  # type: ignore
	except Exception as e:
		raise RuntimeError("faiss is required. pip install faiss-cpu") from e
	try:
		from sentence_transformers import SentenceTransformer  # type: ignore
	except Exception as e:
		raise RuntimeError("sentence-transformers is required. pip install sentence-transformers") from e
	return faiss, SentenceTransformer

@dataclass
class MemoryItem:
	uid: str
	text: str
	tags: List[str] | None = None
	ts_epoch: float | None = None
	quality: float = 0.5

class EpisodicMemory:
	"""
	SentenceTransformers embeddings + FAISS (inner product) index.
	Cosine similarity via normalized embeddings.
	"""

	def __init__(self, model_name: str = "sentence-transformers/all-MiniLM-L6-v2", device: str | None = None) -> None:
		faiss, SentenceTransformer = _try_imports()
		self.faiss = faiss
		self.model_name = model_name
		self.model = SentenceTransformer(model_name, device=device)
		self.index = None  # type: ignore[var-annotated]
		self.items: List[MemoryItem] = []

	@staticmethod
	def _row_to_text(obj: Dict[str, Any]) -> Tuple[str, str]:
		uid = str(obj.get("task_id") or obj.get("id") or obj.get("uid") or "")
		# Concise canonical text from trace fields
		obs = obj.get("obs", {})
		plan = obj.get("plan", {})
		action = obj.get("action", {})
		result = obj.get("result", {})
		parts: List[str] = []
		if isinstance(obs, dict):
			parts.append(str(obs.get("content", "")))
		if isinstance(plan, dict):
			subgoals = plan.get("subgoals")
			if isinstance(subgoals, list):
				parts.extend([str(s) for s in subgoals])
		if isinstance(action, dict):
			parts.append(json.dumps(action, ensure_ascii=False))
		if isinstance(result, dict):
			parts.append(json.dumps(result, ensure_ascii=False))
		text = " \n ".join([p for p in parts if p]).strip()
		return uid, text

	@staticmethod
	def _extract_ts_and_quality(obj: Dict[str, Any]) -> Tuple[float | None, float]:
		ts_epoch: float | None = None
		# Try multiple timestamp locations
		for key in ("ts", "timestamp", "time"):
			try:
				val = obj.get(key)
				if isinstance(val, (int, float)):
					ts_epoch = float(val)
					break
				if isinstance(val, str) and val:
					# Support ISO8601 with 'Z'
					s = val.strip().replace("Z", "+00:00")
					try:
						dt = datetime.fromisoformat(s)
						if dt.tzinfo is None:
							dt = dt.replace(tzinfo=timezone.utc)
						ts_epoch = dt.timestamp()
						break
					except Exception:
						pass
			except Exception:
				continue
		# Quality heuristic: prefer successful, low-risk items
		qual = 0.5
		try:
			res = obj.get("result", {}) if isinstance(obj, dict) else {}
			status = str(res.get("status", "")).lower()
			ok = 1.0 if status == "ok" else 0.0
			crit = obj.get("critique", {}) if isinstance(obj, dict) else {}
			risk = float(crit.get("risk", 0.5)) if isinstance(crit, dict) else 0.5
			qual = max(0.0, min(1.0, 0.7 * ok + 0.3 * (1.0 - risk)))
		except Exception:
			qual = 0.5
		return ts_epoch, qual

	def _embed(self, texts: List[str]) -> np.ndarray:
		embs = self.model.encode(texts, batch_size=64, show_progress_bar=False, normalize_embeddings=True)
		if isinstance(embs, list):
			embs = np.array(embs, dtype=np.float32)
		return embs.astype(np.float32)

	def fit_from_jsonl(self, files: List[str]) -> int:
		rows: List[MemoryItem] = []
		for fp in files:
			p = Path(fp)
			if not p.exists():
				continue
			with p.open("r", encoding="utf-8") as f:
				for line in f:
					line = line.strip()
					if not line:
						continue
					try:
						obj = json.loads(line)
					except Exception:
						continue
					uid, text = self._row_to_text(obj)
					if not text:
						continue
					if not uid:
						uid = f"auto-{len(rows)}"
					# Optional tag from kind/domain
					kind = None
					try:
						kind = (obj.get("obs") or {}).get("kind")
					except Exception:
						kind = None
					tag_list = [str(kind)] if kind else None
					ts_epoch, qual = self._extract_ts_and_quality(obj)
					rows.append(MemoryItem(uid=uid, text=text, tags=tag_list, ts_epoch=ts_epoch, quality=qual))
		# Dedupe by content hash while preferring later entries (recency bias)
		if rows:
			seen: set[str] = set()
			dedup: List[MemoryItem] = []
			for it in reversed(rows):
				h = str(hash(it.text))
				if h in seen:
					continue
				seen.add(h)
				dedup.append(it)
			rows = list(reversed(dedup))
		if not rows:
			self.items = []
			self.index = None
			return 0
		self.items = rows
		texts = [it.text for it in rows]
		embs = self._embed(texts)
		d = int(embs.shape[1])
		index = self.faiss.IndexFlatIP(d)
		index.add(embs)
		self.index = index
		return len(rows)

	def add(self, uid: str, text: str, tags: List[str] | None = None, ts_epoch: float | None = None, quality: float = 0.5) -> None:
		if not text:
			return
		it = MemoryItem(uid=uid, text=text, tags=tags, ts_epoch=ts_epoch, quality=max(0.0, min(1.0, float(quality))))
		vec = self._embed([text])
		if self.index is None:
			d = int(vec.shape[1])
			self.index = self.faiss.IndexFlatIP(d)
		self.index.add(vec)
		self.items.append(it)

	def query(
		self,
		text: str,
		k: int = 5,
		recency_weight: float = 0.0,
		include_tags: List[str] | None = None,
		exclude_tags: List[str] | None = None,
		quality_weight: float = 0.0,
		max_age_days: float | None = None,
	) -> List[Dict[str, Any]]:
		if self.index is None or not self.items:
			return []
		q = self._embed([text])
		# Retrieve top-2k to allow light recency reweighting, then cut to k
		topn = min(max(2 * k, k), len(self.items))
		scores, idxs = self.index.search(q, topn)
		out: List[Dict[str, Any]] = []
		now_ts = datetime.now(timezone.utc).timestamp()
		for score, idx in zip(scores[0], idxs[0]):
			if idx < 0 or idx >= len(self.items):
				continue
			it = self.items[int(idx)]
			# Tag filter
			if include_tags:
				tags = set([t for t in (it.tags or []) if t])
				if not tags.intersection(include_tags):
					continue
			if exclude_tags:
				tags = set([t for t in (it.tags or []) if t])
				if tags.intersection(exclude_tags):
					continue
			# TTL filter
			if max_age_days is not None and it.ts_epoch is not None:
				age_days = (now_ts - float(it.ts_epoch)) / 86400.0
				if age_days > float(max_age_days):
					continue
			adj_score = float(score)
			if quality_weight:
				adj_score = float(adj_score + float(quality_weight) * (float(it.quality) - 0.5))
			out.append({
				"uid": it.uid,
				"score": float(adj_score),
				"text": it.text,
				"pos": int(idx),
				"tags": it.tags or [],
				"ts": (float(it.ts_epoch) if it.ts_epoch is not None else None),
				"quality": float(it.quality),
			})
		# Apply a simple linear recency bonus: newer items (higher pos) get a small boost
		if recency_weight and out:
			n = float(len(self.items))
			for r in out:
				pos = float(r.get("pos", 0))
				rec = (pos / max(1.0, n))
				r["score"] = float(r["score"] + recency_weight * rec)
			out.sort(key=lambda x: x.get("score", 0.0), reverse=True)
		return out[:k]

	def save(self, out_dir: str) -> None:
		if self.index is None:
			raise RuntimeError("memory index is empty; build before saving")
		faiss, _ = _try_imports()
		od = Path(out_dir)
		od.mkdir(parents=True, exist_ok=True)
		faiss.write_index(self.index, str(od / "index.faiss"))
		with (od / "items.jsonl").open("w", encoding="utf-8") as f:
			for it in self.items:
				f.write(json.dumps({
					"uid": it.uid,
					"text": it.text,
					"tags": it.tags or [],
					"ts": (float(it.ts_epoch) if it.ts_epoch is not None else None),
					"quality": float(it.quality),
				}, ensure_ascii=False) + "\n")
		with (od / "meta.json").open("w", encoding="utf-8") as f:
			json.dump({"model_name": self.model_name}, f)

	@classmethod
	def load(cls, in_dir: str, device: str | None = None) -> "EpisodicMemory":
		faiss, SentenceTransformer = _try_imports()
		od = Path(in_dir)
		meta = json.loads((od / "meta.json").read_text(encoding="utf-8"))
		mem = EpisodicMemory(model_name=str(meta.get("model_name", "sentence-transformers/all-MiniLM-L6-v2")), device=device)
		mem.index = faiss.read_index(str(od / "index.faiss"))
		items: List[MemoryItem] = []
		with (od / "items.jsonl").open("r", encoding="utf-8") as f:
			for line in f:
				obj = json.loads(line)
				items.append(MemoryItem(
					uid=str(obj.get("uid", "")),
					text=str(obj.get("text", "")),
					tags=list(obj.get("tags", [])),
					ts_epoch=(float(obj.get("ts")) if obj.get("ts") is not None else None),
					quality=float(obj.get("quality", 0.5)),
				))
		mem.items = items
		return mem


	except Exception as e:
		raise RuntimeError("sentence-transformers is required. pip install sentence-transformers") from e
	return faiss, SentenceTransformer

@dataclass
class MemoryItem:
	uid: str
	text: str
	tags: List[str] | None = None
	ts_epoch: float | None = None
	quality: float = 0.5

class EpisodicMemory:
	"""
	SentenceTransformers embeddings + FAISS (inner product) index.
	Cosine similarity via normalized embeddings.
	"""

	def __init__(self, model_name: str = "sentence-transformers/all-MiniLM-L6-v2", device: str | None = None) -> None:
		faiss, SentenceTransformer = _try_imports()
		self.faiss = faiss
		self.model_name = model_name
		self.model = SentenceTransformer(model_name, device=device)
		self.index = None  # type: ignore[var-annotated]
		self.items: List[MemoryItem] = []

	@staticmethod
	def _row_to_text(obj: Dict[str, Any]) -> Tuple[str, str]:
		uid = str(obj.get("task_id") or obj.get("id") or obj.get("uid") or "")
		# Concise canonical text from trace fields
		obs = obj.get("obs", {})
		plan = obj.get("plan", {})
		action = obj.get("action", {})
		result = obj.get("result", {})
		parts: List[str] = []
		if isinstance(obs, dict):
			parts.append(str(obs.get("content", "")))
		if isinstance(plan, dict):
			subgoals = plan.get("subgoals")
			if isinstance(subgoals, list):
				parts.extend([str(s) for s in subgoals])
		if isinstance(action, dict):
			parts.append(json.dumps(action, ensure_ascii=False))
		if isinstance(result, dict):
			parts.append(json.dumps(result, ensure_ascii=False))


	tags: List[str] | None = None
	ts_epoch: float | None = None
	quality: float = 0.5

class EpisodicMemory:
	"""
	SentenceTransformers embeddings + FAISS (inner product) index.
	Cosine similarity via normalized embeddings.
	"""

	def __init__(self, model_name: str = "sentence-transformers/all-MiniLM-L6-v2", device: str | None = None) -> None:
		faiss, SentenceTransformer = _try_imports()
		self.faiss = faiss
		self.model_name = model_name
		self.model = SentenceTransformer(model_name, device=device)
		self.index = None  # type: ignore[var-annotated]
		self.items: List[MemoryItem] = []

	@staticmethod
	def _row_to_text(obj: Dict[str, Any]) -> Tuple[str, str]:
		uid = str(obj.get("task_id") or obj.get("id") or obj.get("uid") or "")
		# Concise canonical text from trace fields
		obs = obj.get("obs", {})
		plan = obj.get("plan", {})
		action = obj.get("action", {})
		result = obj.get("result", {})
		parts: List[str] = []
		if isinstance(obs, dict):
			parts.append(str(obs.get("content", "")))
		if isinstance(plan, dict):
			subgoals = plan.get("subgoals")
			if isinstance(subgoals, list):
				parts.extend([str(s) for s in subgoals])
		if isinstance(action, dict):
			parts.append(json.dumps(action, ensure_ascii=False))
		if isinstance(result, dict):
			parts.append(json.dumps(result, ensure_ascii=False))
		text = " \n ".join([p for p in parts if p]).strip()
		return uid, text

	@staticmethod
	def _extract_ts_and_quality(obj: Dict[str, Any]) -> Tuple[float | None, float]:
		ts_epoch: float | None = None
		# Try multiple timestamp locations
		for key in ("ts", "timestamp", "time"):
			try:
				val = obj.get(key)
				if isinstance(val, (int, float)):
					ts_epoch = float(val)
					break
				if isinstance(val, str) and val:
					# Support ISO8601 with 'Z'
					s = val.strip().replace("Z", "+00:00")
					try:
						dt = datetime.fromisoformat(s)
						if dt.tzinfo is None:
							dt = dt.replace(tzinfo=timezone.utc)
						ts_epoch = dt.timestamp()
						break


		# Concise canonical text from trace fields
		obs = obj.get("obs", {})
		plan = obj.get("plan", {})
		action = obj.get("action", {})
		result = obj.get("result", {})
		parts: List[str] = []
		if isinstance(obs, dict):
			parts.append(str(obs.get("content", "")))
		if isinstance(plan, dict):
			subgoals = plan.get("subgoals")
			if isinstance(subgoals, list):
				parts.extend([str(s) for s in subgoals])
		if isinstance(action, dict):
			parts.append(json.dumps(action, ensure_ascii=False))
		if isinstance(result, dict):
			parts.append(json.dumps(result, ensure_ascii=False))
		text = " \n ".join([p for p in parts if p]).strip()
		return uid, text

	@staticmethod
	def _extract_ts_and_quality(obj: Dict[str, Any]) -> Tuple[float | None, float]:
		ts_epoch: float | None = None
		# Try multiple timestamp locations
		for key in ("ts", "timestamp", "time"):
			try:
				val = obj.get(key)
				if isinstance(val, (int, float)):
					ts_epoch = float(val)
					break
				if isinstance(val, str) and val:
					# Support ISO8601 with 'Z'
					s = val.strip().replace("Z", "+00:00")
					try:
						dt = datetime.fromisoformat(s)
						if dt.tzinfo is None:
							dt = dt.replace(tzinfo=timezone.utc)
						ts_epoch = dt.timestamp()
						break
					except Exception:
						pass
			except Exception:
				continue
		# Quality heuristic: prefer successful, low-risk items
		qual = 0.5
		try:
			res = obj.get("result", {}) if isinstance(obj, dict) else {}
			status = str(res.get("status", "")).lower()
			ok = 1.0 if status == "ok" else 0.0
			crit = obj.get("critique", {}) if isinstance(obj, dict) else {}
			risk = float(crit.get("risk", 0.5)) if isinstance(crit, dict) else 0.5
			qual = max(0.0, min(1.0, 0.7 * ok + 0.3 * (1.0 - risk)))
		except Exception:
			qual = 0.5
		return ts_epoch, qual

	def _embed(self, texts: List[str]) -> np.ndarray:
		embs = self.model.encode(texts, batch_size=64, show_progress_bar=False, normalize_embeddings=True)
		if isinstance(embs, list):
			embs = np.array(embs, dtype=np.float32)
		return embs.astype(np.float32)

	def fit_from_jsonl(self, files: List[str]) -> int:
		rows: List[MemoryItem] = []
		for fp in files:
			p = Path(fp)
			if not p.exists():
				continue
			with p.open("r", encoding="utf-8") as f:
				for line in f:
					line = line.strip()
					if not line:
						continue
					try:
						obj = json.loads(line)


							dt = dt.replace(tzinfo=timezone.utc)
						ts_epoch = dt.timestamp()
						break
					except Exception:
						pass
			except Exception:
				continue
		# Quality heuristic: prefer successful, low-risk items
		qual = 0.5
		try:
			res = obj.get("result", {}) if isinstance(obj, dict) else {}
			status = str(res.get("status", "")).lower()
			ok = 1.0 if status == "ok" else 0.0
			crit = obj.get("critique", {}) if isinstance(obj, dict) else {}
			risk = float(crit.get("risk", 0.5)) if isinstance(crit, dict) else 0.5
			qual = max(0.0, min(1.0, 0.7 * ok + 0.3 * (1.0 - risk)))
		except Exception:
			qual = 0.5
		return ts_epoch, qual

	def _embed(self, texts: List[str]) -> np.ndarray:
		embs = self.model.encode(texts, batch_size=64, show_progress_bar=False, normalize_embeddings=True)
		if isinstance(embs, list):
			embs = np.array(embs, dtype=np.float32)
		return embs.astype(np.float32)

	def fit_from_jsonl(self, files: List[str]) -> int:
		rows: List[MemoryItem] = []
		for fp in files:
			p = Path(fp)
			if not p.exists():
				continue
			with p.open("r", encoding="utf-8") as f:
				for line in f:
					line = line.strip()
					if not line:
						continue
					try:
						obj = json.loads(line)
					except Exception:
						continue
					uid, text = self._row_to_text(obj)
					if not text:
						continue
					if not uid:


				continue
		# Quality heuristic: prefer successful, low-risk items
		qual = 0.5
		try:
			res = obj.get("result", {}) if isinstance(obj, dict) else {}
			status = str(res.get("status", "")).lower()
			ok = 1.0 if status == "ok" else 0.0
			crit = obj.get("critique", {}) if isinstance(obj, dict) else {}
			risk = float(crit.get("risk", 0.5)) if isinstance(crit, dict) else 0.5
			qual = max(0.0, min(1.0, 0.7 * ok + 0.3 * (1.0 - risk)))
		except Exception:
			qual = 0.5
		return ts_epoch, qual

	def _embed(self, texts: List[str]) -> np.ndarray:
		embs = self.model.encode(texts, batch_size=64, show_progress_bar=False, normalize_embeddings=True)
		if isinstance(embs, list):
			embs = np.array(embs, dtype=np.float32)
		return embs.astype(np.float32)

	def fit_from_jsonl(self, files: List[str]) -> int:
		rows: List[MemoryItem] = []
		for fp in files:
			p = Path(fp)
			if not p.exists():
				continue
			with p.open("r", encoding="utf-8") as f:
				for line in f:
					line = line.strip()
					if not line:
						continue
					try:
						obj = json.loads(line)
					except Exception:
						continue
					uid, text = self._row_to_text(obj)
					if not text:
						continue
					if not uid:
						uid = f"auto-{len(rows)}"
					# Optional tag from kind/domain
					kind = None
					try:
						kind = (obj.get("obs") or {}).get("kind")
					except Exception:
						kind = None
					tag_list = [str(kind)] if kind else None
					ts_epoch, qual = self._extract_ts_and_quality(obj)
					rows.append(MemoryItem(uid=uid, text=text, tags=tag_list, ts_epoch=ts_epoch, quality=qual))
		# Dedupe by content hash while preferring later entries (recency bias)
		if rows:
			seen: set[str] = set()
			dedup: List[MemoryItem] = []
			for it in reversed(rows):
				h = str(hash(it.text))
				if h in seen:
					continue
				seen.add(h)
				dedup.append(it)
			rows = list(reversed(dedup))
		if not rows:
			self.items = []
			self.index = None
			return 0
		self.items = rows
		texts = [it.text for it in rows]
		embs = self._embed(texts)
		d = int(embs.shape[1])
		index = self.faiss.IndexFlatIP(d)
		index.add(embs)
		self.index = index
		return len(rows)

	def add(self, uid: str, text: str, tags: List[str] | None = None, ts_epoch: float | None = None, quality: float = 0.5) -> None:
		if not text:
			return
		it = MemoryItem(uid=uid, text=text, tags=tags, ts_epoch=ts_epoch, quality=max(0.0, min(1.0, float(quality))))
		vec = self._embed([text])
		if self.index is None:
			d = int(vec.shape[1])
			self.index = self.faiss.IndexFlatIP(d)
		self.index.add(vec)
		self.items.append(it)

	def query(
		self,
		text: str,
		k: int = 5,
		recency_weight: float = 0.0,
		include_tags: List[str] | None = None,
		exclude_tags: List[str] | None = None,
		quality_weight: float = 0.0,


add

			for it in reversed(rows):
				h = str(hash(it.text))
				if h in seen:
					continue
				seen.add(h)
				dedup.append(it)
			rows = list(reversed(dedup))
		if not rows:
			self.items = []
			self.index = None
			return 0
		self.items = rows
		texts = [it.text for it in rows]
		embs = self._embed(texts)
		d = int(embs.shape[1])
		index = self.faiss.IndexFlatIP(d)
		index.add(embs)
		self.index = index
		return len(rows)

	def add(self, uid: str, text: str, tags: List[str] | None = None, ts_epoch: float | None = None, quality: float = 0.5) -> None:
		if not text:
			return
		it = MemoryItem(uid=uid, text=text, tags=tags, ts_epoch=ts_epoch, quality=max(0.0, min(1.0, float(quality))))
		vec = self._embed([text])
		if self.index is None:
			d = int(vec.shape[1])
			self.index = self.faiss.IndexFlatIP(d)
		self.index.add(vec)
		self.items.append(it)

	def query(
		self,
		text: str,
		k: int = 5,
		recency_weight: float = 0.0,
		include_tags: List[str] | None = None,
		exclude_tags: List[str] | None = None,
		quality_weight: float = 0.0,
		max_age_days: float | None = None,
	) -> List[Dict[str, Any]]:
		if self.index is None or not self.items:
			return []
		q = self._embed([text])
		# Retrieve top-2k to allow light recency reweighting, then cut to k
		topn = min(max(2 * k, k), len(self.items))
		scores, idxs = self.index.search(q, topn)
		out: List[Dict[str, Any]] = []
		now_ts = datetime.now(timezone.utc).timestamp()
		for score, idx in zip(scores[0], idxs[0]):


query

		self.items = rows
		texts = [it.text for it in rows]
		embs = self._embed(texts)
		d = int(embs.shape[1])
		index = self.faiss.IndexFlatIP(d)
		index.add(embs)
		self.index = index
		return len(rows)

	def add(self, uid: str, text: str, tags: List[str] | None = None, ts_epoch: float | None = None, quality: float = 0.5) -> None:
		if not text:
			return
		it = MemoryItem(uid=uid, text=text, tags=tags, ts_epoch=ts_epoch, quality=max(0.0, min(1.0, float(quality))))
		vec = self._embed([text])
		if self.index is None:
			d = int(vec.shape[1])
			self.index = self.faiss.IndexFlatIP(d)
		self.index.add(vec)
		self.items.append(it)

	def query(
		self,
		text: str,
		k: int = 5,
		recency_weight: float = 0.0,
		include_tags: List[str] | None = None,
		exclude_tags: List[str] | None = None,
		quality_weight: float = 0.0,
		max_age_days: float | None = None,
	) -> List[Dict[str, Any]]:
		if self.index is None or not self.items:
			return []
		q = self._embed([text])
		# Retrieve top-2k to allow light recency reweighting, then cut to k
		topn = min(max(2 * k, k), len(self.items))
		scores, idxs = self.index.search(q, topn)
		out: List[Dict[str, Any]] = []
		now_ts = datetime.now(timezone.utc).timestamp()
		for score, idx in zip(scores[0], idxs[0]):
			if idx < 0 or idx >= len(self.items):
				continue
			it = self.items[int(idx)]
			# Tag filter
			if include_tags:
				tags = set([t for t in (it.tags or []) if t])
				if not tags.intersection(include_tags):
					continue
			if exclude_tags:
				tags = set([t for t in (it.tags or []) if t])
				if tags.intersection(exclude_tags):
					continue
			# TTL filter
			if max_age_days is not None and it.ts_epoch is not None:
				age_days = (now_ts - float(it.ts_epoch)) / 86400.0
				if age_days > float(max_age_days):
					continue
			adj_score = float(score)
			if quality_weight:
				adj_score = float(adj_score + float(quality_weight) * (float(it.quality) - 0.5))
			out.append({
				"uid": it.uid,
				"score": float(adj_score),
				"text": it.text,
				"pos": int(idx),
				"tags": it.tags or [],
				"ts": (float(it.ts_epoch) if it.ts_epoch is not None else None),
				"quality": float(it.quality),
			})
		# Apply a simple linear recency bonus: newer items (higher pos) get a small boost
		if recency_weight and out:
			n = float(len(self.items))
			for r in out:
				pos = float(r.get("pos", 0))
				rec = (pos / max(1.0, n))
				r["score"] = float(r["score"] + recency_weight * rec)
			out.sort(key=lambda x: x.get("score", 0.0), reverse=True)
		return out[:k]

	def save(self, out_dir: str) -> None:
		if self.index is None:
			raise RuntimeError("memory index is empty; build before saving")
		faiss, _ = _try_imports()
		od = Path(out_dir)
		od.mkdir(parents=True, exist_ok=True)
		faiss.write_index(self.index, str(od / "index.faiss"))
		with (od / "items.jsonl").open("w", encoding="utf-8") as f:
			for it in self.items:
				f.write(json.dumps({
					"uid": it.uid,
					"text": it.text,
					"tags": it.tags or [],
					"ts": (float(it.ts_epoch) if it.ts_epoch is not None else None),
					"quality": float(it.quality),
				}, ensure_ascii=False) + "\n")
		with (od / "meta.json").open("w", encoding="utf-8") as f:
			json.dump({"model_name": self.model_name}, f)


save

				adj_score = float(adj_score + float(quality_weight) * (float(it.quality) - 0.5))
			out.append({
				"uid": it.uid,
				"score": float(adj_score),
				"text": it.text,
				"pos": int(idx),
				"tags": it.tags or [],
				"ts": (float(it.ts_epoch) if it.ts_epoch is not None else None),
				"quality": float(it.quality),
			})
		# Apply a simple linear recency bonus: newer items (higher pos) get a small boost
		if recency_weight and out:
			n = float(len(self.items))
			for r in out:
				pos = float(r.get("pos", 0))
				rec = (pos / max(1.0, n))
				r["score"] = float(r["score"] + recency_weight * rec)
			out.sort(key=lambda x: x.get("score", 0.0), reverse=True)
		return out[:k]

	def save(self, out_dir: str) -> None:
		if self.index is None:
			raise RuntimeError("memory index is empty; build before saving")
		faiss, _ = _try_imports()
		od = Path(out_dir)
		od.mkdir(parents=True, exist_ok=True)
		faiss.write_index(self.index, str(od / "index.faiss"))
		with (od / "items.jsonl").open("w", encoding="utf-8") as f:
			for it in self.items:
				f.write(json.dumps({
					"uid": it.uid,
					"text": it.text,
					"tags": it.tags or [],
					"ts": (float(it.ts_epoch) if it.ts_epoch is not None else None),
					"quality": float(it.quality),
				}, ensure_ascii=False) + "\n")
		with (od / "meta.json").open("w", encoding="utf-8") as f:
			json.dump({"model_name": self.model_name}, f)

	@classmethod
	def load(cls, in_dir: str, device: str | None = None) -> "EpisodicMemory":
		faiss, SentenceTransformer = _try_imports()
		od = Path(in_dir)
		meta = json.loads((od / "meta.json").read_text(encoding="utf-8"))
		mem = EpisodicMemory(model_name=str(meta.get("model_name", "sentence-transformers/all-MiniLM-L6-v2")), device=device)
		mem.index = faiss.read_index(str(od / "index.faiss"))
		items: List[MemoryItem] = []
		with (od / "items.jsonl").open("r", encoding="utf-8") as f:
			for line in f:
				obj = json.loads(line)
				items.append(MemoryItem(
					uid=str(obj.get("uid", "")),
					text=str(obj.get("text", "")),
					tags=list(obj.get("tags", [])),
					ts_epoch=(float(obj.get("ts")) if obj.get("ts") is not None else None),
					quality=float(obj.get("quality", 0.5)),
				))
		mem.items = items


load

	def save(self, out_dir: str) -> None:
		if self.index is None:
			raise RuntimeError("memory index is empty; build before saving")
		faiss, _ = _try_imports()
		od = Path(out_dir)
		od.mkdir(parents=True, exist_ok=True)
		faiss.write_index(self.index, str(od / "index.faiss"))
		with (od / "items.jsonl").open("w", encoding="utf-8") as f:
			for it in self.items:
				f.write(json.dumps({
					"uid": it.uid,
					"text": it.text,
					"tags": it.tags or [],
					"ts": (float(it.ts_epoch) if it.ts_epoch is not None else None),
					"quality": float(it.quality),
				}, ensure_ascii=False) + "\n")
		with (od / "meta.json").open("w", encoding="utf-8") as f:
			json.dump({"model_name": self.model_name}, f)

	@classmethod
	def load(cls, in_dir: str, device: str | None = None) -> "EpisodicMemory":
		faiss, SentenceTransformer = _try_imports()
		od = Path(in_dir)
		meta = json.loads((od / "meta.json").read_text(encoding="utf-8"))
		mem = EpisodicMemory(model_name=str(meta.get("model_name", "sentence-transformers/all-MiniLM-L6-v2")), device=device)
		mem.index = faiss.read_index(str(od / "index.faiss"))
		items: List[MemoryItem] = []
		with (od / "items.jsonl").open("r", encoding="utf-8") as f:
			for line in f:
				obj = json.loads(line)
				items.append(MemoryItem(
					uid=str(obj.get("uid", "")),
					text=str(obj.get("text", "")),
					tags=list(obj.get("tags", [])),
					ts_epoch=(float(obj.get("ts")) if obj.get("ts") is not None else None),
					quality=float(obj.get("quality", 0.5)),
				))
		mem.items = items
		return mem


"""Product Key Memory implementation."""

from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional, Tuple
import torch
import torch.nn as nn
import torch.nn.functional as F
from pathlib import Path
import json
try:
    import numpy as np  # type: ignore
except Exception:  # optional
    np = None  # type: ignore
try:
    from sentence_transformers import SentenceTransformer  # type: ignore
except Exception:  # optional
    SentenceTransformer = None  # type: ignore

class ProductKeyMemory(nn.Module):
    """Product Key Memory optimized for code storage and retrieval."""

    def __init__(
        self,
        query_dim: int,
        key_dim: int,
        value_dim: int,
        num_heads: int = 4,
        num_keys_per_head: int = 256,
        top_k: int = 32,
        scaling_factor: float = 0.1,
        shared_memory: Optional[nn.Parameter] = None,
        encoder_name: Optional[str] = None,
        device: Optional[str] = None
    ):
        super().__init__()
        self.num_heads = num_heads
        self.key_dim = key_dim
        self.top_k = top_k
        self.scaling_factor = scaling_factor
        self.query_dim = int(query_dim)
        self.value_dim = int(value_dim)
        self.encoder_name = encoder_name or "sentence-transformers/all-MiniLM-L6-v2"
        self.device = device

        # Query projection
        self.query_net = nn.Linear(query_dim, num_heads * key_dim)

        # Use shared memory if provided, otherwise create new
        if shared_memory is not None:
            self.keys = shared_memory
        else:
            self.keys = nn.Parameter(
                torch.randn(num_heads, num_keys_per_head, key_dim) * scaling_factor
            )

        self.values = nn.Parameter(
            torch.randn(num_heads, num_keys_per_head, value_dim) * scaling_factor
        )

        # Output projection
        self.output_net = nn.Linear(num_heads * value_dim, query_dim)

        # Gating mechanism
        self.gate1 = nn.Linear(query_dim, query_dim)
        self.gate2 = nn.Linear(query_dim, query_dim)

        # Storage for metadata
        self.metadata: List[Dict[str, Any]] = []
        # Stored value vectors aligned with metadata (each is 1D tensor of value_dim)
        self._value_vectors: List[torch.Tensor] = []

        # Initialize encoder and projection
        self._init_encoder_and_projection()

    def _init_encoder_and_projection(self) -> None:
        """Initialize text encoder (if available) and input projection to query_dim."""
        self.encoder = None
        self.encoder_dim: Optional[int] = None
        if SentenceTransformer is not None:
            try:
                self.encoder = SentenceTransformer(self.encoder_name, device=self.device)  # type: ignore
                try:
                    self.encoder_dim = int(self.encoder.get_sentence_embedding_dimension())  # type: ignore
                except Exception:
                    emb = self.encoder.encode([""], show_progress_bar=False, normalize_embeddings=False)  # type: ignore
                    if isinstance(emb, list) and len(emb) > 0:
                        self.encoder_dim = int(len(emb[0]))
                    else:
                        self.encoder_dim = int(self.query_dim)
            except Exception:
                self.encoder = None
                self.encoder_dim = None
        if self.encoder_dim is None:
            self.encoder_dim = int(self.query_dim)
        if int(self.encoder_dim) == int(self.query_dim):
            self.input_proj = nn.Identity()
        else:
            self.input_proj = nn.Linear(int(self.encoder_dim), int(self.query_dim))

    def _to_value_vector(self, text_tensor: torch.Tensor) -> torch.Tensor:
        """Project or pool an input tensor to match value_dim.

        If input dim already equals value_dim, return as-is (squeezed).
        Otherwise, mean-pool chunks to reduce to value_dim; pad if needed.
        """
        vdim = int(self.values.size(-1))
        vec = text_tensor
        if vec.dim() == 2 and vec.size(0) == 1:
            vec = vec.squeeze(0)
        if vec.dim() == 1 and int(vec.size(0)) == vdim:
            return vec
        # Ensure 1D
        if vec.dim() > 1:
            vec = vec.view(-1)
        length = int(vec.size(0))
        if length < vdim:
            # Pad to vdim
            pad = vdim - length
            vec = F.pad(vec, (0, pad))
            length = vdim
        # Pad to multiple of vdim
        if length % vdim != 0:
            pad = vdim - (length % vdim)
            vec = F.pad(vec, (0, pad))
            length = int(vec.size(0))
        # Pool into vdim chunks
        reshaped = vec.view(vdim, length // vdim)
        pooled = reshaped.mean(dim=-1)
        return pooled

    def store(
        self,
        text: str,
        quality: float = 0.5,
        metadata: Optional[Dict[str, Any]] = None
    ) -> None:
        """Store text and metadata in memory."""
        # Convert text to tensor format
        with torch.no_grad():
            text_tensor = self._text_to_tensor(text)

            # Project to key space
            projected = self.query_net(text_tensor)
            projected = projected.view(-1, self.num_heads, self.key_dim)

            # Find closest keys
            scores = torch.einsum(
                "hkd,bhd->bhk",
                self.keys,
                projected
            )

            # Store in value space
            _, top_indices = scores.max(dim=-1)
            # Compute a value vector compatible with value_dim
            val_vec = self._to_value_vector(text_tensor)
            for head_idx, key_idx in enumerate(top_indices[0]):
                self.values.data[head_idx, key_idx] = val_vec

            # Store metadata
            if metadata is None:
                metadata = {}
            metadata["quality"] = quality
            metadata["text"] = text
            self.metadata.append(metadata)
            # Keep value vector for retrieval scoring
            self._value_vectors.append(self._to_value_vector(text_tensor).detach().clone())

    def query(
        self,
        text: str,
        k: int = 5,
        **kwargs
    ) -> List[Dict[str, Any]]:
        """Query memory for similar items."""
        # If nothing stored yet, return empty without error
        if not self.metadata:
            return []
        with torch.no_grad():
            # Compute query value vector and compare in value space
            query_tensor = self._text_to_tensor(text)
            q_val = self._to_value_vector(query_tensor)
            if self._value_vectors:
                stored_vals = torch.stack(self._value_vectors)
            else:
                stored_list: List[torch.Tensor] = []
                for m in self.metadata:
                    stored_list.append(self._to_value_vector(self._text_to_tensor(m.get("text", ""))))
                if not stored_list:
                    return []
                stored_vals = torch.stack(stored_list)
            q = F.normalize(q_val, dim=0)
            s = F.normalize(stored_vals, dim=1)
            scores = torch.matmul(s, q)

            # Get top k results
            top_k_actual = int(min(int(k), len(self.metadata)))
            if top_k_actual <= 0:
                return []
            top_scores, top_indices = torch.topk(scores, top_k_actual)

            results = []
            for score, idx in zip(top_scores.tolist(), top_indices.tolist()):
                metadata = self.metadata[idx].copy()
                metadata["score"] = score
                results.append(metadata)

            return results

    def forward(
        self,
        queries: torch.Tensor,
        mask: Optional[torch.Tensor] = None
    ) -> Tuple[torch.Tensor, torch.Tensor]:
        """Forward pass through memory."""
        batch_size = queries.size(0)

        # Project queries
        projected_queries = self.query_net(queries)
        projected_queries = projected_queries.view(
            batch_size, -1, self.num_heads, self.key_dim
        )

        # Compute attention scores
        scores = torch.einsum(
            "bshd,hkd->bhsk",
            projected_queries,
            self.keys
        ) / (self.key_dim ** 0.5)

        # Apply mask if provided
        if mask is not None:
            expanded_mask = mask.unsqueeze(1).unsqueeze(-1)
            expanded_mask = expanded_mask.expand(-1, scores.size(1), -1, scores.size(-1))
            scores = scores.masked_fill(~expanded_mask, float('-inf'))

        # Get top-k keys
        topk_scores, topk_indices = torch.topk(scores, self.top_k, dim=-1)

        # Get corresponding values
        expanded_values = self.values.unsqueeze(0).unsqueeze(0)
        expanded_values = expanded_values.expand(batch_size, -1, -1, -1, -1)

        # Gather values
        gather_indices = topk_indices.unsqueeze(-1).expand(-1, -1, -1, -1, self.values.size(-1))
        expanded_values = expanded_values.permute(0, 2, 1, 3, 4)
        topk_values = torch.gather(expanded_values, 3, gather_indices)

        # Compute attention weights
        attention_weights = F.softmax(topk_scores, dim=-1)

        # Apply attention
        attention_weights = attention_weights.unsqueeze(-1)
        weighted_values = attention_weights * topk_values
        memory_output = weighted_values.sum(dim=3)

        # Reshape and project output
        memory_output = memory_output.permute(0, 2, 1, 3)
        memory_output = memory_output.reshape(batch_size, -1, self.num_heads * self.values.size(-1))
        memory_output = self.output_net(memory_output)

        # Apply gating
        gate = torch.sigmoid(self.gate1(queries))
        output = gate * memory_output + (1 - gate) * self.gate2(queries)

        return output, attention_weights

    def _text_to_tensor(self, text: str) -> torch.Tensor:
        """Encode text deterministically and project to [1, query_dim]."""
        if self.encoder is not None and SentenceTransformer is not None:
            try:
                vec = self.encoder.encode([text or ""], show_progress_bar=False, normalize_embeddings=False)  # type: ignore
                if isinstance(vec, list):
                    vec = vec[0]
                if np is not None and not isinstance(vec, np.ndarray):  # type: ignore
                    vec = np.array(vec, dtype=np.float32)  # type: ignore
                ten = torch.tensor(vec, dtype=torch.float32)
            except Exception:
                ten = self._deterministic_hash_embed(text or "")
        else:
            ten = self._deterministic_hash_embed(text or "")
        if ten.dim() == 1:
            ten = ten.unsqueeze(0)
        proj = self.input_proj(ten)
        return proj

    def _deterministic_hash_embed(self, text: str) -> torch.Tensor:
        dim = int(getattr(self, "encoder_dim", self.query_dim) or self.query_dim)
        vec = torch.zeros(dim, dtype=torch.float32)
        if not text:
            return vec
        p1 = 16777619
        p2 = 2166136261
        h = p2
        for ch in text.encode("utf-8", errors="ignore"):
            h = (h ^ int(ch)) * p1
            idx = int(h % dim)
            vec[idx] += 1.0
        vec = torch.log1p(vec)
        n = vec.norm(p=2)
        if float(n) > 0:
            vec = vec / n
        return vec

    def save(self, out_dir: str) -> None:
        """Save memory state."""
        out_dir = Path(out_dir)
        out_dir.mkdir(parents=True, exist_ok=True)

        # Save model state
        torch.save(self.state_dict(), out_dir / "model.pt")

        # Save metadata
        with open(out_dir / "metadata.json", "w") as f:
            json.dump(self.metadata, f)
        # Save config
        cfg = {
            "query_dim": int(self.query_dim),
            "key_dim": int(self.key_dim),
            "value_dim": int(self.value_dim),
            "num_heads": int(self.num_heads),
            "top_k": int(self.top_k),
            "scaling_factor": float(self.scaling_factor),
            "encoder_name": str(self.encoder_name or ""),
            "device": (str(self.device) if self.device is not None else None),
        }
        with open(out_dir / "config.json", "w") as f:
            json.dump(cfg, f)
        # Save stored value vectors
        if self._value_vectors:
            torch.save(torch.stack(self._value_vectors), out_dir / "values.pt")

    @classmethod
    def load(cls, in_dir: str) -> "ProductKeyMemory":
        """Load memory state."""
        in_dir = Path(in_dir)

        # Create instance from config if available
        cfg_path = in_dir / "config.json"
        if cfg_path.exists():
            cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
            mem = cls(
                query_dim=int(cfg.get("query_dim", 512)),
                key_dim=int(cfg.get("key_dim", 64)),
                value_dim=int(cfg.get("value_dim", 64)),
                num_heads=int(cfg.get("num_heads", 4)),
                top_k=int(cfg.get("top_k", 32)),
                scaling_factor=float(cfg.get("scaling_factor", 0.1)),
                encoder_name=str(cfg.get("encoder_name", "sentence-transformers/all-MiniLM-L6-v2") or "sentence-transformers/all-MiniLM-L6-v2"),
                device=(str(cfg.get("device")) if cfg.get("device") is not None else None),
            )
        else:
            mem = cls(
                query_dim=512,
                key_dim=64,
                value_dim=64
            )

        # Load model state
        mem.load_state_dict(torch.load(in_dir / "model.pt"))

        # Load metadata
        with open(in_dir / "metadata.json") as f:
            mem.metadata = json.load(f)
        # Load stored value vectors if present
        vals_path = in_dir / "values.pt"
        mem._value_vectors = []
        if vals_path.exists():
            try:
                vals = torch.load(vals_path)
                if isinstance(vals, torch.Tensor) and vals.dim() == 2 and int(vals.size(1)) == int(mem.value_dim):
                    mem._value_vectors = [v.detach().clone() for v in vals]
            except Exception:
                mem._value_vectors = []
        if not mem._value_vectors:
            # Reconstruct from text deterministically
            for m in mem.metadata:
                vt = mem._to_value_vector(mem._text_to_tensor(m.get("text", "")))
                mem._value_vectors.append(vt)

        return mem


from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional, Tuple
import torch
import torch.nn as nn
import torch.nn.functional as F
from pathlib import Path
import json
try:
    import numpy as np  # type: ignore
except Exception:  # optional
    np = None  # type: ignore
try:
    from sentence_transformers import SentenceTransformer  # type: ignore
except Exception:  # optional
    SentenceTransformer = None  # type: ignore

class ProductKeyMemory(nn.Module):
    """Product Key Memory optimized for code storage and retrieval."""

    def __init__(
        self,
        query_dim: int,
        key_dim: int,
        value_dim: int,
        num_heads: int = 4,
        num_keys_per_head: int = 256,
        top_k: int = 32,
        scaling_factor: float = 0.1,
        shared_memory: Optional[nn.Parameter] = None,
        encoder_name: Optional[str] = None,
        device: Optional[str] = None
    ):
        super().__init__()
        self.num_heads = num_heads
        self.key_dim = key_dim
        self.top_k = top_k
        self.scaling_factor = scaling_factor
        self.query_dim = int(query_dim)
        self.value_dim = int(value_dim)
        self.encoder_name = encoder_name or "sentence-transformers/all-MiniLM-L6-v2"
        self.device = device

        # Query projection
        self.query_net = nn.Linear(query_dim, num_heads * key_dim)

        # Use shared memory if provided, otherwise create new
        if shared_memory is not None:
            self.keys = shared_memory
        else:
            self.keys = nn.Parameter(
                torch.randn(num_heads, num_keys_per_head, key_dim) * scaling_factor
            )

        self.values = nn.Parameter(
            torch.randn(num_heads, num_keys_per_head, value_dim) * scaling_factor
        )

        # Output projection
        self.output_net = nn.Linear(num_heads * value_dim, query_dim)

        # Gating mechanism
        self.gate1 = nn.Linear(query_dim, query_dim)
        self.gate2 = nn.Linear(query_dim, query_dim)

        # Storage for metadata
        self.metadata: List[Dict[str, Any]] = []
        # Stored value vectors aligned with metadata (each is 1D tensor of value_dim)
        self._value_vectors: List[torch.Tensor] = []

        # Initialize encoder and projection
        self._init_encoder_and_projection()

    def _init_encoder_and_projection(self) -> None:
        """Initialize text encoder (if available) and input projection to query_dim."""
        self.encoder = None
        self.encoder_dim: Optional[int] = None
        if SentenceTransformer is not None:
            try:
                self.encoder = SentenceTransformer(self.encoder_name, device=self.device)  # type: ignore
                try:
                    self.encoder_dim = int(self.encoder.get_sentence_embedding_dimension())  # type: ignore
                except Exception:
                    emb = self.encoder.encode([""], show_progress_bar=False, normalize_embeddings=False)  # type: ignore
                    if isinstance(emb, list) and len(emb) > 0:
                        self.encoder_dim = int(len(emb[0]))
                    else:
                        self.encoder_dim = int(self.query_dim)
            except Exception:
                self.encoder = None
                self.encoder_dim = None
        if self.encoder_dim is None:


        self.values = nn.Parameter(
            torch.randn(num_heads, num_keys_per_head, value_dim) * scaling_factor
        )

        # Output projection
        self.output_net = nn.Linear(num_heads * value_dim, query_dim)

        # Gating mechanism
        self.gate1 = nn.Linear(query_dim, query_dim)
        self.gate2 = nn.Linear(query_dim, query_dim)

        # Storage for metadata
        self.metadata: List[Dict[str, Any]] = []
        # Stored value vectors aligned with metadata (each is 1D tensor of value_dim)
        self._value_vectors: List[torch.Tensor] = []

        # Initialize encoder and projection
        self._init_encoder_and_projection()

    def _init_encoder_and_projection(self) -> None:
        """Initialize text encoder (if available) and input projection to query_dim."""
        self.encoder = None
        self.encoder_dim: Optional[int] = None
        if SentenceTransformer is not None:
            try:
                self.encoder = SentenceTransformer(self.encoder_name, device=self.device)  # type: ignore
                try:
                    self.encoder_dim = int(self.encoder.get_sentence_embedding_dimension())  # type: ignore
                except Exception:
                    emb = self.encoder.encode([""], show_progress_bar=False, normalize_embeddings=False)  # type: ignore
                    if isinstance(emb, list) and len(emb) > 0:
                        self.encoder_dim = int(len(emb[0]))
                    else:
                        self.encoder_dim = int(self.query_dim)
            except Exception:
                self.encoder = None
                self.encoder_dim = None
        if self.encoder_dim is None:
            self.encoder_dim = int(self.query_dim)
        if int(self.encoder_dim) == int(self.query_dim):
            self.input_proj = nn.Identity()
        else:
            self.input_proj = nn.Linear(int(self.encoder_dim), int(self.query_dim))

    def _to_value_vector(self, text_tensor: torch.Tensor) -> torch.Tensor:
        """Project or pool an input tensor to match value_dim.

        If input dim already equals value_dim, return as-is (squeezed).
        Otherwise, mean-pool chunks to reduce to value_dim; pad if needed.
        """
        vdim = int(self.values.size(-1))
        vec = text_tensor
        if vec.dim() == 2 and vec.size(0) == 1:
            vec = vec.squeeze(0)
        if vec.dim() == 1 and int(vec.size(0)) == vdim:
            return vec
        # Ensure 1D
        if vec.dim() > 1:
            vec = vec.view(-1)
        length = int(vec.size(0))
        if length < vdim:
            # Pad to vdim
            pad = vdim - length


            try:
                self.encoder = SentenceTransformer(self.encoder_name, device=self.device)  # type: ignore
                try:
                    self.encoder_dim = int(self.encoder.get_sentence_embedding_dimension())  # type: ignore
                except Exception:
                    emb = self.encoder.encode([""], show_progress_bar=False, normalize_embeddings=False)  # type: ignore
                    if isinstance(emb, list) and len(emb) > 0:
                        self.encoder_dim = int(len(emb[0]))
                    else:
                        self.encoder_dim = int(self.query_dim)
            except Exception:
                self.encoder = None
                self.encoder_dim = None
        if self.encoder_dim is None:
            self.encoder_dim = int(self.query_dim)
        if int(self.encoder_dim) == int(self.query_dim):
            self.input_proj = nn.Identity()
        else:
            self.input_proj = nn.Linear(int(self.encoder_dim), int(self.query_dim))

    def _to_value_vector(self, text_tensor: torch.Tensor) -> torch.Tensor:
        """Project or pool an input tensor to match value_dim.

        If input dim already equals value_dim, return as-is (squeezed).
        Otherwise, mean-pool chunks to reduce to value_dim; pad if needed.
        """
        vdim = int(self.values.size(-1))
        vec = text_tensor
        if vec.dim() == 2 and vec.size(0) == 1:
            vec = vec.squeeze(0)
        if vec.dim() == 1 and int(vec.size(0)) == vdim:
            return vec
        # Ensure 1D
        if vec.dim() > 1:
            vec = vec.view(-1)
        length = int(vec.size(0))
        if length < vdim:
            # Pad to vdim
            pad = vdim - length
            vec = F.pad(vec, (0, pad))
            length = vdim
        # Pad to multiple of vdim
        if length % vdim != 0:
            pad = vdim - (length % vdim)
            vec = F.pad(vec, (0, pad))
            length = int(vec.size(0))
        # Pool into vdim chunks
        reshaped = vec.view(vdim, length // vdim)
        pooled = reshaped.mean(dim=-1)
        return pooled

    def store(
        self,
        text: str,
        quality: float = 0.5,
        metadata: Optional[Dict[str, Any]] = None
    ) -> None:
        """Store text and metadata in memory."""
        # Convert text to tensor format
        with torch.no_grad():
            text_tensor = self._text_to_tensor(text)

            # Project to key space
            projected = self.query_net(text_tensor)
            projected = projected.view(-1, self.num_heads, self.key_dim)

            # Find closest keys
            scores = torch.einsum(
                "hkd,bhd->bhk",
                self.keys,


store

            return vec
        # Ensure 1D
        if vec.dim() > 1:
            vec = vec.view(-1)
        length = int(vec.size(0))
        if length < vdim:
            # Pad to vdim
            pad = vdim - length
            vec = F.pad(vec, (0, pad))
            length = vdim
        # Pad to multiple of vdim
        if length % vdim != 0:
            pad = vdim - (length % vdim)
            vec = F.pad(vec, (0, pad))
            length = int(vec.size(0))
        # Pool into vdim chunks
        reshaped = vec.view(vdim, length // vdim)
        pooled = reshaped.mean(dim=-1)
        return pooled

    def store(
        self,
        text: str,
        quality: float = 0.5,
        metadata: Optional[Dict[str, Any]] = None
    ) -> None:
        """Store text and metadata in memory."""
        # Convert text to tensor format
        with torch.no_grad():
            text_tensor = self._text_to_tensor(text)

            # Project to key space
            projected = self.query_net(text_tensor)
            projected = projected.view(-1, self.num_heads, self.key_dim)

            # Find closest keys
            scores = torch.einsum(
                "hkd,bhd->bhk",
                self.keys,
                projected
            )

            # Store in value space
            _, top_indices = scores.max(dim=-1)
            # Compute a value vector compatible with value_dim
            val_vec = self._to_value_vector(text_tensor)
            for head_idx, key_idx in enumerate(top_indices[0]):
                self.values.data[head_idx, key_idx] = val_vec

            # Store metadata
            if metadata is None:
                metadata = {}
            metadata["quality"] = quality
            metadata["text"] = text
            self.metadata.append(metadata)
            # Keep value vector for retrieval scoring
            self._value_vectors.append(self._to_value_vector(text_tensor).detach().clone())

    def query(
        self,
        text: str,
        k: int = 5,
        **kwargs
    ) -> List[Dict[str, Any]]:
        """Query memory for similar items."""
        # If nothing stored yet, return empty without error
        if not self.metadata:
            return []
        with torch.no_grad():
            # Compute query value vector and compare in value space
            query_tensor = self._text_to_tensor(text)
            q_val = self._to_value_vector(query_tensor)
            if self._value_vectors:
                stored_vals = torch.stack(self._value_vectors)
            else:
                stored_list: List[torch.Tensor] = []
                for m in self.metadata:


query

                self.keys,
                projected
            )

            # Store in value space
            _, top_indices = scores.max(dim=-1)
            # Compute a value vector compatible with value_dim
            val_vec = self._to_value_vector(text_tensor)
            for head_idx, key_idx in enumerate(top_indices[0]):
                self.values.data[head_idx, key_idx] = val_vec

            # Store metadata
            if metadata is None:
                metadata = {}
            metadata["quality"] = quality
            metadata["text"] = text
            self.metadata.append(metadata)
            # Keep value vector for retrieval scoring
            self._value_vectors.append(self._to_value_vector(text_tensor).detach().clone())

    def query(
        self,
        text: str,
        k: int = 5,
        **kwargs
    ) -> List[Dict[str, Any]]:
        """Query memory for similar items."""
        # If nothing stored yet, return empty without error
        if not self.metadata:
            return []
        with torch.no_grad():
            # Compute query value vector and compare in value space
            query_tensor = self._text_to_tensor(text)
            q_val = self._to_value_vector(query_tensor)
            if self._value_vectors:
                stored_vals = torch.stack(self._value_vectors)
            else:
                stored_list: List[torch.Tensor] = []
                for m in self.metadata:
                    stored_list.append(self._to_value_vector(self._text_to_tensor(m.get("text", ""))))
                if not stored_list:
                    return []
                stored_vals = torch.stack(stored_list)
            q = F.normalize(q_val, dim=0)
            s = F.normalize(stored_vals, dim=1)
            scores = torch.matmul(s, q)

            # Get top k results
            top_k_actual = int(min(int(k), len(self.metadata)))
            if top_k_actual <= 0:
                return []
            top_scores, top_indices = torch.topk(scores, top_k_actual)

            results = []
            for score, idx in zip(top_scores.tolist(), top_indices.tolist()):
                metadata = self.metadata[idx].copy()
                metadata["score"] = score
                results.append(metadata)

            return results

    def forward(
        self,
        queries: torch.Tensor,
        mask: Optional[torch.Tensor] = None
    ) -> Tuple[torch.Tensor, torch.Tensor]:
        """Forward pass through memory."""
        batch_size = queries.size(0)

        # Project queries
        projected_queries = self.query_net(queries)
        projected_queries = projected_queries.view(
            batch_size, -1, self.num_heads, self.key_dim
        )

        # Compute attention scores
        scores = torch.einsum(
            "bshd,hkd->bhsk",
            projected_queries,
            self.keys


forward

                    return []
                stored_vals = torch.stack(stored_list)
            q = F.normalize(q_val, dim=0)
            s = F.normalize(stored_vals, dim=1)
            scores = torch.matmul(s, q)

            # Get top k results
            top_k_actual = int(min(int(k), len(self.metadata)))
            if top_k_actual <= 0:
                return []
            top_scores, top_indices = torch.topk(scores, top_k_actual)

            results = []
            for score, idx in zip(top_scores.tolist(), top_indices.tolist()):
                metadata = self.metadata[idx].copy()
                metadata["score"] = score
                results.append(metadata)

            return results

    def forward(
        self,
        queries: torch.Tensor,
        mask: Optional[torch.Tensor] = None
    ) -> Tuple[torch.Tensor, torch.Tensor]:
        """Forward pass through memory."""
        batch_size = queries.size(0)

        # Project queries
        projected_queries = self.query_net(queries)
        projected_queries = projected_queries.view(
            batch_size, -1, self.num_heads, self.key_dim
        )

        # Compute attention scores
        scores = torch.einsum(
            "bshd,hkd->bhsk",
            projected_queries,
            self.keys
        ) / (self.key_dim ** 0.5)

        # Apply mask if provided
        if mask is not None:
            expanded_mask = mask.unsqueeze(1).unsqueeze(-1)
            expanded_mask = expanded_mask.expand(-1, scores.size(1), -1, scores.size(-1))
            scores = scores.masked_fill(~expanded_mask, float('-inf'))

        # Get top-k keys
        topk_scores, topk_indices = torch.topk(scores, self.top_k, dim=-1)

        # Get corresponding values
        expanded_values = self.values.unsqueeze(0).unsqueeze(0)
        expanded_values = expanded_values.expand(batch_size, -1, -1, -1, -1)

        # Gather values
        gather_indices = topk_indices.unsqueeze(-1).expand(-1, -1, -1, -1, self.values.size(-1))
        expanded_values = expanded_values.permute(0, 2, 1, 3, 4)
        topk_values = torch.gather(expanded_values, 3, gather_indices)

        # Compute attention weights
        attention_weights = F.softmax(topk_scores, dim=-1)

        # Apply attention
        attention_weights = attention_weights.unsqueeze(-1)
        weighted_values = attention_weights * topk_values
        memory_output = weighted_values.sum(dim=3)

        # Reshape and project output
        memory_output = memory_output.permute(0, 2, 1, 3)
        memory_output = memory_output.reshape(batch_size, -1, self.num_heads * self.values.size(-1))
        memory_output = self.output_net(memory_output)

        # Apply gating
        gate = torch.sigmoid(self.gate1(queries))
        output = gate * memory_output + (1 - gate) * self.gate2(queries)

        return output, attention_weights

    def _text_to_tensor(self, text: str) -> torch.Tensor:
        """Encode text deterministically and project to [1, query_dim]."""
        if self.encoder is not None and SentenceTransformer is not None:
            try:
                vec = self.encoder.encode([text or ""], show_progress_bar=False, normalize_embeddings=False)  # type: ignore
                if isinstance(vec, list):
                    vec = vec[0]
                if np is not None and not isinstance(vec, np.ndarray):  # type: ignore
                    vec = np.array(vec, dtype=np.float32)  # type: ignore
                ten = torch.tensor(vec, dtype=torch.float32)
            except Exception:
                ten = self._deterministic_hash_embed(text or "")
        else:
            ten = self._deterministic_hash_embed(text or "")
        if ten.dim() == 1:
            ten = ten.unsqueeze(0)
        proj = self.input_proj(ten)
        return proj


        # Compute attention weights
        attention_weights = F.softmax(topk_scores, dim=-1)

        # Apply attention
        attention_weights = attention_weights.unsqueeze(-1)
        weighted_values = attention_weights * topk_values
        memory_output = weighted_values.sum(dim=3)

        # Reshape and project output
        memory_output = memory_output.permute(0, 2, 1, 3)
        memory_output = memory_output.reshape(batch_size, -1, self.num_heads * self.values.size(-1))
        memory_output = self.output_net(memory_output)

        # Apply gating
        gate = torch.sigmoid(self.gate1(queries))
        output = gate * memory_output + (1 - gate) * self.gate2(queries)

        return output, attention_weights

    def _text_to_tensor(self, text: str) -> torch.Tensor:
        """Encode text deterministically and project to [1, query_dim]."""
        if self.encoder is not None and SentenceTransformer is not None:
            try:
                vec = self.encoder.encode([text or ""], show_progress_bar=False, normalize_embeddings=False)  # type: ignore
                if isinstance(vec, list):
                    vec = vec[0]
                if np is not None and not isinstance(vec, np.ndarray):  # type: ignore
                    vec = np.array(vec, dtype=np.float32)  # type: ignore
                ten = torch.tensor(vec, dtype=torch.float32)
            except Exception:
                ten = self._deterministic_hash_embed(text or "")
        else:
            ten = self._deterministic_hash_embed(text or "")
        if ten.dim() == 1:
            ten = ten.unsqueeze(0)
        proj = self.input_proj(ten)
        return proj

    def _deterministic_hash_embed(self, text: str) -> torch.Tensor:
        dim = int(getattr(self, "encoder_dim", self.query_dim) or self.query_dim)
        vec = torch.zeros(dim, dtype=torch.float32)
        if not text:
            return vec
        p1 = 16777619
        p2 = 2166136261
        h = p2
        for ch in text.encode("utf-8", errors="ignore"):
            h = (h ^ int(ch)) * p1
            idx = int(h % dim)
            vec[idx] += 1.0
        vec = torch.log1p(vec)
        n = vec.norm(p=2)
        if float(n) > 0:
            vec = vec / n
        return vec

    def save(self, out_dir: str) -> None:


    def _text_to_tensor(self, text: str) -> torch.Tensor:
        """Encode text deterministically and project to [1, query_dim]."""
        if self.encoder is not None and SentenceTransformer is not None:
            try:
                vec = self.encoder.encode([text or ""], show_progress_bar=False, normalize_embeddings=False)  # type: ignore
                if isinstance(vec, list):
                    vec = vec[0]
                if np is not None and not isinstance(vec, np.ndarray):  # type: ignore
                    vec = np.array(vec, dtype=np.float32)  # type: ignore
                ten = torch.tensor(vec, dtype=torch.float32)
            except Exception:
                ten = self._deterministic_hash_embed(text or "")
        else:
            ten = self._deterministic_hash_embed(text or "")
        if ten.dim() == 1:
            ten = ten.unsqueeze(0)
        proj = self.input_proj(ten)
        return proj

    def _deterministic_hash_embed(self, text: str) -> torch.Tensor:
        dim = int(getattr(self, "encoder_dim", self.query_dim) or self.query_dim)
        vec = torch.zeros(dim, dtype=torch.float32)
        if not text:
            return vec
        p1 = 16777619
        p2 = 2166136261
        h = p2
        for ch in text.encode("utf-8", errors="ignore"):
            h = (h ^ int(ch)) * p1
            idx = int(h % dim)
            vec[idx] += 1.0
        vec = torch.log1p(vec)
        n = vec.norm(p=2)
        if float(n) > 0:
            vec = vec / n
        return vec

    def save(self, out_dir: str) -> None:
        """Save memory state."""
        out_dir = Path(out_dir)
        out_dir.mkdir(parents=True, exist_ok=True)

        # Save model state
        torch.save(self.state_dict(), out_dir / "model.pt")

        # Save metadata
        with open(out_dir / "metadata.json", "w") as f:
            json.dump(self.metadata, f)
        # Save config
        cfg = {
            "query_dim": int(self.query_dim),
            "key_dim": int(self.key_dim),
            "value_dim": int(self.value_dim),
            "num_heads": int(self.num_heads),
            "top_k": int(self.top_k),
            "scaling_factor": float(self.scaling_factor),


save

        return proj

    def _deterministic_hash_embed(self, text: str) -> torch.Tensor:
        dim = int(getattr(self, "encoder_dim", self.query_dim) or self.query_dim)
        vec = torch.zeros(dim, dtype=torch.float32)
        if not text:
            return vec
        p1 = 16777619
        p2 = 2166136261
        h = p2
        for ch in text.encode("utf-8", errors="ignore"):
            h = (h ^ int(ch)) * p1
            idx = int(h % dim)
            vec[idx] += 1.0
        vec = torch.log1p(vec)
        n = vec.norm(p=2)
        if float(n) > 0:
            vec = vec / n
        return vec

    def save(self, out_dir: str) -> None:
        """Save memory state."""
        out_dir = Path(out_dir)
        out_dir.mkdir(parents=True, exist_ok=True)

        # Save model state
        torch.save(self.state_dict(), out_dir / "model.pt")

        # Save metadata
        with open(out_dir / "metadata.json", "w") as f:
            json.dump(self.metadata, f)
        # Save config
        cfg = {
            "query_dim": int(self.query_dim),
            "key_dim": int(self.key_dim),
            "value_dim": int(self.value_dim),
            "num_heads": int(self.num_heads),
            "top_k": int(self.top_k),
            "scaling_factor": float(self.scaling_factor),
            "encoder_name": str(self.encoder_name or ""),
            "device": (str(self.device) if self.device is not None else None),
        }
        with open(out_dir / "config.json", "w") as f:
            json.dump(cfg, f)
        # Save stored value vectors
        if self._value_vectors:
            torch.save(torch.stack(self._value_vectors), out_dir / "values.pt")

    @classmethod
    def load(cls, in_dir: str) -> "ProductKeyMemory":
        """Load memory state."""
        in_dir = Path(in_dir)

        # Create instance from config if available
        cfg_path = in_dir / "config.json"
        if cfg_path.exists():
            cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
            mem = cls(
                query_dim=int(cfg.get("query_dim", 512)),
                key_dim=int(cfg.get("key_dim", 64)),
                value_dim=int(cfg.get("value_dim", 64)),
                num_heads=int(cfg.get("num_heads", 4)),
                top_k=int(cfg.get("top_k", 32)),
                scaling_factor=float(cfg.get("scaling_factor", 0.1)),
                encoder_name=str(cfg.get("encoder_name", "sentence-transformers/all-MiniLM-L6-v2") or "sentence-transformers/all-MiniLM-L6-v2"),
                device=(str(cfg.get("device")) if cfg.get("device") is not None else None),
            )


load

        with open(out_dir / "metadata.json", "w") as f:
            json.dump(self.metadata, f)
        # Save config
        cfg = {
            "query_dim": int(self.query_dim),
            "key_dim": int(self.key_dim),
            "value_dim": int(self.value_dim),
            "num_heads": int(self.num_heads),
            "top_k": int(self.top_k),
            "scaling_factor": float(self.scaling_factor),
            "encoder_name": str(self.encoder_name or ""),
            "device": (str(self.device) if self.device is not None else None),
        }
        with open(out_dir / "config.json", "w") as f:
            json.dump(cfg, f)
        # Save stored value vectors
        if self._value_vectors:
            torch.save(torch.stack(self._value_vectors), out_dir / "values.pt")

    @classmethod
    def load(cls, in_dir: str) -> "ProductKeyMemory":
        """Load memory state."""
        in_dir = Path(in_dir)

        # Create instance from config if available
        cfg_path = in_dir / "config.json"
        if cfg_path.exists():
            cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
            mem = cls(
                query_dim=int(cfg.get("query_dim", 512)),
                key_dim=int(cfg.get("key_dim", 64)),
                value_dim=int(cfg.get("value_dim", 64)),
                num_heads=int(cfg.get("num_heads", 4)),
                top_k=int(cfg.get("top_k", 32)),
                scaling_factor=float(cfg.get("scaling_factor", 0.1)),
                encoder_name=str(cfg.get("encoder_name", "sentence-transformers/all-MiniLM-L6-v2") or "sentence-transformers/all-MiniLM-L6-v2"),
                device=(str(cfg.get("device")) if cfg.get("device") is not None else None),
            )
        else:
            mem = cls(
                query_dim=512,
                key_dim=64,
                value_dim=64
            )

        # Load model state
        mem.load_state_dict(torch.load(in_dir / "model.pt"))

        # Load metadata
        with open(in_dir / "metadata.json") as f:
            mem.metadata = json.load(f)
        # Load stored value vectors if present
        vals_path = in_dir / "values.pt"
        mem._value_vectors = []
        if vals_path.exists():
            try:
                vals = torch.load(vals_path)
                if isinstance(vals, torch.Tensor) and vals.dim() == 2 and int(vals.size(1)) == int(mem.value_dim):
                    mem._value_vectors = [v.detach().clone() for v in vals]
            except Exception:
                mem._value_vectors = []
        if not mem._value_vectors:
            # Reconstruct from text deterministically
            for m in mem.metadata:
                vt = mem._to_value_vector(mem._text_to_tensor(m.get("text", "")))
                mem._value_vectors.append(vt)

        return mem


from __future__ import annotations

import logging
import os
from pathlib import Path
from datetime import datetime
from typing import Optional

def _should_redact() -> bool:
	try:
		v = os.environ.get("AGI_LLM_REDACT_LOGS", "1").strip()
		return v not in ("0", "false", "False")
	except Exception:
		return True

def _redact(text: str) -> str:
	if not _should_redact():
		return text
	try:
		from agi_dw.core.utils.redact import redact_text  # type: ignore
		t, _ = redact_text(text)
		return t
	except Exception:
		return text

class PromptLogger:
	"""Unified, PII-safe prompt/response logger.

	- Respects AGI_LLM_REDACT_LOGS env (on by default)
	- Writes to agi_dw/data/logs with timestamped filenames
	- Optional echo to stdout when echo=True
	"""

	def __init__(self, component: str, echo: bool = False) -> None:
		self.component = component
		self.echo = echo
		try:
			root = Path(__file__).resolve().parents[2]
		except Exception:
			root = Path.cwd()
		self.log_dir = root / "data" / "logs"
		try:
			self.log_dir.mkdir(parents=True, exist_ok=True)
		except Exception:
			pass

	def _ts(self) -> str:
		try:
			return datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
		except Exception:
			return "00000000T000000Z"

	def log_text(self, kind: str, text: str) -> Optional[Path]:
		safe = _redact(text)
		if self.echo:
			try:
				print(f"[{self.component.upper()} {kind.upper()}]\n" + safe)
			except Exception:
				pass
		try:
			p = self.log_dir / f"{self.component}_{kind}_{self._ts()}.txt"
			p.write_text(safe, encoding="utf-8")
			return p
		except Exception:
			return None

def get_prompt_logger(component: str, enabled: bool, echo: bool = False) -> Optional[PromptLogger]:
	"""Return a PromptLogger when enabled, else None.

	Centralizes a common pattern scattered across harness/planner/verifier.
	"""
	try:
		return PromptLogger(component, echo=bool(echo)) if bool(enabled) else None
	except Exception:
		return None


from __future__ import annotations

import logging
import os
from pathlib import Path
from datetime import datetime
from typing import Optional

def _should_redact() -> bool:
	try:
		v = os.environ.get("AGI_LLM_REDACT_LOGS", "1").strip()
		return v not in ("0", "false", "False")
	except Exception:
		return True

def _redact(text: str) -> str:
	if not _should_redact():
		return text
	try:
		from agi_dw.core.utils.redact import redact_text  # type: ignore
		t, _ = redact_text(text)
		return t
	except Exception:
		return text

class PromptLogger:
	"""Unified, PII-safe prompt/response logger.

	- Respects AGI_LLM_REDACT_LOGS env (on by default)
	- Writes to agi_dw/data/logs with timestamped filenames
	- Optional echo to stdout when echo=True
	"""