@polycode-projects/the-mechanical-code-talker 0.5.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.
- package/README.md +91 -27
- package/ROADMAP.md +45 -0
- package/bin/tmct.mjs +64 -2
- package/corpus/README.md +77 -5
- package/corpus/conceptnet/README.md +92 -33
- package/corpus/conceptnet/filter-dump.mjs +59 -2
- package/corpus/conceptnet/quality-filter.mjs +95 -0
- package/corpus/conceptnet/slice.jsonl +31067 -378
- package/corpus/seon/LICENSE-NOTICE +37 -0
- package/corpus/seon/README.md +121 -0
- package/corpus/seon/concepts.jsonl +238 -0
- package/corpus/seon/definitions.jsonl +288 -0
- package/corpus/seon/relations.jsonl +8 -0
- package/corpus/tier2/aws.jsonl +39 -0
- package/corpus/tier2/generate.mjs +253 -0
- package/corpus/tier2/java.jsonl +31 -0
- package/corpus/tier2/manifest.json +48 -0
- package/corpus/tier2/python.jsonl +30 -0
- package/data/templates/responses.jsonl +3 -0
- package/package.json +13 -2
- package/src/ask-vocab.mjs +19 -1
- package/src/ask.mjs +13 -13
- package/src/chat.mjs +759 -66
- package/src/codegraph.mjs +28 -5
- package/src/concept.mjs +393 -0
- package/src/conformance.mjs +166 -0
- package/src/corpus/conceptnet.mjs +42 -19
- package/src/init.mjs +12 -9
- package/src/interpret/normalize.mjs +42 -0
- package/src/interpret/pipeline.mjs +2 -2
- package/src/memory/core.mjs +84 -0
- package/src/memory/inspect.mjs +25 -0
- package/src/server.mjs +88 -7
- package/src/tui/app.mjs +26 -2
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";
|
|
@@ -104,6 +105,37 @@ export const COMMANDS = {
|
|
|
104
105
|
* back to the focus for, and that update the focus on a successful resolve. */
|
|
105
106
|
const ENTITY_ARGS = new Set(["symbol", "module", "class"]);
|
|
106
107
|
|
|
108
|
+
/** System-command words that a forgiving shell accepts WITHOUT the leading "/":
|
|
109
|
+
* `stats`, `memory`, `describe X`, `members X`, … all work bare. "help" is left
|
|
110
|
+
* out on purpose — bare "help" stays the friendly orientation; "/help" is the
|
|
111
|
+
* full command list. */
|
|
112
|
+
const COMMAND_WORDS = new Set(["stats", "memory", "focus", ...Object.keys(COMMANDS)]);
|
|
113
|
+
|
|
114
|
+
/** Query connectives that mark a line as a COMPOSITIONAL question the ask engine
|
|
115
|
+
* should own, even when it happens to start with a command word ("untested modules
|
|
116
|
+
* IMPORTING x", "find functions THAT CALL y"). Their presence blocks slash-routing. */
|
|
117
|
+
const QUERY_CONNECTIVES = /\b(that|which|and|or|imports?|importing|calls?|calling|uses?|using|covers?|covering|tests?|testing|touch(?:es|ed|ing)?|inherits?|of|with|from|into|by|most|least)\b/i;
|
|
118
|
+
|
|
119
|
+
/** A bare leading command word → its slash form ("stats" → "/stats", "describe x"
|
|
120
|
+
* → "/describe x"), so the system commands are slash-optional. Conservative on the
|
|
121
|
+
* entity/arg commands: it routes a bare word or a SHORT name-like argument, but
|
|
122
|
+
* falls through (returns null) for a multi-word compositional query so the ask
|
|
123
|
+
* engine still owns things like "untested modules importing a.mjs". Returns null
|
|
124
|
+
* when the first token is not a command word. */
|
|
125
|
+
export function asBareCommand(line) {
|
|
126
|
+
const trimmed = String(line || "").trim();
|
|
127
|
+
if (!trimmed || trimmed.startsWith("/")) return null;
|
|
128
|
+
const [first, ...restTok] = trimmed.split(/\s+/);
|
|
129
|
+
if (!COMMAND_WORDS.has(first.toLowerCase())) return null;
|
|
130
|
+
const rest = restTok.join(" ");
|
|
131
|
+
// Zero-arg system commands are always the command; a bare command word is too.
|
|
132
|
+
if (!rest || first.toLowerCase() === "stats" || first.toLowerCase() === "memory") return `/${trimmed}`;
|
|
133
|
+
// Arg commands: route only a short, name-like argument (no query connectives),
|
|
134
|
+
// so "describe Widget" / "members my class" route but a compositional query does not.
|
|
135
|
+
if (restTok.length <= 3 && !QUERY_CONNECTIVES.test(rest)) return `/${trimmed}`;
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
107
139
|
// ---- aggregate / count queries — answered MECHANICALLY off the loaded graph
|
|
108
140
|
// header (individuals grouped by class, relation groups by predicate), not by
|
|
109
141
|
// dispatching to the ask engine. Deterministic, fully in-ethos. ----
|
|
@@ -191,6 +223,45 @@ async function countFromFacts(graph, memoryDir, query) {
|
|
|
191
223
|
return null;
|
|
192
224
|
}
|
|
193
225
|
|
|
226
|
+
// ---- memory-store counts (the .tmct/memory graph, distinct from the code graph
|
|
227
|
+
// answerCount reads) — so "how many facts do you know" is answerable, consistent
|
|
228
|
+
// with what `/memory` advertises. The code graph owns the structural kinds
|
|
229
|
+
// (classes/functions/modules/…); the memory store owns Facts + Utterances. Sessions
|
|
230
|
+
// stay with answerCount (chat writes Session individuals into the code graph as
|
|
231
|
+
// first-class temporal data — see sessions.mjs), so this never shadows them. ----
|
|
232
|
+
|
|
233
|
+
/** Nouns that name a MEMORY-STORE individual class, → the class to count. */
|
|
234
|
+
const MEMORY_COUNT_NOUNS = {
|
|
235
|
+
fact: "Fact", facts: "Fact",
|
|
236
|
+
utterance: "Utterance", utterances: "Utterance", said: "Utterance",
|
|
237
|
+
};
|
|
238
|
+
const MEMORY_CLASS_LABELS = { Fact: ["fact", "facts"], Utterance: ["utterance", "utterances"] };
|
|
239
|
+
|
|
240
|
+
/** Recognise a memory-store count question and answer it by loading the memory
|
|
241
|
+
* graph, or null (→ answerCount / the ask engine own it). Handles "how many facts",
|
|
242
|
+
* "how many utterances", and the bare "how many do you know" (→ facts). Lazy +
|
|
243
|
+
* failure-tolerated: no memory / a broken store → null, so the honest fall-through
|
|
244
|
+
* stands. */
|
|
245
|
+
async function answerMemoryCount(memoryDir, query) {
|
|
246
|
+
if (!memoryDir) return null;
|
|
247
|
+
const q = String(query).toLowerCase();
|
|
248
|
+
let cls = null;
|
|
249
|
+
// the bare "how many do you know" (no explicit noun) defaults to remembered facts
|
|
250
|
+
if (/\bhow many(?:\s+(?:things?|facts?))?\s+(?:do|d'?)\s+(?:you|u)\s+know\b/.test(q)) cls = "Fact";
|
|
251
|
+
if (!cls) {
|
|
252
|
+
const m = q.match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b/);
|
|
253
|
+
if (m) cls = MEMORY_COUNT_NOUNS[m[1]] || null;
|
|
254
|
+
}
|
|
255
|
+
if (!cls) return null;
|
|
256
|
+
let loadMemory;
|
|
257
|
+
try { ({ loadMemory } = await import("./memory/core.mjs")); } catch { return null; }
|
|
258
|
+
let mem;
|
|
259
|
+
try { mem = await loadMemory(memoryDir); } catch { return null; }
|
|
260
|
+
const n = (mem.individuals || []).filter((i) => (i.class || "") === cls).length;
|
|
261
|
+
const [sing, plur] = MEMORY_CLASS_LABELS[cls];
|
|
262
|
+
return `${n} ${n === 1 ? sing : plur}.`;
|
|
263
|
+
}
|
|
264
|
+
|
|
194
265
|
/** `/stats`: a one-screen overview of the graph — class counts, relationship
|
|
195
266
|
* (predicate) counts, and module/package totals — read straight off the header. */
|
|
196
267
|
export function renderStats(graph) {
|
|
@@ -234,6 +305,12 @@ const STRUCT_WORDS = new Set([
|
|
|
234
305
|
"subclass", "subclasses", "inherit", "inherits", "test", "tests", "touch", "touches",
|
|
235
306
|
"commit", "commits", "export", "exports", "caller", "callers", "callee", "callees",
|
|
236
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",
|
|
237
314
|
]);
|
|
238
315
|
|
|
239
316
|
/** Does this look like small-talk / an orientation request rather than a
|
|
@@ -269,6 +346,15 @@ const T_THANKS = "conversational-thanks";
|
|
|
269
346
|
const T_FAREWELL = "conversational-farewell";
|
|
270
347
|
const T_ORIENTATION = "orientation-friendly";
|
|
271
348
|
const T_WHY_EMPTY = "miss-no-previous-answer";
|
|
349
|
+
/** Empty / degenerate-graph variants (#3/#5): shown when the loaded graph has 0
|
|
350
|
+
* modules (a graph-less bootstrap OR a graph.json with no code entities). They
|
|
351
|
+
* orient toward `--repo`/`tmct init` + the seeded vocabulary instead of
|
|
352
|
+
* over-promising "ask me about this codebase". */
|
|
353
|
+
const T_GREETING_EMPTY = "conversational-greeting-empty";
|
|
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";
|
|
272
358
|
|
|
273
359
|
/** The degraded line when the template library itself cannot load — a packaging
|
|
274
360
|
* failure said out loud, never a crashed turn or a silently different answer. */
|
|
@@ -369,9 +455,189 @@ function conversationalTurn(line, ctx) {
|
|
|
369
455
|
if (v.empty) return mk(tRender(ctx.templates, T_WHY_EMPTY) ?? v.text, { miss: true });
|
|
370
456
|
return mk(v.text, { via: "conversational" });
|
|
371
457
|
}
|
|
372
|
-
if (GREET.has(q))
|
|
458
|
+
if (GREET.has(q)) {
|
|
459
|
+
// #3 empty/degenerate-graph greeting: a plain "hi"/"hello" over a graph with 0
|
|
460
|
+
// modules orients toward --repo/tmct init instead of over-promising "ask me
|
|
461
|
+
// about this codebase". Phrase-specific variants (good morning, hello there)
|
|
462
|
+
// keep their wording; only the default greeting swaps.
|
|
463
|
+
const id = (!T_GREETING_BY_PHRASE[q] && noCodeGraph(ctx.graph)) ? T_GREETING_EMPTY : (T_GREETING_BY_PHRASE[q] || T_GREETING);
|
|
464
|
+
return mk(t(id));
|
|
465
|
+
}
|
|
373
466
|
if (THANKS.has(q)) return mk(t(T_THANKS));
|
|
374
|
-
if (q === "help" || q === "?" || HELP_PHRASES.some((re) => re.test(raw))) return mk(
|
|
467
|
+
if (q === "help" || q === "?" || HELP_PHRASES.some((re) => re.test(raw))) return mk(orientationAnswer(ctx.templates, ctx.graph));
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ---- #1/#2/#3 conversational-UX helpers: module-aware orientation, the short
|
|
472
|
+
// tailored miss, and the intent lanes (teach + meta/self). All are recognizer-
|
|
473
|
+
// gated and (for the lanes) only consulted on a would-miss, so ordinary graph
|
|
474
|
+
// queries are never hijacked. ----
|
|
475
|
+
|
|
476
|
+
/** Code entities (Modules) in the loaded graph — the "is there a code graph here"
|
|
477
|
+
* test. 0 means a graph-less bootstrap OR a graph.json with no code entities (the
|
|
478
|
+
* degenerate trap); both orient rather than over-promise. */
|
|
479
|
+
export function moduleCountOf(graph) {
|
|
480
|
+
if (!graph || !Array.isArray(graph.individuals)) return 0;
|
|
481
|
+
return graph.individuals.filter((i) => (i.class || "") === "Module").length;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** A KNOWN-empty code graph: a loaded graph object with 0 modules. A null graph
|
|
485
|
+
* (a bare runTurn that wasn't handed one) is "unknown", NOT empty — the empty
|
|
486
|
+
* orientation/greeting only fires when we actually hold an empty graph. */
|
|
487
|
+
const noCodeGraph = (graph) => !!graph && moduleCountOf(graph) === 0;
|
|
488
|
+
|
|
489
|
+
/** The orientation surface, module-aware: the empty variant (→ --repo/tmct init +
|
|
490
|
+
* seeded vocabulary) when there's no code graph, the standard one otherwise. */
|
|
491
|
+
function orientationAnswer(templates, graph) {
|
|
492
|
+
return tRender(templates, noCodeGraph(graph) ? T_ORIENTATION_EMPTY : T_ORIENTATION) ?? TEMPLATES_UNAVAILABLE;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** A dynamic orientation string for the meta/self lane: a /stats-style overview
|
|
496
|
+
* when a code graph is loaded, else the honest empty-graph orientation. */
|
|
497
|
+
function orientationText(graph) {
|
|
498
|
+
if (noCodeGraph(graph)) {
|
|
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.';
|
|
503
|
+
}
|
|
504
|
+
const by = (cls) => (graph.individuals || []).filter((i) => (i.class || "") === cls).length;
|
|
505
|
+
const parts = [];
|
|
506
|
+
for (const [cls, sing, plur] of [["Module", "module", "modules"], ["Class", "class", "classes"], ["Function", "function", "functions"]]) {
|
|
507
|
+
const n = by(cls); if (n) parts.push(`${n} ${n === 1 ? sing : plur}`);
|
|
508
|
+
}
|
|
509
|
+
return `This is a tmct code graph — ${(graph.individuals || []).length} entities`
|
|
510
|
+
+ `${parts.length ? ` (${parts.join(", ")})` : ""}. `
|
|
511
|
+
+ 'Ask about imports, calls, definitions or history — e.g. "which modules import <name>", "what calls <name>". '
|
|
512
|
+
+ "/stats for the full overview, /help for commands.";
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// #1 SHORT, TAILORED MISS — the engine's full grammar cheat-sheet (rephraseHint)
|
|
516
|
+
// now lives ONLY behind /help. A genuine parse-miss gets ONE line: an honest miss
|
|
517
|
+
// + at most two example shapes chosen for what the user typed + a /help pointer.
|
|
518
|
+
// The opening "couldn't parse this as a graph question. Try:" is preserved (the
|
|
519
|
+
// honest-miss contract + the graded hm-joke case pin those words).
|
|
520
|
+
const MISS_EXAMPLES = {
|
|
521
|
+
import: ['"which modules import <name>"', '"what does <name> import"'],
|
|
522
|
+
export: ['"what does <name> export"', '"which modules import <name>"'],
|
|
523
|
+
call: ['"what calls <name>"', '"which functions call <name>"'],
|
|
524
|
+
test: ['"what tests <name>"', '"which functions are tested"'],
|
|
525
|
+
inherit: ['"which classes inherit from <name>"', '"what are the subclasses of <name>"'],
|
|
526
|
+
history: ['"when did <name> change"', '"who touched <name>"'],
|
|
527
|
+
define: ['"where is <name> defined"', '"where is <name> mentioned"'],
|
|
528
|
+
meaning: ['"what is a <ClassName>"', '"what does <term> mean"'],
|
|
529
|
+
count: ['"how many classes are there"', '"how many modules are there"'],
|
|
530
|
+
};
|
|
531
|
+
const MISS_DEFAULT = ['"which modules import <name>"', '"what calls <name>"'];
|
|
532
|
+
|
|
533
|
+
/** Choose up to two example shapes RELEVANT to the user's words. */
|
|
534
|
+
function tailoredExamples(q) {
|
|
535
|
+
// membership yes/no ("is a algorithm information") — the grammar wants an article
|
|
536
|
+
// before BOTH terms; hint the working shape rather than dumping the wall.
|
|
537
|
+
if (/^is\s+(?:an?\s+)?[\w-]+\b/.test(q)) return ['"is a <thing> a <kind>" (an article before the kind, too)'];
|
|
538
|
+
const has = (re) => re.test(q);
|
|
539
|
+
if (has(/\bimport/)) return MISS_EXAMPLES.import;
|
|
540
|
+
if (has(/\bexport/)) return MISS_EXAMPLES.export;
|
|
541
|
+
if (has(/\b(?:calls?|caller|callee)\b/)) return MISS_EXAMPLES.call;
|
|
542
|
+
if (has(/\b(?:tests?|cover|covering|tested)\b/)) return MISS_EXAMPLES.test;
|
|
543
|
+
if (has(/\b(?:inherit|subclass|extends?|superclass|hierarchy|base class|parent class)\b/)) return MISS_EXAMPLES.inherit;
|
|
544
|
+
if (has(/\b(?:history|when|changed?|commit|touch(?:e[ds])?|who)\b/)) return MISS_EXAMPLES.history;
|
|
545
|
+
if (has(/\b(?:defined?|where|located?|mention)\b/)) return MISS_EXAMPLES.define;
|
|
546
|
+
if (has(/\b(?:mean|means|meaning|definition|vocab)\b/) || /\bwhat(?:'s| is)? an? \w/.test(q)) return MISS_EXAMPLES.meaning;
|
|
547
|
+
if (has(/\b(?:how many|count|number of)\b/)) return MISS_EXAMPLES.count;
|
|
548
|
+
return MISS_DEFAULT;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** The one-line short miss. */
|
|
552
|
+
export function shortMissHint(query) {
|
|
553
|
+
const ex = tailoredExamples(String(query || "").toLowerCase());
|
|
554
|
+
return `couldn't parse this as a graph question. Try: ${ex.join(" or ")}. Type /help for all query shapes.`;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** The exact opening of the engine's full grammar-wall miss — the ONLY miss the
|
|
558
|
+
* short-miss rewrites. Receipt-bearing misses (honest empties, unresolved terms,
|
|
559
|
+
* the empty-graph bootstrap note, compositional misses) never match, so their
|
|
560
|
+
* specific wording + traversal receipts stand. */
|
|
561
|
+
const WALL_MISS_RE = /^couldn't parse this as a graph question\. Try:/;
|
|
562
|
+
|
|
563
|
+
// #2 INTENT LANE — MEMORY/TEACH. "remember that X is a Y", "note that …", or a
|
|
564
|
+
// bare "X is a Y" declarative the graph parser couldn't handle → route to the
|
|
565
|
+
// assert/memory path; when it can't be stored, say what CAN be remembered
|
|
566
|
+
// (LOUD, the working shape) — never the grammar wall, never a silent data loss.
|
|
567
|
+
const TEACH_RE = /^(?:please\s+)?(?:remember|note|keep in mind|jot down|for the record|fyi)\b[:,]?\s*(?:that\s+)?(.+?)[.?!]*$/i;
|
|
568
|
+
const BARE_DECLARATIVE_RE = /^(?:every |each |all |a |an )?[\w-]+ (?:is|are) (?:a |an )?[\w-]+$/i;
|
|
569
|
+
/** Interrogative / auxiliary leads that make an "X is a Y"-shaped line a QUESTION
|
|
570
|
+
* ("what is a cache", "is a module a component"), never a teach declarative. */
|
|
571
|
+
const QUESTION_LEAD_RE = /^(?:what|who|which|where|when|why|how|is|are|do|does|did|can|could|should|would|will|has|have)\b/i;
|
|
572
|
+
|
|
573
|
+
/** Sentence forms to try asserting for a teach payload: the payload as-is, and
|
|
574
|
+
* (if it carries no determiner) its "every …" universal — the ACE-OWL shape the
|
|
575
|
+
* grammar actually lands. */
|
|
576
|
+
function assertCandidates(payload) {
|
|
577
|
+
const p = String(payload).trim();
|
|
578
|
+
const out = [p];
|
|
579
|
+
if (!/^(?:every|each|all|a|an)\b/i.test(p)) out.push(`every ${p}`);
|
|
580
|
+
return [...new Set(out)];
|
|
581
|
+
}
|
|
582
|
+
/** The "every X is a Y" rewrite of a declarative, for the "did you mean …" hint. */
|
|
583
|
+
function teachSuggestion(payload) {
|
|
584
|
+
const m = String(payload).match(/^(?:every |each |all |a |an )?([\w-]+) (?:is|are) (?:a |an )?([\w-]+)$/i);
|
|
585
|
+
return m ? `every ${m[1].toLowerCase()} is a ${m[2].toLowerCase()}` : null;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
589
|
+
const raw = String(query).trim();
|
|
590
|
+
let payload = null;
|
|
591
|
+
const m = raw.match(TEACH_RE);
|
|
592
|
+
if (m && /\b(?:is|are)\b/i.test(m[1])) payload = m[1].trim();
|
|
593
|
+
else if (BARE_DECLARATIVE_RE.test(raw) && !QUESTION_LEAD_RE.test(raw)) payload = raw;
|
|
594
|
+
if (!payload) return null;
|
|
595
|
+
// Try to store it (a live session provides the write target). assertTurn returns
|
|
596
|
+
// the "noted — remembered …" confirmation or null (grammar miss / unknown words).
|
|
597
|
+
if (memoryDir) {
|
|
598
|
+
for (const cand of assertCandidates(payload)) {
|
|
599
|
+
const stored = await assertTurn(cand, { memoryDir, sessionId, focus: null, lexicon });
|
|
600
|
+
if (stored) return { text: stored.answer, via: "assert", miss: false };
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
const suggestion = teachSuggestion(payload);
|
|
604
|
+
const did = suggestion && suggestion !== payload.toLowerCase() ? ` Did you mean: "${suggestion}"?` : "";
|
|
605
|
+
return {
|
|
606
|
+
text: 'I couldn\'t store that — I remember facts in the shape "every X is a Y", where X and Y are '
|
|
607
|
+
+ `words I know.${did} Type /memory to see what I already remember.`,
|
|
608
|
+
via: "teach-miss", miss: true,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// #2 INTENT LANE — META/SELF. Bare self/session questions answered from stats /
|
|
613
|
+
// memory / orientation, never the grammar wall or the raw fact-dump. WOULD-MISS
|
|
614
|
+
// ONLY (the caller gates on a miss) and every pattern is a WHOLE-LINE self/session
|
|
615
|
+
// reference with no graph entity or predicate, so real graph queries ("what does X
|
|
616
|
+
// import", the meta "what does imports mean", "what did i ask before") never match.
|
|
617
|
+
const WHAT_KNOW_RE = /^what\s+(?:do\s+you|d'?you)\s+know(?:\s+so\s+far)?$/;
|
|
618
|
+
const META_ORIENT_RE = /^(?:what(?:'s| is| are)?\s+this(?:\s+(?:codebase|repo|repository|project|code|thing))?|what\s+(?:codebase|repo|repository|project)\s+is\s+this|what\s+am\s+i\s+looking\s+at|what\s+is\s+tmct|how\s+do\s+i\s+(?:start|begin|get\s+started|get\s+going|load\s+(?:my\s+)?code|index\s+(?:my\s+)?(?:code|repo|repository)|use\s+(?:this|you|tmct))|where\s+do\s+i\s+(?:start|begin))$/;
|
|
619
|
+
|
|
620
|
+
/** A SHORT memory summary (never a fact dump) for the bare "what do you know". */
|
|
621
|
+
async function memorySummary(memoryDir, graph) {
|
|
622
|
+
const rows = memoryDir ? await memoryFacts(memoryDir) : [];
|
|
623
|
+
if (!rows.length) {
|
|
624
|
+
const hook = moduleCountOf(graph) > 0
|
|
625
|
+
? 'ask about this codebase\'s structure (imports, calls, definitions), or teach me with "every X is a Y"'
|
|
626
|
+
: 'teach me with "every X is a Y", or try general vocabulary like "what is a cache"';
|
|
627
|
+
return `I haven't been told any facts yet — ${hook}. /memory to inspect, /help for commands.`;
|
|
628
|
+
}
|
|
629
|
+
const preds = new Set(rows.map((f) => f.predicate).filter(Boolean));
|
|
630
|
+
const n = rows.length;
|
|
631
|
+
return `I remember ${n} fact${n === 1 ? "" : "s"} across ${preds.size} relation `
|
|
632
|
+
+ `type${preds.size === 1 ? "" : "s"}. Ask "what do you know about <term>", or /memory to explore.`;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async function metaLane(query, { graph, memoryDir }) {
|
|
636
|
+
const q = String(query).trim().toLowerCase().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
|
|
637
|
+
if (WHAT_KNOW_RE.test(q) || q === "what have you learned" || q === "what have you learnt") {
|
|
638
|
+
return { text: await memorySummary(memoryDir, graph), via: "meta" };
|
|
639
|
+
}
|
|
640
|
+
if (META_ORIENT_RE.test(q)) return { text: orientationText(graph), via: "meta" };
|
|
375
641
|
return null;
|
|
376
642
|
}
|
|
377
643
|
|
|
@@ -543,11 +809,18 @@ const FACT_PREDICATE_PHRASES = {
|
|
|
543
809
|
};
|
|
544
810
|
const factPhrase = (f) => `${f.subject} ${FACT_PREDICATE_PHRASES[f.predicate] || f.predicate} ${f.object}`;
|
|
545
811
|
|
|
546
|
-
/** One rendered fact line
|
|
547
|
-
* provenance
|
|
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. */
|
|
548
817
|
function renderFactLine(f) {
|
|
549
|
-
const
|
|
550
|
-
|
|
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}`;
|
|
551
824
|
}
|
|
552
825
|
|
|
553
826
|
/** Read every reified Fact out of the memory graph as plain {subject, predicate,
|
|
@@ -597,8 +870,8 @@ const ISA_ASK_RE = /^(?:is|are)\s+(?:an?\s+)?(.+?)\s+(?:a\s+kind\s+of|a\s+type\s
|
|
|
597
870
|
const ISA_PREDICATES = new Set(["rdfs:subClassOf", "rdf:type"]);
|
|
598
871
|
/** "what do you know about caches" — the open recall-everything form. */
|
|
599
872
|
const KNOW_ABOUT_RE = /^what\s+do\s+you\s+know\s+about\s+(.+?)[?.!\s]*$/i;
|
|
600
|
-
/** How many facts a single answer lists before
|
|
601
|
-
const FACT_ANSWER_CAP =
|
|
873
|
+
/** How many facts a single answer lists before the remainder is paged with "more". */
|
|
874
|
+
const FACT_ANSWER_CAP = 32;
|
|
602
875
|
|
|
603
876
|
/** W4 seam: answer (or extend) a vocabulary/definition question from the MEMORY
|
|
604
877
|
* graph's Facts. Returns { text, replace } — `replace:false` means the engine's
|
|
@@ -625,9 +898,11 @@ async function factAnswer(memoryDir, query, envelope, miss) {
|
|
|
625
898
|
const variants = factTermVariants(normFactTerm, metaTerm);
|
|
626
899
|
const hits = (await memoryFacts(memoryDir)).filter((f) => variants.has(f.subject));
|
|
627
900
|
if (!hits.length) return null;
|
|
628
|
-
const
|
|
629
|
-
const
|
|
630
|
-
|
|
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" } } : {}) };
|
|
631
906
|
}
|
|
632
907
|
if (!miss) return null;
|
|
633
908
|
|
|
@@ -652,9 +927,11 @@ async function factAnswer(memoryDir, query, envelope, miss) {
|
|
|
652
927
|
if (!hits.length) return null;
|
|
653
928
|
// echo the STORED spelling ("caches" asked → "cache" known), never a guess
|
|
654
929
|
const term = variants.has(hits[0].subject) ? hits[0].subject : hits[0].object;
|
|
655
|
-
const
|
|
656
|
-
const
|
|
657
|
-
|
|
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" } } : {}) };
|
|
658
935
|
}
|
|
659
936
|
return null;
|
|
660
937
|
}
|
|
@@ -708,10 +985,11 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null) {
|
|
|
708
985
|
const isa = rows.filter((f) => ISA_PREDICATES.has(f.predicate));
|
|
709
986
|
const byTrust = (a, b) => b.trust - a.trust;
|
|
710
987
|
const renderMany = (hits) => {
|
|
711
|
-
const
|
|
712
|
-
const
|
|
713
|
-
const
|
|
714
|
-
|
|
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" } } : {}) };
|
|
715
993
|
};
|
|
716
994
|
|
|
717
995
|
// (d) WHOLE-STORE recall (CHATBENCH_006 lever 3) — "what did i tell you last time",
|
|
@@ -746,9 +1024,11 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null) {
|
|
|
746
1024
|
const hits = rows.filter((f) => variants.has(f.subject) || variants.has(f.object)).sort(byTrust);
|
|
747
1025
|
if (!hits.length) return null;
|
|
748
1026
|
const term = variants.has(hits[0].subject) ? hits[0].subject : hits[0].object;
|
|
749
|
-
const
|
|
750
|
-
const
|
|
751
|
-
|
|
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" } } : {}) };
|
|
752
1032
|
}
|
|
753
1033
|
|
|
754
1034
|
// (c) REVERSE / "what kind of thing" membership. The meta form ("what is a Y")
|
|
@@ -877,13 +1157,233 @@ function discourseRewrite(query, last) {
|
|
|
877
1157
|
return prevQ.replace(NAME_TOKEN_RE, () => newSubj);
|
|
878
1158
|
}
|
|
879
1159
|
|
|
1160
|
+
// ---- curated SEON definitions (corpus/seon/definitions.jsonl) ----
|
|
1161
|
+
// A "what is a <term>" for a LEXICON term prefers the curated one-sentence
|
|
1162
|
+
// definition — the richer surface form of the same curated SEON knowledge that the
|
|
1163
|
+
// concept seed reifies — over the bare seon concept fact / schema-docs / honest
|
|
1164
|
+
// miss. Cited via:"corpus/seon". Two guards keep it honest and test-safe:
|
|
1165
|
+
// - it only fires when this repo actually carries the SEON concept seed (a
|
|
1166
|
+
// corpus:seon fact about the term is in memory) — so a repo seeded with only
|
|
1167
|
+
// ConceptNet (or nothing) is byte-unchanged;
|
|
1168
|
+
// - a fact the USER personally asserted (ace:chat) still wins — you told me beats
|
|
1169
|
+
// the corpus definition.
|
|
1170
|
+
|
|
1171
|
+
let seonDefsPromise = null;
|
|
1172
|
+
/** Load corpus/seon/definitions.jsonl once → Map(normFactTerm(term) → definition).
|
|
1173
|
+
* Lazy + failure-tolerated (chat.mjs ethos): any failure degrades to an empty map. */
|
|
1174
|
+
function seonDefinitions() {
|
|
1175
|
+
if (!seonDefsPromise) {
|
|
1176
|
+
seonDefsPromise = (async () => {
|
|
1177
|
+
const { SEON_DEFINITIONS_FILE } = await import("./corpus/conceptnet.mjs");
|
|
1178
|
+
const { normFactTerm } = await import("./memory/core.mjs");
|
|
1179
|
+
const raw = await readFile(SEON_DEFINITIONS_FILE, "utf8");
|
|
1180
|
+
const map = new Map();
|
|
1181
|
+
for (const line of raw.split("\n")) {
|
|
1182
|
+
const t = line.trim();
|
|
1183
|
+
if (!t) continue;
|
|
1184
|
+
try {
|
|
1185
|
+
const row = JSON.parse(t);
|
|
1186
|
+
if (row.term && row.definition) map.set(normFactTerm(row.term), String(row.definition));
|
|
1187
|
+
} catch { /* skip a malformed line, never throw */ }
|
|
1188
|
+
}
|
|
1189
|
+
return map;
|
|
1190
|
+
})().catch(() => new Map());
|
|
1191
|
+
}
|
|
1192
|
+
return seonDefsPromise;
|
|
1193
|
+
}
|
|
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
|
+
|
|
1221
|
+
/** The meta term a "what is a X" / "what does X mean" / "define X" question asks
|
|
1222
|
+
* about — from the parse when present, else recognized directly (same required-
|
|
1223
|
+
* article discipline as the grammar's T5). Null when the line isn't such a form. */
|
|
1224
|
+
function metaTermOf(query, envelope) {
|
|
1225
|
+
if (envelope?.parsed?.shape === "meta" && envelope.parsed.object) return envelope.parsed.object;
|
|
1226
|
+
const q = String(query).trim();
|
|
1227
|
+
const m = q.match(/^what\s+(?:is|are)\s+an?\s+(.+?)[?.!\s]*$/i)
|
|
1228
|
+
|| q.match(/^what\s+(?:does|do)\s+(?:an?\s+)?(.+?)\s+means?[?.!\s]*$/i)
|
|
1229
|
+
|| q.match(/^define\s+(?:an?\s+)?(.+?)[?.!\s]*$/i);
|
|
1230
|
+
return m ? m[1].trim() : null;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/** The curated SEON definition to PREFER for a "what is a <lexicon term>", or null.
|
|
1234
|
+
* Gated: the term parses as a meta question, is a grammar-lexicon noun, has a
|
|
1235
|
+
* curated definition, this repo carries the SEON concept seed for it (a corpus:seon
|
|
1236
|
+
* fact), and the user has NOT personally asserted a fact about it. Returns { text,
|
|
1237
|
+
* term } or null. Lazy + failure-tolerated throughout. */
|
|
1238
|
+
async function curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon }) {
|
|
1239
|
+
if (!memoryDir) return null;
|
|
1240
|
+
const term = metaTermOf(query, envelope);
|
|
1241
|
+
if (!term) return null;
|
|
1242
|
+
let normFactTerm;
|
|
1243
|
+
try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
|
|
1244
|
+
// lexicon-noun gate: the curated defs are keyed on SE lexicon terms only.
|
|
1245
|
+
let lex = lexicon;
|
|
1246
|
+
try {
|
|
1247
|
+
if (!lex) { const { loadLexicon } = await import("./grammar/lexicon.mjs"); lex = loadLexicon(); }
|
|
1248
|
+
const { lookupNoun } = await import("./grammar/lexicon.mjs");
|
|
1249
|
+
if (!lookupNoun(lex, term)) return null;
|
|
1250
|
+
} catch { return null; }
|
|
1251
|
+
const def = (await seonDefinitions()).get(normFactTerm(term));
|
|
1252
|
+
if (!def) return null;
|
|
1253
|
+
// tie the definition to the SEON concept seed being present, and let a user fact win.
|
|
1254
|
+
const variants = factTermVariants(normFactTerm, term);
|
|
1255
|
+
const facts = await memoryFacts(memoryDir);
|
|
1256
|
+
const about = facts.filter((f) => variants.has(f.subject) || variants.has(f.object));
|
|
1257
|
+
if (about.some((f) => f.provenance.includes("ace:chat"))) return null; // you told me — that wins
|
|
1258
|
+
if (!about.some((f) => f.provenance.includes("corpus:seon"))) return null; // no SEON seed here
|
|
1259
|
+
return { text: `${def} (source: corpus/seon)`, term };
|
|
1260
|
+
}
|
|
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
|
+
|
|
880
1380
|
/** A bare question → tmct_ask. When a focus is set AND the graph is in hand we
|
|
881
1381
|
* call ask() directly to thread the focus as contextId (so a pronoun like "it"
|
|
882
1382
|
* resolves to the focus) — building the SAME delimited string dispatchTool emits;
|
|
883
1383
|
* otherwise the unchanged dispatchTool path (which also yields the no-graph error).
|
|
884
1384
|
* A hit updates the focus to the resolved object. Grammar miss / ToolError → a
|
|
885
1385
|
* normal answer, never a crash. */
|
|
886
|
-
async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, env }) {
|
|
1386
|
+
async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env }) {
|
|
887
1387
|
const ts = new Date().toISOString();
|
|
888
1388
|
// DISCOURSE ANAPHORA (CHATBENCH_006 levers 1+2): a follow-up like "which of those
|
|
889
1389
|
// are tested" / "how many of those" / "count them" filters or counts the PREVIOUS
|
|
@@ -939,25 +1439,39 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
939
1439
|
// orientation swap below is template wording, so those turns carry via:"template".
|
|
940
1440
|
let via = "composed";
|
|
941
1441
|
let recordMiss = miss;
|
|
942
|
-
|
|
943
|
-
//
|
|
944
|
-
//
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1442
|
+
let factPending = null; // a truncated fact listing's held remainder (for "more" paging)
|
|
1443
|
+
// MISS handling. The intent lanes + short-miss are RECOGNIZER-gated on the query
|
|
1444
|
+
// text AND only consulted on a would-miss, so a real graph query — a hit, an honest
|
|
1445
|
+
// empty with a receipt, a fuzzy repair — is never hijacked. Order: (1) META/SELF
|
|
1446
|
+
// lane (would-miss), (2) conversational orientation (would-miss), (3) memory
|
|
1447
|
+
// facts/recall (a fact EXTENDS a non-miss schema hit too — NOT miss-gated),
|
|
1448
|
+
// (4) TEACH lane (would-miss), (5) the short tailored miss (would-miss).
|
|
1449
|
+
let handled = false;
|
|
1450
|
+
// (1) #2 META/SELF: bare self/session questions ("what do you know", "what is this
|
|
1451
|
+
// codebase", "how do i start") → a summary / orientation, answered before the
|
|
1452
|
+
// fact-dump readers so "what do you know" gets a summary, not raw facts.
|
|
1453
|
+
if (miss) {
|
|
1454
|
+
const meta = await metaLane(query, { graph, memoryDir });
|
|
1455
|
+
if (meta) { answer = meta.text; via = meta.via; recordMiss = false; handled = true; }
|
|
1456
|
+
}
|
|
1457
|
+
if (!handled && miss && isConversational(query)) {
|
|
1458
|
+
// A conversational miss (a greeting, "what can you do", a very short non-code
|
|
1459
|
+
// line) gets the friendly orientation (module-aware: empty → --repo/tmct init).
|
|
1460
|
+
answer = orientationAnswer(templates, graph); via = "template"; handled = true;
|
|
1461
|
+
} else if (!handled && memoryDir) {
|
|
1462
|
+
// W4: vocabulary/definition questions consult the MEMORY graph's Facts alongside
|
|
1463
|
+
// the schema-docs surface — a remembered fact answers a miss OR extends a (non-
|
|
1464
|
+
// miss) schema hit, cited with its provenance verbatim. Checked BEFORE recall: a
|
|
1465
|
+
// reified fact is stronger evidence than a transcript echo. Subject-side facts
|
|
1466
|
+
// first (factAnswer), then the reverse-membership read-back (factReadBack) so an
|
|
1467
|
+
// asserted "every X is a Y" answers "what is a Y" too.
|
|
955
1468
|
const fact = (await factAnswer(memoryDir, query, envelope, miss))
|
|
956
1469
|
?? (await factReadBack(memoryDir, query, envelope, miss, graph));
|
|
957
1470
|
if (fact) {
|
|
958
1471
|
answer = fact.replace ? fact.text : `${answer}\n${fact.text}`;
|
|
959
1472
|
via = "fact";
|
|
960
1473
|
recordMiss = false;
|
|
1474
|
+
if (fact.pending) factPending = fact.pending; // a truncated fact list → paginable remainder
|
|
961
1475
|
} else if (miss) {
|
|
962
1476
|
// W2: after the honest miss is composed, consult the folded-session memory. A
|
|
963
1477
|
// relevant enough block ANSWERS — recalled Q/A framed + cited first, with the
|
|
@@ -970,6 +1484,66 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
970
1484
|
}
|
|
971
1485
|
}
|
|
972
1486
|
}
|
|
1487
|
+
// CURATED SEON DEFINITION (corpus/seon) — a "what is a <lexicon term>" prefers the
|
|
1488
|
+
// curated prose definition over the seon concept fact / schema-docs / honest miss.
|
|
1489
|
+
// Runs after the fact branch and overrides its corpus-fact answer (via:"fact"), but
|
|
1490
|
+
// curatedDefinitionAnswer itself defers to a user-asserted (ace:chat) fact, so a
|
|
1491
|
+
// "you told me" answer already standing is left untouched. Skips the meta/self +
|
|
1492
|
+
// conversational lanes (via:"meta"/"template"), which answer a different question.
|
|
1493
|
+
if (via === "composed" || via === "fact") {
|
|
1494
|
+
const def = await curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon });
|
|
1495
|
+
if (def) { answer = def.text; via = "corpus/seon"; recordMiss = false; }
|
|
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
|
+
}
|
|
1529
|
+
// (4) #2 TEACH lane — a teach-shaped would-miss nothing above answered: route to
|
|
1530
|
+
// memory, or say what CAN be remembered (LOUD), never the wall / a silent drop.
|
|
1531
|
+
if (miss && recordMiss && via === "composed") {
|
|
1532
|
+
const taught = await teachLane(query, { memoryDir, sessionId, lexicon });
|
|
1533
|
+
if (taught) { answer = taught.text; via = taught.via; recordMiss = taught.miss; }
|
|
1534
|
+
}
|
|
1535
|
+
// (5) #1 SHORT TAILORED MISS — replace ONLY the engine's full grammar cheat-sheet
|
|
1536
|
+
// wall (WALL_MISS_RE). Receipt-bearing misses keep their specific wording.
|
|
1537
|
+
if (miss && recordMiss && via === "composed" && WALL_MISS_RE.test(answer)) {
|
|
1538
|
+
answer = shortMissHint(query); via = "miss";
|
|
1539
|
+
}
|
|
1540
|
+
// #4 HONEST-EMPTY POLISH — an empty CODE graph: any still-standing engine
|
|
1541
|
+
// dead-end (an honest empty, the short miss, the bootstrap note) carries the exit
|
|
1542
|
+
// toward a real graph, unless it already points there. Only when genuinely empty.
|
|
1543
|
+
if (recordMiss && (via === "composed" || via === "miss")
|
|
1544
|
+
&& noCodeGraph(graph) && !/--repo|tmct init|no code graph/i.test(answer)) {
|
|
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.)`;
|
|
1546
|
+
}
|
|
973
1547
|
// W5 (flag-gated, default OFF): an unknown-term miss may consult the LOCAL
|
|
974
1548
|
// committed corpus slice — a hit APPENDS a grounded, licence-cited aside under
|
|
975
1549
|
// the honest miss (the miss itself stands; the aside is context, not an answer).
|
|
@@ -980,11 +1554,23 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
980
1554
|
via = "corpus";
|
|
981
1555
|
}
|
|
982
1556
|
}
|
|
983
|
-
|
|
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 };
|
|
984
1562
|
const logLines = [ts, `> ${query}`, answer, ""];
|
|
985
1563
|
// `detail` feeds why/say-more's verbose re-render: the traversal receipt + the
|
|
986
|
-
// matched entities the terse render trims (see renderVerbose).
|
|
987
|
-
|
|
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));
|
|
988
1574
|
return { answer, logLines, record, focus: newFocus, detail };
|
|
989
1575
|
}
|
|
990
1576
|
|
|
@@ -1111,6 +1697,29 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null })
|
|
|
1111
1697
|
* subject), `answeredIds` the entity ids an ask answer cited; a slash-command turn
|
|
1112
1698
|
* also carries its `command` name. Both drive the mgx:asksAbout graph append.
|
|
1113
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
|
+
|
|
1114
1723
|
export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null } = {}) {
|
|
1115
1724
|
const line = String(input ?? "").trim();
|
|
1116
1725
|
const templates = await chatTemplates(); // failure-tolerated: null degrades, never throws
|
|
@@ -1128,11 +1737,25 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
1128
1737
|
return { ...finished, last: { query: line, answer: finished.answer, detail: finished.detail ?? null } };
|
|
1129
1738
|
};
|
|
1130
1739
|
|
|
1131
|
-
//
|
|
1740
|
+
// Slash-optional system commands: a bare leading command word ("stats",
|
|
1741
|
+
// "memory", "describe X") is routed to its slash form BEFORE the conversational
|
|
1742
|
+
// layer, so a forgiving shell answers "stats" the way it answers "/stats" instead
|
|
1743
|
+
// of falling through to the generic orientation.
|
|
1744
|
+
const bareCmd = asBareCommand(line);
|
|
1745
|
+
if (bareCmd) return withLast(await runCommand(bareCmd, ctx));
|
|
1746
|
+
|
|
1747
|
+
// Conversational layer next (greetings, thanks, help, bye, why/say-more) — these
|
|
1132
1748
|
// resolve no entity and carry their own preserved `last`.
|
|
1133
1749
|
const convo = conversationalTurn(line, ctx);
|
|
1134
1750
|
if (convo) return convo;
|
|
1135
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
|
+
|
|
1136
1759
|
if (line.startsWith("/")) return withLast(await runCommand(line, ctx));
|
|
1137
1760
|
// Declarative ACE sentences ("every module is a artifact") ASSERT into tmct's
|
|
1138
1761
|
// own memory and confirm — they are statements to remember, not graph queries.
|
|
@@ -1142,6 +1765,15 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
1142
1765
|
const asserted = await assertTurn(line, ctx);
|
|
1143
1766
|
if (asserted) return withLast(asserted);
|
|
1144
1767
|
}
|
|
1768
|
+
// MEMORY-STORE counts first ("how many facts / utterances do you know") — the
|
|
1769
|
+
// memory graph owns Facts + Utterances, so these are answerable and consistent
|
|
1770
|
+
// with `/memory`. Checked before answerCount (which reads the CODE graph and would
|
|
1771
|
+
// otherwise say "I can't count facts"); it only speaks for a memory-class noun, so
|
|
1772
|
+
// structural counts (classes/functions/…) and sessions fall through unaffected.
|
|
1773
|
+
if (memoryDir) {
|
|
1774
|
+
const memCount = await answerMemoryCount(memoryDir, line);
|
|
1775
|
+
if (memCount != null) return withLast(plainTurn(line, memCount, { via: "count", focus }));
|
|
1776
|
+
}
|
|
1145
1777
|
// Aggregate/count questions are answered mechanically off the loaded graph header,
|
|
1146
1778
|
// BEFORE falling through to the ask engine (focus unchanged — a count names no entity).
|
|
1147
1779
|
const count = answerCount(graph, line);
|
|
@@ -1159,26 +1791,40 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
1159
1791
|
|
|
1160
1792
|
// ---- W3: seedMemory → bootstrap (first run in a graph-less repo) ----
|
|
1161
1793
|
|
|
1162
|
-
/**
|
|
1163
|
-
*
|
|
1164
|
-
*
|
|
1165
|
-
*
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
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. */
|
|
1171
1808
|
export const SEED_PREFER = ["rdfs:subClassOf", "rdf:type", "mgx:usedFor", "mgx:partOf", "mgx:capableOf"];
|
|
1172
1809
|
|
|
1173
1810
|
/** The seed marker: its presence means this repo's memory already carries the
|
|
1174
1811
|
* corpus seed, so re-runs skip without even reading the slice. */
|
|
1175
1812
|
export const SEED_MARKER_REL = join(".tmct", "memory", "corpus-seed.json");
|
|
1176
1813
|
|
|
1177
|
-
/** Seed the
|
|
1178
|
-
*
|
|
1179
|
-
*
|
|
1180
|
-
*
|
|
1181
|
-
*
|
|
1814
|
+
/** Seed the starter corpus into <repo>/.tmct/memory once, in TWO passes:
|
|
1815
|
+
* 1. the curated SEON ontology (corpus/seon/concepts.jsonl) FIRST and UNCAPPED
|
|
1816
|
+
* — it is small + fully curated (the SE vocabulary + orientation facts),
|
|
1817
|
+
* tagged "corpus:seon", so a fresh repo knows the curated terms before any
|
|
1818
|
+
* general ConceptNet noise;
|
|
1819
|
+
* 2. THEN the capped ConceptNet slice (the definitional band first, SEED_LIMIT
|
|
1820
|
+
* facts), tagged "corpus:conceptnet".
|
|
1821
|
+
* seon runs first so its curated facts win the content-hash idempotency race — a
|
|
1822
|
+
* term the ConceptNet slice also carries keeps the seon provenance. Idempotent
|
|
1823
|
+
* twice over (the marker short-circuits; seedMemory content-hashes fact ids) and
|
|
1824
|
+
* failure-tolerated: a missing/broken corpus degrades to the unseeded bootstrap —
|
|
1825
|
+
* never an error before the prompt. Returns { appended, skipped, total, seon,
|
|
1826
|
+
* conceptnet } on a fresh seed (the banner counts stay honest), null when
|
|
1827
|
+
* skipped/failed. */
|
|
1182
1828
|
async function seedBootstrapMemory(repo) {
|
|
1183
1829
|
const marker = join(repo, SEED_MARKER_REL);
|
|
1184
1830
|
try {
|
|
@@ -1186,11 +1832,22 @@ async function seedBootstrapMemory(repo) {
|
|
|
1186
1832
|
return null; // already seeded — the marker is authoritative
|
|
1187
1833
|
} catch { /* no marker → first run */ }
|
|
1188
1834
|
try {
|
|
1189
|
-
const { seedMemory } = await import("./corpus/conceptnet.mjs");
|
|
1190
|
-
|
|
1835
|
+
const { seedMemory, SEON_CONCEPTS_FILE } = await import("./corpus/conceptnet.mjs");
|
|
1836
|
+
// (1) curated SEON ontology — uncapped, seon-tagged, seeded FIRST.
|
|
1837
|
+
const seon = await seedMemory(repo, { slicePath: SEON_CONCEPTS_FILE, provenancePrefix: "corpus:seon" });
|
|
1838
|
+
// (2) the capped ConceptNet band — byte-identical to the prior single seed.
|
|
1839
|
+
const conceptnet = await seedMemory(repo, { limit: SEED_LIMIT, prefer: SEED_PREFER });
|
|
1840
|
+
const res = {
|
|
1841
|
+
appended: seon.appended + conceptnet.appended,
|
|
1842
|
+
skipped: seon.skipped + conceptnet.skipped,
|
|
1843
|
+
total: seon.total + conceptnet.total,
|
|
1844
|
+
seon: seon.appended,
|
|
1845
|
+
conceptnet: conceptnet.appended,
|
|
1846
|
+
};
|
|
1191
1847
|
await mkdir(dirname(marker), { recursive: true });
|
|
1192
1848
|
await writeFile(marker, JSON.stringify({
|
|
1193
|
-
seededAt: new Date().toISOString(), limit: SEED_LIMIT,
|
|
1849
|
+
seededAt: new Date().toISOString(), limit: SEED_LIMIT,
|
|
1850
|
+
appended: res.appended, skipped: res.skipped, seon: res.seon, conceptnet: res.conceptnet,
|
|
1194
1851
|
}) + "\n");
|
|
1195
1852
|
return res;
|
|
1196
1853
|
} catch {
|
|
@@ -1246,19 +1903,43 @@ export async function createSession({
|
|
|
1246
1903
|
env = process.env,
|
|
1247
1904
|
cwd = process.cwd(),
|
|
1248
1905
|
gitRoot = gitToplevel,
|
|
1906
|
+
ephemeral = false,
|
|
1249
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 || ""));
|
|
1915
|
+
// Graph resolution order for the chat surface (documented; --repo wins):
|
|
1916
|
+
// 1. --repo <path> → pins <path>/.tmct/graph.json (repo AND graph).
|
|
1917
|
+
// 2. TMCT_GRAPH_FILE env → loads that graph anywhere (loadConfig reads it), so
|
|
1918
|
+
// `TMCT_GRAPH_FILE=<path> tmct chat` works even inside a git repo — the chat
|
|
1919
|
+
// surface used to ignore it (only the `cli` tool path honoured it). The repo
|
|
1920
|
+
// for logs/memory is still the git root / cwd; only the graph file is overridden.
|
|
1921
|
+
// 3. git root → <root>/.tmct/graph.json (the default target).
|
|
1922
|
+
// 4. cwd → <cwd>/.tmct/graph.json (not a git repo).
|
|
1250
1923
|
// Default the target to the GIT ROOT, not raw cwd: running from a nested package
|
|
1251
1924
|
// dir (npm sets cwd there) would otherwise index only that package's ~few modules
|
|
1252
|
-
// instead of the whole repo.
|
|
1925
|
+
// instead of the whole repo.
|
|
1253
1926
|
let repo;
|
|
1254
1927
|
let config;
|
|
1255
1928
|
if (repoPath) { repo = repoPath; config = configFor(repoPath); }
|
|
1256
1929
|
else {
|
|
1257
1930
|
const root = gitRoot(cwd);
|
|
1258
|
-
|
|
1259
|
-
|
|
1931
|
+
repo = root || cwd;
|
|
1932
|
+
const envGraph = env.TMCT_GRAPH_FILE && String(env.TMCT_GRAPH_FILE).trim();
|
|
1933
|
+
// TMCT_GRAPH_FILE (via loadConfig) overrides the repo-derived default graph path;
|
|
1934
|
+
// otherwise the repo's own .tmct/graph.json is the target.
|
|
1935
|
+
config = envGraph ? loadConfig(env, cwd) : { graphFile: join(repo, DEFAULT_GRAPH_REL) };
|
|
1260
1936
|
}
|
|
1261
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
|
+
|
|
1262
1943
|
// Load the graph once up front — the banner needs the module count, and focus/`it`
|
|
1263
1944
|
// resolution and contextId threading need it in hand. A missing artifact loads as
|
|
1264
1945
|
// the empty bootstrap graph (source.mjs) — the banner says so; never an error.
|
|
@@ -1305,6 +1986,7 @@ export async function createSession({
|
|
|
1305
1986
|
// artifact mid-session must degrade the recording, never kill the chat.
|
|
1306
1987
|
const turnRecords = [];
|
|
1307
1988
|
const upsertGraph = async (ended) => {
|
|
1989
|
+
if (ephemeral) return; // a demo/read-only session never writes back to the graph
|
|
1308
1990
|
if (!turnRecords.length) return; // a zero-turn session never pollutes the graph
|
|
1309
1991
|
try { await appendSessionToGraph(config.graphFile, { id: sessionId, started: startIso, ended, turns: turnRecords }); }
|
|
1310
1992
|
catch { /* best-effort — see above */ }
|
|
@@ -1320,14 +2002,24 @@ export async function createSession({
|
|
|
1320
2002
|
if (empty && String(env.TMCT_NO_SEED || "") !== "1") {
|
|
1321
2003
|
seeded = await seedBootstrapMemory(repo);
|
|
1322
2004
|
}
|
|
2005
|
+
// #3/#5: 0 modules means no code graph to answer structure questions from —
|
|
2006
|
+
// whether the graph file is absent (empty bootstrap) OR present with no code
|
|
2007
|
+
// entities (the degenerate trap). Both get orienting, non-over-promising banner
|
|
2008
|
+
// + greeting messaging rather than a silent dead-end.
|
|
2009
|
+
const noCodeGraph = moduleCount === 0;
|
|
1323
2010
|
const bannerLines = [
|
|
1324
|
-
|
|
1325
|
-
//
|
|
1326
|
-
? `tmct chat — ${repo} — no graph loaded — starting empty; ` +
|
|
2011
|
+
noCodeGraph
|
|
2012
|
+
// No code graph: honest, orienting messaging — never an error before the prompt.
|
|
2013
|
+
? `tmct chat — ${repo} — no code graph loaded — ${empty ? "starting empty" : "graph has no code entities"}; ` +
|
|
1327
2014
|
`the conversation is remembered to ${DEFAULT_GRAPH_REL} — log ${logFile}`
|
|
1328
2015
|
: `tmct chat — ${repo} — ${moduleCount} module(s) — log ${logFile}`,
|
|
1329
|
-
// the honest seed line appears ONLY on the run that actually seeded
|
|
1330
|
-
|
|
2016
|
+
// the honest seed line appears ONLY on the run that actually seeded — the count
|
|
2017
|
+
// is the TOTAL appended, split into the curated SEON ontology + the ConceptNet band.
|
|
2018
|
+
...(seeded ? [`seeded ${seeded.appended} starter facts (${seeded.seon} curated SEON + ${seeded.conceptnet} ConceptNet) — /memory to inspect`] : []),
|
|
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"'] : []),
|
|
1331
2023
|
"pass --repo <path> to target a different repo",
|
|
1332
2024
|
"ask a question, or /help for commands (/stats for an overview) — /exit to leave",
|
|
1333
2025
|
];
|
|
@@ -1400,8 +2092,9 @@ export async function runChat({
|
|
|
1400
2092
|
env = process.env,
|
|
1401
2093
|
cwd = process.cwd(),
|
|
1402
2094
|
gitRoot = gitToplevel,
|
|
2095
|
+
ephemeral = false,
|
|
1403
2096
|
} = {}) {
|
|
1404
|
-
const session = await createSession({ repoPath, source, env, cwd, gitRoot });
|
|
2097
|
+
const session = await createSession({ repoPath, source, env, cwd, gitRoot, ephemeral });
|
|
1405
2098
|
|
|
1406
2099
|
const dim = (s) => (env.NO_COLOR || !output.isTTY ? s : `\x1b[2m${s}\x1b[0m`);
|
|
1407
2100
|
for (const line of session.bannerLines) output.write(dim(line) + "\n");
|