@polycode-projects/the-mechanical-code-talker 0.3.0 → 0.4.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.
@@ -211,15 +213,43 @@ export function isConversational(query) {
211
213
  return q.split(/\s+/).filter(Boolean).length <= 3 && !codeish;
212
214
  }
213
215
 
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");
216
+ // ---- the response-template library (W1: templates render path) ----
217
+ // The WORDING of the conversational/orientation surfaces lives in
218
+ // data/templates/responses.jsonl (corpus/templates.mjs) the template library is
219
+ // load-bearing for these turns. The recognizer sets below stay code: they decide
220
+ // WHICH template answers, never what it says. Loading is lazy + failure-tolerated
221
+ // (chat.mjs ethos: a turn never crashes) — a broken/missing data file degrades to
222
+ // one short honest line, never a throw before the prompt.
223
+
224
+ /** Template ids (data/templates/responses.jsonl) for the surfaces chat renders. */
225
+ const T_GREETING = "conversational-greeting";
226
+ const T_GREETING_BY_PHRASE = {
227
+ "hello there": "conversational-greeting-hello-there",
228
+ "good morning": "conversational-greeting-good-morning",
229
+ "good afternoon": "conversational-greeting-good-afternoon",
230
+ "good evening": "conversational-greeting-good-evening",
231
+ };
232
+ const T_THANKS = "conversational-thanks";
233
+ const T_FAREWELL = "conversational-farewell";
234
+ const T_ORIENTATION = "orientation-friendly";
235
+ const T_WHY_EMPTY = "miss-no-previous-answer";
236
+
237
+ /** The degraded line when the template library itself cannot load — a packaging
238
+ * failure said out loud, never a crashed turn or a silently different answer. */
239
+ const TEMPLATES_UNAVAILABLE = "response templates unavailable — ask a question, or /help for commands.";
240
+
241
+ let templatesPromise = null;
242
+ /** Load data/templates/responses.jsonl once per process; null on failure. */
243
+ function chatTemplates() {
244
+ if (!templatesPromise) templatesPromise = loadTemplates().catch(() => null);
245
+ return templatesPromise;
246
+ }
247
+ /** Strict render through the loaded map; null on any failure (no map / unknown
248
+ * id / missing slot) so every call site degrades explicitly, never half-fills. */
249
+ function tRender(templates, id, slots = {}) {
250
+ if (!templates) return null;
251
+ try { return renderTemplate(id, slots, templates); } catch { return null; }
252
+ }
223
253
 
224
254
  // ---- conversational (ELIZA/Zork-manners) templated layer ----
225
255
  // A small CLOSED set of human expressions handled with a TEMPLATED response BEFORE
@@ -248,16 +278,8 @@ const WHY = new Set([
248
278
  "elaborate", "tell me more", "more detail", "expand",
249
279
  ]);
250
280
 
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.";
281
+ // (Greeting/thanks/farewell wording moved to data/templates/responses.jsonlW1.
282
+ // The expression-specific greeting variants map through T_GREETING_BY_PHRASE above.)
261
283
 
