/**
 * Memory Tool - Persistent memory storage for agents
 *
 * Purpose:
 * - Allow agents to store, retrieve, update, and delete persistent memories
 * - Memories persist across sessions and agent restarts
 * - Support expiration conditions (date-based or custom conditions)
 * - Group memories by date for organized retrieval
 *
 * Actions:
 * - add: Create a new memory
 * - update: Update an existing memory
 * - delete: Remove a memory
 * - list: List memories with configurable detail level
 * - read: Load a memory's full content into context
 * - search: Search memories by title or description
 * - stats: Get memory statistics
 */

import { BaseTool } from './baseTool.js';
import { getMemoryService, PLAN_TITLE_PREFIX } from '../services/memoryService.js';
import { MemoryIndexer } from '../services/embeddings/indexers/memoryIndexer.js';
import { ReminisceIndexer } from '../services/embeddings/indexers/reminisceIndexer.js';

/**
 * True if the given memory title is a "working plan" — those are
 * auto-injected into the agent's system prompt under AGENT WORKING
 * PLAN, so the tool result for ADD/UPDATE/READ on these should NOT
 * echo the full content (would duplicate ~bulk into the conversation).
 *
 * TODO (future Plan C): add `team-plan/` prefix here too once the
 * team-shared variant lands — same compression rules apply.
 */
function isPlanTitle(title) {
  return typeof title === 'string' && title.startsWith(PLAN_TITLE_PREFIX);
}
import {
  overview as convOverview,
  range as convRange,
  search as convSearch,
  around as convAround,
  byTool as convByTool,
  read as convRead,
} from '../services/conversationQuery.js';

class MemoryTool extends BaseTool {
  constructor(config = {}, logger = null) {
    super(config, logger);

    this.memoryService = null;

    // Tool metadata
    this.requiresProject = false;
    this.isAsync = false;
    this.timeout = 30000; // 30 seconds
  }

  /**
   * Initialize memory service lazily
   */
  async _ensureMemoryService() {
    if (!this.memoryService) {
      this.memoryService = getMemoryService(this.logger);
      await this.memoryService.initialize();
    }
    return this.memoryService;
  }

  /**
   * Get tool description for LLM consumption
   * @returns {string} Tool description
   */
  getDescription() {
    return `
Memory Tool: Store and retrieve persistent memories that survive across sessions.

★ WORKING PLAN MEMORIES (auto-injected into system prompt):
If a memory's TITLE starts with "plan/" (e.g. "plan/research", "plan/refactor-v2"),
it is treated as a "working plan" and auto-injected into your system prompt
under an "AGENT WORKING PLAN" header EVERY TURN, until you delete or rename it.
This is the right tool for arc-spanning strategy that should persist across
sessions: the why, the constraints, the long-horizon plan.

Use it WHEN:
  - You're working on something that will take multiple turns or sessions.
  - You want to record a strategy/approach that you should re-read every turn.
  - You want notes that will outlive the current chat history.

Do NOT use it for:
  - Step-by-step within-task checkboxes — that's what the task-manager tool
    (TodoList) is for. Plans answer "the why"; the task list answers "what
    am I doing right now."
  - One-shot facts the agent will only need once.

Size guard: each plan/* memory is capped at ~2000 tokens before truncation,
and the total of all plan/* memories is capped at ~8000 tokens. If exceeded,
the content is truncated AND a warning is logged for the operator. Summarize
verbose plans yourself rather than relying on the truncation.

After add/update of a plan/* memory, the tool DOES NOT echo the content back
in the result — it would duplicate the header injection. You'll see the plan
in your system prompt on the next turn instead.

USAGE (canonical plural — same shape across all tools):
\`\`\`json
{
  "toolId": "memory",
  "actions": [
    {"type": "read", "id": "mem-123-abc"}
  ]
}
\`\`\`

BATCH multiple memory operations in one call:
\`\`\`json
{
  "toolId": "memory",
  "actions": [
    {"type": "read", "id": "mem-auth-plan"},
    {"type": "read", "id": "mem-retry-policy"},
    {"type": "append", "id": "mem-auth-plan", "content": "Step 4 added: revoke v1 tokens"}
  ]
}
\`\`\`

ACTIONS:

1. ADD - Create a new memory
\`\`\`json
{
  "toolId": "memory",
  "actions": [{
    "type": "add",
    "title": "User prefers TypeScript",
    "description": "Programming language preference",
    "content": "The user explicitly stated they prefer TypeScript over JavaScript.",
    "expiration": null
  }]
}
\`\`\`

Expiration options:
- null or omit: Never expires
- "2025-06-01": Expires on date (ISO format)
- "When user changes preference": Custom condition (manually removed)

2. UPDATE - Modify an existing memory
\`\`\`json
{
  "toolId": "memory",
  "actions": [{
    "type": "update",
    "id": "mem-123-abc",
    "title": "Updated title",
    "description": "Updated description",
    "content": "Updated content",
    "expiration": "2025-12-31"
  }]
}
\`\`\`

3. DELETE - Remove a memory
\`\`\`json
{
  "toolId": "memory",
  "actions": [{"type": "delete", "id": "mem-123-abc"}]
}
\`\`\`

4. LIST - List memories (grouped by date, newest first)
\`\`\`json
{
  "toolId": "memory",
  "actions": [{"type": "list", "level": "titles"}]
}
\`\`\`

Level options:
- "titles": ID and title only (default, recommended)
- "descriptions": ID, title, and description
- "full": All fields including expiration and timestamps

5. READ - Load a memory's full content
\`\`\`json
{
  "toolId": "memory",
  "actions": [{"type": "read", "id": "mem-123-abc"}]
}
\`\`\`

6. SEARCH - Find memories by keyword
\`\`\`json
{
  "toolId": "memory",
  "actions": [{"type": "search", "query": "TypeScript"}]
}
\`\`\`

7. STATS - Get memory statistics
\`\`\`json
{
  "toolId": "memory",
  "actions": [{"type": "stats"}]
}
\`\`\`

8. REMINISCE - Browse your own pre-compaction conversation archive (READ-ONLY)

This is separate from the other actions. It does NOT read memory entries —
it reads your full past conversation as stored on disk. Use it when you
recall a detail from earlier in the session (a user instruction, a tool
output, an earlier decision) that's no longer in your recent context
because compaction removed it. The archive is durable: every message you
and the user exchanged is still there, even if you can't see it now.

The stable pointer is \`messageId\`. You can bookmark one result (e.g. from
\`search\`) and use it later in \`around\` to pull the surrounding context.
messageIds never change, even as the conversation grows or compaction runs.

Sub-modes:

  8a. overview — "where have I been"
  \`\`\`json
  { "toolId": "memory", "action": "reminisce", "mode": "overview" }
  \`\`\`
  Returns: totalMessages, firstAt, lastAt, totalApproxTokens, and a sparse
  timeline of ~20 evenly-spaced markers { at, role, messageId, snippet }.

  8b. range — slice by index or timestamp
  \`\`\`json
  {
    "toolId": "memory", "action": "reminisce", "mode": "range",
    "offset": 0, "limit": 20,
    "from": "2026-04-18T00:00:00Z", "to": "2026-04-18T12:00:00Z"
  }
  \`\`\`
  Returns: { messages, total, offset, limit, hasMore }.

  8c. search — substring across content + tool-call arguments
  \`\`\`json
  {
    "toolId": "memory", "action": "reminisce", "mode": "search",
    "query": "database schema", "role": "user", "maxResults": 10
  }
  \`\`\`
  Returns: { matches [{ messageId, role, at, source, snippet, highlightRanges }],
             total, hasMore, cursor }.
  Pass \`cursor\` back verbatim to get the next page.
  Note: search does NOT match tool results/outputs — only content and
  tool-call arguments.

  8d. around — N MESSAGES before + N MESSAGES after a bookmarked messageId
  \`\`\`json
  {
    "toolId": "memory", "action": "reminisce", "mode": "around",
    "messageId": "msg_abc", "before": 3, "after": 3
  }
  \`\`\`
  \`before\` and \`after\` count MESSAGES (not lines, not characters).
  Range: 0–20 each side. For a larger window, paginate with a new \`around\`
  call anchored at the messageId of the last-returned entry.
  Returns: { messages, center, targetFound }.

  8e. byTool — list your past tool calls (optionally filtered)
  \`\`\`json
  {
    "toolId": "memory", "action": "reminisce", "mode": "byTool",
    "toolId": "terminal", "limit": 20
  }
  \`\`\`
  Returns: { toolCalls [{ messageId, at, toolId, status, inputSnippet }],
             total, hasMore, cursor }.

  8f. read — open ONE message at sub-message granularity (lines OR chars)
  \`\`\`json
  // Read whole message (byte-capped at ~32 KB)
  { "toolId": "memory", "action": "reminisce", "mode": "read",
    "messageId": "msg_abc" }

  // Read lines 100–150 of a long message (stack trace, paste, etc.)
  { "toolId": "memory", "action": "reminisce", "mode": "read",
    "messageId": "msg_abc", "lineFrom": 100, "lineTo": 150 }

  // Read characters 5000–7000 (half-open range)
  { "toolId": "memory", "action": "reminisce", "mode": "read",
    "messageId": "msg_abc", "contentFrom": 5000, "contentTo": 7000 }
  \`\`\`
  Use this when \`search\` found a hit inside a huge message and the snippet
  isn't enough, OR when you want to page through a single long message
  without pulling the whole thing into context.

  Window selectors are mutually exclusive — if you pass both, LINES win.
  Omit both selectors to read the whole message. Caps: 500 lines / 16 000
  chars per call. If the message is larger, the response's
  \`contentWindow.hasMoreAfter\` is true — call \`read\` again with the next
  \`lineFrom\` or \`contentFrom\` to continue.

  Returns:
  \`\`\`
  {
    message: {
      messageId, role, at, tokenCount, hasToolCalls, model,
      content: "<requested slice>",
      contentWindow: {
        kind: "lines" | "chars" | "full",
        lineFrom?, lineTo?, totalLines?,       // kind="lines"
        contentFrom?, contentTo?,              // kind="chars"
        totalContentLength,
        hasMoreBefore, hasMoreAfter,
        truncatedAtBytes   // byte cap kicked in mid-window
      }
    },
    targetFound, center
  }
  \`\`\`

All reminisce responses are byte-capped (~32 KB) to protect your context.
When a response is capped it includes \`truncatedByBytes > 0\`; fetch fewer
or narrower results.

Add \`"detail": "full"\` on range/around to receive full untruncated content
and the raw toolExecutions / contextReferences — use sparingly; it eats
tokens fast.

BEST PRACTICES:
- Use memories for important facts, preferences, or decisions that should persist
- Keep titles concise and searchable
- Use descriptions for quick context without loading full content
- Set expiration dates for time-sensitive information
- Use LIST with "titles" first, then READ specific memories as needed
- Avoid storing sensitive data (API keys, passwords) in memories
- For reminisce: start with \`overview\`, then \`search\` or \`range\` to find
  the right messageId, then \`around\` for surrounding messages or \`read\`
  to open a specific message (whole, by line range, or by char range).
  Bookmark messageIds in memory entries via \`[reminisce:msg_xxx]\`
  references for a durable pointer that survives compaction.
    `;
  }

