"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
  for (var name in all)
    __defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
  if (from && typeof from === "object" || typeof from === "function") {
    for (let key of __getOwnPropNames(from))
      if (!__hasOwnProp.call(to, key) && key !== except)
        __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
  }
  return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);

// src/proactive-surfacing/index.ts
var proactive_surfacing_exports = {};
__export(proactive_surfacing_exports, {
  DashboardSurfacingChannel: () => DashboardSurfacingChannel,
  Debouncer: () => Debouncer,
  DummyTriggerSource: () => DummyTriggerSource,
  InMemoryDebounceStore: () => InMemoryDebounceStore,
  InMemoryOperatorPreferencesStore: () => InMemoryOperatorPreferencesStore,
  InMemorySurfacingMessageQueue: () => InMemorySurfacingMessageQueue,
  LLMAugmentedRelevanceScorer: () => LLMAugmentedRelevanceScorer,
  MemoryCalibrationLearner: () => MemoryCalibrationLearner,
  PipelineCoachingTriggerSource: () => PipelineCoachingTriggerSource,
  PrismaDebounceStore: () => PrismaDebounceStore,
  ProactiveSurfacingPipeline: () => ProactiveSurfacingPipeline,
  RateLimiter: () => RateLimiter,
  RuleBasedRelevanceScorer: () => RuleBasedRelevanceScorer,
  SurfacingAggregator: () => SurfacingAggregator,
  TelegramSurfacingChannel: () => TelegramSurfacingChannel,
  makeDummyIntelSource: () => makeDummyIntelSource,
  parseSurfacingCallback: () => parseSurfacingCallback
});
module.exports = __toCommonJS(proactive_surfacing_exports);

// src/proactive-surfacing/relevance-scorer-rules.ts
var PRIORITY_BOOST_PER_MATCH = 0.1;
var PRIORITY_BOOST_MAX = 0.3;
var URGENCY_NOW_THRESHOLD = 0.85;
var URGENCY_BRIEF_THRESHOLD = 0.6;
function urgencyFromScore(score) {
  if (score >= URGENCY_NOW_THRESHOLD) return "now";
  if (score >= URGENCY_BRIEF_THRESHOLD) return "morning_brief";
  return "weekly_brief";
}
function clamp(value, min, max) {
  return Math.max(min, Math.min(max, value));
}
var RuleBasedRelevanceScorer = class {
  constructor(calibrationLearner) {
    this.calibrationLearner = calibrationLearner;
  }
  calibrationLearner;
  async score(candidate, operatorContext) {
    const parts = [];
    const payloadBase = candidate.payload["baseRelevance"];
    const base = typeof payloadBase === "number" ? payloadBase : 0.5;
    parts.push(
      `base=${base.toFixed(2)} (${typeof payloadBase === "number" ? "payload" : "default"})`
    );
    let priorityBoost = 0;
    if (operatorContext.priorities && operatorContext.priorities.length > 0) {
      const candidateText = JSON.stringify(candidate.payload).toLowerCase();
      const matched = operatorContext.priorities.filter(
        (tag) => candidateText.includes(tag.toLowerCase())
      );
      if (matched.length > 0) {
        priorityBoost = clamp(
          matched.length * PRIORITY_BOOST_PER_MATCH,
          0,
          PRIORITY_BOOST_MAX
        );
        parts.push(
          `+${priorityBoost.toFixed(2)} priority (${matched.slice(0, 3).join(", ")})`
        );
      }
    }
    const multiplier = await this.calibrationLearner.getCalibration(
      operatorContext.operatorId,
      candidate.sourceName
    );
    const raw = (base + priorityBoost) * multiplier;
    if (multiplier !== 1) {
      parts.push(`\xD7${multiplier.toFixed(2)} calibration`);
    }
    const score = clamp(raw, 0, 1);
    if (score !== raw) {
      parts.push(`clamped from ${raw.toFixed(2)}`);
    }
    return {
      score,
      urgency: urgencyFromScore(score),
      rationale: parts.join("; ")
    };
  }
};