262
284
  /** Re-render the last answer in verbose form: the previous query + its full answer
263
285
  * plus the ask envelope's traversal receipt and the matched entities (the detail a
@@ -289,22 +311,31 @@ export function renderVerbose(last) {
289
311
  function conversationalTurn(line, ctx) {
290
312
  const raw = String(line);
291
313
  const q = raw.toLowerCase().replace(/[.!?]+$/, "").replace(/\s+/g, " ").trim();
292
- const mk = (answer, { end = false, miss = false } = {}) => {
314
+ const t = (id) => tRender(ctx.templates, id) ?? TEMPLATES_UNAVAILABLE;
315
+ const mk = (answer, { end = false, miss = false, via = "template" } = {}) => {
293
316
  const ts = new Date().toISOString();
294
317
  return {
295
318
  answer,
296
319
  logLines: [ts, `> ${raw}`, answer, ""],
297
- record: { type: "turn", ts, query: raw, conversational: true, resolvedIds: [], answeredIds: [], miss },
320
+ record: { type: "turn", ts, query: raw, conversational: true, via, resolvedIds: [], answeredIds: [], miss },
298
321
  focus: ctx.focus,
299
322
  last: ctx.last, // a conversational turn never overwrites the last real answer
300
323
  ...(end ? { end: true } : {}),
301
324
  };
302
325
  };
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);
326
+ if (BYE.has(q)) return mk(t(T_FAREWELL), { end: true });
327
+ if (WHY.has(q)) {
328
+ const v = renderVerbose(ctx.last);
329
+ // The empty-state hint is template wording (via:"template", the data row wins;
330
+ // renderVerbose's own string is the degraded fallback for direct library callers).
331
+ // A real expansion re-renders the LAST ANSWER — its wording is the prior answer's,
332
+ // not a template's, so it carries via:"conversational".
333
+ if (v.empty) return mk(tRender(ctx.templates, T_WHY_EMPTY) ?? v.text, { miss: true });
334
+ return mk(v.text, { via: "conversational" });
335
+ }
336
+ if (GREET.has(q)) return mk(t(T_GREETING_BY_PHRASE[q] || T_GREETING));
337
+ if (THANKS.has(q)) return mk(t(T_THANKS));
338
+ if (q === "help" || q === "?" || HELP_PHRASES.some((re) => re.test(raw))) return mk(t(T_ORIENTATION));
308
339
  return null;
309
340
  }
310
341
 
@@ -348,6 +379,7 @@ export async function helpText() {
348
379
  ["<question>", "ask the graph in plain English (the default for any non-slash line)"],
349
380
  ...Object.entries(COMMANDS).map(([name, s]) => [`/${name}${s.arg ? (s.optional ? ` [${s.arg}]` : ` <${s.arg}>`) : ""}`, s.help]),
350
381
  ["/stats", "a one-screen overview: entity counts, relationship counts, packages"],
382
+ ["/memory [verbose]", "what tmct remembers: facts, utterances, sessions, folded blocks"],
351
383
  ["/focus <symbol>", "set the current focus (reused by 'it'/'this' and no-arg entity commands)"],
352
384
  ["/help", "this list"],
353
385
  ["/exit", "leave the session (also Ctrl+C / Ctrl+D)"],
@@ -368,14 +400,360 @@ export async function helpText() {
368
400
  ].join("\n");
369
401
  }
370
402
 
403
+ // ---- W2: memory recall on the miss path (retrieveBlocks → runAsk) ----
404
+
405
+ /** The conservative relevance floor a folded-session block must clear (the
406
+ * retrieveBlocks idf×(1+rank) score) before an honest ask-miss is answered from
407
+ * memory. Calibrated in the small-corpus regime (test/wiring-recall.test.mjs):
408
+ * a genuine re-ask scores ~4, a frame-word coincidence ~1. */
409
+ export const RECALL_MIN_SCORE = 2.0;
410
+
411
+ /** How many blocks the miss path consults (the W2 seam: retrieveBlocks(dir, q, 2)). */
412
+ const RECALL_TOP_K = 2;
413
+
414
+ /** Frame/stop words ignored when checking that a recalled Q genuinely shares
415
+ * vocabulary with the live query — at least one shared CONTENT word is required,
416
+ * so "which …" alone can never masquerade as a memory. */
417
+ const RECALL_STOPWORDS = new Set([
418
+ "the", "a", "an", "and", "or", "for", "with", "about", "into", "from",
419
+ "which", "what", "who", "how", "when", "where", "why",
420
+ "does", "do", "did", "is", "are", "was", "were", "there",
421
+ "me", "my", "we", "i", "you", "it", "this", "that", "in", "of", "to",
422
+ ]);
423
+ const recallWords = (s) => new Set(
424
+ String(s).toLowerCase().split(/[^a-z0-9.]+/).filter((w) => w.length >= 3 && !RECALL_STOPWORDS.has(w)),
425
+ );
426
+
427
+ /** Decode a uuidv7 id's leading 48-bit unix-ms timestamp → "YYYY-MM-DD", or null
428
+ * (block ids ARE session uuidv7s — fold.mjs sets block id = session id). */
429
+ function uuidv7Day(id) {
430
+ const hex = String(id || "").replace(/-/g, "").slice(0, 12);
431
+ if (!/^[0-9a-f]{12}$/i.test(hex)) return null;
432
+ const ms = parseInt(hex, 16);
433
+ return ms > 0 && Number.isFinite(ms) ? new Date(ms).toISOString().slice(0, 10) : null;
434
+ }
435
+
436
+ /** Pick the recalled block's Q/A pair most relevant to the query (content-word
437
+ * overlap; ties → first). Null when NO pair shares a content word — the block
438
+ * matched on packaging, not substance, so the honest miss must stand. */
439
+ function bestQaPair(blockText, query) {
440
+ const qWords = recallWords(query);
441
+ const pairs = [];
442
+ let open = null;
443
+ for (const line of String(blockText).split("\n")) {
444
+ if (line.startsWith("Q: ")) { open = { q: line.slice(3), a: "" }; pairs.push(open); }
445
+ else if (open && line.startsWith("A: ")) open.a = line.slice(3);
446
+ }
447
+ let best = null;
448
+ let bestScore = 0;
449
+ for (const p of pairs) {
450
+ let score = 0;
451
+ for (const w of recallWords(p.q)) if (qWords.has(w)) score += 1;
452
+ if (score > bestScore) { best = p; bestScore = score; }
453
+ }
454
+ return best;
455
+ }
456
+
457
+ /** W2 seam: consult the folded-session block index for an honest miss. A
458
+ * sufficiently-relevant hit returns the recalled Q/A, framed and cited to its
459
+ * session; anything less returns null and the miss stands unchanged. Lazy +
460
+ * failure-tolerated (chat.mjs ethos): a broken memory store degrades to null. */
461
+ async function recallFromBlocks(memoryDir, query) {
462
+ try {
463
+ const { retrieveBlocks } = await import("./memory/blocks.mjs");
464
+ const hits = await retrieveBlocks(memoryDir, query, RECALL_TOP_K);
465
+ const best = hits[0];
466
+ if (!best || best.score < RECALL_MIN_SCORE || !best.text) return null;
467
+ const pair = bestQaPair(best.text, query);
468
+ if (!pair) return null;
469
+ const day = uuidv7Day(best.id);
470
+ const cite = `session ${String(best.id).slice(0, 8)}${day ? `, ${day}` : ""}`;
471
+ const qa = pair.a ? `Q: ${pair.q}\n A: ${pair.a}` : `Q: ${pair.q}`;
472
+ return `you asked about this before (${cite}):\n ${qa}`;
473
+ } catch {
474
+ return null;
475
+ }
476
+ }
477
+
478
+ // ---- W4: asserted Facts → answers (the memory graph's reified triples) ----
479
+
480
+ /** How a stored fact predicate reads in English — one phrase per predicate the
481
+ * two writers (the ACE grammar, the ConceptNet map) actually emit. An unknown
482
+ * predicate renders verbatim rather than being guessed around. */
483
+ const FACT_PREDICATE_PHRASES = {
484
+ "rdfs:subClassOf": "is a kind of",
485
+ "rdf:type": "is a",
486
+ "owl:disjointWith": "is not a",
487
+ "mgx:partOf": "is part of",
488
+ "mgx:hasA": "has",
489
+ "mgx:usedFor": "is used for",
490
+ "mgx:capableOf": "can",
491
+ "mgx:atLocation": "is found in",
492
+ "mgx:causes": "causes",
493
+ "mgx:hasProperty": "is",
494
+ "mgx:madeOf": "is made of",
495
+ "mgx:receivesAction": "can be",
496
+ "mgx:createdBy": "is created by",
497
+ "mgx:mannerOf": "is a way to",
498
+ "mgx:desires": "wants",
499
+ "mgx:locatedNear": "is typically near",
500
+ "mgx:motivatedByGoal": "is motivated by",
501
+ "mgx:obstructedBy": "can be prevented by",
502
+ "mgx:causesDesire": "makes you want to",
503
+ "mgx:hasSubevent": "involves",
504
+ "mgx:hasFirstSubevent": "begins with",
505
+ "mgx:hasLastSubevent": "ends with",
506
+ "mgx:hasPrerequisite": "requires",
507
+ };
508
+ const factPhrase = (f) => `${f.subject} ${FACT_PREDICATE_PHRASES[f.predicate] || f.predicate} ${f.object}`;
509
+
510
+ /** One rendered fact line: "you told me" when the chat asserted it (an ace:chat
511
+ * provenance tag), "i learned" for corpus-only facts — provenance VERBATIM. */
512
+ function renderFactLine(f) {
513
+ const lead = f.provenance.includes("ace:chat") ? "you told me" : "i learned";
514
+ return `${lead}: ${factPhrase(f)}${f.provenance ? ` (source: ${f.provenance})` : ""}`;
515
+ }
516
+
517
+ /** Read every reified Fact out of the memory graph as plain {subject, predicate,
518
+ * object, provenance} rows. Lazy + failure-tolerated: no memory → []. */
519
+ async function memoryFacts(memoryDir) {
520
+ try {
521
+ const { loadMemory } = await import("./memory/core.mjs");
522
+ const m = await loadMemory(memoryDir);
523
+ const out = [];
524
+ for (const ind of m.individuals || []) {
525
+ if (ind?.class !== "Fact") continue;
526
+ const get = (k) => (ind.attributes || []).find((a) => a.key === k)?.value || "";
527
+ out.push({ subject: get("subject"), predicate: get("predicate"), object: get("object"), provenance: get("provenance") });
528
+ }
529
+ return out;
530
+ } catch {
531
+ return [];
532
+ }
533
+ }
534
+
535
+ /** Load memory once and resolve every reified Fact into a TRUST-BEARING row
536
+ * ({subject,predicate,object,provenance,trust,sourceTypes,…}) via core's
537
+ * readFactRows — the seam the answer layer ranks + cites without re-walking the
538
+ * graph shape (Wave-A memory/core.mjs). Lazy + failure-tolerated: no memory → []. */
539
+ async function factRows(memoryDir) {
540
+ try {
541
+ const { loadMemory, readFactRows } = await import("./memory/core.mjs");
542
+ return readFactRows(await loadMemory(memoryDir));
543
+ } catch {
544
+ return [];
545
+ }
546
+ }
547
+
548
+ /** Spelling variants a question term is matched under (normFactTerm + a naive
549
+ * singular): "caches"/"a cache"/"/c/en/cache" all reach the stored "cache". */
550
+ function factTermVariants(normFactTerm, term) {
551
+ const t = normFactTerm(term);
552
+ const v = new Set([t]);
553
+ if (t.endsWith("es")) v.add(t.slice(0, -2));
554
+ if (t.endsWith("s")) v.add(t.slice(0, -1));
555
+ return v;
556
+ }
557
+
558
+ /** "is a module a component" — the yes/no vocabulary form the graph grammar
559
+ * doesn't parse; checked against the isa-family fact predicates only. */
560
+ const ISA_ASK_RE = /^(?:is|are)\s+(?:an?\s+)?(.+?)\s+(?:a\s+kind\s+of|a\s+type\s+of|an?)\s+(.+?)[?.!\s]*$/i;
561
+ const ISA_PREDICATES = new Set(["rdfs:subClassOf", "rdf:type"]);
562
+ /** "what do you know about caches" — the open recall-everything form. */
563
+ const KNOW_ABOUT_RE = /^what\s+do\s+you\s+know\s+about\s+(.+?)[?.!\s]*$/i;
564
+ /** How many facts a single answer lists before "…and N more". */
565
+ const FACT_ANSWER_CAP = 5;
566
+
567
+ /** W4 seam: answer (or extend) a vocabulary/definition question from the MEMORY
568
+ * graph's Facts. Returns { text, replace } — `replace:false` means the engine's
569
+ * own (schema-docs) answer stands and the fact lines are appended under it —
570
+ * or null when memory holds nothing relevant (misses stay unchanged). */
571
+ async function factAnswer(memoryDir, query, envelope, miss) {
572
+ let normFactTerm;
573
+ try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
574
+ const q = String(query).trim();
575
+
576
+ // (a) meta-shaped questions ("what is a module", "what does cache mean") — the
577
+ // parsed object term, matched against fact SUBJECTS; consulted for hits (append
578
+ // alongside the schema-docs answer) and misses (facts answer alone) alike.
579
+ // When the engine produced NO parse at all (the empty-bootstrap graph
580
+ // short-circuits before parsing), the meta FORM is recognized directly on a
581
+ // miss — same required-article discipline as the grammar's own T5 template.
582
+ let metaTerm = envelope?.parsed?.shape === "meta" ? envelope.parsed.object : null;
583
+ if (!metaTerm && miss && !envelope?.parsed) {
584
+ const m = q.match(/^what\s+(?:is|are)\s+an?\s+(.+?)[?.!\s]*$/i)
585
+ || q.match(/^what\s+(?:does|do)\s+(.+?)\s+means?[?.!\s]*$/i);
586
+ if (m) metaTerm = m[1];
587
+ }
588
+ if (metaTerm) {
589
+ const variants = factTermVariants(normFactTerm, metaTerm);
590
+ const hits = (await memoryFacts(memoryDir)).filter((f) => variants.has(f.subject));
591
+ if (!hits.length) return null;
592
+ const shown = hits.slice(0, FACT_ANSWER_CAP).map(renderFactLine);
593
+ const extra = hits.length > FACT_ANSWER_CAP ? `\n…and ${hits.length - FACT_ANSWER_CAP} more remembered fact${hits.length - FACT_ANSWER_CAP === 1 ? "" : "s"}.` : "";
594
+ return { text: shown.join("\n") + extra, replace: miss };
595
+ }
596
+ if (!miss) return null;
597
+
598
+ // (b) "is a module a component" — yes iff a remembered isa-family fact says so.
599
+ const isa = q.match(ISA_ASK_RE);
600
+ if (isa) {
601
+ const subj = factTermVariants(normFactTerm, isa[1]);
602
+ const obj = factTermVariants(normFactTerm, isa[2]);
603
+ const hit = (await memoryFacts(memoryDir)).find(
604
+ (f) => ISA_PREDICATES.has(f.predicate) && subj.has(f.subject) && obj.has(f.object),
605
+ );
606
+ if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
607
+ return null; // no remembered fact — the honest miss stands (never a guessed "no")
608
+ }
609
+
610
+ // (c) "what do you know about caches" — everything remembered that MENTIONS the
611
+ // term (subject or object), capped.
612
+ const know = q.match(KNOW_ABOUT_RE);
613
+ if (know) {
614
+ const variants = factTermVariants(normFactTerm, know[1]);
615
+ const hits = (await memoryFacts(memoryDir)).filter((f) => variants.has(f.subject) || variants.has(f.object));
616
+ if (!hits.length) return null;
617
+ // echo the STORED spelling ("caches" asked → "cache" known), never a guess
618
+ const term = variants.has(hits[0].subject) ? hits[0].subject : hits[0].object;
619
+ const shown = hits.slice(0, FACT_ANSWER_CAP).map((f) => ` ${renderFactLine(f)}`);
620
+ const extra = hits.length > FACT_ANSWER_CAP ? `\n …and ${hits.length - FACT_ANSWER_CAP} more.` : "";
621
+ return { text: `${hits.length} remembered fact${hits.length === 1 ? "" : "s"} about ${term}:\n${shown.join("\n")}${extra}`, replace: true };
622
+ }
623
+ return null;
624
+ }
625
+
626
+ /** ASSERT-RECALL READ-BACK (PLAN_CYCLE_4 tail): after "every X is a Y" is
627
+ * asserted, the *superclass* side is otherwise unqueryable — factAnswer's meta
628
+ * path matches fact SUBJECTS only, so "what is a Y" (Y the asserted OBJECT) dies
629
+ * as an honest miss ("'component' isn't a term in this graph's own vocabulary")
630
+ * even though the fact "X is a kind of Y" is remembered. This is the REVERSE-
631
+ * membership reader: it consults readFactRows (trust-bearing) for isa-family
632
+ * facts whose OBJECT is the asked term and reports the members, citing each
633
+ * fact's provenance verbatim, higher-trust first. Miss-only and run AFTER
634
+ * factAnswer returns null, so it never shadows the subject-side answer or a
635
+ * schema hit. Returns { text, replace:true } or null (the miss stands). */
636
+ async function factReadBack(memoryDir, query, envelope, miss) {
637
+ if (!miss) return null;
638
+ let normFactTerm;
639
+ try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
640
+ const q = String(query).trim();
641
+ // The meta form the grammar's T5 template speaks ("what is a Y"), taken from the
642
+ // parse when present, else recognized directly on a no-parse miss (same required-
643
+ // article discipline as factAnswer's own meta fallback).
644
+ let term = envelope?.parsed?.shape === "meta" ? envelope.parsed.object : null;
645
+ if (!term && !envelope?.parsed) {
646
+ const m = q.match(/^what\s+(?:is|are)\s+an?\s+(.+?)[?.!\s]*$/i);
647
+ if (m) term = m[1];
648
+ }
649
+ if (!term) return null;
650
+ const variants = factTermVariants(normFactTerm, term);
651
+ const hits = (await factRows(memoryDir))
652
+ .filter((f) => ISA_PREDICATES.has(f.predicate) && variants.has(f.object))
653
+ .sort((a, b) => b.trust - a.trust);
654
+ if (!hits.length) return null;
655
+ const shown = hits.slice(0, FACT_ANSWER_CAP).map(renderFactLine);
656
+ const n = hits.length - FACT_ANSWER_CAP;
657
+ const extra = n > 0 ? `\n…and ${n} more remembered fact${n === 1 ? "" : "s"}.` : "";
658
+ return { text: shown.join("\n") + extra, replace: true };
659
+ }
660
+
661
+ // ---- W5: corpus on-demand — LOCAL tier only, behind an explicit flag ----
662
+
663
+ /** The opt-in env flag: TMCT_CORPUS_LOOKUP=1 lets an unknown-term miss consult
664
+ * the LOCAL committed ConceptNet slice (tier 1 of the corpus tiering policy).
665
+ * Default OFF for this wave.
666
+ *
667
+ * TIER-3 SEAM (documented, NOT implemented): a network lookup (the ConceptNet
668
+ * API for terms the local slice misses, cached down into .tmct/corpus/ per the
669
+ * tier-2 policy) would attach exactly where corpusAside() returns null below —
670
+ * behind its own explicit opt-in flag, never in the default path, and any
671
+ * network failure must degrade to the honest miss ($0-offline is inviolable). */
672
+ export const CORPUS_LOOKUP_FLAG = "TMCT_CORPUS_LOOKUP";
673
+ /** How many corpus surface lines one aside quotes. */
674
+ const CORPUS_ASIDE_CAP = 2;
675
+
676
+ let corpusPromise = null; // the local slice as renderable rows, one load per process
677
+ /** Load the committed slice + relation map once, as { key, surface } rows —
678
+ * `surface` is the map's own canonical sentence ("a cache is used for storing
679
+ * data"), `key` the normFactTerm-normalized subject. Failure → []. */
680
+ function localCorpus() {
681
+ if (!corpusPromise) {
682
+ corpusPromise = (async () => {
683
+ const { loadSlice, loadMap, termText } = await import("./corpus/conceptnet.mjs");
684
+ const { normFactTerm } = await import("./memory/core.mjs");
685
+ const [assertions, map] = await Promise.all([loadSlice(), loadMap()]);
686
+ const rows = [];
687
+ for (const a of assertions) {
688
+ const row = map.get(a.rel);
689
+ if (!row || row.ace === "none" || !row.surface) continue; // non-emissions stay out here too
690
+ const subject = termText(a.start);
691
+ const object = termText(a.end);
692
+ if (!subject || !object) continue;
693
+ rows.push({
694
+ key: normFactTerm(subject),
695
+ surface: String(row.surface).replace("{start}", subject).replace("{end}", object),
696
+ });
697
+ }
698
+ return rows;
699
+ })().catch(() => []);
700
+ }
701
+ return corpusPromise;
702
+ }
703
+
704
+ /** W5 seam: the grounded aside for an unknown term, or null (which is also
705
+ * where the tier-3 network lookup would attach — see CORPUS_LOOKUP_FLAG). */
706
+ async function corpusAside(term) {
707
+ try {
708
+ const { normFactTerm } = await import("./memory/core.mjs");
709
+ const variants = factTermVariants(normFactTerm, term);
710
+ const rows = (await localCorpus()).filter((r) => variants.has(r.key));
711
+ if (!rows.length) return null;
712
+ const shown = rows.slice(0, CORPUS_ASIDE_CAP).map((r) => r.surface);
713
+ return `the corpus knows: ${shown.join("; ")} (ConceptNet, CC-BY-SA)`;
714
+ } catch {
715
+ return null;
716
+ }
717
+ }
718
+
719
+ /** The explicit recall question forms — "what did i ask before", "what did we
720
+ * talk about", "what have we discussed" — answered from memory, never the graph. */
721
+ const RECALL_ASK_RE = /^what (?:did|have) (?:i|we) (?:ask(?:ed)?(?: you)?|talk(?:ed)? about|discuss(?:ed)?)(?: before| earlier| previously| last time)?[?.!]*$/i;
722
+
723
+ /** Summarize the most recent folded session's questions (block ids are session
724
+ * uuidv7s, so a plain sort is chronological). Null when nothing is folded yet. */
725
+ async function recallSummary(memoryDir) {
726
+ try {
727
+ const { loadBlockIndex, BLOCKS_DIR_REL } = await import("./memory/blocks.mjs");
728
+ const index = await loadBlockIndex(memoryDir);
729
+ const id = Object.keys(index.blocks).sort().at(-1);
730
+ if (!id) return null;
731
+ const text = await readFile(join(memoryDir, BLOCKS_DIR_REL, index.blocks[id].file), "utf8");
732
+ const qs = text.split("\n").filter((l) => l.startsWith("Q: ")).map((l) => l.slice(3)).slice(0, 6);
733
+ if (!qs.length) return null;
734
+ const day = uuidv7Day(id);
735
+ return `last time (session ${String(id).slice(0, 8)}${day ? `, ${day}` : ""}) you asked: ${qs.map((q) => `"${q}"`).join("; ")}`;
736
+ } catch {
737
+ return null;
738
+ }
739
+ }
740
+
371
741
  /** A bare question → tmct_ask. When a focus is set AND the graph is in hand we
372
742
  * call ask() directly to thread the focus as contextId (so a pronoun like "it"
373
743
  * resolves to the focus) — building the SAME delimited string dispatchTool emits;
374
744
  * otherwise the unchanged dispatchTool path (which also yields the no-graph error).
375
745
  * A hit updates the focus to the resolved object. Grammar miss / ToolError → a
376
746
  * normal answer, never a crash. */
