@polycode-projects/the-mechanical-code-talker 0.2.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.
Files changed (53) hide show
  1. package/README.md +77 -3
  2. package/ROADMAP.md +416 -3
  3. package/bin/tmct.mjs +308 -12
  4. package/corpus/README.md +52 -0
  5. package/corpus/conceptnet/LICENSE-NOTICE +37 -0
  6. package/corpus/conceptnet/README.md +103 -0
  7. package/corpus/conceptnet/fetch-slice.mjs +136 -0
  8. package/corpus/conceptnet/filter-dump.mjs +89 -0
  9. package/corpus/conceptnet/slice.jsonl +14258 -0
  10. package/data/phrasebook/software-phrases.txt +231 -0
  11. package/data/templates/grammar-rules.toml +89 -0
  12. package/data/templates/responses.jsonl +68 -0
  13. package/package.json +40 -3
  14. package/src/ask-nlp.mjs +22 -10
  15. package/src/ask-vocab.mjs +35 -1
  16. package/src/ask.mjs +171 -494
  17. package/src/chat.mjs +709 -81
  18. package/src/corpus/conceptnet-map.toml +251 -0
  19. package/src/corpus/conceptnet.mjs +167 -0
  20. package/src/corpus/templates.mjs +188 -0
  21. package/src/finish.mjs +443 -0
  22. package/src/grammar/ace.mjs +341 -0
  23. package/src/grammar/assert.mjs +40 -0
  24. package/src/grammar/lexicon-core.json +287 -0
  25. package/src/grammar/lexicon.mjs +202 -0
  26. package/src/hash.mjs +32 -0
  27. package/src/index.mjs +21 -5
  28. package/src/init.mjs +264 -0
  29. package/src/interpret/fuzzy.mjs +89 -0
  30. package/src/interpret/merge.mjs +148 -0
  31. package/src/interpret/normalize.mjs +151 -0
  32. package/src/interpret/pipeline.mjs +112 -0
  33. package/src/interpret/strategies/grammar.mjs +137 -0
  34. package/src/interpret/strategies/keywords.mjs +241 -0
  35. package/src/interpret/strategies/noise-strip.mjs +114 -0
  36. package/src/memory/blocks.mjs +221 -0
  37. package/src/memory/core.mjs +533 -0
  38. package/src/memory/fold.mjs +0 -0
  39. package/src/memory/inspect.mjs +141 -0
  40. package/src/memory/trust.mjs +113 -0
  41. package/src/prose-nlp.mjs +14 -16
  42. package/src/providers/bootstrap.mjs +24 -0
  43. package/src/providers/fixture.mjs +118 -0
  44. package/src/providers/graph-service.mjs +312 -0
  45. package/src/repository-interface.mjs +318 -0
  46. package/src/server.mjs +44 -28
  47. package/src/sessions.mjs +137 -4
  48. package/src/source.mjs +44 -5
  49. package/src/syllogise.mjs +0 -0
  50. package/src/toml-config.mjs +14 -0
  51. package/src/tui/app.mjs +173 -0
  52. package/src/wink-model.mjs +74 -0
  53. package/bin/cli.mjs +0 -226
package/src/chat.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // chat.mjs — `tmct chat`: a full interactive client over the tmct code-graph.
2
2
  // Any BARE line is a plain-English question dispatched through the mechanical
3
- // tmct_ask engine (the EXACT path bin/cli.mjs's `cli tmct_ask` fallback uses),
3
+ // tmct_ask engine (the EXACT path bin/tmct.mjs's `cli tmct_ask` fallback uses),
4
4
  // so chat is the same zero-model engine with a readline shell around it, plus:
5
5
  //
6
6
  // - SLASH-COMMANDS to reach every richer tool dispatchTool (server.mjs) serves —
@@ -31,10 +31,15 @@
31
31
  // focus }) so tests exercise it directly; every ask.mjs import is LAZY and
32
32
  // failure-tolerated, so concurrent evolution of the engine can never crash a turn
33
33
  // (worst case a turn records fewer ids / an honest miss hint, never wrong data).