  /**
   * Parse parameters from tool command content
   * @param {string} content - Raw tool command content
   * @returns {Object} Parsed parameters
   */
  parseParameters(content) {
    // Already parsed as JSON by the time it reaches here
    return content;
  }

  /**
   * Get required parameters based on action
   * @returns {Array<string>}
   */
  getRequiredParameters() {
    return ['action'];
  }

  /**
   * Validate parameter types
   * @param {Object} params
   * @returns {Object}
   */
  validateParameterTypes(params) {
    const errors = [];

    if (params.action && typeof params.action !== 'string') {
      errors.push('action must be a string');
    }

    if (params.id && typeof params.id !== 'string') {
      errors.push('id must be a string');
    }

    if (params.title && typeof params.title !== 'string') {
      errors.push('title must be a string');
    }

    if (params.description && typeof params.description !== 'string') {
      errors.push('description must be a string');
    }

    if (params.content && typeof params.content !== 'string') {
      errors.push('content must be a string');
    }

    if (params.query && typeof params.query !== 'string') {
      errors.push('query must be a string');
    }

    if (params.level && !['titles', 'descriptions', 'full'].includes(params.level)) {
      errors.push('level must be one of: titles, descriptions, full');
    }

    return {
      valid: errors.length === 0,
      errors
    };
  }