377
- async function runAsk(query, { config, source, graph, focus }) {
747
+ async function runAsk(query, { config, source, graph, focus, templates, memoryDir, env }) {
378
748
  const ts = new Date().toISOString();
749
+ // W2: the explicit recall forms are answered from memory's folded blocks, never
750
+ // the graph. Gated on memoryDir — a bare runTurn (no session shell) stays pure.
751
+ if (memoryDir && RECALL_ASK_RE.test(String(query).trim())) {
752
+ const summary = await recallSummary(memoryDir);
753
+ return plainTurn(query, summary ?? "nothing to recall yet — no earlier session has been folded into memory.", {
754
+ via: "recall", miss: !summary, focus,
755
+ });
756
+ }
379
757
  let answer;
380
758
  let envelope = null;
381
759
  try {
@@ -404,11 +782,52 @@ async function runAsk(query, { config, source, graph, focus }) {
404
782
  }
405
783
  const answeredIds = (envelope?.matches || []).map((m) => m?.id).filter(Boolean);
406
784
  const miss = envelope ? !!envelope.miss : true;
785
+ // Answer provenance (W1): "composed" is the ask engine's productive band; the
786
+ // orientation swap below is template wording, so those turns carry via:"template".
787
+ let via = "composed";
788
+ let recordMiss = miss;
407
789
  // On a MISS: a conversational miss (a greeting, "what can you do", a very short
408
790
  // non-code line) gets the friendly orientation instead of the raw grammar hint. A
409
791
  // 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 };
792
+ if (miss && isConversational(query)) {
793
+ answer = tRender(templates, T_ORIENTATION) ?? TEMPLATES_UNAVAILABLE;
794
+ via = "template";
795
+ } else if (memoryDir) {
796
+ // W4: vocabulary/definition questions consult the MEMORY graph's Facts
797
+ // alongside the schema-docs surface — a remembered fact answers a miss (or
798
+ // extends a schema hit), cited with its provenance verbatim. Checked BEFORE
799
+ // recall: a reified fact is stronger evidence than a transcript echo.
800
+ // Subject-side facts first (factAnswer), then the reverse-membership read-back
801
+ // (factReadBack) so an asserted "every X is a Y" answers "what is a Y" too.
802
+ const fact = (await factAnswer(memoryDir, query, envelope, miss))
803
+ ?? (await factReadBack(memoryDir, query, envelope, miss));
804
+ if (fact) {
805
+ answer = fact.replace ? fact.text : `${answer}\n${fact.text}`;
806
+ via = "fact";
807
+ recordMiss = false;
808
+ } else if (miss) {
809
+ // W2: after the honest miss is composed, consult the folded-session memory. A
810
+ // relevant enough block ANSWERS — recalled Q/A framed + cited first, with the
811
+ // engine's own miss hint kept below; no hit leaves the miss byte-unchanged.
812
+ const recalled = await recallFromBlocks(memoryDir, query);
813
+ if (recalled) {
814
+ answer = `${recalled}\n\n${answer}`;
815
+ via = "recall";
816
+ recordMiss = false; // memory answered it, cited — no longer a blank
817
+ }
818
+ }
819
+ }
820
+ // W5 (flag-gated, default OFF): an unknown-term miss may consult the LOCAL
821
+ // committed corpus slice — a hit APPENDS a grounded, licence-cited aside under
822
+ // the honest miss (the miss itself stands; the aside is context, not an answer).
823
+ if (recordMiss && envelope?.parsed?.object && String(env?.[CORPUS_LOOKUP_FLAG] || "") === "1") {
824
+ const aside = await corpusAside(envelope.parsed.object);
825
+ if (aside) {
826
+ answer = `${answer}\n${aside}`;
827
+ via = "corpus";
828
+ }
829
+ }
830
+ const record = { type: "turn", ts, query, via, resolvedIds, answeredIds, miss: recordMiss };
412
831
  const logLines = [ts, `> ${query}`, answer, ""];
413
832
  // `detail` feeds why/say-more's verbose re-render: the traversal receipt + the
414
833
  // matched entities the terse render trims (see renderVerbose).
@@ -418,12 +837,12 @@ async function runAsk(query, { config, source, graph, focus }) {
418
837
 
419
838
  /** A non-ask, non-dispatch chat turn (count answer, /stats) — the same
420
839
  * { answer, logLines, record, focus } shape, recorded like any other turn. */
421
- function plainTurn(query, answer, { command, miss = false, focus = null } = {}) {
840
+ function plainTurn(query, answer, { command, via = "composed", miss = false, focus = null } = {}) {
422
841
  const ts = new Date().toISOString();
423
842
  return {
424
843
  answer,
425
844
  logLines: [ts, `> ${query}`, answer, ""],
426
- record: { type: "turn", ts, query, ...(command ? { command } : {}), resolvedIds: [], answeredIds: [], miss },
845
+ record: { type: "turn", ts, query, ...(command ? { command } : {}), via, resolvedIds: [], answeredIds: [], miss },
427
846
  focus,
428
847
  };
429
848
  }
@@ -432,7 +851,7 @@ function plainTurn(query, answer, { command, miss = false, focus = null } = {})
432
851
  * the same { answer, logLines, record, focus } shape as runAsk; the record carries
433
852
  * the command name and the resolved entity id (for entity commands) so a
434
853
  * slash-command turn becomes asksAbout graph data wherever it resolves an entity. */
435
- async function runCommand(line, { config, source, graph, focus }) {
854
+ async function runCommand(line, { config, source, graph, focus, memoryDir }) {
436
855
  const ts = new Date().toISOString();
437
856
  const sp = line.indexOf(" ");
438
857
  const name = (sp === -1 ? line.slice(1) : line.slice(1, sp)).toLowerCase();
@@ -440,13 +859,25 @@ async function runCommand(line, { config, source, graph, focus }) {
440
859
  const mk = (answer, { resolvedIds = [], miss = false, newFocus = focus } = {}) => ({
441
860
  answer,
442
861
  logLines: [ts, `> ${line}`, answer, ""],
443
- record: { type: "turn", ts, query: line, command: name, resolvedIds, answeredIds: [], miss },
862
+ record: { type: "turn", ts, query: line, command: name, via: "command", resolvedIds, answeredIds: [], miss },
444
863
  focus: newFocus,
445
864
  });
446
865
 
447
866
  if (name === "help") return mk(await helpText());
448
867
  if (name === "stats") return graph ? mk(renderStats(graph)) : mk("no graph loaded — /stats needs an index.", { miss: true });
449
868
 
869
+ // /memory [verbose] — what tmct remembers, as text (the ROADMAP "Memory
870
+ // inspection" surface; the same renderer serves the `tmct memory` CLI).
871
+ if (name === "memory") {
872
+ if (!memoryDir) return mk("no memory store here — /memory works inside a repo session.", { miss: true });
873
+ try {
874
+ const { inspectMemory } = await import("./memory/inspect.mjs");
875
+ return mk(await inspectMemory(memoryDir, { verbose: /^(?:-v|--verbose|verbose)$/i.test(argText) }));
876
+ } catch (e) {
877
+ return mk(String(e?.message || e), { miss: true }); // a broken store reads as its own clean error
878
+ }
879
+ }
880
+
450
881
  if (name === "focus") {
451
882
  if (!argText) return mk(focus ? `focus is ${focus.label}` : "no focus set — /focus <symbol> to set one.");
452
883
  const ent = await resolveEntity(graph, isPronoun(argText) ? focus?.label : argText);
@@ -484,16 +915,21 @@ async function runCommand(line, { config, source, graph, focus }) {
484
915
  * any grammar miss / residue / import failure so the query engine keeps first
485
916
  * refusal on everything else. Lazy imports + catch-all: the grammar layer can
486
917
  * never crash a turn (chat.mjs ethos). Writes ONLY under memoryDir/.tmct/memory. */
487
- async function assertTurn(line, { memoryDir, sessionId, focus }) {
918
+ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null }) {
488
919
  try {
489
920
  const { parseAce } = await import("./grammar/ace.mjs");
490
- const { loadLexicon } = await import("./grammar/lexicon.mjs");
491
- const parse = parseAce(line, loadLexicon());
921
+ // A session handle carries its own loaded lexicon (createSession loads it once);
922
+ // a bare runTurn (no handle) lazy-loads the cached core lexicon. The lexicon is
923
+ // immutable, so sharing one reference across concurrent handles is re-entrant.
924
+ let lex = lexicon;
925
+ if (!lex) { const { loadLexicon } = await import("./grammar/lexicon.mjs"); lex = loadLexicon(); }
926
+ const parse = parseAce(line, lex);
492
927
  if (!parse || !parse.triples?.length || parse.residue?.length) return null;
493
928
  const { assertSentence } = await import("./grammar/assert.mjs");
494
929
  const { normFactTerm } = await import("./memory/core.mjs");
495
930
  const ts = new Date().toISOString();
496
931
  const res = await assertSentence(memoryDir, line, {
932
+ lexicon: lex,
497
933
  provenance: { source: "chat", sessionId, ts },
498
934
  });
499
935
  if (!res || !res.ids?.length) return null;
@@ -502,7 +938,7 @@ async function assertTurn(line, { memoryDir, sessionId, focus }) {
502
938
  .join("; ");
503
939
  const n = res.ids.length;
504
940
  const answer = `noted — remembered ${n} fact${n === 1 ? "" : "s"}: ${shown}`;
505
- return plainTurn(line, answer, { command: "assert", focus });
941
+ return plainTurn(line, answer, { command: "assert", via: "assert", focus });
506
942
  } catch {
507
943
  return null; // grammar unavailable / write failed — fall through to the engine
508
944
  }
@@ -522,12 +958,22 @@ async function assertTurn(line, { memoryDir, sessionId, focus }) {
522
958
  * subject), `answeredIds` the entity ids an ask answer cited; a slash-command turn
523
959
  * also carries its `command` name. Both drive the mgx:asksAbout graph append.
524
960
  */
525
- export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "" } = {}) {
961
+ export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null } = {}) {
526
962
  const line = String(input ?? "").trim();
527
- const ctx = { config, source, graph, focus, last, memoryDir, sessionId };
963
+ const templates = await chatTemplates(); // failure-tolerated: null degrades, never throws
964
+ const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon };
528
965
  // A DISPATCHED turn (count / slash-command / ask) becomes the new "last answer"
529
966
  // 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 } });
967
+ // FINISH SEAM (PLAN_RESPONSE_FINISHING §"Where it lives"): every dispatched turn's
968
+ // result passes through finish() here — the LAST transform in the turn — before its
969
+ // finished answer becomes the `last` we expand. finish() owns the prose-span
970
+ // grammar pass (src/finish.mjs); it rewrites result.answer/logLines and leaves the
971
+ // protected spans (entities, paths, numbers, receipts, provenance) byte-invariant,
972
+ // so `last` and the transcript stay consistent with what the shell prints.
973
+ const withLast = (result) => {
974
+ const finished = finish(result, { graph });
975
+ return { ...finished, last: { query: line, answer: finished.answer, detail: finished.detail ?? null } };
976
+ };
531
977
 
532
978
  // Conversational layer first (greetings, thanks, help, bye, why/say-more) — these
533
979
  // resolve no entity and carry their own preserved `last`.
@@ -546,10 +992,51 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
546
992
  // Aggregate/count questions are answered mechanically off the loaded graph header,
547
993
  // BEFORE falling through to the ask engine (focus unchanged — a count names no entity).
548
994
  const count = answerCount(graph, line);
549
- if (count != null) return withLast(plainTurn(line, count, { focus }));
995
+ if (count != null) return withLast(plainTurn(line, count, { via: "count", focus }));
550
996
  return withLast(await runAsk(line, ctx));
551
997
  }
552
998
 
999
+ // ---- W3: seedMemory → bootstrap (first run in a graph-less repo) ----
1000
+
1001
+ /** How many corpus facts the first-run bootstrap seeds. Measured curve (dev
1002
+ * laptop, appendFact's read-modify-write per fact): 100→~0.16s, 250→~0.54s,
1003
+ * 500→~1.7s — the full 500 stays inside a session-start budget, so the seed
1004
+ * runs synchronously and complete (no partial-sync cap needed). */
1005
+ export const SEED_LIMIT = 500;
1006
+
1007
+ /** Which predicates the capped seed prefers (stable order — see seedMemory's
1008
+ * `prefer`): the definitional band first, so a bootstrap's 500 facts answer
1009
+ * "what is a cache?"-style vocabulary questions rather than location trivia. */
1010
+ export const SEED_PREFER = ["rdfs:subClassOf", "rdf:type", "mgx:usedFor", "mgx:partOf", "mgx:capableOf"];
1011
+
1012
+ /** The seed marker: its presence means this repo's memory already carries the
1013
+ * corpus seed, so re-runs skip without even reading the slice. */
1014
+ export const SEED_MARKER_REL = join(".tmct", "memory", "corpus-seed.json");
1015
+
1016
+ /** Seed the ConceptNet slice into <repo>/.tmct/memory once. Idempotent twice
1017
+ * over (the marker short-circuits; seedMemory itself content-hashes fact ids)
1018
+ * and failure-tolerated: a missing/broken corpus degrades to the unseeded
1019
+ * bootstrap — never an error before the prompt. Returns seedMemory's
1020
+ * { appended, skipped, total } on a fresh seed, null when skipped/failed. */
1021
+ async function seedBootstrapMemory(repo) {
1022
+ const marker = join(repo, SEED_MARKER_REL);
1023
+ try {
1024
+ await readFile(marker, "utf8");
1025
+ return null; // already seeded — the marker is authoritative
1026
+ } catch { /* no marker → first run */ }
1027
+ try {
1028
+ const { seedMemory } = await import("./corpus/conceptnet.mjs");
1029
+ const res = await seedMemory(repo, { limit: SEED_LIMIT, prefer: SEED_PREFER });
1030
+ await mkdir(dirname(marker), { recursive: true });
1031
+ await writeFile(marker, JSON.stringify({
1032
+ seededAt: new Date().toISOString(), limit: SEED_LIMIT, appended: res.appended, skipped: res.skipped,
1033
+ }) + "\n");
1034
+ return res;
1035
+ } catch {
1036
+ return null; // corpus unavailable — bootstrap proceeds unseeded
1037
+ }
1038
+ }
1039
+
553
1040
  /** Trim a focus label for the prompt so a long module path can't run the line off. */
554
1041
  const shortLabel = (l) => { const s = String(l); return s.length > 40 ? "…" + s.slice(-39) : s; };
555
1042
  const promptFor = (focus) => (focus ? `tmct(${shortLabel(focus.label)})> ` : PROMPT);
@@ -570,10 +1057,27 @@ const promptFor = (focus) => (focus ? `tmct(${shortLabel(focus.label)})> ` : PRO
570
1057
  * - opt-in telemetry and the end-of-session close (end lines, final upsert,
571
1058
  * stream flush).
572
1059
  *
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.
1060
+ * THE CALLER-OWNED HANDLE (PLAN_REPOSITORY_INTERFACE §"The in-process lifecycle").
1061
+ * The returned object IS the session handle — created here, disposed by the caller
1062
+ * (`close()`), with NO process-global state. All of a session's between-turn state
1063
+ * lives on the handle: the mutable `focus` and `lastAnswer` (closure-private, read
1064
+ * through getters) and the read-only `memoryDir`, `graph`, `config` and `lexicon`.
1065
+ * - CREATE: `const s = await createSession({ repoPath })` — resolves repo/config,
1066
+ * loads the graph + lexicon once, opens the log/sidecar streams, seeds first-run
1067
+ * memory. Cheap to hold; a session is one repo's worth of chat.
1068
+ * - DISPOSE: `await s.close()` — idempotent; flushes both artifacts and the final
1069
+ * graph upsert (which triggers the memory fold). A dropped handle leaks only its
1070
+ * two write streams, so callers SHOULD close; a second close is a no-op.
1071
+ * - RE-ENTRANCY / CONCURRENCY: two handles never clobber each other. Each owns its
1072
+ * own `focus`/`lastAnswer`/streams/`sessionId`; the only cross-handle sharing is
1073
+ * the IMMUTABLE lexicon (a cached read-only singleton) and the read-through
1074
+ * provider graph — neither is mutated by a turn, so concurrent handles over the
1075
+ * same or different repos run isolated. Proven by test/chat-session.test.mjs.
1076
+ *
1077
+ * Returns { repo, config, graph, lexicon, memoryDir, moduleCount, version, sessionId,
1078
+ * logFile, sidecarFile, bannerLines, empty, focus, lastAnswer, turns, promptFor(),
1079
+ * turn(line), close() }. `turn(line)` runs one dispatched turn through runTurn and the
1080
+ * full sink sequencing, returning { answer, end, prompt }; `close()` is idempotent.
577
1081
  */
578
1082
  export async function createSession({
579
1083
  repoPath,
@@ -601,6 +1105,14 @@ export async function createSession({
601
1105
  const moduleCount = graph.individuals.filter((i) => (i.class || "") === "Module").length;
602
1106
  const { version } = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
603
1107
 
1108
+ // Load this handle's lexicon once (the immutable cached core vocabulary the ACE
1109
+ // assert path parses against). Threaded into every turn so the grammar layer never
1110
+ // re-imports per turn; failure-tolerated — a broken lexicon degrades to the lazy
1111
+ // per-turn load inside assertTurn, never an error before the prompt.
1112
+ let lexicon = null;
1113
+ try { const { loadLexicon } = await import("./grammar/lexicon.mjs"); lexicon = loadLexicon(); }
1114
+ catch { lexicon = null; }
1115
+
604
1116
  // Opt-in telemetry (default OFF → null → the sink's `tel?.record` is a no-op, and
605
1117
  // nothing is written). The conversational session log + sidecar above stay the
606
1118
  // authoritative chat record; this is the machine-readable query telemetry.
@@ -638,12 +1150,23 @@ export async function createSession({
638
1150
  };
639
1151
 
640
1152
  const empty = graph.individuals.length === 0;
1153
+ // W3: FIRST RUN in a graph-less repo seeds a capped ConceptNet slice into
1154
+ // .tmct/memory so vocabulary questions ("what is a cache?") have something
1155
+ // honest to stand on from turn one. Guarded three ways: only the empty
1156
+ // bootstrap (a fixture/provider graph never seeds), only once (the marker),
1157
+ // and never when TMCT_NO_SEED=1 opts out.
1158
+ let seeded = null;
1159
+ if (empty && String(env.TMCT_NO_SEED || "") !== "1") {
1160
+ seeded = await seedBootstrapMemory(repo);
1161
+ }
641
1162
  const bannerLines = [
642
1163
  empty
643
1164
  // Empty-graph bootstrap: honest-miss messaging, never an error before the prompt.
644
1165
  ? `tmct chat — ${repo} — no graph loaded — starting empty; ` +
645
1166
  `the conversation is remembered to ${DEFAULT_GRAPH_REL} — log ${logFile}`
646
1167
  : `tmct chat — ${repo} — ${moduleCount} module(s) — log ${logFile}`,
1168
+ // the honest seed line appears ONLY on the run that actually seeded
1169
+ ...(seeded ? [`seeded ${seeded.appended} starter facts from the ConceptNet slice — /memory to inspect`] : []),
647
1170
  "pass --repo <path> to target a different repo",
648
1171
  "ask a question, or /help for commands (/stats for an overview) — /exit to leave",
649
1172
  ];
@@ -654,9 +1177,12 @@ export async function createSession({
654
1177
  let closed = false;
655
1178
 
656
1179
  return {
657
- repo, config, graph, moduleCount, version, sessionId, logFile, sidecarFile,
658
- bannerLines, empty,
1180
+ repo, config, graph, lexicon, memoryDir: repo, moduleCount, version, sessionId,
1181
+ logFile, sidecarFile, bannerLines, empty,
1182
+ // Mutable between-turn state — read-only to the caller, so a shell can render the
1183
+ // prompt/expand-hint without reaching into runTurn's threading.
659
1184
  get focus() { return focus; },
1185
+ get lastAnswer() { return last; },
660
1186
  get turns() { return turns; },
661
1187
  promptFor: () => promptFor(focus),
662
1188
 
@@ -664,7 +1190,7 @@ export async function createSession({
664
1190
  * → telemetry → upsertGraph, in that exact order). Returns { answer, end, prompt }. */
665
1191
  async turn(line) {
666
1192
  const { answer, logLines, record, focus: nextFocus, last: nextLast, end } =
667
- await runTurn(line, { config, source, graph, focus, last, memoryDir: repo, sessionId });
1193
+ await runTurn(line, { config, source, graph, focus, last, memoryDir: repo, sessionId, env, lexicon });
668
1194
  focus = nextFocus;
669
1195
  last = nextLast;
670
1196
  await writeLog(logLines.join("\n") + "\n");