@polycode-projects/the-mechanical-code-talker 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ {"relation":"imports","definition":"To import is to bring another module's definitions into the current one.","sense":"software"}
2
+ {"relation":"calls","definition":"A call is one function invoking another.","sense":"software"}
3
+ {"relation":"contains","definition":"Containment is a class or module holding a member (a method, attribute, or nested definition).","sense":"software"}
4
+ {"relation":"inherits","definition":"Inheritance is one class deriving its structure and behaviour from another.","sense":"software"}
5
+ {"relation":"tests","definition":"A test is code that exercises another unit and checks its behaviour.","sense":"software"}
6
+ {"relation":"defines","definition":"A definition is where a name (a class, function, or variable) is introduced.","sense":"software"}
7
+ {"relation":"touches","definition":"A touch is a commit changing a file or a symbol in the codebase.","sense":"software"}
8
+ {"relation":"cochange","definition":"Change-coupling is two files that tend to be changed together in the same commits.","sense":"software"}
@@ -62,9 +62,10 @@
62
62
  {"id":"conversational-farewell","class":"conversational","register":"friendly","template":"Bye — flushing the session log. Come back with a question any time."}
63
63
  {"id":"orientation-friendly","class":"orientation","register":"friendly","template":"I answer questions about THIS codebase's structure — imports, calls, definitions,\nhistory and counts. For example:\n which modules import walk.mjs\n what calls buildContextBundle\n how many classes are there\n/help for commands, /stats for an overview of the graph."}
64
64
  {"id":"miss-no-previous-answer","class":"miss","register":"friendly","template":"No previous answer to expand yet — ask me a question first, then say \"why\" or \"say more\"."}
65
- {"id":"conversational-greeting-empty","class":"conversational","register":"friendly","template":"Hi. There's no code graph loaded here yet — point me at your code with `--repo <path>` or run `tmct init`. Meanwhile I know some general vocabulary — try \"what is a cache\". /help for commands."}
66
- {"id":"orientation-empty","class":"orientation","register":"friendly","template":"There's no code graph loaded here, so I can't answer structure questions (imports, calls, definitions) yet.\nPoint me at your code with `--repo <path>` or run `tmct init` to index this repo.\nI do know some general vocabulary — try \"what is a cache\". /help for commands, /memory for what I remember."}
65
+ {"id":"conversational-greeting-empty","class":"conversational","register":"friendly","template":"Hi. There's no code graph loaded here — for code structure (imports, calls, definitions) I need a `.tmct/graph.json`: point me at one with `--repo <path>`, or try the shipped example `npm run example:mini`. (tmct reads graphs; it doesn't index code itself.) For general vocabulary, `tmct init` seeds concepts — try \"what is a cache\". /help for commands."}
66
+ {"id":"orientation-empty","class":"orientation","register":"friendly","template":"There's no code graph loaded here, so I can't answer structure questions (imports, calls, definitions) yet.\nFor those I need a `.tmct/graph.json` produced by a graph producer — point me at one with `--repo <path>`, or try the shipped example `npm run example:mini`. tmct reads graphs; it doesn't index code itself.\nFor general vocabulary, `tmct init` seeds concepts — try \"what is a cache\". /help for commands, /memory for what I remember."}
67
67
  {"id":"technical-density","class":"count","register":"technical","template":"{subject} carries {count} {noun} across {scope} — a concentration well above what a codebase of this size typically sustains ({provenance})."}
68
68
  {"id":"technical-comparison","class":"count","register":"technical","template":"At {count} {noun}, {subject} sits {comparison} the comparable-project baseline, a divergence that reflects deliberate structure rather than measurement noise ({provenance})."}
69
69
  {"id":"technical-superlative","class":"count","register":"technical","template":"No {noun} in {scope} is more {metric} than {subject}; it leads the next candidate by a clear margin of {count} ({provenance})."}
70
70
  {"id":"technical-ratio","class":"count","register":"technical","template":"{subject} sustains a ratio of {count} {noun} per {unit}, placing it in the upper band for projects of comparable {scope} ({provenance})."}
