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}