@equationalapplications/core-llm-wiki 4.15.3 → 4.16.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
@@ -21,6 +21,7 @@ Platform-agnostic TypeScript engine for hybrid LLM memory. Features episodic fac
21
21
  - **Full-featured memory** — Facts, tasks, events, maintenance jobs (librarian, heal, reembed, prune)
22
22
  - **Type-safe** — Built with TypeScript, full type exports
23
23
  - **Interoperability:** Supports [Open Knowledge Format (OKF) v0.1](https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf) import and export.
24
+ - **Per-entity seeded ontology** — Optional Strict, Emergent, or Off modes govern LLM graph extraction; seed taxonomies per entity and persist typed facts with inline edges.
24
25
 
25
26
  ## Installation
26
27
 
@@ -445,6 +446,101 @@ Notes:
445
446
  - A throwing callback is caught (logged via `console.error`) and does not block other subscribers or the underlying job.
446
447
  - Subscriptions are scoped to a single `entityId`. There is no wildcard or "all entities" form.
447
448
 
449
+ ## Per-Entity Seeded Ontology
450
+
451
+ Control how librarian and ingest passes classify facts and extract graph relationships. The system defaults to **`off`** so existing deployments behave unchanged.
452
+
453
+ ### The Three Modes
454
+
455
+ | Mode | Behavior |
456
+ |------|----------|
457
+ | **`off`** (default) | No ontology guidance. LLM output and persistence match pre-ontology behavior: `okf_type` stays `null` on LLM-created facts; maintenance passes do not create edges. OKF import still populates `okf_type` and edges independently. |
458
+ | **`strict`** | The LLM must use only `node_types` and `edge_types` from the entity manifest. Invalid `okf_type` falls back to an untyped fact with no edges; invalid individual edges are dropped while a valid `okf_type` and matching edges are kept. |
459
+ | **`emergent`** | Same validation as Strict, plus the LLM may return `ontology_updates` with new node/edge types. Updates are append-only (deduped by `type` string) and take effect before facts from the same response are validated. |
460
+
461
+ Mode resolution per entity: persisted DB row `mode` (when present) → `seedManifests[entityId].mode` (when no row but a seed exists) → `WikiConfig.ontology.mode` → `'off'`.
462
+
463
+ ### WikiConfig
464
+
465
+ Set a global default mode and bootstrap manifests for known entities at construction time:
466
+
467
+ ```typescript
468
+ const wikiMemory = new WikiMemory(db, {
469
+ llmProvider,
470
+ config: {
471
+ ontology: {
472
+ mode: 'strict', // global default when an entity has no per-entity override
473
+ seedManifests: {
474
+ 'team-alpha': {
475
+ mode: 'emergent', // optional per-entity override
476
+ manifest: {
477
+ node_types: [
478
+ { type: 'person', description: 'An individual or user.' },
479
+ { type: 'project', description: 'An ongoing initiative.' },
480
+ ],
481
+ edge_types: [
482
+ {
483
+ type: 'contributes_to',
484
+ source_type: 'person',
485
+ target_type: 'project',
486
+ description: 'Person working on a project.',
487
+ },
488
+ ],
489
+ },
490
+ },
491
+ },
492
+ },
493
+ },
494
+ });
495
+ ```
496
+
497
+ `seedManifests` entries are written to SQLite on first access when no row exists for that entity.
498
+
499
+ ### Public API
500
+
501
+ Read or seed an entity's ontology at runtime:
502
+
503
+ ```typescript
504
+ // Read effective mode + manifest (DB row, then seedManifests fallback)
505
+ const ontology = await wikiMemory.getOntologyManifest('team-alpha');
506
+ // { mode: 'emergent', manifest: { node_types: [...], edge_types: [...] } }
507
+ // null when no row and no seed entry
508
+
509
+ // Seed or replace manifest; optional per-entity mode override
510
+ await wikiMemory.setOntologyManifest('team-alpha', {
511
+ node_types: [{ type: 'person', description: 'An individual.' }],
512
+ edge_types: [{
513
+ type: 'reports_to',
514
+ source_type: 'person',
515
+ target_type: 'person',
516
+ description: 'Reporting hierarchy.',
517
+ }],
518
+ }, { mode: 'strict' });
519
+ ```
520
+
521
+ ### Fact Shape Extensions
522
+
523
+ In **Strict** and **Emergent** modes, librarian and ingest JSON may include typed facts with inline edges:
524
+
525
+ ```json
526
+ {
527
+ "facts": [{
528
+ "title": "Jane reports to Bob",
529
+ "body": "Jane reports to Bob Smith.",
530
+ "tags": [],
531
+ "confidence": "certain",
532
+ "okf_type": "person",
533
+ "edges": [{ "edge_type": "reports_to", "target_title": "Bob Smith" }]
534
+ }]
535
+ }
536
+ ```
537
+
538
+ - `okf_type` maps to a `node_types[].type` entry (case-insensitive lookup; canonical manifest casing is persisted).
539
+ - `edges` are resolved by `target_title` within the same maintenance transaction and persisted via `EdgeRepository`.
540
+ - Invalid `okf_type` falls back to `null` with no edges for that fact. Invalid individual edges are dropped; valid `okf_type` and matching edges are still persisted.
541
+
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
+
448
544
  ## OKF Import/Export