71
+ {"id":"concept-force","class":"concept","register":"friendly","template":"{definition}\n{examples}{followups}"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
@@ -78,8 +78,11 @@
78
78
  "test": "node --test \"test/**/*.test.mjs\"",
79
79
  "chat": "node bin/tmct.mjs",
80
80
  "chat:repo": "node bin/tmct.mjs chat --repo",
81
- "example:mini": "node bin/tmct.mjs chat --repo examples/mini-webapp",
82
- "example:polyglot": "node bin/tmct.mjs chat --repo examples/polyglot",
81
+ "init": "node bin/tmct.mjs init",
82
+ "memory": "node bin/tmct.mjs memory",
83
+ "syllogise": "node bin/tmct.mjs syllogise",
84
+ "example:mini": "node bin/tmct.mjs chat --repo examples/mini-webapp --ephemeral",
85
+ "example:polyglot": "node bin/tmct.mjs chat --repo examples/polyglot --ephemeral",
83
86
  "chatbench:run": "node chatbench/run.mjs",
84
87
  "chatbench:judge": "node chatbench/judge.mjs",
85
88
  "audit": "npm audit --audit-level=high",
package/src/ask.mjs CHANGED
@@ -51,7 +51,7 @@ import {
51
51
  // grammar, split out of this file: normalization pre-pass, the two parsing
52
52
  // strategies, and the bounded-fuzzy service. Re-exported below where existing
53
53
  // callers/tests import them from here.
54
- import { normalizeQuery, applyNegationFrames, matchNegationSet, STOPWORDS, splitWords, wordsOf } from "./interpret/normalize.mjs";
54
+ import { normalizeQuery, applyNegationFrames, applyPhrasingFrames, matchNegationSet, STOPWORDS, splitWords, wordsOf } from "./interpret/normalize.mjs";
55
55
  import { editDistance, fuzzyBound } from "./interpret/fuzzy.mjs";
56
56
  import { parseAnchored } from "./interpret/strategies/grammar.mjs";
57
57
  import { parseKeywordSpot, findPhrase } from "./interpret/strategies/keywords.mjs";
@@ -168,7 +168,7 @@ export function parseQuery(query, { nlp = undefined } = {}) {
168
168
  const adapter = nlp === undefined ? defaultNlp() : nlp;
169
169
  const raw = String(query || "").trim().replace(/\s+/g, " ");
170
170
  if (!raw) return null;
171
- const text = applyNegationFrames(normalizeQuery(raw));
171
+ const text = applyPhrasingFrames(applyNegationFrames(normalizeQuery(raw)));
172
172
  if (!text) return null;
173
173
  // COMPOSITIONAL PARSE PATH (PLAN §5.16 P3) — the new PRIMARY layer: a recursive
174
174
  // descent over CLAUSES for the compositional shapes (nested/relative, boolean,
package/src/chat.mjs CHANGED
@@ -39,7 +39,8 @@
39
39
 
40
40
  import { join, dirname } from "node:path";
41
41
  import { createWriteStream } from "node:fs";
42
- import { mkdir, readFile, writeFile } from "node:fs/promises";
42
+ import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
43
+ import { tmpdir } from "node:os";
43
44
  import { createInterface } from "node:readline/promises";
44
45
  import { spawnSync } from "node:child_process";
45
46
  import { dispatchTool } from "./server.mjs";
@@ -304,6 +305,12 @@ const STRUCT_WORDS = new Set([
304
305
  "subclass", "subclasses", "inherit", "inherits", "test", "tests", "touch", "touches",
305
306
  "commit", "commits", "export", "exports", "caller", "callers", "callee", "callees",
306
307
  "history", "where", "mentioned", "signature", "impact",
308
+ // relation-concept vocabulary (gerunds + relation nouns) so a SHORT relation touch
309
+ // ("what is calling", "what about inheritance") is a structural question, not
310
+ // small-talk — otherwise a ≤3-word relation touch is grabbed by the conversational
311
+ // orientation before the relation concept force can serve it.
312
+ "importing", "calling", "invoking", "inheriting", "containing", "contains", "containment",
313
+ "testing", "defining", "touching", "extending", "inheritance", "coverage", "member", "members",
307
314
  ]);
308
315
 
309
316
  /** Does this look like small-talk / an orientation request rather than a
@@ -345,6 +352,9 @@ const T_WHY_EMPTY = "miss-no-previous-answer";
345
352
  * over-promising "ask me about this codebase". */
346
353
  const T_GREETING_EMPTY = "conversational-greeting-empty";
347
354
  const T_ORIENTATION_EMPTY = "orientation-empty";
355
+ /** THE CONCEPT FORCE (concept.mjs): the three-band answer to a vague "what is a X"
356
+ * that names a known concept WITH instances — {definition}/{examples}/{followups}. */
357
+ const T_CONCEPT = "concept-force";
348
358
 
349
359
  /** The degraded line when the template library itself cannot load — a packaging
350
360
  * failure said out loud, never a crashed turn or a silently different answer. */
@@ -486,9 +496,10 @@ function orientationAnswer(templates, graph) {
486
496
  * when a code graph is loaded, else the honest empty-graph orientation. */
487
497
  function orientationText(graph) {
488
498
  if (noCodeGraph(graph)) {
489
- return "There's no code graph loaded here, so I can't answer structure questions yet. "
490
- + "Point me at your code with `--repo <path>` or run `tmct init` to index this repo. "
491
- + 'I do know some general vocabulary try "what is a cache". /help for commands.';
499
+ return "There's no code graph loaded here, so I can't answer structure questions (imports, calls, definitions) yet. "
500
+ + "For those I need a `.tmct/graph.json` produced by a graph producer point me at one with `--repo <path>`, "
501
+ + "or try the shipped example `npm run example:mini`. tmct reads graphs; it doesn't index code itself. "
502
+ + 'For general vocabulary, `tmct init` seeds concepts — try "what is a cache". /help for commands.';
492
503
  }
493
504
  const by = (cls) => (graph.individuals || []).filter((i) => (i.class || "") === cls).length;
494
505
  const parts = [];
@@ -798,11 +809,18 @@ const FACT_PREDICATE_PHRASES = {
798
809
  };
799
810
  const factPhrase = (f) => `${f.subject} ${FACT_PREDICATE_PHRASES[f.predicate] || f.predicate} ${f.object}`;
800
811
 
801
- /** One rendered fact line: "you told me" when the chat asserted it (an ace:chat
802
- * provenance tag), "i learned" for corpus-only facts provenance VERBATIM. */
812
+ /** One rendered fact line. An OPERATOR-asserted fact keeps the true first-person
813
+ * provenance ("you told me: …"). A CORPUS fact is presented as clean DATA with its
814
+ * source cited — NEVER "i learned: …", which over-claims and anthropomorphises
815
+ * (especially when the corpus row is noise); the relation and its provenance speak
816
+ * for themselves. Provenance stays VERBATIM either way. */
803
817
  function renderFactLine(f) {
804
- const lead = f.provenance.includes("ace:chat") ? "you told me" : "i learned";
805
- return `${lead}: ${factPhrase(f)}${f.provenance ? ` (source: ${f.provenance})` : ""}`;
818
+ const cite = f.provenance ? ` (source: ${f.provenance})` : "";
819
+ if (f.provenance.includes("ace:chat")) return `you told me: ${factPhrase(f)}${cite}`;
820
+ // CORPUS facts are background DATA — present the relation plainly, cited to its
821
+ // source, NEVER "i learned: …" (the footgun: a first-person claim over corpus noise).
822
+ if (f.provenance.includes("corpus:")) return `${factPhrase(f)}${cite}`;
823
+ return `i learned: ${factPhrase(f)}${cite}`;
806
824
  }
807
825
 
808
826
  /** Read every reified Fact out of the memory graph as plain {subject, predicate,
@@ -852,8 +870,8 @@ const ISA_ASK_RE = /^(?:is|are)\s+(?:an?\s+)?(.+?)\s+(?:a\s+kind\s+of|a\s+type\s
852
870
  const ISA_PREDICATES = new Set(["rdfs:subClassOf", "rdf:type"]);
853
871
  /** "what do you know about caches" — the open recall-everything form. */
854
872
  const KNOW_ABOUT_RE = /^what\s+do\s+you\s+know\s+about\s+(.+?)[?.!\s]*$/i;
855
- /** How many facts a single answer lists before "…and N more". */
856
- const FACT_ANSWER_CAP = 5;
873
+ /** How many facts a single answer lists before the remainder is paged with "more". */
874
+ const FACT_ANSWER_CAP = 32;
857
875
 
858
876
  /** W4 seam: answer (or extend) a vocabulary/definition question from the MEMORY
859
877
  * graph's Facts. Returns { text, replace } — `replace:false` means the engine's
@@ -880,9 +898,11 @@ async function factAnswer(memoryDir, query, envelope, miss) {
880
898
  const variants = factTermVariants(normFactTerm, metaTerm);
881
899
  const hits = (await memoryFacts(memoryDir)).filter((f) => variants.has(f.subject));
882
900
  if (!hits.length) return null;
883
- const shown = hits.slice(0, FACT_ANSWER_CAP).map(renderFactLine);
884
- const extra = hits.length > FACT_ANSWER_CAP ? `\n…and ${hits.length - FACT_ANSWER_CAP} more remembered fact${hits.length - FACT_ANSWER_CAP === 1 ? "" : "s"}.` : "";
885
- return { text: shown.join("\n") + extra, replace: miss };
901
+ const lines = hits.map(renderFactLine);
902
+ const shown = lines.slice(0, FACT_ANSWER_CAP);
903
+ const rest = lines.slice(FACT_ANSWER_CAP);
904
+ const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
905
+ return { text: shown.join("\n") + extra, replace: miss, ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}) };
886
906
  }
887
907
  if (!miss) return null;
888
908
 
@@ -907,9 +927,11 @@ async function factAnswer(memoryDir, query, envelope, miss) {
907
927
  if (!hits.length) return null;
908
928
  // echo the STORED spelling ("caches" asked → "cache" known), never a guess
909
929
  const term = variants.has(hits[0].subject) ? hits[0].subject : hits[0].object;
910
- const shown = hits.slice(0, FACT_ANSWER_CAP).map((f) => ` ${renderFactLine(f)}`);
911
- const extra = hits.length > FACT_ANSWER_CAP ? `\n …and ${hits.length - FACT_ANSWER_CAP} more.` : "";
912
- return { text: `${hits.length} remembered fact${hits.length === 1 ? "" : "s"} about ${term}:\n${shown.join("\n")}${extra}`, replace: true };
930
+ const lines = hits.map((f) => ` ${renderFactLine(f)}`);
931
+ const shown = lines.slice(0, FACT_ANSWER_CAP);
932
+ const rest = lines.slice(FACT_ANSWER_CAP);
933
+ const extra = rest.length ? `\n …and ${rest.length} more — say 'more' to see them.` : "";
934
+ return { text: `${hits.length} remembered fact${hits.length === 1 ? "" : "s"} about ${term}:\n${shown.join("\n")}${extra}`, replace: true, ...(rest.length ? { pending: { items: rest.map((l) => l.trim()), noun: "facts" } } : {}) };
913
935
  }
914
936
  return null;
915
937
  }
@@ -963,10 +985,11 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null) {
963
985
  const isa = rows.filter((f) => ISA_PREDICATES.has(f.predicate));
964
986
  const byTrust = (a, b) => b.trust - a.trust;
965
987
  const renderMany = (hits) => {
966
- const shown = hits.slice(0, FACT_ANSWER_CAP).map(renderFactLine);
967
- const n = hits.length - FACT_ANSWER_CAP;
968
- const extra = n > 0 ? `\n…and ${n} more remembered fact${n === 1 ? "" : "s"}.` : "";
969
- return { text: shown.join("\n") + extra, replace: true };
988
+ const lines = hits.map(renderFactLine);
989
+ const shown = lines.slice(0, FACT_ANSWER_CAP);
990
+ const rest = lines.slice(FACT_ANSWER_CAP);
991
+ const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
992
+ return { text: shown.join("\n") + extra, replace: true, ...(rest.length ? { pending: { items: rest, noun: "facts" } } : {}) };
970
993
  };
971
994
 
972
995
  // (d) WHOLE-STORE recall (CHATBENCH_006 lever 3) — "what did i tell you last time",
@@ -1001,9 +1024,11 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null) {
1001
1024
  const hits = rows.filter((f) => variants.has(f.subject) || variants.has(f.object)).sort(byTrust);
1002
1025
  if (!hits.length) return null;
1003
1026
  const term = variants.has(hits[0].subject) ? hits[0].subject : hits[0].object;
1004
- const shown = hits.slice(0, FACT_ANSWER_CAP).map((f) => ` ${renderFactLine(f)}`);
1005
- const extra = hits.length > FACT_ANSWER_CAP ? `\n …and ${hits.length - FACT_ANSWER_CAP} more.` : "";
1006
- return { text: `${hits.length} remembered fact${hits.length === 1 ? "" : "s"} about ${term}:\n${shown.join("\n")}${extra}`, replace: true };
1027
+ const lines = hits.map((f) => ` ${renderFactLine(f)}`);
1028
+ const shown = lines.slice(0, FACT_ANSWER_CAP);
1029
+ const rest = lines.slice(FACT_ANSWER_CAP);
1030
+ const extra = rest.length ? `\n …and ${rest.length} more — say 'more' to see them.` : "";
1031
+ return { text: `${hits.length} remembered fact${hits.length === 1 ? "" : "s"} about ${term}:\n${shown.join("\n")}${extra}`, replace: true, ...(rest.length ? { pending: { items: rest.map((l) => l.trim()), noun: "facts" } } : {}) };
1007
1032
  }
1008
1033
 
1009
1034
  // (c) REVERSE / "what kind of thing" membership. The meta form ("what is a Y")
@@ -1167,6 +1192,32 @@ function seonDefinitions() {
1167
1192
  return seonDefsPromise;
1168
1193
  }
1169
1194
 
1195
+ let seonRelsPromise = null;
1196
+ /** Load corpus/seon/relations.jsonl once → Map(relationTerm → definition), keyed on
1197
+ * the concept key ("imports","calls",…). Sits beside definitions.jsonl (same seon
1198
+ * dir), loaded the same lazy + failure-tolerated way — any failure degrades to an
1199
+ * empty map, so the relation force simply declines rather than throwing. */
1200
+ function relationDefinitions() {
1201
+ if (!seonRelsPromise) {
1202
+ seonRelsPromise = (async () => {
1203
+ const { SEON_DEFINITIONS_FILE } = await import("./corpus/conceptnet.mjs");
1204
+ const relFile = join(dirname(SEON_DEFINITIONS_FILE), "relations.jsonl");
1205
+ const raw = await readFile(relFile, "utf8");
1206
+ const map = new Map();
1207
+ for (const line of raw.split("\n")) {
1208
+ const t = line.trim();
1209
+ if (!t) continue;
1210
+ try {
1211
+ const row = JSON.parse(t);
1212
+ if (row.relation && row.definition) map.set(String(row.relation).toLowerCase(), String(row.definition));
1213
+ } catch { /* skip a malformed line, never throw */ }
1214
+ }
1215
+ return map;
1216
+ })().catch(() => new Map());
1217
+ }
1218
+ return seonRelsPromise;
1219
+ }
1220
+
1170
1221
  /** The meta term a "what is a X" / "what does X mean" / "define X" question asks
1171
1222
  * about — from the parse when present, else recognized directly (same required-
1172
1223
  * article discipline as the grammar's T5). Null when the line isn't such a form. */
@@ -1208,6 +1259,132 @@ async function curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon })
1208
1259
  return { text: `${def} (source: corpus/seon)`, term };
1209
1260
  }
1210
1261
 
1262
+ /** The concept term a vague "what is a X" / "tell me about X" / "what does X mean" /
1263
+ * "define X" asks about — metaTermOf's forms plus the "tell me about …" opener that
1264
+ * the graph parser reads as a count. Null when the line isn't such a touch. The
1265
+ * concept force is gated further downstream (a KNOWN, instance-bearing concept), so
1266
+ * this only has to recognize the SHAPE, not vet the term. */
1267
+ /** The VAGUE-TOUCH shapes ("tell me about X", "[and] what about X") — a concept
1268
+ * touch that is NOT the "what is a X" / "what does X mean" META shape. The meta shape
1269
+ * has its own established handling (a noun definition, a predicate definition, or the
1270
+ * honest ambiguity surround for a term that is BOTH a noun and a predicate — e.g.
1271
+ * "imports"), which the RELATION force must never preempt (frozen case
1272
+ * am-meta-imports). Gated downstream by CONCEPT_CLASS / RELATION_TERM, so a real
1273
+ * entity name declines here. */
1274
+ function vagueTouchTermOf(query) {
1275
+ const q = String(query).trim();
1276
+ const m = q.match(/^tell me about\s+(?:an?\s+)?(.+?)[?.!\s]*$/i)
1277
+ || q.match(/^(?:(?:and|so|but|ok|okay|now|then)\s+)*what about\s+(?:an?\s+|the\s+)?(.+?)[?.!\s]*$/i);
1278
+ return m ? m[1].trim() : null;
1279
+ }
1280
+
1281
+ function conceptTermOf(query, envelope) {
1282
+ return metaTermOf(query, envelope) || vagueTouchTermOf(query);
1283
+ }
1284
+
1285
+ /** The RELATION term a vague touch names — the VAGUE-touch shapes only ("tell me
1286
+ * about X", "what about X"), NOT the "what is a X"/"what does X mean" meta shape (a
1287
+ * relation term that is also a vocabulary word, like "imports", keeps its established
1288
+ * ambiguity/predicate-definition answer — frozen case am-meta-imports). Plus the
1289
+ * relation-only openers the graph parser reads as something else: "what are the
1290
+ * imports", "what calls are there", "what is calling". Null when the line isn't such a
1291
+ * touch. Gated downstream by RELATION_TERM, so this only has to recognize the SHAPE. */
1292
+ function relationTermOf(query, envelope) {
1293
+ const base = vagueTouchTermOf(query);
1294
+ if (base) return base;
1295
+ const q = String(query).trim().toLowerCase().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
1296
+ let m;
1297
+ // "what are the imports", "what is the containment", "what are all the calls"
1298
+ if ((m = q.match(/^what\s+(?:are|is)\s+(?:all\s+)?(?:the\s+)?([a-z][a-z-]*?)(?:\s+(?:edges|relationships|relations))?$/))) return m[1];
1299
+ // "what calls are there", "what imports are there"
1300
+ if ((m = q.match(/^what\s+([a-z][a-z-]*?)\s+are\s+there$/))) return m[1];
1301
+ // "what is calling", "what is importing" (bare gerund, no object)
1302
+ if ((m = q.match(/^what\s+(?:is|are)\s+([a-z][a-z-]*ing)$/))) return m[1];
1303
+ return null;
1304
+ }
1305
+
1306
+ /** THE RELATION CONCEPT FORCE — compose the three-band answer (curated relation
1307
+ * definition + real example EDGES + pre-validated follow-ups) for a vague touch on a
1308
+ * relation/edge kind ("what about imports", "what are the calls", "tell me about
1309
+ * contains"), or null when it isn't one: not a recognizable relation touch, not a
1310
+ * known edge concept (RELATION_TERM), no curated definition, or the graph has NO
1311
+ * edges of that kind (composeRelation's own honest-miss gate). Loads the definition
1312
+ * from the shipped corpus/seon/relations.jsonl, so it works without per-repo memory
1313
+ * seeding. Lazy + failure-tolerated throughout. Returns { text, pending }. */
1314
+ async function relationForceAnswer(query, envelope, { graph, config, source, templates }) {
1315
+ const rawTerm = relationTermOf(query, envelope);
1316
+ if (!rawTerm) return null;
1317
+ let composeRelation; let RELATION_TERM;
1318
+ try { ({ composeRelation, RELATION_TERM } = await import("./concept.mjs")); }
1319
+ catch { return null; }
1320
+ const term = String(rawTerm).toLowerCase();
1321
+ if (!RELATION_TERM[term]) return null; // not an enumerable edge concept — ordinary path owns it
1322
+ const definition = (await relationDefinitions()).get(RELATION_TERM[term]) ?? null;
1323
+ if (!definition) return null;
1324
+ // Same graph-load fallback as conceptForceAnswer: the shell hands the loaded graph
1325
+ // straight in; the pure runTurn(config) path loads it the way dispatchTool does.
1326
+ let g = graph;
1327
+ if (!g && config && source) {
1328
+ try { g = parseEntities(await source.fetchEntities(config)); } catch { g = null; }
1329
+ }
1330
+ if (!g) return null;
1331
+ let composed;
1332
+ try { composed = composeRelation(g, term, { definition }); }
1333
+ catch { return null; }
1334
+ if (!composed) return null;
1335
+ const rendered = tRender(templates, T_CONCEPT, {
1336
+ definition: composed.definition, examples: composed.examples, followups: composed.followups,
1337
+ });
1338
+ const text = rendered ?? `${composed.definition}\n${composed.examples}${composed.followups}`;
1339
+ const pending = composed.remainder && composed.remainder.length
1340
+ ? { items: composed.remainder, noun: composed.noun }
1341
+ : null;
1342
+ return { text, pending };
1343
+ }
1344
+
1345
+ /** THE CONCEPT FORCE — compose the three-band answer (corpus/seon definition + real
1346
+ * graph/memory instances + pre-validated follow-ups) for a vague concept touch, or
1347
+ * null when it isn't one: not a "what is a X"/"tell me about X" shape, not a known
1348
+ * enumerable concept (CONCEPT_CLASS), no curated definition, or NO instances anywhere
1349
+ * (composeConcept's own honest-miss gate). Loads the definition DIRECTLY from the
1350
+ * shipped corpus/seon file (seonDefinitions), so it works without per-repo memory
1351
+ * seeding; the memory fact rows only ADD remembered "A is a X" examples when present.
1352
+ * Lazy + failure-tolerated throughout (chat.mjs ethos). Returns { text, instances }. */
1353
+ async function conceptForceAnswer(query, envelope, { graph, config, source, memoryDir, templates }) {
1354
+ const rawTerm = conceptTermOf(query, envelope);
1355
+ if (!rawTerm) return null;
1356
+ let normFactTerm; let composeConcept; let CONCEPT_CLASS;
1357
+ try {
1358
+ ({ normFactTerm } = await import("./memory/core.mjs"));
1359
+ ({ composeConcept, CONCEPT_CLASS } = await import("./concept.mjs"));
1360
+ } catch { return null; }
1361
+ const term = normFactTerm(rawTerm);
1362
+ if (!CONCEPT_CLASS[term]) return null; // not an enumerable code concept — ordinary path owns it
1363
+ const definition = (await seonDefinitions()).get(term) ?? null;
1364
+ if (!definition) return null;
1365
+ // The runChat shell hands the loaded graph straight in; the pure runTurn(config)
1366
+ // path (tests, chatbench) does not, so load it the same way dispatchTool does when
1367
+ // it's missing. Failure-tolerated: no loadable graph → no concept force.
1368
+ let g = graph;
1369
+ if (!g && config && source) {
1370
+ try { g = parseEntities(await source.fetchEntities(config)); } catch { g = null; }
1371
+ }
1372
+ if (!g) return null;
1373
+ const rows = memoryDir ? await factRows(memoryDir) : [];
1374
+ let composed;
1375
+ try { composed = composeConcept(g, term, { definition, factRows: rows }); }
1376
+ catch { return null; }
1377
+ if (!composed) return null;
1378
+ const rendered = tRender(templates, T_CONCEPT, {
1379
+ definition: composed.definition, examples: composed.examples, followups: composed.followups,
1380
+ });
1381
+ const text = rendered ?? `${composed.definition}\n${composed.examples}${composed.followups}`;
1382
+ const pending = composed.remainder && composed.remainder.length
1383
+ ? { items: composed.remainder, noun: composed.noun }
1384
+ : null;
1385
+ return { text, instances: composed.instances, pending };
1386
+ }
1387
+
1211
1388
  /** A bare question → tmct_ask. When a focus is set AND the graph is in hand we
1212
1389
  * call ask() directly to thread the focus as contextId (so a pronoun like "it"
1213
1390
  * resolves to the focus) — building the SAME delimited string dispatchTool emits;
@@ -1270,6 +1447,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
1270
1447
  // orientation swap below is template wording, so those turns carry via:"template".
1271
1448
  let via = "composed";
1272
1449
  let recordMiss = miss;
1450
+ let factPending = null; // a truncated fact listing's held remainder (for "more" paging)
1273
1451
  // MISS handling. The intent lanes + short-miss are RECOGNIZER-gated on the query
1274
1452
  // text AND only consulted on a would-miss, so a real graph query — a hit, an honest
1275
1453
  // empty with a receipt, a fuzzy repair — is never hijacked. Order: (1) META/SELF
@@ -1301,6 +1479,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
1301
1479
  answer = fact.replace ? fact.text : `${answer}\n${fact.text}`;
1302
1480
  via = "fact";
1303
1481
  recordMiss = false;
1482
+ if (fact.pending) factPending = fact.pending; // a truncated fact list → paginable remainder
1304
1483
  } else if (miss) {
1305
1484
  // W2: after the honest miss is composed, consult the folded-session memory. A
1306
1485
  // relevant enough block ANSWERS — recalled Q/A framed + cited first, with the
@@ -1323,6 +1502,38 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
1323
1502
  const def = await curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon });
1324
1503
  if (def) { answer = def.text; via = "corpus/seon"; recordMiss = false; }
1325
1504
  }
1505
+ // THE CONCEPT FORCE (concept.mjs) — a vague "what is a X" / "tell me about X" that
1506
+ // names a KNOWN code concept WITH real instances composes the three-band answer
1507
+ // (definition + real examples + pre-validated follow-ups), superseding the bare
1508
+ // schema-doc / curated-definition surface (both corpus-sourced). It declines unless
1509
+ // the term is a known, instance-bearing concept, so a precise query, an unknown
1510
+ // term, or an instance-less concept is never hijacked — the ordinary answer stands.
1511
+ // Runs after the corpus-fact/curated branches (via composed|corpus/seon) but not
1512
+ // over a "you told me" fact, a meta/self summary, or the conversational lanes.
1513
+ let conceptInstances = null;
1514
+ let conceptPending = null;
1515
+ if (via === "composed" || via === "corpus/seon") {
1516
+ const concept = await conceptForceAnswer(query, envelope, { graph, config, source, memoryDir, templates });
1517
+ if (concept) {
1518
+ answer = concept.text; via = "corpus/seon"; recordMiss = false;
1519
+ conceptInstances = concept.instances;
1520
+ conceptPending = concept.pending;
1521
+ } else {
1522
+ // THE RELATION CONCEPT FORCE — the noun force declined, so try the edge-kind
1523
+ // touch ("what about imports", "what are the calls", "tell me about contains").
1524
+ // Same three-band shape over real EDGES; declines unless the term is a known,
1525
+ // edge-bearing relation, so a precise query / unknown word is never hijacked.
1526
+ // For a "what about <relation>" this also SUPERSEDES the discourse rewrite's
1527
+ // dead-end (rewriting the prior question with a relation word rarely resolves) —
1528
+ // but only when the touched word is a relation concept; a real entity name in
1529
+ // "what about X" declines here and the discourse continuation stands.
1530
+ const relation = await relationForceAnswer(query, envelope, { graph, config, source, templates });
1531
+ if (relation) {
1532
+ answer = relation.text; via = "corpus/seon"; recordMiss = false;
1533
+ conceptPending = relation.pending;
1534
+ }
1535
+ }
1536
+ }
1326
1537
  // (4) #2 TEACH lane — a teach-shaped would-miss nothing above answered: route to
1327
1538
  // memory, or say what CAN be remembered (LOUD), never the wall / a silent drop.
1328
1539
  if (miss && recordMiss && via === "composed") {
@@ -1339,7 +1550,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
1339
1550
  // toward a real graph, unless it already points there. Only when genuinely empty.
1340
1551
  if (recordMiss && (via === "composed" || via === "miss")
1341
1552
  && noCodeGraph(graph) && !/--repo|tmct init|no code graph/i.test(answer)) {
1342
- answer = `${answer}\n(this repo has no code graph — try \`--repo <path>\` or \`tmct init\`.)`;
1553
+ answer = `${answer}\n(this repo has no code graph — for structure, point me at a \`.tmct/graph.json\` with \`--repo <path>\` or run \`npm run example:mini\`; tmct doesn't index code itself.)`;
1343
1554
  }
1344
1555
  // W5 (flag-gated, default OFF): an unknown-term miss may consult the LOCAL
1345
1556
  // committed corpus slice — a hit APPENDS a grounded, licence-cited aside under
@@ -1351,11 +1562,23 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
1351
1562
  via = "corpus";
1352
1563
  }
1353
1564
  }
1354
- const record = { type: "turn", ts, query, via, resolvedIds, answeredIds, miss: recordMiss };
1565
+ // The concept force answers WITH real example instances those are the entities the
1566
+ // turn "asked about" (the SchemaClass meta-node is documentation, not a code entity),
1567
+ // so record + expand them, not the schema match.
1568
+ const finalAnsweredIds = conceptInstances ? conceptInstances.map((i) => i.id) : answeredIds;
1569
+ const record = { type: "turn", ts, query, via, resolvedIds, answeredIds: finalAnsweredIds, miss: recordMiss };
1355
1570
  const logLines = [ts, `> ${query}`, answer, ""];
1356
1571
  // `detail` feeds why/say-more's verbose re-render: the traversal receipt + the
1357
- // matched entities the terse render trims (see renderVerbose).
1358
- const detail = envelope ? { traversal: envelope.traversal || null, matches: envelope.matches || [] } : null;
1572
+ // matched entities the terse render trims (see renderVerbose). `pending` carries a
1573
+ // truncated listing's held remainder for "more" paging the concept/relation force
1574
+ // holds it on conceptPending (the relation force resolves no instance ids, so it can
1575
+ // still page even with an empty matches set); a fact listing holds it on factPending.
1576
+ const pending = conceptPending ?? factPending;
1577
+ const detail = conceptInstances
1578
+ ? { traversal: envelope?.traversal || null, matches: conceptInstances, ...(pending ? { pending } : {}) }
1579
+ : (envelope
1580
+ ? { traversal: envelope.traversal || null, matches: envelope.matches || [], ...(pending ? { pending } : {}) }
1581
+ : (pending ? { traversal: null, matches: [], pending } : null));
1359
1582
  return { answer, logLines, record, focus: newFocus, detail };
1360
1583
  }
1361
1584
 
@@ -1482,6 +1705,29 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null })
1482
1705
  * subject), `answeredIds` the entity ids an ask answer cited; a slash-command turn
1483
1706
  * also carries its `command` name. Both drive the mgx:asksAbout graph append.
1484
1707
  */
1708
+ // ---- "more" pagination — a long examples/facts listing shows the first PAGE
1709
+ // entries and holds the remainder on the turn's `last.detail.pending`; a bare
1710
+ // "more"/"show more"/"the rest" in the NEXT turn renders the next batch, advancing
1711
+ // the same pending state. Any other (real) query produces a fresh `last` without
1712
+ // `pending`, so the remainder is naturally cleared — no stale continuation. ----
1713
+ const PAGE = 32;
1714
+ const MORE_RE = /^(?:more|show more|see more|the rest|next|continue|go on)\b[.!?]*$/i;
1715
+ const joinList = (a) => (a.length > 1 ? `${a.slice(0, -1).join(", ")} and ${a[a.length - 1]}` : (a[0] ?? ""));
1716
+
1717
+ /** Render the next page of a held remainder (pending: {items:[str], noun}). Returns a
1718
+ * plain turn whose `detail.pending` carries what's still unseen (null when the batch
1719
+ * finished the list), so a follow-on "more" continues. */
1720
+ function morePage(query, { last, focus }) {
1721
+ const p = last.detail.pending;
1722
+ const batch = p.items.slice(0, PAGE);
1723
+ const rest = p.items.slice(PAGE);
1724
+ const tail = rest.length ? ` …and ${rest.length} more — say 'more' to see them.` : "";
1725
+ const answer = `${joinList(batch)}.${tail}`;
1726
+ const turn = plainTurn(query, answer, { via: "count", focus });
1727
+ turn.detail = { traversal: null, matches: [], ...(rest.length ? { pending: { items: rest, noun: p.noun } } : {}) };
1728
+ return turn;
1729
+ }
1730
+
1485
1731
  export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null } = {}) {
1486
1732
  const line = String(input ?? "").trim();
1487
1733
  const templates = await chatTemplates(); // failure-tolerated: null degrades, never throws
@@ -1511,6 +1757,13 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
1511
1757
  const convo = conversationalTurn(line, ctx);
1512
1758
  if (convo) return convo;
1513
1759
 
1760
+ // "more" — page the remainder of a previous long listing, if one is held. Gated on
1761
+ // an actual pending remainder so a bare "more" with nothing to continue falls through
1762
+ // to the ordinary path (an honest miss), never a pretend page.
1763
+ if (MORE_RE.test(line) && Array.isArray(last?.detail?.pending?.items) && last.detail.pending.items.length) {
1764
+ return withLast(morePage(line, ctx));
1765
+ }
1766
+
1514
1767
  if (line.startsWith("/")) return withLast(await runCommand(line, ctx));
1515
1768
  // Declarative ACE sentences ("every module is a artifact") ASSERT into tmct's
1516
1769
  // own memory and confirm — they are statements to remember, not graph queries.
@@ -1546,15 +1799,20 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
1546
1799
 
1547
1800
  // ---- W3: seedMemory → bootstrap (first run in a graph-less repo) ----
1548
1801
 
1549
- /** How many corpus facts the first-run bootstrap seeds. Measured curve (dev
1550
- * laptop, appendFact's read-modify-write per fact): 100→~0.16s, 250→~0.54s,
1551
- * 500→~1.7s the full 500 stays inside a session-start budget, so the seed
1552
- * runs synchronously and complete (no partial-sync cap needed). */
1553
- export const SEED_LIMIT = 500;
1554
-
1555
- /** Which predicates the capped seed prefers (stable order see seedMemory's
1556
- * `prefer`): the definitional band first, so a bootstrap's 500 facts answer
1557
- * "what is a cache?"-style vocabulary questions rather than location trivia. */
1802
+ /** The first-run bootstrap seeds the WHOLE shipped ConceptNet band (no cap) — the
1803
+ * operator's "seed all 40k" call. `undefined` means seedMemory writes every
1804
+ * seedable fact in the committed slice (~6.3k). The batched appendFacts write
1805
+ * (src/memory/core.mjs) makes this a single O(N) pass the full slice seeds in a
1806
+ * couple of seconds, inside a session-start budget, so it still runs synchronously
1807
+ * and complete. A finite value here would re-impose the old cap; keep it undefined
1808
+ * to mean "all". (Kept as a named export so init.mjs and tests share the intent.) */
1809
+ export const SEED_LIMIT = undefined;
1810
+
1811
+ /** Which predicates the seed lists FIRST (stable order — see seedMemory's `prefer`):
1812
+ * the definitional band leads, so the on-disk memory opens with the vocabulary that
1813
+ * answers "what is a cache?"-style questions rather than location trivia. With the
1814
+ * cap lifted this only sets ORDER (every fact seeds either way), but a well-ordered
1815
+ * memory keeps inspection and any future re-cap honest. */
1558
1816
  export const SEED_PREFER = ["rdfs:subClassOf", "rdf:type", "mgx:usedFor", "mgx:partOf", "mgx:capableOf"];
1559
1817
 
1560
1818
  /** The seed marker: its presence means this repo's memory already carries the
@@ -1653,7 +1911,15 @@ export async function createSession({
1653
1911
  env = process.env,
1654
1912
  cwd = process.cwd(),
1655
1913
  gitRoot = gitToplevel,
1914
+ ephemeral = false,
1656
1915
  } = {}) {
1916
+ // EPHEMERAL mode (--ephemeral, or TMCT_EPHEMERAL=1): read the target graph but
1917
+ // write NOTHING back into it. The shipped examples run this way so a demo never
1918
+ // dirties the committed code graph (`npm run example:mini` used to fold a session
1919
+ // into examples/*/.tmct/graph.json and rewrite it). We still read config.graphFile
1920
+ // for structure; only the WRITE base (logs, memory, sessions) is diverted to an OS
1921
+ // temp dir and the read-time graph upsert is suppressed.
1922
+ ephemeral = ephemeral || /^(1|true|yes)$/i.test(String(env.TMCT_EPHEMERAL || ""));
1657
1923
  // Graph resolution order for the chat surface (documented; --repo wins):