// src/proactive-surfacing/relevance-scorer-llm.ts
var URGENCY_NOW_THRESHOLD2 = 0.85;
var URGENCY_BRIEF_THRESHOLD2 = 0.6;
var DEFAULT_CACHE_TTL_MS = 6e4;
var DEFAULT_BAND = { lower: 0.4, upper: 0.75 };
function urgencyFromScore2(score) {
  if (score >= URGENCY_NOW_THRESHOLD2) return "now";
  if (score >= URGENCY_BRIEF_THRESHOLD2) return "morning_brief";
  return "weekly_brief";
}
function clamp2(value, min, max) {
  return Math.max(min, Math.min(max, value));
}
var LLMAugmentedRelevanceScorer = class {
  constructor(baseScorer, anthropic, model = "claude-sonnet-4-6", band = DEFAULT_BAND, cacheTtlMs = DEFAULT_CACHE_TTL_MS) {
    this.baseScorer = baseScorer;
    this.anthropic = anthropic;
    this.model = model;
    this.band = band;
    this.cacheTtlMs = cacheTtlMs;
  }
  baseScorer;
  anthropic;
  model;
  band;
  cacheTtlMs;
  cache = /* @__PURE__ */ new Map();
  /**
   * Cache key for a (candidate, operator) pair. The candidate id is stable
   * across calls from the same TriggerSource within its short fetch window;
   * combining with operatorId isolates per-operator scoring.
   */
  cacheKey(candidate, operatorContext) {
    return `${operatorContext.operatorId}:${candidate.sourceName}:${candidate.id}`;
  }
  async score(candidate, operatorContext) {
    const key = this.cacheKey(candidate, operatorContext);
    const cached = this.cache.get(key);
    if (cached && cached.expiresAt > Date.now()) {
      return cached.score;
    }
    if (cached) {
      this.cache.delete(key);
    }
    const baseResult = await this.baseScorer.score(candidate, operatorContext);
    if (baseResult.score < this.band.lower || baseResult.score > this.band.upper) {
      this.cache.set(key, {
        score: baseResult,
        expiresAt: Date.now() + this.cacheTtlMs
      });
      return baseResult;
    }
    const llmAdjustment = await this.callLlm(
      candidate,
      operatorContext,
      baseResult
    );
    const adjustedScore = clamp2(
      baseResult.score + llmAdjustment.adjustment,
      0,
      1
    );
    const result = {
      score: adjustedScore,
      urgency: urgencyFromScore2(adjustedScore),
      rationale: `${baseResult.rationale} | LLM adjustment: ${llmAdjustment.rationale}`
    };
    this.cache.set(key, {
      score: result,
      expiresAt: Date.now() + this.cacheTtlMs
    });
    return result;
  }
  async callLlm(candidate, operatorContext, baseResult) {
    const systemPrompt = `You are a relevance scoring assistant. You review a rule-based relevance score for a candidate event and determine whether a small adjustment is warranted.

Respond with valid JSON only (no markdown fences, no extra text):
{"adjustment": <number between -0.2 and 0.2>, "rationale": "<one sentence under 20 words>"}

Rules:
- adjustment must be in [-0.2, 0.2]
- Be conservative: only adjust when you have a clear reason
- Return adjustment: 0 if the rule-based score already seems correct`;
    const userPrompt = `Candidate event:
${JSON.stringify(
      {
        id: candidate.id,
        sourceName: candidate.sourceName,
        payload: candidate.payload
      },
      null,
      2
    )}

Operator context:
- operatorId: ${operatorContext.operatorId}
- priorities: ${JSON.stringify(operatorContext.priorities ?? [])}

Rule-based score: ${baseResult.score.toFixed(3)} (urgency: ${baseResult.urgency})
Rule-based rationale: ${baseResult.rationale}

Should this score be adjusted? Reply with JSON only.`;
    try {
      const response = await this.anthropic.messages.create({
        model: this.model,
        max_tokens: 128,
        messages: [{ role: "user", content: userPrompt }],
        system: systemPrompt
      });
      const text = response.content[0]?.type === "text" ? response.content[0].text : "";
      const parsed = JSON.parse(text);
      const adjustment = clamp2(
        typeof parsed.adjustment === "number" ? parsed.adjustment : 0,
        -0.2,
        0.2
      );
      const rationale = typeof parsed.rationale === "string" ? parsed.rationale : "no rationale provided";
      return { adjustment, rationale };
    } catch {
      return { adjustment: 0, rationale: "LLM unavailable; base score used" };
    }
  }
};

// src/proactive-surfacing/calibration-learner-memory.ts
var RESPONSE_DELTA = {
  acted: 0.1,
  dismissed: -0.05,
  more_like_this: 0.2,
  less_like_this: -0.15
};
var MULTIPLIER_MIN = 0;
var MULTIPLIER_MAX = 2;
var MULTIPLIER_DEFAULT = 1;
function clamp3(value, min, max) {
  return Math.max(min, Math.min(max, value));
}
var MemoryCalibrationLearner = class {
  /**
   * Outer key: operatorId. Inner key: sourceName.
   * Value: current calibration multiplier.
   */
  store = /* @__PURE__ */ new Map();
  getMultiplier(operatorId, sourceName) {
    return this.store.get(operatorId)?.get(sourceName) ?? MULTIPLIER_DEFAULT;
  }
  setMultiplier(operatorId, sourceName, value) {
    if (!this.store.has(operatorId)) {
      this.store.set(operatorId, /* @__PURE__ */ new Map());
    }
    this.store.get(operatorId).set(sourceName, value);
  }
  async record(operatorId, sourceName, _candidate, response) {
    const current = this.getMultiplier(operatorId, sourceName);
    const delta = RESPONSE_DELTA[response];
    const updated = clamp3(current + delta, MULTIPLIER_MIN, MULTIPLIER_MAX);
    this.setMultiplier(operatorId, sourceName, updated);
  }
  async getCalibration(operatorId, sourceName) {
    return this.getMultiplier(operatorId, sourceName);
  }
  /**
   * Inspect the internal state in tests without going through the public API.
   */
  snapshot() {
    return new Map(
      [...this.store.entries()].map(([opId, inner]) => [opId, new Map(inner)])
    );
  }
};

