@polycode-projects/the-mechanical-code-talker 6.0.13 → 6.0.15

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.
@@ -240,6 +240,19 @@ const ENTITY_FRAGMENT_LEAD_WORDS = new Set([
240
240
  // term; "back" alone is a fine noun.
241
241
  const ENTITY_PARTICLE_LEAD_WORDS = new Set(["back", "up", "down", "out", "off", "away", "along", "around"]);
242
242
 
243
+ // A pronoun points back at whatever the last clause named, so a multi-word
244
+ // term opening with one is a clause the split lost the subject of. A term
245
+ // ending in a bare auxiliary is the front half of one. Both mirror
246
+ // extract-facts.mjs's own lexical rules, for the same reason the sets above do.
247
+ const ENTITY_PRONOUN_LEAD_WORDS = new Set([
248
+ "i", "he", "she", "it", "we", "they", "you", "me", "him", "them", "us",
249
+ "his", "her", "its", "their", "our", "your", "my",
250
+ ]);
251
+ const ENTITY_CLITIC_SUFFIX_RE = /['’](?:s|re|ve|ll|d|m)$/;
252
+ const ENTITY_TRAILING_AUXILIARY_WORDS = new Set([
253
+ "is", "are", "was", "were", "be", "been", "being", "am", "has", "have", "had",
254
+ ]);
255
+
243
256
  /** Does `term` read as a thing's name rather than a clause fragment? Bounds
244
257
  * the word count and rejects a leading conjunction, auxiliary or
245
258
  * preposition (test E's condition 3, PLAN_NEWSWORTHINESS.md section 2), plus
@@ -251,7 +264,10 @@ function looksLikeEntityTerm(term) {
251
264
  if (words.length > ENTITY_TERM_MAX_WORDS) return false;
252
265
  const first = words[0].toLowerCase().replace(/^[^a-z0-9]+/, "");
253
266
  if (!first || ENTITY_FRAGMENT_LEAD_WORDS.has(first)) return false;
254
- if (words.length > 1 && ENTITY_PARTICLE_LEAD_WORDS.has(first)) return false;
267
+ if (words.length === 1) return true;
268
+ if (ENTITY_PARTICLE_LEAD_WORDS.has(first)) return false;
269
+ if (ENTITY_PRONOUN_LEAD_WORDS.has(first.replace(ENTITY_CLITIC_SUFFIX_RE, ""))) return false;
270
+ if (ENTITY_TRAILING_AUXILIARY_WORDS.has(words[words.length - 1].toLowerCase())) return false;
255
271
  return true;
256
272
  }
257
273
 
@@ -425,21 +441,29 @@ export function buildTermAdjacency(rows) {
425
441
  }
426
442
 
427
443
  /** Breadth-first over subject/object adjacency from `hub`, exactly `hops`
428
- * levels deep, then capped by content-addressed id so the cap never
429
- * depends on `rows`' own order, only on which rows the hop-bounded walk
430
- * actually reaches. */
431
- export function subgraphAround(rows, hub, { hops = NEWS_HUB_HOPS, cap = 60, adjacency = null } = {}) {
444
+ * levels deep, then capped: a `priorityIds` row first, then the nearer hop,
445
+ * then content-addressed id. The cap never depends on `rows`' own order, only
446
+ * on which rows the hop-bounded walk actually reaches and how far out each
447
+ * one sits.
448
+ *
449
+ * `priorityIds` is what keeps a card about a term the graph already knows
450
+ * thousands of things about from being built out of an arbitrary slice of
451
+ * them: a hub like "france" reaches far more rows than the cap, and the one
452
+ * report that made it news would otherwise be the row that fell out. */
453
+ export function subgraphAround(rows, hub, { hops = NEWS_HUB_HOPS, cap = 60, adjacency = null, priorityIds = null } = {}) {
432
454
  const adj = adjacency ?? buildTermAdjacency(rows);
433
455
  const hubTerm = normFactTerm(hub);
434
456
  const visited = new Set([hubTerm]);
435
457
  let frontier = [hubTerm];
436
458
  const collected = new Map();
459
+ const hopOf = new Map();
437
460
  for (let hop = 0; hop < hops; hop += 1) {
438
461
  const nextFrontier = new Set();
439
462
  for (const term of [...frontier].sort()) {
440
463
  for (const idx of adj.byTerm.get(term) ?? []) {
441
464
  const row = rows[idx];
442
465
  collected.set(row.id, row);
466
+ if (!hopOf.has(row.id)) hopOf.set(row.id, hop);
443
467
  const [s, o] = adj.terms[idx];
444
468
  if (!visited.has(s)) nextFrontier.add(s);
445
469
  if (!visited.has(o)) nextFrontier.add(o);
@@ -448,7 +472,12 @@ export function subgraphAround(rows, hub, { hops = NEWS_HUB_HOPS, cap = 60, adja
448
472
  for (const term of nextFrontier) visited.add(term);
449
473
  frontier = [...nextFrontier].sort();
450
474
  }
451
- return [...collected.values()].sort(byId).slice(0, cap);
475
+ const isPriority = (id) => (priorityIds instanceof Set ? priorityIds.has(id) : Boolean(priorityIds?.includes?.(id)));
476
+ return [...collected.values()]
477
+ .sort((a, b) => (isPriority(b.id) - isPriority(a.id))
478
+ || (hopOf.get(a.id) - hopOf.get(b.id))
479
+ || byId(a, b))
480
+ .slice(0, cap);
452
481
  }
453
482
 
454
483
  /** The strongest prior kind among `rows`, for the item's trust chip — read
@@ -487,6 +516,28 @@ function joinWithAnd(items) {
487
516
 
488
517
  const IDENTITY_PREDICATES = new Set(["rdf:type", "rdfs:subClassOf"]);
489
518
  const SENTENCE_CAP = 5;
519
+ // How many objects one sentence names before it counts the rest. A live source
520
+ // reports the same relation over and over inside one window — every quake of
521
+ // the day strikes near somewhere — and an unbounded list turns a card into a
522
+ // wall of text.
523
+ const OBJECTS_PER_SENTENCE = 6;
524
+
525
+ function joinObjects(objects) {
526
+ if (objects.length <= OBJECTS_PER_SENTENCE) return joinWithAnd(objects);
527
+ const shown = objects.slice(0, OBJECTS_PER_SENTENCE);
528
+ return `${shown.join(", ")} and ${objects.length - OBJECTS_PER_SENTENCE} more`;
529
+ }
530
+
531
+ /** The predicates `rows` carry, curated-table order first and then whatever
532
+ * is left, sorted. A relation minted from a source's own verb ("mgx:hit",
533
+ * "mgx:strike-near") has no curated entry, and reading the table alone left
534
+ * every card built from live headlines with an empty paragraph. */
535
+ function predicatesInRenderOrder(rows) {
536
+ const present = new Set(rows.map((r) => r.predicate));
537
+ const curated = Object.keys(FACT_PREDICATE_PHRASES).filter((predicate) => present.has(predicate));
538
+ const rest = [...present].filter((predicate) => !Object.hasOwn(FACT_PREDICATE_PHRASES, predicate)).sort();
539
+ return [...curated, ...rest];
540
+ }
490
541
 
491
542
  /** The fixed five-sentence paraphrase template (PLAN_NEWS_FEED.md section
492
543
  * 8.3): identity first, then the hub's own relations grouped by predicate in
@@ -519,17 +570,30 @@ export function renderNewsParagraph(hub, subgraphRows, { reportedIds = null } =
519
570
  .sort();
520
571
  if (identityObjects.length) {
521
572
  const withArticles = identityObjects.map((object) => `${articleFor(object)} ${object}`);
522
- sentences.push(`${hub} is ${joinWithAnd(withArticles)}`);
573
+ sentences.push(`${hub} is ${joinObjects(withArticles)}`);
523
574
  }
524
575
 
525
- for (const predicate of Object.keys(FACT_PREDICATE_PHRASES)) {
576
+ for (const predicate of predicatesInRenderOrder(reportedHubRows)) {
526
577
  if (IDENTITY_PREDICATES.has(predicate) || sentences.length >= SENTENCE_CAP) continue;
527
578
  const objects = reportedHubRows
528
579
  .filter((r) => r.predicate === predicate)
529
580
  .map((r) => r.object)
530
581
  .sort();
531
582
  if (!objects.length) continue;
532
- sentences.push(`${hub} ${predicatePhrase(predicate)} ${joinWithAnd(objects)}`);
583
+ sentences.push(`${hub} ${predicatePhrase(predicate)} ${joinObjects(objects)}`);
584
+ }
585
+
586
+ // A hub that only ever appears as an OBJECT — the place a quake struck, the
587
+ // story a site discussed — has no subject-side row to build a sentence from,
588
+ // and its card came out blank. What was reported about it still says
589
+ // something, so those rows render whole, subject and all.
590
+ if (!sentences.length) {
591
+ const aboutHub = subgraphRows
592
+ .filter((r) => normFactTerm(r.object) === hubTerm && normFactTerm(r.subject) !== hubTerm && isReported(r.id))
593
+ .sort(byId)
594
+ .slice(0, OBJECTS_PER_SENTENCE)
595
+ .map((r) => factSentence(r));
596
+ if (aboutHub.length) sentences.push(aboutHub.join("; "));
533
597
  }
534
598
 
535
599
  if (sentences.length < SENTENCE_CAP && secondHopRows.length) {
@@ -564,7 +628,7 @@ export function buildNewsItems(rows, { now, windowMs, limit = 6, sourcesByFactId
564
628
  if (readsAsEntityTerm) hubOptions.readsAsEntityTerm = readsAsEntityTerm;
565
629
  const hubs = newsworthyHubs(rows, reported, hubOptions);
566
630
  const items = hubs.map(({ term, changed }) => {
567
- const subgraphRows = subgraphAround(rows, term, { adjacency });
631
+ const subgraphRows = subgraphAround(rows, term, { adjacency, priorityIds: reportedIds });
568
632
  const factIds = subgraphRows.map((r) => r.id).sort();
569
633
  const { background } = splitCardRows(subgraphRows, reportedIds);
570
634
  return {
@@ -8,6 +8,33 @@ import { normFactTerm } from "./hash.mjs";
8
8
 
9
9
  const ITEM_IDS_CAP = 12;
10
10
 
11
+ // A preposition, conjunction or degree/frequency adverb scaffolds a
12
+ // sentence; it never names the thing the sentence is about, so no
13
+ // occurrence count makes one a useful ledger term. Closed set, checked
14
+ // against the already-normalized (lowercased) term. bumpTerms uses this to
15
+ // keep a function word from ever being admitted; ledgerFromPayload uses it
16
+ // to drop one that reached a persisted payload before this filter existed.
17
+ const FUNCTION_WORD_TERMS = new Set([
18
+ // prepositions
19
+ "about", "above", "across", "after", "against", "along", "among", "around",
20
+ "at", "before", "behind", "below", "beneath", "beside", "between", "beyond",
21
+ "by", "despite", "down", "during", "except", "for", "from", "in", "into",
22
+ "near", "of", "off", "on", "onto", "out", "over", "since", "through",
23
+ "throughout", "to", "toward", "towards", "under", "underneath", "until",
24
+ "up", "upon", "with", "within", "without",
25
+ // conjunctions
26
+ "and", "or", "nor", "but", "so", "yet", "although", "because", "if",
27
+ "though", "unless", "when", "whenever", "whereas", "while", "than",
28
+ // degree and frequency adverbs
29
+ "very", "quite", "rather", "too", "just", "only", "even", "also", "still",
30
+ "already", "almost", "always", "never", "ever", "often", "sometimes",
31
+ "usually", "indeed", "however", "therefore", "thus", "hence", "meanwhile",
32
+ ]);
33
+
34
+ function isFunctionWordTerm(term) {
35
+ return FUNCTION_WORD_TERMS.has(term);
36
+ }
37
+
11
38
  /** Ledger entry field order fixed once here so `ledgerPayload` serializes
12
39
  * byte-identically across peers regardless of insertion order elsewhere. */
13
40
  function newEntry(term, vocabGrounded, now) {
@@ -35,7 +62,7 @@ export function createTermLedger() {
35
62
  export function bumpTerms(ledger, termCounts, itemId, now, vocabGroundedByTerm = new Map()) {
36
63
  for (const [rawTerm, occurrences] of termCounts) {
37
64
  const term = normFactTerm(rawTerm);
38
- if (!term) continue;
65
+ if (!term || isFunctionWordTerm(term)) continue;
39
66
  let entry = ledger.terms.get(term);
40
67
  if (!entry) {
41
68
  const vocabGrounded = vocabGroundedByTerm.has(rawTerm)
@@ -112,6 +139,7 @@ export function ledgerPayload(ledger) {
112
139
  export function ledgerFromPayload(payload) {
113
140
  const ledger = createTermLedger();
114
141
  for (const entry of payload?.terms ?? []) {
142
+ if (isFunctionWordTerm(entry.term)) continue;
115
143
  ledger.terms.set(entry.term, { ...entry, itemIds: [...(entry.itemIds ?? [])] });
116
144
  }
117
145
  return ledger;
@@ -18487,14 +18487,15 @@ async function factRowSnapshot(memoryDir) {
18487
18487
  try { return readStoredFactRows(await loadMemoryStore(memoryDir)); } catch { return null; }
18488
18488
  }
18489
18489
 
18490
- /** The Fact rows this turn wrote, diffed against the snapshot taken before it.
18491
- * Empty when the turn had no store to write to, or wrote nothing. */
18490
+ /** The Fact rows this turn wrote, diffed against the snapshot taken before it,
18491
+ * with the after-snapshot handed back beside them. Empty when the turn had no
18492
+ * store to write to, or wrote nothing. */
18492
18493
  async function factsTouchedSince(memoryDir, before) {
18493
- if (!before) return [];
18494
+ if (!before) return { factsTouched: [], factRowsAfter: null };
18494
18495
  const after = await factRowSnapshot(memoryDir);
18495
- if (!after) return [];
18496
+ if (!after) return { factsTouched: [], factRowsAfter: null };
18496
18497
  const { touchedFactRows } = await import("../domain/memory/touched-facts.mjs");
18497
- return touchedFactRows(before, after);
18498
+ return { factsTouched: touchedFactRows(before, after), factRowsAfter: after };
18498
18499
  }
18499
18500
 
18500
18501
  /** A whole-line miss whose only problem is a closed filler clause in front of a
@@ -18519,12 +18520,19 @@ async function answerWithoutFillerPrefix(input, options, missed) {
18519
18520
  }
18520
18521
 
18521
18522
  /** Run one turn and report which Fact rows it wrote, as `factsTouched` beside
18522
- * the answer/record/logLines every caller already reads. The dispatch itself
18523
- * is dispatchTurn, below; this wrapper exists so the field lands on EVERY
18524
- * return path (dispatched, conversational, multi-sentence) from one place. */
18523
+ * the answer/record/logLines every caller already reads, with the after-fold
18524
+ * the diff was taken against as `factRowsAfter`. The dispatch itself is
18525
+ * dispatchTurn, below; this wrapper exists so the field lands on EVERY return
18526
+ * path (dispatched, conversational, multi-sentence) from one place.
18527
+ *
18528
+ * `options.factRowsBefore` is the caller's own already-folded view of the
18529
+ * store, standing in for the before-snapshot. A caller running many turns over
18530
+ * one document folds once and threads it; folding a seed-sized graph again per
18531
+ * turn, to read back the rows the caller just handed over, is the most
18532
+ * expensive thing an ingest does. */
18525
18533
  export async function runTurn(input, options = {}) {
18526
18534
  const memoryDir = options?.memoryDir ?? null;
18527
- const before = await factRowSnapshot(memoryDir);
18535
+ const before = options?.factRowsBefore || await factRowSnapshot(memoryDir);
18528
18536
  let result;
18529
18537
  try {
18530
18538
  result = await dispatchTurn(input, options);
@@ -18541,7 +18549,7 @@ export async function runTurn(input, options = {}) {
18541
18549
  });
18542
18550
  }
18543
18551
  if (!result || typeof result !== "object") return result;
18544
- return { ...result, factsTouched: await factsTouchedSince(memoryDir, before) };
18552
+ return { ...result, ...(await factsTouchedSince(memoryDir, before)) };
18545
18553
  }
18546
18554
 
18547
18555
  async function dispatchTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, liveReference = false, researchSource = null, onLiveLookup = null, vocabHint = null, tel = null, biasByBundle = {}, factRowsCache: injectedFactRowsCache = null, planState = null, gameConfig = null, uiContext = "cli", synthesisBudget = AUTO_SYNTHESIS_BUDGET, researchState = null, researchConfig = null, newsState = null, newsConfig = null, newsProviders = null, discourse = null, _noSplit = false, actingSubject = "player", codeDomainActive = null, laneVocab = null, domainPacks = null, retrieval = null } = {}) {
@@ -66,6 +66,7 @@ import { splitSentencesPreservingPaths, stripCitationResidue } from "./sentences
66
66
  import { loadMemory, readFactRows, appendFacts, removeFacts } from "../adapters/memory/core.mjs";
67
67
  import { loadConfig } from "../adapters/config.mjs";
68
68
  import { touchedFactRows } from "../domain/memory/touched-facts.mjs";
69
+ import { INGEST_SESSION_MARKER } from "../domain/memory/trust.mjs";
69
70
  import { normFactTerm } from "../domain/hash.mjs";
70
71
  import { splitIdentifierWords } from "../domain/prose.mjs";
71
72
  import { winkInstance } from "../adapters/wink-model.mjs";
@@ -104,18 +105,22 @@ export function parseArgs(argv) {
104
105
  * on a browser-sized graph — so the caller threads one fold from sentence to
105
106
  * sentence instead of paying a fresh one per candidate.
106
107
  */
107
- async function runSentence(sentence, { config, memoryDir, env, beforeRows }) {
108
+ async function runSentence(sentence, { config, memoryDir, env, beforeRows, sessionId = "" }) {
108
109
  const before = beforeRows || readFactRows(await loadMemory(memoryDir));
109
110
  if (ingestYield) await ingestYield();
110
- const { record, answer } = await runTurn(sentence, { config, memoryDir, sessionId: uuidv7(), env });
111
+ // The turn takes the caller's fold as its own before-view and hands back the
112
+ // after-fold it already had to take, so one sentence costs one fold rather
113
+ // than three of the same graph.
114
+ const { record, answer, factsTouched, factRowsAfter } = await runTurn(sentence, {
115
+ config, memoryDir, sessionId: sessionId || uuidv7(), env, factRowsBefore: before,
116
+ });
117
+ const after = factRowsAfter || before;
111
118
  // Only an assert turn can have written a Fact, so only an assert turn earns
112
- // the post-turn fold; every other turn hands the caller's own view straight
113
- // back untouched.
119
+ // a fresh view; every other turn hands the caller's own straight back.
114
120
  if (record?.via !== "assert") return { recognized: false, rows: [], afterRows: before, decline: String(answer || "") };
115
121
  if (ingestYield) await ingestYield();
116
- const after = readFactRows(await loadMemory(memoryDir));
117
122
  if (record?.miss) return { recognized: false, rows: [], afterRows: after, decline: String(answer || "") };
118
- return { recognized: true, rows: touchedFactRows(before, after), afterRows: after };
123
+ return { recognized: true, rows: factsTouched || touchedFactRows(before, after), afterRows: after };
119
124
  }
120
125
 
121
126
  /** The recognizer's own words for why it turned a sentence down, when it named
@@ -668,6 +673,32 @@ const FRAGMENT_LEAD_TAGS = new Set(["VERB", "AUX", "ADP", "CCONJ", "SCONJ", "PAR
668
673
  // which names nothing. Read lexically so a checkout with no wink model catches
669
674
  // it too, and only for a multi-word term — "back" alone is a fine noun.
670
675
  const PARTICLE_LEAD_WORDS = new Set(["back", "up", "down", "out", "off", "away", "along", "around"]);
676
+ // A pronoun names nothing on its own — it points back at whatever the last
677
+ // clause named — so a multi-word term opening with one is a clause the split
678
+ // lost the subject of ("he's also destroyed the city's soul"), never a name. A
679
+ // one-word term is exempt for the same reason the particle rule exempts one:
680
+ // "us" is also how a headline writes the United States.
681
+ const PRONOUN_LEAD_WORDS = new Set([
682
+ "i", "he", "she", "it", "we", "they", "you", "me", "him", "them", "us",
683
+ "his", "her", "its", "their", "our", "your", "my",
684
+ ]);
685
+ const CLITIC_SUFFIX_RE = /['’](?:s|re|ve|ll|d|m)$/;
686
+ // A term ending in a bare auxiliary is the front half of a clause the split cut
687
+ // ("rooms were"), never the whole of a name.
688
+ const TRAILING_AUXILIARY_WORDS = new Set([
689
+ "is", "are", "was", "were", "be", "been", "being", "am", "has", "have", "had",
690
+ ]);
691
+ // A compass word opening a place name is a modifier, not a clause lead —
692
+ // "north korea", "south sandwich islands". A tagger reading the LOWERCASED
693
+ // term has no capital left to tell the place from the direction and tags
694
+ // "north"/"south" as an adverb, so the POS rule below would turn every one of
695
+ // them down. Followed by "of" the word really is heading a prepositional
696
+ // phrase ("north of the border"), and that stays declined.
697
+ const COMPASS_LEAD_WORDS = new Set([
698
+ "north", "south", "east", "west",
699
+ "northeast", "northwest", "southeast", "southwest",
700
+ "northern", "southern", "eastern", "western",
701
+ ]);
671
702
 
672
703
  /** Does `term` read as a thing's name rather than a clause fragment? Bounds
673
704
  * the word count and rejects a leading conjunction, auxiliary, preposition,
@@ -684,6 +715,9 @@ export function readsAsEntityTerm(term, nlp) {
684
715
  if (FRAGMENT_LEAD_WORDS.has(first)) return false;
685
716
  if (words.length === 1) return true;
686
717
  if (PARTICLE_LEAD_WORDS.has(first)) return false;
718
+ if (PRONOUN_LEAD_WORDS.has(first.replace(CLITIC_SUFFIX_RE, ""))) return false;
719
+ if (TRAILING_AUXILIARY_WORDS.has(words[words.length - 1].toLowerCase())) return false;
720
+ if (COMPASS_LEAD_WORDS.has(first) && words[1].toLowerCase() !== "of") return true;
687
721
  const engine = nlp === undefined ? winkInstance() : nlp;
688
722
  if (!engine) return true;
689
723
  try {
@@ -800,6 +834,13 @@ function canonicalLines(facts, storeRows) {
800
834
  * are the only output.
801
835
  * sourceTag the label the audit provenance carries (extracted:<tag> /
802
836
  * optimistic-extract:<tag>). Default "text".
837
+ * attributeToSource
838
+ * file the recognizer's own assertion under `sourceTag`'s
839
+ * publication instead of a fresh chat session per sentence.
840
+ * Off by default: an operator running `tmct extract` over their
841
+ * own notes IS the asserting party, so that lane keeps minting
842
+ * a session. A feed or a reference work is not, and one
843
+ * publication's sentences must never corroborate each other.
803
844
  * optimistic also run the fuzzy tier over strict-skipped sentences.
804
845
  * canonical include a `canonical` array: one enriched triple line per
805
846
  * ingested fact.
@@ -830,7 +871,12 @@ function canonicalLines(facts, storeRows) {
830
871
  export async function ingestText(text, {
831
872
  memoryDir = null, sourceTag = "text", optimistic = false,
832
873
  canonical = false, config = null, lexicon = null, observedAt = "", findings = false,
874
+ attributeToSource = false,
833
875
  } = {}) {
876
+ // The session id every sentence's recognizer turn runs under. Stable and
877
+ // derived from the publication when the caller attributes to it, so the whole
878
+ // run lands on one Source; a fresh uuid per sentence otherwise (runSentence).
879
+ const recognizerSessionId = attributeToSource ? `${INGEST_SESSION_MARKER}${sourceTag.split("@")[0]}` : "";
834
880
  // Paragraphs first (blank-line separated), so the pronoun carry never bridges
835
881
  // a topic break: a fresh paragraph clears the last-subject it would resolve
836
882
  // "they"/"it" against. Each paragraph then splits into sentences the shared
@@ -900,7 +946,7 @@ export async function ingestText(text, {
900
946
  if (ingestYield) await ingestYield();
901
947
  const knownIds = new Set(currentRows.map((r) => r.id));
902
948
  const { recognized, rows, afterRows, decline } = await runSentence(form, {
903
- config: cfg, memoryDir: dir, env: runEnv, beforeRows: currentRows,
949
+ config: cfg, memoryDir: dir, env: runEnv, beforeRows: currentRows, sessionId: recognizerSessionId,
904
950
  });
905
951
  currentRows = afterRows;
906
952
  if (!recognized) { lastDecline = decline || lastDecline; return null; }
@@ -338,6 +338,7 @@ async function ingestSnapshotFacts(ctx, snapshot) {
338
338
  const sourceTag = `news:${snapshot.sourceId}@${snapshot.id}`;
339
339
  const result = await ingestText(text, {
340
340
  memoryDir, sourceTag, optimistic: true, lexicon: lex, observedAt: nowVal, findings: true,
341
+ attributeToSource: true,
341
342
  });
342
343
  invalidateCache(cache);
343
344
 
@@ -662,6 +663,7 @@ async function ingestResearchArticle(ctx, term, provider, article) {
662
663
  ingested = await ingestText(prose, {
663
664
  memoryDir, sourceTag: provenance, optimistic: true,
664
665
  lexicon: lexicon || loadLexicon(), observedAt: resolveNow(now), findings: true,
666
+ attributeToSource: true,
665
667
  });
666
668
  invalidateCache(ctx.cache);
667
669
  }