@polycode-projects/the-mechanical-code-talker 0.6.0 → 0.7.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.
@@ -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.0",
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,124 @@ 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
+ function conceptTermOf(query, envelope) {
1268
+ const base = metaTermOf(query, envelope);
1269
+ if (base) return base;
1270
+ const q = String(query).trim();
1271
+ const m = q.match(/^tell me about\s+(?:an?\s+)?(.+?)[?.!\s]*$/i)
1272
+ // "[and/so/…] what about X" with no good discourse continuation — the concept
1273
+ // KIND word is a concept touch, not a bare module lookup (DEAD-END 4). Gated
1274
+ // downstream by CONCEPT_CLASS/RELATION_TERM, so a real entity name declines here.
1275
+ || q.match(/^(?:(?:and|so|but|ok|okay|now|then)\s+)*what about\s+(?:an?\s+|the\s+)?(.+?)[?.!\s]*$/i);
1276
+ return m ? m[1].trim() : null;
1277
+ }
1278
+
1279
+ /** The RELATION term a vague touch names — reuses conceptTermOf's shapes ("what is
1280
+ * X"/"what does X mean"/"tell me about X"/"what about X"), plus the relation-only
1281
+ * openers the graph parser reads as something else: "what are the imports", "what
1282
+ * calls are there", "what is calling". Null when the line isn't such a touch. Gated
1283
+ * downstream by RELATION_TERM, so this only has to recognize the SHAPE. */
1284
+ function relationTermOf(query, envelope) {
1285
+ const base = conceptTermOf(query, envelope);
1286
+ if (base) return base;
1287
+ const q = String(query).trim().toLowerCase().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
1288
+ let m;
1289
+ // "what are the imports", "what is the containment", "what are all the calls"
1290
+ if ((m = q.match(/^what\s+(?:are|is)\s+(?:all\s+)?(?:the\s+)?([a-z][a-z-]*?)(?:\s+(?:edges|relationships|relations))?$/))) return m[1];
1291
+ // "what calls are there", "what imports are there"
1292
+ if ((m = q.match(/^what\s+([a-z][a-z-]*?)\s+are\s+there$/))) return m[1];
1293
+ // "what is calling", "what is importing" (bare gerund, no object)
1294
+ if ((m = q.match(/^what\s+(?:is|are)\s+([a-z][a-z-]*ing)$/))) return m[1];
1295
+ return null;
1296
+ }
1297
+
1298
+ /** THE RELATION CONCEPT FORCE — compose the three-band answer (curated relation
1299
+ * definition + real example EDGES + pre-validated follow-ups) for a vague touch on a
1300
+ * relation/edge kind ("what about imports", "what are the calls", "tell me about
1301
+ * contains"), or null when it isn't one: not a recognizable relation touch, not a
1302
+ * known edge concept (RELATION_TERM), no curated definition, or the graph has NO
1303
+ * edges of that kind (composeRelation's own honest-miss gate). Loads the definition
1304
+ * from the shipped corpus/seon/relations.jsonl, so it works without per-repo memory
1305
+ * seeding. Lazy + failure-tolerated throughout. Returns { text, pending }. */
1306
+ async function relationForceAnswer(query, envelope, { graph, config, source, templates }) {
1307
+ const rawTerm = relationTermOf(query, envelope);
1308
+ if (!rawTerm) return null;
1309
+ let composeRelation; let RELATION_TERM;
1310
+ try { ({ composeRelation, RELATION_TERM } = await import("./concept.mjs")); }
1311
+ catch { return null; }
1312
+ const term = String(rawTerm).toLowerCase();
1313
+ if (!RELATION_TERM[term]) return null; // not an enumerable edge concept — ordinary path owns it
1314
+ const definition = (await relationDefinitions()).get(RELATION_TERM[term]) ?? null;
1315
+ if (!definition) return null;
1316
+ // Same graph-load fallback as conceptForceAnswer: the shell hands the loaded graph
1317
+ // straight in; the pure runTurn(config) path loads it the way dispatchTool does.
1318
+ let g = graph;
1319
+ if (!g && config && source) {
1320
+ try { g = parseEntities(await source.fetchEntities(config)); } catch { g = null; }
1321
+ }
1322
+ if (!g) return null;
1323
+ let composed;
1324
+ try { composed = composeRelation(g, term, { definition }); }
1325
+ catch { return null; }
1326
+ if (!composed) return null;
1327
+ const rendered = tRender(templates, T_CONCEPT, {
1328
+ definition: composed.definition, examples: composed.examples, followups: composed.followups,
1329
+ });
1330
+ const text = rendered ?? `${composed.definition}\n${composed.examples}${composed.followups}`;
1331
+ const pending = composed.remainder && composed.remainder.length
1332
+ ? { items: composed.remainder, noun: composed.noun }
1333
+ : null;
1334
+ return { text, pending };
1335
+ }
1336
+
1337
+ /** THE CONCEPT FORCE — compose the three-band answer (corpus/seon definition + real
1338
+ * graph/memory instances + pre-validated follow-ups) for a vague concept touch, or
1339
+ * null when it isn't one: not a "what is a X"/"tell me about X" shape, not a known
1340
+ * enumerable concept (CONCEPT_CLASS), no curated definition, or NO instances anywhere
1341
+ * (composeConcept's own honest-miss gate). Loads the definition DIRECTLY from the
1342
+ * shipped corpus/seon file (seonDefinitions), so it works without per-repo memory
1343
+ * seeding; the memory fact rows only ADD remembered "A is a X" examples when present.
1344
+ * Lazy + failure-tolerated throughout (chat.mjs ethos). Returns { text, instances }. */
1345
+ async function conceptForceAnswer(query, envelope, { graph, config, source, memoryDir, templates }) {
1346
+ const rawTerm = conceptTermOf(query, envelope);
1347
+ if (!rawTerm) return null;
1348
+ let normFactTerm; let composeConcept; let CONCEPT_CLASS;
1349
+ try {
1350
+ ({ normFactTerm } = await import("./memory/core.mjs"));
1351
+ ({ composeConcept, CONCEPT_CLASS } = await import("./concept.mjs"));
1352
+ } catch { return null; }
1353
+ const term = normFactTerm(rawTerm);
1354
+ if (!CONCEPT_CLASS[term]) return null; // not an enumerable code concept — ordinary path owns it
1355
+ const definition = (await seonDefinitions()).get(term) ?? null;
1356
+ if (!definition) return null;
1357
+ // The runChat shell hands the loaded graph straight in; the pure runTurn(config)
1358
+ // path (tests, chatbench) does not, so load it the same way dispatchTool does when
1359
+ // it's missing. Failure-tolerated: no loadable graph → no concept force.
1360
+ let g = graph;
1361
+ if (!g && config && source) {
1362
+ try { g = parseEntities(await source.fetchEntities(config)); } catch { g = null; }
1363
+ }
1364
+ if (!g) return null;
1365
+ const rows = memoryDir ? await factRows(memoryDir) : [];
1366
+ let composed;
1367
+ try { composed = composeConcept(g, term, { definition, factRows: rows }); }
1368
+ catch { return null; }
1369
+ if (!composed) return null;
1370
+ const rendered = tRender(templates, T_CONCEPT, {
1371
+ definition: composed.definition, examples: composed.examples, followups: composed.followups,
1372
+ });
1373
+ const text = rendered ?? `${composed.definition}\n${composed.examples}${composed.followups}`;
1374
+ const pending = composed.remainder && composed.remainder.length
1375
+ ? { items: composed.remainder, noun: composed.noun }
1376
+ : null;
1377
+ return { text, instances: composed.instances, pending };
1378
+ }
1379
+
1211
1380
  /** A bare question → tmct_ask. When a focus is set AND the graph is in hand we
1212
1381
  * call ask() directly to thread the focus as contextId (so a pronoun like "it"
1213
1382
  * resolves to the focus) — building the SAME delimited string dispatchTool emits;
@@ -1270,6 +1439,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
1270
1439
  // orientation swap below is template wording, so those turns carry via:"template".
1271
1440
  let via = "composed";
1272
1441
  let recordMiss = miss;
1442
+ let factPending = null; // a truncated fact listing's held remainder (for "more" paging)
1273
1443
  // MISS handling. The intent lanes + short-miss are RECOGNIZER-gated on the query
1274
1444
  // text AND only consulted on a would-miss, so a real graph query — a hit, an honest
1275
1445
  // empty with a receipt, a fuzzy repair — is never hijacked. Order: (1) META/SELF
@@ -1301,6 +1471,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
1301
1471
  answer = fact.replace ? fact.text : `${answer}\n${fact.text}`;
1302
1472
  via = "fact";
1303
1473
  recordMiss = false;
1474
+ if (fact.pending) factPending = fact.pending; // a truncated fact list → paginable remainder
1304
1475
  } else if (miss) {
1305
1476
  // W2: after the honest miss is composed, consult the folded-session memory. A
1306
1477
  // relevant enough block ANSWERS — recalled Q/A framed + cited first, with the
@@ -1323,6 +1494,38 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
1323
1494
  const def = await curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon });
1324
1495
  if (def) { answer = def.text; via = "corpus/seon"; recordMiss = false; }
1325
1496
  }
1497
+ // THE CONCEPT FORCE (concept.mjs) — a vague "what is a X" / "tell me about X" that
1498
+ // names a KNOWN code concept WITH real instances composes the three-band answer
1499
+ // (definition + real examples + pre-validated follow-ups), superseding the bare
1500
+ // schema-doc / curated-definition surface (both corpus-sourced). It declines unless
1501
+ // the term is a known, instance-bearing concept, so a precise query, an unknown
1502
+ // term, or an instance-less concept is never hijacked — the ordinary answer stands.
1503
+ // Runs after the corpus-fact/curated branches (via composed|corpus/seon) but not
1504
+ // over a "you told me" fact, a meta/self summary, or the conversational lanes.
1505
+ let conceptInstances = null;
1506
+ let conceptPending = null;
1507
+ if (via === "composed" || via === "corpus/seon") {
1508
+ const concept = await conceptForceAnswer(query, envelope, { graph, config, source, memoryDir, templates });
1509
+ if (concept) {
1510
+ answer = concept.text; via = "corpus/seon"; recordMiss = false;
1511
+ conceptInstances = concept.instances;
1512
+ conceptPending = concept.pending;
1513
+ } else {
1514
+ // THE RELATION CONCEPT FORCE — the noun force declined, so try the edge-kind
1515
+ // touch ("what about imports", "what are the calls", "tell me about contains").
1516
+ // Same three-band shape over real EDGES; declines unless the term is a known,
1517
+ // edge-bearing relation, so a precise query / unknown word is never hijacked.
1518
+ // For a "what about <relation>" this also SUPERSEDES the discourse rewrite's
1519
+ // dead-end (rewriting the prior question with a relation word rarely resolves) —
1520
+ // but only when the touched word is a relation concept; a real entity name in
1521
+ // "what about X" declines here and the discourse continuation stands.
1522
+ const relation = await relationForceAnswer(query, envelope, { graph, config, source, templates });
1523
+ if (relation) {
1524
+ answer = relation.text; via = "corpus/seon"; recordMiss = false;
1525
+ conceptPending = relation.pending;
1526
+ }
1527
+ }
1528
+ }
1326
1529
  // (4) #2 TEACH lane — a teach-shaped would-miss nothing above answered: route to
1327
1530
  // memory, or say what CAN be remembered (LOUD), never the wall / a silent drop.
1328
1531
  if (miss && recordMiss && via === "composed") {
@@ -1339,7 +1542,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
1339
1542
  // toward a real graph, unless it already points there. Only when genuinely empty.
1340
1543
  if (recordMiss && (via === "composed" || via === "miss")
1341
1544
  && 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\`.)`;
1545
+ 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
1546
  }
1344
1547
  // W5 (flag-gated, default OFF): an unknown-term miss may consult the LOCAL
1345
1548
  // committed corpus slice — a hit APPENDS a grounded, licence-cited aside under
@@ -1351,11 +1554,23 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
1351
1554
  via = "corpus";
1352
1555
  }
1353
1556
  }
1354
- const record = { type: "turn", ts, query, via, resolvedIds, answeredIds, miss: recordMiss };
1557
+ // The concept force answers WITH real example instances those are the entities the
1558
+ // turn "asked about" (the SchemaClass meta-node is documentation, not a code entity),
1559
+ // so record + expand them, not the schema match.
1560
+ const finalAnsweredIds = conceptInstances ? conceptInstances.map((i) => i.id) : answeredIds;
1561
+ const record = { type: "turn", ts, query, via, resolvedIds, answeredIds: finalAnsweredIds, miss: recordMiss };
1355
1562
  const logLines = [ts, `> ${query}`, answer, ""];
1356
1563
  // `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;
1564
+ // matched entities the terse render trims (see renderVerbose). `pending` carries a
1565
+ // truncated listing's held remainder for "more" paging the concept/relation force
1566
+ // holds it on conceptPending (the relation force resolves no instance ids, so it can
1567
+ // still page even with an empty matches set); a fact listing holds it on factPending.
1568
+ const pending = conceptPending ?? factPending;
1569
+ const detail = conceptInstances
1570
+ ? { traversal: envelope?.traversal || null, matches: conceptInstances, ...(pending ? { pending } : {}) }
1571
+ : (envelope
1572
+ ? { traversal: envelope.traversal || null, matches: envelope.matches || [], ...(pending ? { pending } : {}) }
1573
+ : (pending ? { traversal: null, matches: [], pending } : null));
1359
1574
  return { answer, logLines, record, focus: newFocus, detail };
1360
1575
  }
1361
1576
 
@@ -1482,6 +1697,29 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null })
1482
1697
  * subject), `answeredIds` the entity ids an ask answer cited; a slash-command turn
1483
1698
  * also carries its `command` name. Both drive the mgx:asksAbout graph append.
1484
1699
  */
