#!/usr/bin/env node
// claude-mem-lite MCP Server — All-in-one memory system
// FTS5 search, zero LLM calls, single process

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { truncate, typeIcon, inferProject, scrubSecrets, fmtDate, debugLog, debugCatch, isPathConfined } from './utils.mjs';
import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
import { ensureDb, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
import { reRankWithContext, autoBoostIfNeeded, runIdleCleanup, buildServerInstructions } from './search-scoring.mjs';
import { searchObservationsHybrid } from './search-engine.mjs';
import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady } from './deep-search.mjs';
import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
import { buildNotLowSignalSql } from './lib/low-signal-patterns.mjs';
import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentTimeline, fetchTimelineWindow } from './lib/timeline-core.mjs';
import { buildSearchFtsQuery, parseDateBounds, parseDuration, coreRunSearchPipeline } from './lib/search-core.mjs';
import {
  cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
  recoverOrphanedChildren, recoverBuriedLessons,
  purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
  recoverChildrenOf, hardDeleteCandidateCount,
  OP_CAP, STALE_AGE_MS,
} from './lib/maintain-core.mjs';
import { snapshotDb } from './lib/db-backup.mjs';
import { effectiveQuiet, RUNTIME_DIR } from './hook-shared.mjs';
import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
import { formatObsFieldValue } from './cli/common.mjs';
import { memSearchSchema, memRecentSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema, memMaintainSchema, memOptimizeSchema, memUpdateSchema, memExportSchema, memRecallSchema, memFtsCheckSchema, memRegistrySchema, memBrowseSchema, memUseSchema, memDeferSchema, memDeferListSchema, memDeferDropSchema, tools as TOOL_DEFS } from './tool-schemas.mjs';

// Lookup helper: all user-facing tool descriptions live in tool-schemas.mjs
// (discouragement-style, Task 5). This keeps server.mjs from drifting.
const _toolDescByName = Object.fromEntries(TOOL_DEFS.map((t) => [t.name, t.description]));
function descriptionOf(name) {
  const d = _toolDescByName[name];
  if (!d) throw new Error(`tool-schemas.mjs is missing description for "${name}"`);
  return d;
}
import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
import { join, sep } from 'path';
import { homedir } from 'os';
import { ensureRegistryDb, upsertResource } from './registry.mjs';
import { searchResources } from './registry-retriever.mjs';
import { probeOtherSources as probeIdSources, bucketIdTokens } from './lib/id-routing.mjs';
import { saveObservation } from './lib/save-observation.mjs';
import { rebuildObservationDerived } from './lib/observation-write.mjs';
import { recallByFile } from './lib/recall-core.mjs';
import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
import {
  insertDeferred, listOpenWithOrdinal, dropDeferred,
  resolveDeferredIds, closeDeferredItems,
} from './lib/deferred-work.mjs';
import { _resetVocabCache } from './tfidf.mjs';
import { createRequire } from 'module';

const require = createRequire(import.meta.url);
const { version: PKG_VERSION } = require('./package.json');

// ─── Database ───────────────────────────────────────────────────────────────

import { rmSync, existsSync, readFileSync, appendFileSync, mkdirSync } from 'fs';

let db;
try {
  db = ensureDb();
} catch (firstErr) {
  // WAL-delete recovery is ONLY safe for genuine corruption. On a transient
  // error (SQLITE_BUSY) or the forward-version guard throw, deleting the WAL
  // would discard committed-but-uncheckpointed transactions — silent data loss.
  // Restrict the rm to corruption signatures; otherwise fail fast, WAL intact.
  const sig = `${firstErr.code || ''} ${firstErr.message || ''}`;
  const isCorruption = /SQLITE_CORRUPT|SQLITE_NOTADB|malformed|not a database|disk image/i.test(sig);
  if (!isCorruption) {
    console.error(`[claude-mem-lite] FATAL: Database cannot be opened: ${firstErr.message}`);
    console.error(`[claude-mem-lite] Left WAL/SHM intact (not a corruption error). If this persists, retry or reinstall: node install.mjs install`);
    process.exit(1);
  }
  // Recovery: remove WAL/SHM files (corrupt WAL is the most common cause) and retry
  debugLog('WARN', 'server', `DB corruption detected, attempting WAL recovery: ${firstErr.message}`);
  try { rmSync(DB_PATH + '-wal', { force: true }); } catch {}
  try { rmSync(DB_PATH + '-shm', { force: true }); } catch {}
  try {
    db = ensureDb();
    debugLog('INFO', 'server', 'DB recovered after WAL cleanup');
  } catch (retryErr) {
    // Fatal: log and exit with descriptive message (Claude Code shows stderr)
    console.error(`[claude-mem-lite] FATAL: Database cannot be opened: ${retryErr.message}`);
    console.error(`[claude-mem-lite] Try: rm "${DB_PATH}-wal" "${DB_PATH}-shm" or reinstall with: node install.mjs install`);
    process.exit(1);
  }
}
// Server process uses longer busy_timeout for concurrent MCP requests
db.pragma('busy_timeout = 5000');

// ─── Registry Database (lazy-loaded on first mem_registry call) ─────────────

let registryDb = null;

function getRegistryDb() {
  if (registryDb) return registryDb;
  try {
    registryDb = ensureRegistryDb(REGISTRY_DB_PATH);
    registryDb.pragma('busy_timeout = 5000'); // match main DB + ensureRegistryDb (was 3000, overriding it back down)
  } catch (e) {
    debugLog('WARN', 'server', `Registry DB not available: ${e.message}`);
  }
  return registryDb;
}

// inferProject, typeIcon, truncate, fmtDate imported from utils.mjs

// ─── Project Name Resolution ────────────────────────────────────────────────
// Users naturally type short names like "mem" but inferProject() stores
// "projects--mem" (parent--base from CWD). resolveProject() bridges this gap.
// Implementation extracted to project-utils.mjs; local adapter closes over module-level `db`.

function resolveProject(name) { return _resolveProjectShared(db, name); }

// ─── Scoring Model Constants ────────────────────────────────────────────────
//
// Composite scoring: BM25(weights) × recency_decay × [project_boost] × [importance] × [access_bonus]
//
// BM25 column weights — higher weight = matches in that column score higher:
//   observations_fts:        title=10, subtitle=5, narrative=5, text=3, facts=3, concepts=2, lesson_learned=8
//   session_summaries_fts:   request=5, investigated=3, learned=3, completed=3, next_steps=2, notes=1, remaining_items=1
//
// Recency decay — exponential half-life:
//   factor = 1 + e^(-ln2 × age_ms / half_life_ms)
//   At age=0: 2.0 (full boost) → at half_life: 1.5 → at ∞: 1.0
//   0.693 = ln(2), ensures exact halving at each half-life interval
//
// Optional per-query modifiers:
//   Project boost: 2× for current project matches
//   Importance:    0.5 + 0.5 × importance (range 0.5–2.0)
//   Access bonus:  1 + 0.1 × ln(1 + access_count)

// Session/prompt FTS scoring (SESS_BM25 + recency decay) lives in lib/search-core.mjs.

// ─── MCP Server ─────────────────────────────────────────────────────────────

// Emit one-line instructions-mode trace on stderr so debugging the "why did
// the server send BASE instead of BASE+VERBOSE?" path doesn't require reading
// three files (server.mjs → hook-shared.mjs → memdir.mjs). CLAUDE_MEM_QUIET_TRACE=0
// opts out. stderr doesn't pollute the MCP stdio protocol channel.
const _quiet = effectiveQuiet();
if (process.env.CLAUDE_MEM_QUIET_TRACE !== '0') {
  const reason = process.env.MEM_QUIET_HOOKS === '1'
    ? 'env:MEM_QUIET_HOOKS=1'
    : _quiet ? 'adopted:steering' : 'none';
  const mode = _quiet ? 'BASE' : 'BASE+VERBOSE';
  process.stderr.write(`[mem] instructions: ${mode} reason=${reason}\n`);
}

const server = new McpServer(
  { name: 'mem-lite', version: PKG_VERSION },
  { instructions: buildServerInstructions(_quiet) },
);

// Track MCP request activity for idle-time cleanup (see idle timer below)
let lastMcpRequestTime = Date.now();
let idleCleanupRan = false;

function safeHandler(fn) {
  return async (args, extra) => {
    try {
      lastMcpRequestTime = Date.now();
      idleCleanupRan = false;
      return await fn(args, extra);
    } catch (err) {
      return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
    }
  };
}

// ─── Tool: mem_search — helper functions ────────────────────────────────────

// TYPE_DECAY_CASE imported from utils.mjs

// Score expression variants for FTS5 queries (see Scoring Model Constants above)
// Observation-search core (FTS query/params builders, hybrid pipeline) lives in
// search-engine.mjs so mem-cli.mjs gets the identical implementation.

// searchObservations / searchSessions / searchPrompts were consolidated into the
// shared coreRunSearchPipeline (lib/search-core.mjs). This surface is now a thin
// adapter (runSearchPipeline below); only output formatting stays local.
function formatSearchOutput(paginatedResults, args, ftsQuery, totalCount, orFallbackFired = false, isDeepSearch = false) {
  if (paginatedResults.length === 0) {
    const hint = [];
    if (isDeepSearch) {
      // Deep search runs even when the literal query sanitizes to empty, so the
      // "query was filtered" hint below would be misleading — the LLM rewrite ran
      // N variants and simply found nothing (F9).
      hint.push('No results — deep search rewrote the query into variants and still found nothing.');
      hint.push('This is a recall miss (the rewrite ran), not a query-syntax issue; the memory likely has no related observations.');
    } else if (args.query && !ftsQuery) {
      hint.push(`Query "${args.query}" was filtered (FTS5 keywords/special chars only).`);
      hint.push('Tip: use content words instead of operators (AND, OR, NOT, NEAR).');
    } else {
      hint.push('No results found.');
      if (args.query) {
        const expanded = ftsQuery || args.query;
        if (expanded !== args.query) hint.push(`Searched as: ${expanded}`);
        hint.push('Tip: check spelling, try broader terms, or use mem_stats to see available data.');
      }
    }
    return { content: [{ type: 'text', text: hint.join('\n') }] };
  }

  const lines = [];
  // "N of M" whenever the population exceeds the page — NOT gated on isCrossSource.
  // totalCount is the true limit/offset-invariant population (countSearchTotal), so
  // single-source searches (obs_type / type / importance filters) must surface it too.
  // The old isCrossSource gate predated countSearchTotal: back then single-source
  // totalCount was just results.length, so suppressing "of M" hid nothing. Now it hid
  // the real total, diverging from the CLI (mem-cli.mjs has no such gate). (#8217)
  const countLabel = totalCount > paginatedResults.length
    ? `${paginatedResults.length} of ${totalCount}`
    : `${paginatedResults.length}`;
  const hasMixed = paginatedResults.some(r => r.source === 'session' || r.source === 'prompt');
  // P2-6: empty/omitted query falls through to a "listing recent" path — label it explicitly
  // so callers don't mistake BM25-less results for relevance-ranked ones.
  const qLabel = args.query ? ` for "${args.query}"` : ' (no query — listing recent)';
  // Surface AND→OR fallback so callers (incl. Claude) know a strict multi-term
  // query actually matched only a subset of the terms. Suppressed when the caller
  // explicitly requested OR semantics — there's no "fallback" in that path.
  const fallbackHint = orFallbackFired && !args.or ? ' (relaxed AND→OR)' : '';
  lines.push(`Found ${countLabel} result(s)${qLabel}${fallbackHint}:${hasMixed ? ' (# observation, S# session, P# prompt)' : ''}\n`);

  // `~Nt` = estimated tokens to fetch this row's full body via mem_get (attachBodyTokens).
  // Conditional so a result that skipped enrichment renders cleanly, not "~undefinedt".
  const tok = r => (r.bodyTokens ? ` ~${r.bodyTokens}t` : '');
  for (const r of paginatedResults) {
    if (r.source === 'obs') {
      lines.push(`#${r.id} ${typeIcon(r.type)} [${r.type}] ${truncate(r.title || r.subtitle || '(untitled)')} | ${r.project} | ${fmtDate(r.date)}${tok(r)}`);
      if (r.snippet && r.snippet.length > 10 && r.snippet !== r.title) {
        lines.push(`     ${truncate(r.snippet, 100)}`);
      }
    } else if (r.source === 'session') {
      lines.push(`S#${r.id} 📋 ${truncate(r.request || r.completed || '(no summary)')} | ${r.project} | ${fmtDate(r.date)}${tok(r)}`);
    } else if (r.source === 'prompt') {
      lines.push(`P#${r.id} 💬 ${truncate(r.text)} | ${fmtDate(r.date)}${tok(r)}`);
    }
  }

  lines.push(`\nWorkflow: mem_timeline(anchor=ID) for context | mem_get(ids=[...]) for full details  ·  ~Nt = est. tokens to fetch full detail`);
  return { content: [{ type: 'text', text: lines.join('\n') }] };
}

