@polycode-projects/the-mechanical-code-talker 0.3.0 → 0.5.0

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/src/chat.mjs CHANGED
@@ -37,9 +37,9 @@
37
37
  // load-bearing — see its docblock), telemetry, and the close. runChat is the
38
38
  // readline shell over it; src/tui/app.mjs is the Ink shell over the same sink.
39
39
 
40
- import { join } from "node:path";
40
+ import { join, dirname } from "node:path";
41
41
  import { createWriteStream } from "node:fs";
42
- import { mkdir, readFile } from "node:fs/promises";
42
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
43
43
  import { createInterface } from "node:readline/promises";
44
44
  import { spawnSync } from "node:child_process";
45
45
  import { dispatchTool } from "./server.mjs";
@@ -49,6 +49,8 @@ import { SESSIONS_DIR_REL, appendSessionToGraph } from "./sessions.mjs";
49
49
  import { uuidv7 } from "./uuid.mjs";
50
50
  import { createTelemetry } from "./telemetry.mjs";
51
51
  import * as defaultSource from "./source.mjs";
52
+ import { loadTemplates, render as renderTemplate } from "./corpus/templates.mjs";
53
+ import { finish } from "./finish.mjs";
52
54
 
53
55
  // uuidv7 lives in ./uuid.mjs (shared with telemetry + the bench stamp); re-exported
54
56
  // here because callers/tests still import it from chat.mjs.
@@ -136,11 +138,22 @@ function countableKinds(graph) {
136
138
  return Object.keys(CLASS_LABELS).filter((c) => present.has(c)).map((c) => CLASS_LABELS[c][1]);
137
139
  }
138
140
 
141
+ /** A discourse-anaphoric count/list head — "how many [of] those/them/these",
142
+ * "count them/those/these". These refer to the previous answer set and are owned
143
+ * by the ask engine's anaphora node, never the header-count path. */
144
+ const ANAPHORA_COUNT_RE = /\b(?:how many|how much|count|number of)\s+(?:of\s+)?(?:those|them|these)\b/i;
145
+
139
146
  /** Recognise a count/aggregate question and answer it from the graph header, or
140
147
  * null if it isn't one (→ fall through to tmct_ask). "how many X [are there]",
141
148
  * "count [the] X", "number of X". An unknown kind lists what it CAN count. */
142
149
  export function answerCount(graph, query) {
143
150
  if (!graph) return null;
151
+ // ANAPHORIC counts ("how many of those are tested", "count them", "how many of
152
+ // them") count the PREVIOUS answer's set, not a graph kind — decline so the turn
153
+ // falls through to the ask engine's anaphora node (which threads `prev`). Without
154
+ // this the bare "of"/pronoun head is mis-reported as an uncountable kind and the
155
+ // discourse+count follow-up dies before it can resolve (CHATBENCH_006 lever 1).
156
+ if (ANAPHORA_COUNT_RE.test(String(query))) return null;
144
157
  const m = String(query).match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b/i);
145
158
  if (!m) return null;
146
159
  const noun = m[1].toLowerCase();
@@ -153,6 +166,31 @@ export function answerCount(graph, query) {
153
166
  return `${n} ${classNoun(cls, n)}.`;
154
167
  }
155
168
 
169
+ /** ASSERTED-VOCABULARY count (CHATBENCH_006 lever 3): once "every class is a type"
170
+ * is remembered, "how many types are there" counts as many types as there are
171
+ * classes — the asserted object noun inherits the subject class's cardinality.
172
+ * Consulted only when answerCount can't map the noun to a graph class (an unknown
173
+ * kind) AND a session's memory is in hand. Returns the count string or null (no
174
+ * such fact → the honest "I can't count …" from answerCount stands). */
175
+ async function countFromFacts(graph, memoryDir, query) {
176
+ if (!graph || !memoryDir) return null;
177
+ const m = String(query).match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b/i);
178
+ if (!m) return null;
179
+ const asked = m[1].toLowerCase();
180
+ if (COUNT_NOUNS[asked]) return null; // a real graph kind — answerCount owns it
181
+ let normFactTerm;
182
+ try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
183
+ const objVariants = factTermVariants(normFactTerm, asked);
184
+ const isa = (await factRows(memoryDir))
185
+ .filter((f) => ISA_PREDICATES.has(f.predicate) && objVariants.has(f.object));
186
+ // pick the highest-trust asserted subject that maps to a countable graph class
187
+ for (const f of isa.sort((a, b) => (b.trust ?? 0) - (a.trust ?? 0))) {
188
+ const cls = COUNT_NOUNS[String(f.subject).toLowerCase()];
189
+ if (cls) { const n = countClass(graph, cls); return `${n} ${asked}.`; }
190
+ }
191
+ return null;
192
+ }
193
+
156
194
  /** `/stats`: a one-screen overview of the graph — class counts, relationship
157
195
  * (predicate) counts, and module/package totals — read straight off the header. */