1700
+ // ---- "more" pagination — a long examples/facts listing shows the first PAGE
1701
+ // entries and holds the remainder on the turn's `last.detail.pending`; a bare
1702
+ // "more"/"show more"/"the rest" in the NEXT turn renders the next batch, advancing
1703
+ // the same pending state. Any other (real) query produces a fresh `last` without
1704
+ // `pending`, so the remainder is naturally cleared — no stale continuation. ----
1705
+ const PAGE = 32;
1706
+ const MORE_RE = /^(?:more|show more|see more|the rest|next|continue|go on)\b[.!?]*$/i;
1707
+ const joinList = (a) => (a.length > 1 ? `${a.slice(0, -1).join(", ")} and ${a[a.length - 1]}` : (a[0] ?? ""));
1708
+
1709
+ /** Render the next page of a held remainder (pending: {items:[str], noun}). Returns a
1710
+ * plain turn whose `detail.pending` carries what's still unseen (null when the batch
1711
+ * finished the list), so a follow-on "more" continues. */
1712
+ function morePage(query, { last, focus }) {
1713
+ const p = last.detail.pending;
1714
+ const batch = p.items.slice(0, PAGE);
1715
+ const rest = p.items.slice(PAGE);
1716
+ const tail = rest.length ? ` …and ${rest.length} more — say 'more' to see them.` : "";
1717
+ const answer = `${joinList(batch)}.${tail}`;
1718
+ const turn = plainTurn(query, answer, { via: "count", focus });
1719
+ turn.detail = { traversal: null, matches: [], ...(rest.length ? { pending: { items: rest, noun: p.noun } } : {}) };
1720
+ return turn;
1721
+ }
1722
+
1485
1723
  export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null } = {}) {
1486
1724
  const line = String(input ?? "").trim();
1487
1725
  const templates = await chatTemplates(); // failure-tolerated: null degrades, never throws
@@ -1511,6 +1749,13 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
1511
1749
  const convo = conversationalTurn(line, ctx);
1512
1750
  if (convo) return convo;
1513
1751
 
1752
+ // "more" — page the remainder of a previous long listing, if one is held. Gated on
1753
+ // an actual pending remainder so a bare "more" with nothing to continue falls through
1754
+ // to the ordinary path (an honest miss), never a pretend page.
1755
+ if (MORE_RE.test(line) && Array.isArray(last?.detail?.pending?.items) && last.detail.pending.items.length) {
1756
+ return withLast(morePage(line, ctx));
1757
+ }
1758
+
1514
1759
  if (line.startsWith("/")) return withLast(await runCommand(line, ctx));
1515
1760
  // Declarative ACE sentences ("every module is a artifact") ASSERT into tmct's
1516
1761
  // own memory and confirm — they are statements to remember, not graph queries.
@@ -1546,15 +1791,20 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
1546
1791
 
1547
1792
  // ---- W3: seedMemory → bootstrap (first run in a graph-less repo) ----
1548
1793
 
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. */
1794
+ /** The first-run bootstrap seeds the WHOLE shipped ConceptNet band (no cap) — the
1795
+ * operator's "seed all 40k" call. `undefined` means seedMemory writes every
1796
+ * seedable fact in the committed slice (~6.3k). The batched appendFacts write
1797
+ * (src/memory/core.mjs) makes this a single O(N) pass the full slice seeds in a
1798
+ * couple of seconds, inside a session-start budget, so it still runs synchronously
1799
+ * and complete. A finite value here would re-impose the old cap; keep it undefined
1800
+ * to mean "all". (Kept as a named export so init.mjs and tests share the intent.) */
1801
+ export const SEED_LIMIT = undefined;
1802
+
1803
+ /** Which predicates the seed lists FIRST (stable order — see seedMemory's `prefer`):
1804
+ * the definitional band leads, so the on-disk memory opens with the vocabulary that
1805
+ * answers "what is a cache?"-style questions rather than location trivia. With the
1806
+ * cap lifted this only sets ORDER (every fact seeds either way), but a well-ordered
1807
+ * memory keeps inspection and any future re-cap honest. */
1558
1808
  export const SEED_PREFER = ["rdfs:subClassOf", "rdf:type", "mgx:usedFor", "mgx:partOf", "mgx:capableOf"];
1559
1809
 
1560
1810
  /** The seed marker: its presence means this repo's memory already carries the
@@ -1653,7 +1903,15 @@ export async function createSession({
1653
1903
  env = process.env,
1654
1904
  cwd = process.cwd(),
1655
1905
  gitRoot = gitToplevel,
1906
+ ephemeral = false,
1656
1907
  } = {}) {
1908
+ // EPHEMERAL mode (--ephemeral, or TMCT_EPHEMERAL=1): read the target graph but
1909
+ // write NOTHING back into it. The shipped examples run this way so a demo never
1910
+ // dirties the committed code graph (`npm run example:mini` used to fold a session
1911
+ // into examples/*/.tmct/graph.json and rewrite it). We still read config.graphFile
1912
+ // for structure; only the WRITE base (logs, memory, sessions) is diverted to an OS
1913
+ // temp dir and the read-time graph upsert is suppressed.
1914
+ ephemeral = ephemeral || /^(1|true|yes)$/i.test(String(env.TMCT_EPHEMERAL || ""));
1657
1915
  // Graph resolution order for the chat surface (documented; --repo wins):
