import {
  launchProcessSync
} from "./chunk-OR64ZGRZ.js";
import {
  mergeEnv,
  readEnvVar
} from "./chunk-MARWOCVP.js";
import {
  log
} from "./chunk-2ODBA7MQ.js";

// src/local-llm.ts
import { existsSync, readFileSync } from "fs";
import os from "os";
function trimTrailingSlashes(s) {
  let end = s.length;
  while (end > 0 && s[end - 1] === "/") end--;
  return s.substring(0, end);
}
var THINKING_COMPATIBLE_BACKENDS = /* @__PURE__ */ new Set([
  "lmstudio",
  "vllm"
]);
var LOCAL_SERVERS = [
  {
    type: "ollama",
    defaultPort: 11434,
    healthEndpoint: "/",
    modelsEndpoint: "/api/tags",
    detectFn: (resp) => typeof resp === "string" && resp.includes("Ollama")
  },
  {
    type: "mlx",
    defaultPort: 8080,
    healthEndpoint: "/v1/models",
    modelsEndpoint: "/v1/models",
    detectFn: (resp) => typeof resp === "object" && resp !== null && "data" in resp && Array.isArray(resp.data)
  },
  {
    type: "lmstudio",
    defaultPort: 1234,
    healthEndpoint: "/v1/models",
    modelsEndpoint: "/v1/models",
    detectFn: (resp) => typeof resp === "object" && resp !== null && "data" in resp && Array.isArray(resp.data)
  },
  {
    type: "vllm",
    defaultPort: 8e3,
    healthEndpoint: "/health",
    modelsEndpoint: "/v1/models",
    detectFn: (resp) => resp === "" || typeof resp === "object" && resp !== null
  }
];
var LOCAL_LLM_GLOBAL_BACKEND_STATE = "__openclawEngramLocalLlmBackendState";
var LocalLlmClient = class _LocalLlmClient {
  config;
  isAvailable = null;
  lastHealthCheck = 0;
  detectedType = null;
  cachedModelInfo = null;
  cachedLmsContext = null;
  lastLmsCheck = 0;
  consecutive400s = 0;
  cooldownUntilMs = 0;
  modelRegistry;
  _disableThinking = false;
  requestQueues = {
    "recall-critical": [],
    background: []
  };
  queueProcessing = /* @__PURE__ */ new Set();
  queueDrainScheduled = false;
  static HEALTH_CHECK_INTERVAL_MS = 6e4;
  // 1 minute
  static LMS_CACHE_INTERVAL_MS = 3e4;
  // 30 seconds
  constructor(config, modelRegistry) {
    this.config = config;
    this.modelRegistry = modelRegistry;
  }
  /**
   * Request thinking/reasoning suppression on the next chat completion.
   *
   * When `true`, the client will inject
   * `chat_template_kwargs: { enable_thinking: false }` into the request
   * body — **but only when the detected backend is known to support it**
   * (LM Studio, vLLM; see `THINKING_COMPATIBLE_BACKENDS`).  Strict
   * OpenAI-compat backends reject unknown fields with 400; on those the
   * client fails open (thinking runs normally).  This is the safe
   * default for Remnic extraction / consolidation: measurable latency
   * win on thinking-capable backends, zero risk on others.  Issue #548.
   */
  set disableThinking(value) {
    this._disableThinking = value;
  }
  resolveHomeDir() {
    return this.config.localLlmHomeDir || readEnvVar("HOME") || os.homedir();
  }
  buildRequestHeaders(base = {}) {
    const headers = {
      ...base,
      ...this.config.localLlmHeaders ?? {}
    };
    if (this.config.localLlmApiKey && this.config.localLlmAuthHeader !== false) {
      headers.Authorization = `Bearer ${this.config.localLlmApiKey}`;
    }
    return headers;
  }
  isAbortError(err) {
    if (!err || typeof err !== "object") return false;
    const maybe = err;
    return maybe.name === "AbortError" || maybe.message === "This operation was aborted" || maybe.message === "The operation was aborted";
  }
  /**
   * Set the ModelRegistry for caching detected capabilities
   */
  setModelRegistry(registry) {
    this.modelRegistry = registry;
  }
  /**
   * Get the detected server type (null if not detected)
   */
  getDetectedType() {
    return this.detectedType;
  }
  getBackendKey() {
    return trimTrailingSlashes(
      this.config.localLlmUrl.replace("localhost", "127.0.0.1")
    ).replace(/\/v1$/, "");
  }
  getGlobalBackendState() {
    const globalAny = globalThis;
    if (!globalAny[LOCAL_LLM_GLOBAL_BACKEND_STATE]) {
      globalAny[LOCAL_LLM_GLOBAL_BACKEND_STATE] = /* @__PURE__ */ new Map();
    }
    return globalAny[LOCAL_LLM_GLOBAL_BACKEND_STATE];
  }
  getTrippedBackendState(now) {
    const state = this.getGlobalBackendState().get(this.getBackendKey()) ?? null;
    if (!state) return null;
    if (state.untilMs <= now) {
      this.getGlobalBackendState().delete(this.getBackendKey());
      this.lastHealthCheck = 0;
      return null;
    }
    return state;
  }
  markBackendUnavailable(reason, durationMs) {
    const normalizedReason = this.normalizeBackendTripReason(reason);
    if (durationMs > 0) {
      const untilMs = Date.now() + durationMs;
      this.getGlobalBackendState().set(this.getBackendKey(), { untilMs, reason: normalizedReason });
    } else {
      this.getGlobalBackendState().delete(this.getBackendKey());
    }
    this.isAvailable = false;
    this.lastHealthCheck = 0;
    log.warn(
      `local LLM backend unavailable for ${durationMs}ms: model=${this.config.localLlmModel} reason=${normalizedReason}`
    );
  }
  extractNonRecoverableBackendReason(reason) {
    const match = reason.match(
      /Failed to load model|Library not loaded|different Team IDs|code signature|llm_engine_mlx_amphibian/i
    );
    return match?.[0] ?? null;
  }
  extractNonRecoverableBackendReasonFromErrorText(errorText) {
    const directReason = this.extractNonRecoverableBackendReason(errorText);
    if (directReason) return directReason;
    try {
      const parsed = JSON.parse(errorText);
      return this.extractNonRecoverableBackendReason(parsed?.error?.message ?? "");
    } catch {
      return null;
    }
  }
  normalizeBackendTripReason(reason) {
    const cleaned = reason.replace(/\s+/g, " ").replace(/^[-:–—\s]+/, "").trim();
    if (!cleaned) return "unknown local backend failure";
    return cleaned.length > 160 ? `${cleaned.slice(0, 157)}...` : cleaned;
  }
  /**
   * Fetch with timeout for health checks
   */
  async fetchWithTimeout(url, timeoutMs = 2e3, headers) {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), timeoutMs);
    try {
      const response = await fetch(url, {
        signal: controller.signal,
        headers: this.buildRequestHeaders({ Accept: "application/json", ...headers ?? {} })
      });
      clearTimeout(timeout);
      if (!response.ok) {
        return { ok: false, data: null, status: response.status };
      }
      const contentType = response.headers.get("content-type");
      if (contentType?.includes("application/json")) {
        return { ok: true, data: await response.json(), status: response.status };
      } else {
        return { ok: true, data: await response.text(), status: response.status };
      }
    } catch (err) {
      clearTimeout(timeout);
      return { ok: false, data: null, status: null };
    }
  }
  /**
   * Check if local LLM is available
   * Uses 127.0.0.1 instead of localhost to avoid DNS issues (consistent with tactician)
   */
  async checkAvailability() {
    const now = Date.now();
    const trippedState = this.getTrippedBackendState(now);
    if (trippedState) {
      this.isAvailable = false;
      this.lastHealthCheck = 0;
      log.info(
        `local LLM availability: backend circuit open for ${Math.max(0, trippedState.untilMs - now)}ms (${trippedState.reason})`
      );
      return false;
    }
    if (this.isAvailable !== null && now - this.lastHealthCheck < _LocalLlmClient.HEALTH_CHECK_INTERVAL_MS) {
      return this.isAvailable;
    }
    const baseUrl = trimTrailingSlashes(
      this.config.localLlmUrl.replace("localhost", "127.0.0.1")
    );
    let sawUnauthorizedProbe = false;
    for (const serverConfig of LOCAL_SERVERS) {
      const healthUrl = `${baseUrl}${serverConfig.healthEndpoint}`;
      log.debug(`checking ${serverConfig.type} at ${healthUrl}`);
      const result = await this.fetchWithTimeout(healthUrl);
      if (result.ok && serverConfig.detectFn(result.data)) {
        this.isAvailable = true;
        this.detectedType = serverConfig.type;
        this.lastHealthCheck = now;
        log.info(`detected ${serverConfig.type} at ${baseUrl}`);
        return true;
      }
      if (result.status === 401 || result.status === 403) {
        sawUnauthorizedProbe = true;
      }
    }
    try {
      const modelsUrl = `${baseUrl}/v1/models`;
      const result = await this.fetchWithTimeout(modelsUrl);
      if (result.ok) {
        this.isAvailable = true;
        this.detectedType = "generic";
        this.lastHealthCheck = now;
        log.info(`detected generic OpenAI-compatible server at ${baseUrl}`);
        return true;
      }
      if (result.status === 401 || result.status === 403) {
        sawUnauthorizedProbe = true;
      }
    } catch {
    }
    this.isAvailable = false;
    this.detectedType = null;
    this.lastHealthCheck = now;
    if (sawUnauthorizedProbe) {
      log.warn(
        `local LLM availability probe was unauthorized at ${baseUrl}; verify localLlmApiKey and localLlmAuthHeader settings`
      );
    }
    log.debug("local LLM not available at", baseUrl);
    return false;
  }
  /**
   * Try to get context window from LM Studio settings.json as fallback.
   * This reads the defaultContextLength setting which is what LM Studio uses
   * when loading models without explicit context configuration.
   */
  getContextFromLmStudioSettings() {
    try {
      const homeDir = this.resolveHomeDir();
      const settingsPath = `${homeDir}/.cache/lm-studio/settings.json`;
      if (!existsSync(settingsPath)) {
        log.debug(`LM Studio settings: file not found at ${settingsPath}`);
        return null;
      }
      const content = readFileSync(settingsPath, "utf-8");
      const settings = JSON.parse(content);
      if (settings.defaultContextLength?.value) {
        const contextWindow = settings.defaultContextLength.value;
        log.debug(`LM Studio settings: found default context length: ${contextWindow}`);
        return contextWindow;
      }
      return null;
    } catch (err) {
      const errorMsg = err instanceof Error ? err.message : String(err);
      log.debug(`LM Studio settings: failed to read - ${errorMsg}`);
      return null;
    }
  }
  /**
   * Try to get context window from LMS CLI (LM Studio specific).
   * Uses --json flag for reliable parsing.
   * Returns null if LMS CLI is not available or model not found.
   */
  getContextFromLmsCli(modelId) {
    try {
      const homeDir = this.resolveHomeDir();
      const lmsPaths = [
        this.config.localLmsCliPath || "",
        `${homeDir}/.cache/lm-studio/bin/lms`,
        "/usr/local/bin/lms",
        "/opt/homebrew/bin/lms"
      ];
      const lmsPath = lmsPaths.find((p) => p.length > 0 && existsSync(p));
      if (!lmsPath) {
        log.debug(`LMS CLI: not found in standard locations (checked: ${lmsPaths.join(", ")})`);
        return null;
      }
      log.debug(`LMS CLI: running: ${lmsPath} ps --json`);
      const existingPath = readEnvVar("PATH") || "";
      const result = launchProcessSync(lmsPath, ["ps", "--json"], {
        encoding: "utf-8",
        timeout: 5e3,
        shell: false,
        // Don't use shell for JSON output - more reliable
        env: mergeEnv({
          PATH: `${this.config.localLmsBinDir || `${homeDir}/.cache/lm-studio/bin`}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${existingPath}`,
          HOME: homeDir
        })
      });
      if (result.error) {
        log.debug(`LMS CLI: spawn error - ${result.error.message}`);
        return null;
      }
      if (result.stderr && result.stderr.trim()) {
        log.debug(`LMS CLI: stderr - ${result.stderr.slice(0, 200)}`);
      }
      const output = result.stdout || "";
      if (!output.trim()) {
        log.debug("LMS CLI: empty output - LM Studio may not be running or no models loaded");
        return null;
      }
      let models;
      try {
        models = JSON.parse(output);
      } catch (parseErr) {
        log.debug(`LMS CLI: JSON parse error - ${parseErr}`);
        return null;
      }
      if (!Array.isArray(models) || models.length === 0) {
        log.debug("LMS CLI: no models loaded");
        return null;
      }
      const model = models.find(
        (m) => m.identifier === modelId || m.modelKey === modelId || m.identifier?.includes(modelId.replace(/@\d+bit$/, ""))
      );
      if (!model) {
        log.debug(`LMS CLI: model "${modelId}" not found in loaded models: ${models.map((m) => m.identifier).join(", ")}`);
        return null;
      }
      const contextWindow = model.contextLength || model.maxContextLength;
      if (contextWindow) {
        log.info(`LMS CLI detected context window: ${contextWindow} for ${modelId} (max: ${model.maxContextLength})`);
        return contextWindow;
      }
      return null;
    } catch (err) {
      const errorMsg = err instanceof Error ? err.message : String(err);
      log.debug(`LMS CLI: failed - ${errorMsg}`);
      return null;
    }
  }
  /**
   * Get full model info from LMS CLI including context length and max context length.
   * Returns null if LMS CLI is unavailable or model not found.
   */
  getLmsModelInfo(modelId) {
    try {
      const result = launchProcessSync("lms", ["ps", "--json"], {
        encoding: "utf-8",
        timeout: 5e3,
        shell: false
      });
      if (result.error) {
        return null;
      }
      const output = result.stdout || "";
      if (!output.trim()) {
        return null;
      }
      let models;
      try {
        models = JSON.parse(output);
      } catch {
        return null;
      }
      if (!Array.isArray(models) || models.length === 0) {
        return null;
      }
      const model = models.find(
        (m) => m.identifier === modelId || m.modelKey === modelId || m.identifier?.includes(modelId.replace(/@\d+bit$/, ""))
      );
      if (!model || !model.contextLength) {
        return null;
      }
      return {
        contextLength: model.contextLength,
        maxContextLength: model.maxContextLength || model.contextLength,
        identifier: model.identifier || modelId
      };
    } catch {
      return null;
    }
  }
  /**
   * Get context window for the configured model, using cache if available.
   * This method caches the result to avoid repeated LMS CLI calls.
   * Order: ModelRegistry (persistent) -> memory cache -> LMS CLI -> settings.json
   */
  getCachedContextWindow(modelId) {
    const now = Date.now();
    if (this.modelRegistry) {
      const caps = this.modelRegistry.getCapabilities(modelId);
      if (caps.source === "lmstudio" && caps.contextWindow) {
        log.debug(`ModelRegistry: using persisted LM Studio context: ${caps.contextWindow}`);
        this.cachedLmsContext = caps.contextWindow;
        this.lastLmsCheck = now;
        return caps.contextWindow;
      }
    }
    if (this.cachedLmsContext && now - this.lastLmsCheck < _LocalLlmClient.LMS_CACHE_INTERVAL_MS) {
      log.debug(`LMS CLI: returning in-memory cached context: ${this.cachedLmsContext}`);
      return this.cachedLmsContext;
    }
    const lmsInfo = this.getLmsModelInfo(modelId);
    if (lmsInfo?.contextLength) {
      this.cachedLmsContext = lmsInfo.contextLength;
      this.lastLmsCheck = now;
      const calculatedOutputTokens = Math.min(Math.floor(lmsInfo.contextLength / 8), 16384);
      const outputTokens = Math.max(calculatedOutputTokens, 4096);
      if (this.modelRegistry) {
        this.modelRegistry.setCapabilities(modelId, {
          maxPositionEmbeddings: lmsInfo.maxContextLength || lmsInfo.contextLength,
          contextWindow: lmsInfo.contextLength,
          supportsExtendedContext: (lmsInfo.maxContextLength || lmsInfo.contextLength) > 65536,
          typicalOutputTokens: outputTokens,
          source: "lmstudio"
        });
        log.info(`LMS CLI: Stored capabilities for ${modelId}: ${lmsInfo.contextLength} context, ${outputTokens} output tokens`);
      }
      return lmsInfo.contextLength;
    }
    const legacyContext = this.getContextFromLmsCli(modelId);
    if (legacyContext) {
      this.cachedLmsContext = legacyContext;
      this.lastLmsCheck = now;
      if (this.modelRegistry) {
        const calculatedOutputTokens = Math.min(Math.floor(legacyContext / 8), 16384);
        const outputTokens = Math.max(calculatedOutputTokens, 4096);
        this.modelRegistry.setCapabilities(modelId, {
          maxPositionEmbeddings: legacyContext,
          contextWindow: legacyContext,
          supportsExtendedContext: false,
          typicalOutputTokens: outputTokens,
          source: "lmstudio"
        });
      }
      return legacyContext;
    }
    const settingsContext = this.getContextFromLmStudioSettings();
    if (settingsContext) {
      log.info(`LM Studio settings: using default context: ${settingsContext}`);
      this.cachedLmsContext = settingsContext;
      this.lastLmsCheck = now;
      return settingsContext;
    }
    return null;
  }
  /**
   * Clear the LMS context cache. Call this when the model changes.
   */
  clearContextCache() {
    this.cachedLmsContext = null;
    this.lastLmsCheck = 0;
    log.debug("LMS CLI: context cache cleared");
  }
  remainingCooldownMs(now = Date.now()) {
    return Math.max(0, this.cooldownUntilMs - now);
  }
  scheduleQueueDrain() {
    if (this.queueDrainScheduled) return;
    this.queueDrainScheduled = true;
    queueMicrotask(() => {
      this.queueDrainScheduled = false;
      this.startAvailableQueuedRequests();
    });
  }
  hasQueuedRequests() {
    return this.requestQueues["recall-critical"].length > 0 || this.requestQueues.background.length > 0;
  }
  dequeueQueuedRequest(priority) {
    const next = this.requestQueues[priority].shift();
    return next ?? null;
  }
  failOpenQueuedRequestsForCooldown() {
    let dropped = 0;
    for (const priority of ["recall-critical", "background"]) {
      while (this.requestQueues[priority].length > 0) {
        const queued = this.requestQueues[priority].shift();
        queued?.resolve(null);
        dropped += 1;
      }
    }
    return dropped;
  }
  startAvailableQueuedRequests() {
    if (!this.queueProcessing.has("recall-critical")) {
      const nextCritical = this.dequeueQueuedRequest("recall-critical");
      if (nextCritical) {
        this.queueProcessing.add("recall-critical");
        void this.runQueuedRequest(nextCritical);
      }
    }
    if (!this.queueProcessing.has("background")) {
      const nextBackground = this.dequeueQueuedRequest("background");
      if (nextBackground) {
        this.queueProcessing.add("background");
        void this.runQueuedRequest(nextBackground);
      }
    }
  }
  async runQueuedRequest(next) {
    try {
      const remainingCooldownMs = this.remainingCooldownMs();
      if (remainingCooldownMs > 0) {
        const additionalDropped = this.failOpenQueuedRequestsForCooldown();
        log.warn(
          `local LLM: cooldown active (${remainingCooldownMs}ms remaining), dropping ${additionalDropped + 1} queued request(s) fail-open`
        );
        next.resolve(null);
        return;
      }
      let result = null;
      try {
        result = await this.runChatCompletionRequest(next.messages, next.options, {
          priority: next.priority,
          enqueuedAtMs: next.enqueuedAtMs
        });
      } catch (err) {
        log.warn(`local LLM queue drain failed open: ${err instanceof Error ? err.message : String(err)}`);
      }
      next.resolve(result);
    } finally {
      this.queueProcessing.delete(next.priority);
      if (this.hasQueuedRequests()) {
        this.scheduleQueueDrain();
      }
    }
  }
  async runChatCompletionRequest(messages, options, queueMeta) {
    log.debug(
      `local LLM chatCompletion: localLlmEnabled=${this.config.localLlmEnabled}, model=${this.config.localLlmModel}`
    );
    const operation = options.operation ?? "unspecified";
    const startedAtMs = Date.now();
    if (queueMeta) {
      log.debug(
        `local LLM queue start: priority=${queueMeta.priority} waitMs=${startedAtMs - queueMeta.enqueuedAtMs} op=${operation}`
      );
    }
    try {
      const isAvailable = await this.checkAvailability();
      if (!isAvailable) {
        log.debug(
          `local LLM: checkAvailability returned false for ${this.config.localLlmUrl}`
        );
        return null;
      }
      const promptChars = messages.reduce((sum, m) => sum + (m.content?.length ?? 0), 0);
      const requestBody = {
        model: this.config.localLlmModel,
        messages,
        temperature: options.temperature ?? 0.7,
        // Use max_tokens consistent with cloud models
        max_tokens: options.maxTokens ?? 4096
      };
      if (options.responseFormat?.type === "json_schema") {
        requestBody.response_format = options.responseFormat;
      }
      if (this._disableThinking && this.detectedType !== null && THINKING_COMPATIBLE_BACKENDS.has(this.detectedType)) {
        requestBody.chat_template_kwargs = { enable_thinking: false };
      }
      const baseUrl = trimTrailingSlashes(
        this.config.localLlmUrl.replace("localhost", "127.0.0.1")
      );
      const chatUrl = baseUrl.endsWith("/v1") ? `${baseUrl}/chat/completions` : `${baseUrl}/v1/chat/completions`;
      const requestBodyJson = JSON.stringify(requestBody);
      log.debug(
        `local LLM: sending request to ${chatUrl} with model ${this.config.localLlmModel}`
      );
      log.debug(`local LLM: request body length=${requestBodyJson.length}`);
      if (this.config.debug) {
        try {
          const { writeFileSync } = await import("fs");
          writeFileSync("/tmp/engram-last-request.json", requestBodyJson);
        } catch {
        }
      }
      const effectiveTimeoutMs = typeof options.timeoutMs === "number" ? Math.min(this.config.localLlmTimeoutMs, options.timeoutMs) : this.config.localLlmTimeoutMs;
      const maxAttempts = 1 + Math.max(0, this.config.localLlmRetry5xxCount);
      let response = null;
      let lastAbortError = null;
      for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
        const attemptAbort = new AbortController();
        const attemptTimeout = setTimeout(() => attemptAbort.abort(), effectiveTimeoutMs);
        try {
          response = await fetch(chatUrl, {
            method: "POST",
            headers: this.buildRequestHeaders({
              "Content-Type": "application/json"
            }),
            body: JSON.stringify(requestBody),
            signal: attemptAbort.signal
          });
        } catch (err) {
          if (!this.isAbortError(err)) throw err;
          lastAbortError = err instanceof Error ? err : new Error(String(err));
          if (attempt < maxAttempts) {
            const backoffMs2 = this.config.localLlmRetryBackoffMs * attempt;
            log.warn(
              `local LLM request aborted: op=${operation} attempt=${attempt}/${maxAttempts} timeoutMs=${effectiveTimeoutMs} model=${this.config.localLlmModel}; retrying after ${backoffMs2}ms`
            );
            await new Promise((resolve) => setTimeout(resolve, backoffMs2));
            continue;
          }
          break;
        } finally {
          clearTimeout(attemptTimeout);
        }
        if (response.ok) break;
        if (response.status >= 500 && attempt < maxAttempts) {
          try {
            const errorText = await response.clone().text();
            const nonRecoverableReason = this.extractNonRecoverableBackendReasonFromErrorText(errorText);
            if (nonRecoverableReason) {
              this.markBackendUnavailable(
                nonRecoverableReason,
                this.config.localLlm400CooldownMs
              );
              this.consecutive400s = 0;
              return null;
            }
          } catch (e) {
            log.debug(`local LLM failed to inspect retryable error body: ${e}`);
          }
        }
        if (response.status < 500 || attempt >= maxAttempts) break;
        const backoffMs = this.config.localLlmRetryBackoffMs * attempt;
        log.warn(
          `local LLM request got ${response.status}; retrying (attempt ${attempt + 1}/${maxAttempts}) after ${backoffMs}ms`
        );
        await new Promise((resolve) => setTimeout(resolve, backoffMs));
      }
      log.debug(
        `local LLM: received response, status=${response?.status}, ok=${response?.ok}`
      );
      if (!response) {
        if (lastAbortError) {
          log.warn(
            `local LLM request aborted after ${maxAttempts} attempt(s): op=${operation} timeoutMs=${effectiveTimeoutMs} model=${this.config.localLlmModel} promptChars=${promptChars} durationMs=${Date.now() - startedAtMs}`
          );
        } else {
          log.warn(
            `local LLM request failed: no response object (op=${operation} model=${this.config.localLlmModel} durationMs=${Date.now() - startedAtMs})`
          );
        }
        return null;
      }
      if (!response.ok) {
        let reason = "";
        let errorText = "";
        try {
          errorText = await response.text();
          try {
            const parsed = JSON.parse(errorText);
            reason = parsed?.error?.message ? ` \u2014 ${parsed.error.message}` : "";
          } catch {
            log.debug(`local LLM error body: ${errorText.slice(0, 500)}`);
          }
        } catch (e) {
          log.debug(`local LLM failed to read error body: ${e}`);
        }
        log.warn(
          `local LLM request failed: ${response.status} ${response.statusText}${reason} (op=${operation}, model=${this.config.localLlmModel}, url=${chatUrl}, promptChars=${promptChars}, maxTokens=${requestBody.max_tokens})`
        );
        const nonRecoverableReason = this.extractNonRecoverableBackendReason(reason) ?? this.extractNonRecoverableBackendReasonFromErrorText(errorText);
        if (nonRecoverableReason) {
          this.markBackendUnavailable(
            nonRecoverableReason,
            this.config.localLlm400CooldownMs
          );
          this.consecutive400s = 0;
          return null;
        }
        if (response.status === 400) {
          this.consecutive400s += 1;
          if (this.consecutive400s >= this.config.localLlm400TripThreshold) {
            this.cooldownUntilMs = Date.now() + this.config.localLlm400CooldownMs;
            log.warn(
              `local LLM: entering cooldown for ${this.config.localLlm400CooldownMs}ms after ${this.consecutive400s} consecutive 400 responses`
            );
            this.consecutive400s = 0;
          }
        } else {
          this.consecutive400s = 0;
        }
        return null;
      }
      this.consecutive400s = 0;
      const data = await response.json();
      log.debug(
        `local LLM response: choices=${data.choices?.length}, usage=${JSON.stringify(data.usage)}`
      );
      const msg = data.choices?.[0]?.message;
      const content = msg?.content || msg?.reasoning_content || "";
      if (!content) {
        log.warn(`local LLM returned empty content. choices=${JSON.stringify(data.choices)?.slice(0, 200)}`);
        return null;
      }
      const usage = data.usage ? {
        promptTokens: data.usage.prompt_tokens ?? 0,
        completionTokens: data.usage.completion_tokens ?? 0,
        totalTokens: data.usage.total_tokens ?? 0
      } : this.estimateTokens(messages, content);
      const durationMs = Date.now() - startedAtMs;
      if (this.config.slowLogEnabled && durationMs >= this.config.slowLogThresholdMs) {
        const promptChars2 = messages.reduce((sum, m) => sum + (m.content?.length ?? 0), 0);
        const op = options.operation ? ` op=${options.operation}` : "";
        log.warn(
          `SLOW local LLM:${op} durationMs=${durationMs} model=${this.config.localLlmModel} url=${chatUrl} promptChars=${promptChars2} outputTokens=${usage.completionTokens} totalTokens=${usage.totalTokens}`
        );
      }
      log.debug("local LLM: request succeeded, tokens:", usage.totalTokens);
      return { content, usage };
    } catch (err) {
      const errMsg = err instanceof Error ? err.message : String(err);
      const durationMs = Date.now() - startedAtMs;
      if (this.isAbortError(err)) {
        log.warn(
          `local LLM request aborted: op=${operation} timeoutMs=${options.timeoutMs ?? this.config.localLlmTimeoutMs} model=${this.config.localLlmModel} durationMs=${durationMs} error=${errMsg}`
        );
        return null;
      }
      log.warn(`local LLM request error: op=${operation} error=${errMsg}`);
      this.isAvailable = false;
      const nonRecoverableReason = this.extractNonRecoverableBackendReason(errMsg);
      if (nonRecoverableReason) {
        this.markBackendUnavailable(
          nonRecoverableReason,
          this.config.localLlm400CooldownMs
        );
      }
      return null;
    } finally {
      if (queueMeta) {
        const finishedAtMs = Date.now();
        const waitMs = startedAtMs - queueMeta.enqueuedAtMs;
        log.debug(
          `local LLM queue finish: priority=${queueMeta.priority} waitMs=${waitMs} runMs=${finishedAtMs - startedAtMs} totalMs=${finishedAtMs - queueMeta.enqueuedAtMs} op=${operation}`
        );
      }
    }
  }
  /**
   * Query the local LLM server for loaded model information.
   * Returns null if unavailable or if the model is not found.
   */
  async getLoadedModelInfo() {
    const baseUrl = trimTrailingSlashes(
      this.config.localLlmUrl.replace("localhost", "127.0.0.1")
    );
    const modelsUrl = baseUrl.endsWith("/v1") ? `${baseUrl}/models` : `${baseUrl}/v1/models`;
    log.debug(`Fetching model info from ${modelsUrl}`);
    try {
      const result = await this.fetchWithTimeout(modelsUrl, 3e3);
      if (!result.ok) {
        if (result.status === 401 || result.status === 403) {
          log.warn(
            `Local LLM: unauthorized while fetching models from ${modelsUrl}; verify localLlmApiKey and localLlmAuthHeader settings`
          );
        }
        log.warn(`Local LLM: Failed to fetch models from ${modelsUrl} - server returned error`);
        return null;
      }
      if (!result.data) {
        log.warn(`Local LLM: No data returned from ${modelsUrl}`);
        return null;
      }
      const data = result.data;
      if (!Array.isArray(data.data) || data.data.length === 0) {
        log.warn("Local LLM returned no models");
        return null;
      }
      const modelIds = data.data.map((m) => m.id).filter(Boolean);
      log.debug(
        `Local LLM: Found ${modelIds.length} model(s). First 10: ${modelIds.slice(0, 10).join(", ")}`
      );
      const configuredModel = this.config.localLlmModel;
      let model = data.data.find((m) => m.id === configuredModel);
      if (!model) {
        model = data.data.find(
          (m) => configuredModel.includes(m.id || "") || (m.id || "").includes(configuredModel.replace(/@\d+bit$/, ""))
        );
      }
      if (!model) {
        model = data.data[0];
        const availablePreview = data.data.map((m) => m.id).filter(Boolean).slice(0, 10).join(", ");
        log.warn(
          `Configured model "${configuredModel}" not found in local LLM. Using "${model.id}" instead. Available (first 10): ${availablePreview}`
        );
      }
      let contextWindow = model.max_context_length || model.max_tokens;
      if (!contextWindow) {
        log.info("Local LLM: API did not report context window, trying LMS CLI...");
        const lmsContext = this.getCachedContextWindow(model.id || "");
        if (lmsContext) {
          contextWindow = lmsContext;
        }
      }
      this.cachedModelInfo = {
        id: model.id || "unknown",
        contextWindow,
        maxTokens: model.max_tokens
      };
      log.info(
        `Local LLM model detected: ${this.cachedModelInfo.id}, context window: ${contextWindow?.toLocaleString() || "unknown (may use default)"}`
      );
      return this.cachedModelInfo;
    } catch (err) {
      log.warn(`Failed to fetch model info: ${err}`);
      return null;
    }
  }
  /**
   * Check if the configured model is available and get its actual context window.
   * Warns if there's a mismatch between expected and actual context.
   */
  async validateModelConfig(expectedContextWindow) {
    const warnings = [];
    const modelInfo = await this.getLoadedModelInfo();
    if (!modelInfo) {
      return { available: false, warnings: ["Could not query local LLM for model info"] };
    }
    if (expectedContextWindow && modelInfo.contextWindow) {
      if (modelInfo.contextWindow < expectedContextWindow) {
        warnings.push(
          `Context window mismatch: Model ${modelInfo.id} supports ${modelInfo.contextWindow.toLocaleString()} tokens, but engram is configured for ${expectedContextWindow.toLocaleString()}. Set localLlmMaxContext: ${modelInfo.contextWindow} in config to avoid errors.`
        );
      }
    }
    if (!modelInfo.contextWindow) {
      warnings.push(
        `Local LLM server did not report context window for ${modelInfo.id}. If you get "context length exceeded" errors, set localLlmMaxContext in config.`
      );
    }
    return {
      available: true,
      actualContextWindow: modelInfo.contextWindow,
      warnings
    };
  }
  /**
   * Make a chat completion request to local LLM
   */
  async chatCompletion(messages, options = {}) {
    if (!this.config.localLlmEnabled) {
      log.debug("local LLM: disabled, returning null");
      return null;
    }
    const remainingMs = this.remainingCooldownMs();
    if (remainingMs > 0) {
      log.debug(`local LLM: cooldown active (${remainingMs}ms remaining), skipping request`);
      return null;
    }
    if (options.priority) {
      const priority = options.priority;
      return await new Promise((resolve) => {
        this.requestQueues[priority].push({
          messages,
          options,
          priority,
          enqueuedAtMs: Date.now(),
          resolve
        });
        this.scheduleQueueDrain();
      });
    }
    return await this.runChatCompletionRequest(messages, options);
  }
  /**
   * Estimate tokens when local LLM doesn't return usage stats
   * Rough estimate: 1 token ≈ 4 characters
   */
  estimateTokens(messages, response) {
    const promptChars = messages.reduce((sum, m) => sum + m.content.length, 0);
    const promptTokens = Math.ceil(promptChars / 4);
    const completionTokens = Math.ceil(response.length / 4);
    return {
      promptTokens,
      completionTokens,
      totalTokens: promptTokens + completionTokens
    };
  }
  /**
   * Try local LLM first, fallback to cloud provider if configured
   */
  async withFallback(localOperation, fallbackOperation, operationName) {
    if (this.config.localLlmEnabled) {
      const localResult = await localOperation();
      if (localResult !== null) {
        log.debug(`${operationName}: used local LLM`);
        return localResult;
      }
      if (this.config.localLlmFallback) {
        log.info(`${operationName}: local LLM unavailable, falling back to cloud`);
      } else {
        throw new Error(`${operationName}: local LLM unavailable and fallback disabled`);
      }
    }
    return fallbackOperation();
  }
};

export {
  LocalLlmClient
};
//# sourceMappingURL=chunk-R2XRID2N.js.map