baa-conductor


baa-conductor / apps / conductor-daemon / src / renewal
im_wower  ·  2026-04-03

conversations.ts

  1import { randomUUID } from "node:crypto";
  2import type {
  3  ArtifactStore,
  4  ConversationAutomationStatus,
  5  ConversationPauseReason,
  6  ConversationLinkRecord,
  7  ListConversationLinksOptions,
  8  ListLocalConversationsOptions,
  9  LocalConversationRecord
 10} from "../../../../packages/artifact-db/dist/index.js";
 11
 12import type { BaaDeliveryRouteSnapshot } from "../artifacts/types.js";
 13import { buildConversationAutomationResetPatch } from "./automation.js";
 14import {
 15  normalizeOptionalString,
 16  normalizeRequiredString
 17} from "./utils.js";
 18
 19const LOCAL_CONVERSATION_ID_PREFIX = "lc_";
 20const CONVERSATION_LINK_ID_PREFIX = "link_";
 21const CONVERSATION_TARGET_ID_PREFIX = "conversation";
 22const DEFAULT_LINK_SCAN_LIMIT = 50;
 23
 24export interface ObserveRenewalConversationInput {
 25  assistantMessageId: string;
 26  clientId?: string | null;
 27  onDiagnostic?: RenewalConversationDiagnosticSink;
 28  observedAt: number;
 29  pageTitle?: string | null;
 30  pageUrl?: string | null;
 31  platform: string;
 32  remoteConversationId?: string | null;
 33  route?: BaaDeliveryRouteSnapshot | null;
 34  store: ArtifactStore;
 35}
 36
 37export interface ObserveRenewalConversationResult {
 38  conversation: LocalConversationRecord;
 39  created: boolean;
 40  link: ConversationLinkRecord;
 41  resolvedBy: "active_link" | "new" | "remote_conversation";
 42}
 43
 44export interface RenewalConversationDetail {
 45  activeLink: ConversationLinkRecord | null;
 46  conversation: LocalConversationRecord;
 47  links: ConversationLinkRecord[];
 48}
 49
 50export class RenewalConversationNotFoundError extends Error {
 51  constructor(readonly localConversationId: string) {
 52    super(`Local conversation "${localConversationId}" was not found.`);
 53  }
 54}
 55
 56interface NormalizedObservedRoute {
 57  legacyTargetId: string | null;
 58  pageTitle: string | null;
 59  pageUrl: string | null;
 60  remoteConversationId: string | null;
 61  routeParams: Record<string, string> | null;
 62  routePath: string | null;
 63  routePattern: string | null;
 64  targetId: string | null;
 65  targetKind: string;
 66  targetPayload: Record<string, unknown>;
 67}
 68
 69interface ExistingLinkResolution {
 70  link: ConversationLinkRecord | null;
 71  resolvedBy: ObserveRenewalConversationResult["resolvedBy"];
 72}
 73
 74interface ConversationLinkScore {
 75  matchedTargetId: boolean;
 76  weakSignalScore: number;
 77}
 78
 79type RenewalConversationDiagnosticOperation = "deactivate" | "resolve";
 80type RenewalConversationDiagnosticSignal = "page_title" | "page_url" | "route_path" | "target_id";
 81type ConversationLinkQueryField = "pageTitle" | "pageUrl" | "routePath" | "targetId";
 82
 83interface ConversationLinkCandidateQuery {
 84  field: ConversationLinkQueryField;
 85  signal: RenewalConversationDiagnosticSignal;
 86  value: string | null;
 87}
 88
 89export interface RenewalConversationDiagnostic {
 90  code: "conversation_link_scan_limit_reached";
 91  clientId: string | null;
 92  limit: number;
 93  offset: number;
 94  operation: RenewalConversationDiagnosticOperation;
 95  platform: string;
 96  signal: RenewalConversationDiagnosticSignal;
 97}
 98
 99export type RenewalConversationDiagnosticSink = (diagnostic: RenewalConversationDiagnostic) => void;
