@polycode-projects/the-mechanical-code-talker 6.0.18 → 6.0.20

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.
Files changed (76) hide show
  1. package/README.md +20 -23
  2. package/bin/tmct.mjs +16 -33
  3. package/corpus/LICENSES.json +0 -21
  4. package/corpus/README.md +10 -13
  5. package/corpus/reference/manifest.json +19 -19
  6. package/corpus/reference/shards/ref-01.jsonl.gz +0 -0
  7. package/corpus/reference/shards/ref-04.jsonl.gz +0 -0
  8. package/corpus/reference/shards/ref-08.jsonl.gz +0 -0
  9. package/corpus/reference/shards/ref-10.jsonl.gz +0 -0
  10. package/corpus/reference/shards/ref-11.jsonl.gz +0 -0
  11. package/corpus/reference/shards/ref-17.jsonl.gz +0 -0
  12. package/corpus/reference/shards/ref-20.jsonl.gz +0 -0
  13. package/corpus/reference/shards/ref-25.jsonl.gz +0 -0
  14. package/corpus/reference/shards/ref-2c.jsonl.gz +0 -0
  15. package/corpus/tier2/generate.mjs +6 -142
  16. package/corpus/tier2/manifest.json +0 -42
  17. package/package.json +6 -4
  18. package/src/adapters/corpus/child-seed.mjs +74 -0
  19. package/src/adapters/corpus/conceptnet.mjs +45 -26
  20. package/src/adapters/corpus/research-source.mjs +6 -2
  21. package/src/adapters/corpus/wikidata-live.mjs +92 -51
  22. package/src/adapters/memory/blocks.mjs +7 -1
  23. package/src/adapters/memory/core.mjs +505 -107
  24. package/src/adapters/memory/corpus-bands.mjs +27 -10
  25. package/src/adapters/memory/inspect.mjs +24 -5
  26. package/src/adapters/memory/rows.mjs +359 -30
  27. package/src/adapters/memory/shacl.mjs +10 -3
  28. package/src/domain/ask.mjs +27 -10
  29. package/src/domain/cli-verbs.mjs +3 -4
  30. package/src/domain/completions/group.mjs +8 -3
  31. package/src/domain/completions/infer.mjs +7 -2
  32. package/src/domain/completions/prune.mjs +5 -1
  33. package/src/domain/completions/rank.mjs +7 -2
  34. package/src/domain/digest/compose.mjs +5 -1
  35. package/src/domain/digest/select.mjs +12 -6
  36. package/src/domain/domain.mjs +15 -8
  37. package/src/domain/el-classify.mjs +11 -2
  38. package/src/domain/fact-phrase.mjs +86 -4
  39. package/src/domain/hash.mjs +9 -0
  40. package/src/domain/memory/bias.mjs +8 -4
  41. package/src/domain/memory/capability.mjs +12 -6
  42. package/src/domain/memory/fact-order.mjs +29 -0
  43. package/src/domain/memory/resolution.mjs +3 -0
  44. package/src/domain/news-feed.mjs +862 -92
  45. package/src/domain/reference-pack.mjs +5 -0
  46. package/src/domain/sense-gate.mjs +220 -0
  47. package/src/domain/sense-scope.mjs +116 -0
  48. package/src/domain/sense-split.mjs +1 -1
  49. package/src/domain/syllogise.mjs +60 -21
  50. package/src/domain/tableau.mjs +23 -14
  51. package/src/domain/term-ledger.mjs +16 -1
  52. package/src/domain/worlds-pack.mjs +5 -1
  53. package/src/services/adventure-autoplay.mjs +6 -1
  54. package/src/services/adventure-editor.mjs +43 -21
  55. package/src/services/adventure-viz.mjs +26 -9
  56. package/src/services/adventure.mjs +40 -10
  57. package/src/services/chat.mjs +270 -125
  58. package/src/services/extensions.mjs +51 -58
  59. package/src/services/extract-facts.mjs +906 -66
  60. package/src/services/init.mjs +4 -4
  61. package/src/services/ledger-viz.mjs +9 -4
  62. package/src/services/memory-panel-viz.mjs +4 -5
  63. package/src/services/mud-editor.mjs +40 -16
  64. package/src/services/mud-viz.mjs +8 -2
  65. package/src/services/mudiii-turn.mjs +5 -3
  66. package/src/services/mudiii-viz.mjs +8 -2
  67. package/src/services/news.mjs +306 -21
  68. package/src/services/research-viz.mjs +1 -1
  69. package/src/services/sprite-catalog-viz.mjs +10 -5
  70. package/src/surfaces/web/adventure-browser-entry.mjs +6 -12
  71. package/src/surfaces/web/memory-ask-browser.bundle.js +152 -151
  72. package/src/surfaces/web/mud-browser-entry.mjs +7 -11
  73. package/src/surfaces/web/research-browser-entry.mjs +5 -2
  74. package/corpus/tier2/aws.jsonl +0 -39
  75. package/corpus/tier2/java.jsonl +0 -31
  76. package/corpus/tier2/python.jsonl +0 -30
