@polycode-projects/the-mechanical-code-talker 0.4.0 → 0.6.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 +64 -28
- package/ROADMAP.md +6 -6
- package/bin/tmct.mjs +56 -0
- package/corpus/README.md +77 -5
- package/corpus/conceptnet/README.md +54 -2
- package/corpus/conceptnet/quality-filter.mjs +95 -0
- package/corpus/conceptnet/slice.jsonl +0 -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/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/grammar-rules.toml +18 -10
- package/data/templates/responses.jsonl +2 -0
- package/package.json +10 -2
- package/src/ask-vocab.mjs +19 -1
- package/src/ask.mjs +90 -6
- package/src/chat.mjs +647 -60
- package/src/codegraph.mjs +28 -5
- package/src/conformance.mjs +166 -0
- package/src/corpus/conceptnet.mjs +24 -6
- package/src/grammar/lexicon-core.json +8 -0
- package/src/memory/inspect.mjs +25 -0
- package/src/server.mjs +88 -7
package/src/chat.mjs
CHANGED
|
@@ -104,6 +104,37 @@ export const COMMANDS = {
|
|
|
104
104
|
* back to the focus for, and that update the focus on a successful resolve. */
|
|
105
105
|
const ENTITY_ARGS = new Set(["symbol", "module", "class"]);
|
|
106
106
|
|
|
107
|
+
/** System-command words that a forgiving shell accepts WITHOUT the leading "/":
|
|
108
|
+
* `stats`, `memory`, `describe X`, `members X`, … all work bare. "help" is left
|
|
109
|
+
* out on purpose — bare "help" stays the friendly orientation; "/help" is the
|
|
110
|
+
* full command list. */
|
|
111
|
+
const COMMAND_WORDS = new Set(["stats", "memory", "focus", ...Object.keys(COMMANDS)]);
|
|
112
|
+
|
|
113
|
+
/** Query connectives that mark a line as a COMPOSITIONAL question the ask engine
|
|
114
|
+
* should own, even when it happens to start with a command word ("untested modules
|
|
115
|
+
* IMPORTING x", "find functions THAT CALL y"). Their presence blocks slash-routing. */
|
|
116
|
+
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;
|
|
117
|
+
|
|
118
|
+
/** A bare leading command word → its slash form ("stats" → "/stats", "describe x"
|
|
119
|
+
* → "/describe x"), so the system commands are slash-optional. Conservative on the
|
|
120
|
+
* entity/arg commands: it routes a bare word or a SHORT name-like argument, but
|
|
121
|
+
* falls through (returns null) for a multi-word compositional query so the ask
|
|
122
|
+
* engine still owns things like "untested modules importing a.mjs". Returns null
|
|
123
|
+
* when the first token is not a command word. */
|
|
124
|
+
export function asBareCommand(line) {
|
|
125
|
+
const trimmed = String(line || "").trim();
|
|
126
|
+
if (!trimmed || trimmed.startsWith("/")) return null;
|
|
127
|
+
const [first, ...restTok] = trimmed.split(/\s+/);
|
|
128
|
+
if (!COMMAND_WORDS.has(first.toLowerCase())) return null;
|
|
129
|
+
const rest = restTok.join(" ");
|
|
130
|
+
// Zero-arg system commands are always the command; a bare command word is too.
|
|
131
|
+
if (!rest || first.toLowerCase() === "stats" || first.toLowerCase() === "memory") return `/${trimmed}`;
|
|
132
|
+
// Arg commands: route only a short, name-like argument (no query connectives),
|
|
133
|
+
// so "describe Widget" / "members my class" route but a compositional query does not.
|
|
134
|
+
if (restTok.length <= 3 && !QUERY_CONNECTIVES.test(rest)) return `/${trimmed}`;
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
107
138
|
// ---- aggregate / count queries — answered MECHANICALLY off the loaded graph
|
|
108
139
|
// header (individuals grouped by class, relation groups by predicate), not by
|
|
109
140
|
// dispatching to the ask engine. Deterministic, fully in-ethos. ----
|
|
@@ -138,11 +169,22 @@ function countableKinds(graph) {
|
|
|
138
169
|
return Object.keys(CLASS_LABELS).filter((c) => present.has(c)).map((c) => CLASS_LABELS[c][1]);
|
|
139
170
|
}
|
|
140
171
|
|
|
172
|
+
/** A discourse-anaphoric count/list head — "how many [of] those/them/these",
|
|
173
|
+
* "count them/those/these". These refer to the previous answer set and are owned
|
|
174
|
+
* by the ask engine's anaphora node, never the header-count path. */
|
|
175
|
+
const ANAPHORA_COUNT_RE = /\b(?:how many|how much|count|number of)\s+(?:of\s+)?(?:those|them|these)\b/i;
|
|
176
|
+
|
|
141
177
|
/** Recognise a count/aggregate question and answer it from the graph header, or
|
|
142
178
|
* null if it isn't one (→ fall through to tmct_ask). "how many X [are there]",
|
|
143
179
|
* "count [the] X", "number of X". An unknown kind lists what it CAN count. */
|
|
144
180
|
export function answerCount(graph, query) {
|
|
145
181
|
if (!graph) return null;
|
|
182
|
+
// ANAPHORIC counts ("how many of those are tested", "count them", "how many of
|
|
183
|
+
// them") count the PREVIOUS answer's set, not a graph kind — decline so the turn
|
|
184
|
+
// falls through to the ask engine's anaphora node (which threads `prev`). Without
|
|
185
|
+
// this the bare "of"/pronoun head is mis-reported as an uncountable kind and the
|
|
186
|
+
// discourse+count follow-up dies before it can resolve (CHATBENCH_006 lever 1).
|
|
187
|
+
if (ANAPHORA_COUNT_RE.test(String(query))) return null;
|
|
146
188
|
const m = String(query).match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b/i);
|
|
147
189
|
if (!m) return null;
|
|
148
190
|
const noun = m[1].toLowerCase();
|
|
@@ -155,6 +197,70 @@ export function answerCount(graph, query) {
|
|
|
155
197
|
return `${n} ${classNoun(cls, n)}.`;
|
|
156
198
|
}
|
|
157
199
|
|
|
200
|
+
/** ASSERTED-VOCABULARY count (CHATBENCH_006 lever 3): once "every class is a type"
|
|
201
|
+
* is remembered, "how many types are there" counts as many types as there are
|
|
202
|
+
* classes — the asserted object noun inherits the subject class's cardinality.
|
|
203
|
+
* Consulted only when answerCount can't map the noun to a graph class (an unknown
|
|
204
|
+
* kind) AND a session's memory is in hand. Returns the count string or null (no
|
|
205
|
+
* such fact → the honest "I can't count …" from answerCount stands). */
|
|
206
|
+
async function countFromFacts(graph, memoryDir, query) {
|
|
207
|
+
if (!graph || !memoryDir) return null;
|
|
208
|
+
const m = String(query).match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b/i);
|
|
209
|
+
if (!m) return null;
|
|
210
|
+
const asked = m[1].toLowerCase();
|
|
211
|
+
if (COUNT_NOUNS[asked]) return null; // a real graph kind — answerCount owns it
|
|
212
|
+
let normFactTerm;
|
|
213
|
+
try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
|
|
214
|
+
const objVariants = factTermVariants(normFactTerm, asked);
|
|
215
|
+
const isa = (await factRows(memoryDir))
|
|
216
|
+
.filter((f) => ISA_PREDICATES.has(f.predicate) && objVariants.has(f.object));
|
|
217
|
+
// pick the highest-trust asserted subject that maps to a countable graph class
|
|
218
|
+
for (const f of isa.sort((a, b) => (b.trust ?? 0) - (a.trust ?? 0))) {
|
|
219
|
+
const cls = COUNT_NOUNS[String(f.subject).toLowerCase()];
|
|
220
|
+
if (cls) { const n = countClass(graph, cls); return `${n} ${asked}.`; }
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---- memory-store counts (the .tmct/memory graph, distinct from the code graph
|
|
226
|
+
// answerCount reads) — so "how many facts do you know" is answerable, consistent
|
|
227
|
+
// with what `/memory` advertises. The code graph owns the structural kinds
|
|
228
|
+
// (classes/functions/modules/…); the memory store owns Facts + Utterances. Sessions
|
|
229
|
+
// stay with answerCount (chat writes Session individuals into the code graph as
|
|
230
|
+
// first-class temporal data — see sessions.mjs), so this never shadows them. ----
|
|
231
|
+
|
|
232
|
+
/** Nouns that name a MEMORY-STORE individual class, → the class to count. */
|
|
233
|
+
const MEMORY_COUNT_NOUNS = {
|
|
234
|
+
fact: "Fact", facts: "Fact",
|
|
235
|
+
utterance: "Utterance", utterances: "Utterance", said: "Utterance",
|
|
236
|
+
};
|
|
237
|
+
const MEMORY_CLASS_LABELS = { Fact: ["fact", "facts"], Utterance: ["utterance", "utterances"] };
|
|
238
|
+
|
|
239
|
+
/** Recognise a memory-store count question and answer it by loading the memory
|
|
240
|
+
* graph, or null (→ answerCount / the ask engine own it). Handles "how many facts",
|
|
241
|
+
* "how many utterances", and the bare "how many do you know" (→ facts). Lazy +
|
|
242
|
+
* failure-tolerated: no memory / a broken store → null, so the honest fall-through
|
|
243
|
+
* stands. */
|
|
244
|
+
async function answerMemoryCount(memoryDir, query) {
|
|
245
|
+
if (!memoryDir) return null;
|
|
246
|
+
const q = String(query).toLowerCase();
|
|
247
|
+
let cls = null;
|
|
248
|
+
// the bare "how many do you know" (no explicit noun) defaults to remembered facts
|
|
249
|
+
if (/\bhow many(?:\s+(?:things?|facts?))?\s+(?:do|d'?)\s+(?:you|u)\s+know\b/.test(q)) cls = "Fact";
|
|
250
|
+
if (!cls) {
|
|
251
|
+
const m = q.match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b/);
|
|
252
|
+
if (m) cls = MEMORY_COUNT_NOUNS[m[1]] || null;
|
|
253
|
+
}
|
|
254
|
+
if (!cls) return null;
|
|
255
|
+
let loadMemory;
|
|
256
|
+
try { ({ loadMemory } = await import("./memory/core.mjs")); } catch { return null; }
|
|
257
|
+
let mem;
|
|
258
|
+
try { mem = await loadMemory(memoryDir); } catch { return null; }
|
|
259
|
+
const n = (mem.individuals || []).filter((i) => (i.class || "") === cls).length;
|
|
260
|
+
const [sing, plur] = MEMORY_CLASS_LABELS[cls];
|
|
261
|
+
return `${n} ${n === 1 ? sing : plur}.`;
|
|
262
|
+
}
|
|
263
|
+
|
|
158
264
|
/** `/stats`: a one-screen overview of the graph — class counts, relationship
|
|
159
265
|
* (predicate) counts, and module/package totals — read straight off the header. */
|
|
160
266
|
export function renderStats(graph) {
|
|
@@ -233,6 +339,12 @@ const T_THANKS = "conversational-thanks";
|
|
|
233
339
|
const T_FAREWELL = "conversational-farewell";
|
|
234
340
|
const T_ORIENTATION = "orientation-friendly";
|
|
235
341
|
const T_WHY_EMPTY = "miss-no-previous-answer";
|
|
342
|
+
/** Empty / degenerate-graph variants (#3/#5): shown when the loaded graph has 0
|
|
343
|
+
* modules (a graph-less bootstrap OR a graph.json with no code entities). They
|
|
344
|
+
* orient toward `--repo`/`tmct init` + the seeded vocabulary instead of
|
|
345
|
+
* over-promising "ask me about this codebase". */
|
|
346
|
+
const T_GREETING_EMPTY = "conversational-greeting-empty";
|
|
347
|
+
const T_ORIENTATION_EMPTY = "orientation-empty";
|
|
236
348
|
|
|
237
349
|
/** The degraded line when the template library itself cannot load — a packaging
|
|
238
350
|
* failure said out loud, never a crashed turn or a silently different answer. */
|
|
@@ -333,9 +445,188 @@ function conversationalTurn(line, ctx) {
|
|
|
333
445
|
if (v.empty) return mk(tRender(ctx.templates, T_WHY_EMPTY) ?? v.text, { miss: true });
|
|
334
446
|
return mk(v.text, { via: "conversational" });
|
|
335
447
|
}
|
|
336
|
-
if (GREET.has(q))
|
|
448
|
+
if (GREET.has(q)) {
|
|
449
|
+
// #3 empty/degenerate-graph greeting: a plain "hi"/"hello" over a graph with 0
|
|
450
|
+
// modules orients toward --repo/tmct init instead of over-promising "ask me
|
|
451
|
+
// about this codebase". Phrase-specific variants (good morning, hello there)
|
|
452
|
+
// keep their wording; only the default greeting swaps.
|
|
453
|
+
const id = (!T_GREETING_BY_PHRASE[q] && noCodeGraph(ctx.graph)) ? T_GREETING_EMPTY : (T_GREETING_BY_PHRASE[q] || T_GREETING);
|
|
454
|
+
return mk(t(id));
|
|
455
|
+
}
|
|
337
456
|
if (THANKS.has(q)) return mk(t(T_THANKS));
|
|
338
|
-
if (q === "help" || q === "?" || HELP_PHRASES.some((re) => re.test(raw))) return mk(
|
|
457
|
+
if (q === "help" || q === "?" || HELP_PHRASES.some((re) => re.test(raw))) return mk(orientationAnswer(ctx.templates, ctx.graph));
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ---- #1/#2/#3 conversational-UX helpers: module-aware orientation, the short
|
|
462
|
+
// tailored miss, and the intent lanes (teach + meta/self). All are recognizer-
|
|
463
|
+
// gated and (for the lanes) only consulted on a would-miss, so ordinary graph
|
|
464
|
+
// queries are never hijacked. ----
|
|
465
|
+
|
|
466
|
+
/** Code entities (Modules) in the loaded graph — the "is there a code graph here"
|
|
467
|
+
* test. 0 means a graph-less bootstrap OR a graph.json with no code entities (the
|
|
468
|
+
* degenerate trap); both orient rather than over-promise. */
|
|
469
|
+
export function moduleCountOf(graph) {
|
|
470
|
+
if (!graph || !Array.isArray(graph.individuals)) return 0;
|
|
471
|
+
return graph.individuals.filter((i) => (i.class || "") === "Module").length;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** A KNOWN-empty code graph: a loaded graph object with 0 modules. A null graph
|
|
475
|
+
* (a bare runTurn that wasn't handed one) is "unknown", NOT empty — the empty
|
|
476
|
+
* orientation/greeting only fires when we actually hold an empty graph. */
|
|
477
|
+
const noCodeGraph = (graph) => !!graph && moduleCountOf(graph) === 0;
|
|
478
|
+
|
|
479
|
+
/** The orientation surface, module-aware: the empty variant (→ --repo/tmct init +
|
|
480
|
+
* seeded vocabulary) when there's no code graph, the standard one otherwise. */
|
|
481
|
+
function orientationAnswer(templates, graph) {
|
|
482
|
+
return tRender(templates, noCodeGraph(graph) ? T_ORIENTATION_EMPTY : T_ORIENTATION) ?? TEMPLATES_UNAVAILABLE;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** A dynamic orientation string for the meta/self lane: a /stats-style overview
|
|
486
|
+
* when a code graph is loaded, else the honest empty-graph orientation. */
|
|
487
|
+
function orientationText(graph) {
|
|
488
|
+
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.';
|
|
492
|
+
}
|
|
493
|
+
const by = (cls) => (graph.individuals || []).filter((i) => (i.class || "") === cls).length;
|
|
494
|
+
const parts = [];
|
|
495
|
+
for (const [cls, sing, plur] of [["Module", "module", "modules"], ["Class", "class", "classes"], ["Function", "function", "functions"]]) {
|
|
496
|
+
const n = by(cls); if (n) parts.push(`${n} ${n === 1 ? sing : plur}`);
|
|
497
|
+
}
|
|
498
|
+
return `This is a tmct code graph — ${(graph.individuals || []).length} entities`
|
|
499
|
+
+ `${parts.length ? ` (${parts.join(", ")})` : ""}. `
|
|
500
|
+
+ 'Ask about imports, calls, definitions or history — e.g. "which modules import <name>", "what calls <name>". '
|
|
501
|
+
+ "/stats for the full overview, /help for commands.";
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// #1 SHORT, TAILORED MISS — the engine's full grammar cheat-sheet (rephraseHint)
|
|
505
|
+
// now lives ONLY behind /help. A genuine parse-miss gets ONE line: an honest miss
|
|
506
|
+
// + at most two example shapes chosen for what the user typed + a /help pointer.
|
|
507
|
+
// The opening "couldn't parse this as a graph question. Try:" is preserved (the
|
|
508
|
+
// honest-miss contract + the graded hm-joke case pin those words).
|
|
509
|
+
const MISS_EXAMPLES = {
|
|
510
|
+
import: ['"which modules import <name>"', '"what does <name> import"'],
|
|
511
|
+
export: ['"what does <name> export"', '"which modules import <name>"'],
|
|
512
|
+
call: ['"what calls <name>"', '"which functions call <name>"'],
|
|
513
|
+
test: ['"what tests <name>"', '"which functions are tested"'],
|
|
514
|
+
inherit: ['"which classes inherit from <name>"', '"what are the subclasses of <name>"'],
|
|
515
|
+
history: ['"when did <name> change"', '"who touched <name>"'],
|
|
516
|
+
define: ['"where is <name> defined"', '"where is <name> mentioned"'],
|
|
517
|
+
meaning: ['"what is a <ClassName>"', '"what does <term> mean"'],
|
|
518
|
+
count: ['"how many classes are there"', '"how many modules are there"'],
|
|
519
|
+
};
|
|
520
|
+
const MISS_DEFAULT = ['"which modules import <name>"', '"what calls <name>"'];
|
|
521
|
+
|
|
522
|
+
/** Choose up to two example shapes RELEVANT to the user's words. */
|
|
523
|
+
function tailoredExamples(q) {
|
|
524
|
+
// membership yes/no ("is a algorithm information") — the grammar wants an article
|
|
525
|
+
// before BOTH terms; hint the working shape rather than dumping the wall.
|
|
526
|
+
if (/^is\s+(?:an?\s+)?[\w-]+\b/.test(q)) return ['"is a <thing> a <kind>" (an article before the kind, too)'];
|
|
527
|
+
const has = (re) => re.test(q);
|
|
528
|
+
if (has(/\bimport/)) return MISS_EXAMPLES.import;
|
|
529
|
+
if (has(/\bexport/)) return MISS_EXAMPLES.export;
|
|
530
|
+
if (has(/\b(?:calls?|caller|callee)\b/)) return MISS_EXAMPLES.call;
|
|
531
|
+
if (has(/\b(?:tests?|cover|covering|tested)\b/)) return MISS_EXAMPLES.test;
|
|
532
|
+
if (has(/\b(?:inherit|subclass|extends?|superclass|hierarchy|base class|parent class)\b/)) return MISS_EXAMPLES.inherit;
|
|
533
|
+
if (has(/\b(?:history|when|changed?|commit|touch(?:e[ds])?|who)\b/)) return MISS_EXAMPLES.history;
|
|
534
|
+
if (has(/\b(?:defined?|where|located?|mention)\b/)) return MISS_EXAMPLES.define;
|
|
535
|
+
if (has(/\b(?:mean|means|meaning|definition|vocab)\b/) || /\bwhat(?:'s| is)? an? \w/.test(q)) return MISS_EXAMPLES.meaning;
|
|
536
|
+
if (has(/\b(?:how many|count|number of)\b/)) return MISS_EXAMPLES.count;
|
|
537
|
+
return MISS_DEFAULT;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** The one-line short miss. */
|
|
541
|
+
export function shortMissHint(query) {
|
|
542
|
+
const ex = tailoredExamples(String(query || "").toLowerCase());
|
|
543
|
+
return `couldn't parse this as a graph question. Try: ${ex.join(" or ")}. Type /help for all query shapes.`;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** The exact opening of the engine's full grammar-wall miss — the ONLY miss the
|
|
547
|
+
* short-miss rewrites. Receipt-bearing misses (honest empties, unresolved terms,
|
|
548
|
+
* the empty-graph bootstrap note, compositional misses) never match, so their
|
|
549
|
+
* specific wording + traversal receipts stand. */
|
|
550
|
+
const WALL_MISS_RE = /^couldn't parse this as a graph question\. Try:/;
|
|
551
|
+
|
|
552
|
+
// #2 INTENT LANE — MEMORY/TEACH. "remember that X is a Y", "note that …", or a
|
|
553
|
+
// bare "X is a Y" declarative the graph parser couldn't handle → route to the
|
|
554
|
+
// assert/memory path; when it can't be stored, say what CAN be remembered
|
|
555
|
+
// (LOUD, the working shape) — never the grammar wall, never a silent data loss.
|
|
556
|
+
const TEACH_RE = /^(?:please\s+)?(?:remember|note|keep in mind|jot down|for the record|fyi)\b[:,]?\s*(?:that\s+)?(.+?)[.?!]*$/i;
|
|
557
|
+
const BARE_DECLARATIVE_RE = /^(?:every |each |all |a |an )?[\w-]+ (?:is|are) (?:a |an )?[\w-]+$/i;
|
|
558
|
+
/** Interrogative / auxiliary leads that make an "X is a Y"-shaped line a QUESTION
|
|
559
|
+
* ("what is a cache", "is a module a component"), never a teach declarative. */
|
|
560
|
+
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;
|
|
561
|
+
|
|
562
|
+
/** Sentence forms to try asserting for a teach payload: the payload as-is, and
|
|
563
|
+
* (if it carries no determiner) its "every …" universal — the ACE-OWL shape the
|
|
564
|
+
* grammar actually lands. */
|
|
565
|
+
function assertCandidates(payload) {
|
|
566
|
+
const p = String(payload).trim();
|
|
567
|
+
const out = [p];
|
|
568
|
+
if (!/^(?:every|each|all|a|an)\b/i.test(p)) out.push(`every ${p}`);
|
|
569
|
+
return [...new Set(out)];
|
|
570
|
+
}
|
|
571
|
+
/** The "every X is a Y" rewrite of a declarative, for the "did you mean …" hint. */
|
|
572
|
+
function teachSuggestion(payload) {
|
|
573
|
+
const m = String(payload).match(/^(?:every |each |all |a |an )?([\w-]+) (?:is|are) (?:a |an )?([\w-]+)$/i);
|
|
574
|
+
return m ? `every ${m[1].toLowerCase()} is a ${m[2].toLowerCase()}` : null;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
|
|
578
|
+
const raw = String(query).trim();
|
|
579
|
+
let payload = null;
|
|
580
|
+
const m = raw.match(TEACH_RE);
|
|
581
|
+
if (m && /\b(?:is|are)\b/i.test(m[1])) payload = m[1].trim();
|
|
582
|
+
else if (BARE_DECLARATIVE_RE.test(raw) && !QUESTION_LEAD_RE.test(raw)) payload = raw;
|
|
583
|
+
if (!payload) return null;
|
|
584
|
+
// Try to store it (a live session provides the write target). assertTurn returns
|
|
585
|
+
// the "noted — remembered …" confirmation or null (grammar miss / unknown words).
|
|
586
|
+
if (memoryDir) {
|
|
587
|
+
for (const cand of assertCandidates(payload)) {
|
|
588
|
+
const stored = await assertTurn(cand, { memoryDir, sessionId, focus: null, lexicon });
|
|
589
|
+
if (stored) return { text: stored.answer, via: "assert", miss: false };
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
const suggestion = teachSuggestion(payload);
|
|
593
|
+
const did = suggestion && suggestion !== payload.toLowerCase() ? ` Did you mean: "${suggestion}"?` : "";
|
|
594
|
+
return {
|
|
595
|
+
text: 'I couldn\'t store that — I remember facts in the shape "every X is a Y", where X and Y are '
|
|
596
|
+
+ `words I know.${did} Type /memory to see what I already remember.`,
|
|
597
|
+
via: "teach-miss", miss: true,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// #2 INTENT LANE — META/SELF. Bare self/session questions answered from stats /
|
|
602
|
+
// memory / orientation, never the grammar wall or the raw fact-dump. WOULD-MISS
|
|
603
|
+
// ONLY (the caller gates on a miss) and every pattern is a WHOLE-LINE self/session
|
|
604
|
+
// reference with no graph entity or predicate, so real graph queries ("what does X
|
|
605
|
+
// import", the meta "what does imports mean", "what did i ask before") never match.
|
|
606
|
+
const WHAT_KNOW_RE = /^what\s+(?:do\s+you|d'?you)\s+know(?:\s+so\s+far)?$/;
|
|
607
|
+
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))$/;
|
|
608
|
+
|
|
609
|
+
/** A SHORT memory summary (never a fact dump) for the bare "what do you know". */
|
|
610
|
+
async function memorySummary(memoryDir, graph) {
|
|
611
|
+
const rows = memoryDir ? await memoryFacts(memoryDir) : [];
|
|
612
|
+
if (!rows.length) {
|
|
613
|
+
const hook = moduleCountOf(graph) > 0
|
|
614
|
+
? 'ask about this codebase\'s structure (imports, calls, definitions), or teach me with "every X is a Y"'
|
|
615
|
+
: 'teach me with "every X is a Y", or try general vocabulary like "what is a cache"';
|
|
616
|
+
return `I haven't been told any facts yet — ${hook}. /memory to inspect, /help for commands.`;
|
|
617
|
+
}
|
|
618
|
+
const preds = new Set(rows.map((f) => f.predicate).filter(Boolean));
|
|
619
|
+
const n = rows.length;
|
|
620
|
+
return `I remember ${n} fact${n === 1 ? "" : "s"} across ${preds.size} relation `
|
|
621
|
+
+ `type${preds.size === 1 ? "" : "s"}. Ask "what do you know about <term>", or /memory to explore.`;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async function metaLane(query, { graph, memoryDir }) {
|
|
625
|
+
const q = String(query).trim().toLowerCase().replace(/[?.!]+$/, "").replace(/\s+/g, " ");
|
|
626
|
+
if (WHAT_KNOW_RE.test(q) || q === "what have you learned" || q === "what have you learnt") {
|
|
627
|
+
return { text: await memorySummary(memoryDir, graph), via: "meta" };
|
|
628
|
+
}
|
|
629
|
+
if (META_ORIENT_RE.test(q)) return { text: orientationText(graph), via: "meta" };
|
|
339
630
|
return null;
|
|
340
631
|
}
|
|
341
632
|
|
|
@@ -623,39 +914,121 @@ async function factAnswer(memoryDir, query, envelope, miss) {
|
|
|
623
914
|
return null;
|
|
624
915
|
}
|
|
625
916
|
|
|
626
|
-
/**
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
*
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
*
|
|
635
|
-
*
|
|
636
|
-
|
|
917
|
+
/** "what did i tell you about X" — the multi-turn recall phrasing (a sibling of
|
|
918
|
+
* factAnswer's "what do you know about X" KNOW_ABOUT form): everything remembered
|
|
919
|
+
* that mentions X on either side. */
|
|
920
|
+
const TOLD_ABOUT_RE = /^what\s+(?:did|have)\s+(?:i|we|you)\s+(?:told|tell|said|say)\s+(?:you|me|us)?\s*about\s+(.+?)[?.!\s]*$/i;
|
|
921
|
+
/** "what kind of thing is an X" — the subject-side membership phrasing the grammar
|
|
922
|
+
* doesn't parse: reports X's OWN remembered type (falling back to X's members). */
|
|
923
|
+
const KIND_OF_RE = /^what\s+kind\s+of\s+(?:thing|class|type|category|entity)?\s*(?:is|are)\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
|
|
924
|
+
/** WHOLE-STORE recall (CHATBENCH_006 lever 3): "what did i tell you [last time]",
|
|
925
|
+
* "what facts do you know", "what do you remember" — list EVERY remembered fact
|
|
926
|
+
* (no subject/object term to filter on), cited, higher-trust first. The multi-turn
|
|
927
|
+
* / cross-session assert-recall surfaces that carry no term the grammar can bind. */
|
|
928
|
+
const WHOLE_RECALL_RE = /^(?:what\s+(?:did|have)\s+(?:i|we)\s+(?:told?|tell|said?|say)\s+(?:you|me|us)?(?:\s+(?:last\s+time|before|earlier|previously|already))?|what\s+facts?\s+do\s+you\s+(?:know|have|remember)|what\s+do\s+you\s+(?:know|remember)|what\s+have\s+you\s+(?:learned|learnt|remembered))[?.!\s]*$/i;
|
|
929
|
+
|
|
930
|
+
/** The singular class-noun of the graph entity a term names ("app/lib/a.mjs" →
|
|
931
|
+
* "module", "Widget" → "class"), via the ask engine's own resolver + the loaded
|
|
932
|
+
* graph's class map — or null on a miss/ambiguity/no-graph. Lets forward
|
|
933
|
+
* membership answer over a graph INSTANCE, not just a bare class word. */
|
|
934
|
+
async function entityClassNoun(graph, term) {
|
|
935
|
+
const ent = await resolveEntity(graph, term);
|
|
936
|
+
if (!ent) return null;
|
|
937
|
+
const cls = (graph?.byId?.get?.(ent.id) || (graph?.individuals || []).find((i) => i?.id === ent.id))?.class;
|
|
938
|
+
return cls && CLASS_LABELS[cls] ? CLASS_LABELS[cls][0] : null;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/** ASSERT-RECALL MULTI-TURN READ-BACK (PLAN_CYCLE_4 tail → cycle-005 lever 2):
|
|
942
|
+
* once "every X is a Y" is asserted in an earlier turn, the graded assert-recall
|
|
943
|
+
* cells (B2/C1 assert) query it back across turns in shapes the graph grammar
|
|
944
|
+
* can't parse — so each dies as an honest miss even though "X is a kind of Y" is
|
|
945
|
+
* remembered. This reader answers those declare-then-recall shapes from the
|
|
946
|
+
* reified Facts (readFactRows — trust-bearing), citing each fact's provenance
|
|
947
|
+
* verbatim, higher-trust first:
|
|
948
|
+
* (a) FORWARD membership "is an X a Y" — X a class WORD ("is a module a
|
|
949
|
+
* component") OR a graph INSTANCE ("is app/lib/a.mjs a component", resolved
|
|
950
|
+
* to its class-noun) — yes iff a remembered isa-family fact says so;
|
|
951
|
+
* (b) RECALL "what did i tell you about X" — every remembered fact mentioning X;
|
|
952
|
+
* (c) REVERSE membership — "what is a Y" reports Y's members (object-side), and
|
|
953
|
+
* "what kind of thing is an X" reports X's own type (subject-side first).
|
|
954
|
+
* Miss-only and run AFTER factAnswer returns null, so it never shadows the
|
|
955
|
+
* subject-side answer or a schema hit. Returns { text, replace:true } or null. */
|
|
956
|
+
async function factReadBack(memoryDir, query, envelope, miss, graph = null) {
|
|
637
957
|
if (!miss) return null;
|
|
638
958
|
let normFactTerm;
|
|
639
959
|
try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
|
|
640
960
|
const q = String(query).trim();
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
961
|
+
const rows = await factRows(memoryDir);
|
|
962
|
+
if (!rows.length) return null;
|
|
963
|
+
const isa = rows.filter((f) => ISA_PREDICATES.has(f.predicate));
|
|
964
|
+
const byTrust = (a, b) => b.trust - a.trust;
|
|
965
|
+
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 };
|
|
970
|
+
};
|
|
971
|
+
|
|
972
|
+
// (d) WHOLE-STORE recall (CHATBENCH_006 lever 3) — "what did i tell you last time",
|
|
973
|
+
// "what facts do you know": no term to bind, so list every remembered fact,
|
|
974
|
+
// higher-trust first, each cited. Answers the cross-session assert-recall surfaces.
|
|
975
|
+
if (WHOLE_RECALL_RE.test(q)) {
|
|
976
|
+
const hits = (isa.length ? isa : rows).slice().sort(byTrust);
|
|
977
|
+
if (!hits.length) return null;
|
|
978
|
+
return renderMany(hits);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// (a) FORWARD membership — "is an X a Y". X's fact-subject candidates are the
|
|
982
|
+
// term itself (a class word) AND, when it resolves in the graph, its class-noun
|
|
983
|
+
// (an instance) — so "is app/lib/a.mjs a component" answers off "module …".
|
|
984
|
+
const isaAsk = q.match(ISA_ASK_RE);
|
|
985
|
+
if (isaAsk) {
|
|
986
|
+
const objVariants = factTermVariants(normFactTerm, isaAsk[2]);
|
|
987
|
+
const subjCandidates = new Set(factTermVariants(normFactTerm, isaAsk[1]));
|
|
988
|
+
const noun = await entityClassNoun(graph, isaAsk[1]);
|
|
989
|
+
if (noun) for (const v of factTermVariants(normFactTerm, noun)) subjCandidates.add(v);
|
|
990
|
+
const hit = isa
|
|
991
|
+
.filter((f) => subjCandidates.has(f.subject) && objVariants.has(f.object))
|
|
992
|
+
.sort(byTrust)[0];
|
|
993
|
+
if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
|
|
994
|
+
return null; // no remembered fact — the honest miss stands (never a guessed "no")
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// (b) RECALL — "what did i tell you about X": every remembered fact mentioning X.
|
|
998
|
+
const told = q.match(TOLD_ABOUT_RE);
|
|
999
|
+
if (told) {
|
|
1000
|
+
const variants = factTermVariants(normFactTerm, told[1]);
|
|
1001
|
+
const hits = rows.filter((f) => variants.has(f.subject) || variants.has(f.object)).sort(byTrust);
|
|
1002
|
+
if (!hits.length) return null;
|
|
1003
|
+
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 };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// (c) REVERSE / "what kind of thing" membership. The meta form ("what is a Y")
|
|
1010
|
+
// comes from the parse when present, else recognized directly on a no-parse miss;
|
|
1011
|
+
// "what kind of thing is an X" is recognized regardless (the grammar never parses
|
|
1012
|
+
// it as meta). "what is a Y" reports Y's MEMBERS (object-side); "what kind of
|
|
1013
|
+
// thing is an X" reports X's own TYPE (subject-side first), so both directions
|
|
1014
|
+
// of a single remembered "X is a kind of Y" are queryable.
|
|
644
1015
|
let term = envelope?.parsed?.shape === "meta" ? envelope.parsed.object : null;
|
|
645
|
-
|
|
1016
|
+
let kindOf = false;
|
|
1017
|
+
const mk = q.match(KIND_OF_RE);
|
|
1018
|
+
if (mk) { term = mk[1]; kindOf = true; }
|
|
1019
|
+
else if (!term && !envelope?.parsed) {
|
|
646
1020
|
const m = q.match(/^what\s+(?:is|are)\s+an?\s+(.+?)[?.!\s]*$/i);
|
|
647
1021
|
if (m) term = m[1];
|
|
648
1022
|
}
|
|
649
1023
|
if (!term) return null;
|
|
650
1024
|
const variants = factTermVariants(normFactTerm, term);
|
|
651
|
-
const
|
|
652
|
-
|
|
653
|
-
|
|
1025
|
+
const subjectHits = isa.filter((f) => variants.has(f.subject)).sort(byTrust);
|
|
1026
|
+
const objectHits = isa.filter((f) => variants.has(f.object)).sort(byTrust);
|
|
1027
|
+
const hits = kindOf
|
|
1028
|
+
? (subjectHits.length ? subjectHits : objectHits)
|
|
1029
|
+
: (objectHits.length ? objectHits : subjectHits);
|
|
654
1030
|
if (!hits.length) return null;
|
|
655
|
-
|
|
656
|
-
const n = hits.length - FACT_ANSWER_CAP;
|
|
657
|
-
const extra = n > 0 ? `\n…and ${n} more remembered fact${n === 1 ? "" : "s"}.` : "";
|
|
658
|
-
return { text: shown.join("\n") + extra, replace: true };
|
|
1031
|
+
return renderMany(hits);
|
|
659
1032
|
}
|
|
660
1033
|
|
|
661
1034
|
// ---- W5: corpus on-demand — LOCAL tier only, behind an explicit flag ----
|
|
@@ -738,14 +1111,121 @@ async function recallSummary(memoryDir) {
|
|
|
738
1111
|
}
|
|
739
1112
|
}
|
|
740
1113
|
|
|
1114
|
+
/** "[and/so/…] what about X" — a discourse continuation that re-asks the previous
|
|
1115
|
+
* turn's question with X swapped in. */
|
|
1116
|
+
const WHAT_ABOUT_RE = /^(?:(?:and|so|but|ok|okay|now|then)\s+)*what about\s+(.+?)[?.!\s]*$/i;
|
|
1117
|
+
/** A code-ish name token in a prior query (a path/dotted name, or a CamelCase/
|
|
1118
|
+
* Capitalized symbol) — the subject "what about X" replaces. */
|
|
1119
|
+
const NAME_TOKEN_RE = /\b[\w-]+(?:[/.][\w-]+)+\b|\b[A-Z][A-Za-z0-9_]*\b/;
|
|
1120
|
+
|
|
1121
|
+
/** DISCOURSE CONTINUATION (CHATBENCH_006 lever 2): "what about X" carries the PRIOR
|
|
1122
|
+
* turn's question shape across the turn boundary — re-asking it with X in place of
|
|
1123
|
+
* the previous subject/object. Returns the reconstructed query (parsed like any
|
|
1124
|
+
* subject question, so X resolves and becomes the new focus), or null when there's
|
|
1125
|
+
* no prior query or no name token to swap (→ the ordinary honest miss stands). */
|
|
1126
|
+
function discourseRewrite(query, last) {
|
|
1127
|
+
const m = String(query).match(WHAT_ABOUT_RE);
|
|
1128
|
+
if (!m || !last?.query) return null;
|
|
1129
|
+
const prevQ = String(last.query);
|
|
1130
|
+
if (!NAME_TOKEN_RE.test(prevQ)) return null;
|
|
1131
|
+
const newSubj = m[1].trim();
|
|
1132
|
+
return prevQ.replace(NAME_TOKEN_RE, () => newSubj);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// ---- curated SEON definitions (corpus/seon/definitions.jsonl) ----
|
|
1136
|
+
// A "what is a <term>" for a LEXICON term prefers the curated one-sentence
|
|
1137
|
+
// definition — the richer surface form of the same curated SEON knowledge that the
|
|
1138
|
+
// concept seed reifies — over the bare seon concept fact / schema-docs / honest
|
|
1139
|
+
// miss. Cited via:"corpus/seon". Two guards keep it honest and test-safe:
|
|
1140
|
+
// - it only fires when this repo actually carries the SEON concept seed (a
|
|
1141
|
+
// corpus:seon fact about the term is in memory) — so a repo seeded with only
|
|
1142
|
+
// ConceptNet (or nothing) is byte-unchanged;
|
|
1143
|
+
// - a fact the USER personally asserted (ace:chat) still wins — you told me beats
|
|
1144
|
+
// the corpus definition.
|
|
1145
|
+
|
|
1146
|
+
let seonDefsPromise = null;
|
|
1147
|
+
/** Load corpus/seon/definitions.jsonl once → Map(normFactTerm(term) → definition).
|
|
1148
|
+
* Lazy + failure-tolerated (chat.mjs ethos): any failure degrades to an empty map. */
|
|
1149
|
+
function seonDefinitions() {
|
|
1150
|
+
if (!seonDefsPromise) {
|
|
1151
|
+
seonDefsPromise = (async () => {
|
|
1152
|
+
const { SEON_DEFINITIONS_FILE } = await import("./corpus/conceptnet.mjs");
|
|
1153
|
+
const { normFactTerm } = await import("./memory/core.mjs");
|
|
1154
|
+
const raw = await readFile(SEON_DEFINITIONS_FILE, "utf8");
|
|
1155
|
+
const map = new Map();
|
|
1156
|
+
for (const line of raw.split("\n")) {
|
|
1157
|
+
const t = line.trim();
|
|
1158
|
+
if (!t) continue;
|
|
1159
|
+
try {
|
|
1160
|
+
const row = JSON.parse(t);
|
|
1161
|
+
if (row.term && row.definition) map.set(normFactTerm(row.term), String(row.definition));
|
|
1162
|
+
} catch { /* skip a malformed line, never throw */ }
|
|
1163
|
+
}
|
|
1164
|
+
return map;
|
|
1165
|
+
})().catch(() => new Map());
|
|
1166
|
+
}
|
|
1167
|
+
return seonDefsPromise;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/** The meta term a "what is a X" / "what does X mean" / "define X" question asks
|
|
1171
|
+
* about — from the parse when present, else recognized directly (same required-
|
|
1172
|
+
* article discipline as the grammar's T5). Null when the line isn't such a form. */
|
|
1173
|
+
function metaTermOf(query, envelope) {
|
|
1174
|
+
if (envelope?.parsed?.shape === "meta" && envelope.parsed.object) return envelope.parsed.object;
|
|
1175
|
+
const q = String(query).trim();
|
|
1176
|
+
const m = q.match(/^what\s+(?:is|are)\s+an?\s+(.+?)[?.!\s]*$/i)
|
|
1177
|
+
|| q.match(/^what\s+(?:does|do)\s+(?:an?\s+)?(.+?)\s+means?[?.!\s]*$/i)
|
|
1178
|
+
|| q.match(/^define\s+(?:an?\s+)?(.+?)[?.!\s]*$/i);
|
|
1179
|
+
return m ? m[1].trim() : null;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/** The curated SEON definition to PREFER for a "what is a <lexicon term>", or null.
|
|
1183
|
+
* Gated: the term parses as a meta question, is a grammar-lexicon noun, has a
|
|
1184
|
+
* curated definition, this repo carries the SEON concept seed for it (a corpus:seon
|
|
1185
|
+
* fact), and the user has NOT personally asserted a fact about it. Returns { text,
|
|
1186
|
+
* term } or null. Lazy + failure-tolerated throughout. */
|
|
1187
|
+
async function curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon }) {
|
|
1188
|
+
if (!memoryDir) return null;
|
|
1189
|
+
const term = metaTermOf(query, envelope);
|
|
1190
|
+
if (!term) return null;
|
|
1191
|
+
let normFactTerm;
|
|
1192
|
+
try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
|
|
1193
|
+
// lexicon-noun gate: the curated defs are keyed on SE lexicon terms only.
|
|
1194
|
+
let lex = lexicon;
|
|
1195
|
+
try {
|
|
1196
|
+
if (!lex) { const { loadLexicon } = await import("./grammar/lexicon.mjs"); lex = loadLexicon(); }
|
|
1197
|
+
const { lookupNoun } = await import("./grammar/lexicon.mjs");
|
|
1198
|
+
if (!lookupNoun(lex, term)) return null;
|
|
1199
|
+
} catch { return null; }
|
|
1200
|
+
const def = (await seonDefinitions()).get(normFactTerm(term));
|
|
1201
|
+
if (!def) return null;
|
|
1202
|
+
// tie the definition to the SEON concept seed being present, and let a user fact win.
|
|
1203
|
+
const variants = factTermVariants(normFactTerm, term);
|
|
1204
|
+
const facts = await memoryFacts(memoryDir);
|
|
1205
|
+
const about = facts.filter((f) => variants.has(f.subject) || variants.has(f.object));
|
|
1206
|
+
if (about.some((f) => f.provenance.includes("ace:chat"))) return null; // you told me — that wins
|
|
1207
|
+
if (!about.some((f) => f.provenance.includes("corpus:seon"))) return null; // no SEON seed here
|
|
1208
|
+
return { text: `${def} (source: corpus/seon)`, term };
|
|
1209
|
+
}
|
|
1210
|
+
|
|
741
1211
|
/** A bare question → tmct_ask. When a focus is set AND the graph is in hand we
|
|
742
1212
|
* call ask() directly to thread the focus as contextId (so a pronoun like "it"
|
|
743
1213
|
* resolves to the focus) — building the SAME delimited string dispatchTool emits;
|
|
744
1214
|
* otherwise the unchanged dispatchTool path (which also yields the no-graph error).
|
|
745
1215
|
* A hit updates the focus to the resolved object. Grammar miss / ToolError → a
|
|
746
1216
|
* normal answer, never a crash. */
|
|
747
|
-
async function runAsk(query, { config, source, graph, focus, templates, memoryDir, env }) {
|
|
1217
|
+
async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env }) {
|
|
748
1218
|
const ts = new Date().toISOString();
|
|
1219
|
+
// DISCOURSE ANAPHORA (CHATBENCH_006 levers 1+2): a follow-up like "which of those
|
|
1220
|
+
// are tested" / "how many of those" / "count them" filters or counts the PREVIOUS
|
|
1221
|
+
// answer's entity set. That set is the ids the last dispatched turn cited — carried
|
|
1222
|
+
// on `last.detail.matches`. Threading it as ask()'s `prev` is what lets the anaphora
|
|
1223
|
+
// node resolve instead of the "needs a previous answer" honest miss.
|
|
1224
|
+
const prev = (last?.detail?.matches || []).map((m) => m?.id).filter(Boolean);
|
|
1225
|
+
// The query the ENGINE parses: a "what about X" continuation is rewritten to the
|
|
1226
|
+
// prior shape with X swapped in; everything else parses verbatim. The record and
|
|
1227
|
+
// transcript keep the user's ACTUAL words (`query`), only the parse target changes.
|
|
1228
|
+
const askQuery = discourseRewrite(query, last) ?? query;
|
|
749
1229
|
// W2: the explicit recall forms are answered from memory's folded blocks, never
|
|
750
1230
|
// the graph. Gated on memoryDir — a bare runTurn (no session shell) stays pure.
|
|
751
1231
|
if (memoryDir && RECALL_ASK_RE.test(String(query).trim())) {
|
|
@@ -758,12 +1238,16 @@ async function runAsk(query, { config, source, graph, focus, templates, memoryDi
|
|
|
758
1238
|
let envelope = null;
|
|
759
1239
|
try {
|
|
760
1240
|
let text;
|
|
761
|
-
if (graph && focus?.id) {
|
|
1241
|
+
if (graph && (focus?.id || prev.length)) {
|
|
1242
|
+
// Direct ask() when EITHER a focus is set (thread it as contextId so "it"
|
|
1243
|
+
// binds) OR the previous turn produced a set to refer back to (thread it as
|
|
1244
|
+
// `prev` for the anaphora node). Builds the SAME delimited envelope dispatchTool
|
|
1245
|
+
// emits, so the parse below is identical either way.
|
|
762
1246
|
const { ask } = await import("./ask.mjs");
|
|
763
|
-
const r = ask(graph,
|
|
1247
|
+
const r = ask(graph, askQuery, { contextId: focus?.id ?? null, prev });
|
|
764
1248
|
text = `${r.content}${ASK_ENVELOPE_DELIM}${JSON.stringify(r.tmct_ask, null, 2)}`;
|
|
765
1249
|
} else {
|
|
766
|
-
text = await dispatchTool("tmct_ask", { query }, { config, source });
|
|
1250
|
+
text = await dispatchTool("tmct_ask", { query: askQuery }, { config, source });
|
|
767
1251
|
}
|
|
768
1252
|
const [content, envJson] = text.split(ASK_ENVELOPE_DELIM);
|
|
769
1253
|
answer = content;
|
|
@@ -786,21 +1270,33 @@ async function runAsk(query, { config, source, graph, focus, templates, memoryDi
|
|
|
786
1270
|
// orientation swap below is template wording, so those turns carry via:"template".
|
|
787
1271
|
let via = "composed";
|
|
788
1272
|
let recordMiss = miss;
|
|
789
|
-
//
|
|
790
|
-
//
|
|
791
|
-
//
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
1273
|
+
// MISS handling. The intent lanes + short-miss are RECOGNIZER-gated on the query
|
|
1274
|
+
// text AND only consulted on a would-miss, so a real graph query — a hit, an honest
|
|
1275
|
+
// empty with a receipt, a fuzzy repair — is never hijacked. Order: (1) META/SELF
|
|
1276
|
+
// lane (would-miss), (2) conversational orientation (would-miss), (3) memory
|
|
1277
|
+
// facts/recall (a fact EXTENDS a non-miss schema hit too — NOT miss-gated),
|
|
1278
|
+
// (4) TEACH lane (would-miss), (5) the short tailored miss (would-miss).
|
|
1279
|
+
let handled = false;
|
|
1280
|
+
// (1) #2 META/SELF: bare self/session questions ("what do you know", "what is this
|
|
1281
|
+
// codebase", "how do i start") → a summary / orientation, answered before the
|
|
1282
|
+
// fact-dump readers so "what do you know" gets a summary, not raw facts.
|
|
1283
|
+
if (miss) {
|
|
1284
|
+
const meta = await metaLane(query, { graph, memoryDir });
|
|
1285
|
+
if (meta) { answer = meta.text; via = meta.via; recordMiss = false; handled = true; }
|
|
1286
|
+
}
|
|
1287
|
+
if (!handled && miss && isConversational(query)) {
|
|
1288
|
+
// A conversational miss (a greeting, "what can you do", a very short non-code
|
|
1289
|
+
// line) gets the friendly orientation (module-aware: empty → --repo/tmct init).
|
|
1290
|
+
answer = orientationAnswer(templates, graph); via = "template"; handled = true;
|
|
1291
|
+
} else if (!handled && memoryDir) {
|
|
1292
|
+
// W4: vocabulary/definition questions consult the MEMORY graph's Facts alongside
|
|
1293
|
+
// the schema-docs surface — a remembered fact answers a miss OR extends a (non-
|
|
1294
|
+
// miss) schema hit, cited with its provenance verbatim. Checked BEFORE recall: a
|
|
1295
|
+
// reified fact is stronger evidence than a transcript echo. Subject-side facts
|
|
1296
|
+
// first (factAnswer), then the reverse-membership read-back (factReadBack) so an
|
|
1297
|
+
// asserted "every X is a Y" answers "what is a Y" too.
|
|
802
1298
|
const fact = (await factAnswer(memoryDir, query, envelope, miss))
|
|
803
|
-
?? (await factReadBack(memoryDir, query, envelope, miss));
|
|
1299
|
+
?? (await factReadBack(memoryDir, query, envelope, miss, graph));
|
|
804
1300
|
if (fact) {
|
|
805
1301
|
answer = fact.replace ? fact.text : `${answer}\n${fact.text}`;
|
|
806
1302
|
via = "fact";
|
|
@@ -817,6 +1313,34 @@ async function runAsk(query, { config, source, graph, focus, templates, memoryDi
|
|
|
817
1313
|
}
|
|
818
1314
|
}
|
|
819
1315
|
}
|
|
1316
|
+
// CURATED SEON DEFINITION (corpus/seon) — a "what is a <lexicon term>" prefers the
|
|
1317
|
+
// curated prose definition over the seon concept fact / schema-docs / honest miss.
|
|
1318
|
+
// Runs after the fact branch and overrides its corpus-fact answer (via:"fact"), but
|
|
1319
|
+
// curatedDefinitionAnswer itself defers to a user-asserted (ace:chat) fact, so a
|
|
1320
|
+
// "you told me" answer already standing is left untouched. Skips the meta/self +
|
|
1321
|
+
// conversational lanes (via:"meta"/"template"), which answer a different question.
|
|
1322
|
+
if (via === "composed" || via === "fact") {
|
|
1323
|
+
const def = await curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon });
|
|
1324
|
+
if (def) { answer = def.text; via = "corpus/seon"; recordMiss = false; }
|
|
1325
|
+
}
|
|
1326
|
+
// (4) #2 TEACH lane — a teach-shaped would-miss nothing above answered: route to
|
|
1327
|
+
// memory, or say what CAN be remembered (LOUD), never the wall / a silent drop.
|
|
1328
|
+
if (miss && recordMiss && via === "composed") {
|
|
1329
|
+
const taught = await teachLane(query, { memoryDir, sessionId, lexicon });
|
|
1330
|
+
if (taught) { answer = taught.text; via = taught.via; recordMiss = taught.miss; }
|
|
1331
|
+
}
|
|
1332
|
+
// (5) #1 SHORT TAILORED MISS — replace ONLY the engine's full grammar cheat-sheet
|
|
1333
|
+
// wall (WALL_MISS_RE). Receipt-bearing misses keep their specific wording.
|
|
1334
|
+
if (miss && recordMiss && via === "composed" && WALL_MISS_RE.test(answer)) {
|
|
1335
|
+
answer = shortMissHint(query); via = "miss";
|
|
1336
|
+
}
|
|
1337
|
+
// #4 HONEST-EMPTY POLISH — an empty CODE graph: any still-standing engine
|
|
1338
|
+
// dead-end (an honest empty, the short miss, the bootstrap note) carries the exit
|
|
1339
|
+
// toward a real graph, unless it already points there. Only when genuinely empty.
|
|
1340
|
+
if (recordMiss && (via === "composed" || via === "miss")
|
|
1341
|
+
&& 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\`.)`;
|
|
1343
|
+
}
|
|
820
1344
|
// W5 (flag-gated, default OFF): an unknown-term miss may consult the LOCAL
|
|
821
1345
|
// committed corpus slice — a hit APPENDS a grounded, licence-cited aside under
|
|
822
1346
|
// the honest miss (the miss itself stands; the aside is context, not an answer).
|
|
@@ -975,7 +1499,14 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
975
1499
|
return { ...finished, last: { query: line, answer: finished.answer, detail: finished.detail ?? null } };
|
|
976
1500
|
};
|
|
977
1501
|
|
|
978
|
-
//
|
|
1502
|
+
// Slash-optional system commands: a bare leading command word ("stats",
|
|
1503
|
+
// "memory", "describe X") is routed to its slash form BEFORE the conversational
|
|
1504
|
+
// layer, so a forgiving shell answers "stats" the way it answers "/stats" instead
|
|
1505
|
+
// of falling through to the generic orientation.
|
|
1506
|
+
const bareCmd = asBareCommand(line);
|
|
1507
|
+
if (bareCmd) return withLast(await runCommand(bareCmd, ctx));
|
|
1508
|
+
|
|
1509
|
+
// Conversational layer next (greetings, thanks, help, bye, why/say-more) — these
|
|
979
1510
|
// resolve no entity and carry their own preserved `last`.
|
|
980
1511
|
const convo = conversationalTurn(line, ctx);
|
|
981
1512
|
if (convo) return convo;
|
|
@@ -989,10 +1520,27 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
989
1520
|
const asserted = await assertTurn(line, ctx);
|
|
990
1521
|
if (asserted) return withLast(asserted);
|
|
991
1522
|
}
|
|
1523
|
+
// MEMORY-STORE counts first ("how many facts / utterances do you know") — the
|
|
1524
|
+
// memory graph owns Facts + Utterances, so these are answerable and consistent
|
|
1525
|
+
// with `/memory`. Checked before answerCount (which reads the CODE graph and would
|
|
1526
|
+
// otherwise say "I can't count facts"); it only speaks for a memory-class noun, so
|
|
1527
|
+
// structural counts (classes/functions/…) and sessions fall through unaffected.
|
|
1528
|
+
if (memoryDir) {
|
|
1529
|
+
const memCount = await answerMemoryCount(memoryDir, line);
|
|
1530
|
+
if (memCount != null) return withLast(plainTurn(line, memCount, { via: "count", focus }));
|
|
1531
|
+
}
|
|
992
1532
|
// Aggregate/count questions are answered mechanically off the loaded graph header,
|
|
993
1533
|
// BEFORE falling through to the ask engine (focus unchanged — a count names no entity).
|
|
994
1534
|
const count = answerCount(graph, line);
|
|
995
|
-
if (count != null)
|
|
1535
|
+
if (count != null) {
|
|
1536
|
+
// An "I can't count <noun>" from a bare kind may still be answerable from an
|
|
1537
|
+
// ASSERTED vocabulary fact ("every class is a type" → "how many types" = the
|
|
1538
|
+
// class count). countFromFacts declines on a real graph kind, so ordinary
|
|
1539
|
+
// counts are unaffected; it only speaks for a remembered object noun.
|
|
1540
|
+
const viaFact = memoryDir ? await countFromFacts(graph, memoryDir, line) : null;
|
|
1541
|
+
if (viaFact != null) return withLast(plainTurn(line, viaFact, { via: "fact", focus }));
|
|
1542
|
+
return withLast(plainTurn(line, count, { via: "count", focus }));
|
|
1543
|
+
}
|
|
996
1544
|
return withLast(await runAsk(line, ctx));
|
|
997
1545
|
}
|
|
998
1546
|
|
|
@@ -1013,11 +1561,20 @@ export const SEED_PREFER = ["rdfs:subClassOf", "rdf:type", "mgx:usedFor", "mgx:p
|
|
|
1013
1561
|
* corpus seed, so re-runs skip without even reading the slice. */
|
|
1014
1562
|
export const SEED_MARKER_REL = join(".tmct", "memory", "corpus-seed.json");
|
|
1015
1563
|
|
|
1016
|
-
/** Seed the
|
|
1017
|
-
*
|
|
1018
|
-
*
|
|
1019
|
-
*
|
|
1020
|
-
*
|
|
1564
|
+
/** Seed the starter corpus into <repo>/.tmct/memory once, in TWO passes:
|
|
1565
|
+
* 1. the curated SEON ontology (corpus/seon/concepts.jsonl) FIRST and UNCAPPED
|
|
1566
|
+
* — it is small + fully curated (the SE vocabulary + orientation facts),
|
|
1567
|
+
* tagged "corpus:seon", so a fresh repo knows the curated terms before any
|
|
1568
|
+
* general ConceptNet noise;
|
|
1569
|
+
* 2. THEN the capped ConceptNet slice (the definitional band first, SEED_LIMIT
|
|
1570
|
+
* facts), tagged "corpus:conceptnet".
|
|
1571
|
+
* seon runs first so its curated facts win the content-hash idempotency race — a
|
|
1572
|
+
* term the ConceptNet slice also carries keeps the seon provenance. Idempotent
|
|
1573
|
+
* twice over (the marker short-circuits; seedMemory content-hashes fact ids) and
|
|
1574
|
+
* failure-tolerated: a missing/broken corpus degrades to the unseeded bootstrap —
|
|
1575
|
+
* never an error before the prompt. Returns { appended, skipped, total, seon,
|
|
1576
|
+
* conceptnet } on a fresh seed (the banner counts stay honest), null when
|
|
1577
|
+
* skipped/failed. */
|
|
1021
1578
|
async function seedBootstrapMemory(repo) {
|
|
1022
1579
|
const marker = join(repo, SEED_MARKER_REL);
|
|
1023
1580
|
try {
|
|
@@ -1025,11 +1582,22 @@ async function seedBootstrapMemory(repo) {
|
|
|
1025
1582
|
return null; // already seeded — the marker is authoritative
|
|
1026
1583
|
} catch { /* no marker → first run */ }
|
|
1027
1584
|
try {
|
|
1028
|
-
const { seedMemory } = await import("./corpus/conceptnet.mjs");
|
|
1029
|
-
|
|
1585
|
+
const { seedMemory, SEON_CONCEPTS_FILE } = await import("./corpus/conceptnet.mjs");
|
|
1586
|
+
// (1) curated SEON ontology — uncapped, seon-tagged, seeded FIRST.
|
|
1587
|
+
const seon = await seedMemory(repo, { slicePath: SEON_CONCEPTS_FILE, provenancePrefix: "corpus:seon" });
|
|
1588
|
+
// (2) the capped ConceptNet band — byte-identical to the prior single seed.
|
|
1589
|
+
const conceptnet = await seedMemory(repo, { limit: SEED_LIMIT, prefer: SEED_PREFER });
|
|
1590
|
+
const res = {
|
|
1591
|
+
appended: seon.appended + conceptnet.appended,
|
|
1592
|
+
skipped: seon.skipped + conceptnet.skipped,
|
|
1593
|
+
total: seon.total + conceptnet.total,
|
|
1594
|
+
seon: seon.appended,
|
|
1595
|
+
conceptnet: conceptnet.appended,
|
|
1596
|
+
};
|
|
1030
1597
|
await mkdir(dirname(marker), { recursive: true });
|
|
1031
1598
|
await writeFile(marker, JSON.stringify({
|
|
1032
|
-
seededAt: new Date().toISOString(), limit: SEED_LIMIT,
|
|
1599
|
+
seededAt: new Date().toISOString(), limit: SEED_LIMIT,
|
|
1600
|
+
appended: res.appended, skipped: res.skipped, seon: res.seon, conceptnet: res.conceptnet,
|
|
1033
1601
|
}) + "\n");
|
|
1034
1602
|
return res;
|
|
1035
1603
|
} catch {
|
|
@@ -1086,16 +1654,27 @@ export async function createSession({
|
|
|
1086
1654
|
cwd = process.cwd(),
|
|
1087
1655
|
gitRoot = gitToplevel,
|
|
1088
1656
|
} = {}) {
|
|
1657
|
+
// Graph resolution order for the chat surface (documented; --repo wins):
|
|
1658
|
+
// 1. --repo <path> → pins <path>/.tmct/graph.json (repo AND graph).
|
|
1659
|
+
// 2. TMCT_GRAPH_FILE env → loads that graph anywhere (loadConfig reads it), so
|
|
1660
|
+
// `TMCT_GRAPH_FILE=<path> tmct chat` works even inside a git repo — the chat
|
|
1661
|
+
// surface used to ignore it (only the `cli` tool path honoured it). The repo
|
|
1662
|
+
// for logs/memory is still the git root / cwd; only the graph file is overridden.
|
|
1663
|
+
// 3. git root → <root>/.tmct/graph.json (the default target).
|
|
1664
|
+
// 4. cwd → <cwd>/.tmct/graph.json (not a git repo).
|
|
1089
1665
|
// Default the target to the GIT ROOT, not raw cwd: running from a nested package
|
|
1090
1666
|
// dir (npm sets cwd there) would otherwise index only that package's ~few modules
|
|
1091
|
-
// instead of the whole repo.
|
|
1667
|
+
// instead of the whole repo.
|
|
1092
1668
|
let repo;
|
|
1093
1669
|
let config;
|
|
1094
1670
|
if (repoPath) { repo = repoPath; config = configFor(repoPath); }
|
|
1095
1671
|
else {
|
|
1096
1672
|
const root = gitRoot(cwd);
|
|
1097
|
-
|
|
1098
|
-
|
|
1673
|
+
repo = root || cwd;
|
|
1674
|
+
const envGraph = env.TMCT_GRAPH_FILE && String(env.TMCT_GRAPH_FILE).trim();
|
|
1675
|
+
// TMCT_GRAPH_FILE (via loadConfig) overrides the repo-derived default graph path;
|
|
1676
|
+
// otherwise the repo's own .tmct/graph.json is the target.
|
|
1677
|
+
config = envGraph ? loadConfig(env, cwd) : { graphFile: join(repo, DEFAULT_GRAPH_REL) };
|
|
1099
1678
|
}
|
|
1100
1679
|
|
|
1101
1680
|
// Load the graph once up front — the banner needs the module count, and focus/`it`
|
|
@@ -1159,14 +1738,22 @@ export async function createSession({
|
|
|
1159
1738
|
if (empty && String(env.TMCT_NO_SEED || "") !== "1") {
|
|
1160
1739
|
seeded = await seedBootstrapMemory(repo);
|
|
1161
1740
|
}
|
|
1741
|
+
// #3/#5: 0 modules means no code graph to answer structure questions from —
|
|
1742
|
+
// whether the graph file is absent (empty bootstrap) OR present with no code
|
|
1743
|
+
// entities (the degenerate trap). Both get orienting, non-over-promising banner
|
|
1744
|
+
// + greeting messaging rather than a silent dead-end.
|
|
1745
|
+
const noCodeGraph = moduleCount === 0;
|
|
1162
1746
|
const bannerLines = [
|
|
1163
|
-
|
|
1164
|
-
//
|
|
1165
|
-
? `tmct chat — ${repo} — no graph loaded — starting empty; ` +
|
|
1747
|
+
noCodeGraph
|
|
1748
|
+
// No code graph: honest, orienting messaging — never an error before the prompt.
|
|
1749
|
+
? `tmct chat — ${repo} — no code graph loaded — ${empty ? "starting empty" : "graph has no code entities"}; ` +
|
|
1166
1750
|
`the conversation is remembered to ${DEFAULT_GRAPH_REL} — log ${logFile}`
|
|
1167
1751
|
: `tmct chat — ${repo} — ${moduleCount} module(s) — log ${logFile}`,
|
|
1168
|
-
// the honest seed line appears ONLY on the run that actually seeded
|
|
1169
|
-
|
|
1752
|
+
// the honest seed line appears ONLY on the run that actually seeded — the count
|
|
1753
|
+
// is the TOTAL appended, split into the curated SEON ontology + the ConceptNet band.
|
|
1754
|
+
...(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"'] : []),
|
|
1170
1757
|
"pass --repo <path> to target a different repo",
|
|
1171
1758
|
"ask a question, or /help for commands (/stats for an overview) — /exit to leave",
|
|
1172
1759
|
];
|