// ─── Tool: mem_search ───────────────────────────────────────────────────────

// Exported for tests: runs the full mem_search pipeline against an explicit db
// with an optional injected llm (deepSearch dependency). The MCP tool handler
// calls this with the module db and the default llm.
// NOTE: resolveProject() inside runSearchPipeline closes over the module-level `db`,
// not the injected one. Tests that pass a project: arg via this seam will trigger
// resolveProject() against the real (module) DB, not the test DB.
export async function handleSearchForTest(db, args, { llm, rerankLlm } = {}) {
  return runSearchPipeline(db, args, { llm, rerankLlm });
}

async function runSearchPipeline(db, args, { llm, rerankLlm } = {}) {
  if (args.project) args = { ...args, project: resolveProject(args.project) };
  const limit = args.limit ?? 20;
  const offset = args.offset ?? 0;
  // args.or: force OR from the start (CLI `search --or` parity). The default path
  // still does AND with the engine's OR-fallback when AND returns 0.
  const ftsQuery = buildSearchFtsQuery(args.query, { or: args.or });
  const currentProject = inferProject();

  const bounds = parseDateBounds(args.date_from, args.date_to, args.date_since);
  if (!bounds.ok) {
    if (bounds.bad === 'since') throw new Error(`Invalid date_since: "${bounds.value}" (use <N><unit>, e.g. 7d, 24h, 90m, 2w)`);
    throw new Error(`Invalid date_${bounds.bad}: "${bounds.value}" (use ISO 8601 or YYYY-MM-DD)`);
  }
  const { epochFrom, epochTo } = bounds;

  // MCP defaults to 'auto' (escalate on weak results) unless overridden by
  // args.deep or CLAUDE_MEM_AUTO_DEEP. Rerank is explicit-deep only (D#43).
  const deepMode = resolveDeepMode(args.deep, { surface: 'mcp' });
  const rerank = args.rerank === true && deepMode === 'deep';

  // Early return when query was provided but sanitized to nothing (all FTS5
  // keywords/special chars). Skipped for deep/auto (the LLM rewrite may still
  // produce variants) and for filter-only listings (date/obs_type/importance).
  if (args.query && !ftsQuery && !epochFrom && !epochTo && !args.obs_type && !args.importance && deepMode === 'normal') {
    return { ...formatSearchOutput([], args, ftsQuery, 0), escalated: false, results: [], total: 0, variants: null };
  }

  // obs_type/importance/branch/tier ⇒ observations-only; deep is observations-only too
  // (deepSearch fuses hybrid-obs lists). args.type is the source filter
  // (observations|sessions|prompts). Forcing obs-only for the obs-exclusive fields
  // matches the CLI (mem-cli.mjs:177): session/prompt tables have no importance/branch/tier
  // column, so without the force those legs return UNFILTERED and leak rows that can't be
  // scoped to the filter (branch/importance/tier previously leaked cross-source on MCP).
  const effectiveType = deepMode === 'deep'
    ? 'observations'
    : (args.type || ((args.obs_type || args.importance || args.branch || args.tier) ? 'observations' : undefined));

  const r = await coreRunSearchPipeline(
    {
      db, currentProject, env: process.env,
      searchObservationsHybrid, deepSearch, shouldEscalateToDeep, autoDeepLlmReady,
      reRankWithContext, llm, rerankLlm,
    },
    {
      query: args.query, ftsQuery, effectiveSource: effectiveType, deepMode, rerank,
      limit, offset, project: args.project ?? null, obsType: args.obs_type ?? null,
      importance: args.importance ?? null, branch: args.branch ?? null,
      includeNoise: args.include_noise === true, epochFrom, epochTo,
      sort: args.sort || 'relevance', tier: args.tier ?? null,
      // ── MCP surface policy ──
      obsTypeFallback: true,             // list-recent-by-type when 0 matches
      crossSourceEpochSortNoFts: true,   // epoch-sort cross-source with no ftsQuery
      rerankPolicy: 'mcp',               // (ftsQuery||isDeep) gate; re-rank/re-sort on ftsQuery&&!reranked
      rerankProject: currentProject,
      recentListingNoFts: true,          // recent-listing for explicit --source with no ftsQuery
      tolerateMissingFts: false,
      tierPosition: 'late',              // tier filter after re-rank
      tierProject: args.project || currentProject,
    }
  );

  // Observability: announce auto-escalation on stderr (parity with CLI deep note).
  if (r.escalated) process.stderr.write(`[mem] auto-escalated to deep search (weak results: ${r.escalatedObsCount} hits)\n`);

  const output = formatSearchOutput(r.page, args, ftsQuery, r.total, r.orFallbackFired, r.isDeep);
  // Surface the rewrite to the calling agent (F13) + the rerank signal (D#43).
  if (r.isDeep && r.variants && output.content?.[0]?.type === 'text') {
    output.content[0].text += r.variants.length > 1
      ? `\n\n[deep search: rewrote into ${r.variants.length} variants — ${r.variants.slice(1).map(v => JSON.stringify(v)).join(', ')}]`
      : '\n\n[deep search: rewrite produced no usable variants; searched the original query only (== baseline)]';
  }
  if (r.reranked && output.content?.[0]?.type === 'text') {
    output.content[0].text += '\n\n[deep search: LLM-reranked the top candidates by relevance]';
  }

  // Expose structured fields for tests + the MCP content blob.
  return { ...output, results: r.page, total: r.total, escalated: r.escalated, variants: r.variants, reranked: r.reranked };
}

server.registerTool(
  'mem_search',
  {
    description: descriptionOf('mem_search'),
    inputSchema: memSearchSchema,
  },
  safeHandler(async (args) => {
    const result = await runSearchPipeline(db, args, {});
    return { content: result.content };
  })
);

// ─── Tool: mem_recent ────────────────────────────────────────────────────────

// In-process test seam (mirrors handleSearchForTest, #8743): threads an injected
// db through the SAME body the registered handler runs. NOTE: a `project` arg is
// still resolved via resolveProject() against the MODULE db, not the injected one.
export async function handleRecentForTest(db, args) {
  return runRecent(db, args);
}

async function runRecent(db, args) {
  if (args.project) args = { ...args, project: resolveProject(args.project) };
  const limit = args.limit ?? 10;
  const project = args.project || inferProject();

  const params = [];
  const wheres = ['COALESCE(compressed_into, 0) = 0', 'superseded_at IS NULL'];
  if (project) { wheres.push('project = ?'); params.push(project); }
  // obs_type: observation-type filter (CLI `recent --type` parity). The zod enum
  // already rejected an invalid value before the handler ran.
  if (args.obs_type) { wheres.push('type = ?'); params.push(args.obs_type); }
  // date_since: relative lower bound on created_at (CLI `recent --since` parity).
  if (args.date_since !== undefined) {
    const d = parseDuration(args.date_since);
    if (!d.ok) throw new Error(`Invalid date_since: "${args.date_since}" (use <N><unit>, e.g. 7d, 24h, 90m, 2w)`);
    wheres.push('created_at_epoch >= ?'); params.push(Date.now() - d.ms);
  }
  params.push(limit);

  const rows = db.prepare(`
    SELECT id, type, title, subtitle, project, created_at, created_at_epoch
    FROM observations
    WHERE ${wheres.join(' AND ')}
    ORDER BY created_at_epoch DESC
    LIMIT ?
  `).all(...params);

  if (rows.length === 0) {
    return { content: [{ type: 'text', text: `No recent observations${project ? ` (${project})` : ''}.` }] };
  }

  const lines = [`Recent observations (${project || 'all'}):\n`];
  for (const r of rows) {
    lines.push(`#${r.id} ${typeIcon(r.type)} [${r.type}] ${truncate(r.title || r.subtitle || '(untitled)')} | ${r.project} | ${fmtDate(r.created_at)}`);
  }
  lines.push(`\nWorkflow: mem_get(ids=[...]) for full details | mem_timeline(anchor=ID) for context`);
  return { content: [{ type: 'text', text: lines.join('\n') }] };
}

server.registerTool(
  'mem_recent',
  {
    description: descriptionOf('mem_recent'),
    inputSchema: memRecentSchema,
  },
  safeHandler(async (args) => runRecent(db, args))
);

// ─── Tool: mem_timeline ─────────────────────────────────────────────────────

server.registerTool(
  'mem_timeline',
  {
    description: descriptionOf('mem_timeline'),
    inputSchema: memTimelineSchema,
  },
  safeHandler(async (args) => {
    if (args.project) args = { ...args, project: resolveProject(args.project) };
    const before = args.before ?? 5;
    const after = args.after ?? 5;
    let anchorId = args.anchor;
    let anchorNote = null;

    // Resolve prefixed-token anchor (e.g. "P#3462" / "S#53" / "#8121") — users pasting
    // from mem_search results expect the same routing as CLI `timeline --anchor`.
    // Resolution ladder (prompt/session → nearest obs, compressed re-anchor, bare-int
    // fallback) is shared with the CLI via lib/timeline-core.mjs. Covers bare numeric
    // anchors too, so `anchor: 7826` (int) can't bypass the compressed check and
    // silently straddle a dead record.
    if (typeof anchorId === 'string' || typeof anchorId === 'number') {
      const resolved = resolveAnchorToken(db, anchorId, { project: args.project ?? null });
      if (!resolved.ok) {
        return { content: [{ type: 'text', text: formatAnchorError(resolved.error, 'mcp') }] };
      }
      anchorId = resolved.anchorId;
      anchorNote = resolved.anchorNote;
    }

    // Auto-find anchor via FTS (with recency decay). Shared with CLI
    // `timeline --query` so AND→OR fallback semantics stay identical (#8217);
    // the relaxed-note hint mirrors search transparency.
    if (!anchorId && args.query) {
      const found = resolveQueryAnchor(db, args.query, { project: args.project ?? null });
      if (found) {
        anchorId = found.anchorId;
        if (found.anchorNote && !anchorNote) anchorNote = found.anchorNote;
      }
    }

    // No anchor: return most recent
    if (!anchorId) {
      const rows = fetchRecentTimeline(db, { project: args.project ?? null, limit: before + after + 1 });

      if (rows.length === 0) {
        return { content: [{ type: 'text', text: 'No observations found.' }] };
      }

      const lines = [`Timeline (most recent ${rows.length}):\n`];
      for (const r of rows.reverse()) {
        lines.push(`#${r.id} ${typeIcon(r.type)} [${r.type}] ${truncate(r.title || r.subtitle || '(untitled)')} | ${r.project} | ${fmtDate(r.created_at)}`);
      }
      return { content: [{ type: 'text', text: lines.join('\n') }] };
    }

    // Window fetch (access-count bump + project auto-scope) shared with CLI.
    const win = fetchTimelineWindow(db, anchorId, { before, after, project: args.project ?? null });
    if (!win) {
      return { content: [{ type: 'text', text: `Observation #${anchorId} not found.` }] };
    }

    const all = [...win.beforeRows, win.anchor, ...win.afterRows];
    const lines = [`Timeline around #${anchorId}${anchorNote ? ' ' + anchorNote : ''}:\n`];
    for (const r of all) {
      const marker = r.id === anchorId ? ' ◀' : '';
      lines.push(`#${r.id} ${typeIcon(r.type)} [${r.type}] ${truncate(r.title || r.subtitle || '(untitled)')} | ${r.project} | ${fmtDate(r.created_at)}${marker}`);
    }

    return { content: [{ type: 'text', text: lines.join('\n') }] };
  })
);

