# tools/memory_tools.py — Long-term memory management for Mugi.
# Append-only + summarization + sliding window + archive. All path handling via utils.path_safety.
from __future__ import annotations

import logging
import os
import re
import time
from datetime import datetime, timedelta
from typing import Any

_log = logging.getLogger(__name__)

from langchain_core.tools import tool
from tools._logging import _debug_log, _emit_input_status
from utils.path_safety import safe_resolve_path, secure_write_file
from agent_loop_config import get_memory_policy_prefs
from tool_policy import emit_policy_event
from utils.memory_promotion import (
    PromotionClass,
    PromotionStrictness,
    blocked_memory_message,
    classify_memory_entry,
    discouraged_memory_message,
    strictness_from_semantic_pref,
)
from utils.memory_scopes import (
    SCOPE_MESH_SAFE,
    SCOPE_USER_DURABLE,
    suggested_scope_for_thread_id,
)


def _memory_inspector_maybe_record_search(tool_name: str, query: str, fused_snippets: list[str]) -> None:
    if not fused_snippets:
        return
    try:
        from utils.memory_turn_trace import record_memory_search_results

        record_memory_search_results(
            tool_name=tool_name,
            query=query,
            fused_snippets=list(fused_snippets),
        )
    except Exception:
        pass


def _memory_inspector_maybe_record_write(
    *,
    tool_name: str,
    path_rel: str,
    outcome: str,
    delta_chars: int | None = None,
    detail: str | None = None,
    promotion_warning: str | None = None,
) -> None:
    try:
        from utils.memory_turn_trace import tls_get_trace

        tr = tls_get_trace()
        if tr:
            tr.record_write(
                tool_name=tool_name,
                path_rel=path_rel,
                outcome=outcome,
                delta_chars=delta_chars,
                detail=detail,
                promotion_warning=promotion_warning,
            )
    except Exception:
        pass


_DEFAULT_WORKSPACE = os.path.expanduser("~/.mugi/workspace")

from utils.memory_text import (
    SECTION_HEADER_RE as _SECTION_HEADER_RE,
    read_memory_file as _read_memory_file,
    split_into_blocks as _split_into_blocks,
    blocks_to_content as _blocks_to_content,
)

_REFRESH_MAX_STALE_ENTRIES = 12


def _touch_memory_fts(workspace: str, *paths: str) -> None:
    try:
        from core.config import get_memory_config
        from utils.memory_fts_index import notify_memory_paths_changed

        uniq = [p for p in dict.fromkeys(paths) if p]
        if not uniq:
            return
        notify_memory_paths_changed(workspace, uniq, mem=get_memory_config())
    except Exception:
        pass


def _workspace() -> str:
    from core import get_workspace_path
    return get_workspace_path()


def _summarize_file_at_path(abs_path: str, max_chars_target: int) -> tuple[bool, str]:
    """Summarize oldest blocks in one memory file. Returns (changed, message)."""
    content = _read_memory_file(abs_path)
    if len(content) <= max_chars_target:
        return False, ""
    blocks = _split_into_blocks(content)
    if not blocks:
        return False, "No structured content to summarize."
    summary_header = f"## Summary of older memories (condensed on {datetime.now().strftime('%Y-%m-%d')})"
    reserve = len(summary_header) + 500
    n = len(blocks)
    recent_count = 0
    for i in range(n - 1, -1, -1):
        suffix = blocks[i:]
        if len(_blocks_to_content(suffix)) + reserve <= max_chars_target:
            recent_count = len(suffix)
        else:
            break
    old_blocks = blocks[: n - recent_count]
    recent_blocks = blocks[n - recent_count:]
    if not old_blocks:
        return False, ""
    old_content = _blocks_to_content(old_blocks)
    max_summary_body = max(2000, max_chars_target - len(_blocks_to_content(recent_blocks)) - len(summary_header) - 200)
    if len(old_content) > max_summary_body:
        summary_body = (
            old_content[: max_summary_body].rstrip()
            + "\n\n... [older content condensed for length; see memory/archives/MEMORY_ARCHIVE_*.md for full history if needed]"
        )
    else:
        summary_body = old_content
    summary_block: tuple[str, str] = (summary_header, summary_body)
    new_blocks = [summary_block] + recent_blocks
    new_content = _blocks_to_content(new_blocks)
    try:
        secure_write_file(abs_path, new_content, mode=0o600)
    except Exception as e:
        return False, f"Summarization failed to write: {e}"
    try:
        from core import get_workspace_path
        from core.config import get_memory_config
        from utils.memory_fts_index import notify_memory_paths_changed

        notify_memory_paths_changed(get_workspace_path(), [abs_path], mem=get_memory_config())
    except Exception:
        pass
    rel = os.path.basename(abs_path)
    return True, f"{rel}: summarized {len(old_blocks)} block(s), now ~{len(new_content)} chars."


def _get_archive_path_shard(month_key: str, shard_slug: str) -> tuple[str | None, str]:
    safe_slug = re.sub(r"[^a-z0-9_-]+", "_", (shard_slug or "general").lower())[:48]
    name = f"memory/archives/MEMORY_ARCHIVE_{month_key}__{safe_slug}.md"
    workspace = _workspace()
    return safe_resolve_path(workspace, name, allow_write=True, create_parents=True)


def _memory_paths_for_refresh(workspace: str) -> list[tuple[str, str]]:
    """(abs_path, label) for refresh_memory: shards + hub if dated blocks likely."""
    from utils.memory_shard_layout import layout_is_sharded, list_shard_abs_paths

    out: list[tuple[str, str]] = []
    if layout_is_sharded(workspace):
        for abs_p in list_shard_abs_paths(workspace):
            out.append((abs_p, os.path.relpath(abs_p, workspace)))
        mem_path = os.path.join(workspace, "MEMORY.md")
        if os.path.isfile(mem_path):
            raw = _read_memory_file(mem_path)
            if raw and re.search(r"##\s+\d{4}-\d{2}-\d{2}", raw):
                out.append((mem_path, "MEMORY.md"))
        return out
    mem_path, err = safe_resolve_path(workspace, "MEMORY.md", allow_write=True, create_parents=True)
    if not err and mem_path:
        out.append((mem_path, "MEMORY.md"))
    return out


def _get_memory_path() -> tuple[str | None, str]:
    """Resolve MEMORY.md in workspace. Returns (abs_path, error_message)."""
    from core import get_workspace_path
    workspace = get_workspace_path()
    return safe_resolve_path(workspace, "MEMORY.md", allow_write=True, create_parents=True)


def _get_archive_path(month_key: str) -> tuple[str | None, str]:
    """Resolve memory/archives/MEMORY_ARCHIVE_YYYY-MM.md in workspace. Returns (abs_path, error_message)."""
    from core import get_workspace_path
    workspace = get_workspace_path()
    return safe_resolve_path(workspace, f"memory/archives/MEMORY_ARCHIVE_{month_key}.md", allow_write=True, create_parents=True)