1658
1924
  // 1. --repo <path> → pins <path>/.tmct/graph.json (repo AND graph).
1659
1925
  // 2. TMCT_GRAPH_FILE env → loads that graph anywhere (loadConfig reads it), so
@@ -1677,6 +1943,11 @@ export async function createSession({
1677
1943
  config = envGraph ? loadConfig(env, cwd) : { graphFile: join(repo, DEFAULT_GRAPH_REL) };
1678
1944
  }
1679
1945
 
1946
+ // Ephemeral: keep config.graphFile pointing at the READ graph, but divert the
1947
+ // write base (repo → logs/memory/sessions) to a throwaway temp dir. The committed
1948
+ // target is never touched; the demo's memory simply doesn't persist across runs.
1949
+ if (ephemeral) repo = await mkdtemp(join(tmpdir(), "tmct-ephemeral-"));
1950
+
1680
1951
  // Load the graph once up front — the banner needs the module count, and focus/`it`
1681
1952
  // resolution and contextId threading need it in hand. A missing artifact loads as
1682
1953
  // the empty bootstrap graph (source.mjs) — the banner says so; never an error.
@@ -1723,6 +1994,7 @@ export async function createSession({
1723
1994
  // artifact mid-session must degrade the recording, never kill the chat.
1724
1995
  const turnRecords = [];
1725
1996
  const upsertGraph = async (ended) => {
1997
+ if (ephemeral) return; // a demo/read-only session never writes back to the graph
1726
1998
  if (!turnRecords.length) return; // a zero-turn session never pollutes the graph
1727
1999
  try { await appendSessionToGraph(config.graphFile, { id: sessionId, started: startIso, ended, turns: turnRecords }); }
1728
2000
  catch { /* best-effort — see above */ }
@@ -1752,8 +2024,10 @@ export async function createSession({
1752
2024
  // the honest seed line appears ONLY on the run that actually seeded — the count
1753
2025
  // is the TOTAL appended, split into the curated SEON ontology + the ConceptNet band.
1754
2026
  ...(seeded ? [`seeded ${seeded.appended} starter facts (${seeded.seon} curated SEON + ${seeded.conceptnet} ConceptNet) — /memory to inspect`] : []),
1755
- // no code indexed → point at how to get one, and at what IS answerable now
1756
- ...(noCodeGraph ? ['no code indexed yet — run `tmct init` here or pass --repo <path>; meanwhile try "what is a cache"'] : []),
2027
+ // no code graph → point at how to GET one (a graph producer / --repo / the shipped
2028
+ // example), honest that `tmct init` seeds VOCABULARY, not a code graph, and at what
2029
+ // IS answerable now. tmct reads graphs; it never indexes code itself.
2030
+ ...(noCodeGraph ? ['for code structure, point me at a .tmct/graph.json with --repo <path> or try `npm run example:mini` (tmct reads graphs, it doesn\'t index code); `tmct init` only seeds vocabulary — try "what is a cache"'] : []),
1757
2031
  "pass --repo <path> to target a different repo",
1758
2032
  "ask a question, or /help for commands (/stats for an overview) — /exit to leave",
1759
2033
  ];
@@ -1826,8 +2100,9 @@ export async function runChat({
1826
2100
  env = process.env,
1827
2101
  cwd = process.cwd(),
1828
2102
  gitRoot = gitToplevel,
2103
+ ephemeral = false,
1829
2104
  } = {}) {
1830
- const session = await createSession({ repoPath, source, env, cwd, gitRoot });
2105
+ const session = await createSession({ repoPath, source, env, cwd, gitRoot, ephemeral });
1831
2106
 
1832
2107
  const dim = (s) => (env.NO_COLOR || !output.isTTY ? s : `\x1b[2m${s}\x1b[0m`);
1833
2108
  for (const line of session.bannerLines) output.write(dim(line) + "\n");