1658
1916
  // 1. --repo <path> → pins <path>/.tmct/graph.json (repo AND graph).
1659
1917
  // 2. TMCT_GRAPH_FILE env → loads that graph anywhere (loadConfig reads it), so
@@ -1677,6 +1935,11 @@ export async function createSession({
1677
1935
  config = envGraph ? loadConfig(env, cwd) : { graphFile: join(repo, DEFAULT_GRAPH_REL) };
1678
1936
  }
1679
1937
 
1938
+ // Ephemeral: keep config.graphFile pointing at the READ graph, but divert the
1939
+ // write base (repo → logs/memory/sessions) to a throwaway temp dir. The committed
1940
+ // target is never touched; the demo's memory simply doesn't persist across runs.
1941
+ if (ephemeral) repo = await mkdtemp(join(tmpdir(), "tmct-ephemeral-"));
1942
+
1680
1943
  // Load the graph once up front — the banner needs the module count, and focus/`it`
1681
1944
  // resolution and contextId threading need it in hand. A missing artifact loads as
1682
1945
  // the empty bootstrap graph (source.mjs) — the banner says so; never an error.
@@ -1723,6 +1986,7 @@ export async function createSession({
1723
1986
  // artifact mid-session must degrade the recording, never kill the chat.
1724
1987
  const turnRecords = [];
1725
1988
  const upsertGraph = async (ended) => {
1989
+ if (ephemeral) return; // a demo/read-only session never writes back to the graph
1726
1990
  if (!turnRecords.length) return; // a zero-turn session never pollutes the graph
1727
1991
  try { await appendSessionToGraph(config.graphFile, { id: sessionId, started: startIso, ended, turns: turnRecords }); }
1728
1992
  catch { /* best-effort — see above */ }
@@ -1752,8 +2016,10 @@ export async function createSession({
1752
2016
  // the honest seed line appears ONLY on the run that actually seeded — the count
1753
2017
  // is the TOTAL appended, split into the curated SEON ontology + the ConceptNet band.
1754
2018
  ...(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"'] : []),
2019
+ // no code graph → point at how to GET one (a graph producer / --repo / the shipped
2020
+ // example), honest that `tmct init` seeds VOCABULARY, not a code graph, and at what
2021
+ // IS answerable now. tmct reads graphs; it never indexes code itself.
2022
+ ...(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
2023
  "pass --repo <path> to target a different repo",
1758
2024
  "ask a question, or /help for commands (/stats for an overview) — /exit to leave",
1759
2025
  ];
@@ -1826,8 +2092,9 @@ export async function runChat({
1826
2092
  env = process.env,
1827
2093
  cwd = process.cwd(),
1828
2094
  gitRoot = gitToplevel,
2095
+ ephemeral = false,
1829
2096
  } = {}) {
1830
- const session = await createSession({ repoPath, source, env, cwd, gitRoot });
2097
+ const session = await createSession({ repoPath, source, env, cwd, gitRoot, ephemeral });
1831
2098
 
1832
2099
  const dim = (s) => (env.NO_COLOR || !output.isTTY ? s : `\x1b[2m${s}\x1b[0m`);
1833
2100
  for (const line of session.bannerLines) output.write(dim(line) + "\n");