// src/telegram/inline-keyboard.ts
function createInlineKeyboard(rows) {
  return { inline_keyboard: rows };
}
function actionButton(text, action, payload = "") {
  const data = payload ? `${action}|${payload}` : action;
  if (data.length > 64) {
    throw new Error(
      `actionButton: callback_data exceeds Telegram's 64-byte limit (got ${data.length})`
    );
  }
  return { text, callback_data: data };
}

// src/telegram/formatting.ts
var HTML_ENTITIES = {
  "&": "&amp;",
  "<": "&lt;",
  ">": "&gt;"
};
function escapeHtml(text) {
  return text.replace(/[&<>]/g, (ch) => HTML_ENTITIES[ch] ?? ch);
}
function formatBold(text) {
  return `<b>${escapeHtml(text)}</b>`;
}
function formatItalic(text) {
  return `<i>${escapeHtml(text)}</i>`;
}

// src/proactive-surfacing/surfacing-channel-telegram.ts
var URGENCY_PREFIX = {
  now: "\u26A1",
  morning_brief: "\u{1F4CB}",
  weekly_brief: "\u{1F4C5}"
};
var PS_PREFIX = "ps";
function buildBody(candidate, score) {
  const prefix = URGENCY_PREFIX[score.urgency] ?? "\u2022";
  const urgencyLabel = score.urgency === "now" ? "Now" : score.urgency === "morning_brief" ? "Briefing" : "Weekly";
  const titleRaw = typeof candidate.payload["title"] === "string" ? candidate.payload["title"] : `Signal from ${candidate.sourceName}`;
  const summaryRaw = typeof candidate.payload["summary"] === "string" ? candidate.payload["summary"] : void 0;
  const lines = [
    `${prefix} ${formatBold(escapeHtml(`[${urgencyLabel}] ${titleRaw}`))}`
  ];
  if (summaryRaw) {
    lines.push(escapeHtml(summaryRaw));
  }
  lines.push("", formatItalic(escapeHtml(score.rationale)));
  return lines.join("\n");
}
function shortId(id) {
  return id.replace(/-/g, "").slice(0, 8);
}
var TelegramSurfacingChannel = class {
  constructor(config) {
    this.config = config;
  }
  config;
  name = "telegram";
  format(candidate, score, _operatorContext) {
    const body = buildBody(candidate, score);
    const sid = shortId(candidate.id);
    const cta = [
      { label: "\u2713 Acted", action: `${PS_PREFIX}_acted|${sid}` },
      { label: "\u25B2 More like this", action: `${PS_PREFIX}_more|${sid}` },
      { label: "\u25BC Less like this", action: `${PS_PREFIX}_less|${sid}` },
      { label: "\u2715 Dismiss", action: `${PS_PREFIX}_dismissed|${sid}` }
    ];
    return { candidate, score, channel: this.name, body, cta };
  }
  async send(message) {
    const chatId = this.config.defaultChatId;
    try {
      const keyboard = message.cta && message.cta.length > 0 ? createInlineKeyboard([
        message.cta.map((c) => actionButton(c.label, c.action))
      ]) : void 0;
      await this.config.bot.sendMessage(chatId, message.body, {
        parseMode: "HTML",
        replyMarkup: keyboard
      });
      return { delivered: true, deliveredAt: /* @__PURE__ */ new Date() };
    } catch (err) {
      const error = err instanceof Error ? err.message : String(err);
      return { delivered: false, error };
    }
  }
};
function parseSurfacingCallback(callbackData) {
  const RESPONSE_MAP = {
    ps_acted: "acted",
    ps_dismissed: "dismissed",
    ps_more: "more_like_this",
    ps_less: "less_like_this"
  };
  const [actionPart, shortId3] = callbackData.split("|");
  if (!actionPart || !shortId3) return null;
  const response = RESPONSE_MAP[actionPart];
  if (!response) return null;
  return { response, shortCandidateId: shortId3 };
}

