@sudobility/sider_lib 0.0.12 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,80 @@
1
+ /** A stored conversation, as much of one as the rules here need. */
2
+ export interface ChatRecord {
3
+ id: string;
4
+ /** Registrable domain, e.g. `ebay.com`. The chat's identity. */
5
+ domain: string;
6
+ /** Epoch ms of the last message either way. The retention clock. */
7
+ lastActivityAt: number;
8
+ /** Epoch ms the chat was started. Shown, never used to expire. */
9
+ createdAt: number;
10
+ /** The page the conversation was last on, so reopening it returns there. */
11
+ lastUrl?: string;
12
+ /** First user message, for the list. Absent until they say something. */
13
+ title?: string;
14
+ /** How many messages it holds. A chat with none is not history. */
15
+ messageCount: number;
16
+ }
17
+ /** How long a chat survives without activity. */
18
+ export declare const RETENTION_CHOICES: readonly [1, 3, 7];
19
+ export type RetentionDays = (typeof RETENTION_CHOICES)[number];
20
+ export declare const DEFAULT_RETENTION_DAYS: RetentionDays;
21
+ /**
22
+ * How many chats are kept at most, whatever the retention says.
23
+ *
24
+ * localStorage is a handful of megabytes and a full one throws on write, which
25
+ * would lose the conversation being saved rather than an old one. A cap turns
26
+ * that into a prune of the least recently used, which is the outcome anyone
27
+ * would have chosen.
28
+ */
29
+ export declare const MAX_CHATS = 100;
30
+ /** Title shown before the user has said anything. */
31
+ export declare const UNTITLED = "New chat";
32
+ /**
33
+ * The domain a chat belongs to.
34
+ *
35
+ * Returns "" for anything without a real host — a new tab, a file, an
36
+ * extension page — which the caller reads as "no site here to chat about".
37
+ */
38
+ export declare function registrableDomain(url: string): string;
39
+ /**
40
+ * The chat to show for a domain: its most recently active one.
41
+ *
42
+ * Most recent rather than most recently created, because "bring chat1 back"
43
+ * means the conversation they were last having there, and a chat they started
44
+ * and abandoned should not outrank the one they actually used.
45
+ */
46
+ export declare function latestChatFor<T extends ChatRecord>(chats: T[], domain: string): T | undefined;
47
+ /** Whether a chat has aged out, measured from its last activity. */
48
+ export declare function isExpired(chat: ChatRecord, retentionDays: RetentionDays, now: number): boolean;
49
+ /**
50
+ * The history, swept.
51
+ *
52
+ * Two rules, in order. Anything untouched for longer than the retention goes —
53
+ * the clock runs from last activity, so a conversation still in use is never
54
+ * swept, however old it is. Then the cap: if what remains still exceeds
55
+ * MAX_CHATS, the least recently used go, because a write that throws would
56
+ * lose the NEW chat rather than an old one.
57
+ *
58
+ * `keepId` protects the chat currently on screen from both rules. Deleting the
59
+ * conversation someone is looking at is never the right answer to a storage
60
+ * limit.
61
+ */
62
+ export declare function sweepChats<T extends ChatRecord>(chats: T[], options: {
63
+ retentionDays: RetentionDays;
64
+ now: number;
65
+ keepId?: string;
66
+ }): T[];
67
+ /** History as the list shows it: everything, newest activity first. */
68
+ export declare function orderedHistory<T extends ChatRecord>(chats: T[]): T[];
69
+ /**
70
+ * Whether a chat is worth remembering.
71
+ *
72
+ * An empty one is not. Starting a new chat twice in a row should not leave two
73
+ * blank rows in the list, and a chat nobody has spoken in has nothing to bring
74
+ * back.
75
+ */
76
+ export declare function isWorthKeeping(chat: ChatRecord): boolean;
77
+ /** How long a title may be before the list truncates it. */
78
+ export declare const MAX_TITLE_LENGTH = 60;
79
+ /** A chat's title: what the user first asked, or a placeholder until they do. */
80
+ export declare function titleFrom(firstUserMessage: string | undefined): string;
@@ -0,0 +1,141 @@
1
+ // Which conversation belongs to the site you are looking at.
2
+ //
3
+ // A chat is tied to a DOMAIN, not a tab or a URL: eBay's sign-in lives on
4
+ // signin.ebay.com and its listings on www.ebay.com, and a conversation about
5
+ // buying something has to survive the walk between them. Splitting per host
6
+ // would have swapped the chat out mid-task — which we watched happen when
7
+ // "Save this search" redirected to signin.ebay.com.
8
+ //
9
+ // Pure and storage-free on purpose. The extension owns localStorage and the
10
+ // panel owns the animation; what lives here is the part worth testing without
11
+ // either: which chat a domain gets, what a domain even is, and what has aged
12
+ // out.
13
+ /** How long a chat survives without activity. */
14
+ export const RETENTION_CHOICES = [1, 3, 7];
15
+ export const DEFAULT_RETENTION_DAYS = 3;
16
+ /**
17
+ * How many chats are kept at most, whatever the retention says.
18
+ *
19
+ * localStorage is a handful of megabytes and a full one throws on write, which
20
+ * would lose the conversation being saved rather than an old one. A cap turns
21
+ * that into a prune of the least recently used, which is the outcome anyone
22
+ * would have chosen.
23
+ */
24
+ export const MAX_CHATS = 100;
25
+ /** Title shown before the user has said anything. */
26
+ export const UNTITLED = "New chat";
27
+ /**
28
+ * Suffixes where the registrable domain takes THREE labels, not two.
29
+ *
30
+ * Not the full public suffix list — that is thousands of entries and a
31
+ * dependency this package does not want. These cover the multi-part suffixes a
32
+ * user is realistically browsing; anything missed falls back to two labels,
33
+ * which errs toward one chat for `bbc.co.uk` rather than a chat per subdomain.
34
+ */
35
+ const MULTI_PART_SUFFIXES = new Set([
36
+ "co.uk", "org.uk", "ac.uk", "gov.uk", "me.uk", "net.uk", "sch.uk",
37
+ "com.au", "net.au", "org.au", "edu.au", "gov.au", "id.au",
38
+ "co.nz", "net.nz", "org.nz", "govt.nz", "ac.nz",
39
+ "co.jp", "or.jp", "ne.jp", "ac.jp", "go.jp",
40
+ "com.br", "net.br", "org.br", "gov.br",
41
+ "com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn",
42
+ "co.in", "net.in", "org.in", "gov.in", "ac.in",
43
+ "com.mx", "com.ar", "com.tr", "com.sg", "com.hk", "com.tw", "com.my",
44
+ "co.za", "co.kr", "co.il", "co.id", "com.pl", "com.ua", "com.ph", "com.vn",
45
+ ]);
46
+ /**
47
+ * The domain a chat belongs to.
48
+ *
49
+ * Returns "" for anything without a real host — a new tab, a file, an
50
+ * extension page — which the caller reads as "no site here to chat about".
51
+ */
52
+ export function registrableDomain(url) {
53
+ let parsed;
54
+ try {
55
+ parsed = new URL(url);
56
+ }
57
+ catch {
58
+ return "";
59
+ }
60
+ // Only pages the agent can actually drive. `chrome://extensions` parses
61
+ // perfectly well and yields a hostname of "extensions", which would open a
62
+ // chat about a browser settings page.
63
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
64
+ return "";
65
+ const host = parsed.hostname.toLowerCase();
66
+ if (!host || host === "localhost")
67
+ return host;
68
+ // An IP address has no registrable domain; it IS the identity.
69
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":"))
70
+ return host;
71
+ const labels = host.split(".").filter(Boolean);
72
+ if (labels.length <= 2)
73
+ return labels.join(".");
74
+ const lastTwo = labels.slice(-2).join(".");
75
+ const take = MULTI_PART_SUFFIXES.has(lastTwo) ? 3 : 2;
76
+ return labels.slice(-take).join(".");
77
+ }
78
+ /**
79
+ * The chat to show for a domain: its most recently active one.
80
+ *
81
+ * Most recent rather than most recently created, because "bring chat1 back"
82
+ * means the conversation they were last having there, and a chat they started
83
+ * and abandoned should not outrank the one they actually used.
84
+ */
85
+ export function latestChatFor(chats, domain) {
86
+ if (!domain)
87
+ return undefined;
88
+ return chats
89
+ .filter(c => c.domain === domain)
90
+ .reduce((best, c) => (!best || c.lastActivityAt > best.lastActivityAt ? c : best), undefined);
91
+ }
92
+ /** Whether a chat has aged out, measured from its last activity. */
93
+ export function isExpired(chat, retentionDays, now) {
94
+ return now - chat.lastActivityAt > retentionDays * 24 * 60 * 60 * 1000;
95
+ }
96
+ /**
97
+ * The history, swept.
98
+ *
99
+ * Two rules, in order. Anything untouched for longer than the retention goes —
100
+ * the clock runs from last activity, so a conversation still in use is never
101
+ * swept, however old it is. Then the cap: if what remains still exceeds
102
+ * MAX_CHATS, the least recently used go, because a write that throws would
103
+ * lose the NEW chat rather than an old one.
104
+ *
105
+ * `keepId` protects the chat currently on screen from both rules. Deleting the
106
+ * conversation someone is looking at is never the right answer to a storage
107
+ * limit.
108
+ */
109
+ export function sweepChats(chats, options) {
110
+ const kept = chats.filter(c => c.id === options.keepId || !isExpired(c, options.retentionDays, options.now));
111
+ if (kept.length <= MAX_CHATS)
112
+ return kept;
113
+ const byRecency = [...kept].sort((a, b) => b.lastActivityAt - a.lastActivityAt);
114
+ const survivors = new Set(byRecency.slice(0, MAX_CHATS).map(c => c.id));
115
+ if (options.keepId)
116
+ survivors.add(options.keepId);
117
+ return kept.filter(c => survivors.has(c.id));
118
+ }
119
+ /** History as the list shows it: everything, newest activity first. */
120
+ export function orderedHistory(chats) {
121
+ return [...chats].sort((a, b) => b.lastActivityAt - a.lastActivityAt);
122
+ }
123
+ /**
124
+ * Whether a chat is worth remembering.
125
+ *
126
+ * An empty one is not. Starting a new chat twice in a row should not leave two
127
+ * blank rows in the list, and a chat nobody has spoken in has nothing to bring
128
+ * back.
129
+ */
130
+ export function isWorthKeeping(chat) {
131
+ return chat.messageCount > 0;
132
+ }
133
+ /** How long a title may be before the list truncates it. */
134
+ export const MAX_TITLE_LENGTH = 60;
135
+ /** A chat's title: what the user first asked, or a placeholder until they do. */
136
+ export function titleFrom(firstUserMessage) {
137
+ const text = firstUserMessage?.replace(/\s+/g, " ").trim();
138
+ if (!text)
139
+ return UNTITLED;
140
+ return text.length <= MAX_TITLE_LENGTH ? text : `${text.slice(0, MAX_TITLE_LENGTH - 1)}…`;
141
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,118 @@
1
+ import { test, expect } from "bun:test";
2
+ import { DEFAULT_RETENTION_DAYS, MAX_CHATS, UNTITLED, isWorthKeeping, latestChatFor, orderedHistory, registrableDomain, sweepChats, titleFrom, } from "./chat-history";
3
+ const DAY = 24 * 60 * 60 * 1000;
4
+ const NOW = 1_760_000_000_000;
5
+ const chat = (over = {}) => ({
6
+ id: "c1",
7
+ domain: "ebay.com",
8
+ createdAt: NOW,
9
+ lastActivityAt: NOW,
10
+ messageCount: 2,
11
+ ...over,
12
+ });
13
+ // --- what a domain is -------------------------------------------------------
14
+ test("a chat spans a site's subdomains", () => {
15
+ // The case that motivated this: "Save this search" redirected to
16
+ // signin.ebay.com, and a per-host rule would have swapped the chat out
17
+ // mid-task.
18
+ expect(registrableDomain("https://www.ebay.com/sch/i.html")).toBe("ebay.com");
19
+ expect(registrableDomain("https://signin.ebay.com/ws/eBayISAPI.dll")).toBe("ebay.com");
20
+ expect(registrableDomain("http://ebay.com")).toBe("ebay.com");
21
+ });
22
+ test("different sites are different chats", () => {
23
+ expect(registrableDomain("https://www.amazon.com")).not.toBe(registrableDomain("https://www.ebay.com"));
24
+ });
25
+ test("handles suffixes where the domain takes three labels", () => {
26
+ // Two labels would make every bbc.co.uk subdomain its own chat, and worse,
27
+ // would merge unrelated sites under "co.uk".
28
+ expect(registrableDomain("https://www.bbc.co.uk/news")).toBe("bbc.co.uk");
29
+ expect(registrableDomain("https://shop.coles.com.au")).toBe("coles.com.au");
30
+ });
31
+ test("says nothing for a page that is not a site", () => {
32
+ // A new tab, a local file, the extension's own pages: nothing to chat about.
33
+ expect(registrableDomain("about:blank")).toBe("");
34
+ expect(registrableDomain("chrome://extensions")).toBe("");
35
+ expect(registrableDomain("not a url")).toBe("");
36
+ });
37
+ test("an address with no domain is its own identity", () => {
38
+ expect(registrableDomain("http://127.0.0.1:8080/app")).toBe("127.0.0.1");
39
+ expect(registrableDomain("http://localhost:7177")).toBe("localhost");
40
+ });
41
+ // --- which chat comes back --------------------------------------------------
42
+ test("switching back to a site brings back the chat you were having there", () => {
43
+ const chats = [
44
+ chat({ id: "ebay-old", lastActivityAt: NOW - 2 * DAY }),
45
+ chat({ id: "ebay-recent", lastActivityAt: NOW - 1000 }),
46
+ chat({ id: "amazon", domain: "amazon.com", lastActivityAt: NOW }),
47
+ ];
48
+ expect(latestChatFor(chats, "ebay.com")?.id).toBe("ebay-recent");
49
+ expect(latestChatFor(chats, "amazon.com")?.id).toBe("amazon");
50
+ });
51
+ test("a site with no chat yet has none, and a new one is started", () => {
52
+ expect(latestChatFor([chat()], "target.com")).toBeUndefined();
53
+ expect(latestChatFor([chat()], "")).toBeUndefined();
54
+ });
55
+ test("most recently USED wins, not most recently started", () => {
56
+ // A chat someone opened and abandoned should not outrank the one they were
57
+ // actually having.
58
+ const chats = [
59
+ chat({ id: "abandoned", createdAt: NOW, lastActivityAt: NOW - DAY }),
60
+ chat({ id: "in-use", createdAt: NOW - 5 * DAY, lastActivityAt: NOW }),
61
+ ];
62
+ expect(latestChatFor(chats, "ebay.com")?.id).toBe("in-use");
63
+ });
64
+ // --- what ages out ----------------------------------------------------------
65
+ test("a chat untouched for longer than the retention is swept", () => {
66
+ const chats = [
67
+ chat({ id: "stale", lastActivityAt: NOW - 4 * DAY }),
68
+ chat({ id: "fresh", lastActivityAt: NOW - 1 * DAY }),
69
+ ];
70
+ const kept = sweepChats(chats, { retentionDays: DEFAULT_RETENTION_DAYS, now: NOW });
71
+ expect(kept.map(c => c.id)).toEqual(["fresh"]);
72
+ });
73
+ test("a chat you are still using is never swept, however old", () => {
74
+ // The clock runs from last activity, so a week-old conversation used this
75
+ // morning survives a one-day retention.
76
+ const chats = [chat({ id: "old-but-live", createdAt: NOW - 30 * DAY, lastActivityAt: NOW - 1000 })];
77
+ expect(sweepChats(chats, { retentionDays: 1, now: NOW })).toHaveLength(1);
78
+ });
79
+ test("the chat on screen survives the sweep even when it has aged out", () => {
80
+ // Deleting the conversation someone is looking at is never the right answer.
81
+ const chats = [chat({ id: "current", lastActivityAt: NOW - 30 * DAY })];
82
+ const kept = sweepChats(chats, { retentionDays: 1, now: NOW, keepId: "current" });
83
+ expect(kept.map(c => c.id)).toEqual(["current"]);
84
+ });
85
+ test("beyond the cap the least recently used go", () => {
86
+ // A full localStorage throws on WRITE, which would lose the new chat rather
87
+ // than an old one. Pruning turns that into the outcome anyone would choose.
88
+ const many = Array.from({ length: MAX_CHATS + 10 }, (_, i) => chat({ id: `c${i}`, lastActivityAt: NOW - i * 1000 }));
89
+ const kept = sweepChats(many, { retentionDays: 7, now: NOW });
90
+ expect(kept).toHaveLength(MAX_CHATS);
91
+ expect(kept.some(c => c.id === "c0")).toBe(true);
92
+ expect(kept.some(c => c.id === `c${MAX_CHATS + 5}`)).toBe(false);
93
+ });
94
+ // --- the list ---------------------------------------------------------------
95
+ test("history is every domain, newest activity first", () => {
96
+ const chats = [
97
+ chat({ id: "a", domain: "amazon.com", lastActivityAt: NOW - DAY }),
98
+ chat({ id: "b", domain: "ebay.com", lastActivityAt: NOW }),
99
+ chat({ id: "c", domain: "nvidia.com", lastActivityAt: NOW - 2 * DAY }),
100
+ ];
101
+ expect(orderedHistory(chats).map(c => c.id)).toEqual(["b", "a", "c"]);
102
+ });
103
+ test("an empty chat is not history", () => {
104
+ // Starting a new chat twice should not leave two blank rows.
105
+ expect(isWorthKeeping(chat({ messageCount: 0 }))).toBe(false);
106
+ expect(isWorthKeeping(chat({ messageCount: 1 }))).toBe(true);
107
+ });
108
+ test("a chat is titled by what was first asked", () => {
109
+ expect(titleFrom("Find me a gaming PC with a 5090")).toBe("Find me a gaming PC with a 5090");
110
+ expect(titleFrom(undefined)).toBe(UNTITLED);
111
+ expect(titleFrom(" ")).toBe(UNTITLED);
112
+ });
113
+ test("a long first message is truncated, not wrapped across the list", () => {
114
+ const long = "x".repeat(200);
115
+ const title = titleFrom(long);
116
+ expect(title.length).toBeLessThanOrEqual(60);
117
+ expect(title.endsWith("…")).toBe(true);
118
+ });
package/dist/index.d.ts CHANGED
@@ -5,3 +5,4 @@ export * from "./recipe";
5
5
  export * from "./gates";
6
6
  export * from "./execute";
7
7
  export * from "./composite";
8
+ export * from "./chat-history";
package/dist/index.js CHANGED
@@ -20,3 +20,6 @@ export * from "./recipe";
20
20
  export * from "./gates";
21
21
  export * from "./execute";
22
22
  export * from "./composite";
23
+ // Client-side chat history: which conversation belongs to the site in front
24
+ // of you, and what has aged out. Storage and UI live in the extension.
25
+ export * from "./chat-history";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sudobility/sider_lib",
3
- "version": "0.0.12",
3
+ "version": "0.0.13",
4
4
  "description": "Sider business logic — recipe compiler, egress gates, tokenizer, clustering. Pure, no Chrome/DOM/server.",
5
5
  "license": "BUSL-1.1",
6
6
  "publishConfig": {
@@ -0,0 +1,153 @@
1
+ import { test, expect } from "bun:test";
2
+ import {
3
+ DEFAULT_RETENTION_DAYS,
4
+ MAX_CHATS,
5
+ UNTITLED,
6
+ isWorthKeeping,
7
+ latestChatFor,
8
+ orderedHistory,
9
+ registrableDomain,
10
+ sweepChats,
11
+ titleFrom,
12
+ type ChatRecord,
13
+ } from "./chat-history";
14
+
15
+ const DAY = 24 * 60 * 60 * 1000;
16
+ const NOW = 1_760_000_000_000;
17
+
18
+ const chat = (over: Partial<ChatRecord> = {}): ChatRecord => ({
19
+ id: "c1",
20
+ domain: "ebay.com",
21
+ createdAt: NOW,
22
+ lastActivityAt: NOW,
23
+ messageCount: 2,
24
+ ...over,
25
+ });
26
+
27
+ // --- what a domain is -------------------------------------------------------
28
+
29
+ test("a chat spans a site's subdomains", () => {
30
+ // The case that motivated this: "Save this search" redirected to
31
+ // signin.ebay.com, and a per-host rule would have swapped the chat out
32
+ // mid-task.
33
+ expect(registrableDomain("https://www.ebay.com/sch/i.html")).toBe("ebay.com");
34
+ expect(registrableDomain("https://signin.ebay.com/ws/eBayISAPI.dll")).toBe("ebay.com");
35
+ expect(registrableDomain("http://ebay.com")).toBe("ebay.com");
36
+ });
37
+
38
+ test("different sites are different chats", () => {
39
+ expect(registrableDomain("https://www.amazon.com")).not.toBe(registrableDomain("https://www.ebay.com"));
40
+ });
41
+
42
+ test("handles suffixes where the domain takes three labels", () => {
43
+ // Two labels would make every bbc.co.uk subdomain its own chat, and worse,
44
+ // would merge unrelated sites under "co.uk".
45
+ expect(registrableDomain("https://www.bbc.co.uk/news")).toBe("bbc.co.uk");
46
+ expect(registrableDomain("https://shop.coles.com.au")).toBe("coles.com.au");
47
+ });
48
+
49
+ test("says nothing for a page that is not a site", () => {
50
+ // A new tab, a local file, the extension's own pages: nothing to chat about.
51
+ expect(registrableDomain("about:blank")).toBe("");
52
+ expect(registrableDomain("chrome://extensions")).toBe("");
53
+ expect(registrableDomain("not a url")).toBe("");
54
+ });
55
+
56
+ test("an address with no domain is its own identity", () => {
57
+ expect(registrableDomain("http://127.0.0.1:8080/app")).toBe("127.0.0.1");
58
+ expect(registrableDomain("http://localhost:7177")).toBe("localhost");
59
+ });
60
+
61
+ // --- which chat comes back --------------------------------------------------
62
+
63
+ test("switching back to a site brings back the chat you were having there", () => {
64
+ const chats = [
65
+ chat({ id: "ebay-old", lastActivityAt: NOW - 2 * DAY }),
66
+ chat({ id: "ebay-recent", lastActivityAt: NOW - 1000 }),
67
+ chat({ id: "amazon", domain: "amazon.com", lastActivityAt: NOW }),
68
+ ];
69
+ expect(latestChatFor(chats, "ebay.com")?.id).toBe("ebay-recent");
70
+ expect(latestChatFor(chats, "amazon.com")?.id).toBe("amazon");
71
+ });
72
+
73
+ test("a site with no chat yet has none, and a new one is started", () => {
74
+ expect(latestChatFor([chat()], "target.com")).toBeUndefined();
75
+ expect(latestChatFor([chat()], "")).toBeUndefined();
76
+ });
77
+
78
+ test("most recently USED wins, not most recently started", () => {
79
+ // A chat someone opened and abandoned should not outrank the one they were
80
+ // actually having.
81
+ const chats = [
82
+ chat({ id: "abandoned", createdAt: NOW, lastActivityAt: NOW - DAY }),
83
+ chat({ id: "in-use", createdAt: NOW - 5 * DAY, lastActivityAt: NOW }),
84
+ ];
85
+ expect(latestChatFor(chats, "ebay.com")?.id).toBe("in-use");
86
+ });
87
+
88
+ // --- what ages out ----------------------------------------------------------
89
+
90
+ test("a chat untouched for longer than the retention is swept", () => {
91
+ const chats = [
92
+ chat({ id: "stale", lastActivityAt: NOW - 4 * DAY }),
93
+ chat({ id: "fresh", lastActivityAt: NOW - 1 * DAY }),
94
+ ];
95
+ const kept = sweepChats(chats, { retentionDays: DEFAULT_RETENTION_DAYS, now: NOW });
96
+ expect(kept.map(c => c.id)).toEqual(["fresh"]);
97
+ });
98
+
99
+ test("a chat you are still using is never swept, however old", () => {
100
+ // The clock runs from last activity, so a week-old conversation used this
101
+ // morning survives a one-day retention.
102
+ const chats = [chat({ id: "old-but-live", createdAt: NOW - 30 * DAY, lastActivityAt: NOW - 1000 })];
103
+ expect(sweepChats(chats, { retentionDays: 1, now: NOW })).toHaveLength(1);
104
+ });
105
+
106
+ test("the chat on screen survives the sweep even when it has aged out", () => {
107
+ // Deleting the conversation someone is looking at is never the right answer.
108
+ const chats = [chat({ id: "current", lastActivityAt: NOW - 30 * DAY })];
109
+ const kept = sweepChats(chats, { retentionDays: 1, now: NOW, keepId: "current" });
110
+ expect(kept.map(c => c.id)).toEqual(["current"]);
111
+ });
112
+
113
+ test("beyond the cap the least recently used go", () => {
114
+ // A full localStorage throws on WRITE, which would lose the new chat rather
115
+ // than an old one. Pruning turns that into the outcome anyone would choose.
116
+ const many = Array.from({ length: MAX_CHATS + 10 }, (_, i) =>
117
+ chat({ id: `c${i}`, lastActivityAt: NOW - i * 1000 }),
118
+ );
119
+ const kept = sweepChats(many, { retentionDays: 7, now: NOW });
120
+ expect(kept).toHaveLength(MAX_CHATS);
121
+ expect(kept.some(c => c.id === "c0")).toBe(true);
122
+ expect(kept.some(c => c.id === `c${MAX_CHATS + 5}`)).toBe(false);
123
+ });
124
+
125
+ // --- the list ---------------------------------------------------------------
126
+
127
+ test("history is every domain, newest activity first", () => {
128
+ const chats = [
129
+ chat({ id: "a", domain: "amazon.com", lastActivityAt: NOW - DAY }),
130
+ chat({ id: "b", domain: "ebay.com", lastActivityAt: NOW }),
131
+ chat({ id: "c", domain: "nvidia.com", lastActivityAt: NOW - 2 * DAY }),
132
+ ];
133
+ expect(orderedHistory(chats).map(c => c.id)).toEqual(["b", "a", "c"]);
134
+ });
135
+
136
+ test("an empty chat is not history", () => {
137
+ // Starting a new chat twice should not leave two blank rows.
138
+ expect(isWorthKeeping(chat({ messageCount: 0 }))).toBe(false);
139
+ expect(isWorthKeeping(chat({ messageCount: 1 }))).toBe(true);
140
+ });
141
+
142
+ test("a chat is titled by what was first asked", () => {
143
+ expect(titleFrom("Find me a gaming PC with a 5090")).toBe("Find me a gaming PC with a 5090");
144
+ expect(titleFrom(undefined)).toBe(UNTITLED);
145
+ expect(titleFrom(" ")).toBe(UNTITLED);
146
+ });
147
+
148
+ test("a long first message is truncated, not wrapped across the list", () => {
149
+ const long = "x".repeat(200);
150
+ const title = titleFrom(long);
151
+ expect(title.length).toBeLessThanOrEqual(60);
152
+ expect(title.endsWith("…")).toBe(true);
153
+ });
@@ -0,0 +1,170 @@
1
+ // Which conversation belongs to the site you are looking at.
2
+ //
3
+ // A chat is tied to a DOMAIN, not a tab or a URL: eBay's sign-in lives on
4
+ // signin.ebay.com and its listings on www.ebay.com, and a conversation about
5
+ // buying something has to survive the walk between them. Splitting per host
6
+ // would have swapped the chat out mid-task — which we watched happen when
7
+ // "Save this search" redirected to signin.ebay.com.
8
+ //
9
+ // Pure and storage-free on purpose. The extension owns localStorage and the
10
+ // panel owns the animation; what lives here is the part worth testing without
11
+ // either: which chat a domain gets, what a domain even is, and what has aged
12
+ // out.
13
+
14
+ /** A stored conversation, as much of one as the rules here need. */
15
+ export interface ChatRecord {
16
+ id: string;
17
+ /** Registrable domain, e.g. `ebay.com`. The chat's identity. */
18
+ domain: string;
19
+ /** Epoch ms of the last message either way. The retention clock. */
20
+ lastActivityAt: number;
21
+ /** Epoch ms the chat was started. Shown, never used to expire. */
22
+ createdAt: number;
23
+ /** The page the conversation was last on, so reopening it returns there. */
24
+ lastUrl?: string;
25
+ /** First user message, for the list. Absent until they say something. */
26
+ title?: string;
27
+ /** How many messages it holds. A chat with none is not history. */
28
+ messageCount: number;
29
+ }
30
+
31
+ /** How long a chat survives without activity. */
32
+ export const RETENTION_CHOICES = [1, 3, 7] as const;
33
+ export type RetentionDays = (typeof RETENTION_CHOICES)[number];
34
+ export const DEFAULT_RETENTION_DAYS: RetentionDays = 3;
35
+
36
+ /**
37
+ * How many chats are kept at most, whatever the retention says.
38
+ *
39
+ * localStorage is a handful of megabytes and a full one throws on write, which
40
+ * would lose the conversation being saved rather than an old one. A cap turns
41
+ * that into a prune of the least recently used, which is the outcome anyone
42
+ * would have chosen.
43
+ */
44
+ export const MAX_CHATS = 100;
45
+
46
+ /** Title shown before the user has said anything. */
47
+ export const UNTITLED = "New chat";
48
+
49
+ /**
50
+ * Suffixes where the registrable domain takes THREE labels, not two.
51
+ *
52
+ * Not the full public suffix list — that is thousands of entries and a
53
+ * dependency this package does not want. These cover the multi-part suffixes a
54
+ * user is realistically browsing; anything missed falls back to two labels,
55
+ * which errs toward one chat for `bbc.co.uk` rather than a chat per subdomain.
56
+ */
57
+ const MULTI_PART_SUFFIXES = new Set([
58
+ "co.uk", "org.uk", "ac.uk", "gov.uk", "me.uk", "net.uk", "sch.uk",
59
+ "com.au", "net.au", "org.au", "edu.au", "gov.au", "id.au",
60
+ "co.nz", "net.nz", "org.nz", "govt.nz", "ac.nz",
61
+ "co.jp", "or.jp", "ne.jp", "ac.jp", "go.jp",
62
+ "com.br", "net.br", "org.br", "gov.br",
63
+ "com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn",
64
+ "co.in", "net.in", "org.in", "gov.in", "ac.in",
65
+ "com.mx", "com.ar", "com.tr", "com.sg", "com.hk", "com.tw", "com.my",
66
+ "co.za", "co.kr", "co.il", "co.id", "com.pl", "com.ua", "com.ph", "com.vn",
67
+ ]);
68
+
69
+ /**
70
+ * The domain a chat belongs to.
71
+ *
72
+ * Returns "" for anything without a real host — a new tab, a file, an
73
+ * extension page — which the caller reads as "no site here to chat about".
74
+ */
75
+ export function registrableDomain(url: string): string {
76
+ let parsed: URL;
77
+ try {
78
+ parsed = new URL(url);
79
+ } catch {
80
+ return "";
81
+ }
82
+ // Only pages the agent can actually drive. `chrome://extensions` parses
83
+ // perfectly well and yields a hostname of "extensions", which would open a
84
+ // chat about a browser settings page.
85
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "";
86
+ const host = parsed.hostname.toLowerCase();
87
+ if (!host || host === "localhost") return host;
88
+ // An IP address has no registrable domain; it IS the identity.
89
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":")) return host;
90
+
91
+ const labels = host.split(".").filter(Boolean);
92
+ if (labels.length <= 2) return labels.join(".");
93
+
94
+ const lastTwo = labels.slice(-2).join(".");
95
+ const take = MULTI_PART_SUFFIXES.has(lastTwo) ? 3 : 2;
96
+ return labels.slice(-take).join(".");
97
+ }
98
+
99
+ /**
100
+ * The chat to show for a domain: its most recently active one.
101
+ *
102
+ * Most recent rather than most recently created, because "bring chat1 back"
103
+ * means the conversation they were last having there, and a chat they started
104
+ * and abandoned should not outrank the one they actually used.
105
+ */
106
+ export function latestChatFor<T extends ChatRecord>(chats: T[], domain: string): T | undefined {
107
+ if (!domain) return undefined;
108
+ return chats
109
+ .filter(c => c.domain === domain)
110
+ .reduce<T | undefined>((best, c) => (!best || c.lastActivityAt > best.lastActivityAt ? c : best), undefined);
111
+ }
112
+
113
+ /** Whether a chat has aged out, measured from its last activity. */
114
+ export function isExpired(chat: ChatRecord, retentionDays: RetentionDays, now: number): boolean {
115
+ return now - chat.lastActivityAt > retentionDays * 24 * 60 * 60 * 1000;
116
+ }
117
+
118
+ /**
119
+ * The history, swept.
120
+ *
121
+ * Two rules, in order. Anything untouched for longer than the retention goes —
122
+ * the clock runs from last activity, so a conversation still in use is never
123
+ * swept, however old it is. Then the cap: if what remains still exceeds
124
+ * MAX_CHATS, the least recently used go, because a write that throws would
125
+ * lose the NEW chat rather than an old one.
126
+ *
127
+ * `keepId` protects the chat currently on screen from both rules. Deleting the
128
+ * conversation someone is looking at is never the right answer to a storage
129
+ * limit.
130
+ */
131
+ export function sweepChats<T extends ChatRecord>(
132
+ chats: T[],
133
+ options: { retentionDays: RetentionDays; now: number; keepId?: string },
134
+ ): T[] {
135
+ const kept = chats.filter(
136
+ c => c.id === options.keepId || !isExpired(c, options.retentionDays, options.now),
137
+ );
138
+ if (kept.length <= MAX_CHATS) return kept;
139
+
140
+ const byRecency = [...kept].sort((a, b) => b.lastActivityAt - a.lastActivityAt);
141
+ const survivors = new Set(byRecency.slice(0, MAX_CHATS).map(c => c.id));
142
+ if (options.keepId) survivors.add(options.keepId);
143
+ return kept.filter(c => survivors.has(c.id));
144
+ }
145
+
146
+ /** History as the list shows it: everything, newest activity first. */
147
+ export function orderedHistory<T extends ChatRecord>(chats: T[]): T[] {
148
+ return [...chats].sort((a, b) => b.lastActivityAt - a.lastActivityAt);
149
+ }
150
+
151
+ /**
152
+ * Whether a chat is worth remembering.
153
+ *
154
+ * An empty one is not. Starting a new chat twice in a row should not leave two
155
+ * blank rows in the list, and a chat nobody has spoken in has nothing to bring
156
+ * back.
157
+ */
158
+ export function isWorthKeeping(chat: ChatRecord): boolean {
159
+ return chat.messageCount > 0;
160
+ }
161
+
162
+ /** How long a title may be before the list truncates it. */
163
+ export const MAX_TITLE_LENGTH = 60;
164
+
165
+ /** A chat's title: what the user first asked, or a placeholder until they do. */
166
+ export function titleFrom(firstUserMessage: string | undefined): string {
167
+ const text = firstUserMessage?.replace(/\s+/g, " ").trim();
168
+ if (!text) return UNTITLED;
169
+ return text.length <= MAX_TITLE_LENGTH ? text : `${text.slice(0, MAX_TITLE_LENGTH - 1)}…`;
170
+ }
package/src/index.ts CHANGED
@@ -37,3 +37,7 @@ export * from "./recipe";
37
37
  export * from "./gates";
38
38
  export * from "./execute";
39
39
  export * from "./composite";
40
+
41
+ // Client-side chat history: which conversation belongs to the site in front
42
+ // of you, and what has aged out. Storage and UI live in the extension.
43
+ export * from "./chat-history";