  /**
   * Custom parameter validation
   * @param {Object} params
   * @returns {Object}
   */
  customValidateParameters(params) {
    const errors = [];
    const validActions = ['add', 'update', 'append', 'delete', 'list', 'read', 'search', 'semantic-search', 'backfill-embeddings', 'reminisce-backfill-embeddings', 'forget-stale', 'publish-to-team', 'search-team', 'unpublish-from-team', 'stats', 'reminisce'];

    // Normalize to the canonical actions[] shape and validate every action.
    // Accepts both the canonical plural `{actions: [{type, ...}]}` and the
    // legacy singular `{action: "X", ...}` shapes — the executor's
    // _normalizeToActions handles the runtime promotion identically.
    const actions = this._normalizeToActions(params);

    if (actions.length === 0) {
      errors.push('action is required (or `actions: [...]` for batched calls)');
    } else {
      for (let i = 0; i < actions.length; i++) {
        const a = actions[i];
        const tag = actions.length > 1 ? `actions[${i}]: ` : '';
        if (!a || typeof a !== 'object') {
          errors.push(`${tag}must be an object with a "type" field`);
          continue;
        }
        const t = a.type;
        if (!t) {
          errors.push(`${tag}"type" is required`);
          continue;
        }
        if (!validActions.includes(t)) {
          errors.push(`${tag}type must be one of: ${validActions.join(', ')}`);
          continue;
        }
        switch (t) {
          case 'add':
            if (!a.title)   errors.push(`${tag}title is required for add`);
            if (!a.content) errors.push(`${tag}content is required for add`);
            break;
          case 'update':
            if (!a.id) errors.push(`${tag}id is required for update`);
            break;
          case 'append':
            if (!a.id)      errors.push(`${tag}id is required for append`);
            if (!a.content) errors.push(`${tag}content is required for append`);
            break;
          case 'delete':
            if (!a.id) errors.push(`${tag}id is required for delete`);
            break;
          case 'read':
            if (!a.id) errors.push(`${tag}id is required for read`);
            break;
          case 'search':
            if (!a.query) errors.push(`${tag}query is required for search`);
            break;
          case 'semantic-search':
            if (!a.query) errors.push(`${tag}query is required for semantic-search`);
            if (a.topK != null && (!Number.isInteger(a.topK) || a.topK <= 0)) {
              errors.push(`${tag}topK must be a positive integer when provided`);
            }
            break;
          case 'forget-stale':
            if (a.ageDays != null && (!Number.isFinite(a.ageDays) || a.ageDays <= 0)) {
              errors.push(`${tag}ageDays must be a positive number when provided`);
            }
            break;
          case 'publish-to-team':
            if (!a.id) errors.push(`${tag}id is required for publish-to-team (the memory id to publish)`);
            if (!a.teamId) errors.push(`${tag}teamId is required for publish-to-team`);
            break;
          case 'search-team':
            if (!a.teamId) errors.push(`${tag}teamId is required for search-team`);
            if (!a.query)  errors.push(`${tag}query is required for search-team`);
            if (a.topK != null && (!Number.isInteger(a.topK) || a.topK <= 0)) {
              errors.push(`${tag}topK must be a positive integer when provided`);
            }
            break;
          case 'unpublish-from-team':
            if (!a.id) errors.push(`${tag}id is required for unpublish-from-team`);
            if (!a.teamId) errors.push(`${tag}teamId is required for unpublish-from-team`);
            break;
          // backfill-embeddings has no required params (re-indexes everything).
        }
        // Length limits (per-action).
        if (a.title    && a.title.length    > 200)   errors.push(`${tag}title cannot exceed 200 characters`);
        if (a.description && a.description.length > 500) errors.push(`${tag}description cannot exceed 500 characters`);
        if (a.content  && a.content.length  > 10000) errors.push(`${tag}content cannot exceed 10000 characters`);
      }
    }

    return {
      valid: errors.length === 0,
      errors
    };
  }

  /**
   * Execute tool with parsed parameters
   * @param {Object} params
   * @param {Object} context
   * @returns {Promise<Object>}
   */
  async execute(params, context) {
    const { agentId } = context;

    if (!agentId) {
      throw new Error('Agent ID is required for memory tool');
    }

    const memoryService = await this._ensureMemoryService();

    // PLURAL-CANONICAL DISPATCH — the canonical shape is
    // `{actions: [{type: "read", id: "X"}, ...]}` matching the rest
    // of the tool conventions (filesystem, terminal, taskmanager).
    // Singular `{action: "read", id: "X"}` is auto-promoted as
    // backward-compat for the agent's training-prior emissions.
    // See commit 5e6b817 + the "tool envelope unification" thread.
    const actions = this._normalizeToActions(params);

    if (actions.length === 0) {
      throw new Error('memory tool requires either `action` (singular) or `actions` (plural array). Each action needs a `type` (or `action`) field.');
    }

    // Single-action path → return one result directly (no array wrap)
    // for backward-compat with every existing caller / test that
    // expects the shape from the singular dispatch.
    if (actions.length === 1) {
      return await this._dispatchSingleAction(memoryService, agentId, actions[0], context);
    }

    // Multi-action path → execute sequentially, return array of results.
    // Errors on one action don't abort the others — each result
    // carries its own success/error state.
    const results = [];
    for (const a of actions) {
      try {
        const r = await this._dispatchSingleAction(memoryService, agentId, a, context);
        results.push(r);
      } catch (err) {
        results.push({ success: false, action: a.type || a.action, error: err.message });
      }
    }
    return {
      success: results.every(r => r.success !== false),
      batched: true,
      actionCount: results.length,
      results,
    };
  }

  /**
   * Promote a tool-call's params into a canonical actions[] array.
   * Accepts:
   *   { actions: [{type, ...}] }            (canonical plural)
   *   { actions: [{action, ...}] }           (plural with `action` instead of `type`)
   *   { action: "read", id: "X" }            (legacy singular at top level)
   *   { action: "read", parameters: {...} }  (legacy singular with nested params)
   * @private
   */
  _normalizeToActions(params) {
    if (!params || typeof params !== 'object') return [];
    if (Array.isArray(params.actions)) {
      // Normalize each item: ensure `type` is set (rename `action`→`type`).
      return params.actions.map(a => {
        if (!a || typeof a !== 'object') return a;
        if (!a.type && a.action) {
          const { action, ...rest } = a;
          return { type: action, ...rest };
        }
        return a;
      });
    }
    if (params.action) {
      const { action, parameters, ...rest } = params;
      // Merge nested `parameters` (some callers wrap fields inside).
      return [{ type: action, ...(parameters && typeof parameters === 'object' ? parameters : {}), ...rest }];
    }
    return [];
  }

  /**
   * Dispatch a single normalized action {type, ...fields}.
   * The downstream _executeX methods expect the legacy params shape
   * (flat with `action: "X"`), so we adapt before calling.
   * @private
   */
  async _dispatchSingleAction(memoryService, agentId, action, context) {
    const { type, ...rest } = action;
    const legacyParams = { action: type, ...rest };
    try {
      switch (type) {
        case 'add':       return await this._executeAdd(memoryService, agentId, legacyParams);
        case 'update':    return await this._executeUpdate(memoryService, agentId, legacyParams);
        case 'append':    return await this._executeAppend(memoryService, agentId, legacyParams);
        case 'delete':    return await this._executeDelete(memoryService, agentId, legacyParams);
        case 'list':      return await this._executeList(memoryService, agentId, legacyParams);
        case 'read':      return await this._executeRead(memoryService, agentId, legacyParams);
        case 'search':            return await this._executeSearch(memoryService, agentId, legacyParams);
        case 'semantic-search':   return await this._executeSemanticSearch(memoryService, agentId, legacyParams);
        case 'backfill-embeddings': return await this._executeBackfillEmbeddings(memoryService, agentId);
        case 'reminisce-backfill-embeddings': return await this._executeReminisceBackfill(agentId, context);
        case 'forget-stale': return await this._executeForgetStale(memoryService, agentId, legacyParams);
        case 'publish-to-team': return await this._executePublishToTeam(memoryService, agentId, legacyParams);
        case 'search-team': return await this._executeSearchTeam(agentId, legacyParams);
        case 'unpublish-from-team': return await this._executeUnpublishFromTeam(agentId, legacyParams);
        case 'stats':             return await this._executeStats(memoryService, agentId);
        case 'reminisce':         return await this._executeReminisce(agentId, legacyParams, context);
        default:
          throw new Error(`Unknown memory action: ${type}`);
      }
    } catch (error) {
      this.logger?.error(`Memory tool action failed: ${error.message}`, {
        agentId,
        type,
        error: error.stack,
      });
      throw error;
    }
  }

