@diegoaltoworks/chatter 0.62.0 → 0.64.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.
@@ -90,6 +90,15 @@ export interface LeaseGatedConnectDeps {
90
90
  waitMs?: number;
91
91
  schedule?: (fn: () => void, ms: number) => void;
92
92
  logger?: Logger;
93
+ /**
94
+ * Recovery for a `connect` that threw, replacing the default (release the
95
+ * session, re-check after `waitMs`). The channel injects one so a thrown
96
+ * connect backs off on the same failure counter a closed connection uses
97
+ * instead of re-attempting at a flat interval. Whatever it does, it must
98
+ * end with another attempt scheduled: a session whose lease is held with
99
+ * no live socket and nothing pending never recovers on its own.
100
+ */
101
+ onConnectFailed?: (sessionId: string, error: unknown) => void;
93
102
  }
94
103
  /**
95
104
  * Acquire `sessionId`'s lease before connecting — two instances must never
@@ -99,6 +108,11 @@ export interface LeaseGatedConnectDeps {
99
108
  * heartbeat ever finds the lease gone (this instance went stale and another
100
109
  * took over), the session is torn down and this re-enters the same
101
110
  * wait-and-retry loop rather than staying connected alongside the new holder.
111
+ *
112
+ * Every failed connect attempt lands back here too, whether it threw on its
113
+ * first try or on a reconnect: this loop is the only way a session recovers,
114
+ * so an attempt that fails without leaving a socket behind (no socket, no
115
+ * future "close" event) must re-enter it rather than stop at a log line.
102
116
  */
103
117
  export declare function acquireSessionLease(sessionId: string, deps: LeaseGatedConnectDeps): Promise<void>;
104
118
  /** Sender-registry name for a session — the default session stays unprefixed so single-number deployments see a plain `"whatsapp"` name. */
@@ -1 +1 @@
1
- {"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../../../src/channels/whatsapp/channel.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../../../src/channels/whatsapp/channel.ts"],"names":[],"mappings":"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"}
@@ -360,11 +360,17 @@ async function shutdownWaSessions(sessions) {
360
360
  })
361
361
  );
362
362
  }