// src/proactive-surfacing/surfacing-channel-dashboard.ts
var InMemorySurfacingMessageQueue = class {
  byOperator = /* @__PURE__ */ new Map();
  async enqueue(message) {
    const existing = this.byOperator.get(message.operatorId) ?? [];
    existing.push(message);
    this.byOperator.set(message.operatorId, existing);
  }
  async list(operatorId, limit = 50) {
    const messages = this.byOperator.get(operatorId) ?? [];
    return [...messages].sort((a, b) => b.enqueuedAt.getTime() - a.enqueuedAt.getTime()).slice(0, limit);
  }
  async markRead(operatorId, messageId) {
    const messages = this.byOperator.get(operatorId);
    if (!messages) return;
    const msg = messages.find((m) => m.id === messageId);
    if (msg) {
      msg.readAt = /* @__PURE__ */ new Date();
    }
  }
  /**
   * Return the raw internal map for test assertions.
   * Do not mutate the returned map.
   */
  snapshot() {
    return this.byOperator;
  }
};
var URGENCY_PREFIX2 = {
  now: "\u26A1",
  morning_brief: "\u{1F4CB}",
  weekly_brief: "\u{1F4C5}"
};
var PS_PREFIX2 = "ps";
function shortId2(id) {
  return id.replace(/-/g, "").slice(0, 8);
}
function buildMarkdownBody(candidate, score) {
  const prefix = URGENCY_PREFIX2[score.urgency] ?? "\u2022";
  const urgencyLabel = score.urgency === "now" ? "Now" : score.urgency === "morning_brief" ? "Briefing" : "Weekly";
  const titleRaw = typeof candidate.payload["title"] === "string" ? candidate.payload["title"] : `Signal from ${candidate.sourceName}`;
  const summaryRaw = typeof candidate.payload["summary"] === "string" ? candidate.payload["summary"] : void 0;
  const lines = [
    `${prefix} **[${urgencyLabel}] ${titleRaw}**`
  ];
  if (summaryRaw) {
    lines.push("", summaryRaw);
  }
  lines.push("", `_${score.rationale}_`);
  return lines.join("\n");
}
var DashboardSurfacingChannel = class {
  constructor(queue) {
    this.queue = queue;
  }
  queue;
  name = "dashboard";
  /**
   * WeakMap captures the operatorId from format() so that send() can enqueue
   * to the correct operator inbox. The pipeline always calls format() then
   * send() in sequence on the same message object, so the entry is always
   * present by the time send() runs.
   */
  _operatorIds = /* @__PURE__ */ new WeakMap();
  format(candidate, score, operatorContext) {
    const body = buildMarkdownBody(candidate, score);
    const sid = shortId2(candidate.id);
    const cta = [
      { label: "Act", action: `${PS_PREFIX2}_acted|${sid}` },
      { label: "Dismiss", action: `${PS_PREFIX2}_dismissed|${sid}` },
      { label: "More like this", action: `${PS_PREFIX2}_more|${sid}` },
      { label: "Less like this", action: `${PS_PREFIX2}_less|${sid}` }
    ];
    const message = {
      candidate,
      score,
      channel: this.name,
      body,
      cta
    };
    this._operatorIds.set(message, operatorContext.operatorId);
    return message;
  }
  async send(message) {
    try {
      const operatorId = this._operatorIds.get(message) ?? "unknown";
      const sid = shortId2(message.candidate.id);
      await this.queue.enqueue({
        id: `${message.candidate.sourceName}-${sid}-${Date.now()}`,
        operatorId,
        sourceName: message.candidate.sourceName,
        body: message.body,
        cta: message.cta,
        score: message.score.score,
        urgency: message.score.urgency,
        candidatePayload: message.candidate.payload,
        enqueuedAt: /* @__PURE__ */ new Date()
      });
      return { delivered: true, deliveredAt: /* @__PURE__ */ new Date() };
    } catch (err) {
      return {
        delivered: false,
        error: err instanceof Error ? err.message : String(err)
      };
    }
  }
};