  /**
   * Execute ADD action
   */
  async _executeAdd(memoryService, agentId, params) {
    const memory = await memoryService.addMemory(agentId, {
      title: params.title,
      description: params.description || '',
      content: params.content,
      expiration: params.expiration
    });

    // Fire-and-forget index update. Best-effort by design — the indexer's
    // public surface swallows errors and returns a result, but we don't
    // even await it so memory writes never wait on the embedding service.
    this._asyncIndexMemory(agentId, memory);

    return {
      success: true,
      action: 'add',
      memory: {
        id: memory.id,
        title: memory.title,
        createdAt: memory.createdAt,
        expiration: memory.expiration
      },
      // Compressed acknowledgment for plan/* — the bulk lives in the
      // AGENT WORKING PLAN header on the next turn, not in the
      // conversation tool-result echo.
      message: isPlanTitle(memory.title)
        ? `Plan saved: "${memory.title}" (${memory.id}) — content now auto-injected as AGENT WORKING PLAN on your next turn.`
        : `Memory created: "${memory.title}" (${memory.id})`
    };
  }

  /**
   * Execute UPDATE action
   */
  async _executeUpdate(memoryService, agentId, params) {
    const updates = {};
    if (params.title !== undefined) updates.title = params.title;
    if (params.description !== undefined) updates.description = params.description;
    if (params.content !== undefined) updates.content = params.content;
    if (params.expiration !== undefined) updates.expiration = params.expiration;

    const memory = await memoryService.updateMemory(agentId, params.id, updates);

    if (!memory) {
      return {
        success: false,
        action: 'update',
        message: `Memory not found: ${params.id}`
      };
    }

    // Re-index after content change. Fire-and-forget.
    this._asyncIndexMemory(agentId, memory);

    return {
      success: true,
      action: 'update',
      memory: {
        id: memory.id,
        title: memory.title,
        updatedAt: memory.updatedAt
      },
      message: isPlanTitle(memory.title)
        ? `Plan updated: "${memory.title}" — new content will appear in AGENT WORKING PLAN on your next turn.`
        : `Memory updated: "${memory.title}"`
    };
  }

  /**
   * Execute APPEND action — additive update with a timestamped block.
   *
   * Designed for the "memory grows as the agent learns" use case:
   * agent attaches a memory to a task on creation, then appends
   * findings/methodology as work progresses. Each append becomes a
   * dated section so the memory reads like a journal.
   *
   * Soft-cap behavior: once total content exceeds ~16K chars, the
   * service compresses older blocks into a summary on subsequent
   * appends. See memoryService.appendToMemory.
   */
  async _executeAppend(memoryService, agentId, params) {
    const memory = await memoryService.appendToMemory(agentId, params.id, params.content);

    if (!memory) {
      return {
        success: false,
        action: 'append',
        message: `Memory not found: ${params.id}`
      };
    }

    // Re-index after content grew. Fire-and-forget.
    this._asyncIndexMemory(agentId, memory);

    return {
      success: true,
      action: 'append',
      memory: {
        id: memory.id,
        title: memory.title,
        updatedAt: memory.updatedAt,
        contentLength: memory.content?.length || 0,
      },
      message: isPlanTitle(memory.title)
        ? `Plan updated (append): "${memory.title}" — full content appears in AGENT WORKING PLAN next turn.`
        : `Appended to memory: "${memory.title}" (now ${memory.content?.length || 0} chars)`
    };
  }

  /**
   * Execute DELETE action
   */
  async _executeDelete(memoryService, agentId, params) {
    const deleted = await memoryService.deleteMemory(agentId, params.id);

    if (!deleted) {
      return {
        success: false,
        action: 'delete',
        message: `Memory not found: ${params.id}`
      };
    }

    // Drop the corresponding vector from the index. Fire-and-forget.
    this._asyncRemoveFromIndex(agentId, params.id);

    return {
      success: true,
      action: 'delete',
      message: `Memory deleted: ${params.id}`
    };
  }

  /**
   * Execute LIST action
   */
  async _executeList(memoryService, agentId, params) {
    const level = params.level || 'titles';
    const result = await memoryService.listMemories(agentId, level);

    return {
      success: true,
      action: 'list',
      level,
      totalMemories: result.count,
      memoriesByDate: result.grouped,
      message: result.count === 0
        ? 'No memories found'
        : `Found ${result.count} memories`
    };
  }

  /**
   * Execute READ action
   */
  async _executeRead(memoryService, agentId, params) {
    const memory = await memoryService.readMemory(agentId, params.id);

    if (!memory) {
      return {
        success: false,
        action: 'read',
        message: `Memory not found: ${params.id}`
      };
    }

    // Plan/* memories: skip echoing content into the tool result —
    // the same content is already in the AGENT WORKING PLAN header
    // of the system prompt on this very turn. Echoing it would
    // double-tokenize the bulk and risk confusing the agent about
    // which copy to trust.
    if (isPlanTitle(memory.title)) {
      return {
        success: true,
        action: 'read',
        memory: {
          id: memory.id,
          title: memory.title,
          description: memory.description,
          // content intentionally omitted — see AGENT WORKING PLAN header
          createdAt: memory.createdAt,
          updatedAt: memory.updatedAt,
          expiration: memory.expiration,
          accessCount: memory.accessCount
        },
        message:
          `Plan "${memory.title}" — content is auto-injected at the bottom of your system prompt ` +
          `under AGENT WORKING PLAN, no need to re-read on subsequent turns.`
      };
    }

    return {
      success: true,
      action: 'read',
      memory: {
        id: memory.id,
        title: memory.title,
        description: memory.description,
        content: memory.content,
        createdAt: memory.createdAt,
        updatedAt: memory.updatedAt,
        expiration: memory.expiration,
        accessCount: memory.accessCount
      },
      message: `Memory loaded: "${memory.title}"`
    };
  }

  /**
   * Execute SEARCH action
   */
  async _executeSearch(memoryService, agentId, params) {
    const results = await memoryService.searchMemories(agentId, params.query);

    return {
      success: true,
      action: 'search',
      query: params.query,
      results,
      message: results.length === 0
        ? `No memories found matching "${params.query}"`
        : `Found ${results.length} memories matching "${params.query}"`
    };
  }