363
- async function loseSession(sessionId, sessions) {
363
+ function claimSession(sessionId, sessions) {
364
364
  const handle = sessions.get(sessionId);
365
- if (!handle) return;
365
+ if (!handle) return void 0;
366
366
  sessions.delete(sessionId);
367
+ return handle;
368
+ }
369
+ async function loseSession(sessionId, sessions) {
370
+ const handle = claimSession(sessionId, sessions);
371
+ if (!handle) return false;
367
372
  await shutdownWaSessions(/* @__PURE__ */ new Map([[sessionId, handle]]));
373
+ return true;
368
374
  }
369
375
  async function acquireSessionLease(sessionId, deps) {
370
376
  const { leaseStore, instanceId, sessions, isStopped, connect } = deps;
@@ -400,7 +406,9 @@ async function acquireSessionLease(sessionId, deps) {
400
406
  void leaseStore.tryAcquire(sessionId, instanceId, now(), staleMs).then((stillHeld) => {
401
407
  if (stillHeld || isStopped()) return;
402
408
  logger.error(`WhatsApp[${sessionId}]: lost the lease to another instance \u2014 disconnecting`);
403
- void loseSession(sessionId, sessions).then(retry);
409
+ void loseSession(sessionId, sessions).then((lost) => {
410
+ if (lost) retry();
411
+ });
404
412
  }).catch((error) => logger.warn(`WhatsApp[${sessionId}]: lease heartbeat failed:`, error));
405
413
  }, heartbeatMs);
406
414
  if (typeof heartbeat === "object" && "unref" in heartbeat) heartbeat.unref();
@@ -412,6 +420,10 @@ async function acquireSessionLease(sessionId, deps) {
412
420
  try {
413
421
  await connect(sessionId);
414
422
  } catch (error) {
423
+ if (deps.onConnectFailed) {
424
+ deps.onConnectFailed(sessionId, error);
425
+ return;
426
+ }
415
427
  logger.error(`WhatsApp[${sessionId}]: connect failed right after acquiring the lease:`, error);
416
428
  await loseSession(sessionId, sessions);
417
429
  retry();
@@ -458,6 +470,50 @@ function createWhatsAppChannel(config) {
458
470
  const authStore = config.authStore ?? createTursoWaAuthKV(deps.db);
459
471
  senders = deps.senders;
460
472
  await loadBaileys2();
473
+ const leaseDeps = {
474
+ leaseStore,
475
+ instanceId,
476
+ sessions,
477
+ isStopped: () => stopped,
478
+ connect,
479
+ now,
480
+ staleMs,
481
+ heartbeatMs,
482
+ waitMs,
483
+ schedule,
484
+ logger: log,
485
+ onConnectFailed: recoverFromFailedConnect
486
+ };
487
+ function nextRetryDelayMs(sessionId) {
488
+ const failures = failureCounts.get(sessionId) ?? 0;
489
+ failureCounts.set(sessionId, failures + 1);
490
+ return reconnectDelayMs(failures);
491
+ }
492
+ function attemptConnect(sessionId) {
493
+ void connect(sessionId).catch((error) => recoverFromFailedConnect(sessionId, error));
494
+ }
495
+ function recoverFromFailedConnect(sessionId, error) {
496
+ if (stopped) return;
497
+ const handle = claimSession(sessionId, sessions);
498
+ if (!handle) return;
499
+ const delay = nextRetryDelayMs(sessionId);
500
+ log.error(
501
+ `WhatsApp[${sessionId}]: connect attempt failed, releasing the lease and retrying in ${Math.round(delay / 1e3)}s:`,
502
+ error
503
+ );
504
+ schedule(
505
+ () => void acquireSessionLease(sessionId, leaseDeps).catch(
506
+ (retryError) => log.error(`WhatsApp[${sessionId}]: lease re-check failed:`, retryError)
507
+ ),
508
+ delay
509
+ );
510
+ void shutdownWaSessions(/* @__PURE__ */ new Map([[sessionId, handle]])).catch(
511
+ (teardownError) => log.warn(
512
+ `WhatsApp[${sessionId}]: teardown after a failed connect failed:`,
513
+ teardownError
514
+ )
515
+ );
516
+ }
461
517
  async function connect(sessionId) {
462
518
  if (stopped || !sessions.has(sessionId)) return;
463
519
  const baileys = await loadBaileys2();
@@ -476,7 +532,7 @@ function createWhatsAppChannel(config) {
476
532
  log.warn(
477
533
  `WhatsApp[${sessionId}]: no paired session found \u2014 pair it first (see docs/channels.md). Re-checking in 60s.`
478
534
  );
479
- schedule(() => void connect(sessionId).catch(() => void 0), 6e4);
535
+ schedule(() => attemptConnect(sessionId), 6e4);
480
536
  return;
481
537
  }
482
538
  const { version } = await baileys.fetchLatestBaileysVersion();
@@ -542,17 +598,11 @@ function createWhatsAppChannel(config) {
542
598
  return;
543
599
  }
544
600
  if (!stopped && sessions.has(sessionId)) {
545
- const delay = reconnectDelayMs(failureCounts.get(sessionId) ?? 0);
546
- failureCounts.set(sessionId, (failureCounts.get(sessionId) ?? 0) + 1);
601
+ const delay = nextRetryDelayMs(sessionId);
547
602
  log.warn(
548
603
  `WhatsApp[${sessionId}]: connection closed (${statusCode ?? "?"}), reconnecting in ${Math.round(delay / 1e3)}s...`
549
604
  );
550
- schedule(
551
- () => void connect(sessionId).catch(
552
- (error) => log.error(`WhatsApp[${sessionId}]: reconnect failed:`, error)
553
- ),
554
- delay
555
- );
605
+ schedule(() => attemptConnect(sessionId), delay);
556
606
  }
557
607
  }
558
608
  });
@@ -570,19 +620,9 @@ function createWhatsAppChannel(config) {
570
620
  }
571
621
  }
572
622
  for (const sessionId of sessionIds) {
573
- void acquireSessionLease(sessionId, {
574
- leaseStore,
575
- instanceId,
576
- sessions,
577
- isStopped: () => stopped,
578
- connect,
579
- now,
580
- staleMs,
581
- heartbeatMs,
582
- waitMs,
583
- schedule,
584
- logger: log
585
- }).catch((error) => log.error(`WhatsApp[${sessionId}]: failed to start:`, error));
623
+ void acquireSessionLease(sessionId, leaseDeps).catch(
624
+ (error) => log.error(`WhatsApp[${sessionId}]: failed to start:`, error)
625
+ );
586
626
  }
587
627
  },
