@cstar.help/js 0.1.0 → 0.2.0

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.
@@ -1,3 +1,243 @@
1
+ // src/chat/realtime.ts
2
+ var RealtimeManager = class {
3
+ constructor(options) {
4
+ this.client = null;
5
+ this.initPromise = null;
6
+ this.channels = /* @__PURE__ */ new Map();
7
+ this.refreshTimer = null;
8
+ this.currentToken = null;
9
+ this.options = options;
10
+ }
11
+ /**
12
+ * Initialize the Supabase client and authenticate with a realtime token.
13
+ * Returns true if successful, false if @supabase/supabase-js is unavailable.
14
+ */
15
+ init() {
16
+ if (this.initPromise) return this.initPromise;
17
+ this.initPromise = this.doInit();
18
+ return this.initPromise;
19
+ }
20
+ async doInit() {
21
+ try {
22
+ const { createClient } = await import(
23
+ /* webpackIgnore: true */
24
+ "@supabase/supabase-js"
25
+ );
26
+ this.client = createClient(this.options.supabaseUrl, this.options.supabaseAnonKey, {
27
+ auth: { persistSession: false, autoRefreshToken: false },
28
+ realtime: { params: { eventsPerSecond: 10 } }
29
+ });
30
+ const tokenData = await this.options.fetchToken();
31
+ this.setToken(tokenData);
32
+ return true;
33
+ } catch {
34
+ this.initPromise = null;
35
+ return false;
36
+ }
37
+ }
38
+ setToken(tokenData) {
39
+ this.currentToken = tokenData.token;
40
+ if (this.client) {
41
+ this.client.realtime.setAuth(tokenData.token);
42
+ }
43
+ this.scheduleRefresh(tokenData.refreshAt);
44
+ }
45
+ scheduleRefresh(refreshAt) {
46
+ if (this.refreshTimer) {
47
+ clearTimeout(this.refreshTimer);
48
+ }
49
+ const msUntilRefresh = new Date(refreshAt).getTime() - Date.now();
50
+ const delay = Math.max(msUntilRefresh, 1e4);
51
+ this.refreshTimer = setTimeout(() => this.doRefresh(), delay);
52
+ }
53
+ async doRefresh() {
54
+ try {
55
+ const tokenData = await this.options.fetchToken();
56
+ this.setToken(tokenData);
57
+ } catch {
58
+ this.refreshTimer = setTimeout(() => this.doRefresh(), 3e4);
59
+ }
60
+ }
61
+ /**
62
+ * Subscribe to new messages on a ticket via Postgres Changes.
63
+ * Returns an unsubscribe function.
64
+ */
65
+ subscribeToMessages(ticketId, callback) {
66
+ if (!this.client) return () => {
67
+ };
68
+ const channelName = `messages:${ticketId}`;
69
+ this.removeChannel(channelName);
70
+ const channel = this.client.channel(channelName).on(
71
+ "postgres_changes",
72
+ {
73
+ event: "INSERT",
74
+ schema: "public",
75
+ table: "messages",
76
+ filter: `ticket_id=eq.${ticketId}`
77
+ },
78
+ (payload) => {
79
+ const row = payload.new;
80
+ if (!row) return;
81
+ const message = {
82
+ id: row.id,
83
+ content: row.content,
84
+ sender: row.sender,
85
+ sender_name: row.sender_name ?? "",
86
+ created_at: row.created_at,
87
+ read_at: row.read_at ?? null,
88
+ attachments: row.attachments ?? []
89
+ };
90
+ callback(message);
91
+ }
92
+ ).subscribe();
93
+ this.channels.set(channelName, channel);
94
+ return () => this.removeChannel(channelName);
95
+ }
96
+ /** Get the underlying Supabase client (for sharing with PresenceManager). */
97
+ getClient() {
98
+ return this.client;
99
+ }
100
+ /** Whether the realtime manager is initialized and has a token. */
101
+ get isReady() {
102
+ return this.client !== null && this.currentToken !== null;
103
+ }
104
+ removeChannel(name) {
105
+ const existing = this.channels.get(name);
106
+ if (existing && this.client) {
107
+ this.client.removeChannel(existing);
108
+ this.channels.delete(name);
109
+ }
110
+ }
111
+ /** Clean up all channels, timers, and the client. */
112
+ destroy() {
113
+ if (this.refreshTimer) {
114
+ clearTimeout(this.refreshTimer);
115
+ this.refreshTimer = null;
116
+ }
117
+ for (const [name] of this.channels) {
118
+ this.removeChannel(name);
119
+ }
120
+ this.channels.clear();
121
+ this.client = null;
122
+ this.initPromise = null;
123
+ this.currentToken = null;
124
+ }
125
+ };
126
+
127
+ // src/chat/presence.ts
128
+ var TYPING_THROTTLE_MS = 2e3;
129
+ var TYPING_CLEAR_DELAY_MS = 3e3;
130
+ var PresenceManager = class {
131
+ constructor(options) {
132
+ this.client = null;
133
+ this.channels = /* @__PURE__ */ new Map();
134
+ this.lastTypingSentAt = /* @__PURE__ */ new Map();
135
+ this.typingClearTimers = /* @__PURE__ */ new Map();
136
+ this.options = options;
137
+ }
138
+ /** Set the Supabase client (shared from RealtimeManager). */
139
+ setClient(client) {
140
+ this.client = client;
141
+ }
142
+ /**
143
+ * Subscribe to agent typing events on a ticket.
144
+ * Only fires callback for agent typing (has `agent_id`), not customer typing echoes.
145
+ *
146
+ * @returns Unsubscribe function
147
+ */
148
+ subscribeToTyping(ticketId, callback) {
149
+ if (!this.client) return () => {
150
+ };
151
+ const channelName = `typing:${ticketId}`;
152
+ this.removeChannel(channelName);
153
+ const channel = this.client.channel(`ticket:${ticketId}`).on("broadcast", { event: "typing" }, (data) => {
154
+ const payload = data?.payload;
155
+ if (!payload) return;
156
+ if (payload.agent_id) {
157
+ const typingPayload = {
158
+ agent_id: payload.agent_id,
159
+ agent_name: payload.agent_name || "Agent",
160
+ is_typing: !!payload.is_typing
161
+ };
162
+ callback(typingPayload);
163
+ }
164
+ }).subscribe();
165
+ this.channels.set(channelName, channel);
166
+ return () => this.removeChannel(channelName);
167
+ }
168
+ /**
169
+ * Send a customer typing indicator.
170
+ * Throttled: max 1 typing=true per 2s, auto-clears after 3s.
171
+ */
172
+ async sendTyping(ticketId, isTyping) {
173
+ if (!this.client) return;
174
+ const existingTimer = this.typingClearTimers.get(ticketId);
175
+ if (existingTimer) {
176
+ clearTimeout(existingTimer);
177
+ this.typingClearTimers.delete(ticketId);
178
+ }
179
+ if (isTyping) {
180
+ const lastSent = this.lastTypingSentAt.get(ticketId) || 0;
181
+ const now = Date.now();
182
+ if (now - lastSent < TYPING_THROTTLE_MS) {
183
+ const timer2 = setTimeout(() => {
184
+ this.doSendTyping(ticketId, false);
185
+ }, TYPING_CLEAR_DELAY_MS);
186
+ this.typingClearTimers.set(ticketId, timer2);
187
+ return;
188
+ }
189
+ this.lastTypingSentAt.set(ticketId, now);
190
+ const timer = setTimeout(() => {
191
+ this.doSendTyping(ticketId, false);
192
+ }, TYPING_CLEAR_DELAY_MS);
193
+ this.typingClearTimers.set(ticketId, timer);
194
+ }
195
+ await this.doSendTyping(ticketId, isTyping);
196
+ }
197
+ async doSendTyping(ticketId, isTyping) {
198
+ if (!this.client) return;
199
+ try {
200
+ const sendChannelName = `typing-send:${ticketId}`;
201
+ let channel = this.channels.get(sendChannelName);
202
+ if (!channel) {
203
+ channel = this.client.channel(`ticket:${ticketId}`);
204
+ channel.subscribe();
205
+ this.channels.set(sendChannelName, channel);
206
+ }
207
+ await channel.send({
208
+ type: "broadcast",
209
+ event: "typing",
210
+ payload: {
211
+ customer_id: this.options.customerId,
212
+ customer_name: this.options.customerName,
213
+ is_typing: isTyping
214
+ }
215
+ });
216
+ } catch {
217
+ }
218
+ }
219
+ removeChannel(name) {
220
+ const existing = this.channels.get(name);
221
+ if (existing && this.client) {
222
+ this.client.removeChannel(existing);
223
+ this.channels.delete(name);
224
+ }
225
+ }
226
+ /** Clean up all channels and timers. */
227
+ destroy() {
228
+ for (const timer of this.typingClearTimers.values()) {
229
+ clearTimeout(timer);
230
+ }
231
+ this.typingClearTimers.clear();
232
+ this.lastTypingSentAt.clear();
233
+ for (const [name] of this.channels) {
234
+ this.removeChannel(name);
235
+ }
236
+ this.channels.clear();
237
+ this.client = null;
238
+ }
239
+ };
240
+
1
241
  // src/chat/index.ts