def _get_soft_cap() -> int:
    """Memory soft cap (characters) from config."""
    try:
        from core import get_memory_config
        return int(get_memory_config().get("soft_cap_characters", 18000))
    except Exception:
        return 18000


def _get_hard_cap() -> int:
    """Memory hard cap (characters) from config."""
    try:
        from core import get_memory_config
        return int(get_memory_config().get("hard_cap_characters", 24000))
    except Exception:
        return 24000


def append_to_memory_for_workspace(workspace: str, entry: str, shard: str | None = None) -> str:
    """Append a dated memory entry to *workspace* (background post-processors; not an agent tool)."""
    return _append_to_memory_impl(entry, shard=shard, workspace=(workspace or "").strip() or None)


def _append_to_memory_impl(
    entry: str,
    shard: str | None = None,
    *,
    workspace: str | None = None,
    tool_invocation: bool = False,
) -> str:
    if tool_invocation:
        _debug_log("[TOOL] append_to_memory called", "TOOL")
        _emit_input_status("Saving to memory…")
    entry = (entry or "").strip()
    if not entry:
        if tool_invocation:
            _emit_input_status("")
        return "No entry provided. Nothing appended."
    try:
        from persistence import get_current_agent_thread_id

        _run_tid = (get_current_agent_thread_id() or "").strip()
    except Exception:
        _run_tid = ""
    if _run_tid and suggested_scope_for_thread_id(_run_tid) == SCOPE_MESH_SAFE:
        if tool_invocation:
            _emit_input_status("")
        return (
            "Refused: hub/shard long-term memory cannot be appended from a mesh-scoped thread "
            "(e.g. `btw_*` / bridge ids). Use a main session, or write only to operator-approved "
            "mesh-safe notes."
        )
    _policy_prefs = get_memory_policy_prefs()
    _strictness = strictness_from_semantic_pref(_policy_prefs.get("memory_semantic_strictness"))
    if tool_invocation and _strictness != PromotionStrictness.OFF:
        try:
            from utils.user_model import is_personal_fact_line

            if is_personal_fact_line(entry):
                _emit_input_status("")
                return (
                    "Refused: this looks like a personal fact about the user. "
                    "Use **patch_user_model** (Settings → You) instead of append_to_memory."
                )
        except Exception:
            _debug_log("[WARN] is_personal_fact_line check raised; blocking as precaution", "WARN")
            if _strictness == PromotionStrictness.ENFORCE:
                _emit_input_status("")
                return (
                    "Refused: could not verify whether this is a personal fact. "
                    "Use **patch_user_model** for personal data, or try again."
                )
    _decision = classify_memory_entry(entry)
    if _strictness == PromotionStrictness.ENFORCE and _decision.classification == PromotionClass.BLOCKED:
        if tool_invocation:
            _emit_input_status("")
        return blocked_memory_message(secret_like=_decision.secret_like)
    if _strictness == PromotionStrictness.ENFORCE and _decision.classification == PromotionClass.DISCOURAGED:
        if tool_invocation:
            _emit_input_status("")
        return discouraged_memory_message(_decision.reason)
    _warn_prefix = ""
    if _strictness == PromotionStrictness.WARN and _decision.classification == PromotionClass.DISCOURAGED:
        _warn_prefix = "Warning: this memory entry may not be durable enough for long-term recall. "
    from utils.memory_shard_layout import (
        ensure_shard_file,
        layout_is_sharded,
        normalize_shard_slug,
        regenerate_memory_hub_toc,
    )

    ws = (workspace or "").strip() or _workspace()
    now = datetime.now()
    block = f"## {now.strftime('%Y-%m-%d %H:%M')}\n{entry}\n"
    if layout_is_sharded(ws):
        slug = normalize_shard_slug(shard)
        if slug is None:
            if tool_invocation:
                _emit_input_status("")
            return "Invalid shard slug: use lowercase letters, digits, underscore, or hyphen (1–64 chars)."
        abs_path, serr = ensure_shard_file(ws, slug)
        if serr or not abs_path:
            if tool_invocation:
                _emit_input_status("")
            return serr or "Could not resolve shard file."
        rel_label = f"memory/shards/{slug}.md"
        existing = _read_memory_file(abs_path)
        new_content = (existing.rstrip() + "\n\n" + block).strip() + "\n"
        try:
            secure_write_file(abs_path, new_content, mode=0o600)
        except Exception as e:
            _debug_log(f"[ERROR append_to_memory] write failed: {e}", "ERROR")
            return f"Failed to write {rel_label}: {e}"
        toc_err = regenerate_memory_hub_toc(ws)
        _touch_extra = [abs_path]
        hub_path = os.path.join(ws, "MEMORY.md")
        if os.path.isfile(hub_path):
            _touch_extra.append(hub_path)
        _touch_memory_fts(ws, *_touch_extra)
        if tool_invocation:
            _emit_input_status("")
        msg = f"{_warn_prefix}Appended to {rel_label} at {now.strftime('%Y-%m-%d %H:%M')} ({len(entry)} characters)."
        if toc_err:
            msg += f" (TOC refresh warning: {toc_err})"
        _pw = ((_warn_prefix or "").strip()) or None
        _memory_inspector_maybe_record_write(
            tool_name="append_to_memory",
            path_rel=rel_label,
            outcome="ok",
            delta_chars=len(entry),
            promotion_warning=_pw,
            detail=("toc_warning" if toc_err else None),
        )
        return msg
    abs_path, err = safe_resolve_path(ws, "MEMORY.md", allow_write=True, create_parents=True)
    if err or not abs_path:
        err_msg = err or "Could not resolve MEMORY.md"
        _debug_log(f"[ERROR append_to_memory] {err_msg}", "ERROR")
        return err_msg
    existing = _read_memory_file(abs_path)
    new_content = (existing.rstrip() + "\n\n" + block).strip() + "\n"
    try:
        secure_write_file(abs_path, new_content, mode=0o600)
    except Exception as e:
        _debug_log(f"[ERROR append_to_memory] write failed: {e}", "ERROR")
        return f"Failed to write MEMORY.md: {e}"
    _touch_memory_fts(ws, abs_path)
    if tool_invocation:
        _emit_input_status("")
    _pw = ((_warn_prefix or "").strip()) or None
    _memory_inspector_maybe_record_write(
        tool_name="append_to_memory",
        path_rel="MEMORY.md",
        outcome="ok",
        delta_chars=len(entry),
        promotion_warning=_pw,
    )
    return f"{_warn_prefix}Appended to MEMORY.md at {now.strftime('%Y-%m-%d %H:%M')} ({len(entry)} characters)."