588
628
  async stop() {
@@ -295,11 +295,17 @@ async function shutdownWaSessions(sessions) {
295
295
  })
296
296
  );
297
297
  }
298
- async function loseSession(sessionId, sessions) {
298
+ function claimSession(sessionId, sessions) {
299
299
  const handle = sessions.get(sessionId);
300
- if (!handle) return;
300
+ if (!handle) return void 0;
301
301
  sessions.delete(sessionId);
302
+ return handle;
303
+ }
304
+ async function loseSession(sessionId, sessions) {
305
+ const handle = claimSession(sessionId, sessions);
306
+ if (!handle) return false;
302
307
  await shutdownWaSessions(/* @__PURE__ */ new Map([[sessionId, handle]]));
308
+ return true;
303
309
  }
304
310
  async function acquireSessionLease(sessionId, deps) {
305
311
  const { leaseStore, instanceId, sessions, isStopped, connect } = deps;
@@ -335,7 +341,9 @@ async function acquireSessionLease(sessionId, deps) {
335
341
  void leaseStore.tryAcquire(sessionId, instanceId, now(), staleMs).then((stillHeld) => {
336
342
  if (stillHeld || isStopped()) return;
337
343
  logger.error(`WhatsApp[${sessionId}]: lost the lease to another instance \u2014 disconnecting`);
338
- void loseSession(sessionId, sessions).then(retry);
344
+ void loseSession(sessionId, sessions).then((lost) => {
345
+ if (lost) retry();
346
+ });
339
347
  }).catch((error) => logger.warn(`WhatsApp[${sessionId}]: lease heartbeat failed:`, error));
340
348
  }, heartbeatMs);
341
349
  if (typeof heartbeat === "object" && "unref" in heartbeat) heartbeat.unref();
@@ -347,6 +355,10 @@ async function acquireSessionLease(sessionId, deps) {
347
355
  try {
348
356
  await connect(sessionId);
349
357
  } catch (error) {
358
+ if (deps.onConnectFailed) {
359
+ deps.onConnectFailed(sessionId, error);
360
+ return;
361
+ }
350
362
  logger.error(`WhatsApp[${sessionId}]: connect failed right after acquiring the lease:`, error);
351
363
  await loseSession(sessionId, sessions);
352
364
  retry();
@@ -393,6 +405,50 @@ function createWhatsAppChannel(config) {
393
405
  const authStore = config.authStore ?? createTursoWaAuthKV(deps.db);
394
406
  senders = deps.senders;
395
407
  await loadBaileys2();
408
+ const leaseDeps = {
409
+ leaseStore,
410
+ instanceId,
411
+ sessions,
412
+ isStopped: () => stopped,
413
+ connect,
414
+ now,
415
+ staleMs,
416
+ heartbeatMs,
417
+ waitMs,
418
+ schedule,
419
+ logger: log,
420
+ onConnectFailed: recoverFromFailedConnect
421
+ };
422
+ function nextRetryDelayMs(sessionId) {
423
+ const failures = failureCounts.get(sessionId) ?? 0;
424
+ failureCounts.set(sessionId, failures + 1);
425
+ return reconnectDelayMs(failures);
426
+ }
427
+ function attemptConnect(sessionId) {
428
+ void connect(sessionId).catch((error) => recoverFromFailedConnect(sessionId, error));
429
+ }
430
+ function recoverFromFailedConnect(sessionId, error) {
431
+ if (stopped) return;
432
+ const handle = claimSession(sessionId, sessions);
433
+ if (!handle) return;
434
+ const delay = nextRetryDelayMs(sessionId);
435
+ log.error(
436
+ `WhatsApp[${sessionId}]: connect attempt failed, releasing the lease and retrying in ${Math.round(delay / 1e3)}s:`,
437
+ error
438
+ );
439
+ schedule(
440
+ () => void acquireSessionLease(sessionId, leaseDeps).catch(
441
+ (retryError) => log.error(`WhatsApp[${sessionId}]: lease re-check failed:`, retryError)
442
+ ),
443
+ delay
444
+ );
445
+ void shutdownWaSessions(/* @__PURE__ */ new Map([[sessionId, handle]])).catch(
446
+ (teardownError) => log.warn(
447
+ `WhatsApp[${sessionId}]: teardown after a failed connect failed:`,
448
+ teardownError
449
+ )
450
+ );
451
+ }
396
452
  async function connect(sessionId) {
397
453
  if (stopped || !sessions.has(sessionId)) return;
398
454
  const baileys = await loadBaileys2();
@@ -411,7 +467,7 @@ function createWhatsAppChannel(config) {
411
467
  log.warn(
412
468
  `WhatsApp[${sessionId}]: no paired session found \u2014 pair it first (see docs/channels.md). Re-checking in 60s.`
413
469
  );
414
- schedule(() => void connect(sessionId).catch(() => void 0), 6e4);
470
+ schedule(() => attemptConnect(sessionId), 6e4);
415
471
  return;
416
472
  }
417
473
  const { version } = await baileys.fetchLatestBaileysVersion();
@@ -477,17 +533,11 @@ function createWhatsAppChannel(config) {
477
533
  return;
478
534
  }
479
535
  if (!stopped && sessions.has(sessionId)) {
480
- const delay = reconnectDelayMs(failureCounts.get(sessionId) ?? 0);
481
- failureCounts.set(sessionId, (failureCounts.get(sessionId) ?? 0) + 1);
536
+ const delay = nextRetryDelayMs(sessionId);
482
537
  log.warn(
483
538
  `WhatsApp[${sessionId}]: connection closed (${statusCode ?? "?"}), reconnecting in ${Math.round(delay / 1e3)}s...`
484
539
  );
485
- schedule(
486
- () => void connect(sessionId).catch(
487
- (error) => log.error(`WhatsApp[${sessionId}]: reconnect failed:`, error)
488
- ),
489
- delay
490
- );
540
+ schedule(() => attemptConnect(sessionId), delay);
491
541
  }
492
542
  }
493
543
  });
@@ -505,19 +555,9 @@ function createWhatsAppChannel(config) {
505
555
  }
506
556
  }
507
557
  for (const sessionId of sessionIds) {
508
- void acquireSessionLease(sessionId, {
509
- leaseStore,
510
- instanceId,
511
- sessions,
512
- isStopped: () => stopped,
513
- connect,
514
- now,
515
- staleMs,
516
- heartbeatMs,
517
- waitMs,
518
- schedule,
519
- logger: log
520
- }).catch((error) => log.error(`WhatsApp[${sessionId}]: failed to start:`, error));
558
+ void acquireSessionLease(sessionId, leaseDeps).catch(
559
+ (error) => log.error(`WhatsApp[${sessionId}]: failed to start:`, error)
560
+ );
521
561
  }
522
562
  },
