#!/usr/bin/env node
/**
 * agentmemory-recall-trigger.mjs
 *
 * PreToolUse hook for Claude Code — derives a prompt-context from the
 * incoming tool event, POSTs to the bridge `/classify/recall` endpoint
 * (Phase C2), and injects the top-3 results as additionalContext so
 * the agent has relevant memory before executing the tool call.
 *
 * Hot-path SLO: p99 latency < 500ms (Phase A plan P1 row). The 500ms
 * budget covers the bridge round-trip + the bridge → container
 * memory_recall hop. Fail-open: any error exits 0 with no stdout —
 * never blocks the tool loop.
 *
 * cwd → store routing (T4 mitigation):
 *   Derived from process.cwd() via the same regex pattern the existing
 *   SessionStart agentmemory-session-recall.js hook uses. The bridge
 *   URL itself is per-store (operators set AGENTMEMORY_BRIDGE_URL per
 *   project / per store) so cross-store leak is impossible from this
 *   hook — the bridge it talks to is single-store by configuration.
 *
 * Install (operator runs manually):
 *   $ cp packages/memory-service/hooks/agentmemory-recall-trigger.mjs \
 *        ~/.claude/scripts/hooks/agentmemory-recall-trigger.mjs
 *   $ packages/memory-service/scripts/install-recall-hook.sh
 *   # the install script does the cp + the settings.json edit + cp BACKUP first
 *
 * Plan reference:
 *   docs/plans/agentmemory-classifier-and-regret/phase-c-tasks.md C3
 */

