@polycode-projects/the-mechanical-code-talker 3.0.1 → 3.0.2

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.
@@ -0,0 +1,38 @@
1
+ // digest/index.mjs — the digest layer's public seam: run the pure pipeline
2
+ // (select -> structures -> compose -> article) end to end. Stage 5 (the chat
3
+ // term answer, the research panel, `tmct digest`) becomes thin wiring over
4
+ // this: build the fact rows and the store statistics from the graph it already
5
+ // holds, hand them in, and render the returned article shape.
6
+ //
7
+ // Everything here is pure and deterministic. The structure table arrives
8
+ // pre-parsed (the same injection posture the construction banks use), so this
9
+ // module — and the whole layer — imports nothing outside src/domain.
10
+
11
+ import { selectFacts, FAMILY_OF, FAMILY_PRIORITY } from "./select.mjs";
12
+ import { buildStructureTable, renderStructure } from "./structures.mjs";
13
+ import { composeTermDigest } from "./compose.mjs";
14
+ import { termArticle, researchRunArticle, sessionDigestArticle } from "./article.mjs";
15
+
16
+ export { selectFacts, FAMILY_OF, FAMILY_PRIORITY };
17
+ export { buildStructureTable, renderStructure };
18
+ export { composeTermDigest };
19
+ export { termArticle, researchRunArticle, sessionDigestArticle };
20
+
21
+ /**
22
+ * Digest one term end to end.
23
+ *
24
+ * @param term the normalized term the digest is about.
25
+ * @param rows its candidate fact rows (readFactRows() rows with subject===term).
26
+ * @param store the store-relative statistics selectFacts() needs
27
+ * ({ classSubjectCounts, totalSubjects, subClassEdges, disjointEdges }).
28
+ * @param table a structure table (buildStructureTable() over the parsed bank).
29
+ * @param opts { budget, config, chains, maxSentencesPerParagraph } — threaded
30
+ * to the stages that use them.
31
+ * @returns the term-article shape, carrying the narrative, its sources, and the
32
+ * full fact list behind the "show the facts" escape.
33
+ */
34
+ export function digestTerm(term, rows, store = {}, table = new Map(), opts = {}) {
35
+ const selection = selectFacts(term, rows, store, opts);
36
+ const composed = composeTermDigest(selection, table, opts);
37
+ return termArticle(selection, composed, opts);
38
+ }
@@ -0,0 +1,233 @@
1
+ // digest/select.mjs — stage 1 of the digest layer: score the candidate fact
2
+ // rows for one term and cut them to a budget. Pure and deterministic over the
3
+ // rows and a store-relative statistics bundle the caller supplies; imports only
4
+ // its sibling domain modules (the trust priors and the sense-split machinery),
5
+ // never the filesystem or the store adapter.
6
+ //
7
+ // The scoring signals, all already present on a readFactRows() row or cheaply
8
+ // derivable from a store scan:
9
+ // - provenance tier: a researched or taught fact outranks an entailed one,
10
+ // read from the row's source-type priors (memory/trust.mjs).
11
+ // - chain depth: a stated fact outranks one the closure derived (a row with a
12
+ // non-empty premise environment is entailed, and demoted).
13
+ // - informativeness: a class nearly every subject in the store shares carries
14
+ // almost no information about THIS term. A store-relative frequency share
15
+ // over a committed threshold CUTS the row — this is what removes "a kind of
16
+ // entity" without a hand-maintained stoplist.
17
+ // - relation coverage: the cut prefers breadth — one good isa, one partOf and
18
+ // one capableOf beat four isa chains — by filling families round-robin.
19
+ // - sense agreement: an isa object whose sense cluster disagrees with the
20
+ // term's dominant sense is demoted (the "medium"/"software" branch the
21
+ // specimen shows), scored through sense-split.mjs's own clustering.
22
+ //
23
+ // Nothing is destroyed. Every row the budget or a cut removes is returned in
24
+ // `cut` with a reason, so "show the facts" downstream reaches the full list and
25
+ // the ranking stays auditable.
26
+
27
+ import { SOURCE_PRIOR } from "../memory/trust.mjs";
28
+ import { clusterSenses } from "../sense-split.mjs";
29
+ import DEFAULT_CONFIG from "./config.json" with { type: "json" };
30
+
31
+ // A relation predicate -> the sentence-family key the digest groups it under.
32
+ // Deliberately small: the families stage 2 authors structures for today. A
33
+ // predicate with no entry lands in "other" — still selectable, still carried
34
+ // into the detail, but never leading the narrative.
35
+ // Each family is VERB-COHERENT: the predicates inside it share one sentence
36
+ // frame, so stage 2 can author one structure per family without the object
37
+ // preposition guessing which verb it belongs to. A predicate with no entry
38
+ // lands in "other" — still selectable, still carried into the detail, but never
39
+ // leading the narrative.
40
+ export const FAMILY_OF = Object.freeze({
41
+ "rdfs:subClassOf": "isa",
42
+ "rdf:type": "isa",
43
+ "mgx:atLocation": "location",
44
+ "mgx:locatedNear": "location",
45
+ "mgx:partOf": "partOf",
46
+ "mgx:capableOf": "capableOf",
47
+ "mgx:usedFor": "usedFor",
48
+ });
49
+
50
+ // The order families lead the digest in: the definition (isa) first, then where
51
+ // it sits, what it belongs to, what it can do, what it is used for, then
52
+ // anything else. The round-robin cut pulls from families in this order, so a tie
53
+ // in score never makes the ordering non-deterministic.
54
+ export const FAMILY_PRIORITY = Object.freeze(["isa", "location", "partOf", "capableOf", "usedFor", "other"]);
55
+
56
+ const familyOf = (predicate) => FAMILY_OF[predicate] || "other";
57
+ const clamp01 = (n) => Math.max(0, Math.min(1, n));
58
+
59
+ /** The provenance component of a row's score: the strongest source-type prior
60
+ * the row carries (a taught fact's 0.95 beats an entailed fact's 0.3), falling
61
+ * back to the row's materialised trust, then to the entailed floor. */
62
+ function provenanceScore(row) {
63
+ const priors = (row.sourceTypes || []).map((t) => SOURCE_PRIOR[t] ?? 0);
64
+ if (priors.length) return Math.max(...priors);
65
+ if (typeof row.trust === "number" && row.trust > 0) return row.trust;
66
+ return SOURCE_PRIOR.entailed;
67
+ }
68
+
69
+ /** A row the closure derived rather than a source stated: it carries a premise
70
+ * environment, or an entailed source type. */
71
+ function isEntailed(row) {
72
+ if (Array.isArray(row.environments) && row.environments.length) return true;
73
+ return (row.sourceTypes || []).includes("entailed");
74
+ }
75
+
76
+ /** The store-relative share of subjects that carry this class as an isa target.
77
+ * Above the committed threshold (in a store large enough for the share to
78
+ * mean anything) the class is uninformative and its row is cut. */
79
+ function classShare(object, store) {
80
+ const total = Number(store?.totalSubjects) || 0;
81
+ if (total <= 0) return 0;
82
+ const count = Number(store?.classSubjectCounts?.[object]) || 0;
83
+ return count / total;
84
+ }
85
+
86
+ /**
87
+ * Select the digest-worthy facts for `term` from its candidate `rows`.
88
+ *
89
+ * `store` supplies the store-relative signals the scoring needs, all derivable
90
+ * from one pass over the graph the caller already holds:
91
+ * - classSubjectCounts: { class -> number of distinct subjects that are isa it }
92
+ * - totalSubjects: number of distinct isa subjects in the store
93
+ * - subClassEdges: [[child, parent], …] for the sense clustering
94
+ * - disjointEdges: [[a, b], …] stored owl:disjointWith pairs (optional)
95
+ *
96
+ * `opts`: { budget (default config.budget.chatReply), config (threshold data) }.
97
+ *
98
+ * Returns { term, selected, cut, senses }:
99
+ * - selected: up to `budget` items { row, family, score, reasons }, ranked,
100
+ * breadth-first across families. Each carries its fact row, so every
101
+ * downstream sentence traces back to stored provenance.
102
+ * - cut: every other row { row, reason } — nothing is discarded silently.
103
+ * - senses: the sense-split verdict over the term's isa objects.
104
+ */
105
+ export function selectFacts(term, rows, store = {}, opts = {}) {
106
+ const config = { ...DEFAULT_CONFIG, ...(opts.config || {}) };
107
+ const budget = Number.isInteger(opts.budget) ? opts.budget : config.budget.chatReply;
108
+ const candidates = (rows || []).filter((r) => r && r.object && r.object !== term);
109
+ const cut = [];
110
+
111
+ // The uninformative-class cut runs FIRST, before sense clustering. A class
112
+ // nearly every subject shares carries no information about this term — and,
113
+ // just as important, a bare uninformative class with no recorded ancestry
114
+ // would act as a union bridge in the clustering below, collapsing genuinely
115
+ // distinct senses into one. Removing it first keeps the sense signal clean.
116
+ const bigEnough = (Number(store?.totalSubjects) || 0) >= config.uninformativeClassMinSubjects;
117
+ const informative = [];
118
+ for (const row of candidates) {
119
+ if (familyOf(row.predicate) === "isa") {
120
+ const share = classShare(row.object, store);
121
+ if (bigEnough && share > config.uninformativeClassMaxShare) {
122
+ cut.push({ row, family: "isa", reason: "uninformative-class", share });
123
+ continue;
124
+ }
125
+ }
126
+ informative.push(row);
127
+ }
128
+
129
+ // Sense clustering over the SURVIVING isa objects — the dominant sense is the
130
+ // cluster whose objects carry the most provenance weight; objects outside it
131
+ // are the demoted branch (the specimen's "medium"/"software" fan-out).
132
+ const isaObjects = informative.filter((r) => familyOf(r.predicate) === "isa").map((r) => r.object);
133
+ const senses = clusterSenses(isaObjects, {
134
+ subClassEdges: store.subClassEdges || [],
135
+ disjointEdges: store.disjointEdges || [],
136
+ });
137
+ const clusterWeight = new Map(); // label -> summed provenance of its objects
138
+ const labelOfObject = new Map(); // object -> its cluster label
139
+ for (const cluster of senses.clusters) {
140
+ let weight = 0;
141
+ for (const obj of cluster.objects) {
142
+ labelOfObject.set(obj, cluster.label);
143
+ const row = informative.find((r) => r.object === obj && familyOf(r.predicate) === "isa");
144
+ weight += row ? provenanceScore(row) : 0;
145
+ }
146
+ clusterWeight.set(cluster.label, weight);
147
+ }
148
+ let dominantLabel = null;
149
+ let bestWeight = -1;
150
+ for (const [label, weight] of [...clusterWeight].sort((a, b) => a[0].localeCompare(b[0]))) {
151
+ if (weight > bestWeight) { bestWeight = weight; dominantLabel = label; }
152
+ }
153
+
154
+ const scored = [];
155
+ for (const row of informative) {
156
+ const family = familyOf(row.predicate);
157
+ let score = provenanceScore(row);
158
+ const reasons = { provenance: score };
159
+ if (isEntailed(row)) { score -= config.entailedDepthPenalty; reasons.entailed = true; }
160
+ if (family === "isa") {
161
+ const share = classShare(row.object, store);
162
+ // A bounded weight in [0.6, 1.0]: a rarer class says more about the term
163
+ // and ranks higher, but a common one is only demoted, never zeroed — the
164
+ // hard removal of a truly uninformative class is the cut above, not this.
165
+ const informativeness = 0.6 + 0.4 * (1 - share);
166
+ score *= informativeness;
167
+ reasons.informativeness = informativeness;
168
+ const label = labelOfObject.get(row.object);
169
+ if (dominantLabel && label && label !== dominantLabel) {
170
+ score -= config.minoritySensePenalty;
171
+ reasons.minoritySense = label;
172
+ }
173
+ }
174
+ score = clamp01(score);
175
+ scored.push({ row, family, score, reasons });
176
+ }
177
+
178
+ // A row scored below the floor (a demoted minority sense, a weak entailed
179
+ // chain) is cut, not held for backfill — the budget stays unfilled rather than
180
+ // padding the narrative with a fact that barely earned a place. Nothing is
181
+ // lost: it is still returned in `cut`, still reachable through the escape.
182
+ const eligible = [];
183
+ for (const item of scored) {
184
+ if (item.score < config.minScore) cut.push({ row: item.row, family: item.family, reason: "below-floor", score: item.score });
185
+ else eligible.push(item);
186
+ }
187
+
188
+ // Breadth-first cut to budget: each family sorted by score (tie broken by
189
+ // object label for determinism), then pulled round-robin in family priority
190
+ // order so the digest covers relations before it repeats one.
191
+ const byFamily = new Map();
192
+ for (const item of eligible) {
193
+ if (!byFamily.has(item.family)) byFamily.set(item.family, []);
194
+ byFamily.get(item.family).push(item);
195
+ }
196
+ for (const list of byFamily.values()) {
197
+ list.sort((a, b) => b.score - a.score || a.row.object.localeCompare(b.row.object));
198
+ }
199
+ const orderedFamilies = FAMILY_PRIORITY.filter((f) => byFamily.has(f))
200
+ .concat([...byFamily.keys()].filter((f) => !FAMILY_PRIORITY.includes(f)).sort());
201
+ const selected = [];
202
+ let drained = false;
203
+ while (selected.length < budget && !drained) {
204
+ drained = true;
205
+ for (const family of orderedFamilies) {
206
+ const list = byFamily.get(family);
207
+ if (list && list.length) {
208
+ selected.push(list.shift());
209
+ drained = false;
210
+ if (selected.length >= budget) break;
211
+ }
212
+ }
213
+ }
214
+ // Whatever the round-robin left over is ranked detail, not noise — returned
215
+ // as budget-cut, still carrying its score, so the escape reaches it in order.
216
+ for (const family of orderedFamilies) {
217
+ for (const item of byFamily.get(family) || []) {
218
+ cut.push({ row: item.row, family: item.family, reason: "over-budget", score: item.score });
219
+ }
220
+ }
221
+ selected.sort((a, b) => {
222
+ const fa = FAMILY_PRIORITY.indexOf(a.family);
223
+ const fb = FAMILY_PRIORITY.indexOf(b.family);
224
+ return (fa - fb) || (b.score - a.score) || a.row.object.localeCompare(b.row.object);
225
+ });
226
+
227
+ return {
228
+ term,
229
+ selected,
230
+ cut,
231
+ senses: { split: senses.split, clusters: senses.clusters, dominantLabel },
232
+ };
233
+ }
@@ -0,0 +1,77 @@
1
+ // digest/store-stats.mjs — the store-relative statistics the selector needs,
2
+ // derived in one pass over the fact rows a caller already holds. Pure and
3
+ // deterministic; imports only its sibling sense-split helpers, so it links into
4
+ // a browser bundle the same way the rest of the digest layer does (the pages
5
+ // digest client-side from an embedded structure table).
6
+ //
7
+ // selectFacts() scores an isa row partly by how many subjects across the store
8
+ // share its object as a class: a class nearly everything is cut as
9
+ // uninformative. That count, the total isa-subject population, and the
10
+ // subClassOf/disjointWith edges the sense clustering walks are all one scan of
11
+ // the rows away — this module is that scan, kept out of the pure selector so
12
+ // the selector stays a function of its inputs.
13
+
14
+ import { subClassParents, ancestryChain } from "../sense-split.mjs";
15
+
16
+ // The predicates the digest groups under the isa family (kept in step with
17
+ // select.mjs's FAMILY_OF): a subclass edge and a type edge both name a class
18
+ // the subject belongs to.
19
+ const ISA_PREDICATES = new Set(["rdfs:subClassOf", "rdf:type"]);
20
+
21
+ /**
22
+ * The store-relative statistics selectFacts() reads, from one pass over `rows`:
23
+ * - classSubjectCounts: { class -> distinct subjects that are isa it }
24
+ * - totalSubjects: distinct subjects that carry any isa fact
25
+ * - subClassEdges: [[child, parent], …] for the sense clustering
26
+ * - disjointEdges: [[a, b], …] stored owl:disjointWith pairs
27
+ */
28
+ export function digestStoreStats(rows) {
29
+ const classSubjects = new Map(); // class -> Set(subject)
30
+ const isaSubjects = new Set();
31
+ const subClassEdges = [];
32
+ const disjointEdges = [];
33
+ for (const r of rows || []) {
34
+ if (!r || !r.predicate) continue;
35
+ if (ISA_PREDICATES.has(r.predicate)) {
36
+ isaSubjects.add(r.subject);
37
+ if (!classSubjects.has(r.object)) classSubjects.set(r.object, new Set());
38
+ classSubjects.get(r.object).add(r.subject);
39
+ subClassEdges.push([r.subject, r.object]);
40
+ } else if (r.predicate === "owl:disjointWith") {
41
+ disjointEdges.push([r.subject, r.object]);
42
+ }
43
+ }
44
+ const classSubjectCounts = {};
45
+ for (const [cls, subs] of classSubjects) classSubjectCounts[cls] = subs.size;
46
+ return { classSubjectCounts, totalSubjects: isaSubjects.size, subClassEdges, disjointEdges };
47
+ }
48
+
49
+ /**
50
+ * Ancestry chains for a set of isa objects, keyed object -> [object, parent, …],
51
+ * so a lone isa fact can render as "a mammal, and so an animal". Built from the
52
+ * store's subClassOf edges; an object with no recorded parent is omitted.
53
+ */
54
+ export function chainsForObjects(subClassEdges, objects, cap = 6) {
55
+ const parents = subClassParents(subClassEdges || []);
56
+ const chains = {};
57
+ for (const obj of objects || []) {
58
+ if (obj in chains) continue;
59
+ const chain = ancestryChain(obj, parents, { cap });
60
+ if (chain.length > 1) chains[obj] = chain;
61
+ }
62
+ return chains;
63
+ }
64
+
65
+ /** The isa objects among a term's rows, first-seen order — the set worth an
66
+ * ancestry chain. */
67
+ export function isaObjectsOf(rows) {
68
+ const out = [];
69
+ const seen = new Set();
70
+ for (const r of rows || []) {
71
+ if (!r || !ISA_PREDICATES.has(r.predicate)) continue;
72
+ if (seen.has(r.object)) continue;
73
+ seen.add(r.object);
74
+ out.push(r.object);
75
+ }
76
+ return out;
77
+ }
@@ -0,0 +1,100 @@
1
+ // digest/structures.mjs — stage 2 of the digest layer: validate the
2
+ // hand-authored sentence-structure bank and render one clause from a set of
3
+ // facts of a single relation family. Pure and deterministic; the raw parsed
4
+ // TOML rides in from a composition point exactly the way the construction banks
5
+ // do (the strategy never reads the filesystem), so this module imports only its
6
+ // sibling word helpers.
7
+
8
+ import { articleFor, pluralOf, capitalizeFirst, series } from "./words.mjs";
9
+
10
+ const VALID_FAMILIES = new Set(["isa", "location", "partOf", "capableOf", "usedFor"]);
11
+ const VALID_FORMS = new Set(["single", "several", "chained"]);
12
+
13
+ const keyFor = (family, form) => `${family}:${form}`;
14
+
15
+ /**
16
+ * Validate and index the raw [[structure]] rows into a Map keyed by
17
+ * `family:form`. Closed-vocabulary discipline, same as the construction banks:
18
+ * an unknown family or form, or a missing template, drops the row rather than
19
+ * coercing it. First occurrence of a (family, form) wins; a later duplicate is
20
+ * ignored.
21
+ */
22
+ export function buildStructureTable(structures) {
23
+ const table = new Map();
24
+ for (const s of structures || []) {
25
+ if (!s || typeof s.family !== "string" || !VALID_FAMILIES.has(s.family)) continue;
26
+ if (typeof s.form !== "string" || !VALID_FORMS.has(s.form)) continue;
27
+ if (typeof s.template !== "string" || !s.template.trim()) continue;
28
+ const key = keyFor(s.family, s.form);
29
+ if (table.has(key)) continue;
30
+ table.set(key, { family: s.family, form: s.form, template: s.template });
31
+ }
32
+ return table;
33
+ }
34
+
35
+ const withArticle = (word) => `${articleFor(word)} ${word}`;
36
+
37
+ /** Render "a mammal, and so an animal" from an ancestry chain [mammal, animal, …]. */
38
+ function renderChain(chain) {
39
+ const parts = (chain || []).map((c) => String(c || "").trim()).filter(Boolean);
40
+ if (!parts.length) return "";
41
+ const head = withArticle(parts[0]);
42
+ const rest = parts.slice(1);
43
+ if (!rest.length) return head;
44
+ return `${head}, and so ${series(rest.map(withArticle))}`;
45
+ }
46
+
47
+ /** The slot values for one render, derived from the term, its objects and an
48
+ * optional ancestry chain. A placeholder with no derived value renders empty. */
49
+ function slotsFor(term, objects, chain) {
50
+ const t = String(term || "").trim();
51
+ const objs = (objects || []).map((o) => String(o || "").trim()).filter(Boolean);
52
+ const first = objs[0] || "";
53
+ return {
54
+ TERM: t,
55
+ TERM_CAP: capitalizeFirst(t),
56
+ A_TERM: withArticle(t),
57
+ A_TERM_CAP: capitalizeFirst(withArticle(t)),
58
+ TERMS: pluralOf(t),
59
+ TERMS_CAP: capitalizeFirst(pluralOf(t)),
60
+ PRONOUN: "it",
61
+ PRONOUN_CAP: "It",
62
+ OBJECT: first,
63
+ A_OBJECT: first ? withArticle(first) : "",
64
+ OBJECTS_A: series(objs.map(withArticle)),
65
+ OBJECTS_PLURAL: series(objs.map(pluralOf)),
66
+ OBJECTS_RAW: series(objs),
67
+ CHAIN: renderChain(chain && chain.length ? chain : objs.slice(0, 1)),
68
+ };
69
+ }
70
+
71
+ /** Substitute every `{SLOT}` in `template` with its value (empty when absent),
72
+ * then collapse any doubled spaces the empty slots left. */
73
+ function fill(template, slots) {
74
+ return String(template)
75
+ .replace(/\{([A-Z_]+)\}/g, (_, name) => (name in slots ? slots[name] : ""))
76
+ .replace(/\s+/g, " ")
77
+ .replace(/\s+([.,;])/g, "$1")
78
+ .trim();
79
+ }
80
+
81
+ /**
82
+ * Render one clause for `facts` (all of one family) using the structure the
83
+ * table holds for (family, form). `form` defaults by fact count — one fact is
84
+ * "single", more is "several" — unless the caller names a form (e.g. "chained"
85
+ * with an ancestry chain). Returns { text, rows, family, form } so the sentence
86
+ * traces to the exact fact rows behind it, or null when no structure matches or
87
+ * there are no facts.
88
+ */
89
+ export function renderStructure(table, family, facts, opts = {}) {
90
+ const rows = (facts || []).filter(Boolean);
91
+ if (!rows.length) return null;
92
+ const term = opts.term ?? rows[0].subject ?? "";
93
+ const form = opts.form || (rows.length > 1 ? "several" : "single");
94
+ const entry = table instanceof Map ? table.get(keyFor(family, form)) : null;
95
+ if (!entry) return null;
96
+ const objects = rows.map((r) => r.object);
97
+ const text = fill(entry.template, slotsFor(term, objects, opts.chain));
98
+ if (!text) return null;
99
+ return { text, rows, family, form };
100
+ }
@@ -0,0 +1,40 @@
1
+ // digest/words.mjs — the small, self-contained English surface helpers the
2
+ // digest layer needs to turn a bare class label into a readable noun phrase:
3
+ // the indefinite article, a regular plural, and first-letter casing. Kept
4
+ // local (not imported from inflect.mjs) so the whole digest layer ships as one
5
+ // self-contained unit — inflect.mjs is excluded from the published package.
6
+
7
+ const VOWEL_START = /^[aeiou]/i;
8
+
9
+ /** "a" or "an" for the following word, by its leading letter. A blank word
10
+ * yields "a" (the caller never renders a phrase around an empty label). */
11
+ export function articleFor(word) {
12
+ return VOWEL_START.test(String(word || "").trim()) ? "an" : "a";
13
+ }
14
+
15
+ /** The regular English plural of a lemma — the same -s/-es/-ies rules the rest
16
+ * of the codebase uses, no irregular table. */
17
+ export function pluralOf(word) {
18
+ const w = String(word || "").trim();
19
+ if (!w) return w;
20
+ if (/(?:s|x|z|ch|sh)$/.test(w)) return `${w}es`;
21
+ if (/[^aeiou]y$/.test(w)) return `${w.slice(0, -1)}ies`;
22
+ return `${w}s`;
23
+ }
24
+
25
+ /** Upper-case the first character only, leaving the rest of the phrase as-is
26
+ * (so "aardvark" -> "Aardvark", but "DNA sequence" keeps its inner caps). */
27
+ export function capitalizeFirst(text) {
28
+ const t = String(text || "");
29
+ return t ? t[0].toUpperCase() + t.slice(1) : t;
30
+ }
31
+
32
+ /** Join a list of phrases as an English series: "a", "a and b",
33
+ * "a, b, and c" (Oxford comma, deterministic). */
34
+ export function series(items) {
35
+ const parts = (items || []).map((s) => String(s || "").trim()).filter(Boolean);
36
+ if (parts.length === 0) return "";
37
+ if (parts.length === 1) return parts[0];
38
+ if (parts.length === 2) return `${parts[0]} and ${parts[1]}`;
39
+ return `${parts.slice(0, -1).join(", ")}, and ${parts[parts.length - 1]}`;
40
+ }
Binary file
@@ -28,6 +28,7 @@ import * as defaultSource from "../adapters/source.mjs";
28
28
  import { resolveExtensions, mergedLexiconExtra } from "./extensions.mjs";