// ─── Tool: mem_get ──────────────────────────────────────────────────────────

server.registerTool(
  'mem_get',
  {
    description: descriptionOf('mem_get'),
    inputSchema: memGetSchema,
  },
  safeHandler(async (args) => {
    // Bucket by per-token prefix (or force all to `args.source` when explicit).
    // coerceMixedIdTokens has already stringified + regex-validated each token.
    const { bySrc, invalid } = bucketIdTokens(args.ids, { explicit: args.source || null, defaultSource: 'obs' });
    if (invalid.length > 0) {
      // Should not happen — schema regex already rejected bad tokens — but guard defensively.
      return { content: [{ type: 'text', text: `Invalid ID token(s): ${invalid.join(', ')}. Expected N, #N, P#N, or S#N.` }] };
    }
    const totalRequested = bySrc.obs.length + bySrc.session.length + bySrc.prompt.length;
    if (totalRequested === 0) {
      return { content: [{ type: 'text', text: 'No valid IDs provided.' }] };
    }

    const OBS_FIELDS = ['id', 'type', 'title', 'subtitle', 'narrative', 'text', 'facts', 'concepts', 'lesson_learned', 'search_aliases', 'files_read', 'files_modified', 'project', 'created_at', 'memory_session_id', 'prompt_number', 'importance', 'related_ids', 'access_count', 'branch', 'superseded_at', 'superseded_by', 'last_accessed_at'];

    // `fields` filter only makes sense for obs rows; session/prompt ignore it.
    // Validate when obs is queried — throw on all-invalid, note on partial-invalid.
    let fieldsNote = '';
    let obsFieldFilter = null;
    if (args.fields?.length && bySrc.obs.length > 0) {
      const invalidFields = args.fields.filter(f => !OBS_FIELDS.includes(f));
      const validFields = args.fields.filter(f => OBS_FIELDS.includes(f));
      if (validFields.length === 0) {
        throw new Error(`No valid fields. Unknown field(s): ${invalidFields.join(', ')}. Valid: ${OBS_FIELDS.join(', ')}`);
      }
      if (invalidFields.length > 0) {
        fieldsNote = `Note: unknown field(s) dropped: ${invalidFields.join(', ')}. Valid: ${OBS_FIELDS.join(', ')}`;
      }
      obsFieldFilter = validFields;
    }

    // Per-source fetchers — each returns { rows, foundIds:Set, prefix }.
    const sections = [];
    const foundBySource = { obs: new Set(), session: new Set(), prompt: new Set() };

    if (bySrc.obs.length > 0) {
      const ph = bySrc.obs.map(() => '?').join(',');
      try {
        db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${ph})`).run(Date.now(), ...bySrc.obs);
        autoBoostIfNeeded(db, bySrc.obs);
      } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
      const rows = db.prepare(`SELECT * FROM observations WHERE id IN (${ph}) ORDER BY created_at_epoch ASC`).all(...bySrc.obs);
      const renderFields = obsFieldFilter || OBS_FIELDS;
      for (const row of rows) {
        foundBySource.obs.add(row.id);
        const lines = [`── #${row.id} ──`];
        for (const f of renderFields) {
          const val = row[f];
          if (val === null || val === undefined || val === '') continue;
          if (f === 'text' && row.narrative && typeof val === 'string' && val.startsWith(row.narrative)) continue;
          // Shared formatter (cli/common.mjs) renders epoch-ms time fields as
          // `<ms> (<relative>)` — parity with the CLI `get` path so an LLM reader
          // gets a scannable hint instead of a bare millisecond integer.
          const display = formatObsFieldValue(f, val);
          const maxLen = f === 'narrative' ? 1000 : f === 'lesson_learned' ? 500 : f === 'text' ? 500 : 200;
          lines.push(`${f}: ${typeof display === 'string' && display.length > maxLen ? display.slice(0, maxLen) + '…' : display}`);
        }
        sections.push(lines.join('\n'));
      }
    }

    if (bySrc.session.length > 0) {
      const ph = bySrc.session.map(() => '?').join(',');
      const rows = db.prepare(`SELECT * FROM session_summaries WHERE id IN (${ph}) ORDER BY created_at_epoch ASC`).all(...bySrc.session);
      const sessFields = ['id', 'request', 'investigated', 'learned', 'completed', 'next_steps', 'files_read', 'files_edited', 'notes', 'project', 'created_at', 'memory_session_id', 'prompt_number'];
      for (const row of rows) {
        foundBySource.session.add(row.id);
        const lines = [`── S#${row.id} ──`];
        for (const f of sessFields) {
          const val = row[f];
          if (val === null || val === undefined || val === '') continue;
          const maxLen = 500;
          lines.push(`${f}: ${typeof val === 'string' && val.length > maxLen ? val.slice(0, maxLen) + '…' : val}`);
        }
        sections.push(lines.join('\n'));
      }
    }

    if (bySrc.prompt.length > 0) {
      const ph = bySrc.prompt.map(() => '?').join(',');
      const rows = db.prepare(`SELECT * FROM user_prompts WHERE id IN (${ph}) ORDER BY created_at_epoch ASC`).all(...bySrc.prompt);
      for (const row of rows) {
        foundBySource.prompt.add(row.id);
        const lines = [`── P#${row.id} ──`];
        if (row.prompt_text) lines.push(`prompt_text: ${row.prompt_text.length > 500 ? row.prompt_text.slice(0, 500) + '…' : row.prompt_text}`);
        if (row.content_session_id) lines.push(`content_session_id: ${row.content_session_id}`);
        if (row.prompt_number !== null && row.prompt_number !== undefined) lines.push(`prompt_number: ${row.prompt_number}`);
        if (row.created_at) lines.push(`created_at: ${row.created_at}`);
        sections.push(lines.join('\n'));
      }
    }

    const totalFound = foundBySource.obs.size + foundBySource.session.size + foundBySource.prompt.size;

    if (totalFound === 0) {
      // Probe other sources so callers can retry with the right prefix/source override.
      const queried = new Set(Object.entries(bySrc).filter(([, v]) => v.length > 0).map(([k]) => k));
      const allNumericIds = [...bySrc.obs, ...bySrc.session, ...bySrc.prompt];
      const probe = probeIdSources(db, allNumericIds, queried);
      const hints = [];
      if (probe.obs.length > 0)     hints.push(`#${probe.obs.join(', #')} (obs — use source='obs' or bare #N)`);
      if (probe.session.length > 0) hints.push(`S#${probe.session.join(', S#')} (session — use source='session' or S#N)`);
      if (probe.prompt.length > 0)  hints.push(`P#${probe.prompt.join(', P#')} (prompt — use source='prompt' or P#N)`);
      const hint = hints.length > 0 ? ` Try: ${hints.join('; ')}.` : '';
      const queriedList = [...queried].join(', ');
      const msg = `No records found in source(s) [${queriedList}] for the given ID(s).${hint}`;
      return { content: [{ type: 'text', text: fieldsNote ? `${msg}\n\n${fieldsNote}` : msg }] };
    }

    // Missing-ID note per bucket (mirrors mem_delete). Show missing IDs with their bucket prefix
    // so callers can tell which source returned nothing.
    const missingHints = [];
    const miss = (arr, found, prefix) => arr.filter(id => !found.has(id)).map(id => `${prefix}${id}`);
    missingHints.push(...miss(bySrc.obs, foundBySource.obs, '#'));
    missingHints.push(...miss(bySrc.session, foundBySource.session, 'S#'));
    missingHints.push(...miss(bySrc.prompt, foundBySource.prompt, 'P#'));

    const parts = [];
    if (fieldsNote) parts.push(fieldsNote);
    parts.push(...sections);
    if (missingHints.length > 0) {
      parts.push(`Note: ID(s) ${missingHints.join(', ')} not found.`);
    }

    return { content: [{ type: 'text', text: parts.join('\n\n') }] };
  })
);

// ─── Tool: mem_delete ────────────────────────────────────────────────────────

server.registerTool(
  'mem_delete',
  {
    description: descriptionOf('mem_delete'),
    inputSchema: memDeleteSchema,
  },
  safeHandler(async (args) => {
    const placeholders = args.ids.map(() => '?').join(',');
    const rows = db.prepare(`
      SELECT id, type, title, project FROM observations WHERE id IN (${placeholders})
    `).all(...args.ids);

    if (rows.length === 0) {
      return { content: [{ type: 'text', text: 'No observations found for given IDs.' }] };
    }

    if (!args.confirm) {
      // Preview mode
      const lines = [`Preview: ${rows.length} observation(s) will be deleted:\n`];
      for (const r of rows) {
        lines.push(`  #${r.id} [${r.type}] ${truncate(r.title || '(untitled)', 80)} | ${r.project}`);
      }
      lines.push(`\nCall mem_delete(ids=[...], confirm=true) to execute.`);
      return { content: [{ type: 'text', text: lines.join('\n') }] };
    }

    // Snapshot before the irreversible hard-delete so a wrong-id delete has a pre-image,
    // matching the CLI delete + maintain purge/cleanup paths (audit MED-2). Best-effort
    // (never throws, skips :memory:). Must run OUTSIDE the transaction below (VACUUM).
    snapshotDb(db, { tag: 'pre-delete' });

    // Wrap cleanup + deletion in a transaction for consistency
    const deletedIds = new Set(args.ids);
    const deleteTx = db.transaction(() => {
      // Clean up stale references in other observations' related_ids
      // Use LIKE filter to avoid O(N) full-table scan — only fetch rows that may reference deleted IDs.
      // NOTE: LIKE %id% has false positives (e.g. %1% matches [10], [21]). This is intentional —
      // the LIKE is a coarse pre-filter; the JSON parse + Set.has below is the precise filter.
      // Acceptable because observation count per user is typically <10K.
      const likeConditions = args.ids.map(() => `related_ids LIKE ?`).join(' OR ');
      const likeParams = args.ids.map(id => `%${id}%`);
      const referencing = db.prepare(`
        SELECT id, related_ids FROM observations
        WHERE related_ids IS NOT NULL AND related_ids != '[]'
          AND (${likeConditions})
      `).all(...likeParams);
      for (const r of referencing) {
        let ids;
        try { ids = JSON.parse(r.related_ids); } catch (e) { debugCatch(e, 'deleteRelatedIds'); continue; }
        if (!Array.isArray(ids) || !ids.every(id => Number.isInteger(id))) continue;
        const filtered = ids.filter(id => !deletedIds.has(id));
        if (filtered.length !== ids.length) {
          db.prepare('UPDATE observations SET related_ids = ? WHERE id = ?').run(JSON.stringify(filtered), r.id);
        }
      }
      // Resurface rows merged/compressed INTO the doomed keepers before deleting, else
      // they dangle behind a now-missing parent (compressed_into has no FK) — invisible
      // to every COALESCE(compressed_into,0)=0 view and unrecoverable. Mirrors the CLI
      // delete path + the maintain hard-delete guard (recoverChildrenOf).
      const recovered = recoverChildrenOf(db, args.ids);
      // Execute deletion (FTS5 cleanup handled by observations_ad trigger)
      const deleted = db.prepare(`DELETE FROM observations WHERE id IN (${placeholders})`).run(...args.ids);
      return { changes: deleted.changes, recovered };
    });
    const result = deleteTx();

    const missing = args.ids.filter(id => !rows.some(r => r.id === id));
    const msg = [`Deleted ${result.changes} observation(s).`];
    if (result.recovered > 0) msg.push(`Recovered ${result.recovered} merged/compressed child observation(s) to live.`);
    if (missing.length > 0) msg.push(`Note: ID(s) ${missing.join(', ')} not found.`);
    return { content: [{ type: 'text', text: msg.join(' ') }] };
  })
);