34
+ //
35
+ // createSession(…) is the SESSION SINK every shell shares: it owns the artifact
36
+ // files, the per-turn writeLog → writeSidecar → upsertGraph sequencing (order is
37
+ // load-bearing — see its docblock), telemetry, and the close. runChat is the
38
+ // readline shell over it; src/tui/app.mjs is the Ink shell over the same sink.
34
39
 
35
- import { join } from "node:path";
40
+ import { join, dirname } from "node:path";
36
41
  import { createWriteStream } from "node:fs";
37
- import { mkdir, readFile } from "node:fs/promises";
42
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
38
43
  import { createInterface } from "node:readline/promises";
39
44
  import { spawnSync } from "node:child_process";
40
45
  import { dispatchTool } from "./server.mjs";
@@ -44,6 +49,8 @@ import { SESSIONS_DIR_REL, appendSessionToGraph } from "./sessions.mjs";
44
49
  import { uuidv7 } from "./uuid.mjs";
45
50
  import { createTelemetry } from "./telemetry.mjs";
46
51
  import * as defaultSource from "./source.mjs";
52
+ import { loadTemplates, render as renderTemplate } from "./corpus/templates.mjs";
53
+ import { finish } from "./finish.mjs";
47
54
 
48
55
  // uuidv7 lives in ./uuid.mjs (shared with telemetry + the bench stamp); re-exported
49
56
  // here because callers/tests still import it from chat.mjs.
@@ -206,15 +213,43 @@ export function isConversational(query) {
206
213
  return q.split(/\s+/).filter(Boolean).length <= 3 && !codeish;
207
214
  }
208
215
 
209
- /** The short friendly orientation shown for conversational input. */
210
- const FRIENDLY = [
211
- "I answer questions about THIS codebase's structure — imports, calls, definitions,",
212
- "history and counts. For example:",
213
- " which modules import walk.mjs",
214
- " what calls buildContextBundle",
215
- " how many classes are there",
216
- "/help for commands, /stats for an overview of the graph.",
217
- ].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
+ }
218
253
 
219
254
  // ---- conversational (ELIZA/Zork-manners) templated layer ----
220
255
  // A small CLOSED set of human expressions handled with a TEMPLATED response BEFORE
@@ -243,16 +278,8 @@ const WHY = new Set([
243
278
  "elaborate", "tell me more", "more detail", "expand",
244
279
  ]);
245
280
 
246
- const GREETING = "Hi. Ask me about this codebase imports, calls, definitions, history — or /help.";
247
- /** A couple of expression-specific lines (a light Zork nod for "hello there"). */
248
- const GREETING_LINES = {
249
- "hello there": 'Hello there. (A hollow voice says, "fool.") Ask me about this codebase, or /help.',
250
- "good morning": "Good morning. Ask me about this codebase, or /help.",
251
- "good afternoon": "Good afternoon. Ask me about this codebase, or /help.",
252
- "good evening": "Good evening. Ask me about this codebase, or /help.",
253
- };
254
- const ACK = "Any time. Ask another, or /help for what I can do.";
255
- 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.)
256
283
 
257
284
  /** Re-render the last answer in verbose form: the previous query + its full answer
258
285
  * plus the ask envelope's traversal receipt and the matched entities (the detail a
@@ -284,22 +311,31 @@ export function renderVerbose(last) {
284
311
  function conversationalTurn(line, ctx) {
285
312
  const raw = String(line);
286
313
  const q = raw.toLowerCase().replace(/[.!?]+$/, "").replace(/\s+/g, " ").trim();
287
- 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" } = {}) => {
288
316
  const ts = new Date().toISOString();
289
317
  return {
290
318
  answer,
291
319
  logLines: [ts, `> ${raw}`, answer, ""],
292
- record: { type: "turn", ts, query: raw, conversational: true, resolvedIds: [], answeredIds: [], miss },
320
+ record: { type: "turn", ts, query: raw, conversational: true, via, resolvedIds: [], answeredIds: [], miss },
293
321
  focus: ctx.focus,
294
322
  last: ctx.last, // a conversational turn never overwrites the last real answer
295
323
  ...(end ? { end: true } : {}),
296
324
  };
297
325
  };
298
- if (BYE.has(q)) return mk(FAREWELL, { end: true });
299
- if (WHY.has(q)) { const v = renderVerbose(ctx.last); return mk(v.text, { miss: v.empty }); }
300
- if (GREET.has(q)) return mk(GREETING_LINES[q] || GREETING);
301
- if (THANKS.has(q)) return mk(ACK);
302
- 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));
303
339
  return null;
304
340
  }
305
341
 
@@ -315,7 +351,7 @@ export function gitToplevel(cwd = process.cwd()) {
315
351
  return null;
316
352
  }
317
353
 
318
- /** Mirror bin/cli.mjs's configFor: an explicit repo pins the artifact path; no
354
+ /** Mirror bin/tmct.mjs's configFor: an explicit repo pins the artifact path; no
319
355
  * repo falls back to the cwd/env-derived default. */