158
196
  export function renderStats(graph) {
@@ -211,15 +249,43 @@ export function isConversational(query) {
211
249
  return q.split(/\s+/).filter(Boolean).length <= 3 && !codeish;
212
250
  }
213
251
 
214
- /** The short friendly orientation shown for conversational input. */
215
- const FRIENDLY = [
216
- "I answer questions about THIS codebase's structure — imports, calls, definitions,",
217
- "history and counts. For example:",
218
- " which modules import walk.mjs",
219
- " what calls buildContextBundle",
220
- " how many classes are there",
221
- "/help for commands, /stats for an overview of the graph.",
222
- ].join("\n");
252
+ // ---- the response-template library (W1: templates render path) ----
253
+ // The WORDING of the conversational/orientation surfaces lives in
254
+ // data/templates/responses.jsonl (corpus/templates.mjs) the template library is
255
+ // load-bearing for these turns. The recognizer sets below stay code: they decide
256
+ // WHICH template answers, never what it says. Loading is lazy + failure-tolerated
257
+ // (chat.mjs ethos: a turn never crashes) — a broken/missing data file degrades to
258
+ // one short honest line, never a throw before the prompt.
259
+
260
+ /** Template ids (data/templates/responses.jsonl) for the surfaces chat renders. */
261
+ const T_GREETING = "conversational-greeting";
262
+ const T_GREETING_BY_PHRASE = {
263
+ "hello there": "conversational-greeting-hello-there",
264
+ "good morning": "conversational-greeting-good-morning",
265
+ "good afternoon": "conversational-greeting-good-afternoon",
266
+ "good evening": "conversational-greeting-good-evening",
267
+ };
268
+ const T_THANKS = "conversational-thanks";
269
+ const T_FAREWELL = "conversational-farewell";
270
+ const T_ORIENTATION = "orientation-friendly";
271
+ const T_WHY_EMPTY = "miss-no-previous-answer";
272
+
273
+ /** The degraded line when the template library itself cannot load — a packaging
274
+ * failure said out loud, never a crashed turn or a silently different answer. */
275
+ const TEMPLATES_UNAVAILABLE = "response templates unavailable — ask a question, or /help for commands.";
276
+
277
+ let templatesPromise = null;
278
+ /** Load data/templates/responses.jsonl once per process; null on failure. */
279
+ function chatTemplates() {
280
+ if (!templatesPromise) templatesPromise = loadTemplates().catch(() => null);
281
+ return templatesPromise;
282
+ }
283
+ /** Strict render through the loaded map; null on any failure (no map / unknown
284
+ * id / missing slot) so every call site degrades explicitly, never half-fills. */
285
+ function tRender(templates, id, slots = {}) {
286
+ if (!templates) return null;
287
+ try { return renderTemplate(id, slots, templates); } catch { return null; }
288
+ }
223
289
 
224
290
  // ---- conversational (ELIZA/Zork-manners) templated layer ----
225
291
  // A small CLOSED set of human expressions handled with a TEMPLATED response BEFORE
@@ -248,16 +314,8 @@ const WHY = new Set([
248
314
  "elaborate", "tell me more", "more detail", "expand",
249
315
  ]);
250
316
 
251
- const GREETING = "Hi. Ask me about this codebase imports, calls, definitions, history — or /help.";
252
- /** A couple of expression-specific lines (a light Zork nod for "hello there"). */
253
- const GREETING_LINES = {
254
- "hello there": 'Hello there. (A hollow voice says, "fool.") Ask me about this codebase, or /help.',
255
- "good morning": "Good morning. Ask me about this codebase, or /help.",
256
- "good afternoon": "Good afternoon. Ask me about this codebase, or /help.",
257
- "good evening": "Good evening. Ask me about this codebase, or /help.",
258
- };
259
- const ACK = "Any time. Ask another, or /help for what I can do.";
260
- const FAREWELL = "Bye — flushing the session log. Come back with a question any time.";
317
+ // (Greeting/thanks/farewell wording moved to data/templates/responses.jsonlW1.
318
+ // The expression-specific greeting variants map through T_GREETING_BY_PHRASE above.)
261
319
 
262
320
  /** Re-render the last answer in verbose form: the previous query + its full answer
263
321
  * plus the ask envelope's traversal receipt and the matched entities (the detail a
@@ -289,22 +347,31 @@ export function renderVerbose(last) {
289
347
  function conversationalTurn(line, ctx) {
290
348
  const raw = String(line);
291
349
  const q = raw.toLowerCase().replace(/[.!?]+$/, "").replace(/\s+/g, " ").trim();
292
- const mk = (answer, { end = false, miss = false } = {}) => {
350
+ const t = (id) => tRender(ctx.templates, id) ?? TEMPLATES_UNAVAILABLE;
351
+ const mk = (answer, { end = false, miss = false, via = "template" } = {}) => {
293
352
  const ts = new Date().toISOString();
294
353
  return {
295
354
  answer,
296
355
  logLines: [ts, `> ${raw}`, answer, ""],
297
- record: { type: "turn", ts, query: raw, conversational: true, resolvedIds: [], answeredIds: [], miss },
356
+ record: { type: "turn", ts, query: raw, conversational: true, via, resolvedIds: [], answeredIds: [], miss },
298
357
  focus: ctx.focus,
299
358
  last: ctx.last, // a conversational turn never overwrites the last real answer
300
359
  ...(end ? { end: true } : {}),
301
360
  };
302
361
  };
303
- if (BYE.has(q)) return mk(FAREWELL, { end: true });
304
- if (WHY.has(q)) { const v = renderVerbose(ctx.last); return mk(v.text, { miss: v.empty }); }
305
- if (GREET.has(q)) return mk(GREETING_LINES[q] || GREETING);
306
- if (THANKS.has(q)) return mk(ACK);
307
- if (q === "help" || q === "?" || HELP_PHRASES.some((re) => re.test(raw))) return mk(FRIENDLY);
362
+ if (BYE.has(q)) return mk(t(T_FAREWELL), { end: true });
363
+ if (WHY.has(q)) {
364
+ const v = renderVerbose(ctx.last);
365
+ // The empty-state hint is template wording (via:"template", the data row wins;
366
+ // renderVerbose's own string is the degraded fallback for direct library callers).
367
+ // A real expansion re-renders the LAST ANSWER — its wording is the prior answer's,
368
+ // not a template's, so it carries via:"conversational".
369
+ if (v.empty) return mk(tRender(ctx.templates, T_WHY_EMPTY) ?? v.text, { miss: true });
370
+ return mk(v.text, { via: "conversational" });
371
+ }
372
+ if (GREET.has(q)) return mk(t(T_GREETING_BY_PHRASE[q] || T_GREETING));
373
+ if (THANKS.has(q)) return mk(t(T_THANKS));
374
+ if (q === "help" || q === "?" || HELP_PHRASES.some((re) => re.test(raw))) return mk(t(T_ORIENTATION));
308
375
  return null;
309
376
  }
310
377
 
@@ -348,6 +415,7 @@ export async function helpText() {
348
415
  ["<question>", "ask the graph in plain English (the default for any non-slash line)"],
349
416
  ...Object.entries(COMMANDS).map(([name, s]) => [`/${name}${s.arg ? (s.optional ? ` [${s.arg}]` : ` <${s.arg}>`) : ""}`, s.help]),
350
417
  ["/stats", "a one-screen overview: entity counts, relationship counts, packages"],
418
+ ["/memory [verbose]", "what tmct remembers: facts, utterances, sessions, folded blocks"],
351
419
  ["/focus <symbol>", "set the current focus (reused by 'it'/'this' and no-arg entity commands)"],
352
420
  ["/help", "this list"],
353
421
  ["/exit", "leave the session (also Ctrl+C / Ctrl+D)"],
@@ -368,24 +436,487 @@ export async function helpText() {
368
436
  ].join("\n");
369
437
  }
370
438
 
439
+ // ---- W2: memory recall on the miss path (retrieveBlocks → runAsk) ----
440
+
441
+ /** The conservative relevance floor a folded-session block must clear (the
442
+ * retrieveBlocks idf×(1+rank) score) before an honest ask-miss is answered from
443
+ * memory. Calibrated in the small-corpus regime (test/wiring-recall.test.mjs):
444
+ * a genuine re-ask scores ~4, a frame-word coincidence ~1. */
445
+ export const RECALL_MIN_SCORE = 2.0;
446
+
447
+ /** How many blocks the miss path consults (the W2 seam: retrieveBlocks(dir, q, 2)). */
448
+ const RECALL_TOP_K = 2;
449
+
450
+ /** Frame/stop words ignored when checking that a recalled Q genuinely shares
451
+ * vocabulary with the live query — at least one shared CONTENT word is required,
452
+ * so "which …" alone can never masquerade as a memory. */
453
+ const RECALL_STOPWORDS = new Set([
454
+ "the", "a", "an", "and", "or", "for", "with", "about", "into", "from",
455
+ "which", "what", "who", "how", "when", "where", "why",
456
+ "does", "do", "did", "is", "are", "was", "were", "there",
457
+ "me", "my", "we", "i", "you", "it", "this", "that", "in", "of", "to",
458
+ ]);
459
+ const recallWords = (s) => new Set(
460
+ String(s).toLowerCase().split(/[^a-z0-9.]+/).filter((w) => w.length >= 3 && !RECALL_STOPWORDS.has(w)),
461
+ );
462
+
463
+ /** Decode a uuidv7 id's leading 48-bit unix-ms timestamp → "YYYY-MM-DD", or null
464
+ * (block ids ARE session uuidv7s — fold.mjs sets block id = session id). */
465
+ function uuidv7Day(id) {
466
+ const hex = String(id || "").replace(/-/g, "").slice(0, 12);
467
+ if (!/^[0-9a-f]{12}$/i.test(hex)) return null;
468
+ const ms = parseInt(hex, 16);
469
+ return ms > 0 && Number.isFinite(ms) ? new Date(ms).toISOString().slice(0, 10) : null;
470
+ }
471
+
472
+ /** Pick the recalled block's Q/A pair most relevant to the query (content-word
473
+ * overlap; ties → first). Null when NO pair shares a content word — the block
474
+ * matched on packaging, not substance, so the honest miss must stand. */
475
+ function bestQaPair(blockText, query) {
476
+ const qWords = recallWords(query);
477
+ const pairs = [];
478
+ let open = null;
479
+ for (const line of String(blockText).split("\n")) {
480
+ if (line.startsWith("Q: ")) { open = { q: line.slice(3), a: "" }; pairs.push(open); }
481
+ else if (open && line.startsWith("A: ")) open.a = line.slice(3);
482
+ }
483
+ let best = null;
484
+ let bestScore = 0;
485
+ for (const p of pairs) {
486
+ let score = 0;
487
+ for (const w of recallWords(p.q)) if (qWords.has(w)) score += 1;
488
+ if (score > bestScore) { best = p; bestScore = score; }
489
+ }
490
+ return best;
491
+ }
492
+
493
+ /** W2 seam: consult the folded-session block index for an honest miss. A
494
+ * sufficiently-relevant hit returns the recalled Q/A, framed and cited to its
495
+ * session; anything less returns null and the miss stands unchanged. Lazy +
496
+ * failure-tolerated (chat.mjs ethos): a broken memory store degrades to null. */
497
+ async function recallFromBlocks(memoryDir, query) {
498
+ try {
499
+ const { retrieveBlocks } = await import("./memory/blocks.mjs");
500
+ const hits = await retrieveBlocks(memoryDir, query, RECALL_TOP_K);
501
+ const best = hits[0];
502
+ if (!best || best.score < RECALL_MIN_SCORE || !best.text) return null;
503
+ const pair = bestQaPair(best.text, query);
504
+ if (!pair) return null;
505
+ const day = uuidv7Day(best.id);
506
+ const cite = `session ${String(best.id).slice(0, 8)}${day ? `, ${day}` : ""}`;
507
+ const qa = pair.a ? `Q: ${pair.q}\n A: ${pair.a}` : `Q: ${pair.q}`;
508
+ return `you asked about this before (${cite}):\n ${qa}`;
509
+ } catch {
510
+ return null;
511
+ }
512
+ }
513
+
514
+ // ---- W4: asserted Facts → answers (the memory graph's reified triples) ----
515
+
516
+ /** How a stored fact predicate reads in English — one phrase per predicate the
517
+ * two writers (the ACE grammar, the ConceptNet map) actually emit. An unknown
518
+ * predicate renders verbatim rather than being guessed around. */
519
+ const FACT_PREDICATE_PHRASES = {
520
+ "rdfs:subClassOf": "is a kind of",
521
+ "rdf:type": "is a",
522
+ "owl:disjointWith": "is not a",
523
+ "mgx:partOf": "is part of",
524
+ "mgx:hasA": "has",
525
+ "mgx:usedFor": "is used for",
526
+ "mgx:capableOf": "can",
527
+ "mgx:atLocation": "is found in",
528
+ "mgx:causes": "causes",
529
+ "mgx:hasProperty": "is",
530
+ "mgx:madeOf": "is made of",
531
+ "mgx:receivesAction": "can be",
532
+ "mgx:createdBy": "is created by",
533
+ "mgx:mannerOf": "is a way to",
534
+ "mgx:desires": "wants",
535
+ "mgx:locatedNear": "is typically near",
536
+ "mgx:motivatedByGoal": "is motivated by",
537
+ "mgx:obstructedBy": "can be prevented by",
538
+ "mgx:causesDesire": "makes you want to",
539
+ "mgx:hasSubevent": "involves",
540
+ "mgx:hasFirstSubevent": "begins with",
541
+ "mgx:hasLastSubevent": "ends with",
542
+ "mgx:hasPrerequisite": "requires",
543
+ };
544
+ const factPhrase = (f) => `${f.subject} ${FACT_PREDICATE_PHRASES[f.predicate] || f.predicate} ${f.object}`;
545
+
546
+ /** One rendered fact line: "you told me" when the chat asserted it (an ace:chat
547
+ * provenance tag), "i learned" for corpus-only facts — provenance VERBATIM. */
548
+ function renderFactLine(f) {
549
+ const lead = f.provenance.includes("ace:chat") ? "you told me" : "i learned";
550
+ return `${lead}: ${factPhrase(f)}${f.provenance ? ` (source: ${f.provenance})` : ""}`;
551
+ }
552
+
553
+ /** Read every reified Fact out of the memory graph as plain {subject, predicate,
554
+ * object, provenance} rows. Lazy + failure-tolerated: no memory → []. */
555
+ async function memoryFacts(memoryDir) {
556
+ try {
557
+ const { loadMemory } = await import("./memory/core.mjs");
558
+ const m = await loadMemory(memoryDir);
559
+ const out = [];
560
+ for (const ind of m.individuals || []) {
561
+ if (ind?.class !== "Fact") continue;
562
+ const get = (k) => (ind.attributes || []).find((a) => a.key === k)?.value || "";
563
+ out.push({ subject: get("subject"), predicate: get("predicate"), object: get("object"), provenance: get("provenance") });
564
+ }
565
+ return out;
566
+ } catch {
567
+ return [];
568
+ }
569
+ }
570
+
571
+ /** Load memory once and resolve every reified Fact into a TRUST-BEARING row
572
+ * ({subject,predicate,object,provenance,trust,sourceTypes,…}) via core's
573
+ * readFactRows — the seam the answer layer ranks + cites without re-walking the
574
+ * graph shape (Wave-A memory/core.mjs). Lazy + failure-tolerated: no memory → []. */
575
+ async function factRows(memoryDir) {
576
+ try {
577
+ const { loadMemory, readFactRows } = await import("./memory/core.mjs");
578
+ return readFactRows(await loadMemory(memoryDir));
579
+ } catch {
580
+ return [];
581
+ }
582
+ }
583
+
584
+ /** Spelling variants a question term is matched under (normFactTerm + a naive
585
+ * singular): "caches"/"a cache"/"/c/en/cache" all reach the stored "cache". */
586
+ function factTermVariants(normFactTerm, term) {
587
+ const t = normFactTerm(term);
588
+ const v = new Set([t]);
589
+ if (t.endsWith("es")) v.add(t.slice(0, -2));
590
+ if (t.endsWith("s")) v.add(t.slice(0, -1));
591
+ return v;
592
+ }
593
+
594
+ /** "is a module a component" — the yes/no vocabulary form the graph grammar
595
+ * doesn't parse; checked against the isa-family fact predicates only. */
596
+ const ISA_ASK_RE = /^(?:is|are)\s+(?:an?\s+)?(.+?)\s+(?:a\s+kind\s+of|a\s+type\s+of|an?)\s+(.+?)[?.!\s]*$/i;
597
+ const ISA_PREDICATES = new Set(["rdfs:subClassOf", "rdf:type"]);
598
+ /** "what do you know about caches" — the open recall-everything form. */
599
+ const KNOW_ABOUT_RE = /^what\s+do\s+you\s+know\s+about\s+(.+?)[?.!\s]*$/i;
600
+ /** How many facts a single answer lists before "…and N more". */
601
+ const FACT_ANSWER_CAP = 5;
602
+
603
+ /** W4 seam: answer (or extend) a vocabulary/definition question from the MEMORY
604
+ * graph's Facts. Returns { text, replace } — `replace:false` means the engine's
605
+ * own (schema-docs) answer stands and the fact lines are appended under it —
606
+ * or null when memory holds nothing relevant (misses stay unchanged). */
607
+ async function factAnswer(memoryDir, query, envelope, miss) {
608
+ let normFactTerm;
609
+ try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
610
+ const q = String(query).trim();
611
+
612
+ // (a) meta-shaped questions ("what is a module", "what does cache mean") — the
613
+ // parsed object term, matched against fact SUBJECTS; consulted for hits (append
614
+ // alongside the schema-docs answer) and misses (facts answer alone) alike.
615
+ // When the engine produced NO parse at all (the empty-bootstrap graph
616
+ // short-circuits before parsing), the meta FORM is recognized directly on a
617
+ // miss — same required-article discipline as the grammar's own T5 template.
618
+ let metaTerm = envelope?.parsed?.shape === "meta" ? envelope.parsed.object : null;
619
+ if (!metaTerm && miss && !envelope?.parsed) {
620
+ const m = q.match(/^what\s+(?:is|are)\s+an?\s+(.+?)[?.!\s]*$/i)
621
+ || q.match(/^what\s+(?:does|do)\s+(.+?)\s+means?[?.!\s]*$/i);
622
+ if (m) metaTerm = m[1];
623
+ }
624
+ if (metaTerm) {
625
+ const variants = factTermVariants(normFactTerm, metaTerm);
626
+ const hits = (await memoryFacts(memoryDir)).filter((f) => variants.has(f.subject));
627
+ if (!hits.length) return null;
628
+ const shown = hits.slice(0, FACT_ANSWER_CAP).map(renderFactLine);
629
+ const extra = hits.length > FACT_ANSWER_CAP ? `\n…and ${hits.length - FACT_ANSWER_CAP} more remembered fact${hits.length - FACT_ANSWER_CAP === 1 ? "" : "s"}.` : "";
630
+ return { text: shown.join("\n") + extra, replace: miss };
631
+ }
632
+ if (!miss) return null;
633
+
634
+ // (b) "is a module a component" — yes iff a remembered isa-family fact says so.
635
+ const isa = q.match(ISA_ASK_RE);
636
+ if (isa) {
637
+ const subj = factTermVariants(normFactTerm, isa[1]);
638
+ const obj = factTermVariants(normFactTerm, isa[2]);
639
+ const hit = (await memoryFacts(memoryDir)).find(
640
+ (f) => ISA_PREDICATES.has(f.predicate) && subj.has(f.subject) && obj.has(f.object),
641
+ );
642
+ if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
643
+ return null; // no remembered fact — the honest miss stands (never a guessed "no")
644
+ }
645
+
646
+ // (c) "what do you know about caches" — everything remembered that MENTIONS the
647
+ // term (subject or object), capped.
648
+ const know = q.match(KNOW_ABOUT_RE);
649
+ if (know) {
650
+ const variants = factTermVariants(normFactTerm, know[1]);
651
+ const hits = (await memoryFacts(memoryDir)).filter((f) => variants.has(f.subject) || variants.has(f.object));
652
+ if (!hits.length) return null;
653
+ // echo the STORED spelling ("caches" asked → "cache" known), never a guess
654
+ const term = variants.has(hits[0].subject) ? hits[0].subject : hits[0].object;
655
+ const shown = hits.slice(0, FACT_ANSWER_CAP).map((f) => ` ${renderFactLine(f)}`);
656
+ const extra = hits.length > FACT_ANSWER_CAP ? `\n …and ${hits.length - FACT_ANSWER_CAP} more.` : "";
657
+ return { text: `${hits.length} remembered fact${hits.length === 1 ? "" : "s"} about ${term}:\n${shown.join("\n")}${extra}`, replace: true };
658
+ }
659
+ return null;
660
+ }
661
+
662
+ /** "what did i tell you about X" — the multi-turn recall phrasing (a sibling of
663
+ * factAnswer's "what do you know about X" KNOW_ABOUT form): everything remembered
664
+ * that mentions X on either side. */
665
+ const TOLD_ABOUT_RE = /^what\s+(?:did|have)\s+(?:i|we|you)\s+(?:told|tell|said|say)\s+(?:you|me|us)?\s*about\s+(.+?)[?.!\s]*$/i;
666
+ /** "what kind of thing is an X" — the subject-side membership phrasing the grammar
667
+ * doesn't parse: reports X's OWN remembered type (falling back to X's members). */
668
+ const KIND_OF_RE = /^what\s+kind\s+of\s+(?:thing|class|type|category|entity)?\s*(?:is|are)\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
669
+ /** WHOLE-STORE recall (CHATBENCH_006 lever 3): "what did i tell you [last time]",
670
+ * "what facts do you know", "what do you remember" — list EVERY remembered fact
671
+ * (no subject/object term to filter on), cited, higher-trust first. The multi-turn
672
+ * / cross-session assert-recall surfaces that carry no term the grammar can bind. */
673
+ const WHOLE_RECALL_RE = /^(?:what\s+(?:did|have)\s+(?:i|we)\s+(?:told?|tell|said?|say)\s+(?:you|me|us)?(?:\s+(?:last\s+time|before|earlier|previously|already))?|what\s+facts?\s+do\s+you\s+(?:know|have|remember)|what\s+do\s+you\s+(?:know|remember)|what\s+have\s+you\s+(?:learned|learnt|remembered))[?.!\s]*$/i;
674
+
675
+ /** The singular class-noun of the graph entity a term names ("app/lib/a.mjs" →
676
+ * "module", "Widget" → "class"), via the ask engine's own resolver + the loaded
677
+ * graph's class map — or null on a miss/ambiguity/no-graph. Lets forward
678
+ * membership answer over a graph INSTANCE, not just a bare class word. */
679
+ async function entityClassNoun(graph, term) {
680
+ const ent = await resolveEntity(graph, term);
681
+ if (!ent) return null;
682
+ const cls = (graph?.byId?.get?.(ent.id) || (graph?.individuals || []).find((i) => i?.id === ent.id))?.class;
683
+ return cls && CLASS_LABELS[cls] ? CLASS_LABELS[cls][0] : null;
684
+ }
685
+
686
+ /** ASSERT-RECALL MULTI-TURN READ-BACK (PLAN_CYCLE_4 tail → cycle-005 lever 2):
687
+ * once "every X is a Y" is asserted in an earlier turn, the graded assert-recall
688
+ * cells (B2/C1 assert) query it back across turns in shapes the graph grammar
689
+ * can't parse — so each dies as an honest miss even though "X is a kind of Y" is
690
+ * remembered. This reader answers those declare-then-recall shapes from the
691
+ * reified Facts (readFactRows — trust-bearing), citing each fact's provenance
692
+ * verbatim, higher-trust first:
693
+ * (a) FORWARD membership "is an X a Y" — X a class WORD ("is a module a
694
+ * component") OR a graph INSTANCE ("is app/lib/a.mjs a component", resolved
695
+ * to its class-noun) — yes iff a remembered isa-family fact says so;
696
+ * (b) RECALL "what did i tell you about X" — every remembered fact mentioning X;
697
+ * (c) REVERSE membership — "what is a Y" reports Y's members (object-side), and
698
+ * "what kind of thing is an X" reports X's own type (subject-side first).
699
+ * Miss-only and run AFTER factAnswer returns null, so it never shadows the
700
+ * subject-side answer or a schema hit. Returns { text, replace:true } or null. */
701
+ async function factReadBack(memoryDir, query, envelope, miss, graph = null) {
702
+ if (!miss) return null;
703
+ let normFactTerm;
704
+ try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
705
+ const q = String(query).trim();
706
+ const rows = await factRows(memoryDir);
707
+ if (!rows.length) return null;
708
+ const isa = rows.filter((f) => ISA_PREDICATES.has(f.predicate));
709
+ const byTrust = (a, b) => b.trust - a.trust;
710
+ const renderMany = (hits) => {
711
+ const shown = hits.slice(0, FACT_ANSWER_CAP).map(renderFactLine);
712
+ const n = hits.length - FACT_ANSWER_CAP;
713
+ const extra = n > 0 ? `\n…and ${n} more remembered fact${n === 1 ? "" : "s"}.` : "";
714
+ return { text: shown.join("\n") + extra, replace: true };
715
+ };
716
+
717
+ // (d) WHOLE-STORE recall (CHATBENCH_006 lever 3) — "what did i tell you last time",
718
+ // "what facts do you know": no term to bind, so list every remembered fact,
719
+ // higher-trust first, each cited. Answers the cross-session assert-recall surfaces.
720
+ if (WHOLE_RECALL_RE.test(q)) {
721
+ const hits = (isa.length ? isa : rows).slice().sort(byTrust);
722
+ if (!hits.length) return null;
723
+ return renderMany(hits);
724
+ }
725
+
726
+ // (a) FORWARD membership — "is an X a Y". X's fact-subject candidates are the
727
+ // term itself (a class word) AND, when it resolves in the graph, its class-noun
728
+ // (an instance) — so "is app/lib/a.mjs a component" answers off "module …".
729
+ const isaAsk = q.match(ISA_ASK_RE);
730
+ if (isaAsk) {
731
+ const objVariants = factTermVariants(normFactTerm, isaAsk[2]);
732
+ const subjCandidates = new Set(factTermVariants(normFactTerm, isaAsk[1]));
733
+ const noun = await entityClassNoun(graph, isaAsk[1]);
734
+ if (noun) for (const v of factTermVariants(normFactTerm, noun)) subjCandidates.add(v);
735
+ const hit = isa
736
+ .filter((f) => subjCandidates.has(f.subject) && objVariants.has(f.object))
737
+ .sort(byTrust)[0];
738
+ if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
739
+ return null; // no remembered fact — the honest miss stands (never a guessed "no")
740
+ }
741
+
742
+ // (b) RECALL — "what did i tell you about X": every remembered fact mentioning X.
743
+ const told = q.match(TOLD_ABOUT_RE);
744
+ if (told) {
745
+ const variants = factTermVariants(normFactTerm, told[1]);
746
+ const hits = rows.filter((f) => variants.has(f.subject) || variants.has(f.object)).sort(byTrust);
747
+ if (!hits.length) return null;
748
+ const term = variants.has(hits[0].subject) ? hits[0].subject : hits[0].object;
749
+ const shown = hits.slice(0, FACT_ANSWER_CAP).map((f) => ` ${renderFactLine(f)}`);
750
+ const extra = hits.length > FACT_ANSWER_CAP ? `\n …and ${hits.length - FACT_ANSWER_CAP} more.` : "";
751
+ return { text: `${hits.length} remembered fact${hits.length === 1 ? "" : "s"} about ${term}:\n${shown.join("\n")}${extra}`, replace: true };
752
+ }
753
+
754
+ // (c) REVERSE / "what kind of thing" membership. The meta form ("what is a Y")
755
+ // comes from the parse when present, else recognized directly on a no-parse miss;
756
+ // "what kind of thing is an X" is recognized regardless (the grammar never parses
757
+ // it as meta). "what is a Y" reports Y's MEMBERS (object-side); "what kind of
758
+ // thing is an X" reports X's own TYPE (subject-side first), so both directions
759
+ // of a single remembered "X is a kind of Y" are queryable.
760
+ let term = envelope?.parsed?.shape === "meta" ? envelope.parsed.object : null;
761
+ let kindOf = false;
762
+ const mk = q.match(KIND_OF_RE);
763
+ if (mk) { term = mk[1]; kindOf = true; }
764
+ else if (!term && !envelope?.parsed) {
765
+ const m = q.match(/^what\s+(?:is|are)\s+an?\s+(.+?)[?.!\s]*$/i);
766
+ if (m) term = m[1];
767
+ }
768
+ if (!term) return null;
769
+ const variants = factTermVariants(normFactTerm, term);
770
+ const subjectHits = isa.filter((f) => variants.has(f.subject)).sort(byTrust);
771
+ const objectHits = isa.filter((f) => variants.has(f.object)).sort(byTrust);
772
+ const hits = kindOf
773
+ ? (subjectHits.length ? subjectHits : objectHits)
774
+ : (objectHits.length ? objectHits : subjectHits);
775
+ if (!hits.length) return null;
776
+ return renderMany(hits);
777
+ }
778
+
779
+ // ---- W5: corpus on-demand — LOCAL tier only, behind an explicit flag ----
780
+
781
+ /** The opt-in env flag: TMCT_CORPUS_LOOKUP=1 lets an unknown-term miss consult
782
+ * the LOCAL committed ConceptNet slice (tier 1 of the corpus tiering policy).
783
+ * Default OFF for this wave.
784
+ *
785
+ * TIER-3 SEAM (documented, NOT implemented): a network lookup (the ConceptNet
786
+ * API for terms the local slice misses, cached down into .tmct/corpus/ per the
787
+ * tier-2 policy) would attach exactly where corpusAside() returns null below —
788
+ * behind its own explicit opt-in flag, never in the default path, and any
789
+ * network failure must degrade to the honest miss ($0-offline is inviolable). */
790
+ export const CORPUS_LOOKUP_FLAG = "TMCT_CORPUS_LOOKUP";
791
+ /** How many corpus surface lines one aside quotes. */
792
+ const CORPUS_ASIDE_CAP = 2;
793
+
794
+ let corpusPromise = null; // the local slice as renderable rows, one load per process
795
+ /** Load the committed slice + relation map once, as { key, surface } rows —
796
+ * `surface` is the map's own canonical sentence ("a cache is used for storing
797
+ * data"), `key` the normFactTerm-normalized subject. Failure → []. */
798
+ function localCorpus() {
799
+ if (!corpusPromise) {
800
+ corpusPromise = (async () => {
801
+ const { loadSlice, loadMap, termText } = await import("./corpus/conceptnet.mjs");
802
+ const { normFactTerm } = await import("./memory/core.mjs");
803
+ const [assertions, map] = await Promise.all([loadSlice(), loadMap()]);
804
+ const rows = [];
805
+ for (const a of assertions) {
806
+ const row = map.get(a.rel);
807
+ if (!row || row.ace === "none" || !row.surface) continue; // non-emissions stay out here too
808
+ const subject = termText(a.start);
809
+ const object = termText(a.end);
810
+ if (!subject || !object) continue;
811
+ rows.push({
812
+ key: normFactTerm(subject),
813
+ surface: String(row.surface).replace("{start}", subject).replace("{end}", object),
814
+ });
815
+ }
816
+ return rows;
817
+ })().catch(() => []);
818
+ }
819
+ return corpusPromise;
820
+ }
821
+
822
+ /** W5 seam: the grounded aside for an unknown term, or null (which is also
823
+ * where the tier-3 network lookup would attach — see CORPUS_LOOKUP_FLAG). */
824
+ async function corpusAside(term) {
825
+ try {
826
+ const { normFactTerm } = await import("./memory/core.mjs");
827
+ const variants = factTermVariants(normFactTerm, term);
828
+ const rows = (await localCorpus()).filter((r) => variants.has(r.key));
829
+ if (!rows.length) return null;
830
+ const shown = rows.slice(0, CORPUS_ASIDE_CAP).map((r) => r.surface);
831
+ return `the corpus knows: ${shown.join("; ")} (ConceptNet, CC-BY-SA)`;
832
+ } catch {
833
+ return null;
834
+ }
835
+ }
836
+
837
+ /** The explicit recall question forms — "what did i ask before", "what did we
838
+ * talk about", "what have we discussed" — answered from memory, never the graph. */
839
+ const RECALL_ASK_RE = /^what (?:did|have) (?:i|we) (?:ask(?:ed)?(?: you)?|talk(?:ed)? about|discuss(?:ed)?)(?: before| earlier| previously| last time)?[?.!]*$/i;
840
+
841
+ /** Summarize the most recent folded session's questions (block ids are session
842
+ * uuidv7s, so a plain sort is chronological). Null when nothing is folded yet. */
843
+ async function recallSummary(memoryDir) {
844
+ try {
845
+ const { loadBlockIndex, BLOCKS_DIR_REL } = await import("./memory/blocks.mjs");
846
+ const index = await loadBlockIndex(memoryDir);
847
+ const id = Object.keys(index.blocks).sort().at(-1);
848
+ if (!id) return null;
849
+ const text = await readFile(join(memoryDir, BLOCKS_DIR_REL, index.blocks[id].file), "utf8");
850
+ const qs = text.split("\n").filter((l) => l.startsWith("Q: ")).map((l) => l.slice(3)).slice(0, 6);
851
+ if (!qs.length) return null;
852
+ const day = uuidv7Day(id);
853
+ return `last time (session ${String(id).slice(0, 8)}${day ? `, ${day}` : ""}) you asked: ${qs.map((q) => `"${q}"`).join("; ")}`;
854
+ } catch {
855
+ return null;
856
+ }
857
+ }
858
+
859
+ /** "[and/so/…] what about X" — a discourse continuation that re-asks the previous
860
+ * turn's question with X swapped in. */
861
+ const WHAT_ABOUT_RE = /^(?:(?:and|so|but|ok|okay|now|then)\s+)*what about\s+(.+?)[?.!\s]*$/i;
862
+ /** A code-ish name token in a prior query (a path/dotted name, or a CamelCase/
863
+ * Capitalized symbol) — the subject "what about X" replaces. */
864
+ const NAME_TOKEN_RE = /\b[\w-]+(?:[/.][\w-]+)+\b|\b[A-Z][A-Za-z0-9_]*\b/;
865
+
866
+ /** DISCOURSE CONTINUATION (CHATBENCH_006 lever 2): "what about X" carries the PRIOR
867
+ * turn's question shape across the turn boundary — re-asking it with X in place of
868
+ * the previous subject/object. Returns the reconstructed query (parsed like any
869
+ * subject question, so X resolves and becomes the new focus), or null when there's
870
+ * no prior query or no name token to swap (→ the ordinary honest miss stands). */
871
+ function discourseRewrite(query, last) {
872
+ const m = String(query).match(WHAT_ABOUT_RE);
873
+ if (!m || !last?.query) return null;
874
+ const prevQ = String(last.query);
875
+ if (!NAME_TOKEN_RE.test(prevQ)) return null;
876
+ const newSubj = m[1].trim();
877
+ return prevQ.replace(NAME_TOKEN_RE, () => newSubj);
878
+ }
879
+
371
880
  /** A bare question → tmct_ask. When a focus is set AND the graph is in hand we
372
881
  * call ask() directly to thread the focus as contextId (so a pronoun like "it"
373
882
  * resolves to the focus) — building the SAME delimited string dispatchTool emits;
374
883
  * otherwise the unchanged dispatchTool path (which also yields the no-graph error).
375
884
  * A hit updates the focus to the resolved object. Grammar miss / ToolError → a
376
885
  * normal answer, never a crash. */
377
- async function runAsk(query, { config, source, graph, focus }) {
886
+ async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, env }) {
378
887
  const ts = new Date().toISOString();
888
+ // DISCOURSE ANAPHORA (CHATBENCH_006 levers 1+2): a follow-up like "which of those
889
+ // are tested" / "how many of those" / "count them" filters or counts the PREVIOUS
890
+ // answer's entity set. That set is the ids the last dispatched turn cited — carried
891
+ // on `last.detail.matches`. Threading it as ask()'s `prev` is what lets the anaphora
892
+ // node resolve instead of the "needs a previous answer" honest miss.
893
+ const prev = (last?.detail?.matches || []).map((m) => m?.id).filter(Boolean);
894
+ // The query the ENGINE parses: a "what about X" continuation is rewritten to the
895
+ // prior shape with X swapped in; everything else parses verbatim. The record and
896
+ // transcript keep the user's ACTUAL words (`query`), only the parse target changes.
897
+ const askQuery = discourseRewrite(query, last) ?? query;
898
+ // W2: the explicit recall forms are answered from memory's folded blocks, never
899
+ // the graph. Gated on memoryDir — a bare runTurn (no session shell) stays pure.
900
+ if (memoryDir && RECALL_ASK_RE.test(String(query).trim())) {
901
+ const summary = await recallSummary(memoryDir);
902
+ return plainTurn(query, summary ?? "nothing to recall yet — no earlier session has been folded into memory.", {
903
+ via: "recall", miss: !summary, focus,
904
+ });
905
+ }
379
906
  let answer;
380
907
  let envelope = null;
381
908
  try {
382
909
  let text;
383
- if (graph && focus?.id) {
910
+ if (graph && (focus?.id || prev.length)) {
911
+ // Direct ask() when EITHER a focus is set (thread it as contextId so "it"
912
+ // binds) OR the previous turn produced a set to refer back to (thread it as
913
+ // `prev` for the anaphora node). Builds the SAME delimited envelope dispatchTool
914
+ // emits, so the parse below is identical either way.
384
915
  const { ask } = await import("./ask.mjs");
385
- const r = ask(graph, query, { contextId: focus.id });
916
+ const r = ask(graph, askQuery, { contextId: focus?.id ?? null, prev });
386
917
  text = `${r.content}${ASK_ENVELOPE_DELIM}${JSON.stringify(r.tmct_ask, null, 2)}`;
387
918
  } else {
388
- text = await dispatchTool("tmct_ask", { query }, { config, source });
919
+ text = await dispatchTool("tmct_ask", { query: askQuery }, { config, source });
389
920
  }
390
921
  const [content, envJson] = text.split(ASK_ENVELOPE_DELIM);
391
922
  answer = content;
@@ -404,11 +935,52 @@ async function runAsk(query, { config, source, graph, focus }) {
404
935
  }
405
936
  const answeredIds = (envelope?.matches || []).map((m) => m?.id).filter(Boolean);
406
937
  const miss = envelope ? !!envelope.miss : true;
938
+ // Answer provenance (W1): "composed" is the ask engine's productive band; the
939
+ // orientation swap below is template wording, so those turns carry via:"template".
940
+ let via = "composed";
941
+ let recordMiss = miss;
407
942
  // On a MISS: a conversational miss (a greeting, "what can you do", a very short
408
943
  // non-code line) gets the friendly orientation instead of the raw grammar hint. A
409
944
  // near-miss STRUCTURAL question keeps the precise hint the engine already produced.
410
- if (miss && isConversational(query)) answer = FRIENDLY;
411
- const record = { type: "turn", ts, query, resolvedIds, answeredIds, miss };
945
+ if (miss && isConversational(query)) {
946
+ answer = tRender(templates, T_ORIENTATION) ?? TEMPLATES_UNAVAILABLE;
947
+ via = "template";
948
+ } else if (memoryDir) {
949
+ // W4: vocabulary/definition questions consult the MEMORY graph's Facts
950
+ // alongside the schema-docs surface — a remembered fact answers a miss (or
951
+ // extends a schema hit), cited with its provenance verbatim. Checked BEFORE
952
+ // recall: a reified fact is stronger evidence than a transcript echo.
953
+ // Subject-side facts first (factAnswer), then the reverse-membership read-back
954
+ // (factReadBack) so an asserted "every X is a Y" answers "what is a Y" too.
955
+ const fact = (await factAnswer(memoryDir, query, envelope, miss))
956
+ ?? (await factReadBack(memoryDir, query, envelope, miss, graph));
957
+ if (fact) {
958
+ answer = fact.replace ? fact.text : `${answer}\n${fact.text}`;
959
+ via = "fact";
960
+ recordMiss = false;
961
+ } else if (miss) {
962
+ // W2: after the honest miss is composed, consult the folded-session memory. A
963
+ // relevant enough block ANSWERS — recalled Q/A framed + cited first, with the
964
+ // engine's own miss hint kept below; no hit leaves the miss byte-unchanged.
965
+ const recalled = await recallFromBlocks(memoryDir, query);
966
+ if (recalled) {
967
+ answer = `${recalled}\n\n${answer}`;
968
+ via = "recall";
969
+ recordMiss = false; // memory answered it, cited — no longer a blank
970
+ }
971
+ }
972
+ }
973
+ // W5 (flag-gated, default OFF): an unknown-term miss may consult the LOCAL
974
+ // committed corpus slice — a hit APPENDS a grounded, licence-cited aside under
975
+ // the honest miss (the miss itself stands; the aside is context, not an answer).
976
+ if (recordMiss && envelope?.parsed?.object && String(env?.[CORPUS_LOOKUP_FLAG] || "") === "1") {
977
+ const aside = await corpusAside(envelope.parsed.object);
978
+ if (aside) {
979
+ answer = `${answer}\n${aside}`;
980
+ via = "corpus";
981
+ }
982
+ }
983
+ const record = { type: "turn", ts, query, via, resolvedIds, answeredIds, miss: recordMiss };
412
984
  const logLines = [ts, `> ${query}`, answer, ""];
413
985
  // `detail` feeds why/say-more's verbose re-render: the traversal receipt + the
414
986
  // matched entities the terse render trims (see renderVerbose).
@@ -418,12 +990,12 @@ async function runAsk(query, { config, source, graph, focus }) {
418
990
 
419
991
  /** A non-ask, non-dispatch chat turn (count answer, /stats) — the same
420
992
  * { answer, logLines, record, focus } shape, recorded like any other turn. */
421
- function plainTurn(query, answer, { command, miss = false, focus = null } = {}) {
993
+ function plainTurn(query, answer, { command, via = "composed", miss = false, focus = null } = {}) {
422
994
  const ts = new Date().toISOString();
423
995
  return {
424
996
  answer,
425
997
  logLines: [ts, `> ${query}`, answer, ""],
426
- record: { type: "turn", ts, query, ...(command ? { command } : {}), resolvedIds: [], answeredIds: [], miss },
998
+ record: { type: "turn", ts, query, ...(command ? { command } : {}), via, resolvedIds: [], answeredIds: [], miss },
427
999
  focus,
428
1000
  };
429
1001
  }
@@ -432,7 +1004,7 @@ function plainTurn(query, answer, { command, miss = false, focus = null } = {})
432
1004
  * the same { answer, logLines, record, focus } shape as runAsk; the record carries
433
1005
  * the command name and the resolved entity id (for entity commands) so a
434
1006
  * slash-command turn becomes asksAbout graph data wherever it resolves an entity. */
435
- async function runCommand(line, { config, source, graph, focus }) {
1007
+ async function runCommand(line, { config, source, graph, focus, memoryDir }) {
436
1008
  const ts = new Date().toISOString();
437
1009
  const sp = line.indexOf(" ");
438
1010
  const name = (sp === -1 ? line.slice(1) : line.slice(1, sp)).toLowerCase();
@@ -440,13 +1012,25 @@ async function runCommand(line, { config, source, graph, focus }) {
440
1012
  const mk = (answer, { resolvedIds = [], miss = false, newFocus = focus } = {}) => ({
441
1013
  answer,
442
1014
  logLines: [ts, `> ${line}`, answer, ""],
443
- record: { type: "turn", ts, query: line, command: name, resolvedIds, answeredIds: [], miss },
1015
+ record: { type: "turn", ts, query: line, command: name, via: "command", resolvedIds, answeredIds: [], miss },
444
1016
  focus: newFocus,
445
1017
  });
446
1018
 
447
1019
  if (name === "help") return mk(await helpText());
448
1020
  if (name === "stats") return graph ? mk(renderStats(graph)) : mk("no graph loaded — /stats needs an index.", { miss: true });
449
1021
 
1022
+ // /memory [verbose] — what tmct remembers, as text (the ROADMAP "Memory
1023
+ // inspection" surface; the same renderer serves the `tmct memory` CLI).
1024
+ if (name === "memory") {
1025
+ if (!memoryDir) return mk("no memory store here — /memory works inside a repo session.", { miss: true });
1026
+ try {
1027
+ const { inspectMemory } = await import("./memory/inspect.mjs");
1028
+ return mk(await inspectMemory(memoryDir, { verbose: /^(?:-v|--verbose|verbose)$/i.test(argText) }));
1029
+ } catch (e) {
1030
+ return mk(String(e?.message || e), { miss: true }); // a broken store reads as its own clean error
1031
+ }
1032
+ }
1033
+
450
1034
  if (name === "focus") {
451
1035
  if (!argText) return mk(focus ? `focus is ${focus.label}` : "no focus set — /focus <symbol> to set one.");
452
1036
  const ent = await resolveEntity(graph, isPronoun(argText) ? focus?.label : argText);
@@ -484,16 +1068,21 @@ async function runCommand(line, { config, source, graph, focus }) {
484
1068
  * any grammar miss / residue / import failure so the query engine keeps first
485
1069
  * refusal on everything else. Lazy imports + catch-all: the grammar layer can
486
1070
  * never crash a turn (chat.mjs ethos). Writes ONLY under memoryDir/.tmct/memory. */
487
- async function assertTurn(line, { memoryDir, sessionId, focus }) {
1071
+ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null }) {
488
1072
  try {
489
1073
  const { parseAce } = await import("./grammar/ace.mjs");
490
- const { loadLexicon } = await import("./grammar/lexicon.mjs");
491
- const parse = parseAce(line, loadLexicon());
1074
+ // A session handle carries its own loaded lexicon (createSession loads it once);
1075
+ // a bare runTurn (no handle) lazy-loads the cached core lexicon. The lexicon is
1076
+ // immutable, so sharing one reference across concurrent handles is re-entrant.
1077
+ let lex = lexicon;
1078
+ if (!lex) { const { loadLexicon } = await import("./grammar/lexicon.mjs"); lex = loadLexicon(); }
1079
+ const parse = parseAce(line, lex);
492
1080
  if (!parse || !parse.triples?.length || parse.residue?.length) return null;
493
1081
  const { assertSentence } = await import("./grammar/assert.mjs");
494
1082
  const { normFactTerm } = await import("./memory/core.mjs");
495
1083
  const ts = new Date().toISOString();
496
1084
  const res = await assertSentence(memoryDir, line, {
1085
+ lexicon: lex,
497
1086
  provenance: { source: "chat", sessionId, ts },
498
1087
  });
499
1088
  if (!res || !res.ids?.length) return null;
@@ -502,7 +1091,7 @@ async function assertTurn(line, { memoryDir, sessionId, focus }) {
502
1091
  .join("; ");
503
1092
  const n = res.ids.length;
504
1093
  const answer = `noted — remembered ${n} fact${n === 1 ? "" : "s"}: ${shown}`;
505
- return plainTurn(line, answer, { command: "assert", focus });
1094
+ return plainTurn(line, answer, { command: "assert", via: "assert", focus });
506
1095
  } catch {
507
1096
  return null; // grammar unavailable / write failed — fall through to the engine
508
1097
  }
@@ -522,12 +1111,22 @@ async function assertTurn(line, { memoryDir, sessionId, focus }) {
522
1111
  * subject), `answeredIds` the entity ids an ask answer cited; a slash-command turn
523
1112
  * also carries its `command` name. Both drive the mgx:asksAbout graph append.
524
1113
  */
525
- export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "" } = {}) {
1114
+ export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null } = {}) {
526
1115
  const line = String(input ?? "").trim();
527
- const ctx = { config, source, graph, focus, last, memoryDir, sessionId };
1116
+ const templates = await chatTemplates(); // failure-tolerated: null degrades, never throws
1117
+ const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon };
528
1118
  // A DISPATCHED turn (count / slash-command / ask) becomes the new "last answer"
529
1119
  // that why/say-more re-renders; a conversational turn does not (it preserves it).
530
- const withLast = (result) => ({ ...result, last: { query: line, answer: result.answer, detail: result.detail ?? null } });
1120
+ // FINISH SEAM (PLAN_RESPONSE_FINISHING §"Where it lives"): every dispatched turn's
1121
+ // result passes through finish() here — the LAST transform in the turn — before its
1122
+ // finished answer becomes the `last` we expand. finish() owns the prose-span
1123
+ // grammar pass (src/finish.mjs); it rewrites result.answer/logLines and leaves the
1124
+ // protected spans (entities, paths, numbers, receipts, provenance) byte-invariant,
1125
+ // so `last` and the transcript stay consistent with what the shell prints.
1126
+ const withLast = (result) => {
1127
+ const finished = finish(result, { graph });
1128
+ return { ...finished, last: { query: line, answer: finished.answer, detail: finished.detail ?? null } };
1129
+ };
531
1130
 
532
1131
  // Conversational layer first (greetings, thanks, help, bye, why/say-more) — these
533
1132
  // resolve no entity and carry their own preserved `last`.
@@ -546,10 +1145,59 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
546
1145
  // Aggregate/count questions are answered mechanically off the loaded graph header,
547
1146
  // BEFORE falling through to the ask engine (focus unchanged — a count names no entity).
548
1147
  const count = answerCount(graph, line);
549
- if (count != null) return withLast(plainTurn(line, count, { focus }));
1148
+ if (count != null) {
1149
+ // An "I can't count <noun>" from a bare kind may still be answerable from an
1150
+ // ASSERTED vocabulary fact ("every class is a type" → "how many types" = the
1151
+ // class count). countFromFacts declines on a real graph kind, so ordinary
1152
+ // counts are unaffected; it only speaks for a remembered object noun.
1153
+ const viaFact = memoryDir ? await countFromFacts(graph, memoryDir, line) : null;
1154
+ if (viaFact != null) return withLast(plainTurn(line, viaFact, { via: "fact", focus }));
1155
+ return withLast(plainTurn(line, count, { via: "count", focus }));
1156
+ }
550
1157
  return withLast(await runAsk(line, ctx));
551
1158
  }
552
1159
 
1160
+ // ---- W3: seedMemory → bootstrap (first run in a graph-less repo) ----
1161
+
1162
+ /** How many corpus facts the first-run bootstrap seeds. Measured curve (dev
1163
+ * laptop, appendFact's read-modify-write per fact): 100→~0.16s, 250→~0.54s,
1164
+ * 500→~1.7s — the full 500 stays inside a session-start budget, so the seed
1165
+ * runs synchronously and complete (no partial-sync cap needed). */
1166
+ export const SEED_LIMIT = 500;
1167
+
1168
+ /** Which predicates the capped seed prefers (stable order — see seedMemory's
1169
+ * `prefer`): the definitional band first, so a bootstrap's 500 facts answer
1170
+ * "what is a cache?"-style vocabulary questions rather than location trivia. */
1171
+ export const SEED_PREFER = ["rdfs:subClassOf", "rdf:type", "mgx:usedFor", "mgx:partOf", "mgx:capableOf"];
1172
+
1173
+ /** The seed marker: its presence means this repo's memory already carries the
1174
+ * corpus seed, so re-runs skip without even reading the slice. */
1175
+ export const SEED_MARKER_REL = join(".tmct", "memory", "corpus-seed.json");
1176
+
1177
+ /** Seed the ConceptNet slice into <repo>/.tmct/memory once. Idempotent twice
1178
+ * over (the marker short-circuits; seedMemory itself content-hashes fact ids)
1179
+ * and failure-tolerated: a missing/broken corpus degrades to the unseeded
1180
+ * bootstrap — never an error before the prompt. Returns seedMemory's
1181
+ * { appended, skipped, total } on a fresh seed, null when skipped/failed. */
1182
+ async function seedBootstrapMemory(repo) {
1183
+ const marker = join(repo, SEED_MARKER_REL);
1184
+ try {
1185
+ await readFile(marker, "utf8");
1186
+ return null; // already seeded — the marker is authoritative
1187
+ } catch { /* no marker → first run */ }
1188
+ try {
1189
+ const { seedMemory } = await import("./corpus/conceptnet.mjs");
1190
+ const res = await seedMemory(repo, { limit: SEED_LIMIT, prefer: SEED_PREFER });
1191
+ await mkdir(dirname(marker), { recursive: true });
1192
+ await writeFile(marker, JSON.stringify({
1193
+ seededAt: new Date().toISOString(), limit: SEED_LIMIT, appended: res.appended, skipped: res.skipped,
1194
+ }) + "\n");
1195
+ return res;
1196
+ } catch {
1197
+ return null; // corpus unavailable — bootstrap proceeds unseeded
1198
+ }
1199
+ }
1200
+
553
1201
  /** Trim a focus label for the prompt so a long module path can't run the line off. */
554
1202
  const shortLabel = (l) => { const s = String(l); return s.length > 40 ? "…" + s.slice(-39) : s; };
555
1203
  const promptFor = (focus) => (focus ? `tmct(${shortLabel(focus.label)})> ` : PROMPT);
@@ -570,10 +1218,27 @@ const promptFor = (focus) => (focus ? `tmct(${shortLabel(focus.label)})> ` : PRO
570
1218
  * - opt-in telemetry and the end-of-session close (end lines, final upsert,
571
1219
  * stream flush).
572
1220
  *
573
- * Returns { repo, config, graph, moduleCount, version, sessionId, logFile,
574
- * sidecarFile, bannerLines, empty, focus, turns, promptFor(), turn(line), close() }.
575
- * `turn(line)` runs one dispatched turn through runTurn and the full sink
576
- * sequencing, returning { answer, end, prompt }; `close()` is idempotent.
1221
+ * THE CALLER-OWNED HANDLE (PLAN_REPOSITORY_INTERFACE §"The in-process lifecycle").
1222
+ * The returned object IS the session handle — created here, disposed by the caller
1223
+ * (`close()`), with NO process-global state. All of a session's between-turn state
1224
+ * lives on the handle: the mutable `focus` and `lastAnswer` (closure-private, read
1225
+ * through getters) and the read-only `memoryDir`, `graph`, `config` and `lexicon`.
1226
+ * - CREATE: `const s = await createSession({ repoPath })` — resolves repo/config,
1227
+ * loads the graph + lexicon once, opens the log/sidecar streams, seeds first-run
1228
+ * memory. Cheap to hold; a session is one repo's worth of chat.
1229
+ * - DISPOSE: `await s.close()` — idempotent; flushes both artifacts and the final
1230
+ * graph upsert (which triggers the memory fold). A dropped handle leaks only its
1231
+ * two write streams, so callers SHOULD close; a second close is a no-op.
1232
+ * - RE-ENTRANCY / CONCURRENCY: two handles never clobber each other. Each owns its
1233
+ * own `focus`/`lastAnswer`/streams/`sessionId`; the only cross-handle sharing is
1234
+ * the IMMUTABLE lexicon (a cached read-only singleton) and the read-through
1235
+ * provider graph — neither is mutated by a turn, so concurrent handles over the
1236
+ * same or different repos run isolated. Proven by test/chat-session.test.mjs.
1237
+ *
1238
+ * Returns { repo, config, graph, lexicon, memoryDir, moduleCount, version, sessionId,
1239
+ * logFile, sidecarFile, bannerLines, empty, focus, lastAnswer, turns, promptFor(),
1240
+ * turn(line), close() }. `turn(line)` runs one dispatched turn through runTurn and the
1241
+ * full sink sequencing, returning { answer, end, prompt }; `close()` is idempotent.
577
1242
  */
578
1243
  export async function createSession({
579
1244
  repoPath,
@@ -601,6 +1266,14 @@ export async function createSession({
601
1266
  const moduleCount = graph.individuals.filter((i) => (i.class || "") === "Module").length;
602
1267
  const { version } = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
603
1268
 
1269
+ // Load this handle's lexicon once (the immutable cached core vocabulary the ACE
1270
+ // assert path parses against). Threaded into every turn so the grammar layer never
1271
+ // re-imports per turn; failure-tolerated — a broken lexicon degrades to the lazy
1272
+ // per-turn load inside assertTurn, never an error before the prompt.
1273
+ let lexicon = null;
1274
+ try { const { loadLexicon } = await import("./grammar/lexicon.mjs"); lexicon = loadLexicon(); }
1275
+ catch { lexicon = null; }
1276
+
604
1277
  // Opt-in telemetry (default OFF → null → the sink's `tel?.record` is a no-op, and
605
1278
  // nothing is written). The conversational session log + sidecar above stay the
606
1279
  // authoritative chat record; this is the machine-readable query telemetry.
@@ -638,12 +1311,23 @@ export async function createSession({
638
1311
  };
639
1312
 
640
1313
  const empty = graph.individuals.length === 0;
1314
+ // W3: FIRST RUN in a graph-less repo seeds a capped ConceptNet slice into
1315
+ // .tmct/memory so vocabulary questions ("what is a cache?") have something
1316
+ // honest to stand on from turn one. Guarded three ways: only the empty
1317
+ // bootstrap (a fixture/provider graph never seeds), only once (the marker),
1318
+ // and never when TMCT_NO_SEED=1 opts out.
1319
+ let seeded = null;
1320
+ if (empty && String(env.TMCT_NO_SEED || "") !== "1") {
1321
+ seeded = await seedBootstrapMemory(repo);
1322
+ }
641
1323
  const bannerLines = [
642
1324
  empty
643
1325
  // Empty-graph bootstrap: honest-miss messaging, never an error before the prompt.
644
1326
  ? `tmct chat — ${repo} — no graph loaded — starting empty; ` +
645
1327
  `the conversation is remembered to ${DEFAULT_GRAPH_REL} — log ${logFile}`
646
1328
  : `tmct chat — ${repo} — ${moduleCount} module(s) — log ${logFile}`,
1329
+ // the honest seed line appears ONLY on the run that actually seeded
1330
+ ...(seeded ? [`seeded ${seeded.appended} starter facts from the ConceptNet slice — /memory to inspect`] : []),
647
1331
  "pass --repo <path> to target a different repo",
648
1332
  "ask a question, or /help for commands (/stats for an overview) — /exit to leave",
649
1333
  ];
@@ -654,9 +1338,12 @@ export async function createSession({
654
1338
  let closed = false;
655
1339
 
656
1340
  return {
657
- repo, config, graph, moduleCount, version, sessionId, logFile, sidecarFile,
658
- bannerLines, empty,
1341
+ repo, config, graph, lexicon, memoryDir: repo, moduleCount, version, sessionId,
1342
+ logFile, sidecarFile, bannerLines, empty,
1343
+ // Mutable between-turn state — read-only to the caller, so a shell can render the
1344
+ // prompt/expand-hint without reaching into runTurn's threading.
659
1345
  get focus() { return focus; },
1346
+ get lastAnswer() { return last; },
660
1347
  get turns() { return turns; },
661
1348
  promptFor: () => promptFor(focus),
662
1349
 
@@ -664,7 +1351,7 @@ export async function createSession({
664
1351
  * → telemetry → upsertGraph, in that exact order). Returns { answer, end, prompt }. */
665
1352
  async turn(line) {
666
1353
  const { answer, logLines, record, focus: nextFocus, last: nextLast, end } =
667
- await runTurn(line, { config, source, graph, focus, last, memoryDir: repo, sessionId });
1354
+ await runTurn(line, { config, source, graph, focus, last, memoryDir: repo, sessionId, env, lexicon });
668
1355
  focus = nextFocus;
669
1356
  last = nextLast;
670
1357
  await writeLog(logLines.join("\n") + "\n");