@polycode-projects/the-mechanical-code-talker 6.0.12 → 6.0.14

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.
@@ -107,6 +107,60 @@ function parsePeerNodeTagRest(rest) {
107
107
  const LIVE_REFERENCE_PACK = "wikipedia-live";
108
108
  const referenceKindFor = (pack) => (pack === LIVE_REFERENCE_PACK ? "referenceLive" : "reference");
109
109
 
110
+ /** The part of a tag that names WHICH publication it came from, with the
111
+ * per-item tail cut off: `news:nyt-world@item-91` names the NYT world feed,
112
+ * and the item id beside it says which article, not which publisher. One feed
113
+ * is one asserting party however many articles it runs, so the tail stays on
114
+ * the tag for audit and out of the Source identity — the same split
115
+ * `world:<name>:turnN` and `mud:<character>:turnN` already make. */
116
+ const publicationKeyOf = (rest) => String(rest || "").split("@")[0];
117
+
118
+ /** The same cut for a tag the fuzzy tier labels its own Source with: the feed
119
+ * or the reference work, never the article. `news:nyt-world@item-91` folds to
120
+ * `news:nyt-world`, `research:wikidata:otter` to `research:wikidata`, and the
121
+ * research lane's `research:otter@2` to `research`. */
122
+ function publicationSourceLabel(rest) {
123
+ if (rest.startsWith("news:")) return publicationKeyOf(rest);
124
+ const { pack } = researchTagToSource(rest.slice("research:".length));
125
+ return `research:${pack}`;
126
+ }
127
+
128
+ /** `teach:chat:ingest#<tag>@<ts>` — the ingest seam driving the chat teach lane
129
+ * as a RECOGNIZER over a document. The party asserting is the document's own
130
+ * publisher, which the embedded `<tag>` names, so the record lands on that
131
+ * publisher's Source rather than on a chat session. Without it an ingest mints
132
+ * a throwaway teach Source per SENTENCE, and one publication's sentences
133
+ * corroborate each other for free. */
134
+ export const INGEST_SESSION_MARKER = "ingest#";
135
+
136
+ /** The two `research:` tag shapes, both live-fetched at query time and both
137
+ * scored at the referenceLive prior, below every curated pack:
138
+ * research:<source>:<term> one KB adapter's own lookup (researchSourceTag)
139
+ * research:<topic>@<depth> the research lane's fan-out, which records how
140
+ * far it reached rather than which adapter answered
141
+ * The `@` is what tells them apart — a folded term never carries one. Either
142
+ * way the PACK names the reference work and the article segment names the page
143
+ * inside it, so `sourceIdFor` can key one Source per work. */
144
+ function researchTagToSource(rest) {
145
+ const at = rest.lastIndexOf("@");
146
+ if (at >= 0) return { kind: "referenceLive", pack: "research", article: rest.slice(0, at).trim() || "unknown" };
147
+ const colon = rest.indexOf(":");
148
+ if (colon < 0) return { kind: "referenceLive", pack: "research", article: rest.trim() || "unknown" };
149
+ return { kind: "referenceLive", pack: rest.slice(0, colon) || "research", article: rest.slice(colon + 1).trim() };
150
+ }
151
+
152
+ /** The publisher a chat-shaped tag stands in for, when its session slot carries
153
+ * the ingest marker; null for an ordinary session, which is every tag a person
154
+ * actually typed. One level only — an embedded chat tag is refused rather than
155
+ * followed, so a hand-written tag cannot nest its way anywhere. */
156
+ function ingestPublisherOf(chat) {
157
+ if (!chat.sessionId?.startsWith(INGEST_SESSION_MARKER)) return null;
158
+ const embedded = chat.sessionId.slice(INGEST_SESSION_MARKER.length);
159
+ if (embedded.startsWith("teach:") || embedded.startsWith("ace:")) return null;
160
+ const publisher = provenanceTagToSource(embedded);
161
+ return publisher ? { ...publisher, ...(chat.createdAt ? { createdAt: chat.createdAt } : {}) } : null;
162
+ }
163
+
110
164
  export function provenanceTagToSource(tag) {
111
165
  const t = String(tag || "").trim();
112
166
  if (!t) return null;
@@ -119,17 +173,7 @@ export function provenanceTagToSource(tag) {
119
173
  const pack = rest.slice(0, colon) || "unknown";
120
174
  return { kind: referenceKindFor(pack), pack, article: rest.slice(colon + 1) };
121
175
  }
122
- // research:<topic>@<depth> the research lane's Simple English Wikipedia
123
- // loads. Live-fetched at query time like the wikipedia-live pack, so it
124
- // scores at the same referenceLive prior, below every curated pack. Parsed
125
- // from the FULL tag (a topic may contain spaces); the depth segment records
126
- // how far the fan-out reached and is not part of the Source identity.
127
- if (t.startsWith("research:")) {
128
- const rest = t.slice("research:".length);
129
- const at = rest.lastIndexOf("@");
130
- const topic = (at >= 0 ? rest.slice(0, at) : rest).trim();
131
- return { kind: "referenceLive", pack: "research", article: topic || "unknown" };
132
- }
176
+ if (t.startsWith("research:")) return researchTagToSource(t.slice("research:".length));
133
177
  const head = t.split(/\s+/)[0]; // drop trailing " /r/IsA" etc.
134
178
  if (head.startsWith("corpus-weak:")) return { kind: "corpusWeak", name: head.slice("corpus-weak:".length) || "unknown" };
135
179
  if (head.startsWith("corpus:")) return { kind: "corpus", name: head.slice("corpus:".length) || "unknown" };
@@ -148,7 +192,10 @@ export function provenanceTagToSource(tag) {
148
192
  // `mud:` prefix so `src:corpus:mud:<character>` can never collide with a
149
193
  // `world:<name>` Source that happens to share the literal name.
150
194
  if (head.startsWith("mud:")) return { kind: "corpus", name: `mud:${head.slice("mud:".length).split(":")[0] || "unknown"}` };
151
- if (head.startsWith("ace:")) return { kind: "operator", ...parseChatTagRest(head.slice("ace:".length)) };
195
+ if (head.startsWith("ace:")) {
196
+ const chat = parseChatTagRest(head.slice("ace:".length));
197
+ return ingestPublisherOf(chat) || { kind: "operator", ...chat };
198
+ }
152
199
  if (head.startsWith("teach:")) {
153
200
  const rest = head.slice("teach:".length);
154
201
  // teach:peer:<name>#node:<id>@<ts> — a peer's own relabeled tag off the
@@ -156,7 +203,8 @@ export function provenanceTagToSource(tag) {
156
203
  const peerNode = parsePeerNodeTagRest(rest);
157
204
  if (peerNode) return peerNode;
158
205
  // the chat teach lane's natural frames — chat.mjs's teachProvenanceTag
159
- return { kind: "teach", ...parseChatTagRest(rest) };
206
+ const chat = parseChatTagRest(rest);
207
+ return ingestPublisherOf(chat) || { kind: "teach", ...chat };
160
208
  }
161
209
  if (head.startsWith("web:")) return { kind: "web", url: head.slice("web:".length) };
162
210
  if (head.startsWith("url:")) return { kind: "web", url: head.slice("url:".length) };
@@ -172,10 +220,23 @@ export function provenanceTagToSource(tag) {
172
220
  // the bare extracted: fallback below, which still catches every other
173
221
  // extracted: caller unchanged. A bare news:<sourceId>@<itemId> tag (a
174
222
  // future caller that writes it directly, with no extracted: wrapper)
175
- // scores the same way.
176
- if (head.startsWith("extracted:news:")) return { kind: "web", url: head.slice("extracted:".length) };
177
- if (head.startsWith("news:")) return { kind: "web", url: head };
178
- if (head.startsWith("optimistic-extract:")) return { kind: "optimisticExtract", name: head.slice("optimistic-extract:".length) || "unknown" };
223
+ // scores the same way. One Source per FEED: the item id rides the tag for
224
+ // audit and stays out of the identity.
225
+ if (head.startsWith("extracted:news:")) return { kind: "web", url: publicationKeyOf(head.slice("extracted:".length)) };
226
+ if (head.startsWith("news:")) return { kind: "web", url: publicationKeyOf(head) };
227
+ // The same wrapper over a KB lookup's own tag: the reference work that
228
+ // answered is the asserting party, not the ingest run that read it.
229
+ if (head.startsWith("extracted:research:")) return researchTagToSource(head.slice("extracted:research:".length));
230
+ // The fuzzy tier keeps a Source of its OWN — a candidate the strict
231
+ // recognizer skipped must never corroborate a curated fact — but one per
232
+ // publication, not one per article.
233
+ if (head.startsWith("optimistic-extract:")) {
234
+ const rest = head.slice("optimistic-extract:".length);
235
+ const publication = rest.startsWith("news:") || rest.startsWith("research:")
236
+ ? publicationSourceLabel(rest)
237
+ : rest;
238
+ return { kind: "optimisticExtract", name: publication || "unknown" };
239
+ }
179
240
  if (head.startsWith("extracted:")) return { kind: "extracted", name: head.slice("extracted:".length) || "unknown" };
180
241
  if (head.startsWith("entailed:")) return { kind: "entailed", rule: head.slice("entailed:".length) };
181
242
  if (head.startsWith("chat:") || head.startsWith("session:") || head.startsWith("operator")) return { kind: "operator" };
@@ -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 } = {}) {
@@ -63,9 +63,10 @@ import { basename, join, resolve } from "node:path";
63
63
  import { runTurn, uuidv7, stripLeadingDiscourseAdverb } from "./chat.mjs";
64
64
  import { beginsWithVowelSound, grammarRules } from "./finish.mjs";
65
65
  import { splitSentencesPreservingPaths, stripCitationResidue } from "./sentences.mjs";
66
- import { loadMemory, readFactRows, appendFact, removeFacts } from "../adapters/memory/core.mjs";
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
@@ -800,6 +805,13 @@ function canonicalLines(facts, storeRows) {
800
805
  * are the only output.
801
806
  * sourceTag the label the audit provenance carries (extracted:<tag> /
802
807
  * optimistic-extract:<tag>). Default "text".
808
+ * attributeToSource
809
+ * file the recognizer's own assertion under `sourceTag`'s
810
+ * publication instead of a fresh chat session per sentence.
811
+ * Off by default: an operator running `tmct extract` over their
812
+ * own notes IS the asserting party, so that lane keeps minting
813
+ * a session. A feed or a reference work is not, and one
814
+ * publication's sentences must never corroborate each other.
803
815
  * optimistic also run the fuzzy tier over strict-skipped sentences.
804
816
  * canonical include a `canonical` array: one enriched triple line per
805
817
  * ingested fact.
@@ -830,7 +842,12 @@ function canonicalLines(facts, storeRows) {
830
842
  export async function ingestText(text, {
831
843
  memoryDir = null, sourceTag = "text", optimistic = false,
832
844
  canonical = false, config = null, lexicon = null, observedAt = "", findings = false,
845
+ attributeToSource = false,
833
846
  } = {}) {
847
+ // The session id every sentence's recognizer turn runs under. Stable and
848
+ // derived from the publication when the caller attributes to it, so the whole
849
+ // run lands on one Source; a fresh uuid per sentence otherwise (runSentence).
850
+ const recognizerSessionId = attributeToSource ? `${INGEST_SESSION_MARKER}${sourceTag.split("@")[0]}` : "";
834
851
  // Paragraphs first (blank-line separated), so the pronoun carry never bridges
835
852
  // a topic break: a fresh paragraph clears the last-subject it would resolve
836
853
  // "they"/"it" against. Each paragraph then splits into sentences the shared
@@ -900,7 +917,7 @@ export async function ingestText(text, {
900
917
  if (ingestYield) await ingestYield();
901
918
  const knownIds = new Set(currentRows.map((r) => r.id));
902
919
  const { recognized, rows, afterRows, decline } = await runSentence(form, {
903
- config: cfg, memoryDir: dir, env: runEnv, beforeRows: currentRows,
920
+ config: cfg, memoryDir: dir, env: runEnv, beforeRows: currentRows, sessionId: recognizerSessionId,
904
921
  });
905
922
  currentRows = afterRows;
906
923
  if (!recognized) { lastDecline = decline || lastDecline; return null; }
@@ -971,9 +988,14 @@ export async function ingestText(text, {
971
988
  const subjects = new Set(rows.map((r) => r.subject));
972
989
  if (subjects.size === 1) carrySubject = [...subjects][0];
973
990
  const tag = `extracted:${sourceTag}`;
991
+ // One write for the whole sentence, not one per row: a write reads
992
+ // and re-derives the whole graph, so N of them cost N times what one
993
+ // carrying the same N rows does. The batch stays inside the sentence
994
+ // — a later sentence still reads everything the earlier ones wrote.
995
+ const writes = [];
974
996
  for (const row of rows) {
975
997
  const extraction = findingsForRow(row, readingFindings, identifierTerms);
976
- await appendFact(dir, {
998
+ writes.push({
977
999
  subject: row.subject, predicate: row.predicate, object: row.object,
978
1000
  provenance: tag, quantifier: row.quantifier || "", observedAt,
979
1001
  ...(extraction.length ? { extraction } : {}),
@@ -985,6 +1007,7 @@ export async function ingestText(text, {
985
1007
  });
986
1008
  taggedIds.add(row.id);
987
1009
  }
1010
+ await appendFacts(dir, writes);
988
1011
  continue;
989
1012
  }
990
1013
  const ungrounded = ungroundedTermsIn(lastDecline);
@@ -1009,15 +1032,17 @@ export async function ingestText(text, {
1009
1032
  if (!keptCandidates.length) continue;
1010
1033
  optimisticSentences += 1;
1011
1034
  const tag = `optimistic-extract:${sourceTag}`;
1035
+ const candidateWrites = [];
1012
1036
  for (const t of keptCandidates) {
1013
1037
  const extraction = findingsForRow(t, mintFindings.has(t) ? [mintFindings.get(t)] : [], identifierTerms);
1014
- const written = await appendFact(dir, {
1038
+ candidateWrites.push({
1015
1039
  subject: t.subject, predicate: t.predicate, object: t.object, provenance: tag, observedAt,
1016
1040
  ...(extraction.length ? { extraction } : {}),
1017
1041
  });
1018
1042
  optimisticFacts.push({ ...t, provenance: tag, sentence, ...(extraction.length ? { extraction } : {}) });
1019
- taggedIds.add(written.id);
1020
1043
  }
1044
+ const { ids } = await appendFacts(dir, candidateWrites);
1045
+ for (const id of ids) taggedIds.add(id);
1021
1046
  }
1022
1047
  }
1023
1048
 
@@ -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
 
@@ -494,6 +495,27 @@ function mergeSnapshotsById(existing, incoming, cap) {
494
495
  return { items, added };
495
496
  }
496
497
 
498
+ const fetchedAtMs = (snapshot) => {
499
+ const ms = toMs(snapshot?.fetchedAt);
500
+ return Number.isFinite(ms) ? ms : 0;
501
+ };
502
+
503
+ /** True once a snapshot has been through a grounding round. `mergeSnapshotsById`
504
+ * files a snapshot the moment it arrives, so "known" and "grounded" are two
505
+ * different states and only this one means the facts landed. */
506
+ const isGroundedSnapshot = (snapshot) => (snapshot?.processedRounds || 0) > 0;
507
+
508
+ /** One source's fetched-but-not-yet-grounded snapshots, oldest first. A cycle
509
+ * that ran out of time leaves its backlog here, so the next cycle works
510
+ * through that before anything newer and the same article is never ingested
511
+ * twice. Ordered off the snapshots' own fields, so two cycles reading the same
512
+ * state take the same work in the same order. */
513
+ function pendingSnapshotsFor(items, sourceId) {
514
+ return (items || [])
515
+ .filter((snap) => snap?.sourceId === sourceId && !isGroundedSnapshot(snap))
516
+ .sort((a, b) => (fetchedAtMs(a) - fetchedAtMs(b)) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
517
+ }
518
+
497
519
  function emptyCycleAccumulator(at) {
498
520
  return { at, sentences: 0, recognized: 0, optimisticCount: 0, factsAdded: 0, termsResolved: 0, derived: 0 };
499
521
  }
@@ -548,21 +570,28 @@ export async function pollNewsSources(ctx) {
548
570
  perSource.push({ sourceId, status: "failed" });
549
571
  continue;
550
572
  }
573
+ let added = [];
551
574
  if (result.notModified) {
552
575
  recordSuccess(health, nowVal, "not-modified");
553
- perSource.push({ sourceId, status: "not-modified" });
554
- continue;
576
+ } else {
577
+ recordSuccess(health, nowVal, "ok");
578
+ const merged = mergeSnapshotsById(state.items, result.items, config.itemCap);
579
+ state.items = merged.items;
580
+ added = merged.added;
581
+ newItemsTotal += added.length;
555
582
  }
556
- recordSuccess(health, nowVal, "ok");
557
- const { items: mergedItems, added } = mergeSnapshotsById(state.items, result.items, config.itemCap);
558
- state.items = mergedItems;
559
- newItemsTotal += added.length;
560
583
 
584
+ // Everything this source has fetched and not yet grounded, not just what
585
+ // arrived on this fetch: a source that answers 304 still has a backlog to
586
+ // finish, and an aborted cycle's leftovers would otherwise sit in the state
587
+ // marked known and never be read again.
561
588
  const before = emptyCycleAccumulator(nowVal);
562
589
  const after = emptyCycleAccumulator(nowVal);
563
- for (const snapshot of added) {
590
+ let grounded = 0;
591
+ for (const snapshot of pendingSnapshotsFor(state.items, sourceId)) {
564
592
  if (shouldAbort()) { aborted = true; break; }
565
593
  const r = await ingestNewsSnapshot(ctx, snapshot);
594
+ grounded += 1;
566
595
  after.sentences += r.sentences;
567
596
  after.recognized += r.recognized;
568
597
  after.optimisticCount += r.optimisticCount;
@@ -571,10 +600,17 @@ export async function pollNewsSources(ctx) {
571
600
  factsTotal += r.facts;
572
601
  derivedTotal += r.derived;
573
602
  }
574
- if (added.length) {
603
+ if (grounded) {
575
604
  state.metrics = [...(state.metrics || []), cycleMetrics(before, after, { source: sourceId })];
576
605
  }
577
- perSource.push({ sourceId, status: "ok", newItems: added.length });
606
+ const pendingLeft = pendingSnapshotsFor(state.items, sourceId).length;
607
+ perSource.push({
608
+ sourceId,
609
+ status: result.notModified ? "not-modified" : "ok",
610
+ newItems: added.length,
611
+ grounded,
612
+ pending: pendingLeft,
613
+ });
578
614
  if (aborted) break;
579
615
  }
580
616
 
@@ -627,6 +663,7 @@ async function ingestResearchArticle(ctx, term, provider, article) {
627
663
  ingested = await ingestText(prose, {
628
664
  memoryDir, sourceTag: provenance, optimistic: true,
629
665
  lexicon: lexicon || loadLexicon(), observedAt: resolveNow(now), findings: true,
666
+ attributeToSource: true,
630
667
  });
631
668
  invalidateCache(ctx.cache);
632
669
  }