@@ -2,48 +2,6 @@
2
2
  "version": 1,
3
3
  "generated": "by corpus/tier2/generate.mjs",
4
4
  "corpuses": [
5
- {
6
- "id": "aws",
7
- "kind": "domain",
8
- "description": "Amazon Web Services core services and primitives (S3, Lambda, DynamoDB, EC2, IAM, SQS) mapped to general cloud/CS concepts.",
9
- "source": {
10
- "kind": "curated",
11
- "tool": "corpus/tier2/generate.mjs"
12
- },
13
- "file": "aws.jsonl",
14
- "facts": 39,
15
- "bytes": 4802,
16
- "sha256": "7ab3e656bc4bc717be3c50d97af030e8592d6597cde182b15390a1dfaca9755a",
17
- "license": "MPL-2.0"
18
- },
19
- {
20
- "id": "python",
21
- "kind": "language",
22
- "description": "Python language constructs and stdlib types mapped to the shared CS concept vocabulary (list->array, dict->hash table, …).",
23
- "source": {
24
- "kind": "curated",
25
- "tool": "corpus/tier2/generate.mjs"
26
- },
27
- "file": "python.jsonl",
28
- "facts": 30,
29
- "bytes": 3794,
30
- "sha256": "009245d024357bc4285e967fba486e32349ccec8fbe2796ba13011cb2be4c19c",
31
- "license": "MPL-2.0"
32
- },
33
- {
34
- "id": "java",
35
- "kind": "language",
36
- "description": "Java language and JVM constructs mapped to the shared CS concept vocabulary (ArrayList->list, HashMap->hash table, …).",
37
- "source": {
38
- "kind": "curated",
39
- "tool": "corpus/tier2/generate.mjs"
40
- },
41
- "file": "java.jsonl",
42
- "facts": 31,
43
- "bytes": 3920,
44
- "sha256": "d089426833c393f6f1574fe75c4ac94a1483ee80f72f79bd7fd32aaaa24a6e44",
45
- "license": "MPL-2.0"
46
- },
47
5
  {
48
6
  "id": "general",
49
7
  "kind": "domain",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "6.0.18",
3
+ "version": "6.0.20",
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.",
@@ -120,6 +120,8 @@
120
120
  "probe:news-sources": "node scripts/probe-news-sources.mjs",
121
121
  "bench:news": "node scripts/news-bench/run.mjs --seed=xl",
122
122
  "bench:news:fast": "node scripts/news-bench/run.mjs --seed=fixture",
123
+ "bench:news:iterate": "node scripts/news-bench/iterate.mjs",
124
+ "bench:inputs": "node scripts/news-bench/ensure-bench-inputs.mjs",
123
125
  "check:links": "node scripts/check-links.mjs",
124
126
  "check:pii": "node scripts/pii-lint.mjs",
125
127
  "check:pack": "node scripts/check-pack-manifest.mjs",
@@ -138,14 +140,14 @@
138
140
  "chat:repo": "node --disable-warning=ExperimentalWarning bin/tmct.mjs chat --repo",
139
141
  "chat:plain": "node --disable-warning=ExperimentalWarning bin/tmct.mjs chat --plain",
140
142
  "chat:narrate": "node --disable-warning=ExperimentalWarning bin/tmct.mjs chat --narrate",
141
- "init": "node --disable-warning=ExperimentalWarning bin/tmct.mjs init && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus seon && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus conceptnet && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus aws && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus python && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus java",
143
+ "init": "node --disable-warning=ExperimentalWarning bin/tmct.mjs init && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus code && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus conceptnet",
142
144
  "init:small": "node --disable-warning=ExperimentalWarning bin/tmct.mjs init",
143
145
  "init:sqlite": "npm run init:small",
144
146
  "init:persona:human": "node --disable-warning=ExperimentalWarning bin/tmct.mjs init --with-persona human",
145
147
  "init:persona:empty": "node --disable-warning=ExperimentalWarning bin/tmct.mjs init --with-persona empty",
146
148
  "init:large": "npm run init",
147
- "init:xl": "node --disable-warning=ExperimentalWarning bin/tmct.mjs init --persona-size large && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus seon && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus conceptnet && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus aws && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus python && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus java && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus wordnet-xl",
148
- "init:xxl": "node --disable-warning=ExperimentalWarning bin/tmct.mjs init --persona-size large && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus seon && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus conceptnet && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus aws && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus python && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus java && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus wordnet-full && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus namenet",
149
+ "init:xl": "node --disable-warning=ExperimentalWarning bin/tmct.mjs init --persona-size large && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus code && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus conceptnet && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus wordnet-xl && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus namenet && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus child",
150
+ "init:xxl": "node --disable-warning=ExperimentalWarning bin/tmct.mjs init --persona-size large && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus code && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus conceptnet && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus wordnet-full && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus namenet && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus child",
149
151
  "memory": "node --disable-warning=ExperimentalWarning bin/tmct.mjs memory",
150
152
  "memory:verbose": "node --disable-warning=ExperimentalWarning bin/tmct.mjs memory --verbose",
151
153
  "syllogise": "node --disable-warning=ExperimentalWarning bin/tmct.mjs syllogise",
@@ -0,0 +1,74 @@
1
+ // corpus/child-seed.mjs — the BULK reader for the shipped CHILD triples pack
2
+ // (corpus/child/), used when the pack seeds a store as an ordinary corpus band.
3
+ // src/adapters/corpus/child-pack.mjs is the other half: one term at a time, on a
4
+ // miss, for the clean-miss cascade. This one walks every shard once.
5
+ //
6
+ // The shards already carry tmct-vocabulary triples ({subject, predicate,
7
+ // object}), so there is no conceptnet-map.toml step here — the rows come out of
8
+ // the pack build already mapped.
9
+
10
+ import { readFileSync } from "node:fs";
11
+ import { gunzipSync } from "node:zlib";
12
+ import { join } from "node:path";
13
+ import { CHILD_SHARD_COUNT, isChildFactsRow } from "../../domain/child-pack.mjs";
14
+ import { appendNewFacts, preferThenLimit } from "./conceptnet.mjs";
15
+
16
+ /** Every shard basename the pack can hold, in order — the naming contract
17
+ * shardNameFor writes, walked rather than listed, so this reader needs no
18
+ * directory listing and runs in a browser bundle. */
19
+ function shardBasenames() {
20
+ return Array.from(
21
+ { length: CHILD_SHARD_COUNT },
22
+ (_, i) => `child-${i.toString(16).padStart(2, "0")}`,
23
+ );
24
+ }
25
+
26
+ /** Every distinct triple in `packDir`'s shards, in shard then row order, as
27
+ * appendFact-shaped facts. A term appears in one shard as a subject and in
28
+ * another as an object, so the same edge arrives twice — the first spelling
29
+ * wins and the duplicate is dropped here, before the store ever sees it.
30
+ * Absent or malformed shards contribute nothing rather than throwing, the same
31
+ * tolerance the per-term loader gives. */
32
+ export function loadChildPackFacts(packDir, provenancePrefix = "corpus:child") {
33
+ const facts = [];
34
+ const seen = new Set();
35
+ for (const shard of shardBasenames()) {
36
+ let body;
37
+ try {
38
+ body = gunzipSync(readFileSync(join(packDir, "shards", `${shard}.jsonl.gz`))).toString("utf8");
39
+ } catch {
40
+ continue;
41
+ }
42
+ for (const line of body.split("\n")) {
43
+ if (!line.trim()) continue;
44
+ let row;
45
+ try {
46
+ row = JSON.parse(line);
47
+ } catch {
48
+ continue;
49
+ }
50
+ if (!isChildFactsRow(row)) continue;
51
+ for (const f of row.facts) {
52
+ const key = `${f.subject} ${f.predicate} ${f.object}`;
53
+ if (seen.has(key)) continue;
54
+ seen.add(key);
55
+ facts.push({
56
+ subject: f.subject,
57
+ predicate: f.predicate,
58
+ object: f.object,
59
+ provenance: `${provenancePrefix} ${row.term}`,
60
+ });
61
+ }
62
+ }
63
+ }
64
+ return facts;
65
+ }
66
+
67
+ /** Seed `packDir`'s triples into `dir`'s memory through the same idempotent
68
+ * append every other corpus band uses. `limit`/`prefer` behave exactly as they
69
+ * do for a slice-shaped band. Returns { appended, skipped, total }. */
70
+ export async function seedChildPack(dir, { packDir, provenancePrefix, limit, prefer } = {}) {
71
+ const facts = preferThenLimit(loadChildPackFacts(packDir, provenancePrefix), prefer, limit);
72
+ const { appended, skipped } = await appendNewFacts(dir, facts);
73
+ return { appended, skipped, total: facts.length };
74
+ }
@@ -25,7 +25,7 @@ export const MAP_FILE = join(PKG_ROOT, "src", "adapters", "corpus", "conceptnet-
25
25
  // The tier-1 curated Software-Engineering ontology (SEON): concepts.jsonl shares
26
26
  // ConceptNet's slice shape and loads through the same loadSlice/loadMap/toFacts path.
27
27
  // definitions.jsonl is a separate {term, definition, sense} list for lexicon lookups.
28
- // Tier-2 corpuses (aws/python/java) share the slice shape too. The data lives under
28
+ // Tier-2 corpuses share the slice shape too. The data lives under
29
29
  // corpus/domains/code/ (the code domain pack's own directory); the bundle name
30
30
  // "seon" and its "corpus:seon" provenance prefix are unchanged by the move —
31
31
  // both are declared literally in src/services/extensions.mjs, not derived from
@@ -127,35 +127,30 @@ export function toFacts(assertions, map, provenancePrefix = "corpus:conceptnet")
127
127
  return facts;
128
128
  }
129
129
 
130
- /** Seed a repo's memory graph (<dir>/.tmct/memory/graph.json) from the committed slice.
131
- * `limit` caps the facts written; `prefer` stable-partitions facts by predicate (so a
132
- * capped seed favors the definitional band over whatever trivia the slice opens with).
133
- * Idempotent: pre-reads the store to skip triples already there, then writes survivors
134
- * in one batched appendFacts call. Returns { appended, skipped, total }.
135
- * `provenancePrefix` tags facts (default "corpus:conceptnet").
136
- *
137
- * `captureUnknownContext` (default false): also runs unknown-ingest.mjs's
138
- * ingestUnknownFromAssertions so a term that only appears in an `ace="none"` row (never
139
- * reified as a Fact) still lands in memory, tagged with the passage it was found in.
140
- * `unknownContextLimit` bounds how many distinct terms one call captures (default 500).
141
- * Loaded dynamically to avoid a load-time import cycle with unknown-ingest.mjs. */
142
- export async function seedMemory(dir, {
143
- limit, slicePath = SLICE_FILE, mapPath = MAP_FILE, prefer, provenancePrefix,
144
- captureUnknownContext = false, unknownContextLimit,
145
- } = {}) {
146
- const [assertions, map] = await Promise.all([loadSlice(slicePath), loadMap(mapPath)]);
147
- let facts = toFacts(assertions, map, provenancePrefix);
130
+ /** Stable-partition `facts` so the `prefer` predicates come first, then take the
131
+ * first `limit` of them. A capped band therefore buys its definitional backbone
132
+ * before it buys trivia. Either argument may be absent. */
133
+ export function preferThenLimit(facts, prefer, limit) {
134
+ let out = facts;
148
135
  if (Array.isArray(prefer) && prefer.length) {
149
136
  const rank = new Map(prefer.map((p, i) => [p, i]));
150
- facts = facts.slice().sort((a, b) => (rank.get(a.predicate) ?? prefer.length) - (rank.get(b.predicate) ?? prefer.length));
137
+ out = out.slice().sort((a, b) => (rank.get(a.predicate) ?? prefer.length) - (rank.get(b.predicate) ?? prefer.length));
151
138
  }
152
- if (limit !== undefined) facts = facts.slice(0, limit);
139
+ return limit === undefined ? out : out.slice(0, limit);
140
+ }
141
+
142
+ // Keyed with normFactTerm so it matches the store's own normalized read-back.
143
+ const factKey = (s, p, o) => `${normFactTerm(s)} ${p} ${normFactTerm(o)}`;
153
144
 
154
- // Keyed with normFactTerm so it matches the store's own normalized read-back.
155
- const factKey = (s, p, o) => `${normFactTerm(s)} ${p} ${normFactTerm(o)}`;
145
+ /** Write every fact `dir`'s store does not already hold, in one batched append,
146
+ * so re-seeding a band is idempotent and a term shared by two bands converges
147
+ * to one fact. `memory` is the store's already-loaded contents (callers that
148
+ * need it for their own work pass theirs rather than paying a second read).
149
+ * Returns { appended, skipped }. */
150
+ export async function appendNewFacts(dir, facts, memory) {
151
+ const store = memory ?? await loadMemory(dir);
156
152
  const existing = new Set();
157
- const memory = await loadMemory(dir);
158
- for (const ind of memory.individuals || []) {
153
+ for (const ind of store.individuals || []) {
159
154
  if (ind?.class !== "Fact") continue;
160
155
  const get = (key) => (ind.attributes || []).find((x) => x.key === key)?.value;
161
156
  existing.add(factKey(get("subject"), get("predicate"), get("object")));
@@ -173,6 +168,30 @@ export async function seedMemory(dir, {
173
168
  toWrite.push(fact);
174
169
  }
175
170
  const res = await appendFacts(dir, toWrite);
171
+ return { appended: res.appended, skipped: skipped + res.skipped };
172
+ }
173
+
174
+ /** Seed a repo's memory graph (<dir>/.tmct/memory/graph.json) from the committed slice.
175
+ * `limit` caps the facts written; `prefer` stable-partitions facts by predicate (so a
176
+ * capped seed favors the definitional band over whatever trivia the slice opens with).
177
+ * Idempotent: pre-reads the store to skip triples already there, then writes survivors
178
+ * in one batched appendFacts call. Returns { appended, skipped, total }.
179
+ * `provenancePrefix` tags facts (default "corpus:conceptnet").
180
+ *
181
+ * `captureUnknownContext` (default false): also runs unknown-ingest.mjs's
182
+ * ingestUnknownFromAssertions so a term that only appears in an `ace="none"` row (never
183
+ * reified as a Fact) still lands in memory, tagged with the passage it was found in.
184
+ * `unknownContextLimit` bounds how many distinct terms one call captures (default 500).
185
+ * Loaded dynamically to avoid a load-time import cycle with unknown-ingest.mjs. */
186
+ export async function seedMemory(dir, {
187
+ limit, slicePath = SLICE_FILE, mapPath = MAP_FILE, prefer, provenancePrefix,
188
+ captureUnknownContext = false, unknownContextLimit,
189
+ } = {}) {
190
+ const [assertions, map] = await Promise.all([loadSlice(slicePath), loadMap(mapPath)]);
191
+ const facts = preferThenLimit(toFacts(assertions, map, provenancePrefix), prefer, limit);
192
+
193
+ const memory = await loadMemory(dir);
194
+ const { appended, skipped } = await appendNewFacts(dir, facts, memory);
176
195
 
177
196
  let unknown;
178
197
  if (captureUnknownContext) {
@@ -185,7 +204,7 @@ export async function seedMemory(dir, {
185
204
  }
186
205
 
187
206
  return {
188
- appended: res.appended, skipped: skipped + res.skipped, total: facts.length,
207
+ appended, skipped, total: facts.length,
189
208
  ...(unknown ? { unknown } : {}),
190
209
  };
191
210
  }
@@ -77,9 +77,13 @@ export function isResearchSource(source) {
77
77
  * `research:<source>:<folded term>`. memory/trust.mjs reads the `research:`
78
78
  * prefix back as a referenceLive Source, so a live-fetched fact scores below
79
79
  * every curated pack, and the source segment keeps which adapter fetched it
80
- * readable off the fact itself. */
80
+ * readable off the fact itself. A multi-word term's internal spaces fold to
81
+ * underscores (ConceptNet's own /c/en/foo_bar convention, which normFactTerm
82
+ * already unwinds on the way in) so the tag stays one whitespace-free token —
83
+ * the same shape every other single-segment provenance tag carries, and safe
84
+ * under a caller that reads only the tag's first whitespace-split word. */
81
85
  export function researchSourceTag(sourceName, term) {
82
- return `research:${sourceName}:${normFactTerm(term)}`;
86
+ return `research:${sourceName}:${normFactTerm(term).replace(/ /g, "_")}`;
83
87
  }
84
88
 
85
89
  /** The facts a looked-up row licenses, each stamped with the source's own tag.
@@ -1,5 +1,5 @@
1
- // corpus/wikidata-live.mjs — the live Wikidata research source. Three small
2
- // GET round trips against www.wikidata.org's Action API, mapped onto tmct's
1
+ // corpus/wikidata-live.mjs — the live Wikidata research source. Small GET
2
+ // round trips against www.wikidata.org's Action API, mapped onto tmct's
3
3
  // seed-ontology relations at THIS boundary and nowhere else: everything
4
4
  // downstream reads ordinary tmct facts and never learns the word "Wikidata".
5
5
  //
@@ -11,12 +11,17 @@
11
11
  // own rate policy and a SPARQL string built per term.
12
12
  //
13
13
  // The round trips:
14
- // 1. wbsearchentities — the item whose English label matches the term.
15
- // 2. wbgetentities — that item's label, description, revision, claims.
16
- // 3. wbgetentities — the English labels of the mapped claims' object
17
- // items, batched into ONE request. A claim's value is
18
- // a Q-id, and a stored fact's object has to be a human
19
- // term. Skipped when nothing mapped.
14
+ // 1. wbsearchentities — every candidate item whose English label matches
15
+ // the term, best fold first.
16
+ // 2. wbgetentities — a candidate's label, description, revision, claims.
17
+ // 3. wbgetentities — the English labels of that candidate's mapped
18
+ // claims' object items, batched into ONE request. A
19
+ // claim's value is a Q-id, and a stored fact's object
20
+ // has to be a human term. Skipped when nothing
21
+ // mapped.
22
+ // Steps 2 and 3 repeat, candidate by candidate, only while the current one
23
+ // turns out to be a media or document work sharing the term's name rather
24
+ // than the term itself — the common case still costs three round trips.
20
25
  //
21
26
  // Courtesy is structural, mirroring wikipedia-live.mjs: one in-flight lookup
22
27
  // at a time, a minimum interval between round trips, a 429/maxlag cool-off
@@ -72,6 +77,29 @@ export const WIKIDATA_PROPERTY_RELATIONS = Object.freeze({
72
77
  // it would store from a prose lead sentence.
73
78
  const ISA_PROPERTIES = ["P279", "P31"];
74
79
 
80
+ // A closed list of Wikidata item classes that name a media or document work,
81
+ // not the everyday concept a term search asked for. A search on "canadian
82
+ // companies" or "continents" can land on a paper or an album that merely
83
+ // SHARES the term's name — Wikidata's own title match, not a definition.
84
+ // Folded through normFactTerm the same way every isa term is, so the check
85
+ // compares like with like. Each class is a Wikidata English label read
86
+ // straight off the item, not a guess at one — refine this list from what
87
+ // Wikidata actually returns, keep it named and small.
88
+ const MEDIA_WORK_CLASSES = new Set([
89
+ "scholarly article",
90
+ "album",
91
+ "song",
92
+ "single",
93
+ "film",
94
+ "television series",
95
+ "television series episode",
96
+ "band",
97
+ "musical group",
98
+ "video game",
99
+ "book",
100
+ "novel",
101
+ ]);
102
+
75
103
  // How much of one item a single lookup reads: at most this many object values
76
104
  // per property, and this many facts in total. A busy item like "human" carries
77
105
  // hundreds of statements, and a research lookup wants the shape of the thing,
@@ -81,23 +109,27 @@ const MAX_FACTS_PER_ITEM = 12;
81
109
 
82
110
  const ITEM_ID_RE = /^Q[1-9][0-9]*$/;
83
111
 
84
- /** The searched item whose English label folds onto the key, or null — the
112
+ /** Every candidate item whose English label folds onto the key, exact folds
113
+ * first then prefix folds, each group in the search result's own order — the
85
114
  * topic-drift guard, matching wikipedia-live.mjs's: "quasar" may resolve to
86
115
  * "quasar" or "quasars", never to the first suggestion about something else.
87
116
  * An exact fold beats a prefix fold wherever it appears in the result list,
88
117
  * so a search that ranks "Quasars (album)" above "quasar" still lands on the
89
- * term the caller asked for. */
90
- function matchingItemId(key, body) {
118
+ * term the caller asked for first. Returning every candidate, not just the
119
+ * best one, lets the caller step to the next title match when the best one
120
+ * turns out to be a media work sharing the name. */
121
+ function candidateItemIds(key, body) {
91
122
  const results = Array.isArray(body?.search) ? body.search : [];
92
- let prefixMatch = null;
123
+ const exact = [];
124
+ const prefix = [];
93
125
  for (const hit of results) {
94
126
  const id = String(hit?.id ?? "");
95
127
  if (!ITEM_ID_RE.test(id)) continue;
96
128
  const folded = normFactTerm(hit?.label ?? "");
97
- if (folded === key) return id;
98
- if (prefixMatch === null && folded.startsWith(key)) prefixMatch = id;
129
+ if (folded === key) exact.push(id);
130
+ else if (folded.startsWith(key)) prefix.push(id);
99
131
  }
100
- return prefixMatch;
132
+ return [...exact, ...prefix];
101
133
  }
102
134
 
103
135
  /** Every mapped claim on an entity as {predicate, id} pairs, capped per
@@ -178,6 +210,11 @@ export function createWikidataLiveProvider({
178
210
  return termById;
179
211
  }
180
212
 
213
+ /** The looked-up item, or null when every candidate that matched the
214
+ * search either has no readable entity or turns out to be a media/document
215
+ * work sharing the term's name — a media-class isa is a wrong identity, not
216
+ * a definition, so the caller steps to the next title match instead of
217
+ * accepting it. Exhausting every candidate this way is the term missing. */
181
218
  async function roundTrips(key) {
182
219
  const search = await gate.fetchJson(actionUrl({
183
220
  action: "wbsearchentities",
@@ -187,45 +224,49 @@ export function createWikidataLiveProvider({
187
224
  limit: "5",
188
225
  search: key,
189
226
  }));
190
- const id = search ? matchingItemId(key, search) : null;
191
- if (!id) return null;
227
+ const candidateIds = search ? candidateItemIds(key, search) : [];
192
228
 
193
- const read = await gate.fetchJson(actionUrl({
194
- action: "wbgetentities",
195
- languages: "en",
196
- props: "labels|descriptions|claims|info",
197
- ids: id,
198
- }));
199
- const entity = read?.entities?.[id];
200
- if (!entity) return null;
229
+ for (const id of candidateIds) {
230
+ const read = await gate.fetchJson(actionUrl({
231
+ action: "wbgetentities",
232
+ languages: "en",
233
+ props: "labels|descriptions|claims|info",
234
+ ids: id,
235
+ }));
236
+ const entity = read?.entities?.[id];
237
+ if (!entity) continue;
201
238
 
202
- const claims = mappedClaims(entity);
203
- const termById = await termsForIds(claims.map((c) => c.id));
204
- const provenance = researchSourceTag(sourceName, key);
205
- const facts = [];
206
- const seen = new Set();
207
- for (const claim of claims) {
208
- const object = termById.get(claim.id);
209
- if (!object || object === key || seen.has(`${claim.predicate}\0${object}`)) continue;
210
- seen.add(`${claim.predicate}\0${object}`);
211
- facts.push({ subject: key, predicate: claim.predicate, object, provenance });
212
- }
239
+ const claims = mappedClaims(entity);
240
+ const termById = await termsForIds(claims.map((c) => c.id));
241
+ const isa = isaFrom(claims, termById, key);
242
+ if (isa && MEDIA_WORK_CLASSES.has(isa)) continue;
213
243
 
214
- const description = String(entity.descriptions?.en?.value ?? "");
215
- const row = {
216
- term: key,
217
- title: String(entity.labels?.en?.value ?? ""),
218
- text: description,
219
- summary: sentencesUpTo(description, SUMMARY_CHAR_CAP),
220
- url: `${origin}/wiki/${id}`,
221
- revid: Number(entity.lastrevid),
222
- source: WIKIDATA_SOURCE_LABEL,
223
- licence: WIKIDATA_LICENCE,
224
- };
225
- const isa = isaFrom(claims, termById, key);
226
- if (isa) row.isa = isa;
227
- if (facts.length) row.facts = facts;
228
- return isResearchSourceRow(row) ? row : null;
244
+ const provenance = researchSourceTag(sourceName, key);
245
+ const facts = [];
246
+ const seen = new Set();
247
+ for (const claim of claims) {
248
+ const object = termById.get(claim.id);
249
+ if (!object || object === key || seen.has(`${claim.predicate}\0${object}`)) continue;
250
+ seen.add(`${claim.predicate}\0${object}`);
251
+ facts.push({ subject: key, predicate: claim.predicate, object, provenance });
252
+ }
253
+
254
+ const description = String(entity.descriptions?.en?.value ?? "");
255
+ const row = {
256
+ term: key,
257
+ title: String(entity.labels?.en?.value ?? ""),
258
+ text: description,
259
+ summary: sentencesUpTo(description, SUMMARY_CHAR_CAP),
260
+ url: `${origin}/wiki/${id}`,
261
+ revid: Number(entity.lastrevid),
262
+ source: WIKIDATA_SOURCE_LABEL,
263
+ licence: WIKIDATA_LICENCE,
264
+ };
265
+ if (isa) row.isa = isa;
266
+ if (facts.length) row.facts = facts;
267
+ if (isResearchSourceRow(row)) return row;
268
+ }
269
+ return null;
229
270
  }
230
271
 
231
272
  return {
@@ -27,6 +27,12 @@ const MAX_TOKENS_PER_BLOCK = 800; // beyond tokenizeProse's per-doc cap: union o
27
27
 
28
28
  const blocksDir = (dir) => join(dir, BLOCKS_DIR_REL);
29
29
 
30
+ // Codepoint order, never localeCompare — the block ids this settles ties on
31
+ // decide WHICH blocks retrieveBlocks hands back, so a locale-dependent
32
+ // comparison would let two readers retrieve different text from one store.
33
+ // src/domain/memory/fact-order.mjs states the rule for the fact rows.
34
+ const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
35
+
30
36
  /** Only safe, filesystem-friendly block file names (ids are session uuids or
31
37
  * corpus slugs; anything else is normalized, never trusted into a path). */
32
38
  const safeName = (id) => String(id).replace(/[^A-Za-z0-9._-]+/g, "_").slice(0, 120) || "_";
@@ -216,7 +222,7 @@ export async function retrieveBlocks(dir, query, k = 3) {
216
222
  id, score: (idfSum * (1 + rank) * trustFactor) / Math.sqrt(1 + degree), rank, trust, file: b.file,
217
223
  });
218
224
  }
219
- scored.sort((a, b) => b.score - a.score || b.rank - a.rank || a.id.localeCompare(b.id));
225
+ scored.sort((a, b) => b.score - a.score || b.rank - a.rank || byCodepoint(a.id, b.id));
220
226
  const top = scored.slice(0, Math.max(1, k));
221
227
  for (const hit of top) {
222
228
  try { hit.text = await readFile(join(blocksDir(dir), hit.file), "utf8"); }