@tool
def append_to_memory(entry: str, shard: str | None = None) -> str:
    """Append a dated memory entry. **Monolith layout:** writes root MEMORY.md. **Sharded layout** (see `memory/shards/.mugi_memory_layout`): writes `memory/shards/{shard}.md` (default `general`). Use optional `shard` for topic files (slug: lowercase letters, digits, `_`, `-`). Run `migrate_memory_to_shards` to switch from monolith. Format: ## YYYY-MM-DD HH:MM + entry body.

    With **Memory semantic strictness** = enforce (app prefs / LLMs pane), entries that look like secrets, meta-memory noise, vague placeholders, ultra-short notes, huge unbounded dumps, or purely episodic moments without a stable user-relevant fact are rejected—rewrite as a concise durable fact (preference, constraint, goal, relationship). Warn mode appends but prefixes a caution."""
    return _append_to_memory_impl(entry, shard=shard, tool_invocation=True)


@tool
def migrate_memory_to_shards(force: bool = False) -> str:
    """Switch from monolithic MEMORY.md to sharded layout (hub + memory/shards/*.md). Moves dated entries into general.md, makes MEMORY.md a TOC hub, and writes the layout marker. Idempotent when already sharded unless force=True."""
    _debug_log("[TOOL] migrate_memory_to_shards called", "TOOL")
    _emit_input_status("Migrating memory layout…")
    from utils.memory_shard_layout import migrate_memory_to_shards_impl

    out = migrate_memory_to_shards_impl(_workspace(), force=bool(force))
    try:
        from utils.memory_fts_index import schedule_memory_fts_rebuild

        schedule_memory_fts_rebuild(_workspace())
    except Exception:
        pass
    _emit_input_status("")
    _memory_inspector_maybe_record_write(
        tool_name="migrate_memory_to_shards",
        path_rel="memory/shards",
        outcome="ok",
        detail=(out[:280] + "…") if len(out) > 280 else out,
    )
    return out


@tool
def get_memory_size() -> dict:
    """Return long-term memory size: **monolith** — root MEMORY.md only; **sharded** — hub + all memory/shards/*.md. Includes `layout`, `by_shard`, `characters`, `lines`, `too_large` (true if aggregate OR any shard exceeds soft cap)."""
    _debug_log("[TOOL] get_memory_size called", "TOOL")
    _emit_input_status("Checking memory size…")
    from utils.memory_shard_layout import layout_is_sharded, list_shard_abs_paths

    workspace = _workspace()
    soft = _get_soft_cap()
    abs_path, err = _get_memory_path()
    base_chars = 0
    base_lines = 0
    if not err and abs_path:
        c = _read_memory_file(abs_path)
        base_chars = len(c)
        base_lines = len(c.splitlines())
    by_shard: dict[str, int] = {}
    agg_chars = 0
    agg_lines = 0
    if layout_is_sharded(workspace):
        layout = "sharded"
        for sap in list_shard_abs_paths(workspace):
            key = os.path.relpath(sap, workspace).replace("\\", "/")
            c = _read_memory_file(sap)
            by_shard[key] = len(c)
            agg_chars += len(c)
            agg_lines += len(c.splitlines())
        by_shard["MEMORY.md"] = base_chars
        agg_chars += base_chars
        agg_lines += base_lines
        too_large = (agg_chars > soft) or any(v > soft for v in by_shard.values())
    else:
        layout = "monolith"
        by_shard["MEMORY.md"] = base_chars
        agg_chars = base_chars
        agg_lines = base_lines
        too_large = base_chars > soft

    _emit_input_status("")
    if err and layout == "monolith":
        return {"characters": 0, "lines": 0, "too_large": False, "layout": layout, "by_shard": {}, "error": err}
    return {
        "characters": agg_chars,
        "lines": agg_lines,
        "too_large": too_large,
        "layout": layout,
        "by_shard": by_shard,
    }


@tool
def summarize_old_memory(max_chars_target: int = 18000) -> str:
    """Condense oversized memory: **sharded** — each shard over the target is summarized (largest first). **Monolith** — MEMORY.md only. Preserves recent blocks; adds '## Summary of older memories'."""
    _debug_log("[TOOL] summarize_old_memory called", "TOOL")
    _emit_input_status("Summarizing memory…")
    if max_chars_target <= 0:
        max_chars_target = _get_soft_cap()
    from utils.memory_shard_layout import layout_is_sharded, list_shard_abs_paths

    workspace = _workspace()
    if layout_is_sharded(workspace):
        paths = list_shard_abs_paths(workspace)
        sizes = [(len(_read_memory_file(p)), p) for p in paths]
        sizes.sort(key=lambda x: -x[0])
        msgs: list[str] = []
        for _sz, abs_p in sizes:
            changed, msg = _summarize_file_at_path(abs_p, max_chars_target)
            if changed and msg:
                msgs.append(msg)
        if not msgs:
            _emit_input_status("")
            return "All shard files are under the size target. No summarization needed."
        _emit_input_status("")
        _memory_inspector_maybe_record_write(
            tool_name="summarize_old_memory",
            path_rel="memory/shards",
            outcome="ok",
            detail=f"shards_touched={len(msgs)}",
        )
        return "Summarization:\n" + "\n".join(msgs)
    abs_path, err = _get_memory_path()
    if err:
        return err
    changed, msg = _summarize_file_at_path(abs_path, max_chars_target)
    if not changed:
        _emit_input_status("")
        return msg or "Memory is still small enough. No summarization needed."
    _emit_input_status("")
    _memory_inspector_maybe_record_write(
        tool_name="summarize_old_memory",
        path_rel="MEMORY.md",
        outcome="ok",
        detail=(msg[:200] + "…") if len(msg) > 200 else msg,
    )
    return msg


