@polycode-projects/the-mechanical-code-talker 2.6.1 → 2.7.1
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/README.md +46 -1
- package/corpus/LICENSES.json +14 -0
- package/corpus/child/LICENSE-NOTICE +43 -0
- package/corpus/child/README.md +79 -0
- package/corpus/child/index.json.gz +0 -0
- package/corpus/child/manifest.json +313 -0
- package/corpus/child/shards/child-00.jsonl.gz +0 -0
- package/corpus/child/shards/child-01.jsonl.gz +0 -0
- package/corpus/child/shards/child-02.jsonl.gz +0 -0
- package/corpus/child/shards/child-03.jsonl.gz +0 -0
- package/corpus/child/shards/child-04.jsonl.gz +0 -0
- package/corpus/child/shards/child-05.jsonl.gz +0 -0
- package/corpus/child/shards/child-06.jsonl.gz +0 -0
- package/corpus/child/shards/child-07.jsonl.gz +0 -0
- package/corpus/child/shards/child-08.jsonl.gz +0 -0
- package/corpus/child/shards/child-09.jsonl.gz +0 -0
- package/corpus/child/shards/child-0a.jsonl.gz +0 -0
- package/corpus/child/shards/child-0b.jsonl.gz +0 -0
- package/corpus/child/shards/child-0c.jsonl.gz +0 -0
- package/corpus/child/shards/child-0d.jsonl.gz +0 -0
- package/corpus/child/shards/child-0e.jsonl.gz +0 -0
- package/corpus/child/shards/child-0f.jsonl.gz +0 -0
- package/corpus/child/shards/child-10.jsonl.gz +0 -0
- package/corpus/child/shards/child-11.jsonl.gz +0 -0
- package/corpus/child/shards/child-12.jsonl.gz +0 -0
- package/corpus/child/shards/child-13.jsonl.gz +0 -0
- package/corpus/child/shards/child-14.jsonl.gz +0 -0
- package/corpus/child/shards/child-15.jsonl.gz +0 -0
- package/corpus/child/shards/child-16.jsonl.gz +0 -0
- package/corpus/child/shards/child-17.jsonl.gz +0 -0
- package/corpus/child/shards/child-18.jsonl.gz +0 -0
- package/corpus/child/shards/child-19.jsonl.gz +0 -0
- package/corpus/child/shards/child-1a.jsonl.gz +0 -0
- package/corpus/child/shards/child-1b.jsonl.gz +0 -0
- package/corpus/child/shards/child-1c.jsonl.gz +0 -0
- package/corpus/child/shards/child-1d.jsonl.gz +0 -0
- package/corpus/child/shards/child-1e.jsonl.gz +0 -0
- package/corpus/child/shards/child-1f.jsonl.gz +0 -0
- package/corpus/conceptnet/child-seed.mjs +169 -0
- package/corpus/conceptnet/filter-dump.mjs +69 -48
- package/corpus/worlds/README.md +26 -0
- package/corpus/worlds/index.json.gz +0 -0
- package/corpus/worlds/manifest.json +33 -0
- package/corpus/worlds/shards/ashcombe-hall.jsonl.gz +0 -0
- package/corpus/worlds/src/ashcombe-hall.jsonl +64 -0
- package/package.json +4 -1
- package/src/adapters/corpus/child-pack.mjs +115 -0
- package/src/adapters/corpus/conceptnet-map.toml +7 -0
- package/src/adapters/corpus/worlds-pack.mjs +122 -0
- package/src/domain/ask-vocab.mjs +10 -2
- package/src/domain/ask.mjs +6 -0
- package/src/domain/child-pack.mjs +79 -0
- package/src/domain/grammar/ace.mjs +77 -0
- package/src/domain/grammar/lexicon-core.json +6 -0
- package/src/domain/interpret/normalize.mjs +32 -8
- package/src/domain/memory/trust.mjs +17 -0
- package/src/domain/worlds-pack.mjs +71 -0
- package/src/services/adventure.mjs +718 -0
- package/src/services/chat.mjs +137 -31
- package/src/surfaces/web/memory-ask-browser.bundle.js +555 -46
- package/src/tools/definitions.mjs +2 -2
package/src/services/chat.mjs
CHANGED
|
@@ -48,8 +48,11 @@ import { loadLexicon, lookupNoun } from "../domain/grammar/lexicon.mjs";
|
|
|
48
48
|
import { pickPhrase } from "../domain/answer-variants.mjs";
|
|
49
49
|
import { REFERENCE_PACK_NAME, cleanMissReferenceTerm, renderReferenceAnswer, referenceProvenanceTag } from "../domain/reference-pack.mjs";
|
|
50
50
|
import { getReferencePackProvider } from "../adapters/corpus/reference-pack.mjs";
|
|
51
|
+
import { CHILD_PACK_NAME, childProvenanceTag } from "../domain/child-pack.mjs";
|
|
52
|
+
import { getChildPackProvider } from "../adapters/corpus/child-pack.mjs";
|
|
51
53
|
import { dialogueActForLane } from "../domain/dialogue-acts.mjs";
|
|
52
54
|
import { relatedForTerm } from "../domain/skos-view.mjs";
|
|
55
|
+
import { adventureTurn } from "./adventure.mjs";
|
|
53
56
|
|
|
54
57
|
// Composition: the chat surface supplies the domain parser's default lemma/POS
|
|
55
58
|
// adapter (the browser bundle's ask-nlp stub carries no factory, so this is a
|
|
@@ -2793,6 +2796,16 @@ const GENERAL_VERB_DETERMINER_TEACH_RE = new RegExp(
|
|
|
2793
2796
|
* stored, and cited, as "len"). */
|
|
2794
2797
|
const QUANTIFIED_HAS_TEACH_RE = /^(every|each|all)\s+([\w'-]+)\s+(?:has|have)\s+(.+?)[.!?]*$/i;
|
|
2795
2798
|
const quantifiedHasSubject = (m) => (/^all$/i.test(m[1]) ? singularizeSurface(m[2]) : m[2]);
|
|
2799
|
+
/** The OBJECT side of the same fold: "all dogs have tails" states one tail
|
|
2800
|
+
* per dog, so the plural sentence form's object stores as its singular
|
|
2801
|
+
* ("tail" — the spelling the have-questions and the seeded corpus's own hasA
|
|
2802
|
+
* facts read back). Only the "all" form folds, the same gate the subject
|
|
2803
|
+
* uses: "every"/"each" take singular grammar, so their object's number is
|
|
2804
|
+
* the speaker's own ("every dog has fur"). The last word folds so a
|
|
2805
|
+
* modified object ("all dogs have long tails") keeps its modifier. */
|
|
2806
|
+
const quantifiedHasObject = (m) => (/^all$/i.test(m[1])
|
|
2807
|
+
? m[3].replace(/[\w'-]+$/, (w) => singularizeSurface(w))
|
|
2808
|
+
: m[3]);
|
|
2796
2809
|
/** Verbs owned by an earlier, more specific recognizer in this lane — is/are
|
|
2797
2810
|
* (class-membership/property, above) and owns/maintains (ownership, above).
|
|
2798
2811
|
* generalVerbTeach declines outright on these so it can never race a more
|
|
@@ -2941,7 +2954,7 @@ async function generalVerbTeach(payload) {
|
|
|
2941
2954
|
if (quantHas) {
|
|
2942
2955
|
subjectRaw = quantifiedHasSubject(quantHas);
|
|
2943
2956
|
verbRaw = "has";
|
|
2944
|
-
objectRaw = quantHas
|
|
2957
|
+
objectRaw = quantifiedHasObject(quantHas);
|
|
2945
2958
|
} else {
|
|
2946
2959
|
const det = p.match(GENERAL_VERB_DETERMINER_TEACH_RE);
|
|
2947
2960
|
if (!det) return null; // not a bare-name subject, and no preposition to pin the verb
|
|
@@ -9044,14 +9057,15 @@ async function curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon })
|
|
|
9044
9057
|
return { text: `${def} (source: corpus/seon)`, term };
|
|
9045
9058
|
}
|
|
9046
9059
|
|
|
9047
|
-
// ---- learn-on-miss: the shipped reference
|
|
9060
|
+
// ---- learn-on-miss: the shipped child + reference packs behind the cleanest miss ----
|
|
9048
9061
|
|
|
9049
|
-
/** The learn-on-miss gate, shared by the
|
|
9050
|
-
* fallback so the
|
|
9051
|
-
*
|
|
9052
|
-
* no remembered fact — then, and
|
|
9053
|
-
*
|
|
9054
|
-
|
|
9062
|
+
/** The learn-on-miss gate, shared by the child-pack hook, the articled
|
|
9063
|
+
* reference hook and the bare-form fallback so the three can never disagree.
|
|
9064
|
+
* Passes only on the CLEANEST miss: a definition-shaped term the lexicon
|
|
9065
|
+
* knows, resolving to no graph entity and no remembered fact — then, and
|
|
9066
|
+
* only then, may a pack provider be consulted. Null means the turn proceeds
|
|
9067
|
+
* byte-identically to a pack-less run. */
|
|
9068
|
+
async function cleanMissPackKey(term, { graph, memoryDir, lexicon, cache }) {
|
|
9055
9069
|
if (!term || !memoryDir) return null;
|
|
9056
9070
|
let key = null;
|
|
9057
9071
|
try { key = cleanMissReferenceTerm(term, lexicon ?? undefined); } catch { key = null; }
|
|
@@ -9063,12 +9077,43 @@ async function referencePackMissAnswer(term, { graph, memoryDir, lexicon, env, c
|
|
|
9063
9077
|
variants.add(key);
|
|
9064
9078
|
const rows = await factRows(memoryDir, cache);
|
|
9065
9079
|
if (rows.some((f) => variants.has(f.subject) || variants.has(f.object))) return null;
|
|
9080
|
+
return key;
|
|
9081
|
+
}
|
|
9082
|
+
|
|
9083
|
+
/** The reference-pack lookup for an already-gated key: the article, or null
|
|
9084
|
+
* (absent pack, missing term, any read failure — all byte-identical). */
|
|
9085
|
+
async function referencePackAnswerForKey(key, env) {
|
|
9066
9086
|
let article = null;
|
|
9067
9087
|
try { article = await getReferencePackProvider(env).lookup(key); } catch { article = null; }
|
|
9068
9088
|
if (!article) return null;
|
|
9069
9089
|
return { key, article, text: renderReferenceAnswer(key, article) };
|
|
9070
9090
|
}
|
|
9071
9091
|
|
|
9092
|
+
/** The gate + the reference lookup in one call, for a caller that has a term
|
|
9093
|
+
* rather than a gated key. */
|
|
9094
|
+
async function referencePackMissAnswer(term, { graph, memoryDir, lexicon, env, cache }) {
|
|
9095
|
+
const key = await cleanMissPackKey(term, { graph, memoryDir, lexicon, cache });
|
|
9096
|
+
return key ? referencePackAnswerForKey(key, env) : null;
|
|
9097
|
+
}
|
|
9098
|
+
|
|
9099
|
+
/** The child-pack half of learn-on-miss, for an already-gated key: look the
|
|
9100
|
+
* key up in the shipped child triples pack and append every fact under child
|
|
9101
|
+
* provenance, so the SAME question can be re-asked from the store. Null on a
|
|
9102
|
+
* pack miss or any failure — the turn then proceeds byte-identically. */
|
|
9103
|
+
async function childPackFactsForKey(key, { memoryDir, env, cache }) {
|
|
9104
|
+
let row = null;
|
|
9105
|
+
try { row = await getChildPackProvider(env).lookup(key); } catch { row = null; }
|
|
9106
|
+
if (!row?.facts?.length) return null;
|
|
9107
|
+
try {
|
|
9108
|
+
const { appendFacts } = await import("../adapters/memory/core.mjs");
|
|
9109
|
+
await appendFacts(memoryDir, row.facts.map(({ subject, predicate, object }) => ({
|
|
9110
|
+
subject, predicate, object, provenance: childProvenanceTag(key),
|
|
9111
|
+
})));
|
|
9112
|
+
} catch { return null; }
|
|
9113
|
+
if (cache) cache.rows = null;
|
|
9114
|
+
return { key, count: row.facts.length };
|
|
9115
|
+
}
|
|
9116
|
+
|
|
9072
9117
|
/** Store the article's first-sentence isa as a subClassOf fact carrying
|
|
9073
9118
|
* reference provenance — AFTER the cited answer composed, and failure-
|
|
9074
9119
|
* tolerated: the answer stands whether or not the fact lands. */
|
|
@@ -10491,9 +10536,12 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
10491
10536
|
// actually stores it, the ORIGINAL ask-engine answer is restored unchanged
|
|
10492
10537
|
// (see "COLLISION RESTORE" below).
|
|
10493
10538
|
const trimmedQuery = String(query).trim();
|
|
10494
|
-
|
|
10539
|
+
// Boolean() and not a bare && chain: with no memoryDir the chain would
|
|
10540
|
+
// short-circuit to null, and `miss` below (askMiss || THIS) would record a
|
|
10541
|
+
// null miss flag on every relaxation-rescued turn of a memory-less session.
|
|
10542
|
+
const relaxedTeachCollision = Boolean(!!(envelope?.relaxed?.dropped?.length) && !askMiss && memoryDir
|
|
10495
10543
|
&& !QUESTION_LEAD_RE.test(trimmedQuery) && !/\?\s*$/.test(trimmedQuery)
|
|
10496
|
-
&& DECLARATIVE_KIND_OF_RE.test(trimmedQuery);
|
|
10544
|
+
&& DECLARATIVE_KIND_OF_RE.test(trimmedQuery));
|
|
10497
10545
|
const preCollisionAnswer = relaxedTeachCollision ? answer : null;
|
|
10498
10546
|
const miss = askMiss || relaxedTeachCollision;
|
|
10499
10547
|
// Answer provenance (W1): "composed" is the ask engine's productive band; the
|
|
@@ -10788,13 +10836,23 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
10788
10836
|
const def = await curatedDefinitionAnswer(factQuery, envelope, { memoryDir, lexicon });
|
|
10789
10837
|
if (def) bareMetaHit = { text: def.text, replace: true };
|
|
10790
10838
|
}
|
|
10791
|
-
// The
|
|
10792
|
-
// under the IDENTICAL clean-miss gate the articled hook (4h)
|
|
10793
|
-
// "what is otter" reaches the
|
|
10839
|
+
// The learn-on-miss packs' bare-form fallback, beside the curated one
|
|
10840
|
+
// and under the IDENTICAL clean-miss gate the articled hook (4h)
|
|
10841
|
+
// applies — "what is otter" reaches the packs exactly as "what is an
|
|
10842
|
+
// otter" does, child triples first, article prose second.
|
|
10794
10843
|
if (!bareMetaHit) {
|
|
10795
10844
|
const refTerm = metaTermOf(factQuery, envelope);
|
|
10796
|
-
const
|
|
10797
|
-
|
|
10845
|
+
const key = refTerm ? await cleanMissPackKey(refTerm, { graph, memoryDir, lexicon, cache }) : null;
|
|
10846
|
+
const learned = key ? await childPackFactsForKey(key, { memoryDir, env, cache }) : null;
|
|
10847
|
+
if (learned) {
|
|
10848
|
+
const fact = (await factAnswer(memoryDir, factQuery, envelope, miss, biasByBundle, cache, newFocus?.label))
|
|
10849
|
+
?? (await factReadBack(memoryDir, factQuery, envelope, miss, graph, newFocus?.label, biasByBundle, cache));
|
|
10850
|
+
if (fact && !fact.miss) bareMetaHit = { text: fact.text, replace: true, child: learned };
|
|
10851
|
+
}
|
|
10852
|
+
if (!bareMetaHit && key) {
|
|
10853
|
+
const ref = await referencePackAnswerForKey(key, env);
|
|
10854
|
+
if (ref) bareMetaHit = { text: ref.text, replace: true, reference: ref };
|
|
10855
|
+
}
|
|
10798
10856
|
}
|
|
10799
10857
|
}
|
|
10800
10858
|
// A bare "what is X" naming a REAL code-graph entity (not a taught fact,
|
|
@@ -10850,8 +10908,13 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
10850
10908
|
if (!bareMetaHit.miss) { via = "fact"; recordMiss = false; }
|
|
10851
10909
|
handled = true;
|
|
10852
10910
|
if (bareMetaHit.pending) factPending = bareMetaHit.pending;
|
|
10853
|
-
|
|
10854
|
-
|
|
10911
|
+
if (bareMetaHit.child) {
|
|
10912
|
+
note(trace, "lane: (2b) CHILD PACK — a bare \"what is X\" clean miss pulled the term's triples from the shipped child pack into memory, and the question was re-answered from the store");
|
|
10913
|
+
note(trace, `source: child pack ${CHILD_PACK_NAME} — ${bareMetaHit.child.count} fact(s) appended as ${childProvenanceTag(bareMetaHit.child.key)}, answer served from .tmct/memory Facts`);
|
|
10914
|
+
} else {
|
|
10915
|
+
note(trace, "lane: (2b) BARE META FACT — \"what is X\" (no article) / \"is X <adjective>\" resolved to a remembered fact before the conversational catch-all could claim it");
|
|
10916
|
+
note(trace, "source: .tmct/memory Facts (see /memory for provenance per line)");
|
|
10917
|
+
}
|
|
10855
10918
|
} else if (isConversationalCandidate && habitualGroundingHint) {
|
|
10856
10919
|
// A bare habitual teach ("penguins swim") naming a subject grounded
|
|
10857
10920
|
// nowhere: an honest, actionable grounding hint beats the orientation
|
|
@@ -11211,22 +11274,40 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
11211
11274
|
note(trace, `lane: (4g) FUZZY-VERB DECLINE — "${from}" only became a verb through the edit-distance repair tier ("${to}"), and the two words are different verbs, so the repaired sentence's graph answer is dropped rather than shown as an answer to what was typed`);
|
|
11212
11275
|
}
|
|
11213
11276
|
}
|
|
11214
|
-
// (4h)
|
|
11215
|
-
//
|
|
11216
|
-
// remembered fact.
|
|
11217
|
-
// a
|
|
11218
|
-
//
|
|
11219
|
-
//
|
|
11277
|
+
// (4h) LEARN-ON-MISS PACKS — the cleanest miss consults the shipped packs:
|
|
11278
|
+
// a definition-shaped term the lexicon knows, no graph entity, no
|
|
11279
|
+
// remembered fact. The CHILD triples pack goes first (facts before prose):
|
|
11280
|
+
// a hit appends the term's triples under child provenance and the SAME
|
|
11281
|
+
// question is re-asked from the store, so the answer is an ordinary cited
|
|
11282
|
+
// fact answer. Only when the store still cannot answer does the reference
|
|
11283
|
+
// pack's article speak, cited as before. Both packs missing leaves the
|
|
11284
|
+
// honest miss byte-identical.
|
|
11220
11285
|
if (miss && recordMiss && via === "composed" && memoryDir) {
|
|
11221
11286
|
const refTerm = metaTermOf(query, envelope);
|
|
11222
|
-
const
|
|
11223
|
-
|
|
11224
|
-
|
|
11225
|
-
|
|
11226
|
-
|
|
11227
|
-
|
|
11228
|
-
|
|
11229
|
-
|
|
11287
|
+
const key = refTerm ? await cleanMissPackKey(refTerm, { graph, memoryDir, lexicon, cache }) : null;
|
|
11288
|
+
const learned = key ? await childPackFactsForKey(key, { memoryDir, env, cache }) : null;
|
|
11289
|
+
if (learned) {
|
|
11290
|
+
const fact = (await factAnswer(memoryDir, query, envelope, miss, biasByBundle, cache, newFocus?.label))
|
|
11291
|
+
?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label, biasByBundle, cache));
|
|
11292
|
+
if (fact && !fact.miss) {
|
|
11293
|
+
answer = fact.replace ? fact.text : `${answer}\n${fact.text}`;
|
|
11294
|
+
via = "fact";
|
|
11295
|
+
recordMiss = false;
|
|
11296
|
+
if (fact.pending) factPending = fact.pending;
|
|
11297
|
+
note(trace, "lane: (4h) CHILD PACK — a clean miss on a lexicon term pulled the term's triples from the shipped child pack into memory, and the question was re-answered from the store");
|
|
11298
|
+
note(trace, `source: child pack ${CHILD_PACK_NAME} — ${learned.count} fact(s) appended as ${childProvenanceTag(key)}, answer served from .tmct/memory Facts`);
|
|
11299
|
+
}
|
|
11300
|
+
}
|
|
11301
|
+
if (miss && recordMiss && via === "composed" && key) {
|
|
11302
|
+
const ref = await referencePackAnswerForKey(key, env);
|
|
11303
|
+
if (ref) {
|
|
11304
|
+
answer = ref.text;
|
|
11305
|
+
via = "reference";
|
|
11306
|
+
recordMiss = false;
|
|
11307
|
+
note(trace, "lane: (4h) REFERENCE PACK — a clean miss on a lexicon term answered from the shipped reference pack, cited");
|
|
11308
|
+
note(trace, `source: reference pack ${REFERENCE_PACK_NAME} — article "${ref.article.title}" (revid ${ref.article.revid})`);
|
|
11309
|
+
await appendReferenceIsaFact(memoryDir, ref.key, ref.article, cache);
|
|
11310
|
+
}
|
|
11230
11311
|
}
|
|
11231
11312
|
}
|
|
11232
11313
|
// (5) #1 SHORT TAILORED MISS — replace ONLY the engine's full grammar cheat-sheet
|
|
@@ -12162,6 +12243,9 @@ function guessNumberTurn(line, { planHolder, env }) {
|
|
|
12162
12243
|
return null;
|
|
12163
12244
|
}
|
|
12164
12245
|
if (!opening) return null;
|
|
12246
|
+
if (state?.adventure) {
|
|
12247
|
+
return { text: 'we\'re mid-adventure — say "stop playing" to end it before a number game.', lane: "game-inform", note: "GAME — an opening arrived mid-adventure; the slot holds one thing at a time" };
|
|
12248
|
+
}
|
|
12165
12249
|
const planActive = state && !state.done
|
|
12166
12250
|
&& ((Array.isArray(state.goals) && state.goals.length) || (Array.isArray(state.actions) && state.actions.length));
|
|
12167
12251
|
if (planActive) {
|
|
@@ -12515,6 +12599,28 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
12515
12599
|
}
|
|
12516
12600
|
}
|
|
12517
12601
|
|
|
12602
|
+
// THE ADVENTURE — world-loading openers ("play ashcombe hall", "start the
|
|
12603
|
+
// adventure"), the stop command, and (once a world is live) the game's own
|
|
12604
|
+
// turns. Ordered AFTER the guess-number lane so that lane keeps every
|
|
12605
|
+
// guessing-game opener it already owns, and before the conversational
|
|
12606
|
+
// layer for the same reason the game lane is: an opening line would
|
|
12607
|
+
// otherwise read as a declarative or an orientation ask.
|
|
12608
|
+
{
|
|
12609
|
+
const advTurn = await adventureTurn(workingLine, {
|
|
12610
|
+
planHolder, memoryDir, sessionId, env, lexicon, graph, cache: factRowsCache, isPlanFrameLine,
|
|
12611
|
+
});
|
|
12612
|
+
if (advTurn) {
|
|
12613
|
+
note(trace, `lane: ${advTurn.note}`);
|
|
12614
|
+
if (advTurn.goal) note(trace, `goal: ${advTurn.goal}`);
|
|
12615
|
+
const result = plainTurn(workingLine, advTurn.text, { via: "game", miss: !!advTurn.miss, focus });
|
|
12616
|
+
if (advTurn.goal) result.goal = advTurn.goal;
|
|
12617
|
+
result.lane = advTurn.lane;
|
|
12618
|
+
const rec = withLast(result, advTurn.goal ?? "play the adventure");
|
|
12619
|
+
rec.planState = planHolder.state;
|
|
12620
|
+
return rec;
|
|
12621
|
+
}
|
|
12622
|
+
}
|
|
12623
|
+
|
|
12518
12624
|
// Conversational layer next (greetings, thanks, help, bye, why/say-more) — these
|
|
12519
12625
|
// resolve no entity and carry their own preserved `last`. Bypasses withLast (a
|
|
12520
12626
|
// conversational turn is never finish()'d / never becomes a new `last`), so the
|