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

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.17",
3
+ "version": "6.0.19",
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",
@@ -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 {
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { access, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
18
18
  import { dirname, join } from "node:path";
19
- import { proseTokensFor, buildProseIndex } from "../../domain/prose.mjs";
19
+ import { proseTokensFor } from "../../domain/prose.mjs";
20
20
  import { fnv1aHex, normText, normFactTerm, normFactPredicate, factIdFor, factIdForTriple } from "../../domain/hash.mjs";
21
21
 
22
22
  // Fact identity (normalization + id derivation) lives in hash.mjs — the one
@@ -68,7 +68,7 @@ export {
68
68
  // fine as long as neither side READS an imported binding while the other's
69
69
  // body is still running — rows.mjs builds its class map on first use, and
70
70
  // every use here is inside a function.
71
- import { payloadToRows, rowsToPayload, diffRows, renormalizeAssembledPayload } from "./rows.mjs";
71
+ import { payloadToRows, rowsToPayload, diffRows, renormalizeAssembledPayload, renormalizeProseIndex } from "./rows.mjs";
72
72
 
73
73
  // The rollup vocabulary and its tuning constants live with the compaction
74
74
  // layer; re-exported here so store consumers keep one import site.
@@ -235,6 +235,14 @@ export function isMemoryOrSqliteHandle(dir) {
235
235
  return isMemoryHandle(dir) || isSqliteHandle(dir) || isRowHandle(dir);
236
236
  }
237
237
 
238
+ /** Move a store handle's write stamp on. `foldedFactRows` keys the fold it
239
+ * holds to this number, so anything that changes what the store would fold to
240
+ * — a landed write, a payload about to be mutated in place, a dropped
241
+ * assembly, a seed assigned over the top — stamps here first. */
242
+ function stampStoreWrite(dir) {
243
+ if (isMemoryOrSqliteHandle(dir)) dir.storeWrites = (dir.storeWrites || 0) + 1;
244
+ }
245
+
238
246
  /** Backend B — pure in-memory store: `{ backend: "memory", payload }` held by
239
247
  * the caller (never module-global). Zero file I/O; distinct from
240
248
  * `--ephemeral`, which still round-trips a throwaway temp dir. */
@@ -250,7 +258,9 @@ export function createInMemoryStore() {
250
258
  * `seedPayload` is null/undefined — a browser session with nothing to seed
251
259
  * keeps its own fresh empty payload untouched. */
252
260
  export function applySeedPayload(memoryDir, seedPayload) {
253
- if (seedPayload) memoryDir.payload = { ...memoryDir.payload, ...seedPayload };
261
+ if (!seedPayload) return;
262
+ memoryDir.payload = { ...memoryDir.payload, ...seedPayload };
263
+ stampStoreWrite(memoryDir);
254
264
  }
255
265
 
256
266
  /** A structurally independent copy of a memory payload — `structuredClone`
@@ -923,6 +933,7 @@ function mutablePayloadCopy(payload) {
923
933
  * coherent graph. */
924
934
  function dropAssembledRowPayload(handle) {
925
935
  if (!isRowHandle(handle)) return;
936
+ stampStoreWrite(handle);
926
937
  handle.cachedPayload = null;
927
938
  handle.storedRows = null;
928
939
  handle.baseRows = null;
@@ -1271,7 +1282,12 @@ const cloneJson = (v) => (v === undefined ? v : structuredClone(v));
1271
1282
  * persist would delete them as absent-from-payload. */
1272
1283
  function readSqlitePayload(handle) {
1273
1284
  const dataVersion = handle.db.prepare("PRAGMA data_version").get()?.data_version;
1274
- if (handle.cachedPayload && handle.cachedDataVersion !== dataVersion) handle.cachedPayload = null;
1285
+ if (handle.cachedPayload && handle.cachedDataVersion !== dataVersion) {
1286
+ // Another connection committed, so the fold this handle holds describes a
1287
+ // store that no longer exists — it goes with the payload it was taken of.
1288
+ stampStoreWrite(handle);
1289
+ handle.cachedPayload = null;
1290
+ }
1275
1291
  if (!handle.cachedPayload) {
1276
1292
  handle.cachedPayload = buildSqlitePayloadFromRows(handle);
1277
1293
  // The head index rides the same cache lifecycle as the payload it indexes,
@@ -2198,6 +2214,7 @@ function migrateFactAssertionKeys(payload) {
2198
2214
  * SQL write (Backend C, persistSqlitePayload), or a diffed row write into an
2199
2215
  * injected store (Backend D, persistRowPayload). */
2200
2216
  async function persistMemory(dir, payload) {
2217
+ stampStoreWrite(dir);
2201
2218
  if (isMemoryHandle(dir)) { dir.payload = payload; return; }
2202
2219
  if (isSqliteHandle(dir)) { persistSqlitePayload(dir, payload); return; }
2203
2220
  if (isRowHandle(dir)) { await persistRowPayload(dir, payload); return; }
@@ -2372,12 +2389,16 @@ const memoryIndexOf = (payload) => payload?.[MEMORY_INDEX] || null;
2372
2389
  async function mutateMemory(dir, fn) {
2373
2390
  const overRowHandle = isRowHandle(dir);
2374
2391
  const payload = overRowHandle ? mutablePayloadCopy(await ensureRowPayload(dir)) : await loadMemory(dir);
2392
+ // Stamped before `fn` runs as well as after it lands: Backend B's own
2393
+ // `loadMemory` hands back the live payload, so the store stops matching any
2394
+ // fold taken of it the moment `fn` starts changing it.
2395
+ stampStoreWrite(dir);
2375
2396
  try {
2376
2397
  buildMemoryIndex(payload);
2377
2398
  const out = (await fn(payload)) ?? payload;
2378
2399
  migrateLegacyProvenance(out);
2379
2400
  recomputeSourceReliability(out);
2380
- if (!overRowHandle) out.proseIndex = buildProseIndex(out.individuals);
2401
+ if (!overRowHandle) renormalizeProseIndex(out);
2381
2402
  await persistMemory(dir, out);
2382
2403
  return overRowHandle ? dir.cachedPayload : out;
2383
2404
  } catch (e) {
@@ -3867,6 +3888,33 @@ export function readFactRows(memory, opts = {}) {
3867
3888
  return foldFactRows(memory, factFoldContext(memory), opts);
3868
3889
  }
3869
3890
 
3891
+ /** `readFactRows` over a whole store, held between writes.
3892
+ *
3893
+ * Folding the graph is the most expensive read tmct does, and the fold is a
3894
+ * pure function of the payload — so between two writes every caller asking for
3895
+ * it is asking the same question. This answers it once. `stampStoreWrite`
3896
+ * moves the stamp the held fold is keyed to, at both ends of `mutateMemory`
3897
+ * (the single seam every backend's writes pass through) and wherever else a
3898
+ * handle's payload is replaced or dropped, so a fold taken before a write can
3899
+ * never be served after one.
3900
+ *
3901
+ * A repo-path dir has no handle to hold anything on, and another process can
3902
+ * write its file between two reads, so it folds fresh every call exactly as
3903
+ * before. */
3904
+ export async function foldedFactRows(dir) {
3905
+ if (!isMemoryOrSqliteHandle(dir)) return readFactRows(await loadMemory(dir));
3906
+ const stamp = dir.storeWrites || 0;
3907
+ if (dir.heldFactRows && dir.heldFactRowsStamp === stamp) return dir.heldFactRows;
3908
+ const rows = readFactRows(await loadMemory(dir));
3909
+ // A write that landed while this fold was running has already moved the
3910
+ // stamp; holding these rows would serve that write's own reader stale ones.
3911
+ if ((dir.storeWrites || 0) === stamp) {
3912
+ dir.heldFactRows = rows;
3913
+ dir.heldFactRowsStamp = stamp;
3914
+ }
3915
+ return rows;
3916
+ }
3917
+
3870
3918
  /** The fold itself, over whatever slice of the graph a context was built for.
3871
3919
  * `readFactRows` hands it the whole graph; a caller that only needs certain
3872
3920
  * (subject, predicate) pairs hands it a scoped context and gets exactly the