import {
  scanSignals
} from "./chunk-XYIK4LF6.js";
import {
  log
} from "./chunk-2ODBA7MQ.js";

// src/buffer.ts
var MAX_BUFFER_ENTRY_COUNT = 200;
var SmartBuffer = class {
  constructor(config, storage, surpriseProbe = null) {
    this.config = config;
    this.storage = storage;
    this.state = { turns: [], lastExtractionAt: null, extractionCount: 0 };
    this.surpriseProbe = surpriseProbe;
  }
  config;
  storage;
  state;
  loaded = false;
  surpriseProbe;
  /**
   * Serialized write chain for `BUFFER_SURPRISE` telemetry events.
   *
   * The telemetry path is fire-and-forget (`addTurn` does not await the
   * ledger append), but multiple concurrent appends would still settle
   * out of order under variable filesystem latency. The report path
   * assumes chronological ordering — it slices the tail of the ledger
   * and treats the most recent entry as the current threshold in force.
   * Chaining ensures each append only runs after the previous settles,
   * preserving wall-clock order.
   *
   * We include a `.catch` on every link so a rejected append does not
   * poison the chain (CLAUDE.md rule #40).
   */
  surpriseTelemetryWriteChain = Promise.resolve();
  entryFor(key) {
    this.state.entries ??= {};
    if (key === "__proto__" || key === "constructor" || key === "prototype") {
      key = `__safe_${key}`;
    }
    if (Object.hasOwn(this.state.entries, key)) {
      const stored = this.state.entries[key];
      if (stored && typeof stored === "object" && Array.isArray(stored.turns)) {
        return stored;
      }
    }
    const created = {
      turns: [],
      lastExtractionAt: null,
      extractionCount: 0
    };
    this.state.entries[key] = created;
    return created;
  }
  peekEntry(key) {
    if (key === "__proto__" || key === "constructor" || key === "prototype") {
      key = `__safe_${key}`;
    }
    const existing = this.state.entries?.[key];
    if (existing) return existing;
    if (key !== "default") return null;
    return {
      turns: Array.isArray(this.state.turns) ? this.state.turns : [],
      lastExtractionAt: this.state.lastExtractionAt ?? null,
      extractionCount: typeof this.state.extractionCount === "number" ? this.state.extractionCount : 0
    };
  }
  normalizeState(state) {
    const entries = state.entries ?? {};
    if (!entries.default) {
      entries.default = {
        turns: Array.isArray(state.turns) ? [...state.turns] : [],
        lastExtractionAt: state.lastExtractionAt ?? null,
        extractionCount: typeof state.extractionCount === "number" ? state.extractionCount : 0
      };
    }
    return {
      turns: entries.default.turns,
      lastExtractionAt: entries.default.lastExtractionAt,
      extractionCount: entries.default.extractionCount,
      entries
    };
  }
  entryActivityAt(entry) {
    const lastTurnAt = entry.turns.reduce((latest, turn) => {
      const parsed = Date.parse(turn.timestamp);
      return Number.isFinite(parsed) ? Math.max(latest, parsed) : latest;
    }, -1);
    const lastExtractionAt = typeof entry.lastExtractionAt === "string" ? Date.parse(entry.lastExtractionAt) : Number.NaN;
    return Math.max(
      lastTurnAt,
      Number.isFinite(lastExtractionAt) ? lastExtractionAt : -1
    );
  }
  pruneEntries(retainKeys) {
    const entries = this.state.entries;
    if (!entries) return;
    const keys = Object.keys(entries);
    if (keys.length <= MAX_BUFFER_ENTRY_COUNT) return;
    const insertionOrder = new Map(keys.map((key, index) => [key, index]));
    const removable = keys.filter((key) => key !== "default" && !retainKeys.includes(key)).filter((key) => (entries[key]?.turns.length ?? 0) === 0).sort((left, right) => {
      const leftAt = this.entryActivityAt(entries[left] ?? {
        turns: [],
        lastExtractionAt: null,
        extractionCount: 0
      });
      const rightAt = this.entryActivityAt(entries[right] ?? {
        turns: [],
        lastExtractionAt: null,
        extractionCount: 0
      });
      if (leftAt !== rightAt) return leftAt - rightAt;
      return (insertionOrder.get(left) ?? 0) - (insertionOrder.get(right) ?? 0);
    });
    const removableCount = Math.max(0, keys.length - MAX_BUFFER_ENTRY_COUNT);
    for (const key of removable.slice(0, removableCount)) {
      delete entries[key];
    }
  }
  async load() {
    if (this.loaded) return;
    this.state = this.normalizeState(await this.storage.loadBuffer());
    this.loaded = true;
  }
  /**
   * Reset the buffer to an empty, usable state.
   * Called when the persisted buffer file is corrupt and load() fails,
   * so the buffer can still accept new turns for the rest of the session.
   */
  resetToEmpty() {
    this.state = { turns: [], lastExtractionAt: null, extractionCount: 0 };
    this.loaded = true;
  }
  async save() {
    await this.storage.saveBuffer(this.state);
  }
  async addTurn(bufferKey, turn) {
    await this.load();
    const entry = this.entryFor(bufferKey);
    entry.turns.push(turn);
    if (bufferKey === "default") {
      this.state.turns = entry.turns;
    }
    const signal = scanSignals(turn.content, this.config.highSignalPatterns);
    let decision = this.evaluate(entry, signal.level);
    if (decision === "keep_buffering" && this.config.bufferSurpriseTriggerEnabled && this.surpriseProbe !== null && // Matching the existing "smart" branch: surprise is a lower-tier
    // novelty signal that should not second-guess a high-signal hit
    // (which already flushes) or fight `every_n` / `time_based` modes.
    this.config.triggerMode === "smart" && signal.level !== "high") {
      const surprise = await this.computeSurpriseSafe(bufferKey, turn, entry);
      if (surprise !== null) {
        const triggered = surprise > this.config.bufferSurpriseThreshold;
        if (triggered) {
          log.debug(
            `buffer[${bufferKey}]: surprise=${surprise.toFixed(3)} > threshold=${this.config.bufferSurpriseThreshold} \u2192 extract_now`
          );
          decision = "extract_now";
        }
        this.queueSurpriseTelemetryWrite({
          bufferKey,
          turnRole: turn.role,
          sessionKey: typeof turn.sessionKey === "string" ? turn.sessionKey : null,
          surpriseScore: surprise,
          triggered,
          turnCountInWindow: entry.turns.length,
          // Stamp at decision time so backpressure on the write chain
          // does not shift the event's apparent moment away from when
          // the turn was actually scored.
          timestamp: (/* @__PURE__ */ new Date()).toISOString(),
          // Snapshot the threshold used to compute `triggered` so a
          // concurrent config mutation cannot retroactively change
          // what the ledger row claims the decision was against.
          threshold: this.config.bufferSurpriseThreshold
        });
      }
    }
    log.debug(
      `buffer[${bufferKey}]: ${entry.turns.length} turns, signal=${signal.level}, decision=${decision}`
    );
    this.pruneEntries([bufferKey]);
    await this.save();
    return decision;
  }
  /**
   * Enqueue a telemetry append on the serialized write chain.
   *
   * The chain is a classic `writeChain = writeChain.then(fn).catch(...)`
   * — each link waits for the previous to settle before its append
   * starts, so out-of-order chronology cannot happen even under
   * variable filesystem latency. We always attach `.catch` so one
   * rejection does not poison the chain for the rest of the session
   * (CLAUDE.md rule #40). The error is logged through
   * `emitSurpriseEventSafe` itself, which swallows its own rejections.
   *
   * Public surface is deliberately narrow — only `addTurn` should call
   * this, so the surprise telemetry path stays centralized.
   */
  queueSurpriseTelemetryWrite(params) {
    this.surpriseTelemetryWriteChain = this.surpriseTelemetryWriteChain.then(() => this.emitSurpriseEventSafe(params)).catch(() => {
    });
  }
  /**
   * Append a single `BUFFER_SURPRISE` telemetry row (issue #563 PR 3).
   *
   * Deliberately swallows write errors: the buffer must never fail to
   * record a turn because the observation ledger is read-only, out of
   * disk, or otherwise unhappy. The log line at debug lets operators
   * confirm the path fired without polluting the error channel.
   */
  async emitSurpriseEventSafe(params) {
    const storage = this.storage;
    if (typeof storage.appendBufferSurpriseEvents !== "function") {
      return;
    }
    const event = {
      event: "BUFFER_SURPRISE",
      // Use the decision-time stamp captured when the event was
      // queued, NOT `Date.now()` here — backpressure on the write
      // chain could otherwise shift timestamps into the future relative
      // to when the turn was scored.
      timestamp: params.timestamp,
      bufferKey: params.bufferKey,
      sessionKey: params.sessionKey,
      turnRole: params.turnRole,
      surpriseScore: params.surpriseScore,
      // Use the snapshotted threshold from the queue entry, not the
      // live config — see `SurpriseTelemetryQueueEntry.threshold`
      // doc for the rationale.
      threshold: params.threshold,
      triggeredFlush: params.triggered,
      turnCountInWindow: params.turnCountInWindow
    };
    try {
      await storage.appendBufferSurpriseEvents([event]);
    } catch (err) {
      log.debug(
        `buffer[${params.bufferKey}]: surprise telemetry write failed, continuing: ${describeError(err)}`
      );
    }
  }
  /**
   * Invoke the injected surprise probe defensively. Any error (probe throws,
   * embedder unavailable, timeout) is swallowed and logged at debug: the
   * surprise path must never crash the happy-path trigger evaluation. A
   * `null` return indicates "no score available, fall through to existing
   * triggers".
   */
  async computeSurpriseSafe(bufferKey, turn, entry) {
    if (!this.surpriseProbe) return null;
    const prior = entry.turns.length > 0 ? entry.turns.slice(0, -1) : [];
    try {
      const score = await probeWithTimeout(
        this.surpriseProbe.scoreTurn(bufferKey, turn, prior),
        this.config.bufferSurpriseProbeTimeoutMs
      );
      if (score === null) return null;
      if (typeof score !== "number" || !Number.isFinite(score)) {
        log.debug(
          `buffer[${bufferKey}]: surprise probe returned non-finite score (${String(score)}), ignoring`
        );
        return null;
      }
      if (score < 0) return 0;
      if (score > 1) return 1;
      return score;
    } catch (err) {
      log.debug(
        `buffer[${bufferKey}]: surprise probe failed, falling back to existing triggers: ${describeError(err)}`
      );
      return null;
    }
  }
  evaluate(entry, signalLevel) {
    if (this.config.triggerMode === "smart") {
      if (signalLevel === "high") return "extract_now";
      if (entry.turns.length >= this.config.bufferMaxTurns) {
        return "extract_batch";
      }
      if (entry.lastExtractionAt) {
        const elapsed = Date.now() - new Date(entry.lastExtractionAt).getTime();
        if (elapsed >= this.config.bufferMaxMinutes * 6e4) {
          return "extract_batch";
        }
      }
      return "keep_buffering";
    }
    if (this.config.triggerMode === "every_n") {
      return entry.turns.length >= this.config.bufferMaxTurns ? "extract_batch" : "keep_buffering";
    }
    if (this.config.triggerMode === "time_based") {
      if (!entry.lastExtractionAt) {
        return entry.turns.length >= this.config.bufferMaxTurns ? "extract_batch" : "keep_buffering";
      }
      const elapsed = Date.now() - new Date(entry.lastExtractionAt).getTime();
      return elapsed >= this.config.bufferMaxMinutes * 6e4 ? "extract_batch" : "keep_buffering";
    }
    return "keep_buffering";
  }
  getTurns(bufferKey = "default") {
    const entry = this.peekEntry(bufferKey);
    if (!entry) return [];
    const retained = entry.retainedTurns ?? [];
    return [...retained, ...entry.turns];
  }
  /**
   * Retain a subset of the current turns across `clearAfterExtraction` so a
   * future extraction pass sees the context behind a deferred candidate
   * (issue #562, PR 2). Callers pass the turns that were seen during the
   * current extraction; the buffer keeps the tail (latest `max` turns) as
   * the retention window. Passing an empty array or `max <= 0` clears the
   * retention slot instead.
   */
  async retainDeferredTurns(bufferKey, turns, max = 10) {
    await this.load();
    const entry = this.entryFor(bufferKey);
    if (!Array.isArray(turns) || turns.length === 0 || max <= 0) {
      delete entry.retainedTurns;
    } else {
      const tail = turns.slice(-max);
      entry.retainedTurns = tail.map((t) => {
        const copy = {
          role: t.role,
          content: typeof t.content === "string" ? t.content : "",
          timestamp: typeof t.timestamp === "string" ? t.timestamp : (/* @__PURE__ */ new Date()).toISOString()
        };
        if (typeof t.sessionKey === "string") copy.sessionKey = t.sessionKey;
        if (typeof t.logicalSessionKey === "string") {
          copy.logicalSessionKey = t.logicalSessionKey;
        }
        if (t.providerThreadId === null || typeof t.providerThreadId === "string") {
          copy.providerThreadId = t.providerThreadId;
        }
        if (typeof t.turnFingerprint === "string") {
          copy.turnFingerprint = t.turnFingerprint;
        }
        if (typeof t.persistProcessedFingerprint === "boolean") {
          copy.persistProcessedFingerprint = t.persistProcessedFingerprint;
        }
        return copy;
      });
    }
    await this.save();
  }
  /**
   * Return the current retention window (issue #562, PR 2). Primarily for
   * tests and diagnostics.
   */
  getRetainedDeferredTurns(bufferKey = "default") {
    const entry = this.peekEntry(bufferKey);
    return entry?.retainedTurns ? [...entry.retainedTurns] : [];
  }
  async findBufferKeyForSession(sessionKey) {
    const bufferKeys = await this.findBufferKeysForSession(sessionKey);
    return bufferKeys[0] ?? null;
  }
  async findBufferKeysForSession(sessionKey) {
    if (typeof sessionKey !== "string" || sessionKey.length === 0) return [];
    await this.load();
    const matches = [];
    const directEntry = this.peekEntry(sessionKey);
    if ((directEntry?.turns.length ?? 0) > 0) {
      matches.push(sessionKey);
    }
    const entries = this.state.entries ?? {};
    for (const [bufferKey, entry] of Object.entries(entries)) {
      if (!matches.includes(bufferKey) && entry.turns.some(
        (turn) => typeof turn.sessionKey === "string" && turn.sessionKey === sessionKey
      )) {
        matches.push(bufferKey);
      }
    }
    return matches;
  }
  async clearAfterExtraction(bufferKey = "default") {
    await this.load();
    const entry = this.entryFor(bufferKey);
    entry.turns = [];
    entry.lastExtractionAt = (/* @__PURE__ */ new Date()).toISOString();
    entry.extractionCount += 1;
    if (bufferKey === "default") {
      this.state.turns = entry.turns;
      this.state.lastExtractionAt = entry.lastExtractionAt;
      this.state.extractionCount = entry.extractionCount;
    }
    this.pruneEntries([bufferKey]);
    await this.save();
  }
  getExtractionCount(bufferKey = "default") {
    return this.peekEntry(bufferKey)?.extractionCount ?? 0;
  }
  /**
   * Await any pending `BUFFER_SURPRISE` telemetry writes.
   *
   * The telemetry path is fire-and-forget from the hot path's point of
   * view, but tests and before-exit hooks sometimes need to make sure
   * the ledger has been flushed before they assert on its contents or
   * close the process. This method resolves once the current chain
   * head has settled; new writes scheduled after this call return a
   * separate, later settlement.
   *
   * Never throws — the chain already catches its own rejections.
   */
  async flushSurpriseTelemetry() {
    await this.surpriseTelemetryWriteChain;
  }
};
function describeError(err) {
  if (err instanceof Error) return err.message;
  if (err === null) return "null";
  if (err === void 0) return "undefined";
  if (typeof err === "string") return err;
  try {
    return JSON.stringify(err);
  } catch {
    return String(err);
  }
}
var ProbeTimeoutError = class extends Error {
  constructor(timeoutMs) {
    super(`probe exceeded ${timeoutMs}ms`);
    this.name = "ProbeTimeoutError";
  }
};
function probeWithTimeout(inflight, timeoutMs) {
  let timer = null;
  const timeout = new Promise((_, reject) => {
    timer = setTimeout(() => reject(new ProbeTimeoutError(timeoutMs)), timeoutMs);
    if (typeof timer.unref === "function") {
      timer.unref();
    }
  });
  return Promise.race([inflight, timeout]).finally(() => {
    if (timer) clearTimeout(timer);
  });
}

export {
  SmartBuffer
};
//# sourceMappingURL=chunk-JBMSGZEQ.js.map