// ─── Tool: mem_save ─────────────────────────────────────────────────────────

server.registerTool(
  'mem_save',
  {
    description: descriptionOf('mem_save'),
    inputSchema: memSaveSchema,
  },
  safeHandler(async (args) => {
    if (args.project) args = { ...args, project: resolveProject(args.project) };
    const project = args.project || inferProject();

    let closesIds = null;
    let result;
    try {
      result = db.transaction(() => {
        const r = saveObservation(db, {
          content: args.content,
          title: args.title,
          type: args.type || 'discovery',
          importance: args.importance,
          project,
          files: args.files || [],
          lesson_learned: args.lesson_learned,
          supersedes: args.supersedes,
        });
        if (r.kind === 'duplicate') return r; // dedup short-circuits BEFORE resolver — replay is idempotent
        // Resolve INSIDE tx + after dedup check so duplicate replays don't throw on
        // already-closed items. Mirrors mem-cli.mjs cmdSave shape.
        if (args.closes_deferred && args.closes_deferred.length > 0) {
          closesIds = resolveDeferredIds(db, project, args.closes_deferred);
          closeDeferredItems(db, closesIds, r.id);
        }
        return r;
      })();
    } catch (e) {
      if (args.closes_deferred && args.closes_deferred.length > 0) {
        // Re-throw with a clearer prefix so MCP error response names the
        // contract failure — gate on caller intent (args.closes_deferred) since
        // closesIds is closure-scoped and may not have been assigned before throw.
        throw new Error(`mem_save with closes_deferred failed: ${e.message}`, { cause: e });
      }
      throw e;  // unwrapped — preserves original message + stack
    }

    if (result.kind === 'duplicate') {
      return { content: [{ type: 'text', text: `Skipped: similar to existing #${result.existingId} in project "${project}". Use mem_get(ids=[${result.existingId}]) to review.` }] };
    }

    const lessonNote = result.lessonCaptured ? ` 💡lesson captured` : '';
    const closedNote = closesIds && closesIds.length > 0
      ? ` Closed deferred: ${closesIds.map(i => `D#${i}`).join(', ')}.`
      : '';
    const supersededNote = result.supersededIds && result.supersededIds.length > 0
      ? ` Superseded: ${result.supersededIds.map(i => `#${i}`).join(', ')}.`
      : '';
    return { content: [{ type: 'text', text: `Saved as observation #${result.id} [${result.type}] in project "${project}".${lessonNote}${closedNote}${supersededNote}` }] };
  })
);

// ─── Tool: mem_defer ────────────────────────────────────────────────────────

server.registerTool(
  'mem_defer',
  {
    description: descriptionOf('mem_defer'),
    inputSchema: memDeferSchema,
  },
  safeHandler(async (args) => {
    if (args.project) args = { ...args, project: resolveProject(args.project) };
    const project = args.project || inferProject();
    const r = insertDeferred(db, {
      project,
      title: args.title,
      priority: args.priority ?? 2,
      detail: args.detail ?? null,
      files: args.files ?? null,
    });
    // Compute the ordinal for the freshly-inserted row so the response is
    // immediately actionable ("ok, I deferred this as item 1").
    const open = listOpenWithOrdinal(db, project, 50);
    const ord = open.find(o => o.id === r.id)?.ordinal ?? null;
    return { content: [{ type: 'text', text:
      `Deferred as D#${r.id} (item ${ord ?? '?'}) in project "${project}" — surfaces in next SessionStart banner.` }] };
  })
);

// ─── Tool: mem_defer_list ───────────────────────────────────────────────────

server.registerTool(
  'mem_defer_list',
  {
    description: descriptionOf('mem_defer_list'),
    inputSchema: memDeferListSchema,
  },
  safeHandler(async (args) => {
    if (args.project) args = { ...args, project: resolveProject(args.project) };
    const project = args.project || inferProject();
    const list = listOpenWithOrdinal(db, project, args.limit ?? 10);
    if (list.length === 0) {
      return { content: [{ type: 'text', text: `No open deferred items in project "${project}".` }] };
    }
    const lines = [`Open deferred items (project "${project}"):`];
    for (const r of list) {
      const pTag = r.priority === 3 ? '🔴' : r.priority === 1 ? '⚪' : '🟡';
      lines.push(`${r.ordinal}. ${pTag} [P${r.priority}] ${r.title} (D#${r.id})`);
    }
    return { content: [{ type: 'text', text: lines.join('\n') }] };
  })
);

// ─── Tool: mem_defer_drop ───────────────────────────────────────────────────

server.registerTool(
  'mem_defer_drop',
  {
    description: descriptionOf('mem_defer_drop'),
    inputSchema: memDeferDropSchema,
  },
  safeHandler(async (args) => {
    if (args.project) args = { ...args, project: resolveProject(args.project) };
    const project = args.project || inferProject();
    // Resolve id (accept D#N or ordinal int) via resolveDeferredIds with a
    // single-element array — reuses the same project + status validation.
    const [realId] = resolveDeferredIds(db, project, [args.id]);
    const r = dropDeferred(db, realId, args.reason);
    if (r.changed === 0) {
      return { content: [{ type: 'text', text: `D#${realId} was not in 'open' status — drop is a no-op.` }] };
    }
    return { content: [{ type: 'text', text: `Dropped D#${realId} in project "${project}". Reason: ${args.reason}` }] };
  })
);

// ─── Tool: mem_stats ────────────────────────────────────────────────────────

server.registerTool(
  'mem_stats',
  {
    description: descriptionOf('mem_stats'),
    inputSchema: memStatsSchema,
  },
  safeHandler(async (args) => {
    if (args.project) args = { ...args, project: resolveProject(args.project) };
    const days = args.days ?? 30;

    // Batch A CLI↔MCP alignment: quality:true → quality dashboard (lesson
    // rate, LOW_SIGNAL rate, per-type hit/lesson %, top lessons, R-2 watchdog).
    // Same computation + format as CLI `stats --quality` via lib/stats-quality.mjs.
    if (args.quality) {
      const { computeQualityStats, formatQualityReport } = await import('./lib/stats-quality.mjs');
      const data = computeQualityStats(db, { project: args.project, days });
      return { content: [{ type: 'text', text: formatQualityReport(data) }] };
    }

    const cutoff = Date.now() - days * 86400000;
    const projectFilter = args.project ? 'AND project = ?' : '';
    const baseParams = args.project ? [args.project] : [];

    // Total counts
    const obsTotal = db.prepare(`SELECT COUNT(*) as c FROM observations WHERE 1=1 ${projectFilter}`).get(...baseParams);
    const sessTotal = db.prepare(`SELECT COUNT(*) as c FROM session_summaries WHERE 1=1 ${projectFilter}`).get(...baseParams);
    const promptTotal = args.project
      ? db.prepare(`SELECT COUNT(*) as c FROM user_prompts p JOIN sdk_sessions s ON p.content_session_id = s.content_session_id WHERE s.project = ?`).get(args.project)
      : db.prepare(`SELECT COUNT(*) as c FROM user_prompts`).get();

    // Recent counts
    const obsRecent = db.prepare(`SELECT COUNT(*) as c FROM observations WHERE created_at_epoch >= ? ${projectFilter}`).get(cutoff, ...baseParams);
    const sessRecent = db.prepare(`SELECT COUNT(*) as c FROM session_summaries WHERE created_at_epoch >= ? ${projectFilter}`).get(cutoff, ...baseParams);

    // Type distribution (recent)
    const types = db.prepare(`
      SELECT type, COUNT(*) as c FROM observations
      WHERE created_at_epoch >= ? ${projectFilter}
      GROUP BY type ORDER BY c DESC
    `).all(cutoff, ...baseParams);

    // Projects (global view — skipped when filtering by single project)
    const projects = args.project ? [] : db.prepare(`
      SELECT project, COUNT(*) as c FROM observations
      GROUP BY project ORDER BY c DESC
      LIMIT 20
    `).all();

    // Daily activity (last 7 days)
    const daily = db.prepare(`
      SELECT date(created_at) as day, COUNT(*) as c FROM observations
      WHERE created_at_epoch >= ? ${projectFilter}
      GROUP BY day ORDER BY day DESC
      LIMIT 7
    `).all(Date.now() - 7 * 86400000, ...baseParams);

    // Health metrics
    const tokenEst = db.prepare(`
      SELECT SUM(LENGTH(COALESCE(title,'')) + LENGTH(COALESCE(narrative,'')) + LENGTH(COALESCE(text,''))) / 4 as t
      FROM observations WHERE 1=1 ${projectFilter}
    `).get(...baseParams);

    const avgImp = db.prepare(`
      SELECT AVG(COALESCE(importance,1)) as v FROM observations WHERE 1=1 ${projectFilter}
    `).get(...baseParams);

    const thirtyDaysAgo = Date.now() - 30 * 86400000;
    // v3.23 noise-gauge de-blinding (mirrors mem-cli cmdStats): `<= 1` makes the
    // imp=0 dormant population visible (decay floor + LLM low-signal filter push
    // ~half the live corpus to 0); injection_count=0 mirrors decay's NEVER-INJECTED
    // guard so injected-but-decayed pinned noise isn't miscounted as "never used".
    const lowVal = db.prepare(`
      SELECT COUNT(*) as c FROM observations
      WHERE COALESCE(importance,1) <= 1 AND COALESCE(access_count,0) = 0
        AND COALESCE(injection_count,0) = 0
        AND COALESCE(compressed_into, 0) = 0
        AND created_at_epoch < ? ${projectFilter}
    `).get(thirtyDaysAgo, ...baseParams);

    const noiseRatio = obsTotal.c > 0 ? lowVal.c / obsTotal.c : 0;
    // Low-signal-title population (template / tool-log titles the read-side filter
    // already excludes). The imp=1 "Low-value" metric can't see these, so the
    // gauge under-reports real noise without it. See lib/low-signal-patterns.mjs.
    const lowSignalTitle = db.prepare(`
      SELECT COUNT(*) as c FROM observations
      WHERE NOT ${buildNotLowSignalSql()}
        AND COALESCE(compressed_into, 0) = 0 ${projectFilter}
    `).get(...baseParams);
    const lowSignalRatio = obsTotal.c > 0 ? lowSignalTitle.c / obsTotal.c : 0;
    const compressedCount = db.prepare(`
      SELECT COUNT(*) as c FROM observations WHERE compressed_into IS NOT NULL ${projectFilter}
    `).get(...baseParams);
    const supersededOnlyCount = db.prepare(`
      SELECT COUNT(*) as c FROM observations WHERE superseded_at IS NOT NULL AND compressed_into IS NULL ${projectFilter}
    `).get(...baseParams);

    // Tier distribution
    const tierCtx = { now: Date.now(), currentProject: args.project || inferProject(), currentSessionId: '' };
    const tdParams = tierSqlParams(tierCtx);
    const tierDist = db.prepare(`
      SELECT tier, COUNT(*) as c FROM (
        SELECT ${TIER_CASE_SQL} as tier FROM observations
        WHERE COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
      ) GROUP BY tier ORDER BY tier
    `).all(...tdParams, ...baseParams);
    const tierMap = Object.fromEntries(tierDist.map(r => [r.tier, r.c]));

    const lines = [
      `Memory Statistics${args.project ? ` (project: ${args.project})` : ''}:`,
      '',
      `Total: ${obsTotal.c} observations | ${sessTotal.c} sessions | ${promptTotal.c} prompts`,
      `Last ${days}d: ${obsRecent.c} observations | ${sessRecent.c} sessions`,
      '',
      'Type distribution (recent):',
      ...types.map(t => `  ${typeIcon(t.type)} ${t.type}: ${t.c}`),
      '',
      ...(projects.length ? ['Top projects:', ...projects.map(p => `  ${p.project}: ${p.c}`)] : []),
      '',
      'Daily activity (last 7d):',
      ...daily.map(d => `  ${d.day}: ${d.c} observations`),
      '',
      'Data Health:',
      `  Est. tokens: ${tokenEst.t ?? 0}`,
      `  Avg importance: ${(avgImp.v ?? 1).toFixed(2)}`,
      `  Low-value (imp≤1, never used, >30d): ${lowVal.c} (${(noiseRatio * 100).toFixed(1)}% noise)`,
      `  Low-signal titles (Modified/Error/Worked on…): ${lowSignalTitle.c} (${(lowSignalRatio * 100).toFixed(1)}%)`,
      `  Compressed: ${compressedCount.c}`,
      ...((noiseRatio > 0.6 || lowSignalRatio > 0.3) ? ['  ⚠️ High noise ratio — consider running mem_compress / maintain'] : []),
      '',
      // Tier counts only live (uncompressed, non-superseded) observations — surface
      // the full decomposition so live + compressed + superseded = Total adds up cleanly.
      `Tier distribution (live ${(tierMap.working ?? 0) + (tierMap.active ?? 0) + (tierMap.archive ?? 0)}, excludes ${compressedCount.c} compressed${supersededOnlyCount.c > 0 ? ` + ${supersededOnlyCount.c} superseded` : ''}):`,
      `  🔴 Working: ${tierMap.working ?? 0} | 🟡 Active: ${tierMap.active ?? 0} | 🔵 Archive: ${tierMap.archive ?? 0}`,
    ];

    return { content: [{ type: 'text', text: lines.join('\n') }] };
  })
);

