@equationalapplications/core-llm-wiki 4.0.0 → 4.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/README.md CHANGED
@@ -302,6 +302,41 @@ wikiMemory.clearVectorCache();
302
302
 
303
303
  The cache is also automatically invalidated on any mutation (`runLibrarian`, `runHeal`, `runPrune`, `runReembed`, `ingestDocument`, `importDump`, `forget`).
304
304
 
305
+ ## Entity Status
306
+
307
+ `WikiMemory` exposes the in-flight job state for a single entity through two complementary APIs.
308
+
309
+ ### `getEntityStatus(entityId)`
310
+
311
+ Synchronous point-in-time snapshot:
312
+
313
+ ```typescript
314
+ const status = wikiMemory.getEntityStatus('user-42');
315
+ // { ingesting: boolean, librarian: boolean, heal: boolean }
316
+ ```
317
+
318
+ Use this when you only need the current value (e.g. inside a request handler).
319
+
320
+ ### `subscribeEntityStatus(entityId, callback)`
321
+
322
+ Push-based change notification — the callback fires synchronously once with the current status, then again on every transition where any of the three booleans flips. There is no polling and no duplicate snapshots.
323
+
324
+ ```typescript
325
+ const unsubscribe = wikiMemory.subscribeEntityStatus('user-42', (status) => {
326
+ console.log(status); // { ingesting, librarian, heal }
327
+ });
328
+
329
+ // Later:
330
+ unsubscribe(); // idempotent — safe to call more than once
331
+ ```
332
+
333
+ Notes:
334
+
335
+ - The first invocation happens **before** `subscribeEntityStatus` returns. Treat it as the initial render value.
336
+ - Each emission may be a fresh object literal. Do not rely on referential equality between callbacks; equality of the three booleans is the contract.
337
+ - A throwing callback is caught (logged via `console.error`) and does not block other subscribers or the underlying job.
338
+ - Subscriptions are scoped to a single `entityId`. There is no wildcard or "all entities" form.
339
+
305
340
  ## Security
306
341
 
307
342
  `@equationalapplications/core-llm-wiki` enforces multiple security layers:
package/dist/index.d.mts CHANGED
@@ -337,6 +337,7 @@ declare class WikiMemory {
337
337
  private options;
338
338
  private activeMaintenanceJobs;
339
339
  private activeIngestJobs;
340
+ private statusSubscribers;
340
341
  private miniSearch;
341
342
  private miniSearchEntryIdsByEntity;
342
343
  /**
@@ -392,6 +393,8 @@ declare class WikiMemory {
392
393
  private _isAnyMaintenanceActiveWithSuffix;
393
394
  /** Returns true if any ingest job is active for the given entity. */
394
395
  private _isIngestActiveFor;
396
+ private _copyEntityStatus;
397
+ private _notifyStatusSubscribers;
395
398
  private _validatePruneDuration;
396
399
  runPrune(entityId: string, options?: {
397
400
  retainSoftDeletedFor?: number | null;
@@ -445,6 +448,17 @@ declare class WikiMemory {
445
448
  failed: number;
446
449
  }>;
447
450
  getEntityStatus(entityId: string): EntityStatus;
451
+ /**
452
+ * Subscribe to {@link EntityStatus} changes for a single entity. The callback
453
+ * is invoked synchronously once with the current status before this method
454
+ * returns, then again on every transition where any of `ingesting`,
455
+ * `librarian`, or `heal` flips. No polling, no duplicate snapshots.
456
+ *
457
+ * Returns an idempotent unsubscribe function.
458
+ *
459
+ * See also {@link getEntityStatus} for a synchronous point-in-time read.
460
+ */
461
+ subscribeEntityStatus(entityId: string, callback: (status: EntityStatus) => void): () => void;
448
462
  clearVectorCache(): void;
449
463
  private _getFullBundle;
450
464
  exportDump(entityIds?: string[]): Promise<MemoryDump>;
package/dist/index.d.ts CHANGED
@@ -337,6 +337,7 @@ declare class WikiMemory {
337
337
  private options;
338
338
  private activeMaintenanceJobs;
339
339
  private activeIngestJobs;
340
+ private statusSubscribers;
340
341
  private miniSearch;
341
342
  private miniSearchEntryIdsByEntity;
342
343
  /**
@@ -392,6 +393,8 @@ declare class WikiMemory {
392
393
  private _isAnyMaintenanceActiveWithSuffix;
393
394
  /** Returns true if any ingest job is active for the given entity. */
394
395
  private _isIngestActiveFor;
396
+ private _copyEntityStatus;
397
+ private _notifyStatusSubscribers;
395
398
  private _validatePruneDuration;
396
399
  runPrune(entityId: string, options?: {
397
400
  retainSoftDeletedFor?: number | null;
@@ -445,6 +448,17 @@ declare class WikiMemory {
445
448
  failed: number;
446
449
  }>;
447
450
  getEntityStatus(entityId: string): EntityStatus;
451
+ /**
452
+ * Subscribe to {@link EntityStatus} changes for a single entity. The callback
453
+ * is invoked synchronously once with the current status before this method
454
+ * returns, then again on every transition where any of `ingesting`,
455
+ * `librarian`, or `heal` flips. No polling, no duplicate snapshots.
456
+ *
457
+ * Returns an idempotent unsubscribe function.
458
+ *
459
+ * See also {@link getEntityStatus} for a synchronous point-in-time read.
460
+ */
461
+ subscribeEntityStatus(entityId: string, callback: (status: EntityStatus) => void): () => void;
448
462
  clearVectorCache(): void;
449
463
  private _getFullBundle;
450
464
  exportDump(entityIds?: string[]): Promise<MemoryDump>;
package/dist/index.js CHANGED
@@ -418,6 +418,7 @@ var _WikiMemory = class _WikiMemory {
418
418
  constructor(db, options) {
419
419
  this.activeMaintenanceJobs = /* @__PURE__ */ new Set();
420
420
  this.activeIngestJobs = /* @__PURE__ */ new Set();
421
+ this.statusSubscribers = /* @__PURE__ */ new Map();
421
422
  this.miniSearch = new MiniSearch__default.default({
422
423
  fields: ["title", "body", "tags"],
423
424
  storeFields: ["entity_id"],
@@ -822,6 +823,24 @@ After running the migration SQL, restart your application.`
822
823
  }
823
824
  return false;
824
825
  }
826
+ _copyEntityStatus(s) {
827
+ return { ingesting: s.ingesting, librarian: s.librarian, heal: s.heal };
828
+ }
829
+ _notifyStatusSubscribers(entityId) {
830
+ const set = this.statusSubscribers.get(entityId);
831
+ if (!set || set.size === 0) return;
832
+ for (const entry of Array.from(set)) {
833
+ if (!set.has(entry)) continue;
834
+ const next = this.getEntityStatus(entityId);
835
+ if (entry.last.ingesting === next.ingesting && entry.last.librarian === next.librarian && entry.last.heal === next.heal) continue;
836
+ entry.last = this._copyEntityStatus(next);
837
+ try {
838
+ entry.callback(this._copyEntityStatus(next));
839
+ } catch (err) {
840
+ console.error(`[WikiMemory.subscribeEntityStatus] callback error for entityId="${entityId}" during transition emission`, err);
841
+ }
842
+ }
843
+ }
825
844
  _validatePruneDuration(value, name) {
826
845
  if (value !== null && value !== void 0 && (typeof value !== "number" || !isFinite(value) || value < 0)) {
827
846
  throw new Error(`Invalid ${name}: must be a non-negative finite number or null`);
@@ -1564,7 +1583,11 @@ After running the migration SQL, restart your application.`
1564
1583
  const jobKey = this._librarianKey(entityId);
1565
1584
  if (!this.activeMaintenanceJobs.has(jobKey) && !this.activeMaintenanceJobs.has(this._pruneKey(entityId)) && !this._isReembedActive(entityId) && !this._isImportActiveFor(entityId) && !this._isForgetActiveFor(entityId)) {
1566
1585
  this.activeMaintenanceJobs.add(jobKey);
1567
- this.runLibrarianThenMaybeHeal(entityId, count).catch(console.error).finally(() => this.activeMaintenanceJobs.delete(jobKey));
1586
+ this._notifyStatusSubscribers(entityId);
1587
+ this.runLibrarianThenMaybeHeal(entityId, count).catch(console.error).finally(() => {
1588
+ this.activeMaintenanceJobs.delete(jobKey);
1589
+ this._notifyStatusSubscribers(entityId);
1590
+ });
1568
1591
  }
1569
1592
  }
1570
1593
  }
@@ -1583,6 +1606,7 @@ After running the migration SQL, restart your application.`
1583
1606
  const healKey = this._healKey(entityId);
1584
1607
  if (!this.activeMaintenanceJobs.has(healKey)) {
1585
1608
  this.activeMaintenanceJobs.add(healKey);
1609
+ this._notifyStatusSubscribers(entityId);
1586
1610
  try {
1587
1611
  await this._doRunHeal(entityId);
1588
1612
  await this.db.runAsync(`
@@ -1592,6 +1616,7 @@ After running the migration SQL, restart your application.`
1592
1616
  `, [entityId, currentEventCount, currentEventCount]);
1593
1617
  } finally {
1594
1618
  this.activeMaintenanceJobs.delete(healKey);
1619
+ this._notifyStatusSubscribers(entityId);
1595
1620
  }
1596
1621
  }
1597
1622
  }
@@ -1783,10 +1808,12 @@ The following document anchors are provided for contradiction detection only. Do
1783
1808
  throw new WikiBusyError("forget", entityId);
1784
1809
  }
1785
1810
  this.activeMaintenanceJobs.add(jobKey);
1811
+ this._notifyStatusSubscribers(entityId);
1786
1812
  try {
1787
1813
  await this._doRunLibrarian(entityId);
1788
1814
  } finally {
1789
1815
  this.activeMaintenanceJobs.delete(jobKey);
1816
+ this._notifyStatusSubscribers(entityId);
1790
1817
  }
1791
1818
  }
1792
1819
  async runHeal(entityId) {
@@ -1807,10 +1834,12 @@ The following document anchors are provided for contradiction detection only. Do
1807
1834
  throw new WikiBusyError("forget", entityId);
1808
1835
  }
1809
1836
  this.activeMaintenanceJobs.add(jobKey);
1837
+ this._notifyStatusSubscribers(entityId);
1810
1838
  try {
1811
1839
  await this._doRunHeal(entityId);
1812
1840
  } finally {
1813
1841
  this.activeMaintenanceJobs.delete(jobKey);
1842
+ this._notifyStatusSubscribers(entityId);
1814
1843
  }
1815
1844
  }
1816
1845
  async runReembed(entityId, opts) {
@@ -1950,6 +1979,40 @@ The following document anchors are provided for contradiction detection only. Do
1950
1979
  heal: this.activeMaintenanceJobs.has(this._healKey(entityId))
1951
1980
  };
1952
1981
  }