523
563
  async stop() {
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Post-boot and periodic health checks for the default `VectorStore`
3
+ * knowledge base, so a collapse is visible in logs the moment it happens
4
+ * instead of waiting for a user to notice broken answers - the gap that let
5
+ * two knowledge-base wipes go unnoticed until someone spotted broken
6
+ * answers.
7
+ */
8
+ import type { Client as LibsqlClient } from "@libsql/client";
9
+ import { type BuildLock } from "./buildLock";
10
+ import { type Logger } from "./logger";
11
+ export interface KnowledgeHealth {
12
+ chunksByBucket: Record<string, number>;
13
+ totalChunks: number;
14
+ totalEmbeddings: number;
15
+ /** Embeddings rows with no matching chunks row. */
16
+ orphanedEmbeddings: number;
17
+ /** Chunks rows with no matching embeddings row (e.g. a build interrupted mid-embed). */
18
+ missingEmbeddings: number;
19
+ }
20
+ /**
21
+ * Read-only snapshot of chunks/embeddings consistency, logged at `info` -
22
+ * escalating to `warn` when `totalChunks === 0` (the exact signature of both
23
+ * knowledge-base wipes) or `orphanedEmbeddings > 0`. The query shape mirrors
24
+ * the diagnostic used live to investigate the second incident.
25
+ */
26
+ export declare function checkKnowledgeHealth(db: LibsqlClient, logger: Logger): Promise<KnowledgeHealth>;
27
+ export interface KnowledgeHealthCheckConfig {
28
+ /** Run post-boot and periodic health checks. Default: true. */
29
+ enabled?: boolean;
30
+ /** How often to re-run the check after boot. Default: 30 minutes. */
31
+ intervalMs?: number;
32
+ /**
33
+ * Delete orphaned embeddings rows (no matching chunk) whenever a check
34
+ * finds them. Default: false - opt-in, since it's a delete even though a
35
+ * safe/idempotent one. Under normal operation orphans should be rare
36
+ * (`cleanupStaleChunks` already deletes a stale chunk and its embedding
37
+ * together); this exists chiefly to clean up historical damage and catch
38
+ * any future code path that doesn't maintain that invariant.
39
+ */
40
+ autoCleanOrphanedEmbeddings?: boolean;
41
+ }
42
+ export declare const DEFAULT_KNOWLEDGE_HEALTH_INTERVAL_MS: number;
43
+ export interface KnowledgeHealthScheduler {
44
+ /** Runs one check immediately, applying auto-clean if configured. */
45
+ runOnce(): Promise<KnowledgeHealth>;
46
+ /** Stops periodic re-checks. Safe to call even if periodic checks never started. */
47
+ stop(): void;
48
+ }
49
+ export interface ScheduleKnowledgeHealthChecksOptions {
50
+ db: LibsqlClient;
51
+ config?: KnowledgeHealthCheckConfig;
52
+ /** Default: a console logger writing to stderr. */
53
+ logger?: Logger;
54
+ /** Default: a lock table in `db` (see buildLock.ts) - the same lock `VectorStore.build()` uses. */
55
+ buildLock?: BuildLock;
56
+ /** Identifies this process while it holds the build lock during cleanup. Default: a random id. */
57
+ instanceId?: string;
58
+ /** Injectable for tests; defaults to `setInterval`/`clearInterval`. */
59
+ schedule?: (fn: () => void, ms: number) => {
60
+ stop: () => void;
61
+ };
62
+ }
63
+ /**
64
+ * Runs {@link checkKnowledgeHealth} once immediately and, unless disabled,
65
+ * again on `intervalMs` via an unref'd timer - so it never blocks process
66
+ * exit or leaves an open handle in tests. `enabled: false` skips both the
67
+ * immediate run and the periodic one.
68
+ */
69
+ export declare function scheduleKnowledgeHealthChecks(options: ScheduleKnowledgeHealthChecksOptions): Promise<KnowledgeHealthScheduler>;
70
+ //# sourceMappingURL=knowledgeHealth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"knowledgeHealth.d.ts","sourceRoot":"","sources":["../../src/core/knowledgeHealth.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,MAAM,IAAI,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC7D,OAAO,EAGL,KAAK,SAAS,EAEf,MAAM,aAAa,CAAC;AACrB,OAAO,EAAuB,KAAK,MAAM,EAAE,MAAM,UAAU,CAAC;AAE5D,MAAM,WAAW,eAAe;IAC9B,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACvC,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;IACxB,mDAAmD;IACnD,kBAAkB,EAAE,MAAM,CAAC;IAC3B,wFAAwF;IACxF,iBAAiB,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;GAKG;AACH,wBAAsB,oBAAoB,CACxC,EAAE,EAAE,YAAY,EAChB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,eAAe,CAAC,CAuC1B;AAsCD,MAAM,WAAW,0BAA0B;IACzC,+DAA+D;IAC/D,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;;OAOG;IACH,2BAA2B,CAAC,EAAE,OAAO,CAAC;CACvC;AAED,eAAO,MAAM,oCAAoC,QAAc,CAAC;AAEhE,MAAM,WAAW,wBAAwB;IACvC,qEAAqE;IACrE,OAAO,IAAI,OAAO,CAAC,eAAe,CAAC,CAAC;IACpC,oFAAoF;IACpF,IAAI,IAAI,IAAI,CAAC;CACd;AAED,MAAM,WAAW,oCAAoC;IACnD,EAAE,EAAE,YAAY,CAAC;IACjB,MAAM,CAAC,EAAE,0BAA0B,CAAC;IACpC,mDAAmD;IACnD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,mGAAmG;IACnG,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,kGAAkG;IAClG,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,uEAAuE;IACvE,QAAQ,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,KAAK;QAAE,IAAI,EAAE,MAAM,IAAI,CAAA;KAAE,CAAC;CACjE;AAQD;;;;;GAKG;AACH,wBAAsB,6BAA6B,CACjD,OAAO,EAAE,oCAAoC,GAC5C,OAAO,CAAC,wBAAwB,CAAC,CAyCnC"}
package/dist/index.js CHANGED
@@ -416,6 +416,119 @@ var init_retrieval = __esm({
416
416
  }
417
417
  });
