@equationalapplications/core-llm-wiki 7.5.0 → 7.6.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
@@ -310,6 +310,36 @@ await wiki.promoteDraft(facts[0].id, 'user-1', { by: 'human:alice' }); // → st
310
310
 
311
311
  `promoteDraft` throws `WikiDraftNotFound` when no live draft with that id exists for the entity. The error is contextless by design. Promotion does not change `updated_at`, so a promoted fact keeps its recency position.
312
312
 
313
+ ## Grounding
314
+
315
+ Opt-in, deterministic evidence check for LLM-authored facts. When on, writers you choose must quote the source they were shown. A fact whose quotes are missing or not found is stored as a `draft` (see [Draft Review](#draft-review)), not rejected. A fact whose quotes all check out is stored `stable` with a `process:grounding-check` verifier, so its `trustTier` is `machine-confirmed`. Default off: 7.x write behavior is unchanged.
316
+
317
+ ```ts
318
+ new WikiMemory(db, {
319
+ llmProvider,
320
+ config: {
321
+ grounding: {
322
+ mode: 'draft', // 'off' (default) | 'draft'
323
+ writers: ['ingest'], // default ['ingest']; also 'librarian', 'heal'
324
+ minEvidenceChars: 20, // shorter quotes count as absent
325
+ maxEvidence: 3, // quotes asked for per fact
326
+ maxEvidenceChars: 300,
327
+ },
328
+ },
329
+ });
330
+ ```
331
+
332
+ - **What counts as source.**
333
+ - Ingest: the chunk text.
334
+ - Librarian: the `summary` of each event in the prompt.
335
+ - Heal: the `summary` of each recent event in the prompt, plus the bodies of non-draft document anchors. When heal is a writer, anchors are shown with their body clipped to 800 characters.
336
+ - Instructions, the ontology manifest, existing facts and identifiers never count, so a model cannot ground a claim by quoting them.
337
+ - **The check.** Both sides are normalized with NFKC, whitespace runs collapse to one space, and matching is case-sensitive. A fact with more than 10 quotes, or any quote not found, fails.
338
+ - **Diagnostics.** `grounding_missing` (reasons `no_evidence`, `evidence_too_short`) and `grounding_failed` (reasons `quote_not_found`, `too_many_quotes`), one per fact, with the new fact's `factId`. Quotes are never included.
339
+ - **`upsertGraph`** nodes are host-supplied and never grounded.
340
+ - **Librarian and heal** synthesize across events, so their pass rates are unknown. Measure them on your own event log before opting them in.
341
+ - Evidence quotes are not stored.
342
+
313
343
  ## Pluggable Vector Retrieval
314
344
 
315
345
  When your entity corpus grows, in-process cosine similarity scoring becomes a bottleneck. The optional **`VectorRanker`** interface lets you delegate semantic ranking to [**sqlite-vec**](https://github.com/asg017/sqlite-vec), [**sqlite-vss**](https://github.com/asg017/sqlite-vss), or an external vector database while `WikiMemory` handles embedding validation, hybrid scoring, and tier-2 row hydration.
@@ -1513,6 +1513,19 @@ function validateTags(tags) {
1513
1513
  if (!Array.isArray(tags)) return [];
1514
1514
  return tags.filter((t) => typeof t === "string").map((t) => t.trim().toLowerCase()).filter((t) => t.length > 0 && t.length <= 40).slice(0, 6);
1515
1515
  }
1516
+ var MAX_EVIDENCE_QUOTES = 10;
1517
+ function normalizeEvidence(raw) {
1518
+ if (!Array.isArray(raw)) return void 0;
1519
+ const out = [];
1520
+ for (const entry of raw) {
1521
+ if (typeof entry !== "string") continue;
1522
+ const trimmed = entry.trim();
1523
+ if (!trimmed) continue;
1524
+ out.push(trimmed);
1525
+ if (out.length > MAX_EVIDENCE_QUOTES) break;
1526
+ }
1527
+ return out;
1528
+ }
1516
1529
  function validateFact(fact) {
1517
1530
  if (typeof fact?.title !== "string" || typeof fact?.body !== "string") return null;
1518
1531
  const title = clip(fact.title, 80);
@@ -1520,13 +1533,17 @@ function validateFact(fact) {
1520
1533
  if (!title || !body) return null;
1521
1534
  let confidence = fact.confidence;
1522
1535
  if (confidence !== "certain" && confidence !== "tentative") confidence = "inferred";
1523
- return {
1536
+ const valid = {
1524
1537
  ...fact,
1525
1538
  title,
1526
1539
  body,
1527
1540
  confidence,
1528
1541
  tags: validateTags(fact.tags)
1529
1542
  };
1543
+ const evidence = normalizeEvidence(fact.evidence);
1544
+ if (evidence) valid.evidence = evidence;
1545
+ else delete valid.evidence;
1546
+ return valid;
1530
1547
  }
1531
1548
  function validateTask(task) {
1532
1549
  if (typeof task?.description !== "string") return null;
@@ -1602,17 +1619,94 @@ Return ONLY a valid JSON object matching this schema:
1602
1619
  }
1603
1620
  If no manifest type fits a fact, omit that fact from "classifications" entirely \u2014 do not guess.
1604
1621
  When echoing an existing fact's title verbatim into "target_title", preserve every JSON escape sequence (\\", \\n, \\\\, \\/) exactly as it appeared in the input body \u2014 do not strip backslashes, do not add unescaped quotes. Do not return markdown, just raw JSON.`;
1622
+ var GROUNDING_SOURCE = {
1623
+ ingest: { key: "facts", source: "the document chunk" },
1624
+ librarian: { key: "facts", source: 'the "summary" text of the events' },
1625
+ heal: { key: "newFacts", source: 'the "summary" text of the recent events or the "body" text of the document anchors' }
1626
+ };
1627
+ function groundingEvidenceBlock(writer, cfg) {
1628
+ const { key, source } = GROUNDING_SOURCE[writer];
1629
+ return `EVIDENCE REQUIREMENT: every object in "${key}" must also carry an "evidence" array of 1 to ${cfg.maxEvidence} quotes. Each quote must be an exact substring copied character-for-character from ${source} (the SOURCE section), at least ${cfg.minEvidenceChars} characters long. Do not paraphrase. Do not quote these instructions, the ontology manifest, or any existing fact. A fact whose quotes cannot be found in the SOURCE section is stored as an unreviewed draft.
1630
+ "evidence": ["exact substring copied from the SOURCE section"]`;
1631
+ }
1605
1632
 
1606
1633
  // src/utils/healConstants.ts
1607
1634
  var HEAL_MAX_ANCHORS = 50;
1608
1635
  var HEAL_ANCHORS_PER_CANDIDATE = 4;
1609
1636
  var HEAL_MAX_FACT_BODY_CHARS_L3 = 4e3;
1610
1637
  var HEAL_MAX_TASKS = 50;
1638
+ var HEAL_ANCHOR_BODY_CHARS = 800;
1639
+
1640
+ // src/utils/grounding.ts
1641
+ var GROUNDING_VERIFIER = "process:grounding-check";
1642
+ var WRITERS = ["ingest", "librarian", "heal"];
1643
+ function positiveInt(value, fallback) {
1644
+ return typeof value === "number" && Number.isFinite(value) && value >= 1 ? Math.floor(value) : fallback;
1645
+ }
1646
+ function resolveGrounding(config) {
1647
+ if (!config || config.mode !== "draft") return null;
1648
+ const writers = Array.isArray(config.writers) ? config.writers.filter((w) => WRITERS.includes(w)) : ["ingest"];
1649
+ return {
1650
+ writers: new Set(writers),
1651
+ minEvidenceChars: positiveInt(config.minEvidenceChars, 20),
1652
+ // Never ask for more quotes than checkGrounding accepts (MAX_EVIDENCE_QUOTES).
1653
+ maxEvidence: Math.min(MAX_EVIDENCE_QUOTES, positiveInt(config.maxEvidence, 3)),
1654
+ maxEvidenceChars: positiveInt(config.maxEvidenceChars, 300)
1655
+ };
1656
+ }
1657
+ function normalizeForGrounding(text) {
1658
+ return text.normalize("NFKC").replace(/\s+/g, " ").trim();
1659
+ }
1660
+ function buildGroundingCorpus(parts) {
1661
+ return parts.filter((p) => typeof p === "string").map(normalizeForGrounding);
1662
+ }
1663
+ function checkGrounding(evidence, normalizedCorpus, cfg) {
1664
+ const quotes = evidence ?? [];
1665
+ if (quotes.length > MAX_EVIDENCE_QUOTES) return { status: "failed", reason: "too_many_quotes" };
1666
+ if (quotes.length === 0) return { status: "missing", reason: "no_evidence" };
1667
+ const qualifying = quotes.map(normalizeForGrounding).filter((q) => q.length >= cfg.minEvidenceChars);
1668
+ if (qualifying.length === 0) return { status: "missing", reason: "evidence_too_short" };
1669
+ if (qualifying.some((q) => !normalizedCorpus.some((part) => part.includes(q)))) {
1670
+ return { status: "failed", reason: "quote_not_found" };
1671
+ }
1672
+ return {
1673
+ status: "grounded",
1674
+ retained: qualifying.slice(0, cfg.maxEvidence).map((q) => safeSlice(q, 0, cfg.maxEvidenceChars))
1675
+ };
1676
+ }
1677
+ function groundingOutcome(verdict, now) {
1678
+ if (verdict.status === "grounded") {
1679
+ return {
1680
+ trust: {
1681
+ lifecycle_status: "stable",
1682
+ okf_verified: [{ by: GROUNDING_VERIFIER, at: new Date(now).toISOString() }],
1683
+ last_verified_at: now,
1684
+ last_verified_by: GROUNDING_VERIFIER
1685
+ }
1686
+ };
1687
+ }
1688
+ return {
1689
+ trust: { lifecycle_status: "draft" },
1690
+ diagnostic: { code: verdict.status === "missing" ? "grounding_missing" : "grounding_failed", reason: verdict.reason }
1691
+ };
1692
+ }
1611
1693
 
1612
1694
  // src/services/PromptService.ts
1613
1695
  var PromptService = class {
1614
- constructor(globalOverrides) {
1696
+ constructor(globalOverrides, grounding = null) {
1615
1697
  this.globalOverrides = globalOverrides;
1698
+ this.grounding = grounding;
1699
+ }
1700
+ /** The resolved grounding config when `writer` is in `grounding.writers`; otherwise null (spec §6.2). */
1701
+ groundingFor(writer) {
1702
+ return this.grounding?.writers.has(writer) ? this.grounding : null;
1703
+ }
1704
+ /** Appended after any override and after ontology context, so it is always last. */
1705
+ appendGrounding(systemPrompt, writer) {
1706
+ const cfg = this.groundingFor(writer);
1707
+ return cfg ? `${systemPrompt}
1708
+
1709
+ ${groundingEvidenceBlock(writer, cfg)}` : systemPrompt;
1616
1710
  }
1617
1711
  hydrate(template, variables) {
1618
1712
  return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_match, key) => {
@@ -1642,13 +1736,13 @@ ${ctx.ontologyModeInstructions}`;
1642
1736
  const hasDocumentChunk = /\{\{\s*documentChunk\s*\}\}/.test(template);
1643
1737
  if (hasDocumentChunk || this.hasOntologyPlaceholders(template)) {
1644
1738
  return {
1645
- systemPrompt: this.buildSystemPrompt(template, { documentChunk }, ontologyContext),
1739
+ systemPrompt: this.appendGrounding(this.buildSystemPrompt(template, { documentChunk }, ontologyContext), "ingest"),
1646
1740
  userPrompt: hasDocumentChunk ? "Please extract the facts." : `Document Chunk:
1647
1741
  ${documentChunk}`
1648
1742
  };
1649
1743
  }
1650
1744
  return {
1651
- systemPrompt: this.appendOntology(template, ontologyContext),
1745
+ systemPrompt: this.appendGrounding(this.appendOntology(template, ontologyContext), "ingest"),
1652
1746
  userPrompt: `Document Chunk:
1653
1747
  ${documentChunk}`
1654
1748
  };
@@ -1657,23 +1751,27 @@ ${documentChunk}`
1657
1751
  const template = runtimeOverride ?? this.globalOverrides?.librarianSystemPrompt ?? LIBRARIAN_SYSTEM_PROMPT;
1658
1752
  const hasEvents = /\{\{\s*events\s*\}\}/.test(template);
1659
1753
  const hasCurrentFacts = /\{\{\s*currentFacts\s*\}\}/.test(template);
1754
+ const eventsShown = hasEvents || !hasCurrentFacts;
1755
+ const corpusField = this.groundingFor("librarian") ? { groundingCorpus: buildGroundingCorpus(eventsShown ? events.map(summaryOf) : []) } : {};
1660
1756
  if (hasEvents || hasCurrentFacts || this.hasOntologyPlaceholders(template)) {
1661
1757
  return {
1662
- systemPrompt: this.buildSystemPrompt(template, { events, currentFacts }, ontologyContext),
1758
+ systemPrompt: this.appendGrounding(this.buildSystemPrompt(template, { events, currentFacts }, ontologyContext), "librarian"),
1663
1759
  userPrompt: hasEvents || hasCurrentFacts ? "Please synthesize the context." : `Events:
1664
1760
  ${JSON.stringify(events, null, 2)}
1665
1761
 
1666
1762
  Current Facts:
1667
- ${JSON.stringify(currentFacts, null, 2)}`
1763
+ ${JSON.stringify(currentFacts, null, 2)}`,
1764
+ ...corpusField
1668
1765
  };
1669
1766
  }
1670
1767
  return {
1671
- systemPrompt: this.appendOntology(template, ontologyContext),
1768
+ systemPrompt: this.appendGrounding(this.appendOntology(template, ontologyContext), "librarian"),
1672
1769
  userPrompt: `Events:
1673
1770
  ${JSON.stringify(events, null, 2)}
1674
1771
 
1675
1772
  Current Facts:
1676
- ${JSON.stringify(currentFacts, null, 2)}`
1773
+ ${JSON.stringify(currentFacts, null, 2)}`,
1774
+ ...corpusField
1677
1775
  };
1678
1776
  }
1679
1777
  /**
@@ -1703,40 +1801,54 @@ ${JSON.stringify(currentFacts, null, 2)}`
1703
1801
  const effectiveEvents = attemptLevel >= 2 ? [] : recentEvents;
1704
1802
  const maxAnchors = Math.max(1, Math.min(HEAL_MAX_ANCHORS, healCandidates.length * HEAL_ANCHORS_PER_CANDIDATE));
1705
1803
  const effectiveAnchors = documentAnchors.slice(0, maxAnchors);
1804
+ const template = runtimeOverride ?? this.globalOverrides?.healSystemPrompt ?? HEAL_SYSTEM_PROMPT;
1805
+ const hasRecentEvents = /\{\{\s*recentEvents\s*\}\}/.test(template);
1806
+ const hasDocumentAnchors = /\{\{\s*documentAnchors\s*\}\}/.test(template);
1807
+ const usesPlaceholders = /\{\{\s*healCandidates\s*\}\}/.test(template) || hasDocumentAnchors || /\{\{\s*allTasks\s*\}\}/.test(template) || hasRecentEvents;
1808
+ const healGrounding = this.groundingFor("heal");
1809
+ const promptAnchors = healGrounding ? effectiveAnchors.map(toGroundingAnchor) : effectiveAnchors;
1810
+ const eventsShown = !usesPlaceholders || hasRecentEvents;
1811
+ const anchorsShown = !usesPlaceholders || hasDocumentAnchors;
1812
+ const groundingCorpus = healGrounding ? buildGroundingCorpus([
1813
+ ...eventsShown ? effectiveEvents.map(summaryOf) : [],
1814
+ ...anchorsShown ? effectiveAnchors.map((a, i) => ({ status: a?.lifecycle_status, shown: promptAnchors[i] })).filter((x) => x.status !== "draft").map((x) => x.shown?.body) : []
1815
+ ]) : null;
1706
1816
  const { shapedCandidates, degraded } = applyBodyTruncation(
1707
1817
  healCandidates,
1708
1818
  attemptLevel,
1709
1819
  bodyTruncationChars
1710
1820
  );
1711
- const template = runtimeOverride ?? this.globalOverrides?.healSystemPrompt ?? HEAL_SYSTEM_PROMPT;
1712
- if (/\{\{\s*healCandidates\s*\}\}/.test(template) || /\{\{\s*documentAnchors\s*\}\}/.test(template) || /\{\{\s*allTasks\s*\}\}/.test(template) || /\{\{\s*recentEvents\s*\}\}/.test(template)) {
1821
+ const corpusField = groundingCorpus !== null ? { groundingCorpus } : {};
1822
+ if (usesPlaceholders) {
1713
1823
  return {
1714
1824
  prompts: {
1715
- systemPrompt: this.hydrate(template, {
1825
+ systemPrompt: this.appendGrounding(this.hydrate(template, {
1716
1826
  healCandidates: shapedCandidates,
1717
- documentAnchors: effectiveAnchors,
1827
+ documentAnchors: promptAnchors,
1718
1828
  allTasks: effectiveTasks,
1719
1829
  recentEvents: effectiveEvents
1720
- }),
1830
+ }), "heal"),
1721
1831
  userPrompt: "Please heal the memory graph."
1722
1832
  },
1723
- degraded
1833
+ degraded,
1834
+ ...corpusField
1724
1835
  };
1725
1836
  }
1726
1837
  return {
1727
1838
  prompts: {
1728
- systemPrompt: template,
1839
+ systemPrompt: this.appendGrounding(template, "heal"),
1729
1840
  userPrompt: `Heal Candidates:
1730
1841
  ${JSON.stringify(shapedCandidates, null, 2)}
1731
1842
  Document Anchors (DO NOT MODIFY OR DELETE):
1732
- ${JSON.stringify(effectiveAnchors, null, 2)}
1843
+ ${JSON.stringify(promptAnchors, null, 2)}
1733
1844
  All Tasks:
1734
1845
  ${JSON.stringify(effectiveTasks, null, 2)}
1735
1846
  Recent Events:
1736
1847
  ${JSON.stringify(effectiveEvents, null, 2)}
1737
1848
  The following document anchors are provided for contradiction detection only. Do not include them in \`downgraded\`, \`deleted\`, or \`newFacts\`.`
1738
1849
  },
1739
- degraded
1850
+ degraded,
1851
+ ...corpusField
1740
1852
  };
1741
1853
  }
1742
1854
  buildOntologyBackfillPrompt(facts, runtimeOverride, ontologyContext) {
@@ -1786,6 +1898,19 @@ function applyBodyTruncation(candidates, attemptLevel, bodyTruncationChars) {
1786
1898
  }
1787
1899
  return { shapedCandidates, degraded };
1788
1900
  }
1901
+ function summaryOf(event) {
1902
+ return event?.summary;
1903
+ }
1904
+ function toGroundingAnchor(anchor) {
1905
+ if (typeof anchor !== "object" || anchor === null) return anchor;
1906
+ const a = anchor;
1907
+ return {
1908
+ id: a.id,
1909
+ title: a.title,
1910
+ source_ref: a.source_ref,
1911
+ body: typeof a.body === "string" ? safeSlice(a.body, 0, HEAL_ANCHOR_BODY_CHARS) : ""
1912
+ };
1913
+ }
1789
1914
 
1790
1915
  // src/utils/chunkingDefaults.ts
1791
1916
  var DEFAULT_MAX_CHUNK_LENGTH = 12e3;
@@ -1913,7 +2038,7 @@ var IngestionService = class {
1913
2038
  this.jobManager = jobManager;
1914
2039
  this.embeddingService = embeddingService;
1915
2040
  this.ontologyService = ontologyService;
1916
- this.promptService = promptService ?? new PromptService(this.options.config?.prompts);
2041
+ this.promptService = promptService ?? new PromptService(this.options.config?.prompts, resolveGrounding(this.options.config?.grounding));
1917
2042
  }
1918
2043
  async ingestDocument(entityId, params, opts) {
1919
2044
  const sourceRef = normalizeSourceRef(params.sourceRef);
@@ -1946,6 +2071,7 @@ var IngestionService = class {
1946
2071
  const { chunks, truncated } = chunkText(params.documentChunk, maxChunkLength, chunkOverlap);
1947
2072
  if (chunks.length === 0) return zeroChunkResult();
1948
2073
  const ontologyContext = await this.ontologyService?.buildPromptContext(entityId) ?? null;
2074
+ const ingestGrounding = this.promptService.groundingFor("ingest");
1949
2075
  const chunkResults = await withConcurrency(
1950
2076
  chunks.map((chunk, chunkIndex) => async () => {
1951
2077
  const { systemPrompt, userPrompt } = this.promptService.buildIngestPrompt(
@@ -1969,11 +2095,16 @@ var IngestionService = class {
1969
2095
  rejected.push({ itemIndex, reason: factRejectionReason(raw) });
1970
2096
  }
1971
2097
  });
2098
+ const verdicts = ingestGrounding ? (() => {
2099
+ const corpus = buildGroundingCorpus([chunk]);
2100
+ return facts.map((f) => checkGrounding(f.evidence, corpus, ingestGrounding));
2101
+ })() : [];
1972
2102
  return {
1973
2103
  status: "ok",
1974
2104
  facts,
1975
2105
  itemIndexes,
1976
2106
  rejected,
2107
+ verdicts,
1977
2108
  ontology_updates: result2.ontology_updates
1978
2109
  };
1979
2110
  } catch (e) {
@@ -2018,6 +2149,7 @@ var IngestionService = class {
2018
2149
  const failures = [];
2019
2150
  const seen = /* @__PURE__ */ new Set();
2020
2151
  const orderedChunkFacts = [];
2152
+ const groundingLedger = /* @__PURE__ */ new Map();
2021
2153
  const diagBuffer = new DiagnosticBuffer();
2022
2154
  const diagBase = { entityId, operation: "ingest", trigger: "call" };
2023
2155
  for (const [chunkIndex, slot] of chunkResults.entries()) {
@@ -2036,6 +2168,7 @@ var IngestionService = class {
2036
2168
  if (!seen.has(normalizedTitle)) {
2037
2169
  seen.add(normalizedTitle);
2038
2170
  dedupedFacts.push(fact);
2171
+ if (ingestGrounding) groundingLedger.set(fact, { verdict: slot.verdicts[k], chunkIndex, itemIndex: slot.itemIndexes[k] });
2039
2172
  } else {
2040
2173
  diagBuffer.push({
2041
2174
  ...diagBase,
@@ -2072,7 +2205,7 @@ var IngestionService = class {
2072
2205
  try {
2073
2206
  if (failedChunks === 0) {
2074
2207
  const fullResult = await this.db.withTransactionAsync(async (tx) => {
2075
- return await this.runFullUpsertGraph(entityId, sourceRef, sourceHash, orderedChunkFacts, tx, diagBuffer);
2208
+ return await this.runFullUpsertGraph(entityId, sourceRef, sourceHash, orderedChunkFacts, tx, diagBuffer, groundingLedger);
2076
2209
  });
2077
2210
  deletedSourceFactIds.push(...fullResult.deletedSourceFactIds);
2078
2211
  insertedFacts.push(...fullResult.insertedFacts);
@@ -2080,7 +2213,7 @@ var IngestionService = class {
2080
2213
  const partialResult = await this.db.withTransactionAsync(async (tx) => {
2081
2214
  const flat = [];
2082
2215
  for (const slot of orderedChunkFacts) flat.push(...slot.facts);
2083
- return await this.appendPartialFacts(entityId, sourceRef, flat, tx, diagBuffer);
2216
+ return await this.appendPartialFacts(entityId, sourceRef, flat, tx, diagBuffer, groundingLedger);
2084
2217
  });
2085
2218
  insertedFacts.push(...partialResult.insertedDescriptors);
2086
2219
  }
@@ -2222,7 +2355,8 @@ var IngestionService = class {
2222
2355
  last_accessed_at: null,
2223
2356
  access_count: 0,
2224
2357
  deleted_at: null,
2225
- okf_type: normalized.okf_type
2358
+ okf_type: normalized.okf_type,
2359
+ ...opts?.nodeTrust?.get(node.id)
2226
2360
  };
2227
2361
  wikiFacts.push(wikiFact);
2228
2362
  }
@@ -2313,9 +2447,10 @@ var IngestionService = class {
2313
2447
  * On the partial path the caller never invokes this method; the empty
2314
2448
  * arrays stay empty.
2315
2449
  */
2316
- async runFullUpsertGraph(entityId, sourceRef, sourceHash, orderedChunkFacts, tx, diagBuffer) {
2450
+ async runFullUpsertGraph(entityId, sourceRef, sourceHash, orderedChunkFacts, tx, diagBuffer, groundingLedger = /* @__PURE__ */ new Map()) {
2317
2451
  const deletedSourceFactIds = [];
2318
2452
  const insertedFacts = [];
2453
+ const nodeTrust = /* @__PURE__ */ new Map();
2319
2454
  deletedSourceFactIds.push(...await this.entryRepo.findIdsBySource(entityId, sourceRef, null, tx, false));
2320
2455
  const titleIndex = /* @__PURE__ */ new Map();
2321
2456
  const existingFacts = await this.entryRepo.findRecentByEntityId(entityId, 500, tx, sourceRef);
@@ -2359,6 +2494,20 @@ var IngestionService = class {
2359
2494
  tags: fact.tags,
2360
2495
  confidence: fact.confidence
2361
2496
  });
2497
+ const grounding = groundingLedger.get(fact);
2498
+ if (grounding) {
2499
+ const outcome = groundingOutcome(grounding.verdict, now);
2500
+ nodeTrust.set(id, outcome.trust);
2501
+ if (outcome.diagnostic) {
2502
+ diagBuffer.push({
2503
+ entityId,
2504
+ operation: "ingest",
2505
+ trigger: "call",
2506
+ code: outcome.diagnostic.code,
2507
+ detail: { factId: id, sourceRef, chunkIndex: grounding.chunkIndex, itemIndex: grounding.itemIndex, reason: outcome.diagnostic.reason }
2508
+ });
2509
+ }
2510
+ }
2362
2511
  insertedFacts.push({ id, entity_id: entityId, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
2363
2512
  titleIndex.set(normalizeTitleKey(fact.title), { id, okf_type: normalized.okf_type });
2364
2513
  if (normalized.edges.length > 0) {
@@ -2390,7 +2539,7 @@ var IngestionService = class {
2390
2539
  entityId,
2391
2540
  { sourceRef, sourceHash, nodes: hostNodes, edges: hostEdges },
2392
2541
  tx,
2393
- { strict: false, diag: { buffer: diagBuffer, operation: "ingest" } }
2542
+ { strict: false, diag: { buffer: diagBuffer, operation: "ingest" }, ...nodeTrust.size > 0 ? { nodeTrust } : {} }
2394
2543
  );
2395
2544
  return { deletedSourceFactIds, insertedFacts };
2396
2545
  }
@@ -2414,7 +2563,7 @@ var IngestionService = class {
2414
2563
  * Runs INSIDE the caller's `tx`. Does not open a nested transaction.
2415
2564
  * Returns `{ inserted, skippedDuplicate }` for observability.
2416
2565
  */
2417
- async appendPartialFacts(entityId, sourceRef, dedupedFacts, tx, diagBuffer) {
2566
+ async appendPartialFacts(entityId, sourceRef, dedupedFacts, tx, diagBuffer, groundingLedger = /* @__PURE__ */ new Map()) {
2418
2567
  const liveIds = await this.entryRepo.findIdsBySource(entityId, sourceRef, null, tx, false);
2419
2568
  const liveFacts = liveIds.length === 0 ? [] : await this.entryRepo.findByIds(liveIds, void 0, tx);
2420
2569
  const liveTitles = new Set(liveFacts.map((f) => normalizeTitleKey(f.title)));
@@ -2437,6 +2586,17 @@ var IngestionService = class {
2437
2586
  }
2438
2587
  liveTitles.add(normalizedTitle);
2439
2588
  const id = generateId("fact_");
2589
+ const grounding = groundingLedger.get(fact);
2590
+ const outcome = grounding ? groundingOutcome(grounding.verdict, now) : null;
2591
+ if (grounding && outcome?.diagnostic) {
2592
+ diagBuffer.push({
2593
+ entityId,
2594
+ operation: "ingest",
2595
+ trigger: "call",
2596
+ code: outcome.diagnostic.code,
2597
+ detail: { factId: id, sourceRef, chunkIndex: grounding.chunkIndex, itemIndex: grounding.itemIndex, reason: outcome.diagnostic.reason }
2598
+ });
2599
+ }
2440
2600
  const wikiFact = {
2441
2601
  id,
2442
2602
  entity_id: entityId,
@@ -2455,7 +2615,8 @@ var IngestionService = class {
2455
2615
  last_accessed_at: null,
2456
2616
  access_count: 0,
2457
2617
  deleted_at: null,
2458
- okf_type: null
2618
+ okf_type: null,
2619
+ ...outcome?.trust
2459
2620
  };
2460
2621
  await this.entryRepo.upsert(wikiFact, tx);
2461
2622
  insertedDescriptors.push({
@@ -2809,7 +2970,7 @@ async function runBatched(args) {
2809
2970
  }
2810
2971
  let result;
2811
2972
  try {
2812
- result = parse(responseText, batch);
2973
+ result = parse(responseText, batch, prompts);
2813
2974
  } catch (err) {
2814
2975
  await onFailure(batch, err, attemptLevel, false);
2815
2976
  return;
@@ -2890,7 +3051,7 @@ var MaintenanceService = class {
2890
3051
  this.jobManager = jobManager;
2891
3052
  this.embeddingService = embeddingService;
2892
3053
  this.ontologyService = ontologyService;
2893
- this.promptService = promptService ?? new PromptService(this.options.config?.prompts);
3054
+ this.promptService = promptService ?? new PromptService(this.options.config?.prompts, resolveGrounding(this.options.config?.grounding));
2894
3055
  }
2895
3056
  async runPrune(entityId, options) {
2896
3057
  this.jobManager.acquireLock("prune", entityId);
@@ -3195,8 +3356,10 @@ var MaintenanceService = class {
3195
3356
  };
3196
3357
  });
3197
3358
  const ontologyContext = await this.ontologyService?.buildPromptContext(entityId) ?? null;
3198
- const { systemPrompt, userPrompt } = this.promptService.buildLibrarianPrompt(
3199
- events.reverse(),
3359
+ const promptEvents = events.reverse();
3360
+ const librarianGrounding = this.promptService.groundingFor("librarian");
3361
+ const { systemPrompt, userPrompt, groundingCorpus: librarianCorpus = [] } = this.promptService.buildLibrarianPrompt(
3362
+ promptEvents,
3200
3363
  currentFacts,
3201
3364
  promptOverride,
3202
3365
  ontologyContext
@@ -3265,6 +3428,10 @@ var MaintenanceService = class {
3265
3428
  const validationDrops = [];
3266
3429
  const normalized = this.ontologyService?.validateAndNormalizeFact(ontologyFact, manifest, { strict: false, drops: validationDrops }) ?? { okf_type: null, edges: [] };
3267
3430
  for (const drop of validationDrops) diagBuffer.push(edgeDropDiagnostic(drop, { ...diagBase, factId: id }));
3431
+ const grounding = librarianGrounding ? groundingOutcome(checkGrounding(fact.evidence, librarianCorpus, librarianGrounding), now) : null;
3432
+ if (grounding?.diagnostic) {
3433
+ diagBuffer.push({ ...diagBase, code: grounding.diagnostic.code, detail: { factId: id, itemIndex: validFactItemIndexes[k], reason: grounding.diagnostic.reason } });
3434
+ }
3268
3435
  const factObj = {
3269
3436
  id,
3270
3437
  entity_id: entityId,
@@ -3280,7 +3447,8 @@ var MaintenanceService = class {
3280
3447
  last_accessed_at: null,
3281
3448
  access_count: 0,
3282
3449
  deleted_at: null,
3283
- okf_type: normalized.okf_type
3450
+ okf_type: normalized.okf_type,
3451
+ ...grounding?.trust
3284
3452
  };
3285
3453
  await this.entryRepo.upsert(factObj, tx);
3286
3454
  insertedFacts.push({ id, entity_id: entityId, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
@@ -3396,11 +3564,14 @@ var MaintenanceService = class {
3396
3564
  }
3397
3565
  const allTasks = await this.taskRepo.findAllPending([entityId], HEAL_MAX_TASKS);
3398
3566
  const recentEvents = await this.eventRepo.getRecent(entityId, 20);
3567
+ const healGrounding = this.promptService.groundingFor("heal");
3399
3568
  const toPromptShape = (f) => {
3400
3569
  const { embedding: _embedding, embedding_blob: _blob, ...rest } = f;
3401
- return { ...rest, tags: typeof rest.tags === "string" ? JSON.parse(rest.tags) : rest.tags };
3570
+ const shown = healGrounding ? (({ lifecycle_status: _l, okf_verified: _v, last_verified_at: _a, last_verified_by: _b, ...r }) => r)(rest) : rest;
3571
+ return { ...shown, tags: typeof shown.tags === "string" ? JSON.parse(shown.tags) : shown.tags };
3402
3572
  };
3403
3573
  const anchorCache = /* @__PURE__ */ new Map();
3574
+ const corpusByPrompt = /* @__PURE__ */ new WeakMap();
3404
3575
  const degraded = [];
3405
3576
  const outcome = await runBatched({
3406
3577
  items: healCandidates,
@@ -3413,9 +3584,10 @@ var MaintenanceService = class {
3413
3584
  // not overfetch the same 200 keyword hits a 25-fact batch once did.
3414
3585
  // buildHealPrompt applies the matching cap on its side — values must match.
3415
3586
  Math.min(HEAL_MAX_ANCHORS, batch.length * HEAL_ANCHORS_PER_CANDIDATE),
3416
- anchorCache
3587
+ anchorCache,
3588
+ healGrounding !== null
3417
3589
  );
3418
- const { prompts, degraded: batchDegraded } = await this.promptService.buildHealPrompt(
3590
+ const { prompts, degraded: batchDegraded, groundingCorpus } = await this.promptService.buildHealPrompt(
3419
3591
  batch.map(toPromptShape),
3420
3592
  documentAnchors,
3421
3593
  allTasks,
@@ -3425,16 +3597,18 @@ var MaintenanceService = class {
3425
3597
  bodyTruncationChars
3426
3598
  );
3427
3599
  degraded.push(...batchDegraded);
3600
+ if (groundingCorpus !== void 0) corpusByPrompt.set(prompts, groundingCorpus);
3428
3601
  return prompts;
3429
3602
  },
3430
3603
  call: (prompts) => this.options.llmProvider.generateText(prompts),
3431
- parse: (responseText, batch) => {
3604
+ parse: (responseText, batch, prompts) => {
3432
3605
  const result = parseJsonResponse(responseText);
3433
3606
  return {
3434
3607
  batch,
3435
3608
  downgraded: Array.isArray(result.downgraded) ? result.downgraded : [],
3436
3609
  deleted: Array.isArray(result.deleted) ? result.deleted : [],
3437
- newFacts: Array.isArray(result.newFacts) ? result.newFacts : []
3610
+ newFacts: Array.isArray(result.newFacts) ? result.newFacts : [],
3611
+ corpus: corpusByPrompt.get(prompts) ?? null
3438
3612
  };
3439
3613
  },
3440
3614
  maxOutputTokens: this.options.llmProvider.maxOutputTokens,
@@ -3449,6 +3623,7 @@ var MaintenanceService = class {
3449
3623
  const safeDeletedSet = /* @__PURE__ */ new Set();
3450
3624
  const newFacts = [];
3451
3625
  const newFactItemIndexes = [];
3626
+ const newFactCorpora = [];
3452
3627
  for (const batchResult of outcome.results) {
3453
3628
  const mutableIds = new Set(batchResult.batch.map((f) => f.id));
3454
3629
  for (const id of batchResult.downgraded) if (mutableIds.has(id)) safeDowngradedSet.add(id);
@@ -3456,6 +3631,7 @@ var MaintenanceService = class {
3456
3631
  batchResult.newFacts.forEach((raw, itemIndex) => {
3457
3632
  newFacts.push(raw);
3458
3633
  newFactItemIndexes.push(itemIndex);
3634
+ newFactCorpora.push(batchResult.corpus);
3459
3635
  });
3460
3636
  }
3461
3637
  const safeDowngraded = Array.from(safeDowngradedSet);
@@ -3463,12 +3639,14 @@ var MaintenanceService = class {
3463
3639
  const diagBuffer = new DiagnosticBuffer();
3464
3640
  const validNewFacts = [];
3465
3641
  const validNewFactItemIndexes = [];
3642
+ const validNewFactCorpora = [];
3466
3643
  newFacts.forEach((raw, k) => {
3467
3644
  const itemIndex = newFactItemIndexes[k];
3468
3645
  const valid = validateFact(raw);
3469
3646
  if (valid) {
3470
3647
  validNewFacts.push(valid);
3471
3648
  validNewFactItemIndexes.push(itemIndex);
3649
+ validNewFactCorpora.push(newFactCorpora[k]);
3472
3650
  } else {
3473
3651
  diagBuffer.push({ ...diagBase, code: "fact_rejected", detail: { itemIndex, reason: factRejectionReason(raw) } });
3474
3652
  }
@@ -3498,6 +3676,10 @@ var MaintenanceService = class {
3498
3676
  continue;
3499
3677
  }
3500
3678
  const id = generateId("fact_");
3679
+ const grounding = healGrounding ? groundingOutcome(checkGrounding(fact.evidence, validNewFactCorpora[k] ?? [], healGrounding), now) : null;
3680
+ if (grounding?.diagnostic) {
3681
+ diagBuffer.push({ ...diagBase, code: grounding.diagnostic.code, detail: { factId: id, itemIndex: validNewFactItemIndexes[k], reason: grounding.diagnostic.reason } });
3682
+ }
3501
3683
  const factObj = {
3502
3684
  id,
3503
3685
  entity_id: entityId,
@@ -3512,7 +3694,8 @@ var MaintenanceService = class {
3512
3694
  updated_at: now,
3513
3695
  last_accessed_at: null,
3514
3696
  access_count: 0,
3515
- deleted_at: null
3697
+ deleted_at: null,
3698
+ ...grounding?.trust
3516
3699
  };
3517
3700
  await this.entryRepo.upsert(factObj, tx);
3518
3701
  insertedFacts.push({ id, entity_id: entityId, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
@@ -3876,7 +4059,7 @@ var MaintenanceService = class {
3876
4059
  * batches that reduce to the same query share one lookup. Caller-owned and
3877
4060
  * per-pass — see the call site in doRunHeal.
3878
4061
  */
3879
- async _selectHealAnchors(entityId, batch, cap = HEAL_MAX_ANCHORS, cache) {
4062
+ async _selectHealAnchors(entityId, batch, cap = HEAL_MAX_ANCHORS, cache, withBodies = false) {
3880
4063
  const query = batch.map((f) => f.title).join(" ").trim();
3881
4064
  if (!query) return [];
3882
4065
  const cached = cache?.get(query);
@@ -3889,7 +4072,7 @@ var MaintenanceService = class {
3889
4072
  const hitIds = hits.map((h) => h.id);
3890
4073
  const anchors = [];
3891
4074
  if (hitIds.length > 0) {
3892
- const rows = await this.entryRepo.findAnchorRowsByIds(entityId, hitIds);
4075
+ const rows = withBodies ? await this.entryRepo.findAnchorRowsByIds(entityId, hitIds, void 0, { withBody: true }) : await this.entryRepo.findAnchorRowsByIds(entityId, hitIds);
3893
4076
  const byId = new Map(rows.map((r) => [r.id, r]));
3894
4077
  for (const id of hitIds) {
3895
4078
  const row = byId.get(id);
@@ -5423,6 +5606,6 @@ var WriteService = class {
5423
5606
  }
5424
5607
  };
5425
5608
 
5426
- export { BaseRepository, DEFAULT_CHUNK_OVERLAP, DEFAULT_MAX_CHUNK_LENGTH, DEFAULT_MAX_EMBED_CHARS, DiagnosticBuffer, EMBED_CHARS_CEILING, EmbeddingService, HEAL_ANCHORS_PER_CANDIDATE, HEAL_BATCH_SIZE, HEAL_MAX_FACT_BODY_CHARS_L3, HEAL_MAX_TASKS, HEAL_RECHECK_MS, 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, WikiDraftNotFound, WikiDuplicateHashError, WikiGraphNodeOwnershipConflict, WikiIngestEmptyError, WikiInvalidReadOptions, WikiParseError, WikiSourceRefHashCollision, WikiStrictOntologyViolation, WikiTransactionError, WriteService, __privateAdd, __privateGet, __privateSet, chunkText, configureRandomSource, emptyManifest, entitySummaryMetaKey, extractSqliteCode, generateId, normalizeSourceHash, normalizeSourceRef, normalizeTitleKey, parseEmbedding, resolveEdgeDefinitions, resolveNodeType, safeSlice, typeSatisfies, validateInlineEdges, validateManifest };
5427
- //# sourceMappingURL=chunk-3P7FAKJA.mjs.map
5428
- //# sourceMappingURL=chunk-3P7FAKJA.mjs.map
5609
+ export { BaseRepository, DEFAULT_CHUNK_OVERLAP, DEFAULT_MAX_CHUNK_LENGTH, DEFAULT_MAX_EMBED_CHARS, DiagnosticBuffer, EMBED_CHARS_CEILING, EmbeddingService, HEAL_ANCHORS_PER_CANDIDATE, HEAL_BATCH_SIZE, HEAL_MAX_FACT_BODY_CHARS_L3, HEAL_MAX_TASKS, HEAL_RECHECK_MS, 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, WikiDraftNotFound, WikiDuplicateHashError, WikiGraphNodeOwnershipConflict, WikiIngestEmptyError, WikiInvalidReadOptions, WikiParseError, WikiSourceRefHashCollision, WikiStrictOntologyViolation, WikiTransactionError, WriteService, __privateAdd, __privateGet, __privateSet, chunkText, configureRandomSource, emptyManifest, entitySummaryMetaKey, extractSqliteCode, generateId, normalizeSourceHash, normalizeSourceRef, normalizeTitleKey, parseEmbedding, resolveEdgeDefinitions, resolveGrounding, resolveNodeType, safeSlice, typeSatisfies, validateInlineEdges, validateManifest };
5610
+ //# sourceMappingURL=chunk-YBWE26XR.mjs.map
5611
+ //# sourceMappingURL=chunk-YBWE26XR.mjs.map