// src/proactive-surfacing/proactive-surfacing-pipeline.ts
var URGENCY_NOW_THRESHOLD3 = 0.85;
var URGENCY_BRIEF_THRESHOLD3 = 0.6;
function deriveUrgency(score) {
  if (score >= URGENCY_NOW_THRESHOLD3) return "now";
  if (score >= URGENCY_BRIEF_THRESHOLD3) return "morning_brief";
  return "weekly_brief";
}
var NULL_LOGGER = {
  info: () => void 0,
  warn: () => void 0,
  error: () => void 0
};
var ProactiveSurfacingPipeline = class {
  constructor(triggerSources, scorer, channels, calibrationLearner, options = {}) {
    this.triggerSources = triggerSources;
    this.scorer = scorer;
    this.channels = channels;
    this.calibrationLearner = calibrationLearner;
    this.scoreThreshold = options.scoreThreshold ?? 0.6;
    this.fallbackChannel = options.fallbackChannel ?? "telegram";
    this.logger = options.logger ?? NULL_LOGGER;
    this.debouncer = options.debouncer;
    this.aggregator = options.aggregator;
    this.operatorPreferencesStore = options.operatorPreferencesStore;
  }
  triggerSources;
  scorer;
  channels;
  calibrationLearner;
  scoreThreshold;
  fallbackChannel;
  logger;
  debouncer;
  aggregator;
  operatorPreferencesStore;
  /**
   * Run one pipeline cycle:
   *   0. Resolve per-operator preferences (if store is configured)
   *   1. Fetch candidates from all registered TriggerSources
   *   2. Filter expired candidates
   *   3. Score each candidate
   *   4. Apply perSourceCalibration (scale scores; skip disabled sources)
   *   5. Filter below effective threshold (pipeline default or operator override)
   *   6. For each above-threshold candidate:
   *      a. Check debouncer — skip if blocked
   *      b. Route through aggregator (non-now) or send immediately (now)
   *   7. Return all sent messages (immediate + auto-flushed bundles)
   */
  async run(operatorContext, since) {
    let prefs = null;
    if (this.operatorPreferencesStore) {
      prefs = await this.operatorPreferencesStore.get(operatorContext.operatorId).catch((err) => {
        this.logger.warn("OperatorPreferencesStore.get failed \u2014 using defaults", {
          operatorId: operatorContext.operatorId,
          error: String(err)
        });
        return null;
      });
    }
    const effectiveThreshold = prefs?.scoreThreshold ?? this.scoreThreshold;
    const effectiveContext = prefs?.preferredChannels ? { ...operatorContext, preferredChannels: prefs.preferredChannels } : operatorContext;
    if (prefs) {
      this.logger.info("Operator preferences applied", {
        operatorId: operatorContext.operatorId,
        effectiveThreshold,
        preferredChannels: effectiveContext.preferredChannels,
        perSourceCalibration: prefs.perSourceCalibration ? Object.keys(prefs.perSourceCalibration) : void 0
      });
    }
    const candidateLists = await Promise.all(
      this.triggerSources.map(
        (source) => source.fetchCandidates(effectiveContext, since).catch((err) => {
          this.logger.error("TriggerSource.fetchCandidates failed", {
            source: source.name,
            error: String(err)
          });
          return [];
        })
      )
    );
    const candidates = candidateLists.flat();
    this.logger.info("Candidates fetched", {
      count: candidates.length,
      operatorId: operatorContext.operatorId
    });
    const now = /* @__PURE__ */ new Date();
    const live = candidates.filter(
      (c) => !c.expiresAt || c.expiresAt > now
    );
    if (live.length < candidates.length) {
      this.logger.info("Expired candidates dropped", {
        dropped: candidates.length - live.length
      });
    }
    const scored = await Promise.all(
      live.map(async (candidate) => {
        const score = await this.scorer.score(candidate, effectiveContext);
        return { candidate, score };
      })
    );
    const aboveThreshold = [];
    for (const { candidate, score } of scored) {
      let effectiveScore = score;
      if (prefs?.perSourceCalibration) {
        const sourceCal = prefs.perSourceCalibration[candidate.sourceName];
        if (sourceCal !== void 0) {
          if (!sourceCal.enabled) {
            this.logger.info(
              "Candidate suppressed by operator perSourceCalibration (disabled)",
              {
                candidateId: candidate.id,
                sourceName: candidate.sourceName
              }
            );
            continue;
          }
          const adjustedScore = Math.min(
            1,
            Math.max(0, score.score * sourceCal.multiplier)
          );
          if (adjustedScore !== score.score) {
            effectiveScore = {
              ...score,
              score: adjustedScore,
              urgency: deriveUrgency(adjustedScore),
              rationale: `${score.rationale}; \xD7${sourceCal.multiplier} operator-pref`
            };
          }
        }
      }
      if (effectiveScore.score >= effectiveThreshold) {
        aboveThreshold.push({ candidate, score: effectiveScore });
      }
    }
    this.logger.info("Candidates above threshold", {
      threshold: effectiveThreshold,
      above: aboveThreshold.length,
      below: scored.length - aboveThreshold.length
    });
    const sent = [];
    for (const { candidate, score } of aboveThreshold) {
      if (this.debouncer) {
        const { allow, reason } = await this.debouncer.shouldSurface(
          effectiveContext.operatorId,
          candidate.sourceName,
          score.urgency
        );
        if (!allow) {
          this.logger.info("Candidate suppressed by debouncer", {
            candidateId: candidate.id,
            sourceName: candidate.sourceName,
            reason
          });
          continue;
        }
      }
      const channel = this.pickChannel(effectiveContext);
      if (!channel) {
        this.logger.warn("No delivery channel available", {
          candidateId: candidate.id,
          preferredChannels: effectiveContext.preferredChannels
        });
        continue;
      }
      if (this.aggregator && score.urgency !== "now") {
        const autoFlushed = this.aggregator.enqueue(
          candidate,
          score,
          channel,
          effectiveContext
        );
        if (autoFlushed.length > 0) {
          for (const bundled of autoFlushed) {
            const deliveryChannel = this.pickChannel(effectiveContext);
            if (!deliveryChannel) continue;
            const result2 = await deliveryChannel.send(bundled);
            if (result2.delivered) {
              sent.push(bundled);
              this.logger.info("Bundled message delivered (auto-flush)", {
                operatorId: effectiveContext.operatorId,
                channel: deliveryChannel.name
              });
            } else {
              this.logger.error("Bundled message delivery failed", {
                channel: deliveryChannel.name,
                error: result2.error
              });
            }
          }
        } else {
          this.logger.info("Candidate queued in aggregator", {
            candidateId: candidate.id,
            urgency: score.urgency
          });
        }
        if (this.debouncer) {
          await this.debouncer.recordSurfaced(
            effectiveContext.operatorId,
            candidate.sourceName
          );
        }
        continue;
      }
      const message = channel.format(candidate, score, effectiveContext);
      const result = await channel.send(message);
      if (result.delivered) {
        sent.push(message);
        this.logger.info("Candidate surfaced", {
          candidateId: candidate.id,
          channel: channel.name,
          score: score.score,
          urgency: score.urgency
        });
        if (this.debouncer) {
          await this.debouncer.recordSurfaced(
            effectiveContext.operatorId,
            candidate.sourceName
          );
        }
      } else {
        this.logger.error("Delivery failed", {
          candidateId: candidate.id,
          channel: channel.name,
          error: result.error
        });
      }
    }
    return sent;
  }
  /**
   * Record an operator response for a previously surfaced candidate.
   * Delegates to CalibrationLearner — updates the multiplier for this
   * (operatorId, sourceName) pair.
   */
  async recordResponse(operatorId, sourceName, candidate, response) {
    return this.calibrationLearner.record(
      operatorId,
      sourceName,
      candidate,
      response
    );
  }
  /**
   * Pick the first channel from the operator's preferredChannels that is
   * registered, falling back to the configured fallback (default "telegram").
   */
  pickChannel(operatorContext) {
    const preferred = operatorContext.preferredChannels ?? [];
    for (const channelName of preferred) {
      const channel = this.channels.get(channelName);
      if (channel) return channel;
    }
    return this.channels.get(this.fallbackChannel) ?? null;
  }
};