2
242
  var DEFAULT_BASE_URL = "https://app.cstar.help";
3
243
  var DEFAULT_POLLING_INTERVAL = 3e3;
@@ -5,10 +245,17 @@ var ChatClient = class {
5
245
  constructor(config) {
6
246
  this.sessionToken = null;
7
247
  this.teamId = null;
248
+ this.customerId = null;
8
249
  this.pollingTimers = /* @__PURE__ */ new Map();
250
+ this.realtimeManager = null;
251
+ this.presenceManager = null;
252
+ this.realtimeReady = false;
253
+ this.realtimeInitPromise = null;
9
254
  this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
10
255
  this.teamSlug = config.teamSlug;
11
256
  this.pollingInterval = config.pollingInterval ?? DEFAULT_POLLING_INTERVAL;
257
+ this.supabaseUrl = config.supabaseUrl;
258
+ this.supabaseAnonKey = config.supabaseAnonKey;
12
259
  this.conversations = new ConversationsApi(this);
13
260
  this.messages = new MessagesApi(this);
14
261
  }
@@ -16,6 +263,9 @@ var ChatClient = class {
16
263
  * Identify a customer using HMAC signature verification.
17
264
  * Must be called before accessing conversations or messages.
18
265
  *
266
+ * If `supabaseUrl` and `supabaseAnonKey` were provided in the config,
267
+ * this will also initialize real-time subscriptions (non-blocking).
268
+ *
19
269
  * @param customer - Customer identity fields
20
270
  * @param signature - HMAC-SHA256 hex signature of the signed fields
21
271
  * @param testMode - Whether to use test mode (relaxed timestamp validation)
@@ -34,40 +284,27 @@ var ChatClient = class {
34
284
  }
35
285
  );
36
286
  this.sessionToken = response.data.sessionToken;
287
+ this.teamId = response.data.teamId;
288
+ this.customerId = response.data.customer.id;
289
+ if (this.supabaseUrl && this.supabaseAnonKey) {
290
+ this.realtimeInitPromise = this.initRealtime(
291
+ response.data.customer.id,
292
+ response.data.customer.name || customer.name || customer.email
293
+ );
294
+ }
37
295
  return response.data;
38
296
  }