29
29
  import { runTurn, hasSeededVocabulary, vocabExampleHint } from "./chat.mjs";
30
30
  import { resolveGameConfig } from "../domain/game-config.mjs";
31
+ import { emptyRecord, resolveDiscourseConfig } from "../domain/discourse.mjs";
31
32
  import { resolveResearchConfig } from "./research.mjs";
32
33
  import { sessionLogHeaderMarkdown, sessionLogTurnMarkdown, sessionLogEndMarkdown } from "./session-log-format.mjs";
33
34
 
@@ -351,6 +352,9 @@ export async function createSession({
351
352
  let last = null; // the last dispatched answer ({query,answer,detail}) — why/say-more re-renders it
352
353
  let planState = null; // the in-progress plan (goals/moves/cursor) — cleared by completion or a fresh goal, never by an aside
353
354
  let researchState = null; // the in-progress research queue — advanced by "research next", cleared by completion or "research stop"
355
+ // The typed discourse record ([discourse] max_referents caps it) — session-scoped
356
+ // like the focus, threaded turn to turn, never persisted.
357
+ let discourseRecord = emptyRecord(resolveDiscourseConfig(toml));
354
358
  let closed = false;
355
359
 
356
360
  return {
@@ -375,7 +379,7 @@ export async function createSession({
375
379
  async turn(line) {
376
380
  let result;
377
381
  try {
378
- result = await runTurn(line, { config, source, graph, focus, last, memoryDir, sessionId, env, lexicon, narrate: narrateOn, liveReference: liveReferenceOn, vocabHint, tel, biasByBundle, planState, gameConfig, researchState, researchConfig });
382
+ result = await runTurn(line, { config, source, graph, focus, last, memoryDir, sessionId, env, lexicon, narrate: narrateOn, liveReference: liveReferenceOn, vocabHint, tel, biasByBundle, planState, gameConfig, researchState, researchConfig, discourse: discourseRecord });
379
383
  } catch (e) {
380
384
  const ts = new Date().toISOString();
381
385
  const message = e instanceof Error ? e.message : String(e);
@@ -391,6 +395,7 @@ export async function createSession({
391
395
  last = nextLast;
392
396
  if ("planState" in result) planState = result.planState;
393
397
  if ("researchState" in result) researchState = result.researchState;
398
+ if ("discourse" in result) discourseRecord = result.discourse;
394
399
  // /narrate on|off and /wiki on|off (runCommand) ride the turn RESULT the
395
400
  // same way a focus update does — apply them to this handle's
396
401
  // session-scoped state.