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

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "6.0.15",
3
+ "version": "6.0.17",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; indexes a repo on request (tmct index) or reads any producer's graph.",
@@ -118,6 +118,8 @@
118
118
  "gen:screenshots": "node scripts/gen-screenshots.mjs",
119
119
  "gen:og-images": "node scripts/gen-og-images.mjs",
120
120
  "probe:news-sources": "node scripts/probe-news-sources.mjs",
121
+ "bench:news": "node scripts/news-bench/run.mjs --seed=xl",
122
+ "bench:news:fast": "node scripts/news-bench/run.mjs --seed=fixture",
121
123
  "check:links": "node scripts/check-links.mjs",
122
124
  "check:pii": "node scripts/pii-lint.mjs",
123
125
  "check:pack": "node scripts/check-pack-manifest.mjs",
@@ -260,16 +260,24 @@ async function fetchFeedFormat(record, gate, { format, now }) {
260
260
  * carries `wikibaseItem` when the article names one, past normalizeFeedItems
261
261
  * (which only knows the snapshot's own fixed fields) so the news
262
262
  * enrichment loop can short-circuit straight to the Wikidata KB source with
263
- * the Q-id already in hand, no lookup needed. */
263
+ * the Q-id already in hand, no lookup needed.
264
+ *
265
+ * Neither `news` nor `mostread` carries a per-article timestamp in the raw
266
+ * payload — the only date this feed exposes is the UTC calendar day the
267
+ * request itself names (the `/YYYY/MM/DD/` path Wikimedia selected these
268
+ * articles for), so `publishedAt` is stamped from whichever day's page
269
+ * actually answered (the primary day, or the prior day on a 404 retry),
270
+ * never from the fetch's own clock. */
264
271
  async function fetchWikimediaFeed(record, gate, { now }) {
265
- const primaryUrl = wikimediaFeedUrl(record.url, now);
266
- let body = await pacedFetchJson(gate, primaryUrl);
272
+ let feedDay = now;
273
+ let body = await pacedFetchJson(gate, wikimediaFeedUrl(record.url, feedDay));
267
274
  if (body === null) {
268
275
  // A 404 means the day's page is not yet published; retry once against
269
276
  // the previous UTC day before giving up.
270
277
  const prevDay = new Date(now);
271
278
  prevDay.setUTCDate(prevDay.getUTCDate() - 1);
272
- body = await pacedFetchJson(gate, wikimediaFeedUrl(record.url, prevDay.toISOString()));
279
+ feedDay = prevDay.toISOString();
280
+ body = await pacedFetchJson(gate, wikimediaFeedUrl(record.url, feedDay));
273
281
  if (body === null) return null;
274
282
  }
275
283
  if (isNotModified(body)) return { items: [], bytes: 0, notModified: true };
@@ -280,12 +288,14 @@ async function fetchWikimediaFeed(record, gate, { now }) {
280
288
  ? body.mostread.articles
281
289
  : [];
282
290
 
291
+ const { yyyy, mm, dd } = utcDateParts(feedDay);
292
+ const feedDayIso = `${yyyy}-${mm}-${dd}T00:00:00.000Z`;
283
293
  const raw = articles.map((a) => ({
284
294
  guid: a?.wikibase_item || a?.normalizedtitle || a?.title || "",
285
295
  title: stripMarkup(a?.normalizedtitle || a?.displaytitle || a?.title || ""),
286
296
  url: a?.content_urls?.desktop?.page || "",
287
297
  summary: stripMarkup(a?.extract || ""),
288
- publishedAt: "",
298
+ publishedAt: feedDayIso,
289
299
  wikibaseItem: a?.wikibase_item || "",
290
300
  }));
291
301
 
@@ -117,6 +117,41 @@ export function gerundVerbSurface(verb) {
117
117
  return /[^aeiou]e$/i.test(base) && base.length > 2 ? `${base.slice(0, -1)}ing` : `${base}ing`;
118
118
  }
119
119
 
120
+ /** Irregular plural nouns whose surface carries no "-s" at all, so the suffix
121
+ * check in isSubjectPlural below would read them as singular. A small closed
122
+ * table, not this file's own pluralizer — it answers one question only
123
+ * ("is this head noun plural"), never generates a form. */
124
+ const IRREGULAR_PLURAL_NOUNS = new Set([
125
+ "people", "men", "women", "children", "mice", "geese", "feet", "teeth", "oxen",
126
+ ]);
127
+
128
+ /** Nouns that end in "s" but stay grammatically SINGULAR ("the news
129
+ * spreads", not "the news spread") — the suffix check below would otherwise
130
+ * misread them as plural. */
131
+ const SINGULAR_NOUNS_ENDING_S = new Set([
132
+ "news", "physics", "species", "series", "means", "measles", "mathematics", "politics", "economics",
133
+ ]);
134
+
135
+ /**
136
+ * Is a stored fact's SUBJECT text grammatically plural, for the one thing
137
+ * this file needs it for: choosing a minted verb's surface form. Reads the
138
+ * HEAD noun only — the word right after any leading article, before a
139
+ * trailing "of ..." phrase, so "the group of scientists" agrees on "group"
140
+ * ("the group of scientists reports"), not "scientists". From there: the
141
+ * closed irregular table above, then the regular "-s" suffix, same
142
+ * naive-morphology trade thirdPersonSingularSurface already takes above. A
143
+ * subject this can't read (empty, or a plural-invariant noun like "sheep")
144
+ * defaults to singular — English's own unmarked form, and also this file's
145
+ * pre-existing default for every caller that passes no subject at all.
146
+ */
147
+ export function isSubjectPlural(subject) {
148
+ const head = String(subject || "").trim().replace(/^(?:the|a|an)\s+/i, "").split(/\s+/)[0]?.toLowerCase() ?? "";
149
+ if (!head) return false;
150
+ if (IRREGULAR_PLURAL_NOUNS.has(head)) return true;
151
+ if (SINGULAR_NOUNS_ENDING_S.has(head)) return false;
152
+ return /[a-z]s$/.test(head) && !/ss$/.test(head);
153
+ }
154
+
120
155
  /**
121
156
  * How a stored predicate reads in English: a curated table hit, else the
122
157
  * mechanical surface form of a minted predicate, else the predicate's local
@@ -124,11 +159,21 @@ export function gerundVerbSurface(verb) {
124
159
  * none). The local-name tail is what keeps a namespace tmct itself mints —
125
160
  * `tmct:needs`, stamped by the lexicon — out of a public-facing sentence.
126
161
  *
162
+ * `subject` is optional and used ONLY by the minted "mgx:<lemma>" verb fold
163
+ * (and the do-support it derives under negation): a plural subject reads the
164
+ * lemma's bare form ("scientists report"), anything else reads its
165
+ * third-person-singular fold ("an earthquake strikes"). A CURATED phrase is
166
+ * returned exactly as written regardless of subject — the table is fixed
167
+ * English on purpose, no morphology applied to it here or anywhere else.
168
+ * Callers with no subject to offer (most of chat.mjs's call sites, which
169
+ * render a bare predicate with no sentence around it) get today's singular
170
+ * default, unchanged.
171
+ *
127
172
  * Self-contained on purpose: the news page runs this exact function in the
128
- * browser (phraseRendererSource below stringifies it and its two helpers), so
129
- * it reaches for nothing outside this module.
173
+ * browser (phraseRendererSource below stringifies it and everything it calls
174
+ * into), so it reaches for nothing outside this module.
130
175
  */
131
- export function predicatePhrase(predicate) {
176
+ export function predicatePhrase(predicate, subject) {
132
177
  if (FACT_PREDICATE_PHRASES[predicate]) return FACT_PREDICATE_PHRASES[predicate];
133
178
  const p = String(predicate ?? "");
134
179
  // NEGATIVE polarity renders as its own positive phrase, negated — ONE branch
@@ -144,12 +189,13 @@ export function predicatePhrase(predicate) {
144
189
  // for the same reason: a modal, a copula and a plain verb take different
145
190
  // negations, and nothing else does.
146
191
  if (p.startsWith("mgxneg:")) {
147
- const phrase = predicatePhrase(`mgx:${p.slice("mgxneg:".length)}`);
192
+ const phrase = predicatePhrase(`mgx:${p.slice("mgxneg:".length)}`, subject);
148
193
  if (phrase === "can") return "cannot";
149
194
  if (phrase === "can be") return "cannot be";
150
195
  if (phrase === "is" || phrase.startsWith("is ")) return `is not${phrase.slice(2)}`;
151
196
  const [head, ...tail] = phrase.split(" ");
152
- return ["does not", baseVerbSurface(head), ...tail].join(" ");
197
+ const doSupport = isSubjectPlural(subject) ? "do not" : "does not";
198
+ return [doSupport, baseVerbSurface(head), ...tail].join(" ");
153
199
  }
154
200
  // a comparative renders as its copula surface: mgx:smaller-than ->
155
201
  // "is smaller than" (never a 3sg fold — "smallers" isn't a word)
@@ -164,15 +210,23 @@ export function predicatePhrase(predicate) {
164
210
  const same = /^mgx:same-([a-z]+)-as$/i.exec(p);
165
211
  if (same) return `has the same ${same[1].toLowerCase()} as`;
166
212
  // a folded preposition renders back naturally: mgx:rest-on -> "rests on"
213
+ // (subject-verb agreement lives here alone: mgx:<lemma> is minted from the
214
+ // verb's OWN base form, so a plural subject reads it bare — "scientists
215
+ // report" — and anything else takes the 3sg fold — "an earthquake strikes")
167
216
  const minted = /^mgx:([a-z]+)(?:-([a-z]+))?$/i.exec(p);
168
- if (minted) return `${thirdPersonSingularSurface(minted[1])}${minted[2] ? ` ${minted[2]}` : ""}`;
217
+ if (minted) {
218
+ const verb = isSubjectPlural(subject) ? minted[1] : thirdPersonSingularSurface(minted[1]);
219
+ return `${verb}${minted[2] ? ` ${minted[2]}` : ""}`;
220
+ }
169
221
  const colon = p.indexOf(":");
170
222
  return colon === -1 ? p : p.slice(colon + 1);
171
223
  }
172
224
 
173
- /** "a heart has a valve" from one { subject, predicate, object } fact row. */
225
+ /** "a heart has a valve" from one { subject, predicate, object } fact row
226
+ * "scientists report a finding" for a plural row.subject, off the same
227
+ * agreement rule in predicatePhrase above. */
174
228
  export function factSentence(row) {
175
- return `${row.subject} ${predicatePhrase(row.predicate)} ${row.object}`;
229
+ return `${row.subject} ${predicatePhrase(row.predicate, row.subject)} ${row.object}`;
176
230
  }
177
231
 
178
232
  /**
@@ -224,6 +278,9 @@ export function phraseRendererSource() {
224
278
  `const TEACH_PARTICIPLE_SRC = ${JSON.stringify(TEACH_PARTICIPLE_SRC)};`,
225
279
  `const thirdPersonSingularSurface = ${thirdPersonSingularSurface};`,
226
280
  `const baseVerbSurface = ${baseVerbSurface};`,
281
+ `const IRREGULAR_PLURAL_NOUNS = new Set(${JSON.stringify([...IRREGULAR_PLURAL_NOUNS])});`,
282
+ `const SINGULAR_NOUNS_ENDING_S = new Set(${JSON.stringify([...SINGULAR_NOUNS_ENDING_S])});`,
283
+ `const isSubjectPlural = ${isSubjectPlural};`,
227
284
  `const predicatePhrase = ${predicatePhrase};`,
228
285
  `const factSentence = ${factSentence};`,
229
286
  ].join("\n ");
@@ -71,6 +71,51 @@ export function newsWindowRows(rows, { now, windowMs }) {
71
71
  });
72
72
  }
73
73
 
74
+ // ---------------------------------------------------------------------------
75
+ // Item identity: what makes two fetched snapshots the same newsworthy item.
76
+ // ---------------------------------------------------------------------------
77
+
78
+ // Everything a source can respell between two readings of one article —
79
+ // punctuation, capitalisation, entity escapes already stripped upstream, run
80
+ // of spaces — folds away, so the key answers to the item's words alone.
81
+ const CONTENT_KEY_NOISE_RE = /[^a-z0-9]+/g;
82
+
83
+ function itemContentText(snapshot) {
84
+ return `${snapshot?.title ?? ""} ${snapshot?.summary ?? ""}`
85
+ .toLowerCase()
86
+ .replace(CONTENT_KEY_NOISE_RE, " ")
87
+ .trim();
88
+ }
89
+
90
+ /** The content key one snapshot answers to, or "" when it carries no words:
91
+ * its source, its own publication stamp and its normalized text. The key a
92
+ * source with no stable id of its own de-dupes on, and the second chance at
93
+ * recognising an article a source re-issued under a fresh id. The publication
94
+ * stamp stays in so two genuinely different events that happen to share a
95
+ * headline — two quakes of the same size near the same town — keep separate
96
+ * keys. Pure. */
97
+ export function newsItemContentKey(snapshot) {
98
+ const text = itemContentText(snapshot);
99
+ if (!text) return "";
100
+ const sourceId = String(snapshot?.sourceId ?? "");
101
+ const publishedAt = String(snapshot?.publishedAt ?? "");
102
+ return `news-text:${sha256HexPrefix(`${sourceId}\0${publishedAt}\0${text}`, 8)}`;
103
+ }
104
+
105
+ /** Every key a fetched snapshot is the same item under: the id the fetcher
106
+ * minted from the source's own identifier (Hacker News story id, USGS event
107
+ * id, an RSS guid, a Wikinews page id, a Wikimedia article title) and its
108
+ * content key. Two snapshots sharing any key name one item. Pure — a function
109
+ * of the snapshot's own fields, never of when or in what order it arrived. */
110
+ export function newsItemKeys(snapshot) {
111
+ const keys = [];
112
+ const id = String(snapshot?.id ?? "");
113
+ if (id) keys.push(id);
114
+ const contentKey = newsItemContentKey(snapshot);
115
+ if (contentKey) keys.push(contentKey);
116
+ return keys;
117
+ }
118
+
74
119
  // ---------------------------------------------------------------------------
75
120
  // The newsworthiness gate (PLAN_NEWS_FEED.md section 17). A card reports what
76
121
  // a contemporary source said inside the window; everything the graph looked
@@ -503,7 +548,11 @@ function collectSources(subgraphRows, sourcesByFactId) {
503
548
  const key = src.url || src.title || "";
504
549
  if (!key || seen.has(key)) continue;
505
550
  seen.add(key);
506
- sources.push({ title: src.title || "", url: src.url || "", name: src.name || "" });
551
+ const entry = { title: src.title || "", url: src.url || "", name: src.name || "" };
552
+ // Carried through only when the source snapshot actually has one — a
553
+ // card for an undated snapshot shows no date rather than a blank field.
554
+ if (src.publishedAt) entry.publishedAt = src.publishedAt;
555
+ sources.push(entry);
507
556
  }
508
557
  return sources;
509
558
  }
@@ -580,7 +629,7 @@ export function renderNewsParagraph(hub, subgraphRows, { reportedIds = null } =
580
629
  .map((r) => r.object)
581
630
  .sort();
582
631
  if (!objects.length) continue;
583
- sentences.push(`${hub} ${predicatePhrase(predicate)} ${joinObjects(objects)}`);
632
+ sentences.push(`${hub} ${predicatePhrase(predicate, hub)} ${joinObjects(objects)}`);
584
633
  }
585
634
 
586
635
  // A hub that only ever appears as an OBJECT — the place a quake struck, the
@@ -612,8 +661,9 @@ export function renderNewsParagraph(hub, subgraphRows, { reportedIds = null } =
612
661
 
613
662
  /** newsworthyHubs -> one item per hub (PLAN_NEWS_FEED.md section 6.6),
614
663
  * paragraph included, sorted builtAt desc then id asc. `sourcesByFactId`
615
- * maps fact ids to snapshot source links ({ title, url, name }). The gate
616
- * (PLAN_NEWS_FEED.md section 17): `reportedRows` replaces `newsWindowRows`
664
+ * maps fact ids to snapshot source links ({ title, url, name, publishedAt?
665
+ * }); publishedAt is present only when the source snapshot carried one.
666
+ * The gate (PLAN_NEWS_FEED.md section 17): `reportedRows` replaces `newsWindowRows`
617
667
  * and `newsworthyHubs` replaces `scoreHubs` as this function's own inputs —
618
668
  * both keep their prior behaviour for every other caller. Each item's
619
669
  * two-hop sub-graph then splits into its own `reported`/`background` rows,
@@ -31,8 +31,42 @@ const FUNCTION_WORD_TERMS = new Set([
31
31
  "usually", "indeed", "however", "therefore", "thus", "hence", "meanwhile",
32
32
  ]);
33
33
 
34
- function isFunctionWordTerm(term) {
35
- return FUNCTION_WORD_TERMS.has(term);
34
+ // A bare measurement-unit abbreviation names a quantity, not a subject —
35
+ // "10 km SSW of Ridgecrest" leaks "km" as a standalone term. Closed set of
36
+ // the abbreviations USGS/wire-service distance, weight and speed reports
37
+ // actually use standalone.
38
+ const MEASUREMENT_UNIT_TERMS = new Set([
39
+ "km", "kms", "m", "mi", "mis", "ft", "kg", "kgs", "mph", "kmh", "kph",
40
+ "cm", "mm", "nm", "yd", "yds", "lb", "lbs", "oz",
41
+ ]);
42
+
43
+ // A compass abbreviation locates something relative to a place; it never
44
+ // names the place itself — "10 km SSW of Ridgecrest" leaks "ssw" the same
45
+ // way it leaks "km". Closed set: the 16 standard compass-rose points.
46
+ const COMPASS_ABBREVIATION_TERMS = new Set([
47
+ "n", "s", "e", "w",
48
+ "ne", "nw", "se", "sw",
49
+ "nne", "ene", "ese", "sse", "ssw", "wsw", "wnw", "nnw",
50
+ ]);
51
+
52
+ // A bare foreign article/preposition arrives only as a fragment of a title
53
+ // that never got tokenized as a whole name ("Tour de France Femmes" leaking
54
+ // "de"). Closed set, standalone only — this never matches "de" glued inside
55
+ // a multi-word term like "tour de france", since bumpTerms and
56
+ // ledgerFromPayload both key on the full normalized term, not its words.
57
+ const FOREIGN_PARTICLE_TERMS = new Set([
58
+ "de", "la", "le", "du", "der", "von", "van", "di", "el",
59
+ ]);
60
+
61
+ const NOISE_TERM_SETS = [
62
+ FUNCTION_WORD_TERMS,
63
+ MEASUREMENT_UNIT_TERMS,
64
+ COMPASS_ABBREVIATION_TERMS,
65
+ FOREIGN_PARTICLE_TERMS,
66
+ ];
67
+
68
+ function isNoiseTerm(term) {
69
+ return NOISE_TERM_SETS.some((set) => set.has(term));
36
70
  }
37
71
 
38
72
  /** Ledger entry field order fixed once here so `ledgerPayload` serializes
@@ -62,7 +96,7 @@ export function createTermLedger() {
62
96
  export function bumpTerms(ledger, termCounts, itemId, now, vocabGroundedByTerm = new Map()) {
63
97
  for (const [rawTerm, occurrences] of termCounts) {
64
98
  const term = normFactTerm(rawTerm);
65
- if (!term || isFunctionWordTerm(term)) continue;
99
+ if (!term || isNoiseTerm(term)) continue;
66
100
  let entry = ledger.terms.get(term);
67
101
  if (!entry) {
68
102
  const vocabGrounded = vocabGroundedByTerm.has(rawTerm)
@@ -139,7 +173,7 @@ export function ledgerPayload(ledger) {
139
173
  export function ledgerFromPayload(payload) {
140
174
  const ledger = createTermLedger();
141
175
  for (const entry of payload?.terms ?? []) {
142
- if (isFunctionWordTerm(entry.term)) continue;
176
+ if (isNoiseTerm(entry.term)) continue;
143
177
  ledger.terms.set(entry.term, { ...entry, itemIds: [...(entry.itemIds ?? [])] });
144
178
  }
145
179
  return ledger;
@@ -151,6 +151,7 @@ ${THEME_TOKENS_CSS}
151
151
  .item .newtag { font-family: ${MONO_STACK}; font-size: .64rem; color: var(--taught); margin-left: .5rem; }
152
152
  .item .paragraph { margin: .4rem 0; }
153
153
  .item .sources-links { font-size: .74rem; color: var(--muted); }
154
+ .item .sourcedate { font-family: ${MONO_STACK}; }
154
155
  .item details.facts summary { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--corpus); cursor: pointer; }
155
156
  .item details.background summary { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--muted); cursor: pointer; }
156
157
  .item details.background p { margin: .35rem 0 0; color: var(--muted); }
@@ -482,12 +483,29 @@ ${NEWS_STYLE}
482
483
  });
483
484
  }
484
485
 
486
+ // The earliest publication date among a card's sources — a lexicographic
487
+ // min over ISO-8601 UTC strings sorts chronologically without parsing, and
488
+ // "earliest" reads as when the reported event actually happened rather
489
+ // than whichever source snapshot the subgraph walk reached last. A source
490
+ // with no publishedAt (its feed never carries one) never contributes here,
491
+ // so a card built entirely from undated sources shows no date at all.
492
+ function earliestSourceDate(sources) {
493
+ let earliest = null;
494
+ for (const s of sources || []) {
495
+ if (!s.publishedAt) continue;
496
+ if (earliest === null || s.publishedAt < earliest) earliest = s.publishedAt;
497
+ }
498
+ return earliest;
499
+ }
500
+
485
501
  function cardHtml(item) {
486
502
  const factLines = item.factLines || [];
487
503
  const factsHtml = factLines.map(function (line) { return '<div class="factrow">' + esc(line) + '</div>'; }).join("");
488
504
  const moreCount = (item.factCount || 0) - factLines.length;
489
505
  const moreHtml = moreCount > 0 ? '<div class="factrow factmore">&hellip;and ' + moreCount + ' more</div>' : "";
490
506
  const sourcesText = (item.sources || []).map(function (s) { return esc(s.title || s.url || ""); }).filter(Boolean).join(", ");
507
+ const sourceDate = earliestSourceDate(item.sources);
508
+ const dateText = sourceDate ? ' <span class="sourcedate">(' + esc(sourceDate.slice(0, 10)) + ')</span>' : "";
491
509
  const background = item.backgroundParagraph
492
510
  ? '<details class="background"><summary>what the graph already knew</summary><p>' + esc(item.backgroundParagraph) + '</p></details>'
493
511
  : "";
@@ -496,7 +514,7 @@ ${NEWS_STYLE}
496
514
  + '<span class="hub">' + esc(item.hub) + '</span><span class="tier">' + esc(item.tier || "unranked") + '</span>' + newTag
497
515
  + '<p class="paragraph">' + esc(item.paragraph) + '</p>'
498
516
  + background
499
- + (sourcesText ? '<p class="sources-links">sources: ' + sourcesText + '</p>' : "")
517
+ + (sourcesText ? '<p class="sources-links">sources: ' + sourcesText + dateText + '</p>' : "")
500
518
  + '<details class="facts"><summary>' + (item.factCount || 0) + ' fact' + (item.factCount === 1 ? "" : "s") + '</summary>' + factsHtml + moreHtml + '</details>'
501
519
  + '</div>';
502
520
  }
@@ -17,11 +17,11 @@
17
17
  // (the same invalidation convention chat.mjs's own caches use).
18
18
  // lexicon a loaded lexicon (loadLexicon() when absent).
19
19
  // config resolveNewsConfig()'s shape.
20
- // state the news-store shape (news-store.mjs): { items, ledger
21
- // (ledgerPayload form), health, requestLog, metrics, lastPollAt,
22
- // lastEnrichAt } — always JSON-plain; a live term ledger is
23
- // built from state.ledger via ledgerFromPayload only for the
24
- // span of one call, then folded back with ledgerPayload.
20
+ // state the news-store shape (news-store.mjs): { items, seenItemKeys,
21
+ // ledger (ledgerPayload form), health, requestLog, metrics,
22
+ // lastPollAt, lastEnrichAt } — always JSON-plain; a live term
23
+ // ledger is built from state.ledger via ledgerFromPayload only
24
+ // for the span of one call, then folded back with ledgerPayload.
25
25
  // providers { newsFetchers: Map<sourceId, { id, fetchItems }>,
26
26
  // getResearchProvider({ source }), preflightNewsUrl?(url) } —
27
27
  // every fetcher and provider this session may call, already
@@ -44,7 +44,7 @@
44
44
  import { normFactTerm, normFactPredicate, factIdFor } from "../domain/hash.mjs";
45
45
  import {
46
46
  newsWindowRows, renderNewsParagraph, buildNewsItems, evictNewsFacts,
47
- conceptTerms, isQuantityTerm,
47
+ conceptTerms, isQuantityTerm, newsItemKeys,
48
48
  } from "../domain/news-feed.mjs";
49
49
  import {
50
50
  createTermLedger, bumpTerms, rankedTerms, markTerm, groundedSweep, ledgerPayload, ledgerFromPayload,
@@ -288,6 +288,10 @@ function buildSourcesByFactId(items) {
288
288
  for (const snap of items || []) {
289
289
  const record = recordsById.get(snap.sourceId);
290
290
  const src = { title: snap.title || "", url: snap.url || "", name: record?.name || snap.sourceId || "" };
291
+ // A snapshot with no publication timestamp (a source whose own feed never
292
+ // carries one) leaves the key off entirely — never an invented or blank
293
+ // date, and never the fetch's own clock standing in for it.
294
+ if (snap.publishedAt) src.publishedAt = snap.publishedAt;
291
295
  for (const factId of snap.factIds || []) map.set(factId, src);
292
296
  }
293
297
  return map;
@@ -473,38 +477,90 @@ function recordSuccess(health, nowVal, status) {
473
477
  health.autoDisabled = false;
474
478
  }
475
479
 
476
- /** Merges `incoming` snapshots into `existing` by id (an already-seen id is
477
- * never re-added or re-ingested), then enforces `cap` by dropping the
478
- * oldest-by-fetchedAt entries. Returns the merged list and the genuinely
479
- * new snapshots the caller still needs to ingest. */
480
- function mergeSnapshotsById(existing, incoming, cap) {
481
- const byIdMap = new Map((existing || []).map((s) => [s.id, s]));
482
- const added = [];
483
- for (const snap of incoming || []) {
484
- if (byIdMap.has(snap.id)) continue;
485
- byIdMap.set(snap.id, snap);
486
- added.push(snap);
487
- }
488
- let items = [...byIdMap.values()];
489
- if (items.length > cap) {
490
- items = items
491
- .slice()
492
- .sort((a, b) => (toMs(a.fetchedAt) - toMs(b.fetchedAt)) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
493
- .slice(items.length - cap);
494
- }
495
- return { items, added };
496
- }
497
-
498
480
  const fetchedAtMs = (snapshot) => {
499
481
  const ms = toMs(snapshot?.fetchedAt);
500
482
  return Number.isFinite(ms) ? ms : 0;
501
483
  };
502
484
 
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. */
485
+ /** True once a snapshot has been through a grounding round. A snapshot is
486
+ * filed the moment it arrives, so "known" and "grounded" are two different
487
+ * states and only this one means the facts landed. */
506
488
  const isGroundedSnapshot = (snapshot) => (snapshot?.processedRounds || 0) > 0;
507
489
 
490
+ const byIdAscending = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
491
+ const byFetchedAtThenId = (a, b) => (fetchedAtMs(a) - fetchedAtMs(b)) || byIdAscending(a, b);
492
+ // The item cap's drop order: a snapshot already read is the first to go, since
493
+ // its facts are in the graph and its keys are remembered, while one still
494
+ // waiting to be read would lose its facts for good.
495
+ const alreadyReadFirst = (a, b) => (isGroundedSnapshot(b) - isGroundedSnapshot(a)) || byFetchedAtThenId(a, b);
496
+
497
+ // How many item keys the de-dupe memory carries. Each grounded item files two
498
+ // (its source id and its content key), so this remembers roughly a thousand
499
+ // articles — many times the item window itself, which is the point: the window
500
+ // forgets an article as soon as newer ones crowd it out, and without this the
501
+ // next poll would read that same article as brand new.
502
+ const SEEN_ITEM_KEY_CAP = 2000;
503
+
504
+ const seenEntries = (seen) => (Array.isArray(seen) ? seen : []);
505
+
506
+ /** Every key `snapshots` and `seen` between them name, newest first and capped
507
+ * — an entry's `at` is the snapshot's own fetchedAt, never a fresh clock
508
+ * reading. Ordered off the entries themselves, so the same items produce the
509
+ * same memory whatever order they arrived in. */
510
+ function rememberItemKeys(seen, snapshots) {
511
+ const atByKey = new Map();
512
+ const remember = (key, at) => {
513
+ if (!key) return;
514
+ const previous = atByKey.get(key);
515
+ atByKey.set(key, previous === undefined ? at : Math.max(previous, at));
516
+ };
517
+ for (const entry of seenEntries(seen)) {
518
+ const at = Number(entry?.at);
519
+ remember(String(entry?.key ?? ""), Number.isFinite(at) ? at : 0);
520
+ }
521
+ for (const snap of snapshots || []) {
522
+ for (const key of newsItemKeys(snap)) remember(key, fetchedAtMs(snap));
523
+ }
524
+ return [...atByKey.entries()]
525
+ .map(([key, at]) => ({ key, at }))
526
+ .sort((a, b) => (b.at - a.at) || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
527
+ .slice(0, SEEN_ITEM_KEY_CAP);
528
+ }
529
+
530
+ /** Merges `incoming` snapshots into `existing` by item identity: a snapshot
531
+ * whose id or content key already sits in the window, or in the `seen` memory
532
+ * of what has been grounded, is neither re-added nor re-ingested. Two
533
+ * incoming snapshots naming one item collapse to the lower id, so the same
534
+ * fetch read in two orders admits the same snapshot. The merged list sorts by
535
+ * fetchedAt then id, so the window is a function of which items are in it
536
+ * rather than of when each arrived, and `cap` then drops the snapshots that
537
+ * have already been read before any that still have facts to contribute.
538
+ * Returns the merged list and the genuinely new snapshots the caller still
539
+ * needs to ingest. */
540
+ function mergeSnapshots(existing, incoming, { cap, seen } = {}) {
541
+ const known = new Set();
542
+ for (const snap of existing || []) for (const key of newsItemKeys(snap)) known.add(key);
543
+ for (const entry of seenEntries(seen)) if (entry?.key) known.add(String(entry.key));
544
+
545
+ const claimed = new Set();
546
+ const admitted = new Set();
547
+ for (const snap of [...(incoming || [])].sort(byIdAscending)) {
548
+ const keys = newsItemKeys(snap);
549
+ if (!keys.length) continue;
550
+ if (keys.some((key) => known.has(key) || claimed.has(key))) continue;
551
+ for (const key of keys) claimed.add(key);
552
+ admitted.add(snap);
553
+ }
554
+
555
+ const added = (incoming || []).filter((snap) => admitted.has(snap));
556
+ const items = [...(existing || []), ...added].sort(byFetchedAtThenId);
557
+ if (items.length <= cap) return { items, added };
558
+ const dropped = new Set(
559
+ items.slice().sort(alreadyReadFirst).slice(0, items.length - cap).map((snap) => snap.id),
560
+ );
561
+ return { items: items.filter((snap) => !dropped.has(snap.id)), added };
562
+ }
563
+
508
564
  /** One source's fetched-but-not-yet-grounded snapshots, oldest first. A cycle
509
565
  * that ran out of time leaves its backlog here, so the next cycle works
510
566
  * through that before anything newer and the same article is never ingested
@@ -513,7 +569,7 @@ const isGroundedSnapshot = (snapshot) => (snapshot?.processedRounds || 0) > 0;
513
569
  function pendingSnapshotsFor(items, sourceId) {
514
570
  return (items || [])
515
571
  .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));
572
+ .sort(byFetchedAtThenId);
517
573
  }
518
574
 
519
575
  function emptyCycleAccumulator(at) {
@@ -575,7 +631,9 @@ export async function pollNewsSources(ctx) {
575
631
  recordSuccess(health, nowVal, "not-modified");
576
632
  } else {
577
633
  recordSuccess(health, nowVal, "ok");
578
- const merged = mergeSnapshotsById(state.items, result.items, config.itemCap);
634
+ const merged = mergeSnapshots(state.items, result.items, {
635
+ cap: config.itemCap, seen: state.seenItemKeys,
636
+ });
579
637
  state.items = merged.items;
580
638
  added = merged.added;
581
639
  newItemsTotal += added.length;
@@ -614,6 +672,15 @@ export async function pollNewsSources(ctx) {
614
672
  if (aborted) break;
615
673
  }
616
674
 
675
+ // Only a GROUNDED snapshot is remembered. One the item cap dropped before it
676
+ // was ever read still has its facts to contribute, so the next poll is meant
677
+ // to pick it up again; one whose facts already landed must never be read a
678
+ // second time, however long ago the window forgot it.
679
+ state.seenItemKeys = rememberItemKeys(
680
+ state.seenItemKeys,
681
+ (state.items || []).filter(isGroundedSnapshot),
682
+ );
683
+
617
684
  const memory = await store.loadMemory(memoryDir);
618
685
  const rows = store.readFactRows(memory);
619
686
  const evictIds = evictNewsFacts(rows, { cap: config.newsFactCap });
@@ -998,7 +1065,7 @@ export function cycleMetrics(before, after, { source } = {}) {
998
1065
 
999
1066
  export function createNewsState() {
1000
1067
  return {
1001
- items: [], ledger: ledgerPayload(createTermLedger()), health: [], requestLog: [], metrics: [],
1002
- lastPollAt: "", lastEnrichAt: "",
1068
+ items: [], seenItemKeys: [], ledger: ledgerPayload(createTermLedger()), health: [],
1069
+ requestLog: [], metrics: [], lastPollAt: "", lastEnrichAt: "",
1003
1070
  };
1004
1071
  }