39
297
  /**
40
298
  * Subscribe to new messages on a conversation.
41
- * Falls back to polling when Supabase Realtime is not available.
299
+ * Uses Supabase Realtime (Postgres Changes) when available, falls back to polling.
42
300
  *
43
301
  * @returns Unsubscribe function
44
302
  */
45
303
  onMessage(ticketId, callback) {
46
- let lastMessageId = null;
47
- const poll = async () => {
48
- try {
49
- const messages = await this.messages.list(ticketId);
50
- if (messages.length > 0) {
51
- const latest = messages[messages.length - 1];
52
- if (lastMessageId && latest.id !== lastMessageId) {
53
- const lastIdx = messages.findIndex((m) => m.id === lastMessageId);
54
- const newMessages = lastIdx >= 0 ? messages.slice(lastIdx + 1) : [latest];
55
- for (const msg of newMessages) {
56
- callback(msg);
57
- }
58
- }
59
- lastMessageId = latest.id;
60
- }
61
- } catch {
62
- }
63
- };
64
- poll();
65
- const timer = setInterval(poll, this.pollingInterval);
66
- this.pollingTimers.set(ticketId, timer);
67
- return () => {
68
- clearInterval(timer);
69
- this.pollingTimers.delete(ticketId);
70
- };
304
+ if (this.realtimeReady && this.realtimeManager) {
305
+ return this.realtimeManager.subscribeToMessages(ticketId, callback);
306
+ }
307
+ return this.pollMessages(ticketId, callback);
71
308
  }