418
418
 
419
+ // src/core/knowledgeHealth.ts
420
+ var knowledgeHealth_exports = {};
421
+ __export(knowledgeHealth_exports, {
422
+ DEFAULT_KNOWLEDGE_HEALTH_INTERVAL_MS: () => DEFAULT_KNOWLEDGE_HEALTH_INTERVAL_MS,
423
+ checkKnowledgeHealth: () => checkKnowledgeHealth,
424
+ scheduleKnowledgeHealthChecks: () => scheduleKnowledgeHealthChecks
425
+ });
426
+ async function checkKnowledgeHealth(db, logger) {
427
+ const [byBucket, totalChunksRes, totalEmbeddingsRes, orphanedRes, missingRes] = await Promise.all(
428
+ [
429
+ db.execute("SELECT bucket, COUNT(*) as count FROM chunks GROUP BY bucket"),
430
+ db.execute("SELECT COUNT(*) as count FROM chunks"),
431
+ db.execute("SELECT COUNT(*) as count FROM embeddings"),
432
+ db.execute(
433
+ "SELECT COUNT(*) as count FROM embeddings e LEFT JOIN chunks c ON c.id = e.id WHERE c.id IS NULL"
434
+ ),
435
+ db.execute(
436
+ "SELECT COUNT(*) as count FROM chunks c LEFT JOIN embeddings e ON e.id = c.id WHERE e.id IS NULL"
437
+ )
438
+ ]
439
+ );
440
+ const chunksByBucket = {};
441
+ for (const row of byBucket.rows) {
442
+ chunksByBucket[String(row.bucket)] = Number(row.count ?? 0);
443
+ }
444
+ const health = {
445
+ chunksByBucket,
446
+ totalChunks: Number(totalChunksRes.rows[0]?.count ?? 0),
447
+ totalEmbeddings: Number(totalEmbeddingsRes.rows[0]?.count ?? 0),
448
+ orphanedEmbeddings: Number(orphanedRes.rows[0]?.count ?? 0),
449
+ missingEmbeddings: Number(missingRes.rows[0]?.count ?? 0)
450
+ };
451
+ const summary = `chunksByBucket=${JSON.stringify(health.chunksByBucket)} totalChunks=${health.totalChunks} totalEmbeddings=${health.totalEmbeddings} orphanedEmbeddings=${health.orphanedEmbeddings} missingEmbeddings=${health.missingEmbeddings}`;
452
+ if (health.totalChunks === 0 || health.orphanedEmbeddings > 0) {
453
+ logger.warn(`Knowledge base health check: ${summary}`);
454
+ } else {
455
+ logger.info(`Knowledge base health check: ${summary}`);
456
+ }
457
+ return health;
458
+ }
459
+ async function cleanOrphanedEmbeddings(db, buildLock, instanceId, logger) {
460
+ const acquired = await buildLock.tryAcquire(
461
+ BUILD_LOCK_KEY,
462
+ instanceId,
463
+ Date.now(),
464
+ BUILD_LOCK_STALE_MS
465
+ );
466
+ if (!acquired) {
467
+ logger.warn(
468
+ "Knowledge health: skipping orphaned-embeddings cleanup - the build lock is held by another instance."
469
+ );
470
+ return;
471
+ }
472
+ try {
473
+ const result = await db.execute(
474
+ "DELETE FROM embeddings WHERE id NOT IN (SELECT id FROM chunks)"
475
+ );
476
+ if (result.rowsAffected > 0) {
477
+ logger.warn(`Knowledge health: deleted ${result.rowsAffected} orphaned embedding row(s).`);
478
+ }
479
+ } finally {
480
+ await buildLock.release(BUILD_LOCK_KEY, instanceId);
481
+ }
482
+ }
483
+ async function scheduleKnowledgeHealthChecks(options) {
484
+ const logger = options.logger ?? createConsoleLogger();
485
+ const enabled = options.config?.enabled !== false;
486
+ const intervalMs = options.config?.intervalMs ?? DEFAULT_KNOWLEDGE_HEALTH_INTERVAL_MS;
487
+ const autoClean = options.config?.autoCleanOrphanedEmbeddings === true;
488
+ const buildLock = options.buildLock ?? createTursoBuildLock(options.db);
489
+ const instanceId = options.instanceId ?? crypto.randomUUID();
490
+ const schedule = options.schedule ?? defaultSchedule;
491
+ async function runOnce() {
492
+ const health = await checkKnowledgeHealth(options.db, logger);
493
+ if (autoClean && health.orphanedEmbeddings > 0) {
494
+ await cleanOrphanedEmbeddings(options.db, buildLock, instanceId, logger);
495
+ }
496
+ return health;
497
+ }
498
+ let handle;
499
+ if (enabled) {
500
+ await runOnce();
501
+ if (intervalMs > 0) {
502
+ let ticking = false;
503
+ handle = schedule(() => {
504
+ if (ticking) return;
505
+ ticking = true;
506
+ runOnce().catch((error) => logger.error("Knowledge health check failed:", error)).finally(() => {
507
+ ticking = false;
508
+ });
509
+ }, intervalMs);
510
+ }
511
+ }
512
+ return {
513
+ runOnce,
514
+ stop: () => handle?.stop()
515
+ };
516
+ }
517
+ var DEFAULT_KNOWLEDGE_HEALTH_INTERVAL_MS, defaultSchedule;
518
+ var init_knowledgeHealth = __esm({
519
+ "src/core/knowledgeHealth.ts"() {
520
+ "use strict";
521
+ init_buildLock();
522
+ init_logger();
523
+ DEFAULT_KNOWLEDGE_HEALTH_INTERVAL_MS = 30 * 6e4;
524
+ defaultSchedule = (fn, ms) => {
525
+ const timer = setInterval(fn, ms);
526
+ if (typeof timer === "object" && "unref" in timer) timer.unref();
527
+ return { stop: () => clearInterval(timer) };
528
+ };
529
+ }
530
+ });
531
+
419
532
  // src/index.ts