import { readFileSync, realpathSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { isAgentMemoryEnabled } from './agent-memory-gate.mjs';
import { resolveAgentMemoryBearer } from './agent-memory-bearer.mjs';

// Built-in tool-event filter: only fire on tool calls where recall has
// signal. Bash + Edit + MultiEdit + Read on substantive paths qualify;
// TodoWrite + Glob + Grep are too lightweight to justify the recall
// round-trip.
const RECALL_WORTHY_TOOL_NAMES = new Set([
  'Bash', 'Edit', 'MultiEdit', 'Write', 'Read', 'NotebookEdit',
]);

// Default to the LIVE memory-service Worker — the same host the remember/write
// path targets, so recall reads where the fleet writes. The prior default
// (olam-agent-memory.ernestcodes.workers.dev) is a dead host (HTTP 404), which
// made recall reach nothing. env still overrides for per-store / wrangler-dev.
// env wins → `olam memory connect` artifact → live default. See
// packages/cli/src/lib/memory-connection.ts (keep precedence in sync).
function resolveBridgeUrlDefault() {
  if (process.env.AGENTMEMORY_BRIDGE_URL) return process.env.AGENTMEMORY_BRIDGE_URL;
  try {
    const conn = JSON.parse(
      readFileSync(join(homedir(), '.olam', 'memory-connection.json'), 'utf8'),
    );
    if (conn && typeof conn.url === 'string' && conn.url) return conn.url;
  } catch {
    // artifact absent / malformed — fall through
  }
  return 'https://atlas-agent-memory.atlas-kitchen.workers.dev';
}
const BRIDGE_URL = resolveBridgeUrlDefault();

// Bearer resolution (first match wins; no literal embedded):
//   env AGENTMEMORY_BRIDGE_SECRET → env OLAM_AGENT_MEMORY_BEARER
//   → file bridge-secret → file agent-memory-bearer → '' (no-op fail-open).
// Shared with the SAVE hook via agent-memory-bearer.mjs so a single
// `olam credentials pull` lights up BOTH recall and capture. Empty string
// short-circuits callBridge() to a fail-open no-op so the hook never blocks
// the tool loop on a missing secret.
const BEARER = resolveAgentMemoryBearer();
const RECALL_TIMEOUT_MS = 1500;

/**
 * Derive the recall prompt from a Claude Code PreToolUse event. Each
 * tool gets a tailored prompt shape:
 *   Bash      → `$ <command>`
 *   Edit      → `edit <file_path>: <truncated new_string>`
 *   MultiEdit → `multi-edit <file_path>`
 *   Write     → `wrote <file_path>`
 *   Read      → `read <file_path>`
 *   default   → JSON.stringify(tool_input).slice(0, 200)
 */
export function derivePrompt(event) {
  if (!event || typeof event !== 'object') return '';
  const toolName = event.tool_name;
  const ti = event.tool_input ?? event.toolInput ?? null;
  if (!RECALL_WORTHY_TOOL_NAMES.has(toolName)) return '';

  if (toolName === 'Bash') {
    const cmd = ti?.command;
    return typeof cmd === 'string' ? `$ ${cmd}` : '';
  }
  if (toolName === 'Edit') {
    const fp = ti?.file_path ?? '';
    const ns = typeof ti?.new_string === 'string'
      ? ti.new_string.slice(0, 200)
      : '';
    return `edit ${fp}: ${ns}`;
  }
  if (toolName === 'MultiEdit') {
    return `multi-edit ${ti?.file_path ?? ''}`;
  }
  if (toolName === 'Write') {
    return `wrote ${ti?.file_path ?? ''}`;
  }
  if (toolName === 'Read') {
    return `read ${ti?.file_path ?? ''}`;
  }
  if (toolName === 'NotebookEdit') {
    return `edit ${ti?.notebook_path ?? ''}`;
  }
  return '';
}

/**
 * Call the bridge /classify/recall endpoint. Returns the parsed
 * response body or null on any failure (fail-open).
 */
async function callBridge(prompt, cwd) {
  if (!prompt || !BRIDGE_URL || !BEARER) return null;
  try {
    const res = await fetch(`${BRIDGE_URL}/classify/recall`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${BEARER}`,
        'content-type': 'application/json',
      },
      body: JSON.stringify({ prompt, cwd, limit: 3 }),
      signal: AbortSignal.timeout(RECALL_TIMEOUT_MS),
    });
    if (!res.ok) return null;
    return await res.json();
  } catch (err) {
    // Fail-open: one-line breadcrumb to stderr, then null (no output, no block).
    process.stderr.write(`[agentmemory-recall-trigger] recall failed: ${err?.message ?? err}\n`);
    return null;
  }
}

/**
 * Format the recall response as a markdown additionalContext block
 * for hookSpecificOutput. Returns empty string when no results so the
 * hook emits no output and the agent sees no noise.
 */
export function formatAdditionalContext(response, prompt) {
  if (!response || !Array.isArray(response.results) || response.results.length === 0) {
    return '';
  }
  const lines = [
    `## Recalled from agent memory (prompt: "${prompt.slice(0, 80)}${prompt.length > 80 ? '…' : ''}")`,
    '',
  ];
  for (const r of response.results) {
    const title = r.title || r.id || '(untitled)';
    const score = typeof r.score === 'number' ? r.score.toFixed(1) : '?';
    const conv = typeof r.conviction === 'number' ? r.conviction : '?';
    const sim = typeof r.similarity === 'number' ? r.similarity.toFixed(2) : '?';
    lines.push(`- [score ${score} | conv ${conv} | sim ${sim}] ${title}`);
    if (r.contentExcerpt) {
      lines.push(`  ${r.contentExcerpt}`);
    }
  }
  return lines.join('\n');
}

async function main() {
  // Gate: memory is off by default — exit silently when not enabled.
  if (!isAgentMemoryEnabled()) return;

  let event = {};
  try {
    event = JSON.parse(readFileSync(0, 'utf-8'));
  } catch {
    return; // unparseable stdin → fail-open
  }

  const prompt = derivePrompt(event);
  if (!prompt) return;

  const response = await callBridge(prompt, event.cwd ?? process.cwd());
  const additionalContext = formatAdditionalContext(response, prompt);
  if (!additionalContext) return;

  const count = response?.results?.length ?? 0;
  process.stderr.write(
    `\x1b[34m[🧠⇣ Memory recalled]\x1b[0m (${count} memories · "${prompt.slice(0, 60)}${prompt.length > 60 ? '…' : ''}")\n`
  );

  process.stdout.write(JSON.stringify({
    hookSpecificOutput: {
      hookEventName: 'PreToolUse',
      additionalContext,
    },
  }));
}

// Run main only when invoked directly (allows test files to import the
// pure functions above without firing the hook side-effects). Compare
// REAL paths (not raw argv vs file://) so the hook still fires when
// resolved through a symlink — toolbox sync mounts these scripts via
// $HOME/.claude/scripts/agentmemory-classifier/ which is a symlink to the
// canonical atlas-toolbox path. Pre-fix, the raw-string compare missed
// the symlink and main() never ran.
function isDirectInvocation() {
  try {
    const argvPath = process.argv[1] ? realpathSync(process.argv[1]) : '';
    const metaPath = realpathSync(fileURLToPath(import.meta.url));
    return argvPath === metaPath;
  } catch {
    return false;
  }
}
if (isDirectInvocation()) {
  main().catch(() => process.exit(0));
}