@tool
def archive_old_memory(max_chars_keep: int = 22000) -> str:
    """Archive oldest raw blocks: **sharded** — each oversized shard to memory/archives/MEMORY_ARCHIVE_YYYY-MM__{shard}.md. **Monolith** — single MEMORY_ARCHIVE_YYYY-MM.md."""
    _debug_log("[TOOL] archive_old_memory called", "TOOL")
    _emit_input_status("Archiving old memory…")
    if max_chars_keep <= 0:
        max_chars_keep = _get_hard_cap()
    from utils.memory_shard_layout import layout_is_sharded, list_shard_abs_paths

    workspace = _workspace()
    if layout_is_sharded(workspace):
        reports: list[str] = []
        for abs_path in list_shard_abs_paths(workspace):
            content = _read_memory_file(abs_path)
            if len(content) <= max_chars_keep:
                continue
            stem = os.path.basename(abs_path)[:-3] if abs_path.endswith(".md") else "shard"
            blocks = _split_into_blocks(content)
            if not blocks:
                continue
            summary_blocks = [(h, b) for h, b in blocks if "Summary of older memories" in (h or "")]
            raw_blocks = [(h, b) for h, b in blocks if "Summary of older memories" not in (h or "")]
            summary_content_len = len(_blocks_to_content(summary_blocks))
            budget = max(0, max_chars_keep - summary_content_len - 100)
            n = len(raw_blocks)
            keep_raw_count = 0
            for i in range(n - 1, -1, -1):
                suffix = raw_blocks[i:]
                if len(_blocks_to_content(suffix)) <= budget:
                    keep_raw_count = len(suffix)
                else:
                    break
            archive_blocks = raw_blocks[: n - keep_raw_count]
            keep_blocks = summary_blocks + raw_blocks[n - keep_raw_count:]
            if not archive_blocks:
                continue
            archive_month = datetime.now().strftime("%Y-%m")
            for h, _ in archive_blocks:
                m = re.match(r"^##\s+(\d{4})-(\d{2})-\d{2}", h or "")
                if m:
                    archive_month = f"{m.group(1)}-{m.group(2)}"
                    break
            archive_path, arch_err = _get_archive_path_shard(archive_month, stem)
            if arch_err or not archive_path:
                return arch_err or "Archive path error"
            archive_content = _blocks_to_content(archive_blocks)
            header_note = f"\n\n<!-- source: memory/shards/{stem}.md -->\n"
            existing_archive = _read_memory_file(archive_path)
            if existing_archive.strip():
                new_archive = existing_archive.rstrip() + "\n\n" + header_note.strip() + "\n" + archive_content
            else:
                new_archive = f"# MEMORY_ARCHIVE_{archive_month}__{stem}\n" + header_note + "\n" + archive_content
            try:
                secure_write_file(archive_path, new_archive.strip() + "\n", mode=0o600)
            except Exception as e:
                return f"Failed to write archive file: {e}"
            new_main = _blocks_to_content(keep_blocks)
            try:
                secure_write_file(abs_path, new_main, mode=0o600)
            except Exception as e:
                return f"Archived partially but failed to trim {stem}.md: {e}"
            _touch_memory_fts(workspace, archive_path, abs_path)
            reports.append(
                f"{stem}.md: moved {len(archive_blocks)} block(s) → {os.path.basename(archive_path)}"
            )
        _emit_input_status("")
        if not reports:
            return f"No shard files over {max_chars_keep} characters. No archiving needed."
        _memory_inspector_maybe_record_write(
            tool_name="archive_old_memory",
            path_rel="memory/archives",
            outcome="ok",
            detail=f"reports={len(reports)}",
        )
        return "Archive:\n" + "\n".join(reports)

    abs_path, err = _get_memory_path()
    if err:
        return err
    content = _read_memory_file(abs_path)
    if len(content) <= max_chars_keep:
        return f"MEMORY.md is already under {max_chars_keep} characters. No archiving needed."
    blocks = _split_into_blocks(content)
    if not blocks:
        return "No structured content to archive."
    summary_blocks = [(h, b) for h, b in blocks if "Summary of older memories" in (h or "")]
    raw_blocks = [(h, b) for h, b in blocks if "Summary of older memories" not in (h or "")]
    summary_content_len = len(_blocks_to_content(summary_blocks))
    budget = max(0, max_chars_keep - summary_content_len - 100)
    # Keep longest suffix of raw_blocks that fits in budget.
    n = len(raw_blocks)
    keep_raw_count = 0
    for i in range(n - 1, -1, -1):
        suffix = raw_blocks[i:]
        if len(_blocks_to_content(suffix)) <= budget:
            keep_raw_count = len(suffix)
        else:
            break
    archive_blocks = raw_blocks[: n - keep_raw_count]
    keep_blocks = summary_blocks + raw_blocks[n - keep_raw_count:]
    if not archive_blocks:
        return "No content to archive; all blocks fit in keep region."
    # Determine archive month from oldest archived block (first ## YYYY-MM-DD).
    archive_month = datetime.now().strftime("%Y-%m")
    for h, _ in archive_blocks:
        m = re.match(r"^##\s+(\d{4})-(\d{2})-\d{2}", h)
        if m:
            archive_month = f"{m.group(1)}-{m.group(2)}"
            break
    archive_path, arch_err = _get_archive_path(archive_month)
    if arch_err:
        return arch_err
    archive_content = _blocks_to_content(archive_blocks)
    existing_archive = _read_memory_file(archive_path)
    if existing_archive.strip():
        new_archive = existing_archive.rstrip() + "\n\n" + archive_content
    else:
        new_archive = f"# MEMORY_ARCHIVE_{archive_month}\n\n" + archive_content
    try:
        secure_write_file(archive_path, new_archive.strip() + "\n", mode=0o600)
    except Exception as e:
        _debug_log(f"[ERROR archive_old_memory] archive write failed: {e}", "ERROR")
        return f"Failed to write archive file: {e}"
    new_main = _blocks_to_content(keep_blocks)
    try:
        secure_write_file(abs_path, new_main, mode=0o600)
    except Exception as e:
        _debug_log(f"[ERROR archive_old_memory] main write failed: {e}", "ERROR")
        return f"Archived to {archive_path} but failed to trim MEMORY.md: {e}"
    _touch_memory_fts(workspace, archive_path, abs_path)
    _emit_input_status("")
    _memory_inspector_maybe_record_write(
        tool_name="archive_old_memory",
        path_rel="memory/archives",
        outcome="ok",
        delta_chars=len(archive_content),
        detail=os.path.basename(archive_path),
    )
    return f"Moved {len(archive_blocks)} block(s) to memory/archives/MEMORY_ARCHIVE_{archive_month}.md. MEMORY.md is now ~{len(new_main)} characters."


# ---------------------------------------------------------------------------
# SESSION-STATE.md tools (WAL Protocol)
# ---------------------------------------------------------------------------

_KEY_DETAIL_RE = re.compile(r"^- \*\*(.+?):\*\*\s*(.*)$", re.MULTILINE)


def _get_session_state_path() -> tuple[str | None, str]:
    """Resolve SESSION-STATE.md in workspace. Returns (abs_path, error_message)."""
    from core import get_workspace_path
    workspace = get_workspace_path()
    return safe_resolve_path(workspace, "SESSION-STATE.md", allow_write=True, create_parents=True)


def _secure_write_file_with_retry(abs_path: str, content: str, *, mode: int = 0o600) -> None:
    """Write via secure_write_file; one retry after ~100ms on transient I/O errors."""
    try:
        secure_write_file(abs_path, content, mode=mode)
    except (OSError, IOError) as e:
        _log.warning("secure_write_file failed, retrying once: %s", e)
        time.sleep(0.1)
        secure_write_file(abs_path, content, mode=mode)