1982
+ /**
1983
+ * Subscribe to {@link EntityStatus} changes for a single entity. The callback
1984
+ * is invoked synchronously once with the current status before this method
1985
+ * returns, then again on every transition where any of `ingesting`,
1986
+ * `librarian`, or `heal` flips. No polling, no duplicate snapshots.
1987
+ *
1988
+ * Returns an idempotent unsubscribe function.
1989
+ *
1990
+ * See also {@link getEntityStatus} for a synchronous point-in-time read.
1991
+ */
1992
+ subscribeEntityStatus(entityId, callback) {
1993
+ const initial = this.getEntityStatus(entityId);
1994
+ let set = this.statusSubscribers.get(entityId);
1995
+ if (!set) {
1996
+ set = /* @__PURE__ */ new Set();
1997
+ this.statusSubscribers.set(entityId, set);
1998
+ }
1999
+ const entry = { callback, last: this._copyEntityStatus(initial) };
2000
+ set.add(entry);
2001
+ try {
2002
+ callback(this._copyEntityStatus(initial));
2003
+ } catch (err) {
2004
+ console.error(`[WikiMemory.subscribeEntityStatus] callback error for entityId="${entityId}" during initial emission`, err);
2005
+ }
2006
+ let active = true;
2007
+ return () => {
2008
+ if (!active) return;
2009
+ active = false;
2010
+ const s = this.statusSubscribers.get(entityId);
2011
+ if (!s) return;
2012
+ s.delete(entry);
2013
+ if (s.size === 0) this.statusSubscribers.delete(entityId);
2014
+ };
2015
+ }
1953
2016
  clearVectorCache() {
1954
2017
  this.vectorCache.clear();
1955
2018
  }
@@ -2475,6 +2538,7 @@ The following document anchors are provided for contradiction detection only. Do
2475
2538
  throw new WikiBusyError("forget", entityId);
2476
2539
  }
2477
2540
  this.activeIngestJobs.add(jobKey);
2541
+ this._notifyStatusSubscribers(entityId);
2478
2542
  try {
2479
2543
  const { chunks, truncated } = chunkText(params.documentChunk, maxChunkLength, chunkOverlap);
2480
2544
  if (chunks.length === 0) {
@@ -2546,6 +2610,7 @@ ${chunk}`;
2546
2610
  return { truncated, chunks: chunks.length };
2547
2611
  } finally {
2548
2612
  this.activeIngestJobs.delete(jobKey);
2613
+ this._notifyStatusSubscribers(entityId);
2549
2614
  }
2550
2615
  }
2551
2616
  };