320
356
  function configFor(repoPath) {
321
357
  return repoPath ? { graphFile: join(repoPath, DEFAULT_GRAPH_REL) } : loadConfig();
@@ -343,6 +379,7 @@ export async function helpText() {
343
379
  ["<question>", "ask the graph in plain English (the default for any non-slash line)"],
344
380
  ...Object.entries(COMMANDS).map(([name, s]) => [`/${name}${s.arg ? (s.optional ? ` [${s.arg}]` : ` <${s.arg}>`) : ""}`, s.help]),
345
381
  ["/stats", "a one-screen overview: entity counts, relationship counts, packages"],
382
+ ["/memory [verbose]", "what tmct remembers: facts, utterances, sessions, folded blocks"],
346
383
  ["/focus <symbol>", "set the current focus (reused by 'it'/'this' and no-arg entity commands)"],
347
384
  ["/help", "this list"],
348
385
  ["/exit", "leave the session (also Ctrl+C / Ctrl+D)"],
@@ -363,14 +400,360 @@ export async function helpText() {
363
400
  ].join("\n");
364
401
  }
365
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
+
366
741
  /** A bare question → tmct_ask. When a focus is set AND the graph is in hand we
367
742
  * call ask() directly to thread the focus as contextId (so a pronoun like "it"
368
743
  * resolves to the focus) — building the SAME delimited string dispatchTool emits;
369
744
  * otherwise the unchanged dispatchTool path (which also yields the no-graph error).
370
745
  * A hit updates the focus to the resolved object. Grammar miss / ToolError → a
371
746
  * normal answer, never a crash. */
372
- async function runAsk(query, { config, source, graph, focus }) {
747
+ async function runAsk(query, { config, source, graph, focus, templates, memoryDir, env }) {
373
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
+ }
374
757
  let answer;
375
758
  let envelope = null;
376
759
  try {
@@ -399,11 +782,52 @@ async function runAsk(query, { config, source, graph, focus }) {
399
782
  }
400
783
  const answeredIds = (envelope?.matches || []).map((m) => m?.id).filter(Boolean);
401
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;
402
789
  // On a MISS: a conversational miss (a greeting, "what can you do", a very short
403
790
  // non-code line) gets the friendly orientation instead of the raw grammar hint. A
404
791
  // near-miss STRUCTURAL question keeps the precise hint the engine already produced.
405
- if (miss && isConversational(query)) answer = FRIENDLY;
406
- 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 };
407
831
  const logLines = [ts, `> ${query}`, answer, ""];
408
832
  // `detail` feeds why/say-more's verbose re-render: the traversal receipt + the
409
833
  // matched entities the terse render trims (see renderVerbose).
@@ -413,12 +837,12 @@ async function runAsk(query, { config, source, graph, focus }) {
413
837
 
414
838
  /** A non-ask, non-dispatch chat turn (count answer, /stats) — the same
415
839
  * { answer, logLines, record, focus } shape, recorded like any other turn. */
416
- function plainTurn(query, answer, { command, miss = false, focus = null } = {}) {
840
+ function plainTurn(query, answer, { command, via = "composed", miss = false, focus = null } = {}) {
417
841
  const ts = new Date().toISOString();
418
842
  return {
419
843
  answer,
420
844
  logLines: [ts, `> ${query}`, answer, ""],
421
- record: { type: "turn", ts, query, ...(command ? { command } : {}), resolvedIds: [], answeredIds: [], miss },
845
+ record: { type: "turn", ts, query, ...(command ? { command } : {}), via, resolvedIds: [], answeredIds: [], miss },
422
846
  focus,
423
847
  };
424
848
  }
@@ -427,7 +851,7 @@ function plainTurn(query, answer, { command, miss = false, focus = null } = {})
427
851
  * the same { answer, logLines, record, focus } shape as runAsk; the record carries
428
852
  * the command name and the resolved entity id (for entity commands) so a
429
853
  * slash-command turn becomes asksAbout graph data wherever it resolves an entity. */
430
- async function runCommand(line, { config, source, graph, focus }) {
854
+ async function runCommand(line, { config, source, graph, focus, memoryDir }) {
431
855
  const ts = new Date().toISOString();
432
856
  const sp = line.indexOf(" ");
433
857
  const name = (sp === -1 ? line.slice(1) : line.slice(1, sp)).toLowerCase();
@@ -435,13 +859,25 @@ async function runCommand(line, { config, source, graph, focus }) {
435
859
  const mk = (answer, { resolvedIds = [], miss = false, newFocus = focus } = {}) => ({
436
860
  answer,
437
861
  logLines: [ts, `> ${line}`, answer, ""],
438
- 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 },
439
863
  focus: newFocus,
440
864
  });
441
865
 
442
866
  if (name === "help") return mk(await helpText());
443
867
  if (name === "stats") return graph ? mk(renderStats(graph)) : mk("no graph loaded — /stats needs an index.", { miss: true });
444
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
+
445
881
  if (name === "focus") {
446
882
  if (!argText) return mk(focus ? `focus is ${focus.label}` : "no focus set — /focus <symbol> to set one.");
447
883
  const ent = await resolveEntity(graph, isPronoun(argText) ? focus?.label : argText);
@@ -475,6 +911,39 @@ async function runCommand(line, { config, source, graph, focus }) {
475
911
  return mk(answer);
476
912
  }
477
913
 
914
+ /** A declarative ACE-grammar sentence → assert into memory + confirm; null on
915
+ * any grammar miss / residue / import failure so the query engine keeps first
916
+ * refusal on everything else. Lazy imports + catch-all: the grammar layer can
917
+ * never crash a turn (chat.mjs ethos). Writes ONLY under memoryDir/.tmct/memory. */
918
+ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null }) {
919
+ try {
920
+ const { parseAce } = await import("./grammar/ace.mjs");
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);
927
+ if (!parse || !parse.triples?.length || parse.residue?.length) return null;
928
+ const { assertSentence } = await import("./grammar/assert.mjs");
929
+ const { normFactTerm } = await import("./memory/core.mjs");
930
+ const ts = new Date().toISOString();
931
+ const res = await assertSentence(memoryDir, line, {
932
+ lexicon: lex,
933
+ provenance: { source: "chat", sessionId, ts },
934
+ });
935
+ if (!res || !res.ids?.length) return null;
936
+ const shown = res.triples
937
+ .map((t) => `${normFactTerm(t.subject)} ${t.predicate} ${normFactTerm(t.object)}`)
938
+ .join("; ");
939
+ const n = res.ids.length;
940
+ const answer = `noted — remembered ${n} fact${n === 1 ? "" : "s"}: ${shown}`;
941
+ return plainTurn(line, answer, { command: "assert", via: "assert", focus });
942
+ } catch {
943
+ return null; // grammar unavailable / write failed — fall through to the engine
944
+ }
945
+ }
946
+
478
947
  /**
479
948
  * One chat turn: input → { answer, logLines, record, focus }. Pure of any
480
949
  * TTY/stream concerns so tests exercise it directly. A leading `/` routes to a
@@ -489,12 +958,22 @@ async function runCommand(line, { config, source, graph, focus }) {
489
958
  * subject), `answeredIds` the entity ids an ask answer cited; a slash-command turn
490
959
  * also carries its `command` name. Both drive the mgx:asksAbout graph append.
491
960
  */
492
- export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null } = {}) {
961
+ export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null } = {}) {
493
962
  const line = String(input ?? "").trim();
494
- const ctx = { config, source, graph, focus, last };
963
+ const templates = await chatTemplates(); // failure-tolerated: null degrades, never throws
964
+ const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon };
495
965
  // A DISPATCHED turn (count / slash-command / ask) becomes the new "last answer"
496
966
  // that why/say-more re-renders; a conversational turn does not (it preserves it).
497
- 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
+ };
498
977
 