@tool
def write_session_state(key: str, value: str) -> str:
    """Write or update a key-value pair in SESSION-STATE.md (WAL Protocol). Use this BEFORE responding when the user provides corrections, decisions, preferences, proper nouns, or specific values. The key should be a short label (e.g. 'Theme', 'User timezone', 'Project name') and value is the detail to persist."""
    _debug_log("[TOOL] write_session_state called", "TOOL")
    _emit_input_status("Saving context…")
    key = (key or "").strip()
    value = (value or "").strip()
    if not key:
        return "No key provided. Nothing written."
    abs_path, err = _get_session_state_path()
    if err:
        _debug_log(f"[ERROR write_session_state] {err}", "ERROR")
        return err
    content = _read_memory_file(abs_path)
    if not content.strip():
        content = (
            "# SESSION-STATE.md - Active Working Memory (WAL Target)\n\n"
            "_This file is your RAM. Chat history is a buffer — this is persistent state._\n\n"
            "## Current Task\n\n"
            "## Key Details\n\n"
            "## Context\n"
        )
    new_line = f"- **{key}:** {value}"
    # Try to update an existing key under ## Key Details
    updated = False
    lines = content.splitlines()
    for i, line in enumerate(lines):
        m = _KEY_DETAIL_RE.match(line)
        if m and m.group(1).strip().lower() == key.lower():
            lines[i] = new_line
            updated = True
            break
    if updated:
        new_content = "\n".join(lines)
        if not new_content.endswith("\n"):
            new_content += "\n"
    else:
        # Append after the "## Key Details" section header (or at end if not found)
        insert_idx = None
        for i, line in enumerate(lines):
            if line.strip() == "## Key Details":
                # Find next non-empty line or next ## to insert before
                insert_idx = i + 1
                while insert_idx < len(lines) and lines[insert_idx].strip() == "":
                    insert_idx += 1
                # If we hit another section header or something, insert before it
                # but after any existing key-detail lines
                while insert_idx < len(lines) and _KEY_DETAIL_RE.match(lines[insert_idx]):
                    insert_idx += 1
                break
        if insert_idx is not None:
            lines.insert(insert_idx, new_line)
        else:
            lines.append(new_line)
        new_content = "\n".join(lines)
        if not new_content.endswith("\n"):
            new_content += "\n"
    try:
        _secure_write_file_with_retry(abs_path, new_content, mode=0o600)
    except Exception as e:
        _debug_log(f"[ERROR write_session_state] write failed: {e}", "ERROR")
        return f"Failed to write SESSION-STATE.md: {e}"
    action = "Updated" if updated else "Added"
    _emit_input_status("")
    return f"{action} SESSION-STATE.md: {key} = {value}"


@tool
def read_session_state() -> str:
    """Read the contents of SESSION-STATE.md (active working memory / WAL target). Use this at the start of a session or after context loss to recover key details, decisions, and task state."""
    _debug_log("[TOOL] read_session_state called", "TOOL")
    _emit_input_status("Reading session state…")
    abs_path, err = _get_session_state_path()
    if err:
        _debug_log(f"[ERROR read_session_state] {err}", "ERROR")
        return err
    content = _read_memory_file(abs_path)
    _emit_input_status("")
    if not content.strip():
        return "SESSION-STATE.md is empty or does not exist yet."
    return content


@tool
def get_daily_memory_paths(include_yesterday: bool = True) -> str:
    """Return existing workspace-relative paths for daily summary files. Use these with read_file to load recent context. include_yesterday=True (default) checks today and yesterday; use False for today only. Format: memory/summaries/YYYY-MM-DD.md"""
    today = datetime.now()
    candidates = [f"memory/summaries/{today.strftime('%Y-%m-%d')}.md"]
    if include_yesterday:
        yesterday = today - timedelta(days=1)
        candidates.append(f"memory/summaries/{yesterday.strftime('%Y-%m-%d')}.md")
    try:
        from core import get_workspace_path
        workspace = get_workspace_path() or _DEFAULT_WORKSPACE
    except Exception:
        workspace = _DEFAULT_WORKSPACE
    workspace = os.path.expanduser(workspace)
    paths = [p for p in candidates if os.path.isfile(os.path.join(workspace, p))]
    if not paths:
        return "Paths for read_file:\n- (none found for requested days)"
    return "Paths for read_file:\n" + "\n".join(f"- {p}" for p in paths)


# ---------------------------------------------------------------------------
# Working Buffer tool (danger-zone logging)
# ---------------------------------------------------------------------------

def _get_working_buffer_path() -> tuple[str | None, str]:
    """Resolve memory/working-buffer.md (or working-buffer-<thread_id>.md when thread is set) in workspace. Returns (abs_path, error_message)."""
    from core import get_workspace_path
    try:
        from persistence import get_current_agent_thread_id
        tid = get_current_agent_thread_id()
    except Exception:
        tid = None
    workspace = get_workspace_path()
    if tid:
        safe_tid = "".join(c if c.isalnum() or c in "_-" else "_" for c in (tid or ""))[:32] or "default"
        rel = f"memory/working-buffer-{safe_tid}.md"
    else:
        rel = "memory/working-buffer.md"
    return safe_resolve_path(workspace, rel, allow_write=True, create_parents=True)


def _get_feedback_path() -> tuple[str | None, str]:
    """Resolve memory/feedback.md in workspace (read-only). Returns (abs_path, error_message)."""
    from core import get_workspace_path
    workspace = get_workspace_path()
    return safe_resolve_path(workspace, "memory/feedback.md", allow_write=False, create_parents=False)


@tool
def append_working_buffer(human_summary: str, agent_summary: str) -> str:
    """Append a timestamped exchange summary to memory/working-buffer.md (danger zone log). Use this when the conversation is long and context may be truncated. Provide a concise summary of the human's message and your response so future sessions can recover context from this file."""
    _debug_log("[TOOL] append_working_buffer called", "TOOL")
    _emit_input_status("Logging to buffer…")
    human_summary = (human_summary or "").strip()
    agent_summary = (agent_summary or "").strip()
    if not human_summary and not agent_summary:
        return "No summaries provided. Nothing appended."
    abs_path, err = _get_working_buffer_path()
    if err:
        _debug_log(f"[ERROR append_working_buffer] {err}", "ERROR")
        return err
    now = datetime.now().strftime("%Y-%m-%d %H:%M")
    entry = f"\n---\n\n## {now} Human\n{human_summary}\n\n## {now} Agent (summary)\n{agent_summary}\n"
    existing = _read_memory_file(abs_path)
    if not existing.strip():
        existing = "# Working Buffer (Danger Zone Log)\n**Status:** ACTIVE\n"
    # Activate the buffer if currently inactive
    if "**Status:** INACTIVE" in existing:
        existing = existing.replace("**Status:** INACTIVE", "**Status:** ACTIVE")
    new_content = existing.rstrip() + "\n" + entry
    try:
        secure_write_file(abs_path, new_content, mode=0o600)
    except Exception as e:
        _debug_log(f"[ERROR append_working_buffer] write failed: {e}", "ERROR")
        return f"Failed to write working-buffer.md: {e}"
    _emit_input_status("")
    return f"Appended exchange summary to working-buffer.md at {now}."


# ---------------------------------------------------------------------------
# Feedback (thumbs up/down) — read-only
# ---------------------------------------------------------------------------

