@polycode-projects/the-mechanical-code-talker 6.0.14 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "6.0.14",
3
+ "version": "6.0.15",
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.",
@@ -298,6 +298,30 @@ async function fetchWikimediaFeed(record, gate, { now }) {
298
298
  return { items, bytes: jsonByteLength(body) };
299
299
  }
300
300
 
301
+ // A Hacker News item is a headline and nothing else — the API carries no
302
+ // summary field to fetch — and a headline is rarely a sentence, so nothing in
303
+ // the item grounds on its own. The fixed sentence below states what the site
304
+ // itself did with the story, and it names the headline IN QUOTES: quoted, the
305
+ // headline's own words can never be re-read as a claim of their own, so "Let's
306
+ // take apart your phone" stays a story Hacker News discusses instead of
307
+ // becoming a fact about Hacker News taking a phone apart.
308
+ //
309
+ // "Hackernews" is one word here on purpose. A two-word proper name in subject
310
+ // position reads as noun + verb ("Hacker" doing "News"), and the fact that
311
+ // falls out of that is nonsense.
312
+ const HACKER_NEWS_TITLE_PREFIX_RE = /^(?:show|ask|tell)\s+hn:\s*/i;
313
+ // Past this many words the stored term is a clause, not a name, and the
314
+ // recognizer turns it down — so the sentence is not built at all rather than
315
+ // stored half-read.
316
+ const HACKER_NEWS_HEADLINE_MAX_WORDS = 6;
317
+
318
+ function hackerNewsSummary(title) {
319
+ const headline = String(title || "").replace(HACKER_NEWS_TITLE_PREFIX_RE, "").trim();
320
+ if (!headline || headline.includes(":")) return "";
321
+ if (headline.split(/\s+/).length > HACKER_NEWS_HEADLINE_MAX_WORDS) return "";
322
+ return `Hackernews discusses "${headline}".`;
323
+ }
324
+
301
325
  /** topstories.json, then item/<id>.json for the first ten ids, through the
302
326
  * same gate — each item fetch takes its own slot, so the ten round trips
303
327
  * are genuinely paced by the gate's minIntervalMs rather than firing back
@@ -311,11 +335,12 @@ async function fetchHackerNews(record, gate, { now }) {
311
335
  const item = await pacedFetchJson(gate, `${record.url}/item/${id}.json`);
312
336
  if (!item) continue;
313
337
  bytes += jsonByteLength(item);
338
+ const title = stripMarkup(item.title || "");
314
339
  raw.push({
315
340
  guid: String(item.id ?? id),
316
- title: stripMarkup(item.title || ""),
341
+ title,
317
342
  url: item.url || `https://news.ycombinator.com/item?id=${item.id ?? id}`,
318
- summary: "",
343
+ summary: hackerNewsSummary(title),
319
344
  publishedAt: Number.isFinite(item.time) ? new Date(item.time * 1000).toISOString() : "",
320
345
  });
321
346
  }
@@ -323,6 +348,32 @@ async function fetchHackerNews(record, gate, { now }) {
323
348
  return { items, bytes };
324
349
  }
325
350
 
351
+ // A USGS place names a point by its distance and bearing from a settlement
352
+ // ("25 km ENE of Wana, Pakistan"), or by an offshore bearing ("off the west
353
+ // coast of Vancouver Island, Canada"), or outright ("Western Australia"). The
354
+ // distance and the bearing are a measurement, not a name, so they come off
355
+ // before the place reaches the sentence below: a number-led term can never
356
+ // head a feed card, and "near" already covers the few kilometres dropped.
357
+ const USGS_DISTANCE_PREFIX_RE = /^\s*\d+(?:\.\d+)?\s*km\s+[NSEW]{1,3}\s+of\s+/i;
358
+ const USGS_OFFSHORE_PREFIX_RE = /^\s*off\s+(?:the\s+)?[a-z\s]*?coast\s+of\s+/i;
359
+
360
+ function usgsPlaceName(place) {
361
+ return String(place || "")
362
+ .replace(USGS_DISTANCE_PREFIX_RE, "")
363
+ .replace(USGS_OFFSHORE_PREFIX_RE, "")
364
+ .trim();
365
+ }
366
+
367
+ // One fixed sentence per quake: a bare-noun subject, the verb the event did,
368
+ // and the named place. The magnitude stays where USGS itself put it, in the
369
+ // item's title — a stored fact's subject here is the CLASS "earthquake", so
370
+ // writing the number into this sentence would say something about earthquakes
371
+ // in general rather than about this one.
372
+ function usgsSummary(place) {
373
+ const name = usgsPlaceName(place);
374
+ return name ? `An earthquake struck near ${name}.` : "";
375
+ }
376
+
326
377
  async function fetchUsgs(record, gate, { now }) {
327
378
  const body = await pacedFetchJson(gate, record.url);
328
379
  if (body === null) return null;
@@ -332,7 +383,7 @@ async function fetchUsgs(record, gate, { now }) {
332
383
  guid: String(f?.id ?? f?.properties?.detail ?? f?.properties?.url ?? ""),
333
384
  title: stripMarkup(f?.properties?.title || ""),
334
385
  url: f?.properties?.url || "",
335
- summary: stripMarkup(`magnitude ${f?.properties?.mag ?? "unknown"} earthquake near ${f?.properties?.place || "an unreported location"}`),
386
+ summary: stripMarkup(usgsSummary(f?.properties?.place)),
336
387
  publishedAt: Number.isFinite(f?.properties?.time) ? new Date(f.properties.time).toISOString() : "",
337
388
  }));
338
389
  const items = normalizeFeedItems(record.id, raw, { now });
@@ -347,7 +398,11 @@ function wikinewsArticleOrigin(apiUrl) {
347
398
  * recentchanges list — the smallest query that names what is new without a
348
399
  * category to maintain by hand. */
349
400
  async function fetchWikinews(record, gate, { now }) {
350
- const url = `${record.url}?action=query&list=recentchanges&rcnamespace=0&rcnewonly=1&rclimit=20&format=json&formatversion=2&origin=*`;
401
+ // `rctype=new` is the action API's own name for "page creations only". An
402
+ // earlier `rcnewonly` is not a parameter the API has: it answers with an
403
+ // "Unrecognized parameter" warning and lists every edit, so a re-edited old
404
+ // article read as a new story.
405
+ const url = `${record.url}?action=query&list=recentchanges&rcnamespace=0&rctype=new&rclimit=20&format=json&formatversion=2&origin=*`;
351
406
  const body = await pacedFetchJson(gate, url);
352
407
  if (body === null) return null;
353
408
  if (isNotModified(body)) return { items: [], bytes: 0, notModified: true };
@@ -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;
@@ -673,6 +673,32 @@ const FRAGMENT_LEAD_TAGS = new Set(["VERB", "AUX", "ADP", "CCONJ", "SCONJ", "PAR
673
673
  // which names nothing. Read lexically so a checkout with no wink model catches
674
674
  // it too, and only for a multi-word term — "back" alone is a fine noun.
675
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
+ ]);
676
702
 
677
703
  /** Does `term` read as a thing's name rather than a clause fragment? Bounds
678
704
  * the word count and rejects a leading conjunction, auxiliary, preposition,
@@ -689,6 +715,9 @@ export function readsAsEntityTerm(term, nlp) {
689
715
  if (FRAGMENT_LEAD_WORDS.has(first)) return false;
690
716
  if (words.length === 1) return true;
691
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;
692
721
  const engine = nlp === undefined ? winkInstance() : nlp;
693
722
  if (!engine) return true;
694
723
  try {