499
978
  // Conversational layer first (greetings, thanks, help, bye, why/say-more) — these
500
979
  // resolve no entity and carry their own preserved `last`.
@@ -502,28 +981,106 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
502
981
  if (convo) return convo;
503
982
 
504
983
  if (line.startsWith("/")) return withLast(await runCommand(line, ctx));
984
+ // Declarative ACE sentences ("every module is a artifact") ASSERT into tmct's
985
+ // own memory and confirm — they are statements to remember, not graph queries.
986
+ // Gated on memoryDir: only a session shell provides a write target, so a bare
987
+ // runTurn (tests, library callers) stays pure and falls through to the engine.
988
+ if (memoryDir) {
989
+ const asserted = await assertTurn(line, ctx);
990
+ if (asserted) return withLast(asserted);
991
+ }
505
992
  // Aggregate/count questions are answered mechanically off the loaded graph header,
506
993
  // BEFORE falling through to the ask engine (focus unchanged — a count names no entity).
507
994
  const count = answerCount(graph, line);
508
- if (count != null) return withLast(plainTurn(line, count, { focus }));
995
+ if (count != null) return withLast(plainTurn(line, count, { via: "count", focus }));
509
996
  return withLast(await runAsk(line, ctx));
510
997
  }
511
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
+
512
1040
  /** Trim a focus label for the prompt so a long module path can't run the line off. */