@tool
def get_recent_feedback(n: int = 20) -> str:
    """Return the last n thumbs up/down entries from memory/feedback.md (most recent first). Use this to see how the user rated your past replies and adjust: prefer patterns that got thumbs up; avoid patterns that got thumbs down. If the file is missing or empty, returns a short message."""
    abs_path, err = _get_feedback_path()
    if err:
        return "No feedback file (path not resolved)."
    content = _read_memory_file(abs_path)
    if not content.strip():
        return "No feedback recorded yet."
    lines = content.splitlines()
    entries = [ln for ln in lines if ln.strip().startswith("- **")]
    if not entries:
        return "No feedback entries yet."
    last_n = entries[-n:] if len(entries) >= n else entries
    last_n.reverse()
    return "\n".join(last_n)


# ---------------------------------------------------------------------------
# Memory search
# ---------------------------------------------------------------------------

import re as _re
import fnmatch as _fnmatch
import math as _math

_MEMORY_SEARCH_DAYS = 14  # number of recent daily summary files to search by default


def _search_in_text(query: str, content: str, context_chars: int = 180) -> list[str]:
    """Return list of excerpt strings where query appears (case-insensitive), with context."""
    results = []
    lower_content = content.lower()
    lower_query = query.lower()
    start = 0
    while True:
        pos = lower_content.find(lower_query, start)
        if pos == -1:
            break
        snip_start = max(0, pos - context_chars // 2)
        snip_end = min(len(content), pos + len(query) + context_chars // 2)
        snippet = content[snip_start:snip_end].strip()
        # Collapse whitespace for display
        snippet = _re.sub(r"\n{3,}", "\n\n", snippet)
        results.append(snippet)
        start = pos + len(lower_query)
        if len(results) >= 5:  # cap per-file matches
            break
    return results


def _chunk_text(content: str, max_chars: int = 500) -> list[str]:
    parts = [p.strip() for p in content.split("\n\n") if p.strip()]
    out: list[str] = []
    for p in parts:
        if len(p) <= max_chars:
            out.append(p)
            continue
        start = 0
        while start < len(p):
            out.append(p[start:start + max_chars].strip())
            start += max_chars
    return out


def _tokenize(text: str) -> list[str]:
    return [t for t in _re.split(r"[^a-z0-9_]+", (text or "").lower()) if t]


def _semantic_hits(query: str, docs: list[tuple[str, str]], max_results: int) -> list[str]:
    # Lightweight TF-IDF cosine ranking without extra dependencies.
    q_tokens = _tokenize(query)
    if not q_tokens:
        return []
    doc_tokens = [(_tokenize(txt), src, txt) for src, txt in docs if txt.strip()]
    if not doc_tokens:
        return []
    n_docs = len(doc_tokens)
    df: dict[str, int] = {}
    for toks, _, _ in doc_tokens:
        for tok in set(toks):
            df[tok] = df.get(tok, 0) + 1
    idf = {tok: _math.log((1 + n_docs) / (1 + cnt)) + 1.0 for tok, cnt in df.items()}

    def vec(tokens: list[str]) -> dict[str, float]:
        tf: dict[str, float] = {}
        for tok in tokens:
            tf[tok] = tf.get(tok, 0.0) + 1.0
        for tok in list(tf.keys()):
            tf[tok] = tf[tok] * idf.get(tok, 1.0)
        return tf

    qv = vec(q_tokens)
    qnorm = _math.sqrt(sum(v * v for v in qv.values())) or 1.0
    scored: list[tuple[float, str, str]] = []
    for toks, src, txt in doc_tokens:
        dv = vec(toks)
        dnorm = _math.sqrt(sum(v * v for v in dv.values())) or 1.0
        dot = 0.0
        for tok, w in qv.items():
            dot += w * dv.get(tok, 0.0)
        score = dot / (qnorm * dnorm)
        if score > 0.05:
            scored.append((score, src, txt))
    scored.sort(key=lambda x: x[0], reverse=True)
    out: list[str] = []
    for _, src, txt in scored[:max_results]:
        out.append(f"[{src}]\n{txt}")
    return out


def _build_memory_fts_ingest_order(
    workspace: str,
    scope_eff: str,
    fts_rels: list[str],
    legacy_queue: list[tuple[str, str]],
    *,
    mem: dict,
) -> tuple[list[tuple[str, str]], bool]:
    """Hub + recent summaries (user scope), FTS-ranked paths, then optional legacy padding."""
    min_hits = int(mem.get("memory_fts_fallback_min_hits") or 4)
    seen: set[str] = set()
    out: list[tuple[str, str]] = []

    def add(rel: str, abs_p: str) -> None:
        if rel in seen:
            return
        if not abs_p or not os.path.isfile(abs_p):
            return
        seen.add(rel)
        out.append((rel, abs_p))

    ws_exp = os.path.expanduser(workspace)
    if scope_eff == SCOPE_USER_DURABLE:
        for rel, abs_p in legacy_queue:
            if rel == "MEMORY.md" or rel.startswith("memory/summaries/"):
                add(rel, abs_p)
    for rel in fts_rels:
        abs_p = os.path.join(ws_exp, rel.replace("/", os.sep))
        add(rel, abs_p)
    padded = len(fts_rels) < min_hits
    if padded:
        for rel, abs_p in legacy_queue:
            if rel not in seen:
                add(rel, abs_p)
    return out, padded


@tool
def search_memory(
    query: str,
    max_results: int = 15,
    include_archives: bool = False,
    scope: str | None = None,
    thread_id: str | None = None,
    semantic: bool = True,
) -> str:
    """Search long-term memory: MEMORY.md (hub), memory/shards/*.md when present, daily summaries, optionally archives.

    Use ``scope='mesh_safe'`` to search only ``memory/mesh_safe/*.md`` (peer-shareable notes); default ``user_durable`` searches standard memory locations.
    When ``thread_id`` identifies a mesh/bridge conversation, search is limited to ``memory/mesh_safe`` unless you use a non-mesh thread. Passing ``scope='user_durable'`` together with a mesh ``thread_id`` is rejected.
    When ``semantic=False``, only lexical/FTS hits are fused (no semantic chunk ranking).
    """
    query = (query or "").strip()
    if not query:
        return "No query provided. Please provide a search term."

    from core import get_workspace_path
    from core.config import get_memory_config
    from utils.memory_search_fusion import (
        apply_memory_retrieval_profile_to_fusion_weights,
        fuse_memory_search,
        read_memory_file_capped,
        recency_for_source,
    )
    from utils.memory_scopes import normalize_memory_scope
    from utils.memory_fts_index import (
        collect_legacy_ingest_queue,
        memory_fts_row_count,
        schedule_memory_fts_rebuild,
        search_memory_fts_candidates,
    )

    workspace = get_workspace_path()
    mem = get_memory_config()
    apply_memory_retrieval_profile_to_fusion_weights(mem, get_memory_policy_prefs().get("memory_retrieval_profile"))
    raw_scope = (scope or "").strip().lower()
    scope_eff = normalize_memory_scope(scope)
    tid = (thread_id or "").strip()
    if not tid:
        try:
            from persistence import get_current_agent_thread_id

            tid = (get_current_agent_thread_id() or "").strip()
        except Exception:
            tid = ""
    if tid and suggested_scope_for_thread_id(tid) == SCOPE_MESH_SAFE:
        if raw_scope == SCOPE_USER_DURABLE:
            return (
                "Memory search refused: mesh-scoped `thread_id` cannot use scope='user_durable'. "
                "Use scope='mesh_safe' (or omit scope) to search only memory/mesh_safe."
            )
        scope_eff = SCOPE_MESH_SAFE
    _debug_log(f"[TOOL] search_memory query={query!r} scope={scope_eff}", "TOOL")
    _emit_input_status("Searching memory…")

    per_file = int(mem.get("memory_search_max_bytes_per_file") or 200_000)
    total_budget = int(mem.get("memory_search_max_total_bytes") or 1_500_000)
    max_chunks = int(mem.get("memory_search_max_semantic_chunks") or 600)

    bytes_read = 0
    scan_truncated = False
    lexical_hits: list[tuple[str, str, float]] = []
    semantic_docs: list[tuple[str, str, float]] = []

    def ingest_file(rel: str, abs_path: str) -> None:
        nonlocal bytes_read, scan_truncated
        if not abs_path or not os.path.isfile(abs_path):
            return
        if bytes_read >= total_budget:
            scan_truncated = True
            return
        budget_left = max(0, total_budget - bytes_read)
        cap = min(per_file, budget_left)
        content, truncated = read_memory_file_capped(abs_path, cap)
        if truncated:
            scan_truncated = True
        bytes_read += len(content.encode("utf-8", errors="replace"))
        rec = recency_for_source(rel)
        for hit in _search_in_text(query, content):
            lexical_hits.append((rel, hit, rec))
        if semantic:
            for c in _chunk_text(content):
                semantic_docs.append((rel, c, rec))

    legacy_queue = collect_legacy_ingest_queue(
        workspace, scope_eff, include_archives=include_archives, mem=mem
    )
    fts_meta: dict[str, Any] = {
        "memory_fts_used": False,
        "memory_fts_candidates": 0,
        "memory_fts_fallback_padded": False,
        "memory_fts_rebuild_scheduled": False,
        "memory_fts_index_error": None,
    }
    ingest_order = legacy_queue
    if bool(mem.get("memory_fts_enabled", True)):
        nrows = memory_fts_row_count(workspace)
        if nrows == 0:
            schedule_memory_fts_rebuild(workspace)
            fts_meta["memory_fts_rebuild_scheduled"] = True
        else:
            max_cand = int(mem.get("memory_fts_max_candidates") or 80)
            rels, err = search_memory_fts_candidates(
                workspace,
                scope_eff,
                query,
                include_archives=include_archives,
                mem=mem,
                limit=max_cand,
            )
            fts_meta["memory_fts_index_error"] = err
            if err:
                ingest_order = legacy_queue
            elif rels:
                ingest_order, padded = _build_memory_fts_ingest_order(
                    workspace, scope_eff, rels, legacy_queue, mem=mem
                )
                fts_meta["memory_fts_used"] = True
                fts_meta["memory_fts_candidates"] = len(rels)
                fts_meta["memory_fts_fallback_padded"] = padded
            else:
                ingest_order = legacy_queue
    for rel, abs_p in ingest_order:
        if bytes_read >= total_budget:
            scan_truncated = True
            break
        ingest_file(rel, abs_p)

    semantic_chunks_capped = False
    if len(semantic_docs) > max_chunks:
        semantic_chunks_capped = True
        semantic_docs.sort(key=lambda x: -x[2])
        semantic_docs = semantic_docs[:max_chunks]

    fused, stats = fuse_memory_search(
        query,
        lexical_hits=lexical_hits,
        semantic_docs=semantic_docs,
        cfg=mem,
        max_results=max_results,
    )
    _debug_log(f"[TOOL] search_memory fusion stats={stats!r} scan_truncated={scan_truncated}", "TOOL")

    def _emit_search_policy(*, result_count: int, empty: bool) -> None:
        try:
            emit_policy_event(
                "memory_search",
                "search_memory",
                {
                    "scope": scope_eff,
                    "mesh_thread": bool(tid and suggested_scope_for_thread_id(tid) == SCOPE_MESH_SAFE),
                    "include_archives": bool(include_archives),
                    "max_results": int(max_results),
                    "bytes_read": int(bytes_read),
                    "scan_truncated": bool(scan_truncated),
                    "semantic_chunks_capped": bool(semantic_chunks_capped),
                    "fusion": stats,
                    "result_count": int(result_count),
                    "empty": bool(empty),
                    **fts_meta,
                },
            )
        except Exception:
            pass

    if not fused:
        _emit_search_policy(result_count=0, empty=True)
        if scope_eff == SCOPE_MESH_SAFE:
            scope_desc = "memory/mesh_safe/*.md"
        else:
            scope_desc = "MEMORY.md, memory/shards, recent summaries" + (
                ", and archives" if include_archives else ""
            )
        msg = f"No matches for '{query}' in {scope_desc}."
        if scan_truncated:
            msg += (
                " Partial scan: byte budget exhausted before all memory files were read; "
                "narrow your query or raise [memory].memory_search_max_total_bytes in mugi_config.toml."
            )
        return msg

    header = f"Search results for '{query}' ({len(fused)} match{'es' if len(fused) != 1 else ''}):\n"
    body = "\n\n---\n".join(fused)
    if scan_truncated:
        body += (
            "\n\n---\nPartial scan: not every memory file was fully read (byte budget). "
            "Narrow the query or increase [memory].memory_search_max_total_bytes."
        )
    _emit_search_policy(result_count=len(fused), empty=False)
    _memory_inspector_maybe_record_search("search_memory", query, fused)
    return header + body


# Time-relative phrases that become stale after their entry's timestamp ages past the threshold.
_TIME_RELATIVE_PATTERNS = re.compile(
    r"\b(this week|this month|right now|currently|at the moment|today|yesterday|"
    r"just now|these days|lately|recently|at present|working on)\b",
    re.IGNORECASE,
)
_DEFAULT_STALE_DAYS = 14


@tool
def refresh_memory() -> str:
    """Review long-term memory (MEMORY.md hub and memory/shards/*.md when sharded) for stale entries (time-relative language older than 14 days). Processes up to a capped number of candidates per call; run again if needed. Uses an LLM for KEEP/REWRITE/REMOVE decisions."""
    _debug_log("[TOOL] refresh_memory called", "TOOL")
    _emit_input_status("Refreshing memory…")

    workspace = _workspace()
    paths = _memory_paths_for_refresh(workspace)
    if not paths:
        _emit_input_status("")
        return "Could not resolve memory files. Nothing to refresh."

    now = datetime.now()
    stale_candidates: list[tuple[str, str, int, str, str, str]] = []
    # abs_path, label, block_idx, header, body, reason
    for abs_path, label in paths:
        if len(stale_candidates) >= _REFRESH_MAX_STALE_ENTRIES:
            break
        content = _read_memory_file(abs_path)
        if not content.strip():
            continue
        blocks = _split_into_blocks(content)
        for i, (header, body) in enumerate(blocks):
            if len(stale_candidates) >= _REFRESH_MAX_STALE_ENTRIES:
                break
            hdr = header or ""
            date_match = re.search(r"(\d{4}-\d{2}-\d{2})", hdr)
            if not date_match:
                continue
            try:
                entry_date = datetime.strptime(date_match.group(1), "%Y-%m-%d")
            except ValueError:
                continue
            age_days = (now - entry_date).days
            if age_days < _DEFAULT_STALE_DAYS:
                continue
            if _TIME_RELATIVE_PATTERNS.search(body):
                stale_candidates.append(
                    (abs_path, label, i, header, body, f"time-relative, {age_days}d old [{label}]"),
                )

    if not stale_candidates:
        _emit_input_status("")
        return "No stale time-relative entries found in long-term memory files. All appear current."

    insights_content = ""
    try:
        insights_path = os.path.join(workspace, "memory", "insights.md")
        if os.path.isfile(insights_path):
            with open(insights_path, "r", encoding="utf-8") as f:
                insights_content = f.read(4000)
    except Exception:
        pass

    superseded_section = ""
    if insights_content and "Superseded" in insights_content:
        sup_match = re.search(r"## Superseded.*?\n(.*?)(?=\n## |\Z)", insights_content, re.DOTALL)
        if sup_match:
            superseded_section = sup_match.group(1).strip()

    entries_text = ""
    for idx, (_ap, label, _i, header, body, reason) in enumerate(stale_candidates):
        entries_text += f"\n\n--- Entry {idx + 1} ({reason}) ---\n{header}\n{body}"

    context_for_llm = f"Stale memory entries to review:{entries_text}"
    if superseded_section:
        context_for_llm += f"\n\n--- Contradictions from insights ---\n{superseded_section}"

    prompt_messages = [
        {"role": "system", "content": (
            "You are a memory maintenance engine. For each stale memory entry below, "
            "decide one of three actions:\n"
            "- KEEP: Still relevant, no change needed\n"
            "- REWRITE: Convert time-relative language to historical fact "
            "(e.g. 'working on X this week' → 'In Jan 2026, worked on X')\n"
            "- REMOVE: No longer relevant, superseded, or trivial\n\n"
            "For REWRITE entries, provide the rewritten text.\n"
            "Output format per entry:\n"
            "Entry N: ACTION\n"
            "Rewritten: [only if REWRITE] new text here\n"
        )},
        {"role": "user", "content": context_for_llm},
    ]
    result_text = ""
    try:
        from utility_model import utility_complete
        result_text = utility_complete(
            prompt_messages,
            max_tokens=512,
            temperature=0.2,
            activity_operation="memory_refresh",
        ) or ""
    except Exception:
        result_text = ""

    if not result_text:
        try:
            from agent_loop import _get_openai_client, get_backend_base_url, get_llmster_model
            base_url = get_backend_base_url()
            is_lmstudio = "1234" in (base_url or "")
            llm_model = (get_llmster_model() or "local-model") if is_lmstudio else "local-model"

            client = _get_openai_client(base_url, "lm-studio")
            response = client.chat.completions.create(
                model=llm_model,
                messages=prompt_messages,
                max_tokens=512,
                temperature=0.2,
                stream=False,
            )
            result_text = (response.choices[0].message.content or "").strip()
        except Exception as e:
            _emit_input_status("")
            return f"Memory refresh failed (LLM call error): {e}. {len(stale_candidates)} stale entries detected but not processed."

    removes_by_file: dict[str, set[int]] = {}
    rewrites_by_file: dict[str, dict[int, str]] = {}
    removed = 0
    rewritten = 0
    kept = 0

    for idx, (abs_path, _label, block_idx, _header, _body, _reason) in enumerate(stale_candidates):
        entry_label = f"Entry {idx + 1}"
        if entry_label not in result_text:
            kept += 1
            continue
        entry_section = result_text[result_text.index(entry_label):]
        first_line = entry_section.split("\n")[0] if entry_section else ""
        if "REMOVE" in first_line:
            removes_by_file.setdefault(abs_path, set()).add(block_idx)
            removed += 1
        elif "REWRITE" in first_line:
            rewrite_match = re.search(r"Rewritten:\s*(.+?)(?=\nEntry |\Z)", entry_section, re.DOTALL)
            if rewrite_match:
                rewrites_by_file.setdefault(abs_path, {})[block_idx] = rewrite_match.group(1).strip()
                rewritten += 1
            else:
                kept += 1
        else:
            kept += 1

    if not removes_by_file and not rewrites_by_file:
        _emit_input_status("")
        return f"Reviewed {len(stale_candidates)} stale entries; all deemed still relevant. No changes made."

    touched: list[str] = []
    for abs_path in sorted(set(removes_by_file) | set(rewrites_by_file)):
        content = _read_memory_file(abs_path)
        blocks = _split_into_blocks(content)
        rm = removes_by_file.get(abs_path, set())
        rw = rewrites_by_file.get(abs_path, {})
        new_blocks: list[tuple[str, str]] = []
        for i, (header, body) in enumerate(blocks):
            if i in rm:
                continue
            if i in rw:
                new_blocks.append((header, rw[i]))
            else:
                new_blocks.append((header, body))
        new_content = _blocks_to_content(new_blocks)
        try:
            secure_write_file(abs_path, new_content, mode=0o600)
            touched.append(os.path.relpath(abs_path, workspace))
        except Exception as e:
            _emit_input_status("")
            return f"Memory refresh: changes computed but write failed for {abs_path}: {e}"

    if touched:
        _touch_memory_fts(workspace, *[os.path.join(workspace, p) for p in touched])
    _emit_input_status("")
    summary = (
        f"Memory refreshed: {removed} entries removed, {rewritten} rewritten, {kept} kept unchanged. "
        f"Files: {', '.join(touched)}."
    )
    if len(stale_candidates) >= _REFRESH_MAX_STALE_ENTRIES:
        summary += f" (Capped at {_REFRESH_MAX_STALE_ENTRIES} entries this run; call again if needed.)"
    return summary


_MEMORY_TOOLS = [
    append_to_memory,
    migrate_memory_to_shards,
    get_memory_size,
    summarize_old_memory,
    archive_old_memory,
    write_session_state,
    read_session_state,
    get_daily_memory_paths,
    append_working_buffer,
    get_recent_feedback,
    search_memory,
    refresh_memory,
]