  /**
   * Execute SEMANTIC-SEARCH action — embeddings-backed retrieval over
   * the agent's memory. Falls back to substring search when embeddings
   * are disabled, so the action is always safe to call regardless of
   * configuration. Hybrid mode (semantic + substring via RRF) is the
   * default — preserves exact-match precision while adding paraphrase
   * recall.
   *
   * Params: { query, topK?=10, hybrid?=true }
   */
  async _executeSemanticSearch(memoryService, agentId, params) {
    const topK = Number.isInteger(params.topK) && params.topK > 0 ? params.topK : 10;
    const hybrid = params.hybrid !== false;

    const indexer = this._getMemoryIndexer();
    if (!indexer || !indexer.isEnabled) {
      // Graceful fallback: when the user hasn't enabled embeddings yet,
      // return substring results so the action is still useful (and the
      // agent doesn't need to branch on config).
      const results = await memoryService.searchMemories(agentId, params.query);
      return {
        success: true,
        action: 'semantic-search',
        mode: 'substring-fallback',
        query: params.query,
        results,
        message: results.length === 0
          ? `Embeddings disabled; substring search found no matches for "${params.query}"`
          : `Embeddings disabled; substring search found ${results.length} memories`,
      };
    }

    const { hits, error } = await indexer.search(agentId, params.query, { topK, hybrid });

    // Hydrate hits → full memory objects via memoryService.readMemory.
    // The indexer returns vector-store metadata (title/text) which is a
    // snapshot — fetching live ensures we surface the current content
    // (in case it was updated since the last index sync).
    const hydrated = [];
    for (const h of hits) {
      // eslint-disable-next-line no-await-in-loop
      const mem = await memoryService.readMemory(agentId, h.id);
      if (mem) hydrated.push({ ...mem, score: h.score });
      else hydrated.push({ id: h.id, score: h.score, missing: true, ...h.metadata });
    }

    return {
      success: !error,
      action: 'semantic-search',
      mode: hybrid ? 'hybrid' : 'semantic',
      query: params.query,
      topK,
      results: hydrated,
      ...(error ? { error } : {}),
      message: error
        ? `Semantic search failed: ${error}`
        : hydrated.length === 0
          ? `No memories matched "${params.query}"`
          : `Found ${hydrated.length} memories ranked by similarity to "${params.query}"`,
    };
  }

  /**
   * Execute BACKFILL-EMBEDDINGS action — drop the agent's memory index
   * and rebuild it from scratch. Use after enabling embeddings for the
   * first time, after switching providers/models, or whenever the index
   * is suspected stale. Idempotent.
   */
  async _executeBackfillEmbeddings(memoryService, agentId) {
    const indexer = this._getMemoryIndexer();
    if (!indexer || !indexer.isEnabled) {
      return {
        success: false,
        action: 'backfill-embeddings',
        message: 'Embeddings are not enabled — nothing to backfill. Enable a provider in Settings → Embeddings first.',
      };
    }

    // Pull every memory for this agent. memoryService.listMemories at
    // 'full' detail returns the full content blob per memory.
    const listing = await memoryService.listMemories(agentId, 'full');
    // listMemories returns { count, grouped }. The 'full' shape gives
    // us per-bucket arrays of memory objects. Flatten + filter.
    const memories = [];
    for (const bucket of Object.values(listing.grouped || {})) {
      for (const m of bucket || []) if (m?.id) memories.push(m);
    }

    const result = await indexer.backfill(agentId, memories);
    return {
      success: result.ok !== false,
      action: 'backfill-embeddings',
      indexed: result.indexed || 0,
      skipped: result.skipped || 0,
      failed: result.failed || 0,
      ...(result.error ? { error: result.error } : {}),
      message: result.ok === false
        ? `Backfill failed: ${result.reason || result.error || 'unknown'}`
        : `Re-indexed ${result.indexed || 0} memories for agent ${agentId}` +
          (result.skipped ? ` (skipped ${result.skipped} with no text)` : '') +
          (result.failed ? ` (failed ${result.failed})` : ''),
    };
  }

  /**
   * Execute FORGET-STALE action — delete memories older than `ageDays`.
   *
   * FadeMem-style soft pruning (arXiv 2601.18642): unbounded memory
   * growth is the real long-term problem. The right answer is graceful
   * decay of unused old memories so the index stays signal-rich.
   *
   * Defaults are conservative — `ageDays=90`, plan/* memories skipped.
   * Pass `dryRun: true` to preview what would be removed. The cascade
   * to the vector index is automatic (memoryService.deleteMemory fires
   * the indexer's deleteOne via _asyncRemoveFromIndex).
   *
   * Params: { ageDays?=90, dryRun?=false, includePlans?=false }
   * @private
   */
  async _executeForgetStale(memoryService, agentId, params) {
    const ageDays = Number.isFinite(params.ageDays) && params.ageDays > 0 ? params.ageDays : 90;
    const dryRun = !!params.dryRun;
    const includePlans = !!params.includePlans;
    const cutoffMs = Date.now() - ageDays * 24 * 60 * 60 * 1000;

    // Walk the full memory list. Use 'descriptions' detail — enough to
    // decide what's stale without pulling huge content blobs.
    const listing = await memoryService.listMemories(agentId, 'full');
    const candidates = [];
    for (const bucket of Object.values(listing.grouped || {})) {
      for (const m of bucket || []) {
        if (!m?.id || !m?.createdAt) continue;
        const createdMs = Date.parse(m.createdAt);
        if (!Number.isFinite(createdMs) || createdMs > cutoffMs) continue;
        // Skip plan/* memories unless explicitly included — they're the
        // agent's active working plan, not stale notes.
        if (!includePlans && isPlanTitle(m.title)) continue;
        candidates.push({
          id: m.id,
          title: m.title,
          createdAt: m.createdAt,
          ageDays: Math.round((Date.now() - createdMs) / (24 * 60 * 60 * 1000)),
        });
      }
    }

    if (dryRun) {
      return {
        success: true,
        action: 'forget-stale',
        dryRun: true,
        thresholdDays: ageDays,
        candidates,
        count: candidates.length,
        message: candidates.length === 0
          ? `No memories older than ${ageDays} days.`
          : `Would forget ${candidates.length} memories older than ${ageDays} days. Pass dryRun:false to apply.`,
      };
    }

    let forgotten = 0;
    let failed = 0;
    for (const c of candidates) {
      try {
        const ok = await memoryService.deleteMemory(agentId, c.id);
        if (ok) forgotten++;
        else failed++;
      } catch (err) {
        this.logger?.warn?.('[memoryTool] forget-stale: delete failed', { id: c.id, error: err?.message });
        failed++;
      }
    }

    return {
      success: failed === 0,
      action: 'forget-stale',
      thresholdDays: ageDays,
      forgotten,
      failed,
      message: forgotten === 0 && failed === 0
        ? `No memories older than ${ageDays} days.`
        : `Forgot ${forgotten} memories older than ${ageDays} days` +
          (failed ? `, ${failed} deletions failed` : '') + '.',
    };
  }

  // ──────────────────────────────────────────────────────────────────
  // Phase 6c — Team-pool memory (the explicit publish channel)
  //
  // Answers the design Q1: per-agent memory stays private by default;
  // teams are an explicit, ACL-gated sharing surface. Lit-aligned with
  // arXiv 2505.18279 "Collaborative Memory" (dual-tier private + shared
  // with read/write policies). Pattern matches Mem0's multi-level
  // scoping (user_id / agent_id / app_id) — we use Loxia's existing
  // Teams primitive as the natural sharing boundary.
  //
  // ACL: agent must be a member of the team. Membership is read from
  // stateManager.getTeam(teamId).memberAgentIds — the authoritative
  // source. We refuse rather than warn so accidental cross-team leaks
  // are impossible. The check is encapsulated in `_assertTeamMember`.
  // ──────────────────────────────────────────────────────────────────