// ─── Tool: mem_compress ──────────────────────────────────────────────────────

server.registerTool(
  'mem_compress',
  {
    description: descriptionOf('mem_compress'),
    inputSchema: memCompressSchema,
  },
  safeHandler(async (args) => {
    if (args.project) args = { ...args, project: resolveProject(args.project) };
    const preview = args.preview !== false;
    const ageDays = args.age_days ?? 30;
    const cutoff = Date.now() - ageDays * 86400000;
    const candidates = selectCompressionCandidates(db, { cutoff, project: args.project || null });

    if (candidates.length === 0) {
      return { content: [{ type: 'text', text: 'No candidates for compression.' }] };
    }

    const compressableGroups = groupByProjectWeek(candidates);

    if (preview) {
      const totalCandidates = compressableGroups.reduce((s, [, obs]) => s + obs.length, 0);
      const lines = [
        `Compression preview:`,
        `  Total candidates: ${candidates.length}`,
        `  Compressable groups (≥3 obs): ${compressableGroups.length}`,
        `  Observations to compress: ${totalCandidates}`,
        '',
        'Groups:',
        ...compressableGroups.slice(0, 20).map(([key, obs]) => {
          const [proj, week] = key.split('::');
          const types = {};
          for (const o of obs) types[o.type] = (types[o.type] || 0) + 1;
          const typeStr = Object.entries(types).map(([t, c]) => `${c} ${t}`).join(', ');
          return `  ${proj} ${week}: ${obs.length} obs (${typeStr})`;
        }),
        '',
        `Call mem_compress(preview=false${args.age_days ? `, age_days=${args.age_days}` : ''}${args.project ? `, project="${args.project}"` : ''}) to execute.`,
      ];
      return { content: [{ type: 'text', text: lines.join('\n') }] };
    }

    // Execute compression — one transaction over all groups (the hook transacts per group).
    let totalCompressed = 0;
    const compress = db.transaction(() => {
      for (const [key, obs] of compressableGroups) {
        const [proj] = key.split('::');
        totalCompressed += compressGroup(db, proj, obs).compressed;
      }
    });
    compress();

    return { content: [{ type: 'text', text: `Compressed ${totalCompressed} observations into ${compressableGroups.length} weekly summaries.` }] };
  })
);

// ─── Tool: mem_maintain ──────────────────────────────────────────────────────

