@equationalapplications/core-llm-wiki 4.20.0 → 4.21.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
@@ -541,6 +541,40 @@ In **Strict** and **Emergent** modes, librarian and ingest JSON may include type
541
541
 
542
542
  See the design spec: [`docs/superpowers/specs/2026-06-23-per-entity-seeded-ontology-design.md`](https://github.com/equationalapplications/expo-llm-wiki/blob/main/docs/superpowers/specs/2026-06-23-per-entity-seeded-ontology-design.md).
543
543
 
544
+ ### Ontology backfill
545
+
546
+ Facts that enter the store without passing through the librarian (synced-down
547
+ remote facts via `importDump`, or facts created before the ontology feature)
548
+ have `okf_type = NULL` and no edges, so `traverseGraph` cannot reach them.
549
+ `runOntologyBackfill` types them in place:
550
+
551
+ ```ts
552
+ const result = await wiki.runOntologyBackfill(entityId);
553
+ // { scanned, typed, failedValidation, edgesAdded, remaining, deferred }
554
+ ```
555
+
556
+ - **When to call it:** you own the trigger — the library provides the operation,
557
+ not a scheduler (same as `runLibrarian`/`runHeal`). A good default is after
558
+ each sync/import completes. `WikiBusyError` under concurrency is safe to
559
+ swallow; the next trigger retries.
560
+ - **Cost model:** one LLM call only when eligible untyped facts exist; otherwise
561
+ one SELECT. Each run scans at most 25 facts (override via
562
+ `options.batchSize`), oldest first, with the full serialized prompt
563
+ (system + user) capped at 40k chars. One exception: a single fact whose
564
+ prompt alone exceeds the cap is still sent by itself so it is never starved.
565
+ - **Convergence:** loop `while (result.remaining > 0)` to drain a backlog.
566
+ `remaining` counts only *eligible* untyped facts, so the loop terminates even
567
+ when unclassifiable facts exist; those are cooldown-stamped and retried after
568
+ 7 days (`deferred` reports them). Because of that weekly retry, host
569
+ dashboards may see small recurring bumps in `scanned`/`failedValidation` as
570
+ the unclassifiable backlog is re-tested — expected, not a regression.
571
+ - **Strictly additive:** never creates, deletes, or rewrites facts, and never
572
+ overwrites an existing `okf_type` (guarded in SQL, not just convention).
573
+ `updated_at` is never touched, so sync merge resolution is unaffected.
574
+ - **Prompt customization:** per-call `options.promptOverride`, or global
575
+ `config.prompts.ontologyBackfillSystemPrompt` (template may use `{{facts}}`
576
+ and the ontology placeholders).
577
+
544
578
  ## OKF Import/Export
545
579
 
546
580
  The core package integrates with `@equationalapplications/core-okf` to seamlessly adapt wiki data dumps to and from Open Knowledge Format (OKF) v0.1 bundles.
@@ -374,7 +374,8 @@ var JobManager = class {
374
374
  heal: (id) => this._healKey(id),
375
375
  reembed: (id) => this._reembedKey(id),
376
376
  import: (id) => this._importKey(id),
377
- forget: (id) => this._forgetKey(id)
377
+ forget: (id) => this._forgetKey(id),
378
+ ontologyBackfill: (id) => this._ontologyBackfillKey(id)
378
379
  };
379
380
  }