  /**
   * Allow callers to inject a StateManager so ACL lookups can resolve
   * team membership. Mirrors the platformControl pattern. Without it,
   * team-scoped actions all fail with a clean error rather than
   * defaulting open.
   */
  setStateManager(stateManager) {
    this.stateManager = stateManager;
    this.logger?.info?.(`[memory] StateManager ${stateManager ? 'attached' : 'detached'}`);
  }

  /**
   * Read-only ACL: throws an Error with a user-facing message if the
   * agent isn't a member of the team. Use inside team-scoped handlers
   * to gate writes and reads alike.
   * @private
   */
  async _assertTeamMember(agentId, teamId) {
    if (!this.stateManager?.getTeam) {
      throw new Error('Team-scoped memory actions require a StateManager. None is attached.');
    }
    const team = await this.stateManager.getTeam(teamId);
    if (!team) {
      throw new Error(`Team not found: ${teamId}`);
    }
    const members = Array.isArray(team.memberAgentIds) ? team.memberAgentIds : [];
    if (!members.includes(agentId)) {
      throw new Error(
        `Access denied: agent ${agentId} is not a member of team ${teamId}. ` +
        `Ask a team admin to add you via platformcontrol.add-team-member.`,
      );
    }
    return team;
  }

  /**
   * Publish an existing per-agent memory to a team's shared pool. The
   * memory itself stays in the agent's own pool — `publish` copies its
   * content into the team's vector index with `publishedBy` metadata.
   *
   * Params: { id, teamId }
   * @private
   */
  async _executePublishToTeam(memoryService, agentId, params) {
    const indexer = this._getMemoryIndexer();
    if (!indexer || !indexer.isEnabled) {
      return {
        success: false,
        action: 'publish-to-team',
        message: 'Embeddings are not enabled. Team-pool memory requires a configured embedding provider — enable one in Settings → Embeddings first.',
      };
    }

    let team;
    try { team = await this._assertTeamMember(agentId, params.teamId); }
    catch (err) {
      return { success: false, action: 'publish-to-team', error: err.message };
    }

    const memory = await memoryService.readMemory(agentId, params.id);
    if (!memory) {
      return {
        success: false,
        action: 'publish-to-team',
        error: `Memory not found in your pool: ${params.id}. publish-to-team copies an existing personal memory into the team pool.`,
      };
    }

    const result = await indexer.publishToTeam(params.teamId, memory, agentId);
    return {
      success: result.indexed === true,
      action: 'publish-to-team',
      teamId: params.teamId,
      teamName: team?.name || params.teamId,
      memoryId: memory.id,
      ...(result.error ? { error: result.error } : {}),
      message: result.indexed
        ? `Published "${memory.title}" to team "${team?.name || params.teamId}".`
        : `Publish failed: ${result.reason || result.error || 'unknown'}`,
    };
  }

  /**
   * Semantic (or hybrid) search over a team's shared memory pool.
   * Requires team membership — otherwise no read.
   *
   * Params: { teamId, query, topK?=10, hybrid?=true }
   * @private
   */
  async _executeSearchTeam(agentId, params) {
    const indexer = this._getMemoryIndexer();
    if (!indexer || !indexer.isEnabled) {
      return {
        success: false,
        action: 'search-team',
        message: 'Embeddings are not enabled. Team-pool memory requires a configured embedding provider.',
      };
    }

    let team;
    try { team = await this._assertTeamMember(agentId, params.teamId); }
    catch (err) {
      return { success: false, action: 'search-team', error: err.message };
    }

    const topK = Number.isInteger(params.topK) && params.topK > 0 ? params.topK : 10;
    const hybrid = params.hybrid !== false;
    const { hits, error } = await indexer.searchTeam(params.teamId, params.query, { topK, hybrid });
    return {
      success: !error,
      action: 'search-team',
      teamId: params.teamId,
      teamName: team?.name || params.teamId,
      query: params.query,
      mode: hybrid ? 'hybrid' : 'semantic',
      topK,
      results: hits,
      ...(error ? { error } : {}),
      message: error
        ? `Team search failed: ${error}`
        : hits.length === 0
          ? `No memories matched "${params.query}" in team "${team?.name || params.teamId}".`
          : `Found ${hits.length} team memories ranked by similarity to "${params.query}".`,
    };
  }

  /**
   * Remove a memory from a team pool. Any team member can unpublish
   * (no per-author restriction at this layer — keeps the ACL surface
   * simple; if richer policies are needed later, layer them here).
   *
   * Params: { teamId, id }
   * @private
   */
  async _executeUnpublishFromTeam(agentId, params) {
    const indexer = this._getMemoryIndexer();
    if (!indexer || !indexer.isEnabled) {
      return {
        success: false,
        action: 'unpublish-from-team',
        message: 'Embeddings are not enabled. Team-pool memory requires a configured embedding provider.',
      };
    }

    let team;
    try { team = await this._assertTeamMember(agentId, params.teamId); }
    catch (err) {
      return { success: false, action: 'unpublish-from-team', error: err.message };
    }

    const result = await indexer.unpublishFromTeam(params.teamId, params.id);
    return {
      success: result.deleted === true,
      action: 'unpublish-from-team',
      teamId: params.teamId,
      teamName: team?.name || params.teamId,
      memoryId: params.id,
      ...(result.error ? { error: result.error } : {}),
      message: result.deleted
        ? `Removed memory ${params.id} from team "${team?.name || params.teamId}".`
        : `Unpublish failed: ${result.reason || result.error || 'unknown'}`,
    };
  }

  /**
   * Lazy accessor for the per-tool MemoryIndexer.
   *
   * Why lazy: the EmbeddingService is attached to aiService AFTER
   * tools are constructed. Pulling it on first use lets us avoid
   * construction-time ordering pain.
   *
   * Returns null when no embedding service is available (cloud-only
   * deploys without local provider, brand-new installs, etc.). Callers
   * MUST check `indexer.isEnabled` before relying on it — the indexer
   * is a thin shim that itself checks the service's enabled flag.
   *
   * @private
   * @returns {import('../services/embeddings/indexers/memoryIndexer.js').MemoryIndexer | null}
   */
  _getMemoryIndexer() {
    if (this._memoryIndexer) return this._memoryIndexer;
    const svc = this.aiService?.getEmbeddingService?.();
    if (!svc) return null;
    this._memoryIndexer = new MemoryIndexer({ embeddingService: svc, logger: this.logger });
    return this._memoryIndexer;
  }

  /**
   * Fire-and-forget memory index update. Errors are logged inside the
   * indexer; we additionally swallow any thrown error here so a runtime
   * surprise (broken provider, full disk) NEVER affects the memory write.
   * @private
   */
  _asyncIndexMemory(agentId, memory) {
    const indexer = this._getMemoryIndexer();
    if (!indexer || !indexer.isEnabled) return;
    // Don't await — the result is consumed only via the indexer's own
    // log. We bury the promise with .catch() so Node doesn't crash on
    // unhandled rejection if something exotic blows up.
    indexer.indexOne(agentId, memory).catch(err => {
      this.logger?.warn?.('[memoryTool] async index failed', { agentId, id: memory?.id, error: err?.message });
    });
  }