449
545
 
450
546
  The core package integrates with `@equationalapplications/core-okf` to seamlessly adapt wiki data dumps to and from Open Knowledge Format (OKF) v0.1 bundles.
@@ -606,30 +606,54 @@ var PromptService = class {
606
606
  return typeof value === "string" ? value : JSON.stringify(value, null, 2);
607
607
  });
608
608
  }
609
- buildIngestPrompt(documentChunk, runtimeOverride) {
609
+ hasOntologyPlaceholders(template) {
610
+ return /\{\{\s*ontology(?:Manifest|ModeInstructions)\s*\}\}/.test(template);
611
+ }
612
+ buildSystemPrompt(template, variables, ontologyContext) {
613
+ const shouldHydrate = Object.keys(variables).some(
614
+ (key) => new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`).test(template)
615
+ ) || ontologyContext != null && this.hasOntologyPlaceholders(template);
616
+ const hydrated = shouldHydrate ? this.hydrate(template, { ...variables, ...ontologyContext ?? {} }) : template;
617
+ return this.hasOntologyPlaceholders(template) ? ontologyContext != null ? hydrated : hydrated.replace(/\{\{\s*ontology(?:Manifest|ModeInstructions)\s*\}\}/g, "") : this.appendOntology(hydrated, ontologyContext);
618
+ }
619
+ appendOntology(systemPrompt, ctx) {
620
+ if (!ctx) return systemPrompt;
621
+ return `${systemPrompt}
622
+
623
+ ${ctx.ontologyModeInstructions}`;
624
+ }
625
+ buildIngestPrompt(documentChunk, runtimeOverride, ontologyContext) {
610
626
  const template = runtimeOverride ?? this.globalOverrides?.ingestSystemPrompt ?? INGEST_SYSTEM_PROMPT;
611
- if (/\{\{\s*documentChunk\s*\}\}/.test(template)) {
627
+ const hasDocumentChunk = /\{\{\s*documentChunk\s*\}\}/.test(template);
628
+ if (hasDocumentChunk || this.hasOntologyPlaceholders(template)) {
612
629
  return {
613
- systemPrompt: this.hydrate(template, { documentChunk }),
614
- userPrompt: "Please extract the facts."
630
+ systemPrompt: this.buildSystemPrompt(template, { documentChunk }, ontologyContext),
631
+ userPrompt: hasDocumentChunk ? "Please extract the facts." : `Document Chunk:
632
+ ${documentChunk}`
615
633
  };
616
634
  }
617
635
  return {
618
- systemPrompt: template,
636
+ systemPrompt: this.appendOntology(template, ontologyContext),
619
637
  userPrompt: `Document Chunk:
620
638
  ${documentChunk}`
621
639
  };
622
640
  }
623
- buildLibrarianPrompt(events, currentFacts, runtimeOverride) {
641
+ buildLibrarianPrompt(events, currentFacts, runtimeOverride, ontologyContext) {
624
642
  const template = runtimeOverride ?? this.globalOverrides?.librarianSystemPrompt ?? LIBRARIAN_SYSTEM_PROMPT;
625
- if (/\{\{\s*events\s*\}\}/.test(template) || /\{\{\s*currentFacts\s*\}\}/.test(template)) {
643
+ const hasEvents = /\{\{\s*events\s*\}\}/.test(template);
644
+ const hasCurrentFacts = /\{\{\s*currentFacts\s*\}\}/.test(template);
645
+ if (hasEvents || hasCurrentFacts || this.hasOntologyPlaceholders(template)) {
626
646
  return {
627
- systemPrompt: this.hydrate(template, { events, currentFacts }),
628
- userPrompt: "Please synthesize the context."
647
+ systemPrompt: this.buildSystemPrompt(template, { events, currentFacts }, ontologyContext),
648
+ userPrompt: hasEvents || hasCurrentFacts ? "Please synthesize the context." : `Events:
649
+ ${JSON.stringify(events, null, 2)}
650
+
651
+ Current Facts:
652
+ ${JSON.stringify(currentFacts, null, 2)}`
629
653
  };
630
654
  }
631
655
  return {
632
- systemPrompt: template,
656
+ systemPrompt: this.appendOntology(template, ontologyContext),
633
657
  userPrompt: `Events:
634
658
  ${JSON.stringify(events, null, 2)}
635
659
 
@@ -875,6 +899,91 @@ function jaccardScore(a, b) {
875
899
  return intersection.size / union.size;
876
900
  }
877
901
 
902
+ // src/utils/ontology.ts
903
+ function emptyManifest() {
904
+ return { node_types: [], edge_types: [] };
905
+ }
906
+ function normalizeTitleKey(title) {
907
+ return title.trim().toLowerCase().replace(/\s+/g, " ");
908
+ }
909
+ function resolveNodeType(raw, manifest) {
910
+ const slug = raw.trim();
911
+ if (!slug) return null;
912
+ const hit = manifest.node_types.find((n) => n.type.toLowerCase() === slug.toLowerCase());
913
+ return hit?.type ?? null;
914
+ }
915
+ function resolveEdgeDefinition(rawEdgeType, manifest) {
916
+ const slug = rawEdgeType.trim();
917
+ if (!slug) return null;
918
+ return manifest.edge_types.find((e) => e.type.toLowerCase() === slug.toLowerCase()) ?? null;
919
+ }
920
+ function validateManifest(manifest) {
921
+ const nodeSlugs = /* @__PURE__ */ new Set();
922
+ for (const node of manifest.node_types ?? []) {
923
+ const type = node.type?.trim();
924
+ if (!type) throw new Error("Ontology node type slug must be non-empty");
925
+ const key = type.toLowerCase();
926
+ if (nodeSlugs.has(key)) throw new Error(`Duplicate node type: ${type}`);
927
+ nodeSlugs.add(key);
928
+ }
929
+ const edgeSlugs = /* @__PURE__ */ new Set();
930
+ for (const edge of manifest.edge_types ?? []) {
931
+ const edgeType = edge.type?.trim();
932
+ const sourceType = edge.source_type?.trim();
933
+ const targetType = edge.target_type?.trim();
934
+ if (!edgeType) throw new Error("Ontology edge type slug must be non-empty");
935
+ const edgeKey = edgeType.toLowerCase();
936
+ if (edgeSlugs.has(edgeKey)) throw new Error(`Duplicate edge type: ${edgeType}`);
937
+ edgeSlugs.add(edgeKey);
938
+ if (!sourceType || !targetType || !nodeSlugs.has(sourceType.toLowerCase()) || !nodeSlugs.has(targetType.toLowerCase())) {
939
+ throw new Error(`Edge type ${edgeType} references unknown node type`);
940
+ }
941
+ }
942
+ }
943
+ function mergeOntologyUpdates(current, updates) {
944
+ const node_types = [...current.node_types];
945
+ const edge_types = [...current.edge_types];
946
+ const nodeSlugs = new Set(node_types.map((n) => n.type.trim().toLowerCase()));
947
+ const edgeSlugs = new Set(edge_types.map((e) => e.type.trim().toLowerCase()));
948
+ for (const node of updates.node_types ?? []) {
949
+ const type = node?.type?.trim();
950
+ if (!type) continue;
951
+ const key = type.toLowerCase();
952
+ if (nodeSlugs.has(key)) continue;
953
+ node_types.push({ type, description: String(node.description ?? "") });
954
+ nodeSlugs.add(key);
955
+ }
956
+ for (const edge of updates.edge_types ?? []) {
957
+ const edgeType = edge?.type?.trim();
958
+ const sourceType = edge?.source_type?.trim();
959
+ const targetType = edge?.target_type?.trim();
960
+ if (!edgeType || !sourceType || !targetType) continue;
961
+ const edgeKey = edgeType.toLowerCase();
962
+ if (edgeSlugs.has(edgeKey)) continue;
963
+ if (!nodeSlugs.has(sourceType.toLowerCase()) || !nodeSlugs.has(targetType.toLowerCase())) continue;
964
+ edge_types.push({
965
+ type: edgeType,
966
+ source_type: sourceType,
967
+ target_type: targetType,
968
+ description: String(edge.description ?? "")
969
+ });
970
+ edgeSlugs.add(edgeKey);
971
+ }
972
+ return { node_types, edge_types };
973
+ }
974
+ function validateInlineEdges(sourceType, _targetType, edges, manifest) {
975
+ if (!Array.isArray(edges)) return [];
976
+ const valid = [];
977
+ for (const edge of edges) {
978
+ if (typeof edge?.edge_type !== "string" || typeof edge?.target_title !== "string") continue;
979
+ const def = resolveEdgeDefinition(edge.edge_type, manifest);
980
+ if (!def) continue;
981
+ if (def.source_type.toLowerCase() !== sourceType.toLowerCase()) continue;
982
+ valid.push({ edge_type: def.type, target_title: edge.target_title });
983
+ }
984
+ return valid;
985
+ }
986
+
878
987
  // src/utils/ids.ts
879
988
  function generateId(prefix = "") {
880
989
  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
@@ -892,7 +1001,7 @@ function generateId(prefix = "") {
892
1001
 
893
1002
  // src/services/IngestionService.ts
894
1003
  var IngestionService = class {
895
- constructor(db, prefix, options, entryRepo, searchService, jobManager, embeddingService, promptService) {
1004
+ constructor(db, prefix, options, entryRepo, searchService, jobManager, embeddingService, promptService, ontologyService) {
896
1005
  this.db = db;
897
1006
  this.prefix = prefix;
898
1007
  this.options = options;
@@ -900,6 +1009,7 @@ var IngestionService = class {
900
1009
  this.searchService = searchService;
901
1010
  this.jobManager = jobManager;
902
1011
  this.embeddingService = embeddingService;
1012
+ this.ontologyService = ontologyService;
903
1013
  this.promptService = promptService ?? new PromptService(this.options.config?.prompts);
904
1014
  }
905
1015
  async ingestDocument(entityId, params) {
@@ -924,23 +1034,33 @@ var IngestionService = class {
924
1034
  if (chunks.length === 0) return { truncated: false, chunks: 0 };
925
1035
  const chunkResults = await withConcurrency(
926
1036
  chunks.map((chunk) => async () => {
927
- const { systemPrompt, userPrompt } = this.promptService.buildIngestPrompt(chunk, params.promptOverride);
1037
+ const ontologyContext = await this.ontologyService?.buildPromptContext(entityId) ?? null;
1038
+ const { systemPrompt, userPrompt } = this.promptService.buildIngestPrompt(
1039
+ chunk,
1040
+ params.promptOverride,
1041
+ ontologyContext
1042
+ );
928
1043
  const responseText = await this.options.llmProvider.generateText({ systemPrompt, userPrompt });
929
1044
  const result = parseJsonResponse(responseText);
930
- return (Array.isArray(result.facts) ? result.facts : []).map(validateFact).filter((f) => f !== null);
1045
+ return {
1046
+ facts: (Array.isArray(result.facts) ? result.facts : []).map(validateFact).filter((f) => f !== null),
1047
+ ontology_updates: result.ontology_updates
1048
+ };
931
1049
  }),
932
1050
  chunkConcurrency
933
1051
  );
934
1052
  const seen = /* @__PURE__ */ new Set();
935
- const allValidFacts = [];
936
- for (const facts of chunkResults) {
937
- for (const fact of facts) {
938
- const normalized = fact.title.trim().toLowerCase().replace(/\s+/g, " ");
939
- if (!seen.has(normalized)) {
940
- seen.add(normalized);
941
- allValidFacts.push(fact);
1053
+ const orderedChunkFacts = [];
1054
+ for (const chunkResult of chunkResults) {
1055
+ const dedupedFacts = [];
1056
+ for (const fact of chunkResult.facts) {
1057
+ const normalizedTitle = normalizeTitleKey(fact.title);
1058
+ if (!seen.has(normalizedTitle)) {
1059
+ seen.add(normalizedTitle);
1060
+ dedupedFacts.push(fact);
942
1061
  }
943
1062
  }
1063
+ orderedChunkFacts.push({ facts: dedupedFacts, ontology_updates: chunkResult.ontology_updates });
944
1064
  }
945
1065
  const now = Date.now();
946
1066
  const insertedFacts = [];
@@ -948,26 +1068,63 @@ var IngestionService = class {
948
1068
  await this.db.withTransactionAsync(async (tx) => {
949
1069
  deletedSourceFactIds.push(...await this.entryRepo.findIdsBySource(entityId, sourceRef, null, tx, false));
950
1070
  await this.entryRepo.softDeleteBySource(entityId, tx, sourceRef, null);
951
- for (const fact of allValidFacts) {
952
- const id = generateId("fact_");
953
- const wikiFact = {
954
- id,
955
- entity_id: entityId,
956
- title: fact.title,
957
- body: fact.body,
958
- tags: fact.tags,
959
- confidence: fact.confidence,
960
- source_type: "immutable_document",
961
- source_hash: sourceHash,
962
- source_ref: sourceRef,
963
- created_at: now,
964
- updated_at: now,
965
- last_accessed_at: null,
966
- access_count: 0,
967
- deleted_at: null
968
- };
969
- await this.entryRepo.upsert(wikiFact, tx);
970
- insertedFacts.push({ id, entity_id: entityId, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
1071
+ const titleIndex = /* @__PURE__ */ new Map();
1072
+ const pendingEdges = [];
1073
+ const existingFacts = await this.entryRepo.findRecentByEntityId(entityId, 500, tx);
1074
+ for (const existing of existingFacts) {
1075
+ titleIndex.set(normalizeTitleKey(existing.title), {
1076
+ id: existing.id,
1077
+ okf_type: existing.okf_type ?? null
1078
+ });
1079
+ }
1080
+ let ontologyState = await this.ontologyService?.getEffectiveState(entityId, tx) ?? { mode: "off", manifest: { node_types: [], edge_types: [] } };
1081
+ let { mode, manifest } = ontologyState;
1082
+ for (const { facts, ontology_updates } of orderedChunkFacts) {
1083
+ if (mode === "emergent" && ontology_updates && this.ontologyService) {
1084
+ manifest = await this.ontologyService.mergeEmergentUpdates(entityId, ontology_updates, tx);
1085
+ ontologyState = await this.ontologyService.getEffectiveState(entityId, tx);
1086
+ mode = ontologyState.mode;
1087
+ }
1088
+ for (const fact of facts) {
1089
+ const ontologyFact = fact;
1090
+ const normalized = this.ontologyService?.validateAndNormalizeFact(ontologyFact, manifest) ?? { okf_type: null, edges: [] };
1091
+ const id = generateId("fact_");
1092
+ const wikiFact = {
1093
+ id,
1094
+ entity_id: entityId,
1095
+ title: fact.title,
1096
+ body: fact.body,
1097
+ tags: fact.tags,
1098
+ confidence: fact.confidence,
1099
+ source_type: "immutable_document",
1100
+ source_hash: sourceHash,
1101
+ source_ref: sourceRef,
1102
+ created_at: now,
1103
+ updated_at: now,
1104
+ last_accessed_at: null,
1105
+ access_count: 0,
1106
+ deleted_at: null,
1107
+ okf_type: normalized.okf_type
1108
+ };
1109
+ await this.entryRepo.upsert(wikiFact, tx);
1110
+ insertedFacts.push({ id, entity_id: entityId, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
1111
+ titleIndex.set(normalizeTitleKey(fact.title), { id, okf_type: normalized.okf_type });
1112
+ if (normalized.edges.length > 0) {
1113
+ pendingEdges.push({ sourceId: id, sourceType: normalized.okf_type, edges: normalized.edges });
1114
+ }
1115
+ }
1116
+ }
1117
+ for (const item of pendingEdges) {
1118
+ await this.ontologyService?.resolveAndPersistEdges(
1119
+ entityId,
1120
+ item.sourceId,
1121
+ item.sourceType,
1122
+ item.edges ?? [],
1123
+ manifest,
1124
+ titleIndex,
1125
+ tx,
1126
+ now
1127
+ );
971
1128
  }
972
1129
  });
973
1130
  await this.searchService.sync(entityId);
@@ -994,7 +1151,7 @@ var IngestionService = class {
994
1151
  var FUZZY_THRESHOLD = 0.5;
995
1152
  var MIN_TOKENS_TO_QUALIFY = 3;
996
1153
  var MaintenanceService = class {
997
- constructor(db, prefix, options, entryRepo, taskRepo, eventRepo, metadataRepo, searchService, jobManager, embeddingService, promptService) {
1154
+ constructor(db, prefix, options, entryRepo, taskRepo, eventRepo, metadataRepo, searchService, jobManager, embeddingService, promptService, ontologyService) {
998
1155
  this.db = db;
999
1156
  this.prefix = prefix;
1000
1157
  this.options = options;
@@ -1005,6 +1162,7 @@ var MaintenanceService = class {
1005
1162
  this.searchService = searchService;
1006
1163
  this.jobManager = jobManager;
1007
1164
  this.embeddingService = embeddingService;
1165
+ this.ontologyService = ontologyService;
1008
1166
  this.promptService = promptService ?? new PromptService(this.options.config?.prompts);
1009
1167
  }
1010
1168
  async runPrune(entityId, options) {
@@ -1227,21 +1385,36 @@ var MaintenanceService = class {
1227
1385
  tags: typeof rest.tags === "string" ? JSON.parse(rest.tags) : rest.tags
1228
1386
  };
1229
1387
  });
1388
+ const ontologyContext = await this.ontologyService?.buildPromptContext(entityId) ?? null;
1230
1389
  const { systemPrompt, userPrompt } = this.promptService.buildLibrarianPrompt(
1231
1390
  events.reverse(),
1232
1391
  currentFacts,
1233
- promptOverride
1392
+ promptOverride,
1393
+ ontologyContext
1234
1394
  );
1235
1395
  const responseText = await this.options.llmProvider.generateText({ systemPrompt, userPrompt });
1236
1396
  const result = parseJsonResponse(responseText);
1237
1397
  const facts = Array.isArray(result.facts) ? result.facts : [];
1238
1398
  const tasks = Array.isArray(result.tasks) ? result.tasks : [];
1399
+ const ontologyUpdates = result.ontology_updates;
1239
1400
  const validFacts = facts.map(validateFact).filter((f) => f !== null);
1240
1401
  const validTasks = tasks.map(validateTask).filter((t) => t !== null);
1241
1402
  const now = Date.now();
1242
1403
  const insertedFacts = [];
1243
1404
  await this.db.withTransactionAsync(async (tx) => {
1405
+ let { mode, manifest } = await this.ontologyService?.getEffectiveState(entityId, tx) ?? { mode: "off", manifest: { node_types: [], edge_types: [] } };
1406
+ if (mode === "emergent" && ontologyUpdates && this.ontologyService) {
1407
+ manifest = await this.ontologyService.mergeEmergentUpdates(entityId, ontologyUpdates, tx);
1408
+ }
1409
+ const titleIndex = /* @__PURE__ */ new Map();
1410
+ for (const existing of currentFactsRows) {
1411
+ titleIndex.set(normalizeTitleKey(existing.title), {
1412
+ id: existing.id,
1413
+ okf_type: existing.okf_type ?? null
1414
+ });
1415
+ }
1244
1416
  const factsForDedupe = await this.entryRepo.findRecentByEntityId(entityId, 100, tx);
1417
+ const pendingEdges = [];
1245
1418
  for (const fact of validFacts) {
1246
1419
  const newTokens = titleTokens(fact.title);
1247
1420
  let skip = false;
@@ -1258,6 +1431,8 @@ var MaintenanceService = class {
1258
1431
  }
1259
1432
  }
1260
1433
  if (skip) continue;
1434
+ const ontologyFact = fact;
1435
+ const normalized = this.ontologyService?.validateAndNormalizeFact(ontologyFact, manifest) ?? { okf_type: null, edges: [] };
1261
1436
  const id = generateId("fact_");
1262
1437
  const factObj = {
1263
1438
  id,
@@ -1273,11 +1448,28 @@ var MaintenanceService = class {
1273
1448
  updated_at: now,
1274
1449
  last_accessed_at: null,
1275
1450
  access_count: 0,
1276
- deleted_at: null
1451
+ deleted_at: null,
1452
+ okf_type: normalized.okf_type
1277
1453
  };
1278
1454
  await this.entryRepo.upsert(factObj, tx);
1279
1455
  insertedFacts.push({ id, entity_id: entityId, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
1280
1456
  factsForDedupe.push(factObj);
1457
+ titleIndex.set(normalizeTitleKey(fact.title), { id, okf_type: normalized.okf_type });
1458
+ if (normalized.edges.length > 0) {
1459
+ pendingEdges.push({ sourceId: id, sourceType: normalized.okf_type, edges: normalized.edges });
1460
+ }
1461
+ }
1462
+ for (const item of pendingEdges) {
1463
+ await this.ontologyService?.resolveAndPersistEdges(
1464
+ entityId,
1465
+ item.sourceId,
1466
+ item.sourceType,
1467
+ item.edges ?? [],
1468
+ manifest,
1469
+ titleIndex,
1470
+ tx,
1471
+ now
1472
+ );
1281
1473
  }
1282
1474
  for (const task of validTasks) {
1283
1475
  const id = generateId("task_");
@@ -2614,6 +2806,6 @@ var WriteService = class {
2614
2806
  }
2615
2807
  };
2616
2808
 
2617
- export { EmbeddingService, HOOK_TIMEOUT_MARKER, ImportExportService, IngestionService, JobManager, MaintenanceService, PromptService, PrunePartialFailureError, RetrievalService, SearchService, WikiBusyError, WriteService, __privateAdd, __privateGet, __privateSet, generateId, normalizeSourceHash, normalizeSourceRef, parseEmbedding };
2618
- //# sourceMappingURL=chunk-J4GBC6CP.mjs.map
2619
- //# sourceMappingURL=chunk-J4GBC6CP.mjs.map
2809
+ export { EmbeddingService, HOOK_TIMEOUT_MARKER, ImportExportService, IngestionService, JobManager, MaintenanceService, PromptService, PrunePartialFailureError, RetrievalService, SearchService, WikiBusyError, WriteService, __privateAdd, __privateGet, __privateSet, emptyManifest, generateId, mergeOntologyUpdates, normalizeSourceHash, normalizeSourceRef, normalizeTitleKey, parseEmbedding, resolveEdgeDefinition, resolveNodeType, validateInlineEdges, validateManifest };
2810
+ //# sourceMappingURL=chunk-2BGLPRT3.mjs.map
2811
+ //# sourceMappingURL=chunk-2BGLPRT3.mjs.map