380
381
  _pruneKey(entityId) {
@@ -401,6 +402,9 @@ var JobManager = class {
401
402
  _healKey(entityId) {
402
403
  return `${this.prefix}:${entityId}:heal`;
403
404
  }
405
+ _ontologyBackfillKey(entityId) {
406
+ return `${this.prefix}:${entityId}:ontologyBackfill`;
407
+ }
404
408
  _isReembedActive(entityId) {
405
409
  return this.activeMaintenanceJobs.has(this._reembedKey(entityId)) || this.activeMaintenanceJobs.has(this._globalReembedKey());
406
410
  }
@@ -451,14 +455,16 @@ var JobManager = class {
451
455
  if (this.activeMaintenanceJobs.has(this._pruneKey(entityId))) blockingOperation = "prune";
452
456
  else if (this.activeMaintenanceJobs.has(this._librarianKey(entityId))) blockingOperation = "librarian";
453
457
  else if (this.activeMaintenanceJobs.has(this._healKey(entityId))) blockingOperation = "heal";
458
+ else if (this.activeMaintenanceJobs.has(this._ontologyBackfillKey(entityId))) blockingOperation = "ontologyBackfill";
454
459
  else if (this._isReembedActive(entityId)) blockingOperation = "reembed";
455
460
  else if (this._isIngestActiveFor(entityId)) blockingOperation = "ingest";
456
461
  else if (this._isImportActiveFor(entityId)) blockingOperation = "import";
457
462
  else if (this._isForgetActiveFor(entityId)) blockingOperation = "forget";
458
463
  break;
459
464
  case "librarian":
460
- case "heal": {
461
- const opKey = operation === "librarian" ? this._librarianKey(entityId) : this._healKey(entityId);
465
+ case "heal":
466
+ case "ontologyBackfill": {
467
+ const opKey = this.lockKeyFns[operation](entityId);
462
468
  if (this.activeMaintenanceJobs.has(opKey)) blockingOperation = operation;
463
469
  else if (this.activeMaintenanceJobs.has(this._pruneKey(entityId))) blockingOperation = "prune";
464
470
  else if (this._isReembedActive(entityId)) blockingOperation = "reembed";
@@ -472,6 +478,7 @@ var JobManager = class {
472
478
  else if (this.activeMaintenanceJobs.has(this._pruneKey(entityId))) blockingOperation = "prune";
473
479
  else if (this.activeMaintenanceJobs.has(this._librarianKey(entityId))) blockingOperation = "librarian";
474
480
  else if (this.activeMaintenanceJobs.has(this._healKey(entityId))) blockingOperation = "heal";
481
+ else if (this.activeMaintenanceJobs.has(this._ontologyBackfillKey(entityId))) blockingOperation = "ontologyBackfill";
475
482
  else if (this._isIngestActiveFor(entityId)) blockingOperation = "ingest";
476
483
  else if (this._isImportActiveFor(entityId)) blockingOperation = "import";
477
484
  else if (this._isForgetActiveFor(entityId)) blockingOperation = "forget";
@@ -482,6 +489,7 @@ var JobManager = class {
482
489
  else if (this._isAnyMaintenanceActiveWithSuffix(":prune")) blockingOperation = "prune";
483
490
  else if (this._isAnyMaintenanceActiveWithSuffix(":librarian")) blockingOperation = "librarian";
484
491
  else if (this._isAnyMaintenanceActiveWithSuffix(":heal")) blockingOperation = "heal";
492
+ else if (this._isAnyMaintenanceActiveWithSuffix(":ontologyBackfill")) blockingOperation = "ontologyBackfill";
485
493
  else if (this.activeIngestJobs.size > 0) blockingOperation = "ingest";
486
494
  else if (this._isAnyMaintenanceActiveWithSuffix(":import")) blockingOperation = "import";
487
495
  else if (this._isAnyMaintenanceActiveWithSuffix(":forget")) blockingOperation = "forget";
@@ -492,6 +500,7 @@ var JobManager = class {
492
500
  if (this.activeMaintenanceJobs.has(selfKey)) blockingOperation = operation;
493
501
  else if (this.activeMaintenanceJobs.has(this._librarianKey(entityId))) blockingOperation = "librarian";
494
502
  else if (this.activeMaintenanceJobs.has(this._healKey(entityId))) blockingOperation = "heal";
503
+ else if (this.activeMaintenanceJobs.has(this._ontologyBackfillKey(entityId))) blockingOperation = "ontologyBackfill";
495
504
  else if (this.activeMaintenanceJobs.has(this._pruneKey(entityId))) blockingOperation = "prune";
496
505
  else if (this._isReembedActive(entityId)) blockingOperation = "reembed";
497
506
  else if (this._isIngestActiveFor(entityId)) blockingOperation = "ingest";
@@ -549,11 +558,11 @@ var JobManager = class {
549
558
  if (operation !== "global_import" && this.activeMaintenanceJobs.has(this._globalImportKey())) return true;
550
559
  switch (operation) {
551
560
  case "librarian":
552
- return this.activeMaintenanceJobs.has(this._librarianKey(entityId)) || this.activeMaintenanceJobs.has(this._pruneKey(entityId)) || this._isReembedActive(entityId) || this._isImportActiveFor(entityId) || this._isForgetActiveFor(entityId);
553
561
  case "heal":
554
- return this.activeMaintenanceJobs.has(this._healKey(entityId)) || this.activeMaintenanceJobs.has(this._pruneKey(entityId)) || this._isReembedActive(entityId) || this._isImportActiveFor(entityId) || this._isForgetActiveFor(entityId);
562
+ case "ontologyBackfill":
563
+ return this.activeMaintenanceJobs.has(this.lockKeyFns[operation](entityId)) || this.activeMaintenanceJobs.has(this._pruneKey(entityId)) || this._isReembedActive(entityId) || this._isImportActiveFor(entityId) || this._isForgetActiveFor(entityId);
555
564
  case "prune":
556
- return this.activeMaintenanceJobs.has(this._pruneKey(entityId)) || this.activeMaintenanceJobs.has(this._librarianKey(entityId)) || this.activeMaintenanceJobs.has(this._healKey(entityId)) || this._isReembedActive(entityId) || this._isIngestActiveFor(entityId) || this._isImportActiveFor(entityId) || this._isForgetActiveFor(entityId);
565
+ return this.activeMaintenanceJobs.has(this._pruneKey(entityId)) || this.activeMaintenanceJobs.has(this._librarianKey(entityId)) || this.activeMaintenanceJobs.has(this._healKey(entityId)) || this.activeMaintenanceJobs.has(this._ontologyBackfillKey(entityId)) || this._isReembedActive(entityId) || this._isIngestActiveFor(entityId) || this._isImportActiveFor(entityId) || this._isForgetActiveFor(entityId);
557
566
  default:
558
567
  return false;
559
568
  }
@@ -578,6 +587,7 @@ var JobManager = class {
578
587
  if (this.activeMaintenanceJobs.has(this._importKey(entityId))) throw new WikiBusyError("import", entityId);
579
588
  if (this.activeMaintenanceJobs.has(this._librarianKey(entityId))) throw new WikiBusyError("librarian", entityId);
580
589
  if (this.activeMaintenanceJobs.has(this._healKey(entityId))) throw new WikiBusyError("heal", entityId);
590
+ if (this.activeMaintenanceJobs.has(this._ontologyBackfillKey(entityId))) throw new WikiBusyError("ontologyBackfill", entityId);
581
591
  if (this.activeMaintenanceJobs.has(this._pruneKey(entityId))) throw new WikiBusyError("prune", entityId);
582
592
  if (this._isReembedActive(entityId)) throw new WikiBusyError("reembed", entityId);
583
593
  if (this._isIngestActiveFor(entityId)) throw new WikiBusyError("ingest", entityId);
@@ -671,6 +681,15 @@ Return ONLY a valid JSON object matching this schema:
671
681
  "facts": [{ "title": "string (max 80 chars)", "body": "string (max 800 chars)", "tags": ["string"], "confidence": "certain|inferred|tentative" }]
672
682
  }
673
683
  Extract verbatim factual content. Do not return markdown, just raw JSON.`;
684
+ var ONTOLOGY_BACKFILL_SYSTEM_PROMPT = `You are a knowledge classification agent. You will receive existing memory facts that currently have no ontology type. For each input fact { "id", "title", "body", "tags" }, assign the best matching okf_type from the ontology manifest and optionally propose edges to related facts by title.
685
+ Return ONLY a valid JSON object matching this schema:
686
+ {
687
+ "classifications": [
688
+ { "id": "string (input fact id, copied verbatim)", "okf_type": "string (manifest node type slug)", "edges": [{ "edge_type": "string", "target_title": "string" }] }
689
+ ]
690
+ }
691
+ If no manifest type fits a fact, omit that fact from "classifications" entirely \u2014 do not guess.
692
+ Do not return markdown, just raw JSON.`;
674
693
 
675
694
  // src/services/PromptService.ts
676
695
  var PromptService = class {
@@ -760,6 +779,22 @@ ${JSON.stringify(recentEvents, null, 2)}
760
779
  The following document anchors are provided for contradiction detection only. Do not include them in \`downgraded\`, \`deleted\`, or \`newFacts\`.`
761
780
  };
762
781
  }
782
+ buildOntologyBackfillPrompt(facts, runtimeOverride, ontologyContext) {
783
+ const template = runtimeOverride ?? this.globalOverrides?.ontologyBackfillSystemPrompt ?? ONTOLOGY_BACKFILL_SYSTEM_PROMPT;
784
+ const hasFacts = /\{\{\s*facts\s*\}\}/.test(template);
785
+ if (hasFacts || this.hasOntologyPlaceholders(template)) {
786
+ return {
787
+ systemPrompt: this.buildSystemPrompt(template, { facts }, ontologyContext),
788
+ userPrompt: hasFacts ? "Please classify the facts." : `Facts:
789
+ ${JSON.stringify(facts, null, 2)}`
790
+ };
791
+ }
792
+ return {
793
+ systemPrompt: this.appendOntology(template, ontologyContext),
794
+ userPrompt: `Facts:
795
+ ${JSON.stringify(facts, null, 2)}`
796
+ };
797
+ }
763
798
  };
764
799
 
765
800
  // src/utils/pure.ts
@@ -1376,6 +1411,9 @@ var MetadataRepository = class extends BaseRepository {
1376
1411
  // src/services/MaintenanceService.ts
1377
1412
  var FUZZY_THRESHOLD = 0.5;
1378
1413
  var MIN_TOKENS_TO_QUALIFY = 3;
1414
+ var ONTOLOGY_BACKFILL_BATCH_SIZE = 25;
1415
+ var ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS = 4e4;
1416
+ var ONTOLOGY_BACKFILL_RECHECK_MS = 7 * 24 * 60 * 60 * 1e3;
1379
1417
  var MaintenanceService = class {
1380
1418
  constructor(db, prefix, options, entryRepo, taskRepo, eventRepo, metadataRepo, searchService, jobManager, embeddingService, promptService, ontologyService) {
1381
1419
  this.db = db;
@@ -1481,6 +1519,14 @@ var MaintenanceService = class {
1481
1519
  this.jobManager.releaseLock("heal", entityId);
1482
1520
  }
1483
1521
  }
1522
+ async runOntologyBackfill(entityId, options) {
1523
+ this.jobManager.acquireLock("ontologyBackfill", entityId);
1524
+ try {
1525
+ return await this.doRunOntologyBackfill(entityId, options);
1526
+ } finally {
1527
+ this.jobManager.releaseLock("ontologyBackfill", entityId);
1528
+ }
1529
+ }
1484
1530
  async runReembed(entityId, opts) {
1485
1531
  const embedFn = this.options.llmProvider.embed;
1486
1532
  if (!embedFn) return { embedded: 0, skipped: 0, failed: 0 };
@@ -1832,6 +1878,121 @@ var MaintenanceService = class {
1832
1878
  }
1833
1879
  this.searchService.evictCache(entityId);
1834
1880
  }
1881
+ /** Core ontology backfill pass (locks handled by {@link runOntologyBackfill}). Package-internal orchestration hook. */
1882
+ async doRunOntologyBackfill(entityId, options) {
1883
+ const batchSize = options?.batchSize ?? ONTOLOGY_BACKFILL_BATCH_SIZE;
1884
+ if (!Number.isInteger(batchSize) || batchSize < 1) {
1885
+ throw new Error("Invalid batchSize: must be an integer >= 1");
1886
+ }
1887
+ const now = Date.now();
1888
+ const recheckCutoff = now - ONTOLOGY_BACKFILL_RECHECK_MS;
1889
+ const zeroed = { scanned: 0, typed: 0, failedValidation: 0, edgesAdded: 0 };
1890
+ const ontologyService = this.ontologyService;
1891
+ if (!ontologyService) {
1892
+ return { ...zeroed, remaining: 0, deferred: 0 };
1893
+ }
1894
+ const { mode } = await ontologyService.getEffectiveState(entityId);
1895
+ if (mode === "off") {
1896
+ const counts2 = await this.entryRepo.countUntypedByEntityId(entityId, recheckCutoff);
1897
+ return { ...zeroed, remaining: 0, deferred: counts2.deferred };
1898
+ }
1899
+ const candidates = await this.entryRepo.findUntypedByEntityId(entityId, batchSize, recheckCutoff);
1900
+ if (candidates.length === 0) {
1901
+ const counts2 = await this.entryRepo.countUntypedByEntityId(entityId, recheckCutoff);
1902
+ return { ...zeroed, remaining: counts2.eligible, deferred: counts2.deferred };
1903
+ }
1904
+ const ontologyContext = await ontologyService.buildPromptContext(entityId);
1905
+ const toPromptShape = (f) => ({ id: f.id, title: f.title, body: f.body, tags: f.tags });
1906
+ const buildPrompt = (facts) => this.promptService.buildOntologyBackfillPrompt(
1907
+ facts.map(toPromptShape),
1908
+ options?.promptOverride,
1909
+ ontologyContext
1910
+ );
1911
+ const batch = [candidates[0]];
1912
+ let built = buildPrompt(batch);
1913
+ for (let i = 1; i < candidates.length; i++) {
1914
+ const next = buildPrompt([...batch, candidates[i]]);
1915
+ if (next.systemPrompt.length + next.userPrompt.length > ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS) break;
1916
+ batch.push(candidates[i]);
1917
+ built = next;
1918
+ }
1919
+ const { systemPrompt, userPrompt } = built;
1920
+ const responseText = await this.options.llmProvider.generateText({ systemPrompt, userPrompt });
1921
+ const parsed = parseJsonResponse(responseText);
1922
+ const classifications = Array.isArray(parsed.classifications) ? parsed.classifications : [];
1923
+ let typed = 0;
1924
+ let failedValidation = 0;
1925
+ let edgesAdded = 0;
1926
+ let abortedOntologyOff = false;
1927
+ await this.db.withTransactionAsync(async (tx) => {
1928
+ let { mode: txMode, manifest } = await ontologyService.getEffectiveState(entityId, tx);
1929
+ if (txMode === "off") {
1930
+ abortedOntologyOff = true;
1931
+ return;
1932
+ }
1933
+ if (txMode === "emergent" && parsed.ontology_updates) {
1934
+ manifest = await ontologyService.mergeEmergentUpdates(entityId, parsed.ontology_updates, tx);
1935
+ }
1936
+ const titleRows = await this.entryRepo.findTitleIndexByEntityId(entityId, tx);
1937
+ const titleIndex = /* @__PURE__ */ new Map();
1938
+ for (const row of titleRows) {
1939
+ titleIndex.set(normalizeTitleKey(row.title), { id: row.id, okf_type: row.okf_type });
1940
+ }
1941
+ const batchById = new Map(batch.map((f) => [f.id, f]));
1942
+ const applied = /* @__PURE__ */ new Set();
1943
+ const pendingEdges = [];
1944
+ for (const classification of classifications) {
1945
+ const fact = typeof classification.id === "string" ? batchById.get(classification.id) : void 0;
1946
+ if (!fact || applied.has(fact.id)) {
1947
+ failedValidation++;
1948
+ continue;
1949
+ }
1950
+ applied.add(fact.id);
1951
+ const normalized = ontologyService.validateAndNormalizeFact(
1952
+ {
1953
+ title: fact.title,
1954
+ body: fact.body,
1955
+ tags: fact.tags,
1956
+ confidence: fact.confidence,
1957
+ okf_type: classification.okf_type,
1958
+ edges: classification.edges
1959
+ },
1960
+ manifest
1961
+ );
1962
+ if (!normalized.okf_type) {
1963
+ failedValidation++;
1964
+ continue;
1965
+ }
1966
+ const result = await this.entryRepo.updateOkfType(fact.id, entityId, normalized.okf_type, tx);
1967
+ if (result.changes === 0) continue;
1968
+ typed++;
1969
+ titleIndex.set(normalizeTitleKey(fact.title), { id: fact.id, okf_type: normalized.okf_type });
1970
+ if (normalized.edges.length > 0) {
1971
+ pendingEdges.push({ sourceId: fact.id, sourceType: normalized.okf_type, edges: normalized.edges });
1972
+ }
1973
+ }
1974
+ for (const item of pendingEdges) {
1975
+ edgesAdded += await ontologyService.resolveAndPersistEdges(
1976
+ entityId,
1977
+ item.sourceId,
1978
+ item.sourceType,
1979
+ item.edges,
1980
+ manifest,
1981
+ titleIndex,
1982
+ tx,
1983
+ now
1984
+ );
1985
+ }
1986
+ await this.entryRepo.markOntologyChecked(batch.map((f) => f.id), entityId, now, tx);
1987
+ });
1988
+ if (abortedOntologyOff) {
1989
+ const counts2 = await this.entryRepo.countUntypedByEntityId(entityId, recheckCutoff);
1990
+ return { ...zeroed, remaining: 0, deferred: counts2.deferred };
1991
+ }
1992
+ this.searchService.evictCache(entityId);
1993
+ const counts = await this.entryRepo.countUntypedByEntityId(entityId, recheckCutoff);
1994
+ return { scanned: batch.length, typed, failedValidation, edgesAdded, remaining: counts.eligible, deferred: counts.deferred };
1995
+ }
1835
1996
  _validatePruneDuration(value, name) {
1836
1997
  if (value !== null && value !== void 0 && (typeof value !== "number" || !isFinite(value) || value < 0)) {
1837
1998
  throw new Error(`Invalid ${name}: must be a non-negative finite number or null`);
@@ -3045,6 +3206,6 @@ var WriteService = class {
3045
3206
  }
3046
3207
  };
3047
3208
 
3048
- export { BaseRepository, EmbeddingService, HOOK_TIMEOUT_MARKER, ImportExportService, IngestionService, JobManager, MaintenanceService, MetadataRepository, PromptService, PrunePartialFailureError, RetrievalService, SearchService, WikiBusyError, WikiTransactionError, WriteService, __privateAdd, __privateGet, __privateSet, configureRandomSource, emptyManifest, entitySummaryMetaKey, extractSqliteCode, generateId, normalizeSourceHash, normalizeSourceRef, normalizeTitleKey, parseEmbedding, resolveEdgeDefinition, resolveNodeType, validateInlineEdges };
3049
- //# sourceMappingURL=chunk-HKB5FEJG.mjs.map
3050
- //# sourceMappingURL=chunk-HKB5FEJG.mjs.map
3209
+ export { BaseRepository, EmbeddingService, HOOK_TIMEOUT_MARKER, ImportExportService, IngestionService, JobManager, MaintenanceService, MetadataRepository, ONTOLOGY_BACKFILL_BATCH_SIZE, ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS, ONTOLOGY_BACKFILL_RECHECK_MS, ONTOLOGY_BACKFILL_SYSTEM_PROMPT, PromptService, PrunePartialFailureError, RetrievalService, SearchService, WikiBusyError, WikiTransactionError, WriteService, __privateAdd, __privateGet, __privateSet, configureRandomSource, emptyManifest, entitySummaryMetaKey, extractSqliteCode, generateId, normalizeSourceHash, normalizeSourceRef, normalizeTitleKey, parseEmbedding, resolveEdgeDefinition, resolveNodeType, validateInlineEdges };
3210
+ //# sourceMappingURL=chunk-UYLYN5N4.mjs.map
3211
+ //# sourceMappingURL=chunk-UYLYN5N4.mjs.map