  /**
   * Fire-and-forget removal from the index after a delete.
   * @private
   */
  _asyncRemoveFromIndex(agentId, memoryId) {
    const indexer = this._getMemoryIndexer();
    if (!indexer || !indexer.isEnabled) return;
    indexer.deleteOne(agentId, memoryId).catch(err => {
      this.logger?.warn?.('[memoryTool] async deleteFromIndex failed', { agentId, id: memoryId, error: err?.message });
    });
  }

  /**
   * Lazy accessor for the per-tool ReminisceIndexer. Same lifetime model
   * as `_getMemoryIndexer`: cached on the tool instance after first use.
   * Returns null when the embedding service isn't attached.
   *
   * @private
   * @returns {import('../services/embeddings/indexers/reminisceIndexer.js').ReminisceIndexer | null}
   */
  _getReminisceIndexer() {
    if (this._reminisceIndexer) return this._reminisceIndexer;
    const svc = this.aiService?.getEmbeddingService?.();
    if (!svc) return null;
    this._reminisceIndexer = new ReminisceIndexer({ embeddingService: svc, logger: this.logger });
    return this._reminisceIndexer;
  }

  /**
   * Reminisce semantic-search handler. Routes through the
   * ReminisceIndexer; falls back to the existing substring-based
   * `reminisce.search` when embeddings aren't configured.
   *
   * Params: { query, topK?=10, hybrid?=true }
   * @private
   */
  async _executeReminisceSemanticSearch(agentId, params, context) {
    const query = typeof params.query === 'string' ? params.query.trim() : '';
    if (!query) {
      return {
        success: false,
        action: 'reminisce',
        mode: 'semantic-search',
        error: 'query is required for reminisce semantic-search',
      };
    }
    const topK = Number.isInteger(params.topK) && params.topK > 0 ? params.topK : 10;
    const hybrid = params.hybrid !== false;

    const indexer = this._getReminisceIndexer();
    if (!indexer || !indexer.isEnabled) {
      // Graceful fallback to the existing substring path so the action
      // remains useful when embeddings are off. Mirrors memoryTool's
      // semantic-search fallback shape for consistency.
      const fb = await this._executeReminisce(agentId, { ...params, mode: 'search' }, context);
      return {
        ...fb,
        mode: 'semantic-search',
        modeUsed: 'substring-fallback',
        message: 'Embeddings disabled; returned substring search results instead.',
      };
    }

    // Run the vector search.
    const { hits, error } = await indexer.search(agentId, query, { topK, hybrid });

    // Hydrate scored messageIds back to full messages from the archive,
    // so the agent sees `role`, `content`, `createdAt`, etc. — the same
    // shape it gets from `reminisce.search`.
    const agentPool = context?.agentPool;
    let messages = null;
    if (agentPool?.getAgent) {
      const agent = await agentPool.getAgent(agentId);
      messages = Array.isArray(agent?.conversations?.full?.messages) ? agent.conversations.full.messages : null;
    }
    const byId = new Map((messages || []).map(m => [m?.id, m]));
    const hydrated = hits.map(h => {
      const m = byId.get(h.messageId);
      const base = {
        messageId: h.messageId,
        score: h.score,
        chunkIndex: h.chunkIndex,
        // Default to the index-snapshot text when the live message has
        // gone missing (rare — only if archive was pruned).
        snippet: h.chunkText,
      };
      if (!m) return { ...base, missing: true };
      return {
        ...base,
        role: m.role || 'unknown',
        at: m.createdAt || m.timestamp || null,
        content: typeof m.content === 'string' ? m.content : '',
      };
    });

    return {
      success: !error,
      action: 'reminisce',
      mode: 'semantic-search',
      modeUsed: hybrid ? 'hybrid' : 'semantic',
      query,
      topK,
      result: {
        hits: hydrated,
        ...(error ? { error } : {}),
      },
      message: error
        ? `Reminisce semantic search failed: ${error}`
        : hydrated.length === 0
          ? `No archived messages matched "${query}"`
          : `Found ${hydrated.length} archived messages ranked by similarity to "${query}"`,
    };
  }

  /**
   * Drop the agent's reminisce index and rebuild from the full
   * conversation archive. Use after first enabling embeddings, or after
   * switching provider/model. Idempotent.
   *
   * NOTE: this is the canonical entry point for keeping reminisce
   * semantic-search current today. Auto-indexing on new archive writes
   * is a planned follow-up; until then, re-run this periodically (or
   * via a scheduled task) to pick up the latest messages.
   *
   * @private
   */
  async _executeReminisceBackfill(agentId, context) {
    const indexer = this._getReminisceIndexer();
    if (!indexer || !indexer.isEnabled) {
      return {
        success: false,
        action: 'reminisce-backfill-embeddings',
        message: 'Embeddings are not enabled — nothing to backfill. Enable a provider in Settings → Embeddings first.',
      };
    }

    const agentPool = context?.agentPool;
    if (!agentPool?.getAgent) {
      return {
        success: false,
        action: 'reminisce-backfill-embeddings',
        error: 'agentPool is not available on the tool context; cannot read conversation archive',
      };
    }
    const agent = await agentPool.getAgent(agentId);
    const messages = Array.isArray(agent?.conversations?.full?.messages) ? agent.conversations.full.messages : [];

    const result = await indexer.backfill(agentId, messages);
    return {
      success: result.ok !== false && !result.error,
      action: 'reminisce-backfill-embeddings',
      indexedMessages: result.indexedMessages || 0,
      indexedChunks: result.indexedChunks || 0,
      skipped: result.skipped || 0,
      failed: result.failed || 0,
      ...(result.error ? { error: result.error } : {}),
      message: result.ok === false
        ? `Reminisce backfill failed: ${result.reason || result.error || 'unknown'}`
        : `Re-indexed ${result.indexedMessages || 0} messages` +
          (result.indexedChunks ? ` (${result.indexedChunks} chunks)` : '') +
          (result.skipped ? `, skipped ${result.skipped}` : '') +
          (result.failed ? `, failed ${result.failed}` : ''),
    };
  }

  /**
   * Execute STATS action
   */
  async _executeStats(memoryService, agentId) {
    const stats = await memoryService.getMemoryStats(agentId);

    return {
      success: true,
      action: 'stats',
      stats,
      message: `Memory statistics: ${stats.totalMemories} total memories, ${stats.totalAccessCount} total accesses`
    };
  }