100
101export async function getRenewalConversationDetail(
102  store: ArtifactStore,
103  localConversationId: string,
104  linkLimit: number = DEFAULT_LINK_SCAN_LIMIT
105): Promise<RenewalConversationDetail | null> {
106  const conversation = await store.getLocalConversation(localConversationId);
107
108  if (conversation == null) {
109    return null;
110  }
111
112  const links = await store.listConversationLinks({
113    limit: linkLimit,
114    localConversationId
115  });
116
117  return {
118    activeLink: links.find((entry) => entry.isActive) ?? null,
119    conversation,
120    links
121  };
122}
123
124export async function listRenewalConversationDetails(
125  store: ArtifactStore,
126  options: ListLocalConversationsOptions = {},
127  linkLimit: number = DEFAULT_LINK_SCAN_LIMIT
128): Promise<RenewalConversationDetail[]> {
129  const conversations = await store.listLocalConversations(options);
130
131  return Promise.all(
132    conversations.map(async (conversation) => {
133      const links = await store.listConversationLinks({
134        limit: linkLimit,
135        localConversationId: conversation.localConversationId
136      });
137
138      return {
139        activeLink: links.find((entry) => entry.isActive) ?? null,
140        conversation,
141        links
142      };
143    })
144  );
145}
146
147export async function observeRenewalConversation(
148  input: ObserveRenewalConversationInput
149): Promise<ObserveRenewalConversationResult> {
150  const platform = normalizeRequiredString(input.platform, "platform", {
151    errorStyle: "field"
152  });
153  const observedAt = normalizeTimestamp(input.observedAt, "observedAt");
154  const clientId = normalizeOptionalString(input.clientId);
155  const observedRoute = buildObservedRoute(platform, {
156    clientId,
157    pageTitle: input.pageTitle,
158    pageUrl: input.pageUrl,
159    remoteConversationId: input.remoteConversationId,
160    route: input.route ?? null
161  });
162  const onDiagnostic = input.onDiagnostic ?? null;
163  const existingResolution = await resolveExistingConversationLink(input.store, {
164    clientId,
165    onDiagnostic,
166    observedRoute,
167    platform
168  });
169  let conversation = await loadOrCreateLocalConversation(input.store, {
170    assistantMessageId: input.assistantMessageId,
171    existingLocalConversationId: existingResolution.link?.localConversationId ?? null,
172    observedAt,
173    pageTitle: observedRoute.pageTitle,
174    platform
175  });
176
177  await deactivateConflictingConversationLinks(input.store, {
178    clientId,
179    keepLinkId: existingResolution.link?.linkId ?? null,
180    localConversationId: conversation.localConversationId,
181    onDiagnostic,
182    observedAt,
183    observedRoute,
184    platform
185  });
186
187  const link = await input.store.upsertConversationLink({
188    clientId,
189    createdAt: existingResolution.link?.createdAt ?? observedAt,
190    isActive: true,
191    linkId: existingResolution.link?.linkId ?? buildConversationLinkId(),
192    localConversationId: conversation.localConversationId,
193    observedAt,
194    pageTitle: observedRoute.pageTitle ?? undefined,
195    pageUrl: observedRoute.pageUrl ?? undefined,
196    platform,
197    remoteConversationId: observedRoute.remoteConversationId ?? undefined,
198    routeParams: observedRoute.routeParams ?? undefined,
199    routePath: observedRoute.routePath ?? undefined,
200    routePattern: observedRoute.routePattern ?? undefined,
201    targetId: observedRoute.targetId ?? undefined,
202    targetKind: observedRoute.targetKind ?? undefined,
203    targetPayload: Object.keys(observedRoute.targetPayload).length === 0 ? undefined : observedRoute.targetPayload,
204    updatedAt: observedAt
205  });
206
207  conversation = await input.store.upsertLocalConversation({
208    lastMessageAt: observedAt,
209    lastMessageId: normalizeRequiredString(input.assistantMessageId, "assistantMessageId", {
210      errorStyle: "field"
211    }),
212    localConversationId: conversation.localConversationId,
213    platform: conversation.platform,
214    title: observedRoute.pageTitle ?? undefined,
215    updatedAt: observedAt
216  });
217
218  return {
219    conversation,
220    created: existingResolution.link == null,
221    link,
222    resolvedBy: existingResolution.resolvedBy
223  };
224}
225
226export async function setRenewalConversationAutomationStatus(input: {
227  automationStatus: ConversationAutomationStatus;
228  localConversationId: string;
229  observedAt?: number | null;
230  pauseReason?: ConversationPauseReason | null;
231  store: ArtifactStore;
232}): Promise<RenewalConversationDetail> {
233  const localConversationId = normalizeRequiredString(input.localConversationId, "localConversationId", {
234    errorStyle: "field"
235  });
236  const existing = await input.store.getLocalConversation(localConversationId);
237
238  if (existing == null) {
239    throw new RenewalConversationNotFoundError(localConversationId);
240  }
241
242  const updatedAt = normalizeTimestamp(
243    input.observedAt ?? Date.now(),
244    "observedAt"
245  );
246  const automationStatus = input.automationStatus;
247  await input.store.upsertLocalConversation({
248    ...(automationStatus === "paused"
249      ? {
250          automationStatus,
251          pauseReason: input.pauseReason ?? "user_pause",
252          pausedAt: updatedAt
253        }
254      : buildConversationAutomationResetPatch({
255        automationStatus
256      })),
257    localConversationId,
258    platform: existing.platform,
259    updatedAt
260  });
261
262  const detail = await getRenewalConversationDetail(input.store, localConversationId);
263
264  if (detail == null) {
265    throw new RenewalConversationNotFoundError(localConversationId);
266  }
267
268  return detail;
269}
270
271function buildConversationLinkId(): string {
272  return `${CONVERSATION_LINK_ID_PREFIX}${randomUUID()}`;
273}
274
275function buildLocalConversationId(): string {
276  return `${LOCAL_CONVERSATION_ID_PREFIX}${randomUUID()}`;
277}
278
279async function deactivateConflictingConversationLinks(
280  store: ArtifactStore,
281  input: {
282    clientId: string | null;
283    keepLinkId: string | null;
284    localConversationId: string;
285    onDiagnostic: RenewalConversationDiagnosticSink | null;
286    observedAt: number;
287    observedRoute: NormalizedObservedRoute;
288    platform: string;
289  }
290): Promise<void> {
291  const activeLinks = await collectConversationLinkCandidates(store, {
292    clientId: input.clientId,
293    onDiagnostic: input.onDiagnostic,
294    operation: "deactivate",
295    platform: input.platform,
296    queries: [
297      {
298        field: "targetId",
299        signal: "target_id",
300        value: input.observedRoute.targetId
301      },
302      {
303        field: "targetId",
304        signal: "target_id",
305        value:
306          input.observedRoute.legacyTargetId != null && input.observedRoute.legacyTargetId !== input.observedRoute.targetId
307            ? input.observedRoute.legacyTargetId
308            : null
309      },
310      {
311        field: "pageUrl",
312        signal: "page_url",
313        value: input.observedRoute.pageUrl
314      },
315      {
316        field: "routePath",
317        signal: "route_path",
318        value: input.observedRoute.routePath
319      }
320    ]
321  });
322
323  for (const candidate of activeLinks) {
324    if (candidate.linkId === input.keepLinkId || candidate.isActive !== true) {
325      continue;
326    }
327
328    if (candidate.localConversationId === input.localConversationId) {
329      continue;
330    }
331
332    if (!shouldDeactivateLink(candidate, input.observedRoute)) {
333      continue;
334    }
335
336    await store.upsertConversationLink({
337      isActive: false,
338      linkId: candidate.linkId,
339      localConversationId: candidate.localConversationId,
340      observedAt: candidate.observedAt,
341      platform: candidate.platform,
342      updatedAt: input.observedAt
343    });
344  }
345}
346
347function shouldDeactivateLink(
348  candidate: ConversationLinkRecord,
349  observedRoute: NormalizedObservedRoute
350): boolean {
351  if (observedRoute.targetId != null && candidate.targetId === observedRoute.targetId) {
352    return true;
353  }
354
355  if (observedRoute.legacyTargetId != null && candidate.targetId === observedRoute.legacyTargetId) {
356    return true;
357  }
358
359  if (observedRoute.pageUrl != null && candidate.pageUrl === observedRoute.pageUrl) {
360    return true;
361  }
362
363  return observedRoute.routePath != null && candidate.routePath === observedRoute.routePath;
364}
365
366function buildObservedRoute(
367  platform: string,
368  input: {
369    clientId: string | null;
370    pageTitle?: string | null;
371    pageUrl?: string | null;
372    remoteConversationId?: string | null;
373    route: BaaDeliveryRouteSnapshot | null;
374  }
375): NormalizedObservedRoute {
376  const explicitPageTitle = normalizeOptionalString(input.pageTitle);
377  const explicitPageUrl = normalizeOptionalString(input.pageUrl);
378  const routePageTitle = normalizeOptionalString(input.route?.pageTitle);
379  const routePageUrl = normalizeOptionalString(input.route?.pageUrl);
380  const pageTitle = explicitPageTitle ?? routePageTitle;
381  const pageUrl = explicitPageUrl ?? routePageUrl;
382  const remoteConversationId =
383    normalizeOptionalString(input.remoteConversationId)
384    ?? normalizeOptionalString(input.route?.conversationId)
385    ?? extractConversationIdFromPageUrl(platform, pageUrl);
386  const routeSnapshot = buildRouteSnapshot(platform, pageUrl, remoteConversationId);
387  const tabId = input.route?.tabId ?? null;
388  const shellPage = input.route?.shellPage === true;
389  const legacyTargetId = Number.isInteger(tabId) ? `tab:${tabId}` : null;
390  const targetId = buildObservedTargetId({
391    clientId: input.clientId,
392    pageUrl,
393    platform,
394    remoteConversationId,
395    shellPage,
396    tabId
397  });
398
399  return {
400    legacyTargetId,
401    pageTitle,
402    pageUrl,
403    remoteConversationId,
404    routeParams: routeSnapshot.routeParams,
405    routePath: routeSnapshot.routePath,
406    routePattern: routeSnapshot.routePattern,
407    targetId,
408    targetKind: shellPage ? "browser.shell_page" : "browser.proxy_delivery",
409    targetPayload: compactObject({
410      clientId: input.clientId ?? undefined,
411      conversationId: remoteConversationId ?? undefined,
412      organizationId: normalizeOptionalString(input.route?.organizationId) ?? undefined,
413      pageTitle: pageTitle ?? undefined,
414      pageUrl: pageUrl ?? undefined,
415      shellPage: shellPage ? true : undefined,
416      tabId: tabId ?? undefined
417    })
418  };
419}
420
421function buildObservedTargetId(input: {
422  clientId: string | null;
423  pageUrl: string | null;
424  platform: string;
425  remoteConversationId: string | null;
426  shellPage: boolean;
427  tabId: number | null;
428}): string | null {
429  if (!input.shellPage && input.remoteConversationId != null) {
430    return buildConversationTargetId(input.platform, input.remoteConversationId);
431  }
432
433  if (Number.isInteger(input.tabId)) {
434    return `tab:${input.tabId}`;
435  }
436
437  return input.pageUrl ?? input.clientId ?? null;
438}
439
440function buildConversationTargetId(platform: string, remoteConversationId: string): string {
441  return `${CONVERSATION_TARGET_ID_PREFIX}:${platform}:${encodeURIComponent(remoteConversationId)}`;
442}
443
444function buildRouteSnapshot(
445  platform: string,
446  pageUrl: string | null,
447  remoteConversationId: string | null
448): Pick<NormalizedObservedRoute, "routeParams" | "routePath" | "routePattern"> {
449  if (pageUrl == null) {
450    return {
451      routeParams: remoteConversationId == null ? null : { conversationId: remoteConversationId },
452      routePath: null,
453      routePattern: null
454    };
455  }
456
457  let parsedUrl: URL;
458
459  try {
460    parsedUrl = new URL(pageUrl, resolvePlatformOrigin(platform));
461  } catch {
462    return {
463      routeParams: remoteConversationId == null ? null : { conversationId: remoteConversationId },
464      routePath: null,
465      routePattern: null
466    };
467  }
468
469  const routePath = normalizePathname(parsedUrl.pathname);
470  const params = remoteConversationId == null ? null : { conversationId: remoteConversationId };
471
472  switch (platform) {
473    case "chatgpt":
474      return {
475        routeParams: params,
476        routePath,
477        routePattern: remoteConversationId != null && /^\/c\/[^/]+$/u.test(routePath)
478          ? "/c/:conversationId"
479          : routePath
480      };
481    case "gemini":
482      return {
483        routeParams: params,
484        routePath,
485        routePattern: remoteConversationId != null && /^\/app\/[^/]+$/u.test(routePath)
486          ? "/app/:conversationId"
487          : routePath
488      };
489    case "claude":
490      return {
491        routeParams: params,
492        routePath,
493        routePattern: replaceTrailingConversationSegment(routePath, remoteConversationId)
494      };
495    default:
496      return {
497        routeParams: params,
498        routePath,
499        routePattern: routePath
500      };
501  }
502}
503
504function replaceTrailingConversationSegment(
505  routePath: string,
506  remoteConversationId: string | null
507): string {
508  if (remoteConversationId == null) {
509    return routePath;
510  }
511
512  const encodedConversationId = encodeURIComponent(remoteConversationId);
513
514  if (routePath === `/${encodedConversationId}`) {
515    return "/:conversationId";
516  }
517
518  if (routePath.endsWith(`/${encodedConversationId}`)) {
519    return `${routePath.slice(0, -encodedConversationId.length)}:conversationId`;
520  }
521
522  return routePath;
523}
524
525function compactObject(
526  input: Record<string, unknown>
527): Record<string, unknown> {
528  return Object.fromEntries(
529    Object.entries(input).filter(([, value]) => value !== undefined)
530  );
531}
532
533function extractConversationIdFromPageUrl(platform: string, pageUrl: string | null): string | null {
534  if (pageUrl == null) {
535    return null;
536  }
537
538  try {
539    const parsed = new URL(pageUrl, resolvePlatformOrigin(platform));
540
541    switch (platform) {
542      case "chatgpt": {
543        const chatgptMatch = normalizePathname(parsed.pathname).match(/^\/c\/([^/?#]+)/u);
544        return normalizeOptionalString(chatgptMatch?.[1]) ?? normalizeOptionalString(parsed.searchParams.get("conversation_id"));
545      }
546      case "gemini": {
547        const geminiMatch = normalizePathname(parsed.pathname).match(/^\/app\/([^/?#]+)/u);
548        return normalizeOptionalString(geminiMatch?.[1]) ?? normalizeOptionalString(parsed.searchParams.get("conversation_id"));
549      }
550      case "claude": {
551        const claudeMatch = normalizePathname(parsed.pathname).match(/([a-f0-9-]{36})(?:\/)?$/iu);
552        return normalizeOptionalString(claudeMatch?.[1]);
553      }
554      default:
555        return null;
556    }
557  } catch {
558    return null;
559  }
560}
561
562async function loadOrCreateLocalConversation(
563  store: ArtifactStore,
564  input: {
565    assistantMessageId: string;
566    existingLocalConversationId: string | null;
567    observedAt: number;
568    pageTitle: string | null;
569    platform: string;
570  }
571): Promise<LocalConversationRecord> {
572  const existingConversation = input.existingLocalConversationId == null
573    ? null
574    : await store.getLocalConversation(input.existingLocalConversationId);
575
576  if (existingConversation != null) {
577    return existingConversation;
578  }
579
580  return store.upsertLocalConversation({
581    automationStatus: "manual",
582    createdAt: input.observedAt,
583    lastMessageAt: input.observedAt,
584    lastMessageId: normalizeRequiredString(input.assistantMessageId, "assistantMessageId", {
585      errorStyle: "field"
586    }),
587    localConversationId: buildLocalConversationId(),
588    platform: input.platform,
589    title: input.pageTitle,
590    updatedAt: input.observedAt
591  });
592}
593
594async function resolveExistingConversationLink(
595  store: ArtifactStore,
596  input: {
597    clientId: string | null;
598    onDiagnostic: RenewalConversationDiagnosticSink | null;
599    observedRoute: NormalizedObservedRoute;
600    platform: string;
601  }
602): Promise<ExistingLinkResolution> {
603  if (input.observedRoute.remoteConversationId != null) {
604    const remoteConversationLink = await store.findConversationLinkByRemoteConversation(
605      input.platform,
606      input.observedRoute.remoteConversationId
607    );
608
609    if (remoteConversationLink != null) {
610      return {
611        link: remoteConversationLink,
612        resolvedBy: "remote_conversation"
613      };
614    }
615  }
616
617  if (
618    input.clientId == null
619    && input.observedRoute.targetId == null
620    && input.observedRoute.pageUrl == null
621    && input.observedRoute.routePath == null
622  ) {
623    return {
624      link: null,
625      resolvedBy: "new"
626    };
627  }
628
629  const candidates = await collectConversationLinkCandidates(store, {
630    clientId: input.clientId,
631    onDiagnostic: input.onDiagnostic,
632    operation: "resolve",
633    platform: input.platform,
634    queries: [
635      {
636        field: "targetId",
637        signal: "target_id",
638        value: input.observedRoute.targetId
639      },
640      {
641        field: "targetId",
642        signal: "target_id",
643        value:
644          input.observedRoute.legacyTargetId != null && input.observedRoute.legacyTargetId !== input.observedRoute.targetId
645            ? input.observedRoute.legacyTargetId
646            : null
647      },
648      {
649        field: "pageUrl",
650        signal: "page_url",
651        value: input.observedRoute.pageUrl
652      },
653      {
654        field: "routePath",
655        signal: "route_path",
656        value: input.observedRoute.routePath
657      },
658      {
659        field: "pageTitle",
660        signal: "page_title",
661        value: input.observedRoute.pageTitle
662      }
663    ]
664  });
665  let bestLink: ConversationLinkRecord | null = null;
666  let bestScore: ConversationLinkScore | null = null;
667
668  for (const candidate of candidates) {
669    const score = scoreConversationLink(candidate, input.observedRoute);
670
671    if (!score.matchedTargetId && score.weakSignalScore <= 0) {
672      continue;
673    }
674
675    if (
676      bestLink == null
677      || bestScore == null
678      || compareConversationLinkScores(score, bestScore, candidate, bestLink) > 0
679    ) {
680      bestLink = candidate;
681      bestScore = score;
682    }
683  }
684
685  return {
686    link: bestLink,
687    resolvedBy: bestLink == null ? "new" : "active_link"
688  };
689}
690
691async function collectConversationLinkCandidates(
692  store: ArtifactStore,
693  input: {
694    clientId: string | null;
695    onDiagnostic: RenewalConversationDiagnosticSink | null;
696    operation: RenewalConversationDiagnosticOperation;
697    platform: string;
698    queries: ConversationLinkCandidateQuery[];
699  }
700): Promise<ConversationLinkRecord[]> {
701  const candidates = new Map<string, ConversationLinkRecord>();
702
703  for (const query of input.queries) {
704    if (query.value == null) {
705      continue;
706    }
707
708    let offset = 0;
709    let warned = false;
710
711    while (true) {
712      const page = await store.listConversationLinks(
713        buildConversationLinkCandidateFilters(input.clientId, input.platform, query, offset)
714      );
715
716      for (const candidate of page) {
717        candidates.set(candidate.linkId, candidate);
718      }
719
720      if (page.length < DEFAULT_LINK_SCAN_LIMIT) {
721        break;
722      }
723
724      if (!warned) {
725        reportConversationLinkScanLimitReached({
726          clientId: input.clientId,
727          limit: DEFAULT_LINK_SCAN_LIMIT,
728          offset,
729          onDiagnostic: input.onDiagnostic,
730          operation: input.operation,
731          platform: input.platform,
732          signal: query.signal
733        });
734        warned = true;
735      }
736
737      offset += page.length;
738    }
739  }
740
741  return [...candidates.values()];
742}
743
744function buildConversationLinkCandidateFilters(
745  clientId: string | null,
746  platform: string,
747  query: ConversationLinkCandidateQuery,
748  offset: number
749): ListConversationLinksOptions {
750  const filters: ListConversationLinksOptions = {
751    isActive: true,
752    limit: DEFAULT_LINK_SCAN_LIMIT,
753    offset,
754    platform
755  };
756
757  if (clientId != null) {
758    filters.clientId = clientId;
759  }
760
761  switch (query.field) {
762    case "pageTitle":
763      filters.pageTitle = query.value ?? undefined;
764      return filters;
765    case "pageUrl":
766      filters.pageUrl = query.value ?? undefined;
767      return filters;
768    case "routePath":
769      filters.routePath = query.value ?? undefined;
770      return filters;
771    case "targetId":
772      filters.targetId = query.value ?? undefined;
773      return filters;
774  }
775}
776
777function reportConversationLinkScanLimitReached(input: {
778  clientId: string | null;
779  limit: number;
780  offset: number;
781  onDiagnostic: RenewalConversationDiagnosticSink | null;
782  operation: RenewalConversationDiagnosticOperation;
783  platform: string;
784  signal: RenewalConversationDiagnosticSignal;
785}): void {
786  const diagnostic: RenewalConversationDiagnostic = {
787    clientId: input.clientId,
788    code: "conversation_link_scan_limit_reached",
789    limit: input.limit,
790    offset: input.offset,
791    operation: input.operation,
792    platform: input.platform,
793    signal: input.signal
794  };
795
796  if (input.onDiagnostic != null) {
797    input.onDiagnostic(diagnostic);
798    return;
799  }
800
801  console.warn(
802    `[baa-renewal] conversation link scan limit reached `
803      + `(operation=${input.operation} signal=${input.signal} `
804      + `platform=${input.platform} clientId=${input.clientId ?? "any"} `
805      + `limit=${input.limit} offset=${input.offset}); continuing with pagination.`
806  );
807}
808
809function compareConversationLinkScores(
810  candidateScore: ConversationLinkScore,
811  bestScore: ConversationLinkScore,
812  candidate: ConversationLinkRecord,
813  bestLink: ConversationLinkRecord
814): number {
815  if (candidateScore.matchedTargetId !== bestScore.matchedTargetId) {
816    return candidateScore.matchedTargetId ? 1 : -1;
817  }
818
819  if (candidateScore.weakSignalScore !== bestScore.weakSignalScore) {
820    return candidateScore.weakSignalScore - bestScore.weakSignalScore;
821  }
822
823  return candidate.observedAt - bestLink.observedAt;
824}
825
826function scoreConversationLink(
827  candidate: ConversationLinkRecord,
828  observedRoute: NormalizedObservedRoute
829): ConversationLinkScore {
830  const matchedTargetId =
831    observedRoute.targetId != null
832    && candidate.targetId === observedRoute.targetId;
833  let weakSignalScore = 0;
834
835  if (observedRoute.pageUrl != null && candidate.pageUrl === observedRoute.pageUrl) {
836    weakSignalScore += 80;
837  }
838
839  if (observedRoute.routePath != null && candidate.routePath === observedRoute.routePath) {
840    weakSignalScore += 60;
841  }
842
843  if (observedRoute.pageTitle != null && candidate.pageTitle === observedRoute.pageTitle) {
844    weakSignalScore += 10;
845  }
846
847  return {
848    matchedTargetId,
849    weakSignalScore
850  };
851}
852
853function normalizePathname(value: string): string {
854  const normalized = value.replace(/\/+$/u, "");
855  return normalized === "" ? "/" : normalized;
856}
857
858function normalizeTimestamp(value: unknown, fieldName: string): number {
859  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
860    throw new Error(`Field "${fieldName}" must be a positive finite number.`);
861  }
862
863  return Math.round(value);
864}
865
866function resolvePlatformOrigin(platform: string): string {
867  switch (platform) {
868    case "claude":
869      return "https://claude.ai/";
870    case "chatgpt":
871      return "https://chatgpt.com/";
872    case "gemini":
873      return "https://gemini.google.com/";
874    default:
875      return "https://example.invalid/";
876  }
877}