72
309
  /**
73
310
  * Subscribe to typing indicator events.
@@ -75,17 +312,41 @@ var ChatClient = class {
75
312
  *
76
313
  * @returns Unsubscribe function
77
314
  */
78
- onTyping(_ticketId, _callback) {
315
+ onTyping(ticketId, callback) {
316
+ if (this.realtimeReady && this.presenceManager) {
317
+ return this.presenceManager.subscribeToTyping(ticketId, callback);
318
+ }
79
319
  return () => {
80
320
  };
81
321
  }
322
+ /**
323
+ * Send a customer typing indicator (requires realtime).
324
+ * No-op if realtime is not available.
325
+ */
326
+ async sendTyping(ticketId, isTyping) {
327
+ if (this.presenceManager) {
328
+ await this.presenceManager.sendTyping(ticketId, isTyping);
329
+ }
330
+ }
82
331
  /** Disconnect all subscriptions and clean up. */
83
332
  disconnect() {
333
+ if (this.realtimeManager) {
334
+ this.realtimeManager.destroy();
335
+ this.realtimeManager = null;
336
+ }
337
+ if (this.presenceManager) {
338
+ this.presenceManager.destroy();
339
+ this.presenceManager = null;
340
+ }
341
+ this.realtimeReady = false;
342
+ this.realtimeInitPromise = null;
84
343
  for (const timer of this.pollingTimers.values()) {
85
344
  clearInterval(timer);
86
345
  }
87
346
  this.pollingTimers.clear();
88
347
  this.sessionToken = null;
348
+ this.teamId = null;
349
+ this.customerId = null;
89
350
  }
90
351
  /** @internal Make an authenticated request to the widget API. */
91
352
  async fetch(path, options) {
@@ -115,6 +376,72 @@ var ChatClient = class {
115
376
  setTeamId(teamId) {
116
377
  this.teamId = teamId;
117
378
  }
379
+ /** @internal Wait for realtime to be initialized (for testing). */
380
+ async waitForRealtime() {
381
+ if (this.realtimeInitPromise) {
382
+ await this.realtimeInitPromise;
383
+ }
384
+ }
385
+ async initRealtime(customerId, customerName) {
386
+ try {
387
+ this.realtimeManager = new RealtimeManager({
388
+ supabaseUrl: this.supabaseUrl,
389
+ supabaseAnonKey: this.supabaseAnonKey,
390
+ fetchToken: () => this.fetchRealtimeToken()
391
+ });
392
+ const ready = await this.realtimeManager.init();
393
+ if (ready) {
394
+ this.realtimeReady = true;
395
+ this.presenceManager = new PresenceManager({
396
+ customerId,
397
+ customerName
398
+ });
399
+ const client = this.realtimeManager.getClient();
400
+ if (client) {
401
+ this.presenceManager.setClient(client);
402
+ }
403
+ }
404
+ } catch {
405
+ this.realtimeReady = false;
406
+ }
407
+ }
408
+ async fetchRealtimeToken() {
409
+ const response = await this.fetch(
410
+ "/api/widget/realtime-token",
411
+ {
412
+ method: "POST",
413
+ body: { teamId: this.teamId }
414
+ }
415
+ );
416
+ return response.data;
417
+ }
418
+ pollMessages(ticketId, callback) {
419
+ let lastMessageId = null;
420
+ const poll = async () => {
421
+ try {
422
+ const messages = await this.messages.list(ticketId);
423
+ if (messages.length > 0) {
424
+ const latest = messages[messages.length - 1];
425
+ if (lastMessageId && latest.id !== lastMessageId) {
426
+ const lastIdx = messages.findIndex((m) => m.id === lastMessageId);
427
+ const newMessages = lastIdx >= 0 ? messages.slice(lastIdx + 1) : [latest];
428
+ for (const msg of newMessages) {
429
+ callback(msg);
430
+ }
431
+ }
432
+ lastMessageId = latest.id;
433
+ }
434
+ } catch {
435
+ }
436
+ };
437
+ poll();
438
+ const timer = setInterval(poll, this.pollingInterval);
439
+ this.pollingTimers.set(ticketId, timer);
440
+ return () => {
441
+ clearInterval(timer);
442
+ this.pollingTimers.delete(ticketId);
443
+ };
444
+ }
118
445
  buildUrl(path, query) {
119
446
  const base = `${this.baseUrl}${path}`;
120
447
  if (!query) return base;
@@ -149,17 +476,14 @@ var ConversationsApi = class {
149
476
  /** Create a new conversation (ticket). */
150
477
  async create(params) {
151
478
  const teamId = this.chat.getTeamId();
152
- const response = await this.chat.fetch(
153
- "/api/widget/tickets",
154
- {
155
- method: "POST",
156
- body: {
157
- teamId,
158
- subject: params.subject,
159
- message: params.message
160
- }
479
+ const response = await this.chat.fetch("/api/widget/tickets", {
480
+ method: "POST",
481
+ body: {
482
+ teamId,
483
+ subject: params.subject,
484
+ message: params.message
161
485
  }
162
- );
486
+ });
163
487
  return {
164
488
  id: response.data.id,
165
489
  ticketNumber: "",
package/dist/index.cjs CHANGED
@@ -97,7 +97,7 @@ function parseError(statusCode, body, retryAfter) {
97
97
  }
98
98
 
99
99
  // src/client.ts
100
- var SDK_VERSION = "0.1.0";
100
+ var SDK_VERSION = "0.2.0";
101
101
  var DEFAULT_BASE_URL = "https://app.cstar.help";
102
102
  var DEFAULT_API_VERSION = "2026-03-01";
103
103
  var DEFAULT_MAX_RETRIES = 3;
@@ -179,7 +179,7 @@ var HttpClient = class {
179
179
  }
180
180
  buildHeaders(idempotencyKey) {
181
181
  const headers = {
182
- "Authorization": `Bearer ${this.config.apiKey}`,
182
+ Authorization: `Bearer ${this.config.apiKey}`,
183
183
  "Content-Type": "application/json",
184
184
  "CStar-Version": this.config.version,
185
185
  "User-Agent": `@cstar.help/js/${SDK_VERSION}`
@@ -235,10 +235,7 @@ var BaseResource = class {
235
235
  }
236
236
  /** Delete a resource by ID. */
237
237
  async del(id) {
238
- return this.client.request(
239
- "DELETE",
240
- `${this.basePath}/${id}`
241
- );
238
+ return this.client.request("DELETE", `${this.basePath}/${id}`);
242
239
  }
243
240
  /**
244
241
  * Auto-paginating async iterator that yields every item across all pages.
@@ -285,21 +282,14 @@ var TicketMessagesResource = class {
285
282
  }
286
283
  /** List all messages on a ticket. */
287
284
  async list(ticketId) {
288
- return this.client.request(
289
- "GET",
290
- `/tickets/${ticketId}/messages`
291
- );
285
+ return this.client.request("GET", `/tickets/${ticketId}/messages`);
292
286
  }
293
287
  /** Add a message to a ticket. */
294
288
  async create(ticketId, params, options) {
295
- return this.client.request(
296
- "POST",
297
- `/tickets/${ticketId}/messages`,
298
- {
299
- body: params,
300
- idempotencyKey: options?.idempotencyKey
301
- }
302
- );
289
+ return this.client.request("POST", `/tickets/${ticketId}/messages`, {
290
+ body: params,
291
+ idempotencyKey: options?.idempotencyKey
292
+ });
303
293
  }
304
294
  };
305
295
  var TicketsResource = class extends BaseResource {
package/dist/index.js CHANGED
@@ -65,7 +65,7 @@ function parseError(statusCode, body, retryAfter) {
65
65
  }
66
66
 
67
67
  // src/client.ts
68
- var SDK_VERSION = "0.1.0";
68
+ var SDK_VERSION = "0.2.0";
69
69
  var DEFAULT_BASE_URL = "https://app.cstar.help";
70
70
  var DEFAULT_API_VERSION = "2026-03-01";
71
71
  var DEFAULT_MAX_RETRIES = 3;
@@ -147,7 +147,7 @@ var HttpClient = class {
147
147
  }
148
148
  buildHeaders(idempotencyKey) {
149
149
  const headers = {
150
- "Authorization": `Bearer ${this.config.apiKey}`,
150
+ Authorization: `Bearer ${this.config.apiKey}`,
151
151
  "Content-Type": "application/json",
152
152
  "CStar-Version": this.config.version,
153
153
  "User-Agent": `@cstar.help/js/${SDK_VERSION}`
@@ -203,10 +203,7 @@ var BaseResource = class {
203
203
  }
204
204
  /** Delete a resource by ID. */
205
205
  async del(id) {
206
- return this.client.request(
207
- "DELETE",
208
- `${this.basePath}/${id}`
209
- );
206
+ return this.client.request("DELETE", `${this.basePath}/${id}`);
210
207
  }
211
208
  /**
212
209
  * Auto-paginating async iterator that yields every item across all pages.
@@ -253,21 +250,14 @@ var TicketMessagesResource = class {
253
250
  }
254
251
  /** List all messages on a ticket. */
255
252
  async list(ticketId) {
256
- return this.client.request(
257
- "GET",
258
- `/tickets/${ticketId}/messages`
259
- );
253
+ return this.client.request("GET", `/tickets/${ticketId}/messages`);
260
254
  }
261
255
  /** Add a message to a ticket. */
262
256
  async create(ticketId, params, options) {
263
- return this.client.request(
264
- "POST",
265
- `/tickets/${ticketId}/messages`,
266
- {
267
- body: params,
268
- idempotencyKey: options?.idempotencyKey
269
- }
270
- );
257
+ return this.client.request("POST", `/tickets/${ticketId}/messages`, {
258
+ body: params,
259
+ idempotencyKey: options?.idempotencyKey
260
+ });
271
261
  }
272
262
  };
273
263
  var TicketsResource = class extends BaseResource {
@@ -65,10 +65,24 @@ var LibraryClient = class {
65
65
  q: query
66
66
  };
67
67
  if (params?.limit) queryParams.limit = String(params.limit);
68
- return this.fetch(
69
- `/api/library/${this.teamSlug}/search`,
70
- queryParams
71
- );
68
+ return this.fetch(`/api/library/${this.teamSlug}/search`, queryParams);
69
+ }
70
+ /** Get the most viewed articles, sorted by view count descending. */
71
+ async popularArticles(limit = 5) {
72
+ const articles = await this.articles({ limit: 50 });
73
+ return articles.sort((a, b) => (b.view_count ?? 0) - (a.view_count ?? 0)).slice(0, limit);
74
+ }
75
+ /** Get aggregate stats for this knowledge base (article count, category count, total views). */
76
+ async stats() {
77
+ const [categories, articles] = await Promise.all([
78
+ this.categories(),
79
+ this.articles({ limit: 1e3 })
80
+ ]);
81
+ return {
82
+ totalArticles: articles.length,
83
+ totalCategories: categories.length,
84
+ totalViews: articles.reduce((sum, a) => sum + (a.view_count ?? 0), 0)
85
+ };
72
86
  }
73
87
  async fetch(path, query) {
74
88
  let url = `${this.baseUrl}${path}`;
@@ -50,6 +50,12 @@ interface LibrarySearchResult {
50
50
  count: number;
51
51
  query: string;
52
52
  }
53
+ /** Aggregate stats for a public knowledge base. */
54
+ interface LibraryStats {
55
+ totalArticles: number;
56
+ totalCategories: number;
57
+ totalViews: number;
58
+ }
53
59
 
54
60
  /**
55
61
  * Public knowledge base client. No authentication required.
@@ -76,7 +82,11 @@ declare class LibraryClient {
76
82
  * Returns matching articles with relevance ranking.
77
83
  */
78
84
  search(query: string, params?: LibrarySearchParams): Promise<LibrarySearchResult>;
85
+ /** Get the most viewed articles, sorted by view count descending. */
86
+ popularArticles(limit?: number): Promise<LibraryArticle[]>;
87
+ /** Get aggregate stats for this knowledge base (article count, category count, total views). */
88
+ stats(): Promise<LibraryStats>;
79
89
  private fetch;
80
90
  }
81
91
 
82
- export { type Category, type LibraryArticle, type LibraryArticleListParams, LibraryClient, type LibraryConfig, type LibrarySearchParams, type LibrarySearchResult };
92
+ export { type Category, type LibraryArticle, type LibraryArticleListParams, LibraryClient, type LibraryConfig, type LibrarySearchParams, type LibrarySearchResult, type LibraryStats };
@@ -50,6 +50,12 @@ interface LibrarySearchResult {
50
50
  count: number;
51
51
  query: string;
52
52
  }
53
+ /** Aggregate stats for a public knowledge base. */
54
+ interface LibraryStats {
55
+ totalArticles: number;
56
+ totalCategories: number;
57
+ totalViews: number;
58
+ }
53
59
 
54
60
  /**
55
61
  * Public knowledge base client. No authentication required.
@@ -76,7 +82,11 @@ declare class LibraryClient {
76
82
  * Returns matching articles with relevance ranking.
77
83
  */
78
84
  search(query: string, params?: LibrarySearchParams): Promise<LibrarySearchResult>;
85
+ /** Get the most viewed articles, sorted by view count descending. */
86
+ popularArticles(limit?: number): Promise<LibraryArticle[]>;
87
+ /** Get aggregate stats for this knowledge base (article count, category count, total views). */
88
+ stats(): Promise<LibraryStats>;
79
89
  private fetch;
80
90
  }
81
91
 
82
- export { type Category, type LibraryArticle, type LibraryArticleListParams, LibraryClient, type LibraryConfig, type LibrarySearchParams, type LibrarySearchResult };
92
+ export { type Category, type LibraryArticle, type LibraryArticleListParams, LibraryClient, type LibraryConfig, type LibrarySearchParams, type LibrarySearchResult, type LibraryStats };
@@ -41,10 +41,24 @@ var LibraryClient = class {
41
41
  q: query
42
42
  };
43
43
  if (params?.limit) queryParams.limit = String(params.limit);
44
- return this.fetch(
45
- `/api/library/${this.teamSlug}/search`,
46
- queryParams
47
- );
44
+ return this.fetch(`/api/library/${this.teamSlug}/search`, queryParams);
45
+ }
46
+ /** Get the most viewed articles, sorted by view count descending. */
47
+ async popularArticles(limit = 5) {
48
+ const articles = await this.articles({ limit: 50 });
49
+ return articles.sort((a, b) => (b.view_count ?? 0) - (a.view_count ?? 0)).slice(0, limit);
50
+ }
51
+ /** Get aggregate stats for this knowledge base (article count, category count, total views). */
52
+ async stats() {
53
+ const [categories, articles] = await Promise.all([
54
+ this.categories(),
55
+ this.articles({ limit: 1e3 })
56
+ ]);
57
+ return {
58
+ totalArticles: articles.length,
59
+ totalCategories: categories.length,
60
+ totalViews: articles.reduce((sum, a) => sum + (a.view_count ?? 0), 0)
61
+ };
48
62
  }
49
63
  async fetch(path, query) {
50
64
  let url = `${this.baseUrl}${path}`;