// src/proactive-surfacing/rate-limiter.ts
var ONE_HOUR_MS = 60 * 60 * 1e3;
var ONE_DAY_MS = 24 * ONE_HOUR_MS;
var DEFAULT_LIMITS = { maxPerHour: 5, maxPerDay: 20 };
var RateLimiter = class {
  constructor(store, config = {}) {
    this.store = store;
    this.defaults = config.default ?? DEFAULT_LIMITS;
    this.perSource = config.perSource ?? {};
  }
  store;
  defaults;
  perSource;
  /**
   * Resolve the effective limits for a source — perSource override merged
   * over defaults.
   */
  limitsFor(sourceName) {
    const override = this.perSource[sourceName];
    return {
      maxPerHour: override?.maxPerHour ?? this.defaults.maxPerHour,
      maxPerDay: override?.maxPerDay ?? this.defaults.maxPerDay
    };
  }
  /**
   * Return `{ allow: true }` if both hourly and daily quotas have headroom;
   * `{ allow: false, reason }` with the first blocking quota otherwise.
   */
  async allow(operatorId, sourceName) {
    const { maxPerHour, maxPerDay } = this.limitsFor(sourceName);
    const hourCount = await this.store.countRecentEvents(
      operatorId,
      sourceName,
      ONE_HOUR_MS
    );
    if (hourCount >= maxPerHour) {
      return {
        allow: false,
        reason: `hourly limit (${hourCount}/${maxPerHour})`
      };
    }
    const dayCount = await this.store.countRecentEvents(
      operatorId,
      sourceName,
      ONE_DAY_MS
    );
    if (dayCount >= maxPerDay) {
      return {
        allow: false,
        reason: `daily limit (${dayCount}/${maxPerDay})`
      };
    }
    return { allow: true };
  }
  /** Record a successful emission. Call after delivery succeeds. */
  async record(operatorId, sourceName) {
    return this.store.recordSurfacingEvent(operatorId, sourceName, /* @__PURE__ */ new Date());
  }
};

// src/proactive-surfacing/debouncer.ts
var ONE_DAY_MS2 = 24 * 60 * 60 * 1e3;
var InMemoryDebounceStore = class {
  // Map<`${operatorId}:${sourceName}`, Date[]>
  store = /* @__PURE__ */ new Map();
  storeKey(operatorId, sourceName) {
    return `${operatorId}:${sourceName}`;
  }
  trimOlderThan24h(entries) {
    const cutoff = new Date(Date.now() - ONE_DAY_MS2);
    return entries.filter((d) => d > cutoff);
  }
  async recordSurfacingEvent(operatorId, sourceName, timestamp) {
    const key = this.storeKey(operatorId, sourceName);
    const existing = this.trimOlderThan24h(this.store.get(key) ?? []);
    existing.push(timestamp);
    this.store.set(key, existing);
  }
  async countRecentEvents(operatorId, sourceName, windowMs) {
    const key = this.storeKey(operatorId, sourceName);
    const trimmed = this.trimOlderThan24h(this.store.get(key) ?? []);
    this.store.set(key, trimmed);
    const cutoff = new Date(Date.now() - windowMs);
    return trimmed.filter((d) => d > cutoff).length;
  }
};
var DEFAULT_CONFIG = {
  maxPerHour: 5,
  maxPerDay: 20
};
var Debouncer = class {
  config;
  rateLimiter;
  constructor(store, config = {}) {
    this.config = { ...DEFAULT_CONFIG, ...config };
    this.rateLimiter = new RateLimiter(store, {
      default: {
        maxPerHour: this.config.maxPerHour,
        maxPerDay: this.config.maxPerDay
      }
    });
  }
  /**
   * Determine whether a candidate should be surfaced right now.
   *
   * Returns `{ allow: true }` when all guards pass, or
   * `{ allow: false, reason: "..." }` with the first blocking reason.
   */
  async shouldSurface(operatorId, sourceName, candidateUrgency) {
    const isNow = candidateUrgency === "now";
    if (!isNow && this.config.quietHours) {
      if (this.isInQuietHours(this.config.quietHours)) {
        return { allow: false, reason: "quiet hours" };
      }
    }
    return this.rateLimiter.allow(operatorId, sourceName);
  }
  /** Record that a candidate was surfaced. Call after successful delivery. */
  async recordSurfaced(operatorId, sourceName) {
    return this.rateLimiter.record(operatorId, sourceName);
  }
  isInQuietHours(quietHours) {
    const now = /* @__PURE__ */ new Date();
    let hour;
    try {
      const parts = new Intl.DateTimeFormat("en-US", {
        timeZone: quietHours.tz,
        hour: "numeric",
        hour12: false
      }).formatToParts(now);
      const hourPart = parts.find((p) => p.type === "hour");
      hour = hourPart ? parseInt(hourPart.value, 10) % 24 : now.getHours();
    } catch {
      hour = now.getUTCHours();
    }
    const { start, end } = quietHours;
    if (start > end) {
      return hour >= start || hour < end;
    }
    return hour >= start && hour < end;
  }
};

