@operor/copilot 0.1.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.
package/dist/index.js ADDED
@@ -0,0 +1,708 @@
1
+ import Database from "better-sqlite3";
2
+ import * as sqliteVec from "sqlite-vec";
3
+ import crypto from "node:crypto";
4
+
5
+ //#region src/types.ts
6
+ /** Default copilot configuration values */
7
+ const DEFAULT_COPILOT_CONFIG = {
8
+ enabled: false,
9
+ trackingThreshold: .7,
10
+ clusterThreshold: .87,
11
+ digestIntervalMs: 864e5,
12
+ digestMaxItems: 10,
13
+ autoSuggest: true
14
+ };
15
+
16
+ //#endregion
17
+ //#region src/SQLiteCopilotStore.ts
18
+ /**
19
+ * SQLite-backed copilot store with sqlite-vec for vector search on cluster centroids.
20
+ */
21
+ var SQLiteCopilotStore = class {
22
+ db;
23
+ constructor(dbPath, dimensions) {
24
+ this.dbPath = dbPath;
25
+ this.dimensions = dimensions;
26
+ this.db = new Database(dbPath);
27
+ this.db.pragma("journal_mode = WAL");
28
+ this.db.pragma("foreign_keys = ON");
29
+ sqliteVec.load(this.db);
30
+ }
31
+ async initialize() {
32
+ this.db.exec(`
33
+ CREATE TABLE IF NOT EXISTS copilot_queries (
34
+ id TEXT PRIMARY KEY,
35
+ query TEXT NOT NULL,
36
+ normalized_query TEXT NOT NULL,
37
+ channel TEXT NOT NULL,
38
+ customer_phone TEXT NOT NULL,
39
+ kb_top_score REAL NOT NULL DEFAULT 0,
40
+ kb_is_faq_match INTEGER NOT NULL DEFAULT 0,
41
+ kb_top_chunk_content TEXT,
42
+ kb_result_count INTEGER NOT NULL DEFAULT 0,
43
+ status TEXT NOT NULL DEFAULT 'pending',
44
+ cluster_id TEXT,
45
+ times_asked INTEGER NOT NULL DEFAULT 1,
46
+ unique_customers TEXT NOT NULL DEFAULT '[]',
47
+ embedding BLOB,
48
+ suggested_answer TEXT,
49
+ taught_answer TEXT,
50
+ created_at INTEGER NOT NULL,
51
+ updated_at INTEGER NOT NULL
52
+ );
53
+
54
+ CREATE INDEX IF NOT EXISTS idx_copilot_queries_status ON copilot_queries(status);
55
+ CREATE INDEX IF NOT EXISTS idx_copilot_queries_normalized ON copilot_queries(normalized_query);
56
+ CREATE INDEX IF NOT EXISTS idx_copilot_queries_cluster ON copilot_queries(cluster_id);
57
+
58
+ CREATE TABLE IF NOT EXISTS copilot_clusters (
59
+ id TEXT PRIMARY KEY,
60
+ label TEXT,
61
+ representative_query TEXT NOT NULL,
62
+ centroid BLOB,
63
+ query_count INTEGER NOT NULL DEFAULT 1,
64
+ unique_customers TEXT NOT NULL DEFAULT '[]',
65
+ status TEXT NOT NULL DEFAULT 'pending',
66
+ created_at INTEGER NOT NULL,
67
+ updated_at INTEGER NOT NULL
68
+ );
69
+
70
+ CREATE INDEX IF NOT EXISTS idx_copilot_clusters_status ON copilot_clusters(status);
71
+
72
+ CREATE TABLE IF NOT EXISTS copilot_meta (
73
+ key TEXT PRIMARY KEY,
74
+ value TEXT NOT NULL
75
+ );
76
+ `);
77
+ this.db.exec(`
78
+ CREATE VIRTUAL TABLE IF NOT EXISTS vec_copilot_clusters USING vec0(
79
+ cluster_id TEXT PRIMARY KEY,
80
+ centroid float[${this.dimensions}]
81
+ );
82
+ `);
83
+ }
84
+ async close() {
85
+ this.db.close();
86
+ }
87
+ async addQuery(query) {
88
+ this.db.prepare(`
89
+ INSERT OR REPLACE INTO copilot_queries
90
+ (id, query, normalized_query, channel, customer_phone, kb_top_score, kb_is_faq_match,
91
+ kb_top_chunk_content, kb_result_count, status, cluster_id, times_asked,
92
+ unique_customers, embedding, suggested_answer, taught_answer, created_at, updated_at)
93
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
94
+ `).run(query.id, query.query, query.normalizedQuery, query.channel, query.customerPhone, query.kbTopScore, query.kbIsFaqMatch ? 1 : 0, query.kbTopChunkContent ?? null, query.kbResultCount, query.status, query.clusterId ?? null, query.timesAsked, JSON.stringify(query.uniqueCustomers), query.embedding ? Buffer.from(new Float32Array(query.embedding).buffer) : null, query.suggestedAnswer ?? null, query.taughtAnswer ?? null, query.createdAt, query.updatedAt);
95
+ }
96
+ async getQuery(id) {
97
+ const row = this.db.prepare("SELECT * FROM copilot_queries WHERE id = ?").get(id);
98
+ return row ? this.rowToQuery(row) : null;
99
+ }
100
+ async updateQuery(id, updates) {
101
+ const existing = await this.getQuery(id);
102
+ if (!existing) return;
103
+ const merged = {
104
+ ...existing,
105
+ ...updates,
106
+ updatedAt: Date.now()
107
+ };
108
+ await this.addQuery(merged);
109
+ }
110
+ async getPendingQueries(limit) {
111
+ const sql = limit ? "SELECT * FROM copilot_queries WHERE status = ? ORDER BY times_asked DESC LIMIT ?" : "SELECT * FROM copilot_queries WHERE status = ? ORDER BY times_asked DESC";
112
+ return (limit ? this.db.prepare(sql).all("pending", limit) : this.db.prepare(sql).all("pending")).map((r) => this.rowToQuery(r));
113
+ }
114
+ async findSimilarQuery(normalizedQuery) {
115
+ const row = this.db.prepare("SELECT * FROM copilot_queries WHERE normalized_query = ? AND status = ? LIMIT 1").get(normalizedQuery, "pending");
116
+ return row ? this.rowToQuery(row) : null;
117
+ }
118
+ async getQueriesByCluster(clusterId) {
119
+ return this.db.prepare("SELECT * FROM copilot_queries WHERE cluster_id = ? ORDER BY times_asked DESC").all(clusterId).map((r) => this.rowToQuery(r));
120
+ }
121
+ async addCluster(cluster) {
122
+ this.db.prepare(`
123
+ INSERT OR REPLACE INTO copilot_clusters
124
+ (id, label, representative_query, centroid, query_count, unique_customers, status, created_at, updated_at)
125
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
126
+ `).run(cluster.id, cluster.label ?? null, cluster.representativeQuery, cluster.centroid ? Buffer.from(new Float32Array(cluster.centroid).buffer) : null, cluster.queryCount, JSON.stringify(cluster.uniqueCustomers), cluster.status, cluster.createdAt, cluster.updatedAt);
127
+ if (cluster.centroid) {
128
+ this.db.prepare("DELETE FROM vec_copilot_clusters WHERE cluster_id = ?").run(cluster.id);
129
+ this.db.prepare("INSERT INTO vec_copilot_clusters (cluster_id, centroid) VALUES (?, ?)").run(cluster.id, new Float32Array(cluster.centroid));
130
+ }
131
+ }
132
+ async getCluster(id) {
133
+ const row = this.db.prepare("SELECT * FROM copilot_clusters WHERE id = ?").get(id);
134
+ return row ? this.rowToCluster(row) : null;
135
+ }
136
+ async updateCluster(id, updates) {
137
+ const existing = await this.getCluster(id);
138
+ if (!existing) return;
139
+ const merged = {
140
+ ...existing,
141
+ ...updates,
142
+ updatedAt: Date.now()
143
+ };
144
+ await this.addCluster(merged);
145
+ }
146
+ async getOpenClusters() {
147
+ return this.db.prepare("SELECT * FROM copilot_clusters WHERE status = ? ORDER BY query_count DESC").all("pending").map((r) => this.rowToCluster(r));
148
+ }
149
+ /** Vector search cluster centroids — returns closest clusters by cosine distance */
150
+ async searchClusters(embedding, limit) {
151
+ return this.db.prepare(`
152
+ SELECT cluster_id, distance
153
+ FROM vec_copilot_clusters
154
+ WHERE centroid MATCH ?
155
+ ORDER BY distance
156
+ LIMIT ?
157
+ `).all(new Float32Array(embedding), limit).map((r) => ({
158
+ clusterId: r.cluster_id,
159
+ distance: r.distance
160
+ }));
161
+ }
162
+ async getImpactMetrics(topN = 10) {
163
+ const counts = this.db.prepare(`
164
+ SELECT
165
+ SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending_count,
166
+ SUM(CASE WHEN status = 'taught' THEN 1 ELSE 0 END) as taught_count,
167
+ SUM(CASE WHEN status = 'dismissed' THEN 1 ELSE 0 END) as dismissed_count,
168
+ SUM(times_asked) as total_times_asked
169
+ FROM copilot_queries
170
+ `).get();
171
+ const allCustomerRows = this.db.prepare("SELECT unique_customers FROM copilot_queries").all();
172
+ const allCustomers = /* @__PURE__ */ new Set();
173
+ for (const row of allCustomerRows) JSON.parse(row.unique_customers).forEach((c) => allCustomers.add(c));
174
+ const topPending = await this.getPendingQueries(topN);
175
+ return {
176
+ pendingCount: counts.pending_count ?? 0,
177
+ taughtCount: counts.taught_count ?? 0,
178
+ dismissedCount: counts.dismissed_count ?? 0,
179
+ totalCustomersAffected: allCustomers.size,
180
+ totalTimesAsked: counts.total_times_asked ?? 0,
181
+ topPendingQueries: topPending
182
+ };
183
+ }
184
+ async getLastDigestTime() {
185
+ const row = this.db.prepare("SELECT value FROM copilot_meta WHERE key = 'last_digest_time'").get();
186
+ return row ? parseInt(row.value, 10) : 0;
187
+ }
188
+ async setLastDigestTime(time) {
189
+ this.db.prepare("INSERT OR REPLACE INTO copilot_meta (key, value) VALUES ('last_digest_time', ?)").run(String(time));
190
+ }
191
+ rowToQuery(row) {
192
+ return {
193
+ id: row.id,
194
+ query: row.query,
195
+ normalizedQuery: row.normalized_query,
196
+ channel: row.channel,
197
+ customerPhone: row.customer_phone,
198
+ kbTopScore: row.kb_top_score,
199
+ kbIsFaqMatch: !!row.kb_is_faq_match,
200
+ kbTopChunkContent: row.kb_top_chunk_content ?? void 0,
201
+ kbResultCount: row.kb_result_count,
202
+ status: row.status,
203
+ clusterId: row.cluster_id ?? void 0,
204
+ timesAsked: row.times_asked,
205
+ uniqueCustomers: JSON.parse(row.unique_customers),
206
+ embedding: row.embedding ? Array.from(new Float32Array(row.embedding.buffer ?? row.embedding)) : void 0,
207
+ suggestedAnswer: row.suggested_answer ?? void 0,
208
+ taughtAnswer: row.taught_answer ?? void 0,
209
+ createdAt: row.created_at,
210
+ updatedAt: row.updated_at
211
+ };
212
+ }
213
+ rowToCluster(row) {
214
+ return {
215
+ id: row.id,
216
+ label: row.label ?? void 0,
217
+ representativeQuery: row.representative_query,
218
+ centroid: row.centroid ? Array.from(new Float32Array(row.centroid.buffer ?? row.centroid)) : void 0,
219
+ queryCount: row.query_count,
220
+ uniqueCustomers: JSON.parse(row.unique_customers),
221
+ status: row.status,
222
+ createdAt: row.created_at,
223
+ updatedAt: row.updated_at
224
+ };
225
+ }
226
+ };
227
+
228
+ //#endregion
229
+ //#region src/InMemoryCopilotStore.ts
230
+ /**
231
+ * In-memory copilot store for testing and development.
232
+ */
233
+ var InMemoryCopilotStore = class {
234
+ queries = /* @__PURE__ */ new Map();
235
+ clusters = /* @__PURE__ */ new Map();
236
+ lastDigestTime = 0;
237
+ async initialize() {}
238
+ async close() {}
239
+ async addQuery(query) {
240
+ this.queries.set(query.id, { ...query });
241
+ }
242
+ async getQuery(id) {
243
+ return this.queries.get(id) ?? null;
244
+ }
245
+ async updateQuery(id, updates) {
246
+ const q = this.queries.get(id);
247
+ if (q) this.queries.set(id, {
248
+ ...q,
249
+ ...updates,
250
+ updatedAt: Date.now()
251
+ });
252
+ }
253
+ async getPendingQueries(limit) {
254
+ const pending = [...this.queries.values()].filter((q) => q.status === "pending").sort((a, b) => b.timesAsked - a.timesAsked);
255
+ return limit ? pending.slice(0, limit) : pending;
256
+ }
257
+ async findSimilarQuery(normalizedQuery) {
258
+ for (const q of this.queries.values()) if (q.normalizedQuery === normalizedQuery) return q;
259
+ return null;
260
+ }
261
+ async getQueriesByCluster(clusterId) {
262
+ return [...this.queries.values()].filter((q) => q.clusterId === clusterId);
263
+ }
264
+ async addCluster(cluster) {
265
+ this.clusters.set(cluster.id, { ...cluster });
266
+ }
267
+ async getCluster(id) {
268
+ return this.clusters.get(id) ?? null;
269
+ }
270
+ async updateCluster(id, updates) {
271
+ const c = this.clusters.get(id);
272
+ if (c) this.clusters.set(id, {
273
+ ...c,
274
+ ...updates,
275
+ updatedAt: Date.now()
276
+ });
277
+ }
278
+ async getOpenClusters() {
279
+ return [...this.clusters.values()].filter((c) => c.status === "pending");
280
+ }
281
+ async getImpactMetrics(topN = 10) {
282
+ const all = [...this.queries.values()];
283
+ const pending = all.filter((q) => q.status === "pending");
284
+ const allCustomers = /* @__PURE__ */ new Set();
285
+ let totalAsked = 0;
286
+ for (const q of all) {
287
+ q.uniqueCustomers.forEach((c) => allCustomers.add(c));
288
+ totalAsked += q.timesAsked;
289
+ }
290
+ return {
291
+ pendingCount: pending.length,
292
+ taughtCount: all.filter((q) => q.status === "taught").length,
293
+ dismissedCount: all.filter((q) => q.status === "dismissed").length,
294
+ totalCustomersAffected: allCustomers.size,
295
+ totalTimesAsked: totalAsked,
296
+ topPendingQueries: pending.sort((a, b) => b.timesAsked - a.timesAsked).slice(0, topN)
297
+ };
298
+ }
299
+ async getLastDigestTime() {
300
+ return this.lastDigestTime;
301
+ }
302
+ async setLastDigestTime(time) {
303
+ this.lastDigestTime = time;
304
+ }
305
+ };
306
+
307
+ //#endregion
308
+ //#region src/UnansweredQueryTracker.ts
309
+ /**
310
+ * Tracks unanswered queries by listening to message:processed events.
311
+ * Runs non-blocking — errors are caught and logged, never thrown.
312
+ */
313
+ var UnansweredQueryTracker = class {
314
+ constructor(store, config, embedder, clusterer) {
315
+ this.store = store;
316
+ this.config = config;
317
+ this.embedder = embedder;
318
+ this.clusterer = clusterer;
319
+ }
320
+ /**
321
+ * Called after each message is processed. Decides whether to track the query.
322
+ * Non-blocking: catches all errors internally.
323
+ */
324
+ async maybeTrack(event) {
325
+ try {
326
+ if (!this.config.enabled) return;
327
+ const kbTopScore = event.response.metadata?.kbTopScore ?? 0;
328
+ const kbResultCount = event.response.metadata?.kbResultCount ?? 0;
329
+ if (kbResultCount > 0 && kbTopScore >= this.config.trackingThreshold) return;
330
+ const normalizedQuery = this.normalizeQuery(event.query);
331
+ const existing = await this.store.findSimilarQuery(normalizedQuery);
332
+ if (existing) {
333
+ const uniqueCustomers = existing.uniqueCustomers.includes(event.customerPhone) ? existing.uniqueCustomers : [...existing.uniqueCustomers, event.customerPhone];
334
+ await this.store.updateQuery(existing.id, {
335
+ timesAsked: existing.timesAsked + 1,
336
+ uniqueCustomers,
337
+ updatedAt: Date.now()
338
+ });
339
+ } else {
340
+ const embedding = await this.embedder.embed(normalizedQuery);
341
+ const now = Date.now();
342
+ const id = crypto.randomUUID();
343
+ const newQuery = {
344
+ id,
345
+ query: event.query,
346
+ normalizedQuery,
347
+ channel: event.channel,
348
+ customerPhone: event.customerPhone,
349
+ kbTopScore,
350
+ kbIsFaqMatch: event.response.metadata?.kbIsFaqMatch ?? false,
351
+ kbTopChunkContent: event.response.metadata?.kbTopChunkContent,
352
+ kbResultCount,
353
+ status: "pending",
354
+ timesAsked: 1,
355
+ uniqueCustomers: [event.customerPhone],
356
+ embedding,
357
+ createdAt: now,
358
+ updatedAt: now
359
+ };
360
+ await this.store.addQuery(newQuery);
361
+ if (this.clusterer) await this.clusterer.assignCluster(id, normalizedQuery, embedding);
362
+ }
363
+ } catch (_err) {}
364
+ }
365
+ /** Normalize query text for deduplication */
366
+ normalizeQuery(text) {
367
+ return text.toLowerCase().trim().replace(/\s+/g, " ");
368
+ }
369
+ };
370
+
371
+ //#endregion
372
+ //#region src/QueryClusterer.ts
373
+ /**
374
+ * Groups semantically similar unanswered queries into clusters.
375
+ */
376
+ var QueryClusterer = class {
377
+ constructor(store, embedder, config = { clusterThreshold: .87 }) {
378
+ this.store = store;
379
+ this.embedder = embedder;
380
+ this.config = config;
381
+ }
382
+ /**
383
+ * Assign a query to an existing cluster or create a new one.
384
+ * Returns the cluster ID.
385
+ */
386
+ async assignCluster(queryId, queryText, embedding) {
387
+ const clusters = await this.store.getOpenClusters();
388
+ let bestCluster = null;
389
+ let bestScore = -1;
390
+ for (const cluster of clusters) {
391
+ if (!cluster.centroid) continue;
392
+ const score = this.cosineSimilarity(embedding, cluster.centroid);
393
+ if (score > bestScore) {
394
+ bestScore = score;
395
+ bestCluster = cluster;
396
+ }
397
+ }
398
+ if (bestCluster && bestScore >= this.config.clusterThreshold) {
399
+ const newCount = bestCluster.queryCount + 1;
400
+ const newCentroid = this.updateCentroid(bestCluster.centroid, embedding, newCount);
401
+ await this.store.updateCluster(bestCluster.id, {
402
+ centroid: newCentroid,
403
+ queryCount: newCount,
404
+ updatedAt: Date.now()
405
+ });
406
+ await this.store.updateQuery(queryId, { clusterId: bestCluster.id });
407
+ return bestCluster.id;
408
+ }
409
+ const clusterId = crypto.randomUUID();
410
+ const now = Date.now();
411
+ const newCluster = {
412
+ id: clusterId,
413
+ representativeQuery: queryText,
414
+ centroid: embedding,
415
+ queryCount: 1,
416
+ uniqueCustomers: [],
417
+ status: "pending",
418
+ createdAt: now,
419
+ updatedAt: now
420
+ };
421
+ await this.store.addCluster(newCluster);
422
+ await this.store.updateQuery(queryId, { clusterId });
423
+ return clusterId;
424
+ }
425
+ /** Cosine similarity between two vectors */
426
+ cosineSimilarity(a, b) {
427
+ let dot = 0, magA = 0, magB = 0;
428
+ for (let i = 0; i < a.length; i++) {
429
+ dot += a[i] * b[i];
430
+ magA += a[i] * a[i];
431
+ magB += b[i] * b[i];
432
+ }
433
+ return dot / (Math.sqrt(magA) * Math.sqrt(magB));
434
+ }
435
+ /** Compute running average centroid */
436
+ updateCentroid(current, newVec, count) {
437
+ return current.map((v, i) => (v * (count - 1) + newVec[i]) / count);
438
+ }
439
+ };
440
+
441
+ //#endregion
442
+ //#region src/SuggestionEngine.ts
443
+ const SYSTEM_PROMPT = `You are a helpful assistant that drafts FAQ answers for a knowledge base. Write concise, accurate answers based on the available context. If the context doesn't contain enough information, say so.`;
444
+ /**
445
+ * Generates suggested answers for unanswered queries using the LLM + KB context.
446
+ */
447
+ var SuggestionEngine = class {
448
+ constructor(aiProvider, kb) {
449
+ this.aiProvider = aiProvider;
450
+ this.kb = kb;
451
+ }
452
+ /**
453
+ * Generate a suggested answer for a query, optionally including related queries from a cluster.
454
+ */
455
+ async suggest(query, relatedQueries) {
456
+ const kbResult = await this.kb.retrieve(query);
457
+ let prompt = `Customer query: "${query}"\n`;
458
+ if (relatedQueries && relatedQueries.length > 0) {
459
+ prompt += `\nRelated queries from other customers:\n`;
460
+ for (const rq of relatedQueries) prompt += `- "${rq}"\n`;
461
+ }
462
+ if (kbResult.context) prompt += `\nKnowledge base context:\n${kbResult.context}\n`;
463
+ prompt += `\nPlease draft a concise, helpful FAQ answer for this query.`;
464
+ return (await this.aiProvider.generateText({
465
+ system: SYSTEM_PROMPT,
466
+ prompt,
467
+ temperature: .3
468
+ })).text;
469
+ }
470
+ };
471
+
472
+ //#endregion
473
+ //#region src/CopilotCommandHandler.ts
474
+ /**
475
+ * Handles /review commands from admin users in training mode.
476
+ */
477
+ var CopilotCommandHandler = class {
478
+ /** Per-admin session state: tracks which query they're currently reviewing */
479
+ sessions = /* @__PURE__ */ new Map();
480
+ constructor(store, suggestionEngine, kb, clusterer) {
481
+ this.store = store;
482
+ this.suggestionEngine = suggestionEngine;
483
+ this.kb = kb;
484
+ this.clusterer = clusterer;
485
+ }
486
+ /**
487
+ * Handle a /review subcommand.
488
+ * @param command The full command (always '/review')
489
+ * @param args Subcommand + arguments (e.g. 'accept This is the answer')
490
+ * @param adminPhone The admin's phone number
491
+ * @param reply Function to send a reply back to the admin
492
+ */
493
+ async handleCommand(command, args, adminPhone, reply) {
494
+ const trimmed = args.trim();
495
+ const spaceIndex = trimmed.indexOf(" ");
496
+ const subcommand = spaceIndex === -1 ? trimmed.toLowerCase() : trimmed.slice(0, spaceIndex).toLowerCase();
497
+ const subArgs = spaceIndex === -1 ? "" : trimmed.slice(spaceIndex + 1).trim();
498
+ switch (subcommand) {
499
+ case "":
500
+ case "next": return this.handleNext(adminPhone, reply);
501
+ case "accept": return this.handleAccept(adminPhone, subArgs, reply);
502
+ case "edit": return this.handleEdit(adminPhone, subArgs, reply);
503
+ case "skip": return this.handleSkip(adminPhone, reply);
504
+ case "stats": return this.handleStats(reply);
505
+ case "cluster": return this.handleCluster(adminPhone, reply);
506
+ case "help": return this.handleHelp(reply);
507
+ default: await reply(`Unknown subcommand: *${subcommand}*\n\nType */review help* to see available commands.`);
508
+ }
509
+ }
510
+ getSession(adminPhone) {
511
+ let session = this.sessions.get(adminPhone);
512
+ if (!session) {
513
+ session = { currentQuery: null };
514
+ this.sessions.set(adminPhone, session);
515
+ }
516
+ return session;
517
+ }
518
+ async handleNext(adminPhone, reply) {
519
+ const session = this.getSession(adminPhone);
520
+ const pending = await this.store.getPendingQueries(1);
521
+ if (pending.length === 0) {
522
+ session.currentQuery = null;
523
+ await reply("No pending queries to review. Great job! šŸŽ‰");
524
+ return;
525
+ }
526
+ const query = pending[0];
527
+ session.currentQuery = query;
528
+ let text = `*šŸ“‹ Query for Review*\n\n`;
529
+ text += `*Question:* ${query.query}\n`;
530
+ text += `*Times asked:* ${query.timesAsked}\n`;
531
+ text += `*Unique customers:* ${query.uniqueCustomers.length}\n`;
532
+ if (query.suggestedAnswer) text += `\n*šŸ’” Suggested answer:*\n${query.suggestedAnswer}\n`;
533
+ else if (this.suggestionEngine) try {
534
+ const suggested = await this.suggestionEngine.suggest(query.query);
535
+ if (suggested) {
536
+ query.suggestedAnswer = suggested;
537
+ await this.store.updateQuery(query.id, { suggestedAnswer: suggested });
538
+ text += `\n*šŸ’” Suggested answer:*\n${suggested}\n`;
539
+ }
540
+ } catch {}
541
+ text += `\n*Actions:*`;
542
+ text += `\n/review accept [answer] — Teach this answer`;
543
+ text += `\n/review edit <answer> — Provide your own answer`;
544
+ text += `\n/review skip — Skip this query`;
545
+ await reply(text);
546
+ }
547
+ async handleAccept(adminPhone, answerText, reply) {
548
+ const session = this.getSession(adminPhone);
549
+ const query = session.currentQuery;
550
+ if (!query) {
551
+ await reply("No query selected. Use */review next* to load one.");
552
+ return;
553
+ }
554
+ const answer = answerText || query.suggestedAnswer;
555
+ if (!answer) {
556
+ await reply("No answer provided and no suggestion available. Use */review edit <answer>* to provide one.");
557
+ return;
558
+ }
559
+ await this.kb.ingestFaq(query.query, answer);
560
+ await this.store.updateQuery(query.id, {
561
+ status: "taught",
562
+ taughtAnswer: answer,
563
+ updatedAt: Date.now()
564
+ });
565
+ session.currentQuery = null;
566
+ await reply(`*āœ… Taught!*\n\n*Q:* ${query.query}\n*A:* ${answer}`);
567
+ }
568
+ async handleEdit(adminPhone, answerText, reply) {
569
+ const session = this.getSession(adminPhone);
570
+ const query = session.currentQuery;
571
+ if (!query) {
572
+ await reply("No query selected. Use */review next* to load one.");
573
+ return;
574
+ }
575
+ if (!answerText) {
576
+ await reply("Please provide an answer: */review edit <your answer>*");
577
+ return;
578
+ }
579
+ await this.kb.ingestFaq(query.query, answerText);
580
+ await this.store.updateQuery(query.id, {
581
+ status: "taught",
582
+ taughtAnswer: answerText,
583
+ updatedAt: Date.now()
584
+ });
585
+ session.currentQuery = null;
586
+ await reply(`*āœ… Taught!*\n\n*Q:* ${query.query}\n*A:* ${answerText}`);
587
+ }
588
+ async handleSkip(adminPhone, reply) {
589
+ const session = this.getSession(adminPhone);
590
+ const query = session.currentQuery;
591
+ if (!query) {
592
+ await reply("No query selected. Use */review next* to load one.");
593
+ return;
594
+ }
595
+ await this.store.updateQuery(query.id, {
596
+ status: "dismissed",
597
+ updatedAt: Date.now()
598
+ });
599
+ session.currentQuery = null;
600
+ await reply(`*ā­ļø Skipped:* ${query.query}`);
601
+ }
602
+ async handleStats(reply) {
603
+ const metrics = await this.store.getImpactMetrics();
604
+ let text = `*šŸ“Š Copilot Stats*\n\n`;
605
+ text += `*Pending:* ${metrics.pendingCount}\n`;
606
+ text += `*Taught:* ${metrics.taughtCount}\n`;
607
+ text += `*Dismissed:* ${metrics.dismissedCount}\n`;
608
+ text += `*Customers affected:* ${metrics.totalCustomersAffected}\n`;
609
+ text += `*Total times asked:* ${metrics.totalTimesAsked}`;
610
+ if (metrics.topPendingQueries.length > 0) {
611
+ text += `\n\n*Top pending queries:*`;
612
+ for (const q of metrics.topPendingQueries) text += `\n• ${q.query} (Ɨ${q.timesAsked})`;
613
+ }
614
+ await reply(text);
615
+ }
616
+ async handleCluster(adminPhone, reply) {
617
+ const query = this.getSession(adminPhone).currentQuery;
618
+ if (!query) {
619
+ await reply("No query selected. Use */review next* to load one.");
620
+ return;
621
+ }
622
+ if (!query.clusterId) {
623
+ await reply("This query is not part of any cluster.");
624
+ return;
625
+ }
626
+ const cluster = await this.store.getCluster(query.clusterId);
627
+ if (!cluster) {
628
+ await reply("Cluster not found.");
629
+ return;
630
+ }
631
+ const queries = await this.store.getQueriesByCluster(query.clusterId);
632
+ let text = `*šŸ”— Cluster: ${cluster.label || cluster.representativeQuery}*\n\n`;
633
+ text += `*Queries in cluster:* ${queries.length}\n`;
634
+ text += `*Unique customers:* ${cluster.uniqueCustomers.length}\n\n`;
635
+ for (const q of queries) {
636
+ const marker = q.id === query.id ? "šŸ‘‰ " : "• ";
637
+ text += `${marker}${q.query} (Ɨ${q.timesAsked})\n`;
638
+ }
639
+ await reply(text);
640
+ }
641
+ async handleHelp(reply) {
642
+ await reply([
643
+ "*šŸ“– Review Commands*",
644
+ "",
645
+ "*/review* — Show next pending query",
646
+ "*/review next* — Same as above",
647
+ "*/review accept [answer]* — Teach with answer (or suggested)",
648
+ "*/review edit <answer>* — Teach with your own answer",
649
+ "*/review skip* — Dismiss current query",
650
+ "*/review stats* — Show impact metrics",
651
+ "*/review cluster* — Show related queries in cluster",
652
+ "*/review help* — This help message"
653
+ ].join("\n"));
654
+ }
655
+ };
656
+
657
+ //#endregion
658
+ //#region src/DigestScheduler.ts
659
+ /**
660
+ * Periodically sends digest summaries of unanswered queries to admin phones.
661
+ */
662
+ var DigestScheduler = class {
663
+ timer = null;
664
+ constructor(store, config, sendMessage) {
665
+ this.store = store;
666
+ this.config = config;
667
+ this.sendMessage = sendMessage;
668
+ }
669
+ /** Start the digest scheduler for the given admin phones */
670
+ start(adminPhones) {
671
+ if (this.timer) clearInterval(this.timer);
672
+ this.timer = setInterval(async () => {
673
+ try {
674
+ const lastDigest = await this.store.getLastDigestTime();
675
+ const now = Date.now();
676
+ if (now - lastDigest < this.config.digestIntervalMs) return;
677
+ const digest = await this.buildDigest();
678
+ if (!digest) return;
679
+ for (const phone of adminPhones) await this.sendMessage(phone, digest);
680
+ await this.store.setLastDigestTime(now);
681
+ } catch {}
682
+ }, this.config.digestIntervalMs);
683
+ }
684
+ /** Stop the digest scheduler */
685
+ stop() {
686
+ if (this.timer) {
687
+ clearInterval(this.timer);
688
+ this.timer = null;
689
+ }
690
+ }
691
+ /** Build the digest message text */
692
+ async buildDigest() {
693
+ const metrics = await this.store.getImpactMetrics(this.config.digestMaxItems);
694
+ if (metrics.pendingCount === 0) return null;
695
+ let text = `*šŸ“¬ Training Copilot Digest*\n\n`;
696
+ text += `*${metrics.pendingCount}* pending queries from *${metrics.totalCustomersAffected}* customers\n`;
697
+ if (metrics.topPendingQueries.length > 0) {
698
+ text += `\n*Top unanswered queries:*\n`;
699
+ for (const q of metrics.topPendingQueries) text += `• ${q.query} (Ɨ${q.timesAsked})\n`;
700
+ }
701
+ text += `\nReply */review* to start reviewing`;
702
+ return text;
703
+ }
704
+ };
705
+
706
+ //#endregion
707
+ export { CopilotCommandHandler, DEFAULT_COPILOT_CONFIG, DigestScheduler, InMemoryCopilotStore, QueryClusterer, SQLiteCopilotStore, SuggestionEngine, UnansweredQueryTracker };
708
+ //# sourceMappingURL=index.js.map