server.registerTool(
  'mem_maintain',
  {
    description: descriptionOf('mem_maintain'),
    inputSchema: memMaintainSchema,
  },
  safeHandler(async (args) => {
    if (args.project) args = { ...args, project: resolveProject(args.project) };
    const DUPLICATE_DISPLAY = 15;

    const action = args.action;
    const project = args.project;
    const projectFilter = project ? 'AND project = ?' : '';
    const baseParams = project ? [project] : [];

    if (action === 'scan') {
      const staleAge = Date.now() - STALE_AGE_MS;
      const mctx = { projectFilter, baseParams, staleAge };
      const duplicates = findDuplicates(db, mctx);
      const stats = maintenanceStats(db, mctx);

      const lines = [
        `Memory maintenance scan:`,
        `  Total active observations: ${stats.total}`,
        `  Near-duplicate pairs: ${duplicates.length}`,
        `  Stale (>30d, imp=1, no access, never injected): ${stats.stale}`,
        `  Broken (no title/narrative): ${stats.broken}`,
        `  Boostable (accessed>3, imp<3): ${stats.boostable}`,
        `  Pending purge (idle-marked): ${stats.pendingPurge}`,
      ];
      if (duplicates.length > 0) {
        const autoMergeable = duplicates.filter(d => parseFloat(d.similarity) >= AUTO_MERGE_THRESHOLD);
        const manualReview = duplicates.filter(d => parseFloat(d.similarity) < AUTO_MERGE_THRESHOLD);

        if (autoMergeable.length > 0) {
          lines.push('', `Auto-mergeable pairs (similarity >= ${AUTO_MERGE_THRESHOLD}):`);
          for (const d of autoMergeable.slice(0, DUPLICATE_DISPLAY)) {
            // Keep the higher-importance or newer observation
            const keep = d.a.importance >= d.b.importance ? d.a : d.b;
            const remove = keep === d.a ? d.b : d.a;
            lines.push(`  [${keep.id}] "${truncate(keep.title, 40)}" <-> [${remove.id}] "${truncate(remove.title, 40)}" (${d.similarity})`);
          }
          // Build ready-to-use merge_ids for auto-mergeable pairs
          const mergeIds = autoMergeable.map(d => {
            const keep = d.a.importance >= d.b.importance ? d.a : d.b;
            const remove = keep === d.a ? d.b : d.a;
            return [keep.id, remove.id];
          });
          lines.push('', `Ready-to-use command:`, `  mem_maintain(action="execute", operations=["dedup"], merge_ids=${JSON.stringify(mergeIds)})`);
        }

        if (manualReview.length > 0) {
          lines.push('', 'Needs review:');
          for (const d of manualReview.slice(0, DUPLICATE_DISPLAY)) {
            lines.push(`  [${d.a.id}] "${truncate(d.a.title, 40)}" <-> [${d.b.id}] "${truncate(d.b.title, 40)}" (${d.similarity})`);
          }
        }
      }
      return { content: [{ type: 'text', text: lines.join('\n') }] };
    }

    if (action === 'execute') {
      const ops = args.operations && args.operations.length > 0
        ? args.operations
        : ['cleanup', 'decay', 'boost'];
      // T2-P1-A: reject explicit empty array (vs. omitted → defaults above). Empty-array
      // callers are almost always mistakes; silently running only FTS5 optimize hides the error.
      if (args.operations && args.operations.length === 0) {
        return { content: [{ type: 'text', text: 'operations array is empty. Pass a non-empty list (e.g. ["cleanup","decay","boost"]) or omit operations to use the default set.' }], isError: true };
      }
      const results = [];
      const staleAge = Date.now() - STALE_AGE_MS;
      const mctx = { projectFilter, baseParams, staleAge, opCap: OP_CAP };

      // T2-P0-A: purge_stale is the only DELETE in this handler. Require confirm=true;
      // a first call without confirm returns a dry-run preview so callers know the blast radius.
      const purgeRequested = ops.includes('purge_stale');
      if (purgeRequested && args.confirm !== true) {
        const retainDays = args.retain_days ?? 30;
        const retainCutoff = Date.now() - retainDays * 86400000;
        const previewRow = purgeStalePreview(db, mctx, retainCutoff);
        const lines = [
          'purge_stale preview (confirm=false):',
          `  Candidates (pending-purge, older than ${retainDays}d): ${previewRow.candidates}`,
        ];
        if (previewRow.candidates > 0) {
          lines.push(`  Oldest: ${new Date(previewRow.oldest).toISOString().slice(0, 10)}`);
          lines.push(`  Newest: ${new Date(previewRow.newest).toISOString().slice(0, 10)}`);
        }
        lines.push('');
        lines.push('Nothing was deleted. To execute, re-run with confirm=true:');
        lines.push(`  mem_maintain(action="execute", operations=${JSON.stringify(ops)}, confirm=true${args.retain_days ? `, retain_days=${args.retain_days}` : ''}${args.project ? `, project="${args.project}"` : ''})`);
        return { content: [{ type: 'text', text: lines.join('\n') }] };
      }

      // MED-2: snapshot the DB before the irreversible cleanup/purge hard-deletes —
      // only when rows will actually be removed, and OUTSIDE the transaction below
      // (VACUUM cannot run inside one). purge_stale is already confirmed by here (the
      // preview branch returned above otherwise). Best-effort; snapshotDb never throws.
      if (hardDeleteCandidateCount(db, mctx, { cleanup: ops.includes('cleanup'), purge: ops.includes('purge_stale') }) > 0) {
        snapshotDb(db, { tag: 'pre-maintain' });
      }

      db.transaction(() => {
        // PURGE FIRST — matches the auto-maintain hook order (hook.mjs:766) and the CLI
        // cmdMaintain. Running decay BEFORE purge in one transaction marked a stale row
        // pending-purge AND deleted it in the SAME call (zero grace), while the pre-txn
        // snapshot guard counts only PRE-EXISTING pending rows so it skipped the backup →
        // permanent loss of notable imp-2/3 memories (audit HIGH-1). Purging first deletes
        // only rows a PRIOR run marked (backed up); rows decay marks below wait one cycle.
        if (ops.includes('purge_stale')) {
          const retainDays = args.retain_days ?? 30;
          const retainCutoff = Date.now() - retainDays * 86400000;
          const purged = purgeStale(db, mctx, retainCutoff);
          results.push(`Purged ${purged} stale observations (retained last ${retainDays} days)` + (purged >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
        }

        if (ops.includes('cleanup')) {
          const deleted = cleanupBroken(db, mctx);
          results.push(`Cleaned up ${deleted} broken observations` + (deleted >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
          // Self-heal legacy orphans (keeper hard-deleted pre-recoverChildrenOf):
          // resurface unreachable children. Non-destructive — un-hide only, no delete.
          const orphans = recoverOrphanedChildren(db, mctx);
          if (orphans > 0) results.push(`Recovered ${orphans} orphaned compression children`);
          // Heal lesson rows citation-decay buried at importance 0 (pre floor=1). 0→1 on
          // lesson-bearing rows only; idempotent no-op once none remain.
          const lessonsHealed = recoverBuriedLessons(db, mctx);
          if (lessonsHealed > 0) results.push(`Healed ${lessonsHealed} lesson rows buried at importance 0`);
        }

        if (ops.includes('decay')) {
          // injection_count>0 protected (maintain-core; unifies with CLI + hook —
          // the MCP copy previously lacked this clause and decayed/purged injected memories).
          const { decayed, idleMarked } = decayAndMarkIdle(db, mctx);
          results.push(`Decayed ${decayed} stale observations, marked ${idleMarked} idle as pending-purge` + ((decayed >= OP_CAP || idleMarked >= OP_CAP) ? ' (cap reached, re-run for more)' : ''));
        }

        if (ops.includes('boost')) {
          const boosted = boostAccessed(db, mctx);
          results.push(`Boosted ${boosted} frequently-accessed observations` + (boosted >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
        }

        if (ops.includes('demote_pinned')) {
          const demoted = demotePinned(db, mctx);
          results.push(`Demoted ${demoted} pinned-but-uncited observations to importance 1 (inj>=8, cited=0)` + (demoted >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
        }

        if (ops.includes('dedup') && args.merge_ids) {
          const totalMerged = mergeDuplicates(db, args.merge_ids);
          results.push(`Merged ${totalMerged} duplicate observations`);
        }

        if (!ops.includes('dedup') && args.merge_ids) {
          results.push('Warning: merge_ids provided but "dedup" not in operations — merge_ids ignored');
        }
      })();

      // FTS5 optimize (outside transaction)
      db.exec("INSERT INTO observations_fts(observations_fts) VALUES('optimize')");
      results.push('FTS5 index optimized');

      // rebuild_vectors: outside main transaction (maintain-core, shared with CLI).
      if (ops.includes('rebuild_vectors')) {
        try {
          const r = rebuildVectors(db);
          results.push(r.ok
            ? `Vectors: rebuilt vocabulary (${r.terms} terms), updated ${r.updated}/${r.total} vectors`
            : `Vectors: ${r.reason}`);
        } catch (e) {
          debugCatch(e, 'rebuild_vectors');
          results.push(`Vectors: rebuild failed — ${e.message}`);
        }
      }

      // vacuum: reclaim freelist dead space left by DELETEs. Whole-DB, outside any
      // transaction. maintain-core, shared with CLI.
      if (ops.includes('vacuum')) {
        try {
          const v = vacuum(db);
          results.push(`VACUUM: reclaimed ~${v.reclaimedMB}MB (freelist ${v.freeBefore} → ${v.freeAfter} pages)`);
        } catch (e) {
          debugCatch(e, 'vacuum');
          results.push(`VACUUM failed — ${e.message}`);
        }
      }

      return { content: [{ type: 'text', text: results.join('\n') }] };
    }

    return { content: [{ type: 'text', text: `Unknown action: ${action}. Use "scan" or "execute".` }], isError: true };
  })
);

// ─── Tool: mem_optimize ────────────────────────────────────────────────────

server.registerTool(
  'mem_optimize',
  {
    description: descriptionOf('mem_optimize'),
    inputSchema: memOptimizeSchema,
  },
  safeHandler(async (args) => {
    const action = args.action || 'preview';

    if (action === 'preview') {
      const preview = optimizePreview(db, { project: args.project, detail: args.detail === true });
      const lines = [
        `🔍 LLM Optimization Preview:`,
      ];
      if (args.project) lines.push(`  Project filter: ${args.project}`);
      lines.push(
        `  Re-enrich candidates: ${preview.reenrich}`,
        `  Normalize: ${preview.normalizeGateOpen ? `${preview.normalize} unique concepts` : 'gate closed (7-day interval)'}`,
        `  Cluster-merge candidates: ${preview.clusterMerge} clusters`,
        `  Smart-compress candidates: ${preview.smartCompress} clusters`,
        `  Total: ${preview.total} items`,
      );
      if (args.detail === true) {
        if (preview.mergeClusters && preview.mergeClusters.length > 0) {
          lines.push('', '─── Cluster-merge details ───');
          for (const [i, cluster] of preview.mergeClusters.entries()) {
            lines.push(`  Cluster ${i + 1} (${cluster.length} obs, project=${cluster[0]?.project || '?'}):`);
            for (const obs of cluster) lines.push(`    #${obs.id} [${obs.type || 'change'}] ${truncate(obs.title || '(untitled)', 100)}`);
          }
        }
        if (preview.reenrichSamples && preview.reenrichSamples.length > 0) {
          lines.push('', '─── Re-enrich sample (first 20) ───');
          for (const obs of preview.reenrichSamples) {
            lines.push(`  #${obs.id} [${obs.type || 'change'}] (project=${obs.project || '?'}) ${truncate(obs.title || '(untitled)', 100)}`);
          }
        }
      }
      return { content: [{ type: 'text', text: lines.join('\n') }] };
    }

    const force = action === 'run_all';
    const results = await optimizeRun(db, {
      tasks: args.tasks,
      maxItems: args.max_items || 15,
      force,
      // T2-P0-B: scope parity with CLI (--scope wide). When omitted, optimizeRun defaults
      // to narrow via its own code; passing through keeps that fallback intact.
      reenrichScope: args.scope,
      project: args.project,
    });

    const lines = ['🔧 LLM Optimization Results:'];
    if (results.reenrich) lines.push(`  Re-enrich: ${results.reenrich.processed || 0} processed, ${results.reenrich.skipped || 0} skipped`);
    if (results.normalize) {
      if (results.normalize.skipped) lines.push(`  Normalize: skipped (${results.normalize.reason})`);
      else lines.push(`  Normalize: ${results.normalize.processed || 0} updated, ${results.normalize.groups || 0} synonym groups`);
    }
    if (results.clusterMerge) lines.push(`  Cluster-merge: ${results.clusterMerge.merged || 0} merged of ${results.clusterMerge.processed || 0} clusters`);
    if (results.smartCompress) lines.push(`  Smart-compress: ${results.smartCompress.compressed || 0} compressed of ${results.smartCompress.processed || 0} clusters`);

    return { content: [{ type: 'text', text: lines.join('\n') }] };
  })
);

// ─── Tool: mem_registry ─────────────────────────────────────────────────────

server.registerTool(
  'mem_registry',
  {
    description: descriptionOf('mem_registry'),
    inputSchema: memRegistrySchema,
  },
  safeHandler(async (args) => {
    const rdb = getRegistryDb();
    if (!rdb) {
      return { content: [{ type: 'text', text: 'Registry DB not available. Run install first.' }], isError: true };
    }

    const action = args.action;

    if (action === 'search') {
      if (!args.query) {
        return { content: [{ type: 'text', text: 'search requires a query parameter' }], isError: true };
      }
      let results = searchResources(rdb, args.query, {
        type: args.type || undefined,
        limit: args.category || args.quality ? 20 : 10, // fetch more for post-filtering
      });
      // Apply category/quality filters if provided
      if (args.category) results = results.filter(r => r.category === args.category);
      if (args.quality) results = results.filter(r => r.quality_tier === args.quality);
      // Prioritize directly invocable resources (with invocation_name) over community resources
      results.sort((a, b) => {
        const aInvocable = a.invocation_name ? 1 : 0;
        const bInvocable = b.invocation_name ? 1 : 0;
        if (aInvocable !== bInvocable) return bInvocable - aInvocable;
        return 0; // preserve FTS5 ranking within same tier
      });
      results = results.slice(0, 5);
      if (results.length === 0) {
        return { content: [{ type: 'text', text: `No matching resources for: "${args.query}"` }] };
      }
      const home = homedir();
      const toPortable = (p) => p && p.startsWith(home) ? '~' + p.slice(home.length) : (p || '');
      const lines = results.map(r => {
        const qualityBadge = r.quality_tier === 'installed' ? '[✓]' : r.quality_tier === 'verified' ? '[★]' : '[○]';
        const categoryLabel = r.category ? ` [${r.category}]` : '';
        const isManaged = r.local_path && r.local_path.includes(join(DB_DIR, 'managed') + sep);
        const portablePath = isManaged ? toPortable(r.local_path) : '';
        let howToUse;
        if (isManaged) {
          // Managed: use Read(path) or mem_use — Skill() won't work for managed resources
          // Agents always have complete .md paths (e.g., agents/group/agents/name.md)
          // Only skills can be directory paths (9 cases) — resolve to /SKILL.md
          const resolvedPath = portablePath.endsWith('.md') ? portablePath : `${portablePath}/SKILL.md`;
          howToUse = `Read("${resolvedPath}") or mem_use(name="${r.name}"${r.type === 'agent' ? ', type="agent"' : ''})`;
        } else if (r.invocation_name) {
          // Native plugin/user skill: Skill() with full invocation name
          howToUse = r.type === 'skill'
            ? `Skill("${r.invocation_name}")`
            : `Agent(subagent_type="${r.invocation_name}")`;
        } else {
          howToUse = `mem_use(name="${r.name}"${r.type === 'agent' ? ', type="agent"' : ''})`;
        }
        const pathLine = portablePath ? `\n  Path: ${portablePath}` : '';
        return `${qualityBadge} ${r.type === 'skill' ? 'S' : 'A'} **${r.name}**${categoryLabel} — ${truncate(r.capability_summary || '', 80)}${pathLine}\n  Use: ${howToUse}`;
      });
      return { content: [{ type: 'text', text: `Found ${results.length} resource(s) for "${args.query}":\n\n${lines.join('\n\n')}` }] };
    }

    if (action === 'list') {
      const typeFilter = args.type;
      const where = typeFilter ? 'WHERE type = ? AND status = ?' : 'WHERE status = ?';
      const params = typeFilter ? [typeFilter, 'active'] : ['active'];
      // T3-P2-A: order by adoption then recommendation (CLI parity), and coalesce NULL counts
      // so the output shows "adopt:0" rather than the jarring "adopt:null".
      const resources = rdb.prepare(`
        SELECT name, type, invocation_name, recommend_count, adopt_count, capability_summary
        FROM resources ${where}
        ORDER BY COALESCE(adopt_count, 0) DESC, COALESCE(recommend_count, 0) DESC, type, name
      `).all(...params);

      if (resources.length === 0) return { content: [{ type: 'text', text: 'No resources found.' }] };

      const lines = resources.map(r =>
        `${r.type === 'skill' ? 'S' : 'A'} ${r.name}${r.invocation_name ? ` (${r.invocation_name})` : ''} — rec:${r.recommend_count ?? 0} adopt:${r.adopt_count ?? 0} — ${truncate(r.capability_summary || '', 80)}`
      );
      return { content: [{ type: 'text', text: `Resources (${resources.length}):\n${lines.join('\n')}` }] };
    }

    if (action === 'stats') {
      const total = rdb.prepare('SELECT COUNT(*) as c FROM resources WHERE status = ?').get('active');
      const byType = rdb.prepare('SELECT type, COUNT(*) as c FROM resources WHERE status = ? GROUP BY type').all('active');
      const topAdopted = rdb.prepare(`
        SELECT name, type, adopt_count, recommend_count
        FROM resources WHERE status = ? AND adopt_count > 0
        ORDER BY adopt_count DESC LIMIT 10
      `).all('active');
      const zeroAdopt = rdb.prepare(`
        SELECT COUNT(*) as c FROM resources
        WHERE status = ? AND recommend_count > 0 AND adopt_count = 0
      `).get('active');
      const userAdded = rdb.prepare(`
        SELECT COUNT(*) as c FROM resources WHERE status = ? AND source = 'user'
      `).get('active');

      const lines = [
        `Registry Stats:`,
        `  Total active: ${total.c}`,
        ...byType.map(t => `  ${t.type}: ${t.c}`),
        `  User-added: ${userAdded.c}`,
        `  Zero adoption (recommended but never adopted): ${zeroAdopt.c}`,
      ];
      if (topAdopted.length > 0) {
        lines.push('', 'Top adopted:');
        for (const r of topAdopted) {
          lines.push(`  ${r.name} (${r.type}): ${r.adopt_count}/${r.recommend_count}`);
        }
      }
      return { content: [{ type: 'text', text: lines.join('\n') }] };
    }

    if (action === 'import') {
      if (!args.name || !args.resource_type) {
        return { content: [{ type: 'text', text: 'import requires name and resource_type' }], isError: true };
      }
      const IMPORT_STRING_FIELDS = ['repo_url', 'local_path', 'invocation_name', 'intent_tags',
        'domain_tags', 'trigger_patterns', 'capability_summary', 'keywords', 'tech_stack', 'use_cases'];
      // Preserve provenance on a metadata-only re-import (parity with cmdRegistry): default
      // source to 'user' only for a NEW resource — a partial re-upsert of an existing
      // github/preinstalled row without `source` must not flip it to 'user'.
      let source = args.source;
      if (!source) {
        const existing = rdb.prepare('SELECT source FROM resources WHERE type = ? AND name = ?').get(args.resource_type, args.name);
        source = existing ? existing.source : 'user';
      }
      const fields = { name: args.name, type: args.resource_type, status: 'active', source };
      for (const f of IMPORT_STRING_FIELDS) fields[f] = args[f] || '';
      const id = upsertResource(rdb, fields);
      return { content: [{ type: 'text', text: `Imported: ${args.resource_type}:${args.name} (id=${id})` }] };
    }

    if (action === 'remove') {
      if (!args.name || !args.resource_type) {
        return { content: [{ type: 'text', text: 'remove requires name and resource_type' }], isError: true };
      }
      const result = rdb.prepare('DELETE FROM resources WHERE type = ? AND name = ?').run(args.resource_type, args.name);
      return { content: [{ type: 'text', text: result.changes > 0 ? `Removed: ${args.resource_type}:${args.name}` : 'Not found.' }] };
    }

    if (action === 'reindex') {
      rdb.exec("INSERT INTO resources_fts(resources_fts) VALUES('rebuild')");
      const count = rdb.prepare('SELECT COUNT(*) as c FROM resources WHERE status = ?').get('active');
      return { content: [{ type: 'text', text: `FTS5 reindexed. ${count.c} active resources.` }] };
    }

    if (action === 'import_url') {
      if (!args.url) {
        return { content: [{ type: 'text', text: 'import_url requires a url parameter' }], isError: true };
      }
      const { importFromGitHub } = await import('./registry-importer.mjs');
      try {
        const results = await importFromGitHub(rdb, args.url);
        if (results.length === 0) {
          return { content: [{ type: 'text', text: `No skills/agents found in: ${args.url}` }] };
        }

        let enrichMsg = '';
        if (args.enrich) {
          const { enrichResource } = await import('./registry-enricher.mjs');
          let ok = 0;
          for (const r of results) {
            const row = rdb.prepare('SELECT local_path FROM resources WHERE id = ?').get(r.id);
            if (!row?.local_path) continue;
            try {
              const content = readFileSync(row.local_path, 'utf8');
              if (await enrichResource(rdb, r.name, r.type, content)) ok++;
            } catch {}
          }
          enrichMsg = `\nEnriched: ${ok}/${results.length}`;
        }

        const lines = results.map(r => `${r.type === 'skill' ? 'S' : 'A'} ${r.name} (id=${r.id})`);
        return { content: [{ type: 'text', text: `Imported ${results.length} resource(s) from ${args.url}:\n${lines.join('\n')}${enrichMsg}` }] };
      } catch (e) {
        return { content: [{ type: 'text', text: `Import failed: ${e.message}` }], isError: true };
      }
    }

    if (action === 'enrich') {
      if (!args.name) {
        return { content: [{ type: 'text', text: 'enrich requires a name parameter' }], isError: true };
      }
      const row = rdb.prepare("SELECT name, type, local_path FROM resources WHERE name = ? AND status = 'active'").get(args.name);
      if (!row) {
        return { content: [{ type: 'text', text: `Resource not found: ${args.name}` }], isError: true };
      }
      if (!row.local_path) {
        return { content: [{ type: 'text', text: `No local_path for ${args.name}` }], isError: true };
      }
      // Confine to the env-aware data dir (managed/ relocates with CLAUDE_MEM_DIR, D#29);
      // === homedir when the env is unset, so non-relocated confinement is unchanged.
      const enrichBase = DB_DIR;
      if (!isPathConfined(row.local_path, enrichBase)) {
        return { content: [{ type: 'text', text: `Access denied: path outside managed directory` }], isError: true };
      }

      const { enrichResource } = await import('./registry-enricher.mjs');
      try {
        const content = readFileSync(row.local_path, 'utf8');
        const ok = await enrichResource(rdb, row.name, row.type, content);
        return { content: [{ type: 'text', text: ok ? `Enriched: ${args.name}` : `Enrichment failed for ${args.name}` }] };
      } catch (e) {
        return { content: [{ type: 'text', text: `Enrich error: ${e.message}` }], isError: true };
      }
    }

    return { content: [{ type: 'text', text: `Unknown action: ${action}. Valid: search, list, stats, import, remove, reindex, import_url, enrich` }], isError: true };
  })
);

// ─── Tool: mem_use ──────────────────────────────────────────────────────────

server.registerTool(
  'mem_use',
  {
    description: descriptionOf('mem_use'),
    inputSchema: memUseSchema,
  },
  safeHandler(async (args) => {
    const rdb = getRegistryDb();
    if (!rdb) {
      return { content: [{ type: 'text', text: 'Registry DB not available.' }], isError: true };
    }

    const name = args.name.trim();
    const type = args.type || 'skill';

    // 1. Exact match by name or invocation_name
    let row = rdb.prepare(`
      SELECT id, name, type, local_path, invocation_name, capability_summary
      FROM resources
      WHERE status = 'active' AND type = ?
        AND (name = ? OR invocation_name = ?)
      LIMIT 1
    `).get(type, name, name);

    // 2. Fuzzy fallback: FTS5 search, take top result
    if (!row) {
      const results = searchResources(rdb, name, { type, limit: 1 });
      if (results.length > 0) {
        row = rdb.prepare(`SELECT id, name, type, local_path, invocation_name, capability_summary FROM resources WHERE name = ? AND type = ? AND status = 'active'`).get(results[0].name, results[0].type);
      }
    }

    if (!row) {
      return { content: [{ type: 'text', text: `No ${type} found for "${name}". Try mem_registry(action="search", query="${name}") to browse.` }] };
    }

    // 3. Resolve path: directory skills → SKILL.md (agents always have full .md paths)
    let skillPath = row.local_path || '';
    if (skillPath && !skillPath.endsWith('.md')) {
      for (const candidate of [
        join(skillPath, 'SKILL.md'),
        join(skillPath, `skills/${row.name}/SKILL.md`),
      ]) {
        if (existsSync(candidate)) { skillPath = candidate; break; }
      }
    }

    // 4. Path confinement check — prevent reading arbitrary files via crafted local_path.
    // Base is the env-aware data dir (D#29): managed/ relocates with CLAUDE_MEM_DIR and
    // equals homedir when unset, so this does not weaken the non-relocated confinement.
    const managedBase = DB_DIR;
    if (skillPath && !isPathConfined(skillPath, managedBase)) {
      return { content: [{ type: 'text', text: `Access denied: path "${skillPath}" is outside managed directory` }], isError: true };
    }

    // 5. Read content
    let content;
    try {
      content = readFileSync(skillPath, 'utf8');
    } catch {
      const msg = skillPath.endsWith('.md')
        ? `Found ${type} "${row.name}" but cannot read file: ${skillPath}`
        : `Found ${type} "${row.name}" but no .md file in: ${skillPath}`;
      return { content: [{ type: 'text', text: msg }], isError: true };
    }

    // 5. Record invocation
    try {
      rdb.prepare(`
        INSERT INTO invocations (resource_id, session_id, trigger, adopted, outcome)
        VALUES (?, ?, 'user_explicit', 1, 'success')
      `).run(row.id, process.env.CLAUDE_SESSION_ID || 'unknown');
    } catch { /* non-critical */ }

    const _home = homedir();
    const portablePath = skillPath && skillPath.startsWith(_home) ? '~' + skillPath.slice(_home.length) : (skillPath || '');
    const pathAttr = portablePath ? ` path="${portablePath}"` : '';
    const reloadHint = portablePath ? ` Reload: Read("${portablePath}")` : '';
    return { content: [{ type: 'text', text: `<skill-loaded name="${row.name}" type="${row.type}"${pathAttr}>\n${content}\n</skill-loaded>\n\nFollow the instructions above to execute this ${row.type}.${reloadHint}` }] };
  }),
);

// ─── Tool: mem_update ────────────────────────────────────────────────────────

server.registerTool(
  'mem_update',
  {
    description: descriptionOf('mem_update'),
    inputSchema: memUpdateSchema,
  },
  safeHandler(async (args) => {
    const obs = db.prepare('SELECT id, title FROM observations WHERE id = ?').get(args.id);
    if (!obs) return { content: [{ type: 'text', text: `Observation #${args.id} not found` }], isError: true };

    const updates = [];
    const params = [];
    for (const [key, col] of [['title','title'],['narrative','narrative'],['type','type'],['importance','importance'],['lesson_learned','lesson_learned'],['concepts','concepts']]) {
      if (args[key] !== undefined) {
        updates.push(`${col} = ?`);
        params.push(typeof args[key] === 'string' ? scrubSecrets(args[key]) : args[key]);
      }
    }
    if (updates.length === 0) return { content: [{ type: 'text', text: 'No fields to update' }], isError: true };

    params.push(args.id);

    // Atomic: update fields + rebuild derived columns (FTS text + vector) via the
    // shared core — single source with CLI cmdUpdate (lib/observation-write.mjs).
    db.transaction(() => {
      db.prepare(`UPDATE observations SET ${updates.join(', ')} WHERE id = ?`).run(...params);
      rebuildObservationDerived(db, args.id);
    })();

    return { content: [{ type: 'text', text: `Updated observation #${args.id}: ${updates.map(u => u.split(' =')[0]).join(', ')}` }] };
  })
);

// ─── Tool: mem_export ────────────────────────────────────────────────────────

server.registerTool(
  'mem_export',
  {
    description: descriptionOf('mem_export'),
    inputSchema: memExportSchema,
  },
  safeHandler(async (args) => {
    const wheres = [];
    const params = [];
    if (!args.include_compressed) wheres.push('COALESCE(compressed_into, 0) = 0');
    wheres.push('superseded_at IS NULL');
    if (args.project) { wheres.push('project = ?'); params.push(resolveProject(args.project)); }
    if (args.type) { wheres.push('type = ?'); params.push(args.type); }
    // T3-P1-A: surface invalid dates instead of silently dropping the filter — mirrors
    // mem_search, which threw. A dropped filter can quietly expand the export blast radius.
    if (args.date_from) {
      const epoch = new Date(args.date_from).getTime();
      if (isNaN(epoch)) throw new Error(`Invalid date_from: "${args.date_from}" (use ISO 8601 or YYYY-MM-DD)`);
      wheres.push('created_at_epoch >= ?');
      params.push(epoch);
    }
    if (args.date_to) {
      const d = args.date_to.length === 10 ? args.date_to + 'T23:59:59.999Z' : args.date_to;
      const epoch = new Date(d).getTime();
      if (isNaN(epoch)) throw new Error(`Invalid date_to: "${args.date_to}" (use ISO 8601 or YYYY-MM-DD)`);
      wheres.push('created_at_epoch <= ?');
      params.push(epoch);
    }

    const where = wheres.length > 0 ? 'WHERE ' + wheres.join(' AND ') : '';
    const exportLimit = Math.min(args.limit ?? 200, 1000);
    // T3-P2-B: probe limit+1 so we can tell "user hit their own limit with more waiting" from
    // "user got exactly what existed". Trim to exportLimit before rendering.
    const probed = db.prepare(`SELECT id, project, type, title, subtitle, narrative, concepts, facts, lesson_learned, importance, files_modified, branch, access_count, memory_session_id, created_at, created_at_epoch FROM observations ${where} ORDER BY created_at_epoch DESC LIMIT ?`).all(...params, exportLimit + 1);
    const rows = probed.slice(0, exportLimit);
    const moreAvailable = probed.length > exportLimit;

    if (rows.length === 0) return { content: [{ type: 'text', text: 'No observations found matching the criteria.' }] };

    const output = args.format === 'jsonl'
      ? rows.map(r => JSON.stringify(r)).join('\n')
      : JSON.stringify(rows, null, 2);

    const cap = moreAvailable ? `\nNote: Results capped at ${exportLimit}. Use date_from/date_to or increase limit (max 1000) to export more.` : '';
    return { content: [{ type: 'text', text: `Exported ${rows.length} observations:${cap}\n${output}` }] };
  })
);

// ─── Tool: mem_recall ────────────────────────────────────────────────────────

server.registerTool(
  'mem_recall',
  {
    description: descriptionOf('mem_recall'),
    inputSchema: memRecallSchema,
  },
  safeHandler(async (args) => {
    // Shared core with CLI cmdRecall: query + escaping + access bump (lib/recall-core.mjs)
    const { filename, rows } = recallByFile(db, args.file, {
      limit: args.limit ?? 10,
      includeNoise: args.include_noise === true,
    });

    if (rows.length === 0) {
      return { content: [{ type: 'text', text: `No history for "${filename}". This file hasn't been observed yet.` }] };
    }

    const lines = [`History for ${filename} (${rows.length} observation${rows.length !== 1 ? 's' : ''}):\n`];
    for (const r of rows) {
      const lesson = r.lesson_learned ? `\n     Lesson: ${truncate(r.lesson_learned, 100)}` : '';
      lines.push(`#${r.id} ${typeIcon(r.type)} [${r.type}] ${truncate(r.title || '(untitled)')} | ${r.project} | ${fmtDate(r.created_at)}${lesson}`);
    }
    lines.push(`\nWorkflow: mem_get(ids=[...]) for full details`);
    return { content: [{ type: 'text', text: lines.join('\n') }] };
  })
);

// ─── Tool: mem_fts_check ─────────────────────────────────────────────────────
// Handler extracted to server/fts-check.mjs (v2.41 split).
import { handleMemFtsCheck } from './server/fts-check.mjs';

server.registerTool(
  'mem_fts_check',
  {
    description: descriptionOf('mem_fts_check'),
    inputSchema: memFtsCheckSchema,
  },
  safeHandler(async (args) => handleMemFtsCheck(db, args))
);

// ─── Tool: mem_browse ────────────────────────────────────────────────────────

server.registerTool(
  'mem_browse',
  {
    description: descriptionOf('mem_browse'),
    inputSchema: memBrowseSchema,
  },
  safeHandler(async (args) => {
    if (args.project) args = { ...args, project: resolveProject(args.project) };
    const project = args.project || inferProject();
    const tierFilter = args.tier || null;
    const limit = args.limit ?? (tierFilter ? 20 : 5);
    const now = Date.now();

    // Get active session for tier classification
    const activeSession = db.prepare(
      "SELECT memory_session_id FROM sdk_sessions WHERE project = ? AND status = 'active' ORDER BY started_at_epoch DESC LIMIT 1"
    ).get(project);

    const ctx = { now, currentProject: project, currentSessionId: activeSession?.memory_session_id ?? '' };
    const params = tierSqlParams(ctx);

    const tiers = ['working', 'active', 'archive'];
    const tierLabels = { working: '🔴 Working Memory', active: '🟡 Active Memory', archive: '🔵 Archive' };
    const showTiers = tierFilter ? [tierFilter] : tiers;

    const lines = [`Memory Dashboard (${project})\n`];
    let grandTotal = 0;
    const tierCounts = {};

    for (const tier of showTiers) {
      const countRow = db.prepare(`
        SELECT COUNT(*) as c FROM (
          SELECT ${TIER_CASE_SQL} as tier FROM observations
          WHERE project = ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL
        ) WHERE tier = ?
      `).get(...params, project, tier);
      const count = countRow?.c ?? 0;
      tierCounts[tier] = count;
      grandTotal += count;

      lines.push(`${tierLabels[tier]} (${count})`);

      if (tier === 'archive' && !tierFilter) {
        if (count > 0) lines.push('');
        continue;
      }

      if (count === 0) { lines.push(''); continue; }

      const rows = db.prepare(`
        SELECT * FROM (
          SELECT id, type, title, created_at, created_at_epoch, ${TIER_CASE_SQL} as tier
          FROM observations
          WHERE project = ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL
        ) WHERE tier = ?
        ORDER BY created_at_epoch DESC
        LIMIT ?
      `).all(...params, project, tier, limit);

      for (const r of rows) {
        lines.push(`  #${r.id} ${typeIcon(r.type)} [${r.type}] ${truncate(r.title || '(untitled)', 80)} | ${fmtDate(r.created_at)}`);
      }
      if (count > rows.length) lines.push(`  ... and ${count - rows.length} more`);
      lines.push('');
    }

    if (grandTotal === 0) {
      return { content: [{ type: 'text', text: 'No observations found. Start a coding session to build memory.' }] };
    }

    if (!tierFilter) {
      const parts = tiers.map(t => `${t[0].toUpperCase() + t.slice(1)}: ${tierCounts[t] ?? 0}`);
      lines.push(`Totals: ${grandTotal} observations | ${parts.join(' | ')}`);
    }

    return { content: [{ type: 'text', text: lines.join('\n') }] };
  })
);

// ─── Hidden tool filter ─────────────────────────────────────────────────────
// All tools are registered (so `tools/call <name>` still resolves for scripts
// and direct MCP clients), but only the core tools appear in the `tools/list`
// response. Hiding the maintenance/admin tools keeps Claude Code's startup
// context small while preserving the contract that the plugin dogfoods (see
// the CLAUDE.md managed block + adopt-content.mjs detail doc).
// Surface counts as of v2.70.0: 9 core (mem_search/recent/timeline/get/save/
// recall + mem_defer/mem_defer_list/mem_defer_drop) + 11 hidden (maintenance/
// admin/specialized) = 20 registered; tests/tool-schemas.test.mjs is the
// authoritative count.
//
// Safe because:
//   - Protocol-layer override: we replace the mcp.js default ListTools
//     handler on the underlying Server (setRequestHandler is a Map.set).
//   - `enabled` stays true, so `tools/call` keeps routing normally — per
//     mcp.js line 106, a `disabled` tool would reject calls too.

const HIDDEN_TOOL_NAMES = new Set(
  TOOL_DEFS.filter((t) => t.hidden === true).map((t) => t.name),
);

// Opt-out: setting CLAUDE_MEM_ALL_TOOLS=1 restores pre-v2.34.0 behavior where
// every registered tool is visible in `tools/list`. Users who relied on Claude
// Code autonomously invoking the now-hidden maintenance tools can use this as
// an immediate escape hatch while adopting the CLI entry points documented in
// adopt-content.mjs / README.
const EXPOSE_ALL_TOOLS = process.env.CLAUDE_MEM_ALL_TOOLS === '1';

if (!EXPOSE_ALL_TOOLS) {
  // Force mcp.js to install its default ListTools/CallTools handlers before
  // we override; registerTool already did this, but keep the call explicit so
  // a future reorder of tool registration doesn't break the override.
  const originalHandler = server.server._requestHandlers.get('tools/list');
  if (typeof originalHandler !== 'function') {
    throw new Error('tools/list handler missing — server initialization order changed');
  }
  server.server.setRequestHandler(ListToolsRequestSchema, async (req, extra) => {
    const full = await originalHandler(req, extra);
    return { ...full, tools: full.tools.filter((t) => !HIDDEN_TOOL_NAMES.has(t.name)) };
  });
}

// One-time discoverability banner (stderr only — Claude Code surfaces it on
// session start). Skipped under MEM_QUIET_HOOKS=1 so CI / tests / hermeticity
// harnesses stay silent.
if (!effectiveQuiet()) {
  const status = EXPOSE_ALL_TOOLS
    ? `all ${TOOL_DEFS.length} tools exposed via CLAUDE_MEM_ALL_TOOLS=1`
    : `tools/list narrowed to ${TOOL_DEFS.length - HIDDEN_TOOL_NAMES.size} core tools (${HIDDEN_TOOL_NAMES.size} hidden but callable by exact name; unset CLAUDE_MEM_ALL_TOOLS to keep, set =1 to restore all)`;
  process.stderr.write(`[claude-mem-lite v${PKG_VERSION}] ${status}\n`);
}

// ─── WAL Checkpoint (periodic) ───────────────────────────────────────────────

// Checkpoint WAL every 5 minutes to prevent unbounded growth
const WAL_CHECKPOINT_INTERVAL = 5 * 60 * 1000;
const walTimer = setInterval(() => {
  try { db.pragma('wal_checkpoint(PASSIVE)'); } catch (e) { debugCatch(e, 'walCheckpoint'); }
}, WAL_CHECKPOINT_INTERVAL);
walTimer.unref(); // Don't keep process alive just for checkpoints

// ─── Idle-Time Memory Optimization ──────────────────────────────────────────
// When no MCP requests for 5 minutes, run lightweight DB maintenance.
// lastMcpRequestTime and idleCleanupRan are declared near safeHandler (which updates them).

const IDLE_THRESHOLD_MS = 5 * 60 * 1000;

const idleTimer = setInterval(() => {
  if (idleCleanupRan) return;
  if (Date.now() - lastMcpRequestTime < IDLE_THRESHOLD_MS) return;
  idleCleanupRan = true;

  try {
    // Type-differentiated cleanup: higher-value types survive longer
    const { marked, compressed } = runIdleCleanup(db);
    if (marked > 0) debugLog('INFO', 'idle-cleanup', `Marked ${marked} stale observations as pending-purge`);
    if (compressed > 0) debugLog('INFO', 'idle-cleanup', `Compressed ${compressed} old observations`);

    // FTS5 index optimization (outside transaction — WAL-friendly)
    db.exec("INSERT INTO observations_fts(observations_fts) VALUES('optimize')");
    debugLog('DEBUG', 'idle-cleanup', 'FTS5 optimize complete');
  } catch (e) {
    debugCatch(e, 'idle-cleanup');
  }
}, 60000); // Check every minute
idleTimer.unref();

// ─── Shutdown Cleanup ────────────────────────────────────────────────────────

function shutdown(exitCode = 0) {
  clearInterval(walTimer);
  clearInterval(idleTimer);
  try { if (db) db.pragma('wal_checkpoint(TRUNCATE)'); } catch {}
  try { if (db) db.close(); } catch {}
  try { if (registryDb) registryDb.close(); } catch {}
  process.exit(exitCode);
}
process.on('SIGINT', () => shutdown(0));
process.on('SIGTERM', () => shutdown(0));
process.on('uncaughtException', (err) => { debugCatch(err, 'uncaughtException'); shutdown(1); });
process.on('unhandledRejection', (err) => { debugCatch(err, 'unhandledRejection'); shutdown(1); });

// ─── Start Server ───────────────────────────────────────────────────────────

// Spawn telemetry — appends one JSON line per process start so we can diagnose
// dual-registration (plugin namespace + local .mcp.json could both spawn the
// server in one Claude Code session). Two records with close timestamps and
// the same ppid is the smoking gun. Never throws — telemetry must not block
// startup. Disable with MEM_DISABLE_SPAWN_LOG=1.
//
// File mode: no explicit chmod. Payload is `{ts, pid, ppid, argv1, version}` —
// no secrets, no project content. Pre-v2.79.1 passed `{mode: 0o600}` to
// appendFileSync but that only applies on file creation (umask-default after
// the first append), so the "0600" claim was misleading honesty-wise. Honest
// comment > misleading code.
if (process.env.MEM_DISABLE_SPAWN_LOG !== '1') {
  try {
    if (!existsSync(RUNTIME_DIR)) mkdirSync(RUNTIME_DIR, { recursive: true });
    const line = JSON.stringify({
      ts: new Date().toISOString(),
      pid: process.pid,
      ppid: process.ppid,
      argv1: process.argv[1] || '',
      version: PKG_VERSION,
    }) + '\n';
    appendFileSync(join(RUNTIME_DIR, 'mcp-spawns.log'), line);
  } catch { /* never block startup on telemetry failure */ }
}

const transport = new StdioServerTransport();
await server.connect(transport);
