@polycode-projects/the-mechanical-code-talker 2.5.3 → 2.5.4

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": "2.5.3",
3
+ "version": "2.5.4",
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; no codebase index of its own.",
@@ -101,7 +101,13 @@ export function registerReferencePackProvider(provider) {
101
101
  registeredProvider = provider && typeof provider.lookup === "function" ? provider : null;
102
102
  }
103
103
 
104
- /** The active provider — the registered one, else the lazy fs loader. */
105
- export function getReferencePackProvider() {
106
- return registeredProvider ?? fsProvider;
104
+ /** The active provider — the registered one, else the lazy fs loader. An
105
+ * explicit `env` bag (a chat turn's own env, which may carry
106
+ * TMCT_REFERENCE_PACK_DIR) makes the fs loader resolve the pack dir from
107
+ * that bag instead of process.env; with no argument the behavior is
108
+ * unchanged. */
109
+ export function getReferencePackProvider(env) {
110
+ if (registeredProvider) return registeredProvider;
111
+ if (env === undefined) return fsProvider;
112
+ return { lookup: async (normTerm) => loadReferenceArticle(referencePackDir(env), normTerm) };
107
113
  }
@@ -142,6 +142,11 @@ export const LANE_DIALOGUE_ACTS = Object.freeze({
142
142
  greeting: "initialGreeting",
143
143
  thanks: "thanking",
144
144
  help: "inform",
145
+ // The guessing game's turns are task-dimension: a reply that discharges
146
+ // the other side's move (a hint, a win, a rebuttal) is an answer; tmct
147
+ // stating its own move (an opening, its next guess) is an inform.
148
+ "game-answer": "answer",
149
+ "game-inform": "inform",
145
150
  });
146
151
 
147
152
  /** The dialogue act a router lane resolves to, with its dimension — or null
@@ -46,6 +46,10 @@ import { readConstructionFiles } from "../adapters/corpus/construction-banks.mjs
46
46
  import { fuzzyMatchInSet, fuzzyBound } from "../domain/interpret/fuzzy.mjs";
47
47
  import { loadLexicon, lookupNoun } from "../domain/grammar/lexicon.mjs";
48
48
  import { pickPhrase } from "../domain/answer-variants.mjs";
49
+ import { REFERENCE_PACK_NAME, cleanMissReferenceTerm, renderReferenceAnswer, referenceProvenanceTag } from "../domain/reference-pack.mjs";
50
+ import { getReferencePackProvider } from "../adapters/corpus/reference-pack.mjs";
51
+ import { dialogueActForLane } from "../domain/dialogue-acts.mjs";
52
+ import { relatedForTerm } from "../domain/skos-view.mjs";
49
53
 
50
54
  // Composition: the chat surface supplies the domain parser's default lemma/POS
51
55
  // adapter (the browser bundle's ask-nlp stub carries no factory, so this is a
@@ -149,6 +153,47 @@ function deduceGoalFromParsed(parsed) {
149
153
  return "understand a graph relationship";
150
154
  }
151
155
 
156
+ // ---- dialogue acts (ISO 24617-2): a lookup over the lane decision ----
157
+ // A turn result may carry a `lane` string naming the router lane that
158
+ // answered it; withLast (and conversationalTurn's own mk) resolve it through
159
+ // dialogueActForLane and stamp `record.dialogueAct` — a fixed lookup over a
160
+ // decision already made, never a classifier. The honest miss is the row that
161
+ // must never drift: autoNegative in the autoFeedback dimension, tmct
162
+ // reporting its OWN processing failed, not a task answer.
163
+
164
+ /** Stamp the record with the lane's dialogue act (a no-op for an unmapped or
165
+ * absent lane) and put the label in the narrate trace. */
166
+ function attachDialogueAct(result, trace) {
167
+ const act = dialogueActForLane(result?.lane);
168
+ if (act && result?.record) {
169
+ result.record.dialogueAct = act;
170
+ note(trace, `dialogue act: ${act.act} (${act.dimension} dimension, ISO 24617-2)`);
171
+ }
172
+ return result;
173
+ }
174
+
175
+ const PROPOSITIONAL_NODES = new Set(["boolean", "qualifier"]);
176
+ const PROPOSITIONAL_LEAD_RE = /^(?:is|are|am|was|were|does|do|did|can|could|will|would|shall|should|has|have|had|must)\b/i;
177
+ const SET_QUESTION_LEAD_RE = /^(?:what|which|who|whose|where|when|why|how|tell|show|list|define|describe|find|count|name)\b/i;
178
+
179
+ /** The dialogue-act lane for a runAsk turn. A recorded miss is ALWAYS the
180
+ * honest-miss lane, whatever the query shape — feedback about tmct's own
181
+ * processing. An answered turn is labelled by its question shape (yes/no
182
+ * vs set), from the parsed AST when one stood, else the lead word. Null
183
+ * when the turn is neither — the record simply carries no act. */
184
+ function askDialogueLane(parsed, query, recordMiss) {
185
+ if (recordMiss) return "honest-miss";
186
+ if (parsed) {
187
+ if (parsed.node) return PROPOSITIONAL_NODES.has(parsed.node) ? "ask-propositional" : "ask-set";
188
+ if (parsed.shape === "ask") return "ask-propositional";
189
+ if (parsed.shape) return "ask-set";
190
+ }
191
+ const q = String(query).trim();
192
+ if (PROPOSITIONAL_LEAD_RE.test(q)) return "ask-propositional";
193
+ if (SET_QUESTION_LEAD_RE.test(q)) return "ask-set";
194
+ return null;
195
+ }
196
+
152
197
  /** Split the collected trace into buckets by its own leading category tag, so
153
198
  * renderNarration can group like with like while the trace array itself stays
154
199
  * a flat, chronological narrative — no structured side-channel to keep in
@@ -1444,16 +1489,17 @@ function conversationalTurn(line, ctx) {
1444
1489
  const raw = String(line);
1445
1490
  const q = raw.toLowerCase().replace(/[.!?]+$/, "").replace(/\s+/g, " ").trim();
1446
1491
  const t = (id, slots = {}) => tRender(ctx.templates, id, slots) ?? TEMPLATES_UNAVAILABLE;
1447
- const mk = (answer, { end = false, miss = false, via = "template" } = {}) => {
1492
+ const mk = (answer, { end = false, miss = false, via = "template", lane = null } = {}) => {
1448
1493
  const ts = new Date().toISOString();
1449
- return {
1494
+ return attachDialogueAct({
1450
1495
  answer,
1451
1496
  logLines: [ts, `> ${raw}`, answer, ""],
1452
1497
  record: { type: "turn", ts, query: raw, conversational: true, via, resolvedIds: [], answeredIds: [], miss },
1453
1498
  focus: ctx.focus,
1454
1499
  last: ctx.last, // a conversational turn never overwrites the last real answer
1500
+ lane,
1455
1501
  ...(end ? { end: true } : {}),
1456
- };
1502
+ }, ctx.trace);
1457
1503
  };
1458
1504
  if (foldedBye(q)) {
1459
1505
  note(ctx.trace, "goal: casual/social — ending the session (no graph intent)");
@@ -1475,7 +1521,7 @@ function conversationalTurn(line, ctx) {
1475
1521
  if (signal === "thanks") {
1476
1522
  note(ctx.trace, "goal: casual/social — acknowledgement, no graph intent");
1477
1523
  note(ctx.trace, "lane: conversational — thanks (multi-clause phrase-shape match)");
1478
- return mk(t(T_THANKS));
1524
+ return mk(t(T_THANKS), { lane: "thanks" });
1479
1525
  }
1480
1526
  }
1481
1527
  if (WHY.has(q)) {
@@ -1504,7 +1550,7 @@ function conversationalTurn(line, ctx) {
1504
1550
  // morning, hello there) keep their wording; only the default greeting swaps.
1505
1551
  const id = (!T_GREETING_BY_PHRASE[greetHit] && noCodeGraph(ctx.graph)) ? T_GREETING_EMPTY : (T_GREETING_BY_PHRASE[greetHit] || T_GREETING);
1506
1552
  note(ctx.trace, `pattern: template "${id}" (data/templates/responses.jsonl)`);
1507
- return mk(t(id, { vocabHint: ctx.vocabHint }));
1553
+ return mk(t(id, { vocabHint: ctx.vocabHint }), { lane: "greeting" });
1508
1554
  }
1509
1555
  }
1510
1556
  {
@@ -1513,23 +1559,23 @@ function conversationalTurn(line, ctx) {
1513
1559
  note(ctx.trace, "goal: casual/social — acknowledgement, no graph intent");
1514
1560
  note(ctx.trace, `lane: conversational — thanks/acknowledgement (${OK_ACK.has(q) ? "OK_ACK" : "THANKS"} closed set${thanksHit === q ? "" : ", elongation-collapsed"})`);
1515
1561
  note(ctx.trace, `pattern: template "${T_THANKS}" (data/templates/responses.jsonl)`);
1516
- return mk(t(T_THANKS));
1562
+ return mk(t(T_THANKS), { lane: "thanks" });
1517
1563
  }
1518
1564
  }
1519
1565
  if (aiIdentityMatch(raw)) {
1520
1566
  note(ctx.trace, "goal: identity — is tmct an AI/LLM (a very likely first question)");
1521
1567
  note(ctx.trace, "lane: conversational — identity/AI (AI_IDENTITY_PHRASES closed set)");
1522
- return mk(t(T_IDENTITY_NOT_LLM));
1568
+ return mk(t(T_IDENTITY_NOT_LLM), { lane: "help" });
1523
1569
  }
1524
1570
  if (FEELINGS_PHRASES.some((re) => re.test(raw))) {
1525
1571
  note(ctx.trace, "goal: identity — does tmct have feelings/consciousness (small-talk persona finding)");
1526
1572
  note(ctx.trace, "lane: conversational — identity/feelings (FEELINGS_PHRASES closed set)");
1527
- return mk(t(T_IDENTITY_NO_FEELINGS));
1573
+ return mk(t(T_IDENTITY_NO_FEELINGS), { lane: "help" });
1528
1574
  }
1529
1575
  if (IDENTITY_PHRASES.some((re) => re.test(raw))) {
1530
1576
  note(ctx.trace, "goal: identity — who/what tmct is, not a capability listing");
1531
1577
  note(ctx.trace, "lane: conversational — identity (IDENTITY_PHRASES closed set)");
1532
- return mk(t(T_IDENTITY_SELF));
1578
+ return mk(t(T_IDENTITY_SELF), { lane: "help" });
1533
1579
  }
1534
1580
  // CAPABILITY_PHRASES' vague-opener entries are self-contained closed
1535
1581
  // regexes, but a preamble ahead of one ("right, can you walk me through
@@ -1541,7 +1587,7 @@ function conversationalTurn(line, ctx) {
1541
1587
  || CAPABILITY_PHRASES.some((re) => re.test(applyPreambleFrames(raw))) || ORIENT_OPENERS.has(q)) {
1542
1588
  note(ctx.trace, "goal: get oriented — what can tmct answer, how do I start");
1543
1589
  note(ctx.trace, "lane: conversational — help/orientation (CAPABILITY_PHRASES/ORIENT_OPENERS / bare help / ?)");
1544
- return mk(orientationAnswer(ctx.templates, ctx.graph, ctx.vocabHint));
1590
+ return mk(orientationAnswer(ctx.templates, ctx.graph, ctx.vocabHint), { lane: "help" });
1545
1591
  }
1546
1592
  // Fuzzy-typo fallback (A4): every exact/collapsed closed-set lookup above missed —
1547
1593
  // try a bounded edit-distance match against the flattened conversational phrase
@@ -1554,11 +1600,11 @@ function conversationalTurn(line, ctx) {
1554
1600
  note(ctx.trace, `goal: casual/social or orientation — fuzzy-typo match "${raw}" → "${fuzzyHit}"`);
1555
1601
  note(ctx.trace, `lane: conversational — fuzzy typo tolerance (${bucket})`);
1556
1602
  if (bucket === "bye") return mk(t(T_FAREWELL), { end: true });
1557
- if (bucket === "thanks") return mk(t(T_THANKS));
1558
- if (bucket === "identity") return mk(t(T_IDENTITY_SELF));
1559
- if (bucket === "capability") return mk(orientationAnswer(ctx.templates, ctx.graph, ctx.vocabHint));
1603
+ if (bucket === "thanks") return mk(t(T_THANKS), { lane: "thanks" });
1604
+ if (bucket === "identity") return mk(t(T_IDENTITY_SELF), { lane: "help" });
1605
+ if (bucket === "capability") return mk(orientationAnswer(ctx.templates, ctx.graph, ctx.vocabHint), { lane: "help" });
1560
1606
  const id = (!T_GREETING_BY_PHRASE[fuzzyHit] && noCodeGraph(ctx.graph)) ? T_GREETING_EMPTY : (T_GREETING_BY_PHRASE[fuzzyHit] || T_GREETING);
1561
- return mk(t(id, { vocabHint: ctx.vocabHint }));
1607
+ return mk(t(id, { vocabHint: ctx.vocabHint }), { lane: "greeting" });
1562
1608
  }
1563
1609
  }
1564
1610
  return null;
@@ -8783,6 +8829,46 @@ async function curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon })
8783
8829
  return { text: `${def} (source: corpus/seon)`, term };
8784
8830
  }
8785
8831
 
8832
+ // ---- learn-on-miss: the shipped reference pack behind the cleanest miss ----
8833
+
8834
+ /** The learn-on-miss gate, shared by the articled miss hook and the bare-form
8835
+ * fallback so the two can never disagree. Fires only on the CLEANEST miss: a
8836
+ * definition-shaped term the lexicon knows, resolving to no graph entity and
8837
+ * no remembered fact — then, and only then, the pack provider is consulted.
8838
+ * Null means the turn proceeds byte-identically to a pack-less run. */
8839
+ async function referencePackMissAnswer(term, { graph, memoryDir, lexicon, env, cache }) {
8840
+ if (!term || !memoryDir) return null;
8841
+ let key = null;
8842
+ try { key = cleanMissReferenceTerm(term, lexicon ?? undefined); } catch { key = null; }
8843
+ if (!key) return null;
8844
+ if (await resolveEntity(graph, term)) return null;
8845
+ let normFactTerm;
8846
+ try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
8847
+ const variants = factTermVariants(normFactTerm, term);
8848
+ variants.add(key);
8849
+ const rows = await factRows(memoryDir, cache);
8850
+ if (rows.some((f) => variants.has(f.subject) || variants.has(f.object))) return null;
8851
+ let article = null;
8852
+ try { article = await getReferencePackProvider(env).lookup(key); } catch { article = null; }
8853
+ if (!article) return null;
8854
+ return { key, article, text: renderReferenceAnswer(key, article) };
8855
+ }
8856
+
8857
+ /** Store the article's first-sentence isa as a subClassOf fact carrying
8858
+ * reference provenance — AFTER the cited answer composed, and failure-
8859
+ * tolerated: the answer stands whether or not the fact lands. */
8860
+ async function appendReferenceIsaFact(memoryDir, key, article, cache) {
8861
+ if (!article?.isa) return;
8862
+ try {
8863
+ const { appendFact } = await import("../adapters/memory/core.mjs");
8864
+ await appendFact(memoryDir, {
8865
+ subject: key, predicate: "rdfs:subClassOf", object: article.isa,
8866
+ provenance: referenceProvenanceTag(article),
8867
+ });
8868
+ if (cache) cache.rows = null;
8869
+ } catch { /* tolerated — the cited answer is already composed */ }
8870
+ }
8871
+
8786
8872
  /** The concept term a vague "what is a X" / "tell me about X" / "what does X mean" /
8787
8873
  * "define X" asks about — metaTermOf's forms plus the "tell me about …" opener that
8788
8874
  * the graph parser reads as a count. Null when the line isn't such a touch. The
@@ -9473,7 +9559,7 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
9473
9559
  const n = heldGoals.length;
9474
9560
  return {
9475
9561
  text: `${added ? "noted" : "already noted"} — the goal is that ${tails.join(" and ")}.${n > 1 ? ` (${n} goals held)` : ""} Say "solve it" when the state is taught.`,
9476
- via: "plan", deduced: "record the goal state for a later plan",
9562
+ via: "plan", lane: "goal", deduced: "record the goal state for a later plan",
9477
9563
  note: added
9478
9564
  ? `GOAL frame — ${added === 1 ? "goal spec" : `${added} goal specs`} accumulated on the session plan slot`
9479
9565
  : "GOAL frame — the same goal spec was already held, so it folded onto the existing one",
@@ -9613,7 +9699,7 @@ async function planLaneAnswer(query, { memoryDir, planHolder, sessionId = "", })
9613
9699
  `${ordering.length ? ` and ${ordering.length} ordering fact${ordering.length === 1 ? "" : "s"}` : ""}. ` +
9614
9700
  `Say "next" to make move 1, or ask "what moves are legal now".${assumptionNote}`;
9615
9701
  return {
9616
- text, via: "plan",
9702
+ text, via: "plan", lane: "imperative",
9617
9703
  deduced: `plan a move sequence from the current state to the goal (${n} move${n === 1 ? "" : "s"})`,
9618
9704
  note: "plan lane — compileDomain + findActionPath over the taught rules; plan held on the session slot",
9619
9705
  plan,
@@ -9982,6 +10068,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
9982
10068
  // facts/recall (a fact EXTENDS a non-miss schema hit too — NOT miss-gated),
9983
10069
  // (4) TEACH lane (would-miss), (5) the short tailored miss (would-miss).
9984
10070
  let handled = false;
10071
+ // The dialogue-act lane, when a lane below knows better than the final
10072
+ // question-shape lookup (a plan frame is a request/instruct, a stored
10073
+ // teach is an inform, whatever the surface punctuation looked like).
10074
+ let dialogueLaneOverride = null;
9985
10075
  // (0) "what else is X" — recognized off the RAW query text, before every
9986
10076
  // other lane below (all of which read `envelope`, already relaxed/reparsed
9987
10077
  // by ask()'s noise-strip cascade, which silently drops "else"). via is set
@@ -10021,6 +10111,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
10021
10111
  const planLane = await planLaneAnswer(query, { memoryDir, planHolder, sessionId });
10022
10112
  if (planLane) {
10023
10113
  answer = planLane.text; via = planLane.via; recordMiss = false; handled = true;
10114
+ if (planLane.lane) dialogueLaneOverride = planLane.lane;
10024
10115
  if (planLane.plan) planResult = planLane.plan;
10025
10116
  if (planLane.deduced) {
10026
10117
  deduced = planLane.deduced;
@@ -10214,6 +10305,14 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
10214
10305
  const def = await curatedDefinitionAnswer(gateQuery, envelope, { memoryDir, lexicon });
10215
10306
  if (def) bareMetaHit = { text: def.text, replace: true };
10216
10307
  }
10308
+ // The reference pack's bare-form fallback, beside the curated one and
10309
+ // under the IDENTICAL clean-miss gate the articled hook (4h) applies —
10310
+ // "what is otter" reaches the pack exactly as "what is an otter" does.
10311
+ if (!bareMetaHit) {
10312
+ const refTerm = metaTermOf(gateQuery, envelope);
10313
+ const ref = refTerm ? await referencePackMissAnswer(refTerm, { graph, memoryDir, lexicon, env, cache }) : null;
10314
+ if (ref) bareMetaHit = { text: ref.text, replace: true, reference: ref };
10315
+ }
10217
10316
  }
10218
10317
  // A bare "what is X" naming a REAL code-graph entity (not a taught fact,
10219
10318
  // not a curated corpus term) needs the SAME race fixed too.
@@ -10249,7 +10348,18 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
10249
10348
  if (fallback) bareMetaHit = { text: fallback.text, replace: true };
10250
10349
  }
10251
10350
  const coldPronounDecline = focus?.label ? null : coldPronounDeclineText(query);
10252
- if (bareMetaHit) {
10351
+ if (bareMetaHit?.reference) {
10352
+ // The bare-form reference hit mirrors (4h): the cited answer replaces the
10353
+ // miss, the turn is no longer recorded as one, and the article's isa is
10354
+ // stored after the answer composes.
10355
+ answer = bareMetaHit.text;
10356
+ via = "reference";
10357
+ recordMiss = false;
10358
+ handled = true;
10359
+ note(trace, "lane: (2b) REFERENCE PACK — a bare \"what is X\" clean miss answered from the shipped reference pack, cited");
10360
+ note(trace, `source: reference pack ${REFERENCE_PACK_NAME} — article "${bareMetaHit.reference.article.title}" (revid ${bareMetaHit.reference.article.revid})`);
10361
+ await appendReferenceIsaFact(memoryDir, bareMetaHit.reference.key, bareMetaHit.reference.article, cache);
10362
+ } else if (bareMetaHit) {
10253
10363
  answer = bareMetaHit.replace ? bareMetaHit.text : `${answer}\n${bareMetaHit.text}`;
10254
10364
  // Same discipline as lane (3): a fact-lane return flagged `miss` is an
10255
10365
  // honest miss in better words — the turn record keeps miss=true and via
@@ -10459,6 +10569,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
10459
10569
  const taught = await teachLane(query, { memoryDir, sessionId, lexicon, cache });
10460
10570
  if (taught) {
10461
10571
  answer = taught.text; via = taught.via; recordMiss = taught.miss;
10572
+ if (!taught.miss) dialogueLaneOverride = "teach";
10462
10573
  note(trace, `lane: (4) TEACH — TEACH_RE/OWNS_TEACH_RE/BARE_DECLARATIVE_RE matched, ${taught.miss ? "but the payload could not be stored" : "reified into .tmct/memory"}`);
10463
10574
  // `deduced` was computed straight off envelope.parsed alone, but the
10464
10575
  // structural grammar has no business parsing a teach-shaped sentence at
@@ -10603,6 +10714,24 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
10603
10714
  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`);
10604
10715
  }
10605
10716
  }
10717
+ // (4h) REFERENCE PACK — the cleanest miss consults the shipped reference
10718
+ // pack: a definition-shaped term the lexicon knows, no graph entity, no
10719
+ // remembered fact. A hit answers with the article's summary, always cited;
10720
+ // a null from any gate leaves the turn byte-identical. After the answer
10721
+ // composes, the article's first-sentence isa is stored as a subClassOf fact
10722
+ // with reference provenance, so the NEXT ask answers from memory.
10723
+ if (miss && recordMiss && via === "composed" && memoryDir) {
10724
+ const refTerm = metaTermOf(query, envelope);
10725
+ const ref = refTerm ? await referencePackMissAnswer(refTerm, { graph, memoryDir, lexicon, env, cache }) : null;
10726
+ if (ref) {
10727
+ answer = ref.text;
10728
+ via = "reference";
10729
+ recordMiss = false;
10730
+ note(trace, "lane: (4h) REFERENCE PACK — a clean miss on a lexicon term answered from the shipped reference pack, cited");
10731
+ note(trace, `source: reference pack ${REFERENCE_PACK_NAME} — article "${ref.article.title}" (revid ${ref.article.revid})`);
10732
+ await appendReferenceIsaFact(memoryDir, ref.key, ref.article, cache);
10733
+ }
10734
+ }
10606
10735
  // (5) #1 SHORT TAILORED MISS — replace ONLY the engine's full grammar cheat-sheet
10607
10736
  // wall (WALL_MISS_RE). Receipt-bearing misses keep their specific wording.
10608
10737
  // WALL KINDNESS: a second consecutive wall collapses to a one-liner whose
@@ -10733,7 +10862,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
10733
10862
  // `goal`: the SAME deduced string the debug trace's own "goal:" line
10734
10863
  // carries. Only runAsk ever sets this field, so the always-on goal line is
10735
10864
  // scoped to real ask-engine turns by construction.
10736
- return { answer, logLines, record, focus: newFocus, detail, effectiveQuery, goal: deduced, ...(planResult ? { plan: planResult } : {}) };
10865
+ // `lane`: the dialogue-act lane a lane's own override, else the
10866
+ // question-shape lookup — resolved to an ISO act by runTurn's withLast.
10867
+ const lane = dialogueLaneOverride ?? askDialogueLane(envelope?.parsed, query, recordMiss);
10868
+ return { answer, logLines, record, focus: newFocus, detail, effectiveQuery, goal: deduced, lane, ...(planResult ? { plan: planResult } : {}) };
10737
10869
  }
10738
10870
 
10739
10871
  /** A non-ask, non-dispatch chat turn (count answer, /stats) — the same
@@ -11287,6 +11419,251 @@ function morePage(query, { last, focus }) {
11287
11419
  return turn;
11288
11420
  }
11289
11421
 
11422
+ // ---- the SKOS view: synonym/related-word questions over the store ----
11423
+ // "another word for X" / "synonyms of X" / "what is related to X" read the
11424
+ // store's mgx:synonym / mgx:relatedTo / mgx:similarTo facts through
11425
+ // buildSkosConceptView's minted concepts (relatedForTerm). Routed ahead of
11426
+ // the generic parse, which reads these phrasings as something else entirely.
11427
+ // A term that mints no concept — unknown, or with no synonym/related facts —
11428
+ // misses honestly, naming the term, never a guessed neighbour.
11429
+ const SKOS_SYNONYM_RE = /^(?:another\s+word\s+for|other\s+words\s+for|synonyms?\s+(?:of|for)|what\s+is\s+a\s+synonym\s+(?:of|for))\s+(?:an?\s+|the\s+)?(.+?)[?.!\s]*$/i;
11430
+ const SKOS_RELATED_RE = /^(?:what\s+is\s+related\s+to|what\s+relates\s+to|what\s+words\s+are\s+related\s+to)\s+(?:an?\s+|the\s+)?(.+?)[?.!\s]*$/i;
11431
+
11432
+ /** The SKOS-view answer for a synonym/related question, or null when the
11433
+ * line is not one. A matched line always answers — a hit lists the group's
11434
+ * other labels and the related concepts; anything else is the honest miss. */
11435
+ async function skosRelatedAnswer(memoryDir, query, cache) {
11436
+ if (!memoryDir) return null;
11437
+ const q = expandContractions(String(query).trim());
11438
+ const syn = q.match(SKOS_SYNONYM_RE);
11439
+ const rel = syn ? null : q.match(SKOS_RELATED_RE);
11440
+ if (!syn && !rel) return null;
11441
+ const term = (syn ?? rel)[1].trim();
11442
+ let hood = null;
11443
+ try { hood = relatedForTerm(await factRows(memoryDir, cache), term); } catch { hood = null; }
11444
+ const parts = [];
11445
+ if (hood?.synonyms?.length) parts.push(`another word for ${term}: ${joinList(hood.synonyms)}`);
11446
+ const relatedLabels = (hood?.related ?? []).map((c) => c.prefLabel);
11447
+ if (relatedLabels.length) parts.push(`related: ${joinList(relatedLabels)}`);
11448
+ if (!parts.length) {
11449
+ return { term, miss: true, text: `I don't know any synonyms or related words for "${term}" yet.` };
11450
+ }
11451
+ return { term, miss: false, text: `${parts.join("; ")} (source: remembered synonym/related facts, read as SKOS)` };
11452
+ }
11453
+
11454
+ // ---- guess-the-number: a closed-loop game over hidden state ----
11455
+ // Two modes on one mechanism. In GUESSER mode the human holds a secret and
11456
+ // tmct searches: a belief interval {lo, hi} narrowed by bisection, one
11457
+ // observation ("higher"/"lower"/"correct") folded in per turn. In THINKER
11458
+ // mode tmct commits a secret up front and each turn is a stateless
11459
+ // comparison against it. The game payload rides the session's plan slot as
11460
+ // a tagged sub-object ({ game: {...} }), so a plan frame and a game never
11461
+ // share the slot — each declines to start while the other is active.
11462
+
11463
+ /** "between A and B" / "up to N" anywhere in an opening line. Loose token
11464
+ * captures (\S+) so a non-numeric bound is SEEN and declined rather than
11465
+ * silently defaulted. */
11466
+ const GAME_BOUNDS_CLAUSE_RE = /\b(?:between\s+(\S+)\s+and\s+(\S+)|up\s+to\s+(\S+))\b/i;
11467
+ const GAME_BOUND_MAX = 1_000_000_000;
11468
+
11469
+ /** The bounds an opening line states — { lo, hi } (default 1–100), or
11470
+ * { problem } naming why the stated range is unplayable. */
11471
+ function parseGameBounds(text) {
11472
+ const m = String(text).match(GAME_BOUNDS_CLAUSE_RE);
11473
+ if (!m) return { lo: 1, hi: 100 };
11474
+ const tokens = (m[3] !== undefined ? ["1", m[3]] : [m[1], m[2]])
11475
+ .map((t) => String(t).replace(/[,.?!]+$/, ""));
11476
+ if (!tokens.every((t) => /^-?\d+$/.test(t))) {
11477
+ return { problem: 'I can only play with whole-number bounds — say "between 1 and 100".' };
11478
+ }
11479
+ const lo = Number(tokens[0]);
11480
+ const hi = Number(tokens[1]);
11481
+ if (Math.abs(lo) > GAME_BOUND_MAX || Math.abs(hi) > GAME_BOUND_MAX) {
11482
+ return { problem: `that range is too big for a fair game — keep both bounds within ${GAME_BOUND_MAX.toLocaleString("en-US")}.` };
11483
+ }
11484
+ if (hi < lo) return { problem: `no number is between ${lo} and ${hi} — that range is empty. Put the smaller bound first.` };
11485
+ if (hi === lo) return { problem: `between ${lo} and ${hi} leaves exactly one number, so there is nothing to guess. Pick a wider range.` };
11486
+ return { lo, hi };
11487
+ }
11488
+
11489
+ // Opening moves, both modes, as closed-set leads + a tail that may only carry
11490
+ // the bounds clause and the closing invitation words — any other tail is a
11491
+ // real sentence and falls through to the ordinary lanes.
11492
+ const GUESSER_OPEN_LEAD_RE = /^(?:i\s*(?:'m|am)\s+thinking\s+of\s+a\s+number|guess\s+my\s+number|guess\s+the\s+number\s+i\s*(?:'m|am)\s+thinking\s+of|guess\s+a\s+number\s+(?:between\s+\S+\s+and\s+\S+\s+|up\s+to\s+\S+\s+)?and\s+i\s*(?:'ll|\s+will)\s+tell\s+you\s+(?:if\s+it\s*(?:'s|\s+is)\s+)?higher\s+or\s+lower)\b(.*)$/i;
11493
+ const THINKER_OPEN_LEAD_RE = /^(?:think\s+of\s+a\s+number)\b(.*)$/i;
11494
+ const GUESSER_OPEN_TAIL_RE = /^[\s,.!?—-]*(?:and\s+)?(?:you\s+)?(?:can\s+|have\s+to\s+|try\s+to\s+)?(?:guess(?:\s+it|\s+what\s+it\s+is)?)?[\s,.!?—-]*$/i;
11495
+ const THINKER_OPEN_TAIL_RE = /^[\s,.!?—-]*(?:and\s+)?(?:i\s*(?:'ll|\s+will)\s+(?:try\s+to\s+)?guess(?:\s+it)?|i\s+guess)?[\s,.!?—-]*$/i;
11496
+
11497
+ /** An opening move — { mode, bounds } — or null. */
11498
+ function matchGameOpening(line) {
11499
+ const l = String(line).trim();
11500
+ const guesser = l.match(GUESSER_OPEN_LEAD_RE);
11501
+ if (guesser && GUESSER_OPEN_TAIL_RE.test(guesser[1].replace(GAME_BOUNDS_CLAUSE_RE, " "))) {
11502
+ return { mode: "guesser", bounds: parseGameBounds(l) };
11503
+ }
11504
+ const thinker = l.match(THINKER_OPEN_LEAD_RE);
11505
+ if (thinker && THINKER_OPEN_TAIL_RE.test(thinker[1].replace(GAME_BOUNDS_CLAUSE_RE, " "))) {
11506
+ return { mode: "thinker", bounds: parseGameBounds(l) };
11507
+ }
11508
+ return null;
11509
+ }
11510
+
11511
+ // Continuation replies, gated STRICTLY on an active game (the same discipline
11512
+ // MORE_RE applies to a held pending remainder): with no game standing none of
11513
+ // these are ever consulted, and mid-game any line that matches none of them
11514
+ // is an ordinary aside — answered by the normal lanes, game untouched.
11515
+ const GAME_STOP_RE = /^(?:ok[,\s]+)?(?:i\s+give\s+up|give\s+up|i\s+quit(?:\s+the\s+game)?|stop\s+(?:the\s+game|playing)|end\s+the\s+game)[.!?\s]*$/i;
11516
+ const GAME_REVEAL_RE = /^(?:just\s+tell\s+me|(?:just\s+)?tell\s+me\s+the\s+(?:number|answer)|what(?:'s|\s+is)\s+(?:the|your)\s+(?:secret\s+)?number|reveal\s+(?:it|the\s+number)|show\s+me\s+the\s+number)[.!?\s]*$/i;
11517
+ const GAME_OBS_HIGHER_RE = /^(?:no[,\s]+)?(?:higher|too\s+low|too\s+small|bigger|greater|go\s+higher|it(?:'s|\s+is)\s+higher)[.!?\s]*$/i;
11518
+ const GAME_OBS_LOWER_RE = /^(?:no[,\s]+)?(?:lower|too\s+high|too\s+big|smaller|less|go\s+lower|it(?:'s|\s+is)\s+lower)[.!?\s]*$/i;
11519
+ const GAME_OBS_CORRECT_RE = /^(?:yes|yep|yeah|correct|you\s+got\s+it|you\s+guessed\s+it|that(?:'s|\s+is)\s+it|that(?:'s|\s+is)\s+right|got\s+it|spot\s+on)[.!?\s]*$/i;
11520
+ const GAME_GUESS_RE = /^(?:is\s+it\s+)?(-?\d{1,12})\s*\??[.!?\s]*$/;
11521
+ const GAME_FALSE_CORRECT_RE = /^(?:but\s+)?you\s+(?:already\s+)?said\s+(?:it\s+was\s+)?(?:correct|right)\b/i;
11522
+
11523
+ /** A natural-language plan frame — the shapes planLaneAnswer owns. Mid-game
11524
+ * these get the one-at-a-time decline instead of clobbering the slot. */
11525
+ function isPlanFrameLine(line) {
11526
+ return GOAL_TEACH_RE.test(line) || GOAL_TEACH_INFINITIVE_RE.test(line)
11527
+ || GOAL_TEACH_VERBLESS_RE.test(line) || GOAL_TEACH_NP_RE.test(line)
11528
+ || GOAL_TEACH_IMPERATIVE_RE.test(line) || GOAL_TEACH_CONJUNCTION_RE.test(line)
11529
+ || PLAN_SOLVE_RE.test(line) || LEGAL_MOVES_RE.test(line);
11530
+ }
11531
+
11532
+ /** The per-turn goal line, table-driven off the live game state. */
11533
+ function gameGoal(game) {
11534
+ if (game.mode === "guesser") return `narrow down your number — currently between ${game.lo} and ${game.hi}`;
11535
+ if (!game.lastHint) return "let you find my secret number I've committed to";
11536
+ return `let you find my secret number — said "${game.lastHint}" so it's ${game.lastHint === "higher" ? "above" : "below"} your last guess`;
11537
+ }
11538
+
11539
+ /** One guesser-mode observation folded into the belief interval, or a
11540
+ * thinker-mode guess compared against the secret. Mutates planHolder.state
11541
+ * (the same slot the plan lane owns) and returns { text, goal?, lane, note },
11542
+ * or null when the line is not a game reply. */
11543
+ function gameContinuationAnswer(line, game, planHolder) {
11544
+ const endGame = () => { planHolder.state = null; };
11545
+ if (game.mode === "guesser") {
11546
+ if (GAME_STOP_RE.test(line)) {
11547
+ endGame();
11548
+ return { text: 'OK, stopping — I never found it. Say "guess my number" any time to play again.', lane: "game-inform", note: "GAME — the game ended on request; the belief interval is discarded" };
11549
+ }
11550
+ if (GAME_OBS_CORRECT_RE.test(line)) {
11551
+ const { guess, guesses } = game;
11552
+ endGame();
11553
+ return { text: `Got it — your number is ${guess}, found in ${guesses} guess${guesses === 1 ? "" : "es"}. Want to play again?`, lane: "game-answer", note: "GAME — the guess was confirmed; game over, won" };
11554
+ }
11555
+ const higher = GAME_OBS_HIGHER_RE.test(line);
11556
+ const lower = !higher && GAME_OBS_LOWER_RE.test(line);
11557
+ if (!higher && !lower) return null;
11558
+ const prior = game.guess;
11559
+ const next = { ...game };
11560
+ if (higher) { next.lo = prior + 1; next.loSetBy = { guess: prior }; }
11561
+ else { next.hi = prior - 1; next.hiSetBy = { guess: prior }; }
11562
+ if (next.lo > next.hi) {
11563
+ // The interval is EMPTY: no number satisfies every observation given,
11564
+ // so name the two observations that cannot both hold and stop guessing
11565
+ // — never a fabricated next guess over a premise known to be false.
11566
+ endGame();
11567
+ const earlier = higher
11568
+ ? (game.hiSetBy ? `lower than ${game.hiSetBy.guess}` : `it's between ${game.lo0} and ${game.hi0}`)
11569
+ : (game.loSetBy ? `higher than ${game.loSetBy.guess}` : `it's between ${game.lo0} and ${game.hi0}`);
11570
+ const now = `${higher ? "higher" : "lower"} than ${prior}`;
11571
+ return {
11572
+ text: `That's not possible — you said ${earlier}, and now ${now}, but no number can be both. One of those answers must be wrong. Say "guess my number" to restart.`,
11573
+ lane: "game-answer",
11574
+ note: "GAME — the observations emptied the belief interval; refused to keep guessing under a false premise",
11575
+ };
11576
+ }
11577
+ next.guess = Math.floor((next.lo + next.hi) / 2);
11578
+ next.guesses = game.guesses + 1;
11579
+ planHolder.state = { game: next };
11580
+ return {
11581
+ text: `My guess: ${next.guess}. Say higher, lower, or correct.`,
11582
+ goal: gameGoal(next),
11583
+ lane: "game-inform",
11584
+ note: `GAME — folded "${higher ? "higher" : "lower"}" into the interval and bisected it again`,
11585
+ };
11586
+ }
11587
+ // Thinker mode: tmct holds the ground truth, so every reply is a plain
11588
+ // comparison — and the hint record is authoritative against false claims.
11589
+ if (GAME_STOP_RE.test(line) || GAME_REVEAL_RE.test(line)) {
11590
+ const { secret } = game;
11591
+ endGame();
11592
+ return { text: `The number was ${secret}. Want to play again?`, lane: "game-answer", note: "GAME — revealed the secret on request; game over" };
11593
+ }
11594
+ if (GAME_FALSE_CORRECT_RE.test(line)) {
11595
+ const record = game.lastHint
11596
+ ? `my last hint was "${game.lastHint}", after your guess of ${game.lastGuess}`
11597
+ : "you haven't guessed yet";
11598
+ return { text: `I haven't said "correct" yet — ${record}. Keep guessing.`, goal: gameGoal(game), lane: "game-answer", note: "GAME — rebutted a false \"you said correct\" from the game's own hint record" };
11599
+ }
11600
+ const m = String(line).trim().match(GAME_GUESS_RE);
11601
+ if (!m) return null;
11602
+ const guess = Number.parseInt(m[1], 10);
11603
+ if (guess < game.lo0 || guess > game.hi0) {
11604
+ return { text: `${guess} is outside the ${game.lo0} to ${game.hi0} range we agreed — try a number in range.`, goal: gameGoal(game), lane: "game-answer", note: "GAME — an out-of-range guess; declined rather than comparing outside the agreed bounds" };
11605
+ }
11606
+ const next = { ...game, guesses: game.guesses + 1, lastGuess: guess };
11607
+ if (guess === game.secret) {
11608
+ endGame();
11609
+ return { text: `Correct — you got it in ${next.guesses} guess${next.guesses === 1 ? "" : "es"}! The number was ${guess}. Want to play again?`, lane: "game-answer", note: "GAME — the guess matched the secret; game over, won" };
11610
+ }
11611
+ next.lastHint = guess < game.secret ? "higher" : "lower";
11612
+ planHolder.state = { game: next };
11613
+ return { text: `${next.lastHint} — guess again.`, goal: gameGoal(next), lane: "game-answer", note: `GAME — compared the guess against the committed secret: ${next.lastHint}` };
11614
+ }
11615
+
11616
+ /** The whole game lane for one turn: continuations first (active game only),
11617
+ * then opening moves, with the one-at-a-time declines both ways across the
11618
+ * shared plan slot. Null when the turn is not the game's to answer. */
11619
+ function guessNumberTurn(line, { planHolder, env }) {
11620
+ const state = planHolder?.state ?? null;
11621
+ const game = state?.game ?? null;
11622
+ const opening = matchGameOpening(line);
11623
+ if (game) {
11624
+ const continuation = gameContinuationAnswer(line, game, planHolder);
11625
+ if (continuation) return continuation;
11626
+ if (opening) {
11627
+ return { text: `we're already playing — I'm ${game.mode === "guesser" ? "guessing your number" : "holding a secret number"}. Say "I give up" to end this game first.`, lane: "game-inform", note: "GAME — an opening arrived mid-game; declined, the running game stands" };
11628
+ }
11629
+ if (isPlanFrameLine(line)) {
11630
+ return { text: 'a guess-the-number game is active — say "I give up" to end it, then set your goal.', lane: "game-inform", note: "GAME — a plan frame arrived mid-game; the slot holds one thing at a time" };
11631
+ }
11632
+ return null;
11633
+ }
11634
+ if (!opening) return null;
11635
+ const planActive = state && !state.done
11636
+ && ((Array.isArray(state.goals) && state.goals.length) || (Array.isArray(state.actions) && state.actions.length));
11637
+ if (planActive) {
11638
+ return { text: "a plan is in progress — finish it or start a fresh goal before we play guess-the-number.", lane: "game-inform", note: "GAME — an opening arrived while a plan frame is active; the slot holds one thing at a time" };
11639
+ }
11640
+ if (opening.bounds.problem) {
11641
+ return { text: opening.bounds.problem, lane: "game-inform", note: "GAME — the opening stated an unplayable range; declined honestly" };
11642
+ }
11643
+ const { lo, hi } = opening.bounds;
11644
+ if (opening.mode === "guesser") {
11645
+ const guess = Math.floor((lo + hi) / 2);
11646
+ planHolder.state = { game: { mode: "guesser", lo0: lo, hi0: hi, lo, hi, guess, guesses: 1, loSetBy: null, hiSetBy: null } };
11647
+ return {
11648
+ text: `OK — you're thinking of a number between ${lo} and ${hi}; I'll guess it. My guess: ${guess}. Say higher, lower, or correct.`,
11649
+ goal: `narrow down your number — currently between ${lo} and ${hi}`,
11650
+ lane: "game-inform",
11651
+ note: "GAME — guesser mode opened; the belief interval starts at the agreed bounds and the first guess is its midpoint",
11652
+ };
11653
+ }
11654
+ const envSecret = Number.parseInt(String(env?.TMCT_GAME_SECRET ?? ""), 10);
11655
+ const secret = Number.isSafeInteger(envSecret) && envSecret >= lo && envSecret <= hi
11656
+ ? envSecret
11657
+ : lo + Math.floor(Math.random() * (hi - lo + 1));
11658
+ planHolder.state = { game: { mode: "thinker", lo0: lo, hi0: hi, secret, guesses: 0, lastHint: null, lastGuess: null } };
11659
+ return {
11660
+ text: `Done — I've thought of a number between ${lo} and ${hi}. Guess it, and I'll say higher, lower, or correct.`,
11661
+ goal: "let you find my secret number I've committed to",
11662
+ lane: "game-inform",
11663
+ note: "GAME — thinker mode opened; the secret is committed for the whole game",
11664
+ };
11665
+ }
11666
+
11290
11667
  // "I want you to search for Widget" / "I'd like you to search for Widget" —
11291
11668
  // a closed-set indirect-request wrapper, checked VERY early. Without this it
11292
11669
  // is mis-swallowed by GENERAL_VERB_TEACH_RE as a bare teach triple (subject
@@ -11501,7 +11878,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
11501
11878
  // what the shell prints. The narrate block is applied AFTER `last` is
11502
11879
  // captured from the PRE-narration finished result.
11503
11880
  const withLast = (result, fallbackGoal = "unclear — no goal signal for this turn type") => {
11504
- const finished = finish(result, { graph });
11881
+ const finished = attachDialogueAct(finish(result, { graph }), trace);
11505
11882
  // Every dispatch path below built its own record off `workingLine` (the
11506
11883
  // indirect-request wrapper stripped and/or the discontiguous-frame
11507
11884
  // rewrite applied) — restore the ORIGINAL raw `line` into record.query
@@ -11541,6 +11918,26 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
11541
11918
  const bareCmd = asBareCommand(workingLine);
11542
11919
  if (bareCmd) return withLast(await runCommand(bareCmd, ctx), "use a specific tool/command directly");
11543
11920
 
11921
+ // GUESS-THE-NUMBER — opening moves, and (with a game standing) the
11922
+ // closed-set continuation replies. Checked before the conversational layer
11923
+ // because the guesser-mode observations ("yes", "got it") share words with
11924
+ // the acknowledgement sets, and before assertTurn/runAsk because an opening
11925
+ // line would otherwise read as a declarative to remember. A mid-game line
11926
+ // matching no game shape returns null here and the game stands untouched.
11927
+ {
11928
+ const gameTurn = guessNumberTurn(workingLine, { planHolder, env });
11929
+ if (gameTurn) {
11930
+ note(trace, `lane: ${gameTurn.note}`);
11931
+ if (gameTurn.goal) note(trace, `goal: ${gameTurn.goal}`);
11932
+ const result = plainTurn(workingLine, gameTurn.text, { via: "game", focus });
11933
+ if (gameTurn.goal) result.goal = gameTurn.goal;
11934
+ result.lane = gameTurn.lane;
11935
+ const rec = withLast(result, gameTurn.goal ?? "play the guessing game");
11936
+ rec.planState = planHolder.state;
11937
+ return rec;
11938
+ }
11939
+ }
11940
+
11544
11941
  // Conversational layer next (greetings, thanks, help, bye, why/say-more) — these
11545
11942
  // resolve no entity and carry their own preserved `last`. Bypasses withLast (a
11546
11943
  // conversational turn is never finish()'d / never becomes a new `last`), so the
@@ -11558,7 +11955,9 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
11558
11955
  const step = await executePlanStep(planHolder, { memoryDir, sessionId });
11559
11956
  note(trace, `goal: ${step.deduced}`);
11560
11957
  note(trace, "lane: PLAN NEXT — executed the active plan's next move as an @stepK snapshot write");
11561
- const rec = withLast(plainTurn(workingLine, step.text, { via: "plan", focus }), step.deduced);
11958
+ const stepTurn = plainTurn(workingLine, step.text, { via: "plan", focus });
11959
+ stepTurn.lane = "imperative";
11960
+ const rec = withLast(stepTurn, step.deduced);
11562
11961
  rec.planState = planHolder.state;
11563
11962
  return rec;
11564
11963
  }
@@ -11669,6 +12068,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
11669
12068
  if (asserted) {
11670
12069
  note(trace, "goal: teach/remember a new fact (declarative ACE sentence)");
11671
12070
  note(trace, "lane: assertTurn — grammar/ace.mjs parseAce matched a full triple with no residue");
12071
+ asserted.lane = "teach";
11672
12072
  return withLast(asserted, "teach/remember a new fact");
11673
12073
  }
11674
12074
  // Bare declarative taxonomy (hyphenated-instance membership, article-led
@@ -11678,7 +12078,26 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
11678
12078
  if (taxonomy) {
11679
12079
  note(trace, "goal: teach/remember a new fact (bare declarative taxonomy)");
11680
12080
  note(trace, "lane: bareTaxonomyTeach — hyphenated-instance or article-led kind-of declarative, stored before the ask engine could parse it as a question");
11681
- return withLast(plainTurn(workingLine, taxonomy.text, { via: taxonomy.via, miss: taxonomy.miss, focus }), "teach/remember a new fact");
12081
+ const taxonomyTurn = plainTurn(workingLine, taxonomy.text, { via: taxonomy.via, miss: taxonomy.miss, focus });
12082
+ if (!taxonomy.miss) taxonomyTurn.lane = "teach";
12083
+ return withLast(taxonomyTurn, "teach/remember a new fact");
12084
+ }
12085
+ }
12086
+ // Synonym/related-word questions read the store through the SKOS view.
12087
+ // Routed before the ask engine: the generic parse reads "another word for
12088
+ // X" as a bare object search and "what is related to X" through
12089
+ // BARE_WHATIS_RE, both wrong lanes for this question.
12090
+ if (memoryDir) {
12091
+ const skos = await skosRelatedAnswer(memoryDir, workingLine, factRowsCache);
12092
+ if (skos) {
12093
+ const goal = `surface the remembered synonym/related-word neighbourhood of "${skos.term}"`;
12094
+ note(trace, `goal: ${goal}`);
12095
+ note(trace, `lane: SKOS VIEW — a synonym/related question ${skos.miss ? "matched but the store holds no such facts (honest miss)" : "answered from the store's relation facts"}`);
12096
+ if (!skos.miss) note(trace, "source: .tmct/memory Facts (mgx:synonym/mgx:relatedTo/mgx:similarTo, read as skos:altLabel/skos:related)");
12097
+ const skosTurn = plainTurn(workingLine, skos.text, { via: skos.miss ? "miss" : "fact", miss: skos.miss, focus });
12098
+ if (!skos.miss) skosTurn.goal = goal;
12099
+ skosTurn.lane = skos.miss ? "honest-miss" : "ask-set";
12100
+ return withLast(skosTurn, goal);
11682
12101
  }
11683
12102
  }
11684
12103
  // MEMORY-STORE counts first ("how many facts / utterances do you know") — the
@@ -7,7 +7,10 @@
7
7
  // the ask() orchestration.
8
8
 
9
9
  // Chat surface (also reachable as the `./chat` subpath export).
10
- export { runChat, COMMANDS, answerCount, renderStats } from "./chat.mjs";
10
+ // createSession is the library's session sink the same focus/last/planState
11
+ // threading and memory-backend seam every shell shares; runTurn is the pure
12
+ // single-turn engine underneath it.
13
+ export { runChat, createSession, runTurn, COMMANDS, answerCount, renderStats } from "./chat.mjs";
11
14
 
12
15
  // Grammar / NL-over-graph primitives.
13
16
  export { ask, resolveObject } from "../domain/ask.mjs";
@@ -21376,6 +21376,189 @@ ${codeblock}`, options);
21376
21376
  }
21377
21377
  });
21378
21378
 
21379
+ // src/domain/concept.mjs
21380
+ var CONCEPT_CLASS, CLASS_NOUN, FOLLOWUP_SHAPES, RELATION_TERM, RELATION_KINDS, RELATION_RENDER, RELATION_FOLLOWUP_SHAPES;
21381
+ var init_concept = __esm({
21382
+ "src/domain/concept.mjs"() {
21383
+ init_ask();
21384
+ init_codegraph();
21385
+ CONCEPT_CLASS = Object.freeze({
21386
+ class: "Class",
21387
+ module: "Module",
21388
+ function: "Function",
21389
+ method: "Method",
21390
+ attribute: "Attribute",
21391
+ variable: "GlobalVariable",
21392
+ constant: "GlobalVariable",
21393
+ commit: "Commit"
21394
+ });
21395
+ CLASS_NOUN = Object.freeze({
21396
+ Class: ["class", "classes"],
21397
+ Module: ["module", "modules"],
21398
+ Function: ["function", "functions"],
21399
+ Method: ["method", "methods"],
21400
+ Attribute: ["attribute", "attributes"],
21401
+ GlobalVariable: ["variable", "variables"],
21402
+ Commit: ["commit", "commits"]
21403
+ });
21404
+ FOLLOWUP_SHAPES = Object.freeze({
21405
+ Class: [
21406
+ (x) => `which classes inherit from ${x}`,
21407
+ (x) => `what does ${x} contain`,
21408
+ (x) => `where is ${x} defined`
21409
+ ],
21410
+ Module: [
21411
+ (x) => `what does ${x} import`,
21412
+ (x) => `which modules import ${x}`,
21413
+ (x) => `where is ${x} defined`
21414
+ ],
21415
+ Function: [
21416
+ (x) => `what calls ${x}`,
21417
+ (x) => `what does ${x} call`,
21418
+ (x) => `where is ${x} defined`
21419
+ ],
21420
+ Method: [
21421
+ (x) => `which class contains ${x}`,
21422
+ (x) => `what calls ${x}`,
21423
+ (x) => `where is ${x} defined`
21424
+ ],
21425
+ Attribute: [
21426
+ (x) => `which class contains ${x}`,
21427
+ (x) => `where is ${x} defined`
21428
+ ],
21429
+ GlobalVariable: [
21430
+ (x) => `where is ${x} defined`,
21431
+ (x) => `where is ${x} mentioned`
21432
+ ],
21433
+ Commit: [
21434
+ (x) => `what did commit ${x} touch`,
21435
+ (x) => `when did ${x} change`
21436
+ ]
21437
+ });
21438
+ RELATION_TERM = Object.freeze({
21439
+ import: "imports",
21440
+ imports: "imports",
21441
+ importing: "imports",
21442
+ imported: "imports",
21443
+ call: "calls",
21444
+ calls: "calls",
21445
+ calling: "calls",
21446
+ called: "calls",
21447
+ invoke: "calls",
21448
+ invokes: "calls",
21449
+ invoking: "calls",
21450
+ contain: "contains",
21451
+ contains: "contains",
21452
+ containing: "contains",
21453
+ containment: "contains",
21454
+ member: "contains",
21455
+ members: "contains",
21456
+ inherit: "inherits",
21457
+ inherits: "inherits",
21458
+ inheriting: "inherits",
21459
+ inheritance: "inherits",
21460
+ extend: "inherits",
21461
+ extends: "inherits",
21462
+ extending: "inherits",
21463
+ subclass: "inherits",
21464
+ subclasses: "inherits",
21465
+ subclassing: "inherits",
21466
+ test: "tests",
21467
+ tests: "tests",
21468
+ testing: "tests",
21469
+ tested: "tests",
21470
+ coverage: "tests",
21471
+ define: "defines",
21472
+ defines: "defines",
21473
+ defining: "defines",
21474
+ defined: "defines",
21475
+ definition: "defines",
21476
+ definitions: "defines",
21477
+ declaration: "defines",
21478
+ touch: "touches",
21479
+ touches: "touches",
21480
+ touching: "touches",
21481
+ touched: "touches",
21482
+ cochange: "cochange",
21483
+ "co-change": "cochange",
21484
+ "change-coupling": "cochange",
21485
+ coupled: "cochange",
21486
+ // "export"/"exports" is also a curated seon lexicon noun, but that meta reading
21487
+ // only owns "what does export mean" — no conflict with this vague-touch table.
21488
+ export: "reexports",
21489
+ exports: "reexports",
21490
+ exporting: "reexports",
21491
+ exported: "reexports",
21492
+ reexport: "reexports",
21493
+ reexports: "reexports",
21494
+ reexporting: "reexports",
21495
+ "re-export": "reexports",
21496
+ "re-exports": "reexports",
21497
+ "re-exporting": "reexports"
21498
+ });
21499
+ RELATION_KINDS = Object.freeze({
21500
+ imports: ["imports"],
21501
+ calls: ["calls", "callsSymbol"],
21502
+ contains: ["contains"],
21503
+ inherits: ["inherits"],
21504
+ tests: ["tests"],
21505
+ defines: ["defines"],
21506
+ touches: ["touches", "touchesSymbol"],
21507
+ cochange: ["cochange"],
21508
+ reexports: ["reexports"]
21509
+ });
21510
+ RELATION_RENDER = Object.freeze({
21511
+ imports: { verb: "imports", edgeNoun: "import" },
21512
+ calls: { verb: "calls", edgeNoun: "call" },
21513
+ contains: { verb: "contains", edgeNoun: "containment" },
21514
+ inherits: { verb: "inherits from", edgeNoun: "inheritance" },
21515
+ tests: { verb: "tests", edgeNoun: "test" },
21516
+ defines: { verb: "defines", edgeNoun: "definition" },
21517
+ touches: { verb: "touches", edgeNoun: "touch" },
21518
+ cochange: { verb: "changes together with", edgeNoun: "change-coupling" },
21519
+ reexports: { verb: "re-exports", edgeNoun: "re-export" }
21520
+ });
21521
+ RELATION_FOLLOWUP_SHAPES = Object.freeze({
21522
+ imports: [
21523
+ { side: "obj", make: (x) => `which modules import ${x}` },
21524
+ { side: "subj", make: (x) => `what does ${x} import` }
21525
+ ],
21526
+ calls: [
21527
+ { side: "obj", make: (x) => `what calls ${x}` },
21528
+ { side: "subj", make: (x) => `what does ${x} call` }
21529
+ ],
21530
+ contains: [
21531
+ { side: "subj", make: (x) => `what does ${x} contain` },
21532
+ { side: "obj", make: (x) => `which class contains ${x}` }
21533
+ ],
21534
+ inherits: [
21535
+ { side: "obj", make: (x) => `which classes inherit from ${x}` },
21536
+ { side: "subj", make: (x) => `where is ${x} defined` }
21537
+ ],
21538
+ tests: [
21539
+ { side: "obj", make: (x) => `what tests ${x}` },
21540
+ { side: "obj", make: (x) => `where is ${x} defined` }
21541
+ ],
21542
+ defines: [
21543
+ { side: "obj", make: (x) => `where is ${x} defined` },
21544
+ { side: "subj", make: (x) => `what does ${x} contain` }
21545
+ ],
21546
+ touches: [
21547
+ { side: "obj", make: (x) => `when did ${x} change` },
21548
+ { side: "subj", make: (x) => `what did commit ${x} touch` }
21549
+ ],
21550
+ cochange: [
21551
+ { side: "obj", make: (x) => `where is ${x} defined` },
21552
+ { side: "subj", make: (x) => `which modules import ${x}` }
21553
+ ],
21554
+ reexports: [
21555
+ { side: "subj", make: (x) => `what does ${x} export` },
21556
+ { side: "obj", make: (x) => `where is ${x} defined` }
21557
+ ]
21558
+ });
21559
+ }
21560
+ });
21561
+
21379
21562
  // src/adapters/toml-config.mjs
21380
21563
  var init_toml_config = __esm({
21381
21564
  "src/adapters/toml-config.mjs"() {
@@ -21432,7 +21615,7 @@ ${codeblock}`, options);
21432
21615
  });
21433
21616
 
21434
21617
  // src/adapters/corpus/conceptnet.mjs
21435
- var import_meta4, PKG_ROOT2, SLICE_FILE, MAP_FILE, SEON_CONCEPTS_FILE, SEON_DEFINITIONS_FILE, TIER2_DIR, TIER2_MANIFEST_FILE, WORDNET_DIR, WORDNET_MANIFEST_FILE;
21618
+ var import_meta5, PKG_ROOT3, SLICE_FILE, MAP_FILE, SEON_CONCEPTS_FILE, SEON_DEFINITIONS_FILE, TIER2_DIR, TIER2_MANIFEST_FILE, WORDNET_DIR, WORDNET_MANIFEST_FILE;
21436
21619
  var init_conceptnet = __esm({
21437
21620
  "src/adapters/corpus/conceptnet.mjs"() {
21438
21621
  init_node_fs();
@@ -21442,15 +21625,15 @@ ${codeblock}`, options);
21442
21625
  init_node_path();
21443
21626
  init_dist();
21444
21627
  init_core();
21445
- import_meta4 = {};
21446
- PKG_ROOT2 = join(dirname(fileURLToPath2(import_meta4.url)), "..", "..", "..");
21447
- SLICE_FILE = join(PKG_ROOT2, "corpus", "conceptnet", "slice.jsonl");
21448
- MAP_FILE = join(PKG_ROOT2, "src", "adapters", "corpus", "conceptnet-map.toml");
21449
- SEON_CONCEPTS_FILE = join(PKG_ROOT2, "corpus", "seon", "concepts.jsonl");
21450
- SEON_DEFINITIONS_FILE = join(PKG_ROOT2, "corpus", "seon", "definitions.jsonl");
21451
- TIER2_DIR = join(PKG_ROOT2, "corpus", "tier2");
21628
+ import_meta5 = {};
21629
+ PKG_ROOT3 = join(dirname(fileURLToPath2(import_meta5.url)), "..", "..", "..");
21630
+ SLICE_FILE = join(PKG_ROOT3, "corpus", "conceptnet", "slice.jsonl");
21631
+ MAP_FILE = join(PKG_ROOT3, "src", "adapters", "corpus", "conceptnet-map.toml");
21632
+ SEON_CONCEPTS_FILE = join(PKG_ROOT3, "corpus", "seon", "concepts.jsonl");
21633
+ SEON_DEFINITIONS_FILE = join(PKG_ROOT3, "corpus", "seon", "definitions.jsonl");
21634
+ TIER2_DIR = join(PKG_ROOT3, "corpus", "tier2");
21452
21635
  TIER2_MANIFEST_FILE = join(TIER2_DIR, "manifest.json");
21453
- WORDNET_DIR = join(PKG_ROOT2, "corpus", "wordnet");
21636
+ WORDNET_DIR = join(PKG_ROOT3, "corpus", "wordnet");
21454
21637
  WORDNET_MANIFEST_FILE = join(WORDNET_DIR, "manifest.json");
21455
21638
  }
21456
21639
  });
@@ -21544,7 +21727,7 @@ ${codeblock}`, options);
21544
21727
  }
21545
21728
  };
21546
21729
  }
21547
- var import_meta5, NAMENET_DIR, EXTENSION_KINDS, CONCEPTNET_PREFER, BUILTIN_EXTENSIONS;
21730
+ var import_meta6, NAMENET_DIR, EXTENSION_KINDS, CONCEPTNET_PREFER, BUILTIN_EXTENSIONS;
21548
21731
  var init_extensions = __esm({
21549
21732
  "src/services/extensions.mjs"() {
21550
21733
  init_node_path();
@@ -21552,8 +21735,8 @@ ${codeblock}`, options);
21552
21735
  init_node_url();
21553
21736
  init_toml_config();
21554
21737
  init_conceptnet();
21555
- import_meta5 = {};
21556
- NAMENET_DIR = join(dirname(fileURLToPath2(import_meta5.url)), "..", "..", "corpus", "namenet");
21738
+ import_meta6 = {};
21739
+ NAMENET_DIR = join(dirname(fileURLToPath2(import_meta6.url)), "..", "..", "corpus", "namenet");
21557
21740
  EXTENSION_KINDS = Object.freeze(["corpus", "lexicon", "templates", "pack", "ontology"]);
21558
21741
  CONCEPTNET_PREFER = ["rdfs:subClassOf", "rdf:type", "mgx:usedFor", "mgx:partOf", "mgx:capableOf"];
21559
21742
  BUILTIN_EXTENSIONS = Object.freeze(builtinExtensions());
@@ -22633,6 +22816,134 @@ ${JSON.stringify(envelope, null, 2)}`;
22633
22816
  init_fuzzy();
22634
22817
  init_lexicon();
22635
22818
 
22819
+ // src/domain/reference-pack.mjs
22820
+ init_hash();
22821
+ init_lexicon();
22822
+ init_concept();
22823
+
22824
+ // src/adapters/corpus/reference-pack.mjs
22825
+ init_node_fs();
22826
+ init_node_url();
22827
+ init_node_path();
22828
+ var import_meta4 = {};
22829
+ var PKG_ROOT2 = join(dirname(fileURLToPath2(import_meta4.url)), "..", "..", "..");
22830
+
22831
+ // src/domain/dialogue-acts.mjs
22832
+ var DIALOGUE_ACT_DIMENSIONS = Object.freeze([
22833
+ "task",
22834
+ "autoFeedback",
22835
+ "alloFeedback",
22836
+ "turnManagement",
22837
+ "timeManagement",
22838
+ "discourseStructuring",
22839
+ "ownCommunicationManagement",
22840
+ "partnerCommunicationManagement",
22841
+ "socialObligationsManagement",
22842
+ "contactManagement"
22843
+ ]);
22844
+ var DIALOGUE_ACTS = Object.freeze({
22845
+ propositionalQuestion: Object.freeze({
22846
+ dimension: "task",
22847
+ gloss: "a yes/no question about whether a proposition holds ('does X import Y?')"
22848
+ }),
22849
+ checkQuestion: Object.freeze({
22850
+ dimension: "task",
22851
+ gloss: "a yes/no question whose asker already expects the answer yes ('..., right?')"
22852
+ }),
22853
+ setQuestion: Object.freeze({
22854
+ dimension: "task",
22855
+ gloss: "a wh-question asking for the members of a set ('what does X import?')"
22856
+ }),
22857
+ choiceQuestion: Object.freeze({
22858
+ dimension: "task",
22859
+ gloss: "a question asking which of the listed alternatives holds ('is X a module or a class?')"
22860
+ }),
22861
+ inform: Object.freeze({
22862
+ dimension: "task",
22863
+ gloss: "a declarative telling the addressee something (a teach turn; also tmct explaining its own function)"
22864
+ }),
22865
+ answer: Object.freeze({
22866
+ dimension: "task",
22867
+ gloss: "an inform that discharges a question just asked (an answer grounded in the graph)"
22868
+ }),
22869
+ confirm: Object.freeze({
22870
+ dimension: "task",
22871
+ gloss: "an answer 'yes' to a check question"
22872
+ }),
22873
+ disconfirm: Object.freeze({
22874
+ dimension: "task",
22875
+ gloss: "an answer 'no' to a check question"
22876
+ }),
22877
+ agreement: Object.freeze({
22878
+ dimension: "task",
22879
+ gloss: "an inform stating that the speaker holds what the addressee just stated to be true"
22880
+ }),
22881
+ disagreement: Object.freeze({
22882
+ dimension: "task",
22883
+ gloss: "an inform stating that the speaker holds what the addressee just stated to be false"
22884
+ }),
22885
+ correction: Object.freeze({
22886
+ dimension: "task",
22887
+ gloss: "a disagreement that also supplies the replacement ('no, I meant Y')"
22888
+ }),
22889
+ request: Object.freeze({
22890
+ dimension: "task",
22891
+ gloss: "asks the addressee to perform an action ('solve it' \u2014 a goal turn)"
22892
+ }),
22893
+ instruct: Object.freeze({
22894
+ dimension: "task",
22895
+ gloss: "a request the addressee is expected to carry out without negotiation (a bare imperative)"
22896
+ }),
22897
+ suggestion: Object.freeze({
22898
+ dimension: "task",
22899
+ gloss: "puts an action forward as advisable without claiming authority over the addressee"
22900
+ }),
22901
+ offer: Object.freeze({
22902
+ dimension: "task",
22903
+ gloss: "commits the speaker to an action, conditional on the addressee wanting it"
22904
+ }),
22905
+ autoPositive: Object.freeze({
22906
+ dimension: "autoFeedback",
22907
+ gloss: "the sender reports its own processing of the previous turn succeeded (an acknowledgement)"
22908
+ }),
22909
+ autoNegative: Object.freeze({
22910
+ dimension: "autoFeedback",
22911
+ gloss: "the sender reports its own processing of the previous turn failed \u2014 tmct's honest miss"
22912
+ }),
22913
+ initialGreeting: Object.freeze({
22914
+ dimension: "socialObligationsManagement",
22915
+ gloss: "opens an exchange of greetings ('hi')"
22916
+ }),
22917
+ returnGreeting: Object.freeze({
22918
+ dimension: "socialObligationsManagement",
22919
+ gloss: "answers a greeting with a greeting"
22920
+ }),
22921
+ thanking: Object.freeze({
22922
+ dimension: "socialObligationsManagement",
22923
+ gloss: "expresses gratitude for something the addressee did"
22924
+ }),
22925
+ apology: Object.freeze({
22926
+ dimension: "socialObligationsManagement",
22927
+ gloss: "expresses regret for something the speaker did"
22928
+ })
22929
+ });
22930
+ var LANE_DIALOGUE_ACTS = Object.freeze({
22931
+ teach: "inform",
22932
+ "ask-set": "setQuestion",
22933
+ "ask-propositional": "propositionalQuestion",
22934
+ goal: "request",
22935
+ imperative: "instruct",
22936
+ "honest-miss": "autoNegative",
22937
+ greeting: "initialGreeting",
22938
+ thanks: "thanking",
22939
+ help: "inform",
22940
+ // The guessing game's turns are task-dimension: a reply that discharges
22941
+ // the other side's move (a hint, a win, a rebuttal) is an answer; tmct
22942
+ // stating its own move (an opening, its next guess) is an inform.
22943
+ "game-answer": "answer",
22944
+ "game-inform": "inform"
22945
+ });
22946
+
22636
22947
  // src/services/chat-session.mjs
22637
22948
  init_node_path();
22638
22949
  init_node_fs();