// src/proactive-surfacing/debounce-store-prisma.ts
var PrismaDebounceStore = class {
  constructor(prisma) {
    this.prisma = prisma;
  }
  prisma;
  async recordSurfacingEvent(operatorId, sourceName, timestamp) {
    await this.prisma.surfacingEmissionLog.create({
      data: { operatorId, sourceName, emittedAt: timestamp }
    });
  }
  async countRecentEvents(operatorId, sourceName, windowMs) {
    const cutoff = new Date(Date.now() - windowMs);
    return this.prisma.surfacingEmissionLog.count({
      where: { operatorId, sourceName, emittedAt: { gt: cutoff } }
    });
  }
};

// src/proactive-surfacing/aggregator.ts
var VOLUME_THRESHOLD = 5;
var SurfacingAggregator = class {
  // Keyed by `${operatorId}:${urgency}` for per-operator-per-urgency isolation
  queues = /* @__PURE__ */ new Map();
  queueKey(operatorId, urgency) {
    return `${operatorId}:${urgency}`;
  }
  /**
   * Enqueue a non-"now" candidate for batched delivery.
   *
   * If the queue hits VOLUME_THRESHOLD after this enqueue, the queue is
   * automatically flushed and the bundled SurfacingMessage(s) are returned.
   * Otherwise returns an empty array.
   *
   * "now" urgency items are passed through with a no-op (returns []) — the
   * caller is responsible for sending them immediately.
   */
  enqueue(candidate, score, channel, operatorContext) {
    if (score.urgency === "now") {
      return [];
    }
    const key = this.queueKey(operatorContext.operatorId, score.urgency);
    const queue = this.queues.get(key) ?? [];
    queue.push({ candidate, score, channel, operatorContext });
    this.queues.set(key, queue);
    if (queue.length >= VOLUME_THRESHOLD) {
      return this.flush(operatorContext.operatorId, score.urgency);
    }
    return [];
  }
  /**
   * Flush all queued candidates for an (operatorId, urgency) pair into a
   * single bundled SurfacingMessage. Returns [] if the queue is empty.
   *
   * Calling flush() for urgency === "now" is always a no-op (returns []).
   */
  flush(operatorId, urgency) {
    if (urgency === "now") return [];
    const key = this.queueKey(operatorId, urgency);
    const queue = this.queues.get(key) ?? [];
    if (queue.length === 0) return [];
    this.queues.delete(key);
    const first = queue[0];
    const label = urgency === "morning_brief" ? "Morning Brief" : "Weekly Digest";
    const count = queue.length;
    const lines = queue.map((entry, idx) => {
      const title = typeof entry.candidate.payload["title"] === "string" ? entry.candidate.payload["title"] : entry.candidate.id;
      const summary = typeof entry.candidate.payload["summary"] === "string" ? ` \u2014 ${entry.candidate.payload["summary"]}` : "";
      return `${idx + 1}. <b>${title}</b> (${entry.candidate.sourceName}, score ${entry.score.score.toFixed(2)})${summary}`;
    });
    const body = [
      `\u{1F4CB} <b>${label} \u2014 ${count} signal${count !== 1 ? "s" : ""}</b>`,
      "",
      ...lines
    ].join("\n");
    const bundled = {
      candidate: first.candidate,
      score: {
        ...first.score,
        rationale: `Bundled ${count} ${urgency} candidates`
      },
      channel: first.channel.name,
      body
    };
    return [bundled];
  }
  /**
   * Return the current queue length for an (operatorId, urgency) pair.
   * Useful for tests and monitoring.
   */
  queueLength(operatorId, urgency) {
    const key = this.queueKey(operatorId, urgency);
    return (this.queues.get(key) ?? []).length;
  }
};

