@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 +3 -1
- package/src/adapters/corpus/research-source.mjs +6 -2
- package/src/adapters/corpus/wikidata-live.mjs +92 -51
- package/src/adapters/memory/core.mjs +53 -5
- package/src/adapters/memory/rows.mjs +253 -21
- package/src/domain/news-feed.mjs +790 -75
- package/src/domain/sense-gate.mjs +220 -0
- package/src/domain/syllogise.mjs +39 -8
- package/src/domain/term-ledger.mjs +16 -1
- package/src/services/chat.mjs +17 -12
- package/src/services/extract-facts.mjs +284 -19
- package/src/services/news-viz.mjs +31 -0
- package/src/services/news.mjs +67 -14
- package/src/surfaces/web/memory-ask-browser.bundle.js +142 -142
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/the-mechanical-code-talker",
|
|
3
|
-
"version": "6.0.
|
|
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.
|
|
2
|
-
//
|
|
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 —
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
98
|
-
if (
|
|
129
|
+
if (folded === key) exact.push(id);
|
|
130
|
+
else if (folded.startsWith(key)) prefix.push(id);
|
|
99
131
|
}
|
|
100
|
-
return
|
|
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
|
|
191
|
-
if (!id) return null;
|
|
227
|
+
const candidateIds = search ? candidateItemIds(key, search) : [];
|
|
192
228
|
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
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)
|
|
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)
|
|
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)
|
|
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
|