513
1041
  const shortLabel = (l) => { const s = String(l); return s.length > 40 ? "…" + s.slice(-39) : s; };
514
1042
  const promptFor = (focus) => (focus ? `tmct(${shortLabel(focus.label)})> ` : PROMPT);
515
1043
 
516
1044
  /**
517
- * The interactive shell. Streams are injectable so tests run scripted sessions
518
- * without a TTY. A repo with NO graph artifact is not an error: the session
519
- * starts from the empty bootstrap graph (the banner says so honestly) and the
520
- * first turn's fold-in creates .tmct/graph.json from the conversation itself.
521
- * Returns { logFile, sidecarFile, turns } once the session ends.
1045
+ * The SESSION SINK everything a chat shell (readline below, the Ink TUI, any
1046
+ * future surface) must share so the on-disk session contract stays identical no
1047
+ * matter what draws the screen:
1048
+ *
1049
+ * - repo/config resolution (git root default, --repo override) + the one-time
1050
+ * graph load and banner strings;
1051
+ * - the transcript log + structured sidecar file creation and per-turn
1052
+ * writeLog → writeSidecar → upsertGraph sequencing. THE ORDER IS LOAD-BEARING:
1053
+ * the memory side-write (sessions.mjs) recovers each turn's ANSWER text by
1054
+ * re-reading the transcript keyed by turnKey(record.ts, query), so the log
1055
+ * line must be flushed before the graph upsert runs, and logLines[0] must be
1056
+ * the record's ts (runTurn guarantees that);
1057
+ * - opt-in telemetry and the end-of-session close (end lines, final upsert,
1058
+ * stream flush).
1059
+ *
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.
522
1081
  */