420
533
  var index_exports = {};
421
534
  __export(index_exports, {
@@ -1221,6 +1334,7 @@ async function createMCPServer(config) {
1221
1334
  });
1222
1335
  const model = config.openai.model || DEFAULT_MODEL;
1223
1336
  let store;
1337
+ let db;
1224
1338
  if (config.retriever) {
1225
1339
  store = config.retriever;
1226
1340
  } else {
@@ -1228,7 +1342,7 @@ async function createMCPServer(config) {
1228
1342
  throw new Error(DATABASE_CONFIG_REQUIRED_MESSAGE);
1229
1343
  }
1230
1344
  const { DEFAULT_KNOWLEDGE_DIR: DEFAULT_KNOWLEDGE_DIR2, VectorStore: VectorStore2, createOpenAIEmbedder: createOpenAIEmbedder2, openLibsqlClient: openLibsqlClient2 } = await Promise.resolve().then(() => (init_retrieval(), retrieval_exports));
1231
- const db = await openLibsqlClient2(config.database);
1345
+ db = await openLibsqlClient2(config.database);
1232
1346
  store = new VectorStore2(createOpenAIEmbedder2(client), {
1233
1347
  databaseClient: db,
1234
1348
  knowledgeDir: config.knowledgeDir || DEFAULT_KNOWLEDGE_DIR2,
@@ -1237,6 +1351,15 @@ async function createMCPServer(config) {
1237
1351
  }
1238
1352
  await store.build?.();
1239
1353
  log.info("\u2705 Knowledge base ready");
1354
+ let knowledgeHealth;
1355
+ if (db) {
1356
+ const { scheduleKnowledgeHealthChecks: scheduleKnowledgeHealthChecks2 } = await Promise.resolve().then(() => (init_knowledgeHealth(), knowledgeHealth_exports));
1357
+ knowledgeHealth = await scheduleKnowledgeHealthChecks2({
1358
+ db,
1359
+ config: config.knowledgeHealthCheck,
1360
+ logger: log
1361
+ });
1362
+ }
1240
1363
  const prompts = new PromptLoader(promptsDir, config.bot);
1241
1364
  const server = new McpServer({
1242
1365
  name: config.bot.name,
@@ -1433,7 +1556,9 @@ async function createMCPServer(config) {
1433
1556
  log.info(
1434
1557
  `\u2705 ${config.bot.name} MCP Server ready (transport: ${transportMode}, tools: ${enabledTools.join(", ") || "none"})`
1435
1558
  );
1436
- return server;
1559
+ return Object.assign(server, {
1560
+ stopKnowledgeHealthChecks: () => knowledgeHealth?.stop()
1561
+ });
1437
1562
  }
1438
1563
 
1439
1564
  // src/middleware/apiKey.ts
@@ -2416,6 +2541,15 @@ async function createServer(config) {
2416
2541
  }
2417
2542
  await store.build?.();
2418
2543
  logger.info("\u2705 Knowledge base ready");
2544
+ let knowledgeHealth;
2545
+ if (db && !config.retriever) {
2546
+ const { scheduleKnowledgeHealthChecks: scheduleKnowledgeHealthChecks2 } = await Promise.resolve().then(() => (init_knowledgeHealth(), knowledgeHealth_exports));
2547
+ knowledgeHealth = await scheduleKnowledgeHealthChecks2({
2548
+ db,
2549
+ config: config.knowledgeHealthCheck,
2550
+ logger
2551
+ });
2552
+ }
2419
2553
  const prompts = new PromptLoader(promptsDir, config.bot);
2420
2554
  let apiKeyManager;
2421
2555
  const secret = config.auth?.secret || process.env.CHATTER_SECRET;
@@ -2546,6 +2680,7 @@ async function createServer(config) {
2546
2680
  // (sync or async) is caught per-channel so one misbehaving channel can't
2547
2681
  // stop the rest from cleaning up.
2548
2682
  stopChannels: async () => {
2683
+ knowledgeHealth?.stop();
2549
2684
  await Promise.allSettled(
2550
2685
  started.map(async (channel) => {
2551
2686
  try {