  /**
   * Execute REMINISCE action — read-only query over the agent's
   * pre-compaction conversation archive (conversations.full.messages).
   *
   * Unlike the other memory actions, reminisce does NOT touch memoryService.
   * It's a lens over a different dataset: the conversation history that
   * stateManager preserves durably on disk regardless of compaction. The
   * agent uses it to pull specific spans of past context back into the
   * current turn when they've fallen out of the active context window.
   *
   * Pointer contract: `messageId` values returned here are stable across
   * session growth and compaction — an agent can bookmark one now and
   * resolve it in a much later turn (see services/conversationQuery.js).
   *
   * @param {string} agentId
   * @param {Object} params
   * @param {'overview'|'range'|'search'|'around'|'byTool'} params.mode
   * @param {Object} context  Must carry `agentPool` to fetch the agent's
   *                          conversation. If missing, returns a clean
   *                          failure (not a crash).
   */
  async _executeReminisce(agentId, params, context) {
    const mode = typeof params.mode === 'string' ? params.mode : '';
    const validModes = ['overview', 'range', 'search', 'semantic-search', 'around', 'byTool', 'read'];
    if (!validModes.includes(mode)) {
      return {
        success: false,
        action: 'reminisce',
        error: `mode must be one of: ${validModes.join(', ')}`,
      };
    }

    // Semantic-search has a different shape than the other modes — it
    // routes through the ReminisceIndexer (vector store) rather than the
    // conversation-query helpers. Falls back to substring `search` when
    // embeddings are disabled so the action is always callable.
    if (mode === 'semantic-search') {
      return await this._executeReminisceSemanticSearch(agentId, params, context);
    }

    const agentPool = context?.agentPool;
    if (!agentPool?.getAgent) {
      return {
        success: false,
        action: 'reminisce',
        error: 'agentPool is not available on the tool context; cannot read conversation archive',
      };
    }

    const agent = await agentPool.getAgent(agentId);
    if (!agent) {
      return {
        success: false,
        action: 'reminisce',
        error: `Agent not found: ${agentId}`,
      };
    }

    // Always read from conversations.full — the durable archive that
    // survives every compaction pass. Never from per-model views.
    const messages = agent?.conversations?.full?.messages;
    if (!Array.isArray(messages)) {
      return {
        success: true,
        action: 'reminisce',
        mode,
        result: { empty: true, message: 'No conversation archive found for this agent.' },
      };
    }

    // Pass only the params relevant to each mode — conversationQuery's
    // per-mode validators clamp/default the rest.
    const modeParams = {
      from: params.from, to: params.to,
      offset: params.offset, limit: params.limit,
      query: params.query, role: params.role,
      maxResults: params.maxResults, cursor: params.cursor,
      messageId: params.messageId, before: params.before, after: params.after,
      toolId: params.toolId, detail: params.detail,
      // read-only window selectors (sub-message granularity)
      lineFrom: params.lineFrom, lineTo: params.lineTo,
      contentFrom: params.contentFrom, contentTo: params.contentTo,
      // Opt-in reasoning-content inclusion (default off — chain-of-thought
      // text can be very long; agents ask for it only when they actually
      // want to inspect what the model was thinking).
      includeReasoning: !!params.includeReasoning,
    };

    let result;
    switch (mode) {
      case 'overview': result = convOverview(messages); break;
      case 'range':    result = convRange(messages, modeParams); break;
      case 'search':   result = convSearch(messages, modeParams); break;
      case 'around':   result = convAround(messages, modeParams); break;
      case 'byTool':   result = convByTool(messages, modeParams); break;
      case 'read':     result = convRead(messages, modeParams); break;
    }

    return {
      success: true,
      action: 'reminisce',
      mode,
      result,
    };
  }

  /**
   * Get supported actions
   * @returns {Array<string>}
   */
  getSupportedActions() {
    return ['add', 'update', 'delete', 'list', 'read', 'search', 'stats', 'reminisce'];
  }

  /**
   * Get parameter schema
   * @returns {Object}
   */
  getParameterSchema() {
    return {
      type: 'object',
      properties: {
        action: {
          type: 'string',
          enum: ['add', 'update', 'delete', 'list', 'read', 'search', 'stats', 'reminisce'],
          description: 'Action to perform'
        },
        id: {
          type: 'string',
          description: 'Memory ID (required for update, delete, read)'
        },
        title: {
          type: 'string',
          maxLength: 200,
          description: 'Memory title (required for add)'
        },
        description: {
          type: 'string',
          maxLength: 500,
          description: 'One-line description'
        },
        content: {
          type: 'string',
          maxLength: 10000,
          description: 'Full memory content (required for add)'
        },
        expiration: {
          oneOf: [
            { type: 'null' },
            { type: 'string' }
          ],
          description: 'Expiration date (ISO) or condition string'
        },
        level: {
          type: 'string',
          enum: ['titles', 'descriptions', 'full'],
          description: 'Detail level for list action'
        },
        query: {
          type: 'string',
          description: 'Search query (required for memory search AND reminisce search)'
        },

        // ── reminisce-specific parameters ────────────────────────────────
        // Required when action="reminisce"; ignored otherwise.
        mode: {
          type: 'string',
          enum: ['overview', 'range', 'search', 'around', 'byTool', 'read'],
          description: 'Reminisce sub-mode (required for action="reminisce")'
        },
        messageId: {
          type: 'string',
          description: 'Stable conversation-archive pointer. Required for reminisce around/read.'
        },
        from: { type: 'string', description: 'ISO timestamp lower bound (reminisce range)' },
        to:   { type: 'string', description: 'ISO timestamp upper bound (reminisce range)' },
        offset:     { type: 'integer', description: 'Pagination offset (reminisce range)' },
        limit:      { type: 'integer', description: 'Max entries (reminisce range/byTool), 1–100' },
        maxResults: { type: 'integer', description: 'Max search matches (reminisce search), 1–50' },
        role: {
          type: 'string',
          enum: ['user', 'assistant', 'system'],
          description: 'Role filter for reminisce search'
        },
        cursor: { type: 'string', description: 'Opaque pagination cursor (reminisce search/byTool) — pass verbatim from a previous response' },
        toolId: { type: 'string', description: 'Filter by toolId (reminisce byTool)' },
        before: { type: 'integer', description: 'Messages BEFORE the target (reminisce around), 0–20' },
        after:  { type: 'integer', description: 'Messages AFTER the target (reminisce around), 0–20' },
        lineFrom:    { type: 'integer', description: 'First line (reminisce read), 1-indexed inclusive' },
        lineTo:      { type: 'integer', description: 'Last line (reminisce read), 1-indexed inclusive; window ≤ 500 lines' },
        contentFrom: { type: 'integer', description: 'Char offset start (reminisce read), 0-indexed inclusive. Ignored if lineFrom is set.' },
        contentTo:   { type: 'integer', description: 'Char offset end (reminisce read), 0-indexed exclusive; window ≤ 16000 chars' },
        detail: {
          type: 'string',
          enum: ['default', 'full'],
          description: 'Reminisce return shape: "default" (truncated/slim) or "full" (everything)'
        }
      },
      required: ['action']
    };
  }

  /**
   * Get tool capabilities
   * @returns {Object}
   */
  getCapabilities() {
    const baseCapabilities = super.getCapabilities();

    return {
      ...baseCapabilities,
      persistent: true,
      actions: this.getSupportedActions(),
      useCases: [
        'user-preferences',
        'project-context',
        'important-decisions',
        'learned-patterns',
        'task-history'
      ]
    };
  }
}

export default MemoryTool;