// src/proactive-surfacing/trigger-source-example.ts
var import_node_crypto = require("crypto");
var PipelineCoachingTriggerSource = class {
  constructor(dealFetcher, options = {}) {
    this.dealFetcher = dealFetcher;
    this.coldThresholdDays = options.coldThresholdDays ?? 14;
  }
  dealFetcher;
  name = "pipeline";
  coldThresholdDays;
  async fetchCandidates(operatorContext, since) {
    const deals = await this.dealFetcher.fetchDeals(
      operatorContext.operatorId,
      since
    );
    const coldCutoff = new Date(
      Date.now() - this.coldThresholdDays * 24 * 60 * 60 * 1e3
    );
    const now = /* @__PURE__ */ new Date();
    return deals.filter((deal) => deal.lastContactAt < coldCutoff).map((deal) => {
      const daysSinceLastContact = Math.floor(
        (now.getTime() - deal.lastContactAt.getTime()) / (24 * 60 * 60 * 1e3)
      );
      return {
        id: deal.dealId,
        sourceName: this.name,
        payload: {
          dealId: deal.dealId,
          dealName: deal.dealName,
          daysSinceLastContact,
          stage: deal.stage,
          signalRationale: deal.signalRationale ?? `No contact in ${daysSinceLastContact} days`,
          baseRelevance: deal.baseRelevance ?? 0.6,
          title: `Cold deal: ${deal.dealName}`,
          summary: `${daysSinceLastContact} days since last contact${deal.stage ? ` (${deal.stage})` : ""}. ${deal.signalRationale ?? "Consider a follow-up."}`
        },
        occurredAt: now
      };
    });
  }
};
var DummyTriggerSource = class {
  name;
  candidates;
  stableIds;
  cachedIds = [];
  constructor(name, candidates, options = {}) {
    this.name = name;
    this.candidates = candidates;
    this.stableIds = options.stableIds ?? false;
  }
  async fetchCandidates(_operatorContext, _since) {
    const now = /* @__PURE__ */ new Date();
    return this.candidates.map((c, idx) => {
      const id = this.stableIds ? this.cachedIds[idx] ?? (() => {
        const newId = (0, import_node_crypto.randomUUID)();
        this.cachedIds[idx] = newId;
        return newId;
      })() : (0, import_node_crypto.randomUUID)();
      return {
        id,
        sourceName: this.name,
        payload: {
          title: c.title,
          summary: c.summary,
          baseRelevance: c.baseRelevance,
          ...c.tags ? { tags: c.tags } : {}
        },
        occurredAt: now
      };
    });
  }
};
function makeDummyIntelSource() {
  return new DummyTriggerSource(
    "intel",
    [
      {
        title: "Retail crime networks \u2014 new WA Police intelligence brief",
        summary: "WAPOL released a coordinated theft intelligence brief covering organised retail crime networks in Perth metro. Directly relevant to active Gatha Security engagement.",
        baseRelevance: 0.9,
        tags: ["retail", "crime", "WA", "police"]
      },
      {
        title: "KPMG federal security contracts \u2014 budget update",
        summary: "Federal budget papers include a 12% reduction in Justice portfolio discretionary spend. May affect KPMG sub-contract pipeline.",
        baseRelevance: 0.65,
        tags: ["KPMG", "federal", "security", "budget"]
      },
      {
        title: "Industry event: Security Leaders Summit Perth",
        summary: "Annual summit on 14 July. Attendance list includes two target prospect organisations.",
        baseRelevance: 0.45,
        tags: ["networking", "perth", "security"]
      }
    ],
    { stableIds: true }
  );
}

// src/proactive-surfacing/operator-preferences.ts
var InMemoryOperatorPreferencesStore = class {
  store = /* @__PURE__ */ new Map();
  async get(operatorId) {
    return this.store.get(operatorId) ?? null;
  }
  async upsert(operatorId, prefs) {
    const existing = this.store.get(operatorId) ?? {};
    const merged = {
      ...existing,
      ...prefs,
      // Shallow-merge nested objects so partial updates don't wipe existing keys
      perSourceCalibration: prefs.perSourceCalibration !== void 0 ? { ...existing.perSourceCalibration, ...prefs.perSourceCalibration } : existing.perSourceCalibration,
      debounceConfig: prefs.debounceConfig !== void 0 ? { ...existing.debounceConfig, ...prefs.debounceConfig } : existing.debounceConfig
    };
    this.store.set(operatorId, merged);
    return merged;
  }
  /** Number of operators with stored preferences. Useful for test assertions. */
  size() {
    return this.store.size;
  }
  /** Clear all stored preferences (test teardown). */
  clear() {
    this.store.clear();
  }
};
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
  DashboardSurfacingChannel,
  Debouncer,
  DummyTriggerSource,
  InMemoryDebounceStore,
  InMemoryOperatorPreferencesStore,
  InMemorySurfacingMessageQueue,
  LLMAugmentedRelevanceScorer,
  MemoryCalibrationLearner,
  PipelineCoachingTriggerSource,
  PrismaDebounceStore,
  ProactiveSurfacingPipeline,
  RateLimiter,
  RuleBasedRelevanceScorer,
  SurfacingAggregator,
  TelegramSurfacingChannel,
  makeDummyIntelSource,
  parseSurfacingCallback
});
//# sourceMappingURL=index.js.map