@polycode-projects/the-mechanical-code-talker 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -3
- package/ROADMAP.md +411 -1
- package/bin/tmct.mjs +56 -1
- package/data/templates/grammar-rules.toml +89 -0
- package/data/templates/responses.jsonl +13 -0
- package/package.json +30 -2
- package/src/ask-nlp.mjs +8 -10
- package/src/ask-vocab.mjs +22 -0
- package/src/ask.mjs +80 -2
- package/src/chat.mjs +576 -50
- package/src/corpus/conceptnet.mjs +14 -2
- package/src/corpus/templates.mjs +94 -10
- package/src/finish.mjs +443 -0
- package/src/hash.mjs +32 -0
- package/src/init.mjs +264 -0
- package/src/interpret/normalize.mjs +34 -0
- package/src/interpret/strategies/keywords.mjs +57 -1
- package/src/memory/blocks.mjs +23 -3
- package/src/memory/core.mjs +257 -16
- package/src/memory/fold.mjs +0 -0
- package/src/memory/inspect.mjs +141 -0
- package/src/memory/trust.mjs +113 -0
- package/src/prose-nlp.mjs +14 -16
- package/src/providers/bootstrap.mjs +24 -0
- package/src/providers/fixture.mjs +118 -0
- package/src/providers/graph-service.mjs +312 -0
- package/src/repository-interface.mjs +318 -0
- package/src/server.mjs +44 -28
- package/src/sessions.mjs +13 -2
- package/src/syllogise.mjs +0 -0
- package/src/toml-config.mjs +14 -0
- package/src/wink-model.mjs +74 -0
package/src/chat.mjs
CHANGED
|
@@ -37,9 +37,9 @@
|
|
|
37
37
|
// load-bearing — see its docblock), telemetry, and the close. runChat is the
|
|
38
38
|
// readline shell over it; src/tui/app.mjs is the Ink shell over the same sink.
|
|
39
39
|
|
|
40
|
-
import { join } from "node:path";
|
|
40
|
+
import { join, dirname } from "node:path";
|
|
41
41
|
import { createWriteStream } from "node:fs";
|
|
42
|
-
import { mkdir, readFile } from "node:fs/promises";
|
|
42
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
43
43
|
import { createInterface } from "node:readline/promises";
|
|
44
44
|
import { spawnSync } from "node:child_process";
|
|
45
45
|
import { dispatchTool } from "./server.mjs";
|
|
@@ -49,6 +49,8 @@ import { SESSIONS_DIR_REL, appendSessionToGraph } from "./sessions.mjs";
|
|
|
49
49
|
import { uuidv7 } from "./uuid.mjs";
|
|
50
50
|
import { createTelemetry } from "./telemetry.mjs";
|
|
51
51
|
import * as defaultSource from "./source.mjs";
|
|
52
|
+
import { loadTemplates, render as renderTemplate } from "./corpus/templates.mjs";
|
|
53
|
+
import { finish } from "./finish.mjs";
|
|
52
54
|
|
|
53
55
|
// uuidv7 lives in ./uuid.mjs (shared with telemetry + the bench stamp); re-exported
|
|
54
56
|
// here because callers/tests still import it from chat.mjs.
|
|
@@ -211,15 +213,43 @@ export function isConversational(query) {
|
|
|
211
213
|
return q.split(/\s+/).filter(Boolean).length <= 3 && !codeish;
|
|
212
214
|
}
|
|
213
215
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
216
|
+
// ---- the response-template library (W1: templates → render path) ----
|
|
217
|
+
// The WORDING of the conversational/orientation surfaces lives in
|
|
218
|
+
// data/templates/responses.jsonl (corpus/templates.mjs) — the template library is
|
|
219
|
+
// load-bearing for these turns. The recognizer sets below stay code: they decide
|
|
220
|
+
// WHICH template answers, never what it says. Loading is lazy + failure-tolerated
|
|
221
|
+
// (chat.mjs ethos: a turn never crashes) — a broken/missing data file degrades to
|
|
222
|
+
// one short honest line, never a throw before the prompt.
|
|
223
|
+
|
|
224
|
+
/** Template ids (data/templates/responses.jsonl) for the surfaces chat renders. */
|
|
225
|
+
const T_GREETING = "conversational-greeting";
|
|
226
|
+
const T_GREETING_BY_PHRASE = {
|
|
227
|
+
"hello there": "conversational-greeting-hello-there",
|
|
228
|
+
"good morning": "conversational-greeting-good-morning",
|
|
229
|
+
"good afternoon": "conversational-greeting-good-afternoon",
|
|
230
|
+
"good evening": "conversational-greeting-good-evening",
|
|
231
|
+
};
|
|
232
|
+
const T_THANKS = "conversational-thanks";
|
|
233
|
+
const T_FAREWELL = "conversational-farewell";
|
|
234
|
+
const T_ORIENTATION = "orientation-friendly";
|
|
235
|
+
const T_WHY_EMPTY = "miss-no-previous-answer";
|
|
236
|
+
|
|
237
|
+
/** The degraded line when the template library itself cannot load — a packaging
|
|
238
|
+
* failure said out loud, never a crashed turn or a silently different answer. */
|
|
239
|
+
const TEMPLATES_UNAVAILABLE = "response templates unavailable — ask a question, or /help for commands.";
|
|
240
|
+
|
|
241
|
+
let templatesPromise = null;
|
|
242
|
+
/** Load data/templates/responses.jsonl once per process; null on failure. */
|
|
243
|
+
function chatTemplates() {
|
|
244
|
+
if (!templatesPromise) templatesPromise = loadTemplates().catch(() => null);
|
|
245
|
+
return templatesPromise;
|
|
246
|
+
}
|
|
247
|
+
/** Strict render through the loaded map; null on any failure (no map / unknown
|
|
248
|
+
* id / missing slot) so every call site degrades explicitly, never half-fills. */
|
|
249
|
+
function tRender(templates, id, slots = {}) {
|
|
250
|
+
if (!templates) return null;
|
|
251
|
+
try { return renderTemplate(id, slots, templates); } catch { return null; }
|
|
252
|
+
}
|
|
223
253
|
|
|
224
254
|
// ---- conversational (ELIZA/Zork-manners) templated layer ----
|
|
225
255
|
// A small CLOSED set of human expressions handled with a TEMPLATED response BEFORE
|
|
@@ -248,16 +278,8 @@ const WHY = new Set([
|
|
|
248
278
|
"elaborate", "tell me more", "more detail", "expand",
|
|
249
279
|
]);
|
|
250
280
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const GREETING_LINES = {
|
|
254
|
-
"hello there": 'Hello there. (A hollow voice says, "fool.") Ask me about this codebase, or /help.',
|
|
255
|
-
"good morning": "Good morning. Ask me about this codebase, or /help.",
|
|
256
|
-
"good afternoon": "Good afternoon. Ask me about this codebase, or /help.",
|
|
257
|
-
"good evening": "Good evening. Ask me about this codebase, or /help.",
|
|
258
|
-
};
|
|
259
|
-
const ACK = "Any time. Ask another, or /help for what I can do.";
|
|
260
|
-
const FAREWELL = "Bye — flushing the session log. Come back with a question any time.";
|
|
281
|
+
// (Greeting/thanks/farewell wording moved to data/templates/responses.jsonl — W1.
|
|
282
|
+
// The expression-specific greeting variants map through T_GREETING_BY_PHRASE above.)
|
|
261
283
|
|
|
262
284
|
/** Re-render the last answer in verbose form: the previous query + its full answer
|
|
263
285
|
* plus the ask envelope's traversal receipt and the matched entities (the detail a
|
|
@@ -289,22 +311,31 @@ export function renderVerbose(last) {
|
|
|
289
311
|
function conversationalTurn(line, ctx) {
|
|
290
312
|
const raw = String(line);
|
|
291
313
|
const q = raw.toLowerCase().replace(/[.!?]+$/, "").replace(/\s+/g, " ").trim();
|
|
292
|
-
const
|
|
314
|
+
const t = (id) => tRender(ctx.templates, id) ?? TEMPLATES_UNAVAILABLE;
|
|
315
|
+
const mk = (answer, { end = false, miss = false, via = "template" } = {}) => {
|
|
293
316
|
const ts = new Date().toISOString();
|
|
294
317
|
return {
|
|
295
318
|
answer,
|
|
296
319
|
logLines: [ts, `> ${raw}`, answer, ""],
|
|
297
|
-
record: { type: "turn", ts, query: raw, conversational: true, resolvedIds: [], answeredIds: [], miss },
|
|
320
|
+
record: { type: "turn", ts, query: raw, conversational: true, via, resolvedIds: [], answeredIds: [], miss },
|
|
298
321
|
focus: ctx.focus,
|
|
299
322
|
last: ctx.last, // a conversational turn never overwrites the last real answer
|
|
300
323
|
...(end ? { end: true } : {}),
|
|
301
324
|
};
|
|
302
325
|
};
|
|
303
|
-
if (BYE.has(q)) return mk(
|
|
304
|
-
if (WHY.has(q)) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
326
|
+
if (BYE.has(q)) return mk(t(T_FAREWELL), { end: true });
|
|
327
|
+
if (WHY.has(q)) {
|
|
328
|
+
const v = renderVerbose(ctx.last);
|
|
329
|
+
// The empty-state hint is template wording (via:"template", the data row wins;
|
|
330
|
+
// renderVerbose's own string is the degraded fallback for direct library callers).
|
|
331
|
+
// A real expansion re-renders the LAST ANSWER — its wording is the prior answer's,
|
|
332
|
+
// not a template's, so it carries via:"conversational".
|
|
333
|
+
if (v.empty) return mk(tRender(ctx.templates, T_WHY_EMPTY) ?? v.text, { miss: true });
|
|
334
|
+
return mk(v.text, { via: "conversational" });
|
|
335
|
+
}
|
|
336
|
+
if (GREET.has(q)) return mk(t(T_GREETING_BY_PHRASE[q] || T_GREETING));
|
|
337
|
+
if (THANKS.has(q)) return mk(t(T_THANKS));
|
|
338
|
+
if (q === "help" || q === "?" || HELP_PHRASES.some((re) => re.test(raw))) return mk(t(T_ORIENTATION));
|
|
308
339
|
return null;
|
|
309
340
|
}
|
|
310
341
|
|
|
@@ -348,6 +379,7 @@ export async function helpText() {
|
|
|
348
379
|
["<question>", "ask the graph in plain English (the default for any non-slash line)"],
|
|
349
380
|
...Object.entries(COMMANDS).map(([name, s]) => [`/${name}${s.arg ? (s.optional ? ` [${s.arg}]` : ` <${s.arg}>`) : ""}`, s.help]),
|
|
350
381
|
["/stats", "a one-screen overview: entity counts, relationship counts, packages"],
|
|
382
|
+
["/memory [verbose]", "what tmct remembers: facts, utterances, sessions, folded blocks"],
|
|
351
383
|
["/focus <symbol>", "set the current focus (reused by 'it'/'this' and no-arg entity commands)"],
|
|
352
384
|
["/help", "this list"],
|
|
353
385
|
["/exit", "leave the session (also Ctrl+C / Ctrl+D)"],
|
|
@@ -368,14 +400,360 @@ export async function helpText() {
|
|
|
368
400
|
].join("\n");
|
|
369
401
|
}
|
|
370
402
|
|
|
403
|
+
// ---- W2: memory recall on the miss path (retrieveBlocks → runAsk) ----
|
|
404
|
+
|
|
405
|
+
/** The conservative relevance floor a folded-session block must clear (the
|
|
406
|
+
* retrieveBlocks idf×(1+rank) score) before an honest ask-miss is answered from
|
|
407
|
+
* memory. Calibrated in the small-corpus regime (test/wiring-recall.test.mjs):
|
|
408
|
+
* a genuine re-ask scores ~4, a frame-word coincidence ~1. */
|
|
409
|
+
export const RECALL_MIN_SCORE = 2.0;
|
|
410
|
+
|
|
411
|
+
/** How many blocks the miss path consults (the W2 seam: retrieveBlocks(dir, q, 2)). */
|
|
412
|
+
const RECALL_TOP_K = 2;
|
|
413
|
+
|
|
414
|
+
/** Frame/stop words ignored when checking that a recalled Q genuinely shares
|
|
415
|
+
* vocabulary with the live query — at least one shared CONTENT word is required,
|
|
416
|
+
* so "which …" alone can never masquerade as a memory. */
|
|
417
|
+
const RECALL_STOPWORDS = new Set([
|
|
418
|
+
"the", "a", "an", "and", "or", "for", "with", "about", "into", "from",
|
|
419
|
+
"which", "what", "who", "how", "when", "where", "why",
|
|
420
|
+
"does", "do", "did", "is", "are", "was", "were", "there",
|
|
421
|
+
"me", "my", "we", "i", "you", "it", "this", "that", "in", "of", "to",
|
|
422
|
+
]);
|
|
423
|
+
const recallWords = (s) => new Set(
|
|
424
|
+
String(s).toLowerCase().split(/[^a-z0-9.]+/).filter((w) => w.length >= 3 && !RECALL_STOPWORDS.has(w)),
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
/** Decode a uuidv7 id's leading 48-bit unix-ms timestamp → "YYYY-MM-DD", or null
|
|
428
|
+
* (block ids ARE session uuidv7s — fold.mjs sets block id = session id). */
|
|
429
|
+
function uuidv7Day(id) {
|
|
430
|
+
const hex = String(id || "").replace(/-/g, "").slice(0, 12);
|
|
431
|
+
if (!/^[0-9a-f]{12}$/i.test(hex)) return null;
|
|
432
|
+
const ms = parseInt(hex, 16);
|
|
433
|
+
return ms > 0 && Number.isFinite(ms) ? new Date(ms).toISOString().slice(0, 10) : null;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Pick the recalled block's Q/A pair most relevant to the query (content-word
|
|
437
|
+
* overlap; ties → first). Null when NO pair shares a content word — the block
|
|
438
|
+
* matched on packaging, not substance, so the honest miss must stand. */
|
|
439
|
+
function bestQaPair(blockText, query) {
|
|
440
|
+
const qWords = recallWords(query);
|
|
441
|
+
const pairs = [];
|
|
442
|
+
let open = null;
|
|
443
|
+
for (const line of String(blockText).split("\n")) {
|
|
444
|
+
if (line.startsWith("Q: ")) { open = { q: line.slice(3), a: "" }; pairs.push(open); }
|
|
445
|
+
else if (open && line.startsWith("A: ")) open.a = line.slice(3);
|
|
446
|
+
}
|
|
447
|
+
let best = null;
|
|
448
|
+
let bestScore = 0;
|
|
449
|
+
for (const p of pairs) {
|
|
450
|
+
let score = 0;
|
|
451
|
+
for (const w of recallWords(p.q)) if (qWords.has(w)) score += 1;
|
|
452
|
+
if (score > bestScore) { best = p; bestScore = score; }
|
|
453
|
+
}
|
|
454
|
+
return best;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** W2 seam: consult the folded-session block index for an honest miss. A
|
|
458
|
+
* sufficiently-relevant hit returns the recalled Q/A, framed and cited to its
|
|
459
|
+
* session; anything less returns null and the miss stands unchanged. Lazy +
|
|
460
|
+
* failure-tolerated (chat.mjs ethos): a broken memory store degrades to null. */
|
|
461
|
+
async function recallFromBlocks(memoryDir, query) {
|
|
462
|
+
try {
|
|
463
|
+
const { retrieveBlocks } = await import("./memory/blocks.mjs");
|
|
464
|
+
const hits = await retrieveBlocks(memoryDir, query, RECALL_TOP_K);
|
|
465
|
+
const best = hits[0];
|
|
466
|
+
if (!best || best.score < RECALL_MIN_SCORE || !best.text) return null;
|
|
467
|
+
const pair = bestQaPair(best.text, query);
|
|
468
|
+
if (!pair) return null;
|
|
469
|
+
const day = uuidv7Day(best.id);
|
|
470
|
+
const cite = `session ${String(best.id).slice(0, 8)}${day ? `, ${day}` : ""}`;
|
|
471
|
+
const qa = pair.a ? `Q: ${pair.q}\n A: ${pair.a}` : `Q: ${pair.q}`;
|
|
472
|
+
return `you asked about this before (${cite}):\n ${qa}`;
|
|
473
|
+
} catch {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ---- W4: asserted Facts → answers (the memory graph's reified triples) ----
|
|
479
|
+
|
|
480
|
+
/** How a stored fact predicate reads in English — one phrase per predicate the
|
|
481
|
+
* two writers (the ACE grammar, the ConceptNet map) actually emit. An unknown
|
|
482
|
+
* predicate renders verbatim rather than being guessed around. */
|
|
483
|
+
const FACT_PREDICATE_PHRASES = {
|
|
484
|
+
"rdfs:subClassOf": "is a kind of",
|
|
485
|
+
"rdf:type": "is a",
|
|
486
|
+
"owl:disjointWith": "is not a",
|
|
487
|
+
"mgx:partOf": "is part of",
|
|
488
|
+
"mgx:hasA": "has",
|
|
489
|
+
"mgx:usedFor": "is used for",
|
|
490
|
+
"mgx:capableOf": "can",
|
|
491
|
+
"mgx:atLocation": "is found in",
|
|
492
|
+
"mgx:causes": "causes",
|
|
493
|
+
"mgx:hasProperty": "is",
|
|
494
|
+
"mgx:madeOf": "is made of",
|
|
495
|
+
"mgx:receivesAction": "can be",
|
|
496
|
+
"mgx:createdBy": "is created by",
|
|
497
|
+
"mgx:mannerOf": "is a way to",
|
|
498
|
+
"mgx:desires": "wants",
|
|
499
|
+
"mgx:locatedNear": "is typically near",
|
|
500
|
+
"mgx:motivatedByGoal": "is motivated by",
|
|
501
|
+
"mgx:obstructedBy": "can be prevented by",
|
|
502
|
+
"mgx:causesDesire": "makes you want to",
|
|
503
|
+
"mgx:hasSubevent": "involves",
|
|
504
|
+
"mgx:hasFirstSubevent": "begins with",
|
|
505
|
+
"mgx:hasLastSubevent": "ends with",
|
|
506
|
+
"mgx:hasPrerequisite": "requires",
|
|
507
|
+
};
|
|
508
|
+
const factPhrase = (f) => `${f.subject} ${FACT_PREDICATE_PHRASES[f.predicate] || f.predicate} ${f.object}`;
|
|
509
|
+
|
|
510
|
+
/** One rendered fact line: "you told me" when the chat asserted it (an ace:chat
|
|
511
|
+
* provenance tag), "i learned" for corpus-only facts — provenance VERBATIM. */
|
|
512
|
+
function renderFactLine(f) {
|
|
513
|
+
const lead = f.provenance.includes("ace:chat") ? "you told me" : "i learned";
|
|
514
|
+
return `${lead}: ${factPhrase(f)}${f.provenance ? ` (source: ${f.provenance})` : ""}`;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Read every reified Fact out of the memory graph as plain {subject, predicate,
|
|
518
|
+
* object, provenance} rows. Lazy + failure-tolerated: no memory → []. */
|
|
519
|
+
async function memoryFacts(memoryDir) {
|
|
520
|
+
try {
|
|
521
|
+
const { loadMemory } = await import("./memory/core.mjs");
|
|
522
|
+
const m = await loadMemory(memoryDir);
|
|
523
|
+
const out = [];
|
|
524
|
+
for (const ind of m.individuals || []) {
|
|
525
|
+
if (ind?.class !== "Fact") continue;
|
|
526
|
+
const get = (k) => (ind.attributes || []).find((a) => a.key === k)?.value || "";
|
|
527
|
+
out.push({ subject: get("subject"), predicate: get("predicate"), object: get("object"), provenance: get("provenance") });
|
|
528
|
+
}
|
|
529
|
+
return out;
|
|
530
|
+
} catch {
|
|
531
|
+
return [];
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Load memory once and resolve every reified Fact into a TRUST-BEARING row
|
|
536
|
+
* ({subject,predicate,object,provenance,trust,sourceTypes,…}) via core's
|
|
537
|
+
* readFactRows — the seam the answer layer ranks + cites without re-walking the
|
|
538
|
+
* graph shape (Wave-A memory/core.mjs). Lazy + failure-tolerated: no memory → []. */
|
|
539
|
+
async function factRows(memoryDir) {
|
|
540
|
+
try {
|
|
541
|
+
const { loadMemory, readFactRows } = await import("./memory/core.mjs");
|
|
542
|
+
return readFactRows(await loadMemory(memoryDir));
|
|
543
|
+
} catch {
|
|
544
|
+
return [];
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Spelling variants a question term is matched under (normFactTerm + a naive
|
|
549
|
+
* singular): "caches"/"a cache"/"/c/en/cache" all reach the stored "cache". */
|
|
550
|
+
function factTermVariants(normFactTerm, term) {
|
|
551
|
+
const t = normFactTerm(term);
|
|
552
|
+
const v = new Set([t]);
|
|
553
|
+
if (t.endsWith("es")) v.add(t.slice(0, -2));
|
|
554
|
+
if (t.endsWith("s")) v.add(t.slice(0, -1));
|
|
555
|
+
return v;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** "is a module a component" — the yes/no vocabulary form the graph grammar
|
|
559
|
+
* doesn't parse; checked against the isa-family fact predicates only. */
|
|
560
|
+
const ISA_ASK_RE = /^(?:is|are)\s+(?:an?\s+)?(.+?)\s+(?:a\s+kind\s+of|a\s+type\s+of|an?)\s+(.+?)[?.!\s]*$/i;
|
|
561
|
+
const ISA_PREDICATES = new Set(["rdfs:subClassOf", "rdf:type"]);
|
|
562
|
+
/** "what do you know about caches" — the open recall-everything form. */
|
|
563
|
+
const KNOW_ABOUT_RE = /^what\s+do\s+you\s+know\s+about\s+(.+?)[?.!\s]*$/i;
|
|
564
|
+
/** How many facts a single answer lists before "…and N more". */
|
|
565
|
+
const FACT_ANSWER_CAP = 5;
|
|
566
|
+
|
|
567
|
+
/** W4 seam: answer (or extend) a vocabulary/definition question from the MEMORY
|
|
568
|
+
* graph's Facts. Returns { text, replace } — `replace:false` means the engine's
|
|
569
|
+
* own (schema-docs) answer stands and the fact lines are appended under it —
|
|
570
|
+
* or null when memory holds nothing relevant (misses stay unchanged). */
|
|
571
|
+
async function factAnswer(memoryDir, query, envelope, miss) {
|
|
572
|
+
let normFactTerm;
|
|
573
|
+
try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
|
|
574
|
+
const q = String(query).trim();
|
|
575
|
+
|
|
576
|
+
// (a) meta-shaped questions ("what is a module", "what does cache mean") — the
|
|
577
|
+
// parsed object term, matched against fact SUBJECTS; consulted for hits (append
|
|
578
|
+
// alongside the schema-docs answer) and misses (facts answer alone) alike.
|
|
579
|
+
// When the engine produced NO parse at all (the empty-bootstrap graph
|
|
580
|
+
// short-circuits before parsing), the meta FORM is recognized directly on a
|
|
581
|
+
// miss — same required-article discipline as the grammar's own T5 template.
|
|
582
|
+
let metaTerm = envelope?.parsed?.shape === "meta" ? envelope.parsed.object : null;
|
|
583
|
+
if (!metaTerm && miss && !envelope?.parsed) {
|
|
584
|
+
const m = q.match(/^what\s+(?:is|are)\s+an?\s+(.+?)[?.!\s]*$/i)
|
|
585
|
+
|| q.match(/^what\s+(?:does|do)\s+(.+?)\s+means?[?.!\s]*$/i);
|
|
586
|
+
if (m) metaTerm = m[1];
|
|
587
|
+
}
|
|
588
|
+
if (metaTerm) {
|
|
589
|
+
const variants = factTermVariants(normFactTerm, metaTerm);
|
|
590
|
+
const hits = (await memoryFacts(memoryDir)).filter((f) => variants.has(f.subject));
|
|
591
|
+
if (!hits.length) return null;
|
|
592
|
+
const shown = hits.slice(0, FACT_ANSWER_CAP).map(renderFactLine);
|
|
593
|
+
const extra = hits.length > FACT_ANSWER_CAP ? `\n…and ${hits.length - FACT_ANSWER_CAP} more remembered fact${hits.length - FACT_ANSWER_CAP === 1 ? "" : "s"}.` : "";
|
|
594
|
+
return { text: shown.join("\n") + extra, replace: miss };
|
|
595
|
+
}
|
|
596
|
+
if (!miss) return null;
|
|
597
|
+
|
|
598
|
+
// (b) "is a module a component" — yes iff a remembered isa-family fact says so.
|
|
599
|
+
const isa = q.match(ISA_ASK_RE);
|
|
600
|
+
if (isa) {
|
|
601
|
+
const subj = factTermVariants(normFactTerm, isa[1]);
|
|
602
|
+
const obj = factTermVariants(normFactTerm, isa[2]);
|
|
603
|
+
const hit = (await memoryFacts(memoryDir)).find(
|
|
604
|
+
(f) => ISA_PREDICATES.has(f.predicate) && subj.has(f.subject) && obj.has(f.object),
|
|
605
|
+
);
|
|
606
|
+
if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
|
|
607
|
+
return null; // no remembered fact — the honest miss stands (never a guessed "no")
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// (c) "what do you know about caches" — everything remembered that MENTIONS the
|
|
611
|
+
// term (subject or object), capped.
|
|
612
|
+
const know = q.match(KNOW_ABOUT_RE);
|
|
613
|
+
if (know) {
|
|
614
|
+
const variants = factTermVariants(normFactTerm, know[1]);
|
|
615
|
+
const hits = (await memoryFacts(memoryDir)).filter((f) => variants.has(f.subject) || variants.has(f.object));
|
|
616
|
+
if (!hits.length) return null;
|
|
617
|
+
// echo the STORED spelling ("caches" asked → "cache" known), never a guess
|
|
618
|
+
const term = variants.has(hits[0].subject) ? hits[0].subject : hits[0].object;
|
|
619
|
+
const shown = hits.slice(0, FACT_ANSWER_CAP).map((f) => ` ${renderFactLine(f)}`);
|
|
620
|
+
const extra = hits.length > FACT_ANSWER_CAP ? `\n …and ${hits.length - FACT_ANSWER_CAP} more.` : "";
|
|
621
|
+
return { text: `${hits.length} remembered fact${hits.length === 1 ? "" : "s"} about ${term}:\n${shown.join("\n")}${extra}`, replace: true };
|
|
622
|
+
}
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** ASSERT-RECALL READ-BACK (PLAN_CYCLE_4 tail): after "every X is a Y" is
|
|
627
|
+
* asserted, the *superclass* side is otherwise unqueryable — factAnswer's meta
|
|
628
|
+
* path matches fact SUBJECTS only, so "what is a Y" (Y the asserted OBJECT) dies
|
|
629
|
+
* as an honest miss ("'component' isn't a term in this graph's own vocabulary")
|
|
630
|
+
* even though the fact "X is a kind of Y" is remembered. This is the REVERSE-
|
|
631
|
+
* membership reader: it consults readFactRows (trust-bearing) for isa-family
|
|
632
|
+
* facts whose OBJECT is the asked term and reports the members, citing each
|
|
633
|
+
* fact's provenance verbatim, higher-trust first. Miss-only and run AFTER
|
|
634
|
+
* factAnswer returns null, so it never shadows the subject-side answer or a
|
|
635
|
+
* schema hit. Returns { text, replace:true } or null (the miss stands). */
|
|
636
|
+
async function factReadBack(memoryDir, query, envelope, miss) {
|
|
637
|
+
if (!miss) return null;
|
|
638
|
+
let normFactTerm;
|
|
639
|
+
try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
|
|
640
|
+
const q = String(query).trim();
|
|
641
|
+
// The meta form the grammar's T5 template speaks ("what is a Y"), taken from the
|
|
642
|
+
// parse when present, else recognized directly on a no-parse miss (same required-
|
|
643
|
+
// article discipline as factAnswer's own meta fallback).
|
|
644
|
+
let term = envelope?.parsed?.shape === "meta" ? envelope.parsed.object : null;
|
|
645
|
+
if (!term && !envelope?.parsed) {
|
|
646
|
+
const m = q.match(/^what\s+(?:is|are)\s+an?\s+(.+?)[?.!\s]*$/i);
|
|
647
|
+
if (m) term = m[1];
|
|
648
|
+
}
|
|
649
|
+
if (!term) return null;
|
|
650
|
+
const variants = factTermVariants(normFactTerm, term);
|
|
651
|
+
const hits = (await factRows(memoryDir))
|
|
652
|
+
.filter((f) => ISA_PREDICATES.has(f.predicate) && variants.has(f.object))
|
|
653
|
+
.sort((a, b) => b.trust - a.trust);
|
|
654
|
+
if (!hits.length) return null;
|
|
655
|
+
const shown = hits.slice(0, FACT_ANSWER_CAP).map(renderFactLine);
|
|
656
|
+
const n = hits.length - FACT_ANSWER_CAP;
|
|
657
|
+
const extra = n > 0 ? `\n…and ${n} more remembered fact${n === 1 ? "" : "s"}.` : "";
|
|
658
|
+
return { text: shown.join("\n") + extra, replace: true };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// ---- W5: corpus on-demand — LOCAL tier only, behind an explicit flag ----
|
|
662
|
+
|
|
663
|
+
/** The opt-in env flag: TMCT_CORPUS_LOOKUP=1 lets an unknown-term miss consult
|
|
664
|
+
* the LOCAL committed ConceptNet slice (tier 1 of the corpus tiering policy).
|
|
665
|
+
* Default OFF for this wave.
|
|
666
|
+
*
|
|
667
|
+
* TIER-3 SEAM (documented, NOT implemented): a network lookup (the ConceptNet
|
|
668
|
+
* API for terms the local slice misses, cached down into .tmct/corpus/ per the
|
|
669
|
+
* tier-2 policy) would attach exactly where corpusAside() returns null below —
|
|
670
|
+
* behind its own explicit opt-in flag, never in the default path, and any
|
|
671
|
+
* network failure must degrade to the honest miss ($0-offline is inviolable). */
|
|
672
|
+
export const CORPUS_LOOKUP_FLAG = "TMCT_CORPUS_LOOKUP";
|
|
673
|
+
/** How many corpus surface lines one aside quotes. */
|
|
674
|
+
const CORPUS_ASIDE_CAP = 2;
|
|
675
|
+
|
|
676
|
+
let corpusPromise = null; // the local slice as renderable rows, one load per process
|
|
677
|
+
/** Load the committed slice + relation map once, as { key, surface } rows —
|
|
678
|
+
* `surface` is the map's own canonical sentence ("a cache is used for storing
|
|
679
|
+
* data"), `key` the normFactTerm-normalized subject. Failure → []. */
|
|
680
|
+
function localCorpus() {
|
|
681
|
+
if (!corpusPromise) {
|
|
682
|
+
corpusPromise = (async () => {
|
|
683
|
+
const { loadSlice, loadMap, termText } = await import("./corpus/conceptnet.mjs");
|
|
684
|
+
const { normFactTerm } = await import("./memory/core.mjs");
|
|
685
|
+
const [assertions, map] = await Promise.all([loadSlice(), loadMap()]);
|
|
686
|
+
const rows = [];
|
|
687
|
+
for (const a of assertions) {
|
|
688
|
+
const row = map.get(a.rel);
|
|
689
|
+
if (!row || row.ace === "none" || !row.surface) continue; // non-emissions stay out here too
|
|
690
|
+
const subject = termText(a.start);
|
|
691
|
+
const object = termText(a.end);
|
|
692
|
+
if (!subject || !object) continue;
|
|
693
|
+
rows.push({
|
|
694
|
+
key: normFactTerm(subject),
|
|
695
|
+
surface: String(row.surface).replace("{start}", subject).replace("{end}", object),
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
return rows;
|
|
699
|
+
})().catch(() => []);
|
|
700
|
+
}
|
|
701
|
+
return corpusPromise;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** W5 seam: the grounded aside for an unknown term, or null (which is also
|
|
705
|
+
* where the tier-3 network lookup would attach — see CORPUS_LOOKUP_FLAG). */
|
|
706
|
+
async function corpusAside(term) {
|
|
707
|
+
try {
|
|
708
|
+
const { normFactTerm } = await import("./memory/core.mjs");
|
|
709
|
+
const variants = factTermVariants(normFactTerm, term);
|
|
710
|
+
const rows = (await localCorpus()).filter((r) => variants.has(r.key));
|
|
711
|
+
if (!rows.length) return null;
|
|
712
|
+
const shown = rows.slice(0, CORPUS_ASIDE_CAP).map((r) => r.surface);
|
|
713
|
+
return `the corpus knows: ${shown.join("; ")} (ConceptNet, CC-BY-SA)`;
|
|
714
|
+
} catch {
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** The explicit recall question forms — "what did i ask before", "what did we
|
|
720
|
+
* talk about", "what have we discussed" — answered from memory, never the graph. */
|
|
721
|
+
const RECALL_ASK_RE = /^what (?:did|have) (?:i|we) (?:ask(?:ed)?(?: you)?|talk(?:ed)? about|discuss(?:ed)?)(?: before| earlier| previously| last time)?[?.!]*$/i;
|
|
722
|
+
|
|
723
|
+
/** Summarize the most recent folded session's questions (block ids are session
|
|
724
|
+
* uuidv7s, so a plain sort is chronological). Null when nothing is folded yet. */
|
|
725
|
+
async function recallSummary(memoryDir) {
|
|
726
|
+
try {
|
|
727
|
+
const { loadBlockIndex, BLOCKS_DIR_REL } = await import("./memory/blocks.mjs");
|
|
728
|
+
const index = await loadBlockIndex(memoryDir);
|
|
729
|
+
const id = Object.keys(index.blocks).sort().at(-1);
|
|
730
|
+
if (!id) return null;
|
|
731
|
+
const text = await readFile(join(memoryDir, BLOCKS_DIR_REL, index.blocks[id].file), "utf8");
|
|
732
|
+
const qs = text.split("\n").filter((l) => l.startsWith("Q: ")).map((l) => l.slice(3)).slice(0, 6);
|
|
733
|
+
if (!qs.length) return null;
|
|
734
|
+
const day = uuidv7Day(id);
|
|
735
|
+
return `last time (session ${String(id).slice(0, 8)}${day ? `, ${day}` : ""}) you asked: ${qs.map((q) => `"${q}"`).join("; ")}`;
|
|
736
|
+
} catch {
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
371
741
|
/** A bare question → tmct_ask. When a focus is set AND the graph is in hand we
|
|
372
742
|
* call ask() directly to thread the focus as contextId (so a pronoun like "it"
|
|
373
743
|
* resolves to the focus) — building the SAME delimited string dispatchTool emits;
|
|
374
744
|
* otherwise the unchanged dispatchTool path (which also yields the no-graph error).
|
|
375
745
|
* A hit updates the focus to the resolved object. Grammar miss / ToolError → a
|
|
376
746
|
* normal answer, never a crash. */
|
|
377
|
-
async function runAsk(query, { config, source, graph, focus }) {
|
|
747
|
+
async function runAsk(query, { config, source, graph, focus, templates, memoryDir, env }) {
|
|
378
748
|
const ts = new Date().toISOString();
|
|
749
|
+
// W2: the explicit recall forms are answered from memory's folded blocks, never
|
|
750
|
+
// the graph. Gated on memoryDir — a bare runTurn (no session shell) stays pure.
|
|
751
|
+
if (memoryDir && RECALL_ASK_RE.test(String(query).trim())) {
|
|
752
|
+
const summary = await recallSummary(memoryDir);
|
|
753
|
+
return plainTurn(query, summary ?? "nothing to recall yet — no earlier session has been folded into memory.", {
|
|
754
|
+
via: "recall", miss: !summary, focus,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
379
757
|
let answer;
|
|
380
758
|
let envelope = null;
|
|
381
759
|
try {
|
|
@@ -404,11 +782,52 @@ async function runAsk(query, { config, source, graph, focus }) {
|
|
|
404
782
|
}
|
|
405
783
|
const answeredIds = (envelope?.matches || []).map((m) => m?.id).filter(Boolean);
|
|
406
784
|
const miss = envelope ? !!envelope.miss : true;
|
|
785
|
+
// Answer provenance (W1): "composed" is the ask engine's productive band; the
|
|
786
|
+
// orientation swap below is template wording, so those turns carry via:"template".
|
|
787
|
+
let via = "composed";
|
|
788
|
+
let recordMiss = miss;
|
|
407
789
|
// On a MISS: a conversational miss (a greeting, "what can you do", a very short
|
|
408
790
|
// non-code line) gets the friendly orientation instead of the raw grammar hint. A
|
|
409
791
|
// near-miss STRUCTURAL question keeps the precise hint the engine already produced.
|
|
410
|
-
if (miss && isConversational(query))
|
|
411
|
-
|
|
792
|
+
if (miss && isConversational(query)) {
|
|
793
|
+
answer = tRender(templates, T_ORIENTATION) ?? TEMPLATES_UNAVAILABLE;
|
|
794
|
+
via = "template";
|
|
795
|
+
} else if (memoryDir) {
|
|
796
|
+
// W4: vocabulary/definition questions consult the MEMORY graph's Facts
|
|
797
|
+
// alongside the schema-docs surface — a remembered fact answers a miss (or
|
|
798
|
+
// extends a schema hit), cited with its provenance verbatim. Checked BEFORE
|
|
799
|
+
// recall: a reified fact is stronger evidence than a transcript echo.
|
|
800
|
+
// Subject-side facts first (factAnswer), then the reverse-membership read-back
|
|
801
|
+
// (factReadBack) so an asserted "every X is a Y" answers "what is a Y" too.
|
|
802
|
+
const fact = (await factAnswer(memoryDir, query, envelope, miss))
|
|
803
|
+
?? (await factReadBack(memoryDir, query, envelope, miss));
|
|
804
|
+
if (fact) {
|
|
805
|
+
answer = fact.replace ? fact.text : `${answer}\n${fact.text}`;
|
|
806
|
+
via = "fact";
|
|
807
|
+
recordMiss = false;
|
|
808
|
+
} else if (miss) {
|
|
809
|
+
// W2: after the honest miss is composed, consult the folded-session memory. A
|
|
810
|
+
// relevant enough block ANSWERS — recalled Q/A framed + cited first, with the
|
|
811
|
+
// engine's own miss hint kept below; no hit leaves the miss byte-unchanged.
|
|
812
|
+
const recalled = await recallFromBlocks(memoryDir, query);
|
|
813
|
+
if (recalled) {
|
|
814
|
+
answer = `${recalled}\n\n${answer}`;
|
|
815
|
+
via = "recall";
|
|
816
|
+
recordMiss = false; // memory answered it, cited — no longer a blank
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
// W5 (flag-gated, default OFF): an unknown-term miss may consult the LOCAL
|
|
821
|
+
// committed corpus slice — a hit APPENDS a grounded, licence-cited aside under
|
|
822
|
+
// the honest miss (the miss itself stands; the aside is context, not an answer).
|
|
823
|
+
if (recordMiss && envelope?.parsed?.object && String(env?.[CORPUS_LOOKUP_FLAG] || "") === "1") {
|
|
824
|
+
const aside = await corpusAside(envelope.parsed.object);
|
|
825
|
+
if (aside) {
|
|
826
|
+
answer = `${answer}\n${aside}`;
|
|
827
|
+
via = "corpus";
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
const record = { type: "turn", ts, query, via, resolvedIds, answeredIds, miss: recordMiss };
|
|
412
831
|
const logLines = [ts, `> ${query}`, answer, ""];
|
|
413
832
|
// `detail` feeds why/say-more's verbose re-render: the traversal receipt + the
|
|
414
833
|
// matched entities the terse render trims (see renderVerbose).
|
|
@@ -418,12 +837,12 @@ async function runAsk(query, { config, source, graph, focus }) {
|
|
|
418
837
|
|
|
419
838
|
/** A non-ask, non-dispatch chat turn (count answer, /stats) — the same
|
|
420
839
|
* { answer, logLines, record, focus } shape, recorded like any other turn. */
|
|
421
|
-
function plainTurn(query, answer, { command, miss = false, focus = null } = {}) {
|
|
840
|
+
function plainTurn(query, answer, { command, via = "composed", miss = false, focus = null } = {}) {
|
|
422
841
|
const ts = new Date().toISOString();
|
|
423
842
|
return {
|
|
424
843
|
answer,
|
|
425
844
|
logLines: [ts, `> ${query}`, answer, ""],
|
|
426
|
-
record: { type: "turn", ts, query, ...(command ? { command } : {}), resolvedIds: [], answeredIds: [], miss },
|
|
845
|
+
record: { type: "turn", ts, query, ...(command ? { command } : {}), via, resolvedIds: [], answeredIds: [], miss },
|
|
427
846
|
focus,
|
|
428
847
|
};
|
|
429
848
|
}
|
|
@@ -432,7 +851,7 @@ function plainTurn(query, answer, { command, miss = false, focus = null } = {})
|
|
|
432
851
|
* the same { answer, logLines, record, focus } shape as runAsk; the record carries
|
|
433
852
|
* the command name and the resolved entity id (for entity commands) so a
|
|
434
853
|
* slash-command turn becomes asksAbout graph data wherever it resolves an entity. */
|
|
435
|
-
async function runCommand(line, { config, source, graph, focus }) {
|
|
854
|
+
async function runCommand(line, { config, source, graph, focus, memoryDir }) {
|
|
436
855
|
const ts = new Date().toISOString();
|
|
437
856
|
const sp = line.indexOf(" ");
|
|
438
857
|
const name = (sp === -1 ? line.slice(1) : line.slice(1, sp)).toLowerCase();
|
|
@@ -440,13 +859,25 @@ async function runCommand(line, { config, source, graph, focus }) {
|
|
|
440
859
|
const mk = (answer, { resolvedIds = [], miss = false, newFocus = focus } = {}) => ({
|
|
441
860
|
answer,
|
|
442
861
|
logLines: [ts, `> ${line}`, answer, ""],
|
|
443
|
-
record: { type: "turn", ts, query: line, command: name, resolvedIds, answeredIds: [], miss },
|
|
862
|
+
record: { type: "turn", ts, query: line, command: name, via: "command", resolvedIds, answeredIds: [], miss },
|
|
444
863
|
focus: newFocus,
|
|
445
864
|
});
|
|
446
865
|
|
|
447
866
|
if (name === "help") return mk(await helpText());
|
|
448
867
|
if (name === "stats") return graph ? mk(renderStats(graph)) : mk("no graph loaded — /stats needs an index.", { miss: true });
|
|
449
868
|
|
|
869
|
+
// /memory [verbose] — what tmct remembers, as text (the ROADMAP "Memory
|
|
870
|
+
// inspection" surface; the same renderer serves the `tmct memory` CLI).
|
|
871
|
+
if (name === "memory") {
|
|
872
|
+
if (!memoryDir) return mk("no memory store here — /memory works inside a repo session.", { miss: true });
|
|
873
|
+
try {
|
|
874
|
+
const { inspectMemory } = await import("./memory/inspect.mjs");
|
|
875
|
+
return mk(await inspectMemory(memoryDir, { verbose: /^(?:-v|--verbose|verbose)$/i.test(argText) }));
|
|
876
|
+
} catch (e) {
|
|
877
|
+
return mk(String(e?.message || e), { miss: true }); // a broken store reads as its own clean error
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
450
881
|
if (name === "focus") {
|
|
451
882
|
if (!argText) return mk(focus ? `focus is ${focus.label}` : "no focus set — /focus <symbol> to set one.");
|
|
452
883
|
const ent = await resolveEntity(graph, isPronoun(argText) ? focus?.label : argText);
|
|
@@ -484,16 +915,21 @@ async function runCommand(line, { config, source, graph, focus }) {
|
|
|
484
915
|
* any grammar miss / residue / import failure so the query engine keeps first
|
|
485
916
|
* refusal on everything else. Lazy imports + catch-all: the grammar layer can
|
|
486
917
|
* never crash a turn (chat.mjs ethos). Writes ONLY under memoryDir/.tmct/memory. */
|
|
487
|
-
async function assertTurn(line, { memoryDir, sessionId, focus }) {
|
|
918
|
+
async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null }) {
|
|
488
919
|
try {
|
|
489
920
|
const { parseAce } = await import("./grammar/ace.mjs");
|
|
490
|
-
|
|
491
|
-
|
|
921
|
+
// A session handle carries its own loaded lexicon (createSession loads it once);
|
|
922
|
+
// a bare runTurn (no handle) lazy-loads the cached core lexicon. The lexicon is
|
|
923
|
+
// immutable, so sharing one reference across concurrent handles is re-entrant.
|
|
924
|
+
let lex = lexicon;
|
|
925
|
+
if (!lex) { const { loadLexicon } = await import("./grammar/lexicon.mjs"); lex = loadLexicon(); }
|
|
926
|
+
const parse = parseAce(line, lex);
|
|
492
927
|
if (!parse || !parse.triples?.length || parse.residue?.length) return null;
|
|
493
928
|
const { assertSentence } = await import("./grammar/assert.mjs");
|
|
494
929
|
const { normFactTerm } = await import("./memory/core.mjs");
|
|
495
930
|
const ts = new Date().toISOString();
|
|
496
931
|
const res = await assertSentence(memoryDir, line, {
|
|
932
|
+
lexicon: lex,
|
|
497
933
|
provenance: { source: "chat", sessionId, ts },
|
|
498
934
|
});
|
|
499
935
|
if (!res || !res.ids?.length) return null;
|
|
@@ -502,7 +938,7 @@ async function assertTurn(line, { memoryDir, sessionId, focus }) {
|
|
|
502
938
|
.join("; ");
|
|
503
939
|
const n = res.ids.length;
|
|
504
940
|
const answer = `noted — remembered ${n} fact${n === 1 ? "" : "s"}: ${shown}`;
|
|
505
|
-
return plainTurn(line, answer, { command: "assert", focus });
|
|
941
|
+
return plainTurn(line, answer, { command: "assert", via: "assert", focus });
|
|
506
942
|
} catch {
|
|
507
943
|
return null; // grammar unavailable / write failed — fall through to the engine
|
|
508
944
|
}
|
|
@@ -522,12 +958,22 @@ async function assertTurn(line, { memoryDir, sessionId, focus }) {
|
|
|
522
958
|
* subject), `answeredIds` the entity ids an ask answer cited; a slash-command turn
|
|
523
959
|
* also carries its `command` name. Both drive the mgx:asksAbout graph append.
|
|
524
960
|
*/
|
|
525
|
-
export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "" } = {}) {
|
|
961
|
+
export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null } = {}) {
|
|
526
962
|
const line = String(input ?? "").trim();
|
|
527
|
-
const
|
|
963
|
+
const templates = await chatTemplates(); // failure-tolerated: null degrades, never throws
|
|
964
|
+
const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon };
|
|
528
965
|
// A DISPATCHED turn (count / slash-command / ask) becomes the new "last answer"
|
|
529
966
|
// that why/say-more re-renders; a conversational turn does not (it preserves it).
|
|
530
|
-
|
|
967
|
+
// FINISH SEAM (PLAN_RESPONSE_FINISHING §"Where it lives"): every dispatched turn's
|
|
968
|
+
// result passes through finish() here — the LAST transform in the turn — before its
|
|
969
|
+
// finished answer becomes the `last` we expand. finish() owns the prose-span
|
|
970
|
+
// grammar pass (src/finish.mjs); it rewrites result.answer/logLines and leaves the
|
|
971
|
+
// protected spans (entities, paths, numbers, receipts, provenance) byte-invariant,
|
|
972
|
+
// so `last` and the transcript stay consistent with what the shell prints.
|
|
973
|
+
const withLast = (result) => {
|
|
974
|
+
const finished = finish(result, { graph });
|
|
975
|
+
return { ...finished, last: { query: line, answer: finished.answer, detail: finished.detail ?? null } };
|
|
976
|
+
};
|
|
531
977
|
|
|
532
978
|
// Conversational layer first (greetings, thanks, help, bye, why/say-more) — these
|
|
533
979
|
// resolve no entity and carry their own preserved `last`.
|
|
@@ -546,10 +992,51 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
546
992
|
// Aggregate/count questions are answered mechanically off the loaded graph header,
|
|
547
993
|
// BEFORE falling through to the ask engine (focus unchanged — a count names no entity).
|
|
548
994
|
const count = answerCount(graph, line);
|
|
549
|
-
if (count != null) return withLast(plainTurn(line, count, { focus }));
|
|
995
|
+
if (count != null) return withLast(plainTurn(line, count, { via: "count", focus }));
|
|
550
996
|
return withLast(await runAsk(line, ctx));
|
|
551
997
|
}
|
|
552
998
|
|
|
999
|
+
// ---- W3: seedMemory → bootstrap (first run in a graph-less repo) ----
|
|
1000
|
+
|
|
1001
|
+
/** How many corpus facts the first-run bootstrap seeds. Measured curve (dev
|
|
1002
|
+
* laptop, appendFact's read-modify-write per fact): 100→~0.16s, 250→~0.54s,
|
|
1003
|
+
* 500→~1.7s — the full 500 stays inside a session-start budget, so the seed
|
|
1004
|
+
* runs synchronously and complete (no partial-sync cap needed). */
|
|
1005
|
+
export const SEED_LIMIT = 500;
|
|
1006
|
+
|
|
1007
|
+
/** Which predicates the capped seed prefers (stable order — see seedMemory's
|
|
1008
|
+
* `prefer`): the definitional band first, so a bootstrap's 500 facts answer
|
|
1009
|
+
* "what is a cache?"-style vocabulary questions rather than location trivia. */
|
|
1010
|
+
export const SEED_PREFER = ["rdfs:subClassOf", "rdf:type", "mgx:usedFor", "mgx:partOf", "mgx:capableOf"];
|
|
1011
|
+
|
|
1012
|
+
/** The seed marker: its presence means this repo's memory already carries the
|
|
1013
|
+
* corpus seed, so re-runs skip without even reading the slice. */
|
|
1014
|
+
export const SEED_MARKER_REL = join(".tmct", "memory", "corpus-seed.json");
|
|
1015
|
+
|
|
1016
|
+
/** Seed the ConceptNet slice into <repo>/.tmct/memory once. Idempotent twice
|
|
1017
|
+
* over (the marker short-circuits; seedMemory itself content-hashes fact ids)
|
|
1018
|
+
* and failure-tolerated: a missing/broken corpus degrades to the unseeded
|
|
1019
|
+
* bootstrap — never an error before the prompt. Returns seedMemory's
|
|
1020
|
+
* { appended, skipped, total } on a fresh seed, null when skipped/failed. */
|
|
1021
|
+
async function seedBootstrapMemory(repo) {
|
|
1022
|
+
const marker = join(repo, SEED_MARKER_REL);
|
|
1023
|
+
try {
|
|
1024
|
+
await readFile(marker, "utf8");
|
|
1025
|
+
return null; // already seeded — the marker is authoritative
|
|
1026
|
+
} catch { /* no marker → first run */ }
|
|
1027
|
+
try {
|
|
1028
|
+
const { seedMemory } = await import("./corpus/conceptnet.mjs");
|
|
1029
|
+
const res = await seedMemory(repo, { limit: SEED_LIMIT, prefer: SEED_PREFER });
|
|
1030
|
+
await mkdir(dirname(marker), { recursive: true });
|
|
1031
|
+
await writeFile(marker, JSON.stringify({
|
|
1032
|
+
seededAt: new Date().toISOString(), limit: SEED_LIMIT, appended: res.appended, skipped: res.skipped,
|
|
1033
|
+
}) + "\n");
|
|
1034
|
+
return res;
|
|
1035
|
+
} catch {
|
|
1036
|
+
return null; // corpus unavailable — bootstrap proceeds unseeded
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
553
1040
|
/** Trim a focus label for the prompt so a long module path can't run the line off. */
|
|
554
1041
|
const shortLabel = (l) => { const s = String(l); return s.length > 40 ? "…" + s.slice(-39) : s; };
|
|
555
1042
|
const promptFor = (focus) => (focus ? `tmct(${shortLabel(focus.label)})> ` : PROMPT);
|
|
@@ -570,10 +1057,27 @@ const promptFor = (focus) => (focus ? `tmct(${shortLabel(focus.label)})> ` : PRO
|
|
|
570
1057
|
* - opt-in telemetry and the end-of-session close (end lines, final upsert,
|
|
571
1058
|
* stream flush).
|
|
572
1059
|
*
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
* `
|
|
576
|
-
*
|
|
1060
|
+
* THE CALLER-OWNED HANDLE (PLAN_REPOSITORY_INTERFACE §"The in-process lifecycle").
|
|
1061
|
+
* The returned object IS the session handle — created here, disposed by the caller
|
|
1062
|
+
* (`close()`), with NO process-global state. All of a session's between-turn state
|
|
1063
|
+
* lives on the handle: the mutable `focus` and `lastAnswer` (closure-private, read
|
|
1064
|
+
* through getters) and the read-only `memoryDir`, `graph`, `config` and `lexicon`.
|
|
1065
|
+
* - CREATE: `const s = await createSession({ repoPath })` — resolves repo/config,
|
|
1066
|
+
* loads the graph + lexicon once, opens the log/sidecar streams, seeds first-run
|
|
1067
|
+
* memory. Cheap to hold; a session is one repo's worth of chat.
|
|
1068
|
+
* - DISPOSE: `await s.close()` — idempotent; flushes both artifacts and the final
|
|
1069
|
+
* graph upsert (which triggers the memory fold). A dropped handle leaks only its
|
|
1070
|
+
* two write streams, so callers SHOULD close; a second close is a no-op.
|
|
1071
|
+
* - RE-ENTRANCY / CONCURRENCY: two handles never clobber each other. Each owns its
|
|
1072
|
+
* own `focus`/`lastAnswer`/streams/`sessionId`; the only cross-handle sharing is
|
|
1073
|
+
* the IMMUTABLE lexicon (a cached read-only singleton) and the read-through
|
|
1074
|
+
* provider graph — neither is mutated by a turn, so concurrent handles over the
|
|
1075
|
+
* same or different repos run isolated. Proven by test/chat-session.test.mjs.
|
|
1076
|
+
*
|
|
1077
|
+
* Returns { repo, config, graph, lexicon, memoryDir, moduleCount, version, sessionId,
|
|
1078
|
+
* logFile, sidecarFile, bannerLines, empty, focus, lastAnswer, turns, promptFor(),
|
|
1079
|
+
* turn(line), close() }. `turn(line)` runs one dispatched turn through runTurn and the
|
|
1080
|
+
* full sink sequencing, returning { answer, end, prompt }; `close()` is idempotent.
|
|
577
1081
|
*/
|
|
578
1082
|
export async function createSession({
|
|
579
1083
|
repoPath,
|
|
@@ -601,6 +1105,14 @@ export async function createSession({
|
|
|
601
1105
|
const moduleCount = graph.individuals.filter((i) => (i.class || "") === "Module").length;
|
|
602
1106
|
const { version } = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
603
1107
|
|
|
1108
|
+
// Load this handle's lexicon once (the immutable cached core vocabulary the ACE
|
|
1109
|
+
// assert path parses against). Threaded into every turn so the grammar layer never
|
|
1110
|
+
// re-imports per turn; failure-tolerated — a broken lexicon degrades to the lazy
|
|
1111
|
+
// per-turn load inside assertTurn, never an error before the prompt.
|
|
1112
|
+
let lexicon = null;
|
|
1113
|
+
try { const { loadLexicon } = await import("./grammar/lexicon.mjs"); lexicon = loadLexicon(); }
|
|
1114
|
+
catch { lexicon = null; }
|
|
1115
|
+
|
|
604
1116
|
// Opt-in telemetry (default OFF → null → the sink's `tel?.record` is a no-op, and
|
|
605
1117
|
// nothing is written). The conversational session log + sidecar above stay the
|
|
606
1118
|
// authoritative chat record; this is the machine-readable query telemetry.
|
|
@@ -638,12 +1150,23 @@ export async function createSession({
|
|
|
638
1150
|
};
|
|
639
1151
|
|
|
640
1152
|
const empty = graph.individuals.length === 0;
|
|
1153
|
+
// W3: FIRST RUN in a graph-less repo seeds a capped ConceptNet slice into
|
|
1154
|
+
// .tmct/memory so vocabulary questions ("what is a cache?") have something
|
|
1155
|
+
// honest to stand on from turn one. Guarded three ways: only the empty
|
|
1156
|
+
// bootstrap (a fixture/provider graph never seeds), only once (the marker),
|
|
1157
|
+
// and never when TMCT_NO_SEED=1 opts out.
|
|
1158
|
+
let seeded = null;
|
|
1159
|
+
if (empty && String(env.TMCT_NO_SEED || "") !== "1") {
|
|
1160
|
+
seeded = await seedBootstrapMemory(repo);
|
|
1161
|
+
}
|
|
641
1162
|
const bannerLines = [
|
|
642
1163
|
empty
|
|
643
1164
|
// Empty-graph bootstrap: honest-miss messaging, never an error before the prompt.
|
|
644
1165
|
? `tmct chat — ${repo} — no graph loaded — starting empty; ` +
|
|
645
1166
|
`the conversation is remembered to ${DEFAULT_GRAPH_REL} — log ${logFile}`
|
|
646
1167
|
: `tmct chat — ${repo} — ${moduleCount} module(s) — log ${logFile}`,
|
|
1168
|
+
// the honest seed line appears ONLY on the run that actually seeded
|
|
1169
|
+
...(seeded ? [`seeded ${seeded.appended} starter facts from the ConceptNet slice — /memory to inspect`] : []),
|
|
647
1170
|
"pass --repo <path> to target a different repo",
|
|
648
1171
|
"ask a question, or /help for commands (/stats for an overview) — /exit to leave",
|
|
649
1172
|
];
|
|
@@ -654,9 +1177,12 @@ export async function createSession({
|
|
|
654
1177
|
let closed = false;
|
|
655
1178
|
|
|
656
1179
|
return {
|
|
657
|
-
repo, config, graph,
|
|
658
|
-
bannerLines, empty,
|
|
1180
|
+
repo, config, graph, lexicon, memoryDir: repo, moduleCount, version, sessionId,
|
|
1181
|
+
logFile, sidecarFile, bannerLines, empty,
|
|
1182
|
+
// Mutable between-turn state — read-only to the caller, so a shell can render the
|
|
1183
|
+
// prompt/expand-hint without reaching into runTurn's threading.
|
|
659
1184
|
get focus() { return focus; },
|
|
1185
|
+
get lastAnswer() { return last; },
|
|
660
1186
|
get turns() { return turns; },
|
|
661
1187
|
promptFor: () => promptFor(focus),
|
|
662
1188
|
|
|
@@ -664,7 +1190,7 @@ export async function createSession({
|
|
|
664
1190
|
* → telemetry → upsertGraph, in that exact order). Returns { answer, end, prompt }. */
|
|
665
1191
|
async turn(line) {
|
|
666
1192
|
const { answer, logLines, record, focus: nextFocus, last: nextLast, end } =
|
|
667
|
-
await runTurn(line, { config, source, graph, focus, last, memoryDir: repo, sessionId });
|
|
1193
|
+
await runTurn(line, { config, source, graph, focus, last, memoryDir: repo, sessionId, env, lexicon });
|
|
668
1194
|
focus = nextFocus;
|
|
669
1195
|
last = nextLast;
|
|
670
1196
|
await writeLog(logLines.join("\n") + "\n");
|