523
- export async function runChat({
1082
+ export async function createSession({
524
1083
  repoPath,
525
- input = process.stdin,
526
- output = process.stdout,
527
1084
  source = defaultSource,
528
1085
  env = process.env,
529
1086
  cwd = process.cwd(),
@@ -548,7 +1105,15 @@ export async function runChat({
548
1105
  const moduleCount = graph.individuals.filter((i) => (i.class || "") === "Module").length;
549
1106
  const { version } = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
550
1107
 
551
- // Opt-in telemetry (default OFF null → the loop's `tel?.record` is a no-op, and
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
+
1116
+ // Opt-in telemetry (default OFF → null → the sink's `tel?.record` is a no-op, and
552
1117
  // nothing is written). The conversational session log + sidecar above stay the
553
1118
  // authoritative chat record; this is the machine-readable query telemetry.
554
1119
  const tel = createTelemetry({ env, config, surface: "chat" });
@@ -562,7 +1127,7 @@ export async function runChat({
562
1127
  const sidecarFile = join(sessionsDir, `session-${sessionId}.jsonl`);
563
1128
  const stream = createWriteStream(logFile, { flags: "a" });
564
1129
  const sidecar = createWriteStream(sidecarFile, { flags: "a" });
565
- // Awaited writes: each chunk is handed to the OS before the loop continues, so a
1130
+ // Awaited writes: each chunk is handed to the OS before the turn completes, so a
566
1131
  // killed session keeps everything up to the last completed turn — in both files.
567
1132
  const flush = (s, text) =>
568
1133
  new Promise((resolve, reject) => s.write(text, (e) => (e ? reject(e) : resolve())));
@@ -584,35 +1149,50 @@ export async function runChat({
584
1149
  catch { /* best-effort — see above */ }
585
1150
  };
586
1151
 
587
- const dim = (s) => (env.NO_COLOR || !output.isTTY ? s : `\x1b[2m${s}\x1b[0m`);
588
- if (graph.individuals.length === 0) {
589
- // Empty-graph bootstrap: honest-miss messaging, never an error before the prompt.
590
- output.write(dim(`tmct chat ${repo} no graph loaded starting empty; ` +
591
- `the conversation is remembered to ${DEFAULT_GRAPH_REL} log ${logFile}`) + "\n");
592
- } else {
593
- output.write(dim(`tmct chat ${repo} — ${moduleCount} module(s) — log ${logFile}`) + "\n");
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);
594
1161
  }
595
- output.write(dim("pass --repo <path> to target a different repo") + "\n");
596
- output.write(dim("ask a question, or /help for commands (/stats for an overview) — /exit to leave") + "\n");
597
-
598
- const rl = createInterface({ input, output, prompt: PROMPT });
599
- rl.on("SIGINT", () => rl.close()); // Ctrl+C behaves like /exit (clean close, log flushed)
600
- let closed = false;
601
- rl.on("close", () => { closed = true; });
602
- const prompt = () => { if (!closed) rl.prompt(); }; // input may end while a turn is in flight
1162
+ const bannerLines = [
1163
+ empty
1164
+ // Empty-graph bootstrap: honest-miss messaging, never an error before the prompt.
1165
+ ? `tmct chat — ${repo} no graph loaded — starting empty; ` +
1166
+ `the conversation is remembered to ${DEFAULT_GRAPH_REL} log ${logFile}`
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`] : []),
1170
+ "pass --repo <path> to target a different repo",
1171
+ "ask a question, or /help for commands (/stats for an overview) — /exit to leave",
1172
+ ];
603
1173
 
604
1174
  let turns = 0;
605
1175
  let focus = null; // the current focus entity ({id,label}) — threaded turn to turn
606
1176
  let last = null; // the last dispatched answer ({query,answer,detail}) — why/say-more re-renders it
607
- prompt();
608
- for await (const raw of rl) { // Ctrl+D / closed stdin ends the iteration cleanly
609
- const line = raw.trim();
610
- if (line === "/exit") break;
611
- if (line) {
612
- const { answer, logLines, record, focus: nextFocus, last: nextLast, end } = await runTurn(line, { config, source, graph, focus, last });
1177
+ let closed = false;
1178
+
1179
+ return {
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.
1184
+ get focus() { return focus; },
1185
+ get lastAnswer() { return last; },
1186
+ get turns() { return turns; },
1187
+ promptFor: () => promptFor(focus),
1188
+
1189
+ /** One dispatched turn through the FULL sink sequencing (writeLog → writeSidecar
1190
+ * → telemetry → upsertGraph, in that exact order). Returns { answer, end, prompt }. */
1191
+ async turn(line) {
1192
+ const { answer, logLines, record, focus: nextFocus, last: nextLast, end } =
1193
+ await runTurn(line, { config, source, graph, focus, last, memoryDir: repo, sessionId, env, lexicon });
613
1194
  focus = nextFocus;
614
1195
  last = nextLast;
615
- output.write(answer + "\n");
616
1196
  await writeLog(logLines.join("\n") + "\n");
617
1197
  await writeSidecar(record);
618
1198
  turnRecords.push(record);
@@ -625,18 +1205,66 @@ export async function runChat({
625
1205
  });
626
1206
  await upsertGraph(record.ts);
627
1207
  turns += 1;
628
- rl.setPrompt(promptFor(focus));
1208
+ return { answer, end: Boolean(end), prompt: promptFor(focus) };
1209
+ },
1210
+
1211
+ /** End-of-session close: end lines in both artifacts, the final graph upsert
1212
+ * (which also triggers the memory fold), stream flush. Idempotent. */
1213
+ async close() {
1214
+ if (closed) return;
1215
+ closed = true;
1216
+ const endIso = new Date().toISOString();
1217
+ await writeLog(`${endIso}\n> /exit\nsession end ${endIso}\n`);
1218
+ await writeSidecar({ type: "end", ts: endIso });
1219
+ await upsertGraph(endIso);
1220
+ await new Promise((resolve) => stream.end(resolve));
1221
+ await new Promise((resolve) => sidecar.end(resolve));
1222
+ },
1223
+ };
1224
+ }
1225
+
1226
+ /**
1227
+ * The interactive readline shell over createSession — the `--plain` surface and
1228
+ * the scripted-test surface. Streams are injectable so tests run sessions
1229
+ * without a TTY. A repo with NO graph artifact is not an error: the session
1230
+ * starts from the empty bootstrap graph (the banner says so honestly) and the
1231
+ * first turn's fold-in creates .tmct/graph.json from the conversation itself.
1232
+ * Returns { logFile, sidecarFile, turns } once the session ends.
1233
+ */
1234
+ export async function runChat({
1235
+ repoPath,
1236
+ input = process.stdin,
1237
+ output = process.stdout,
1238
+ source = defaultSource,
1239
+ env = process.env,
1240
+ cwd = process.cwd(),
1241
+ gitRoot = gitToplevel,
1242
+ } = {}) {
1243
+ const session = await createSession({ repoPath, source, env, cwd, gitRoot });
1244
+
1245
+ const dim = (s) => (env.NO_COLOR || !output.isTTY ? s : `\x1b[2m${s}\x1b[0m`);
1246
+ for (const line of session.bannerLines) output.write(dim(line) + "\n");
1247
+
1248
+ const rl = createInterface({ input, output, prompt: PROMPT });
1249
+ rl.on("SIGINT", () => rl.close()); // Ctrl+C behaves like /exit (clean close, log flushed)
1250
+ let closed = false;
1251
+ rl.on("close", () => { closed = true; });
1252
+ const prompt = () => { if (!closed) rl.prompt(); }; // input may end while a turn is in flight
1253
+
1254
+ prompt();
1255
+ for await (const raw of rl) { // Ctrl+D / closed stdin ends the iteration cleanly
1256
+ const line = raw.trim();
1257
+ if (line === "/exit") break;
1258
+ if (line) {
1259
+ const { answer, end, prompt: nextPrompt } = await session.turn(line);
1260
+ output.write(answer + "\n");
1261
+ rl.setPrompt(nextPrompt);
629
1262
  if (end) break; // a conversational "bye"/"goodbye" — clean end, same as /exit
630
1263
  }
631
1264
  prompt();
632
1265
  }
633
1266
  rl.close();
634
1267
 
635
- const endIso = new Date().toISOString();
636
- await writeLog(`${endIso}\n> /exit\nsession end ${endIso}\n`);
637
- await writeSidecar({ type: "end", ts: endIso });
638
- await upsertGraph(endIso);
639
- await new Promise((resolve) => stream.end(resolve));
640
- await new Promise((resolve) => sidecar.end(resolve));
641
- return { logFile, sidecarFile, turns };
1268
+ await session.close();
1269
+ return { logFile: session.logFile, sidecarFile: session.sidecarFile, turns: session.turns };
642
1270
  }