@polycode-projects/the-mechanical-code-talker 0.3.0 → 0.5.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 +412 -2
- package/bin/tmct.mjs +56 -1
- package/data/templates/grammar-rules.toml +97 -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 +170 -8
- package/src/chat.mjs +740 -53
- package/src/corpus/conceptnet.mjs +14 -2
- package/src/corpus/templates.mjs +94 -10
- package/src/finish.mjs +443 -0
- package/src/grammar/lexicon-core.json +8 -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.
|
|
@@ -136,11 +138,22 @@ function countableKinds(graph) {
|
|
|
136
138
|
return Object.keys(CLASS_LABELS).filter((c) => present.has(c)).map((c) => CLASS_LABELS[c][1]);
|
|
137
139
|
}
|
|
138
140
|
|
|
141
|
+
/** A discourse-anaphoric count/list head — "how many [of] those/them/these",
|
|
142
|
+
* "count them/those/these". These refer to the previous answer set and are owned
|
|
143
|
+
* by the ask engine's anaphora node, never the header-count path. */
|
|
144
|
+
const ANAPHORA_COUNT_RE = /\b(?:how many|how much|count|number of)\s+(?:of\s+)?(?:those|them|these)\b/i;
|
|
145
|
+
|
|
139
146
|
/** Recognise a count/aggregate question and answer it from the graph header, or
|
|
140
147
|
* null if it isn't one (→ fall through to tmct_ask). "how many X [are there]",
|
|
141
148
|
* "count [the] X", "number of X". An unknown kind lists what it CAN count. */
|
|
142
149
|
export function answerCount(graph, query) {
|
|
143
150
|
if (!graph) return null;
|
|
151
|
+
// ANAPHORIC counts ("how many of those are tested", "count them", "how many of
|
|
152
|
+
// them") count the PREVIOUS answer's set, not a graph kind — decline so the turn
|
|
153
|
+
// falls through to the ask engine's anaphora node (which threads `prev`). Without
|
|
154
|
+
// this the bare "of"/pronoun head is mis-reported as an uncountable kind and the
|
|
155
|
+
// discourse+count follow-up dies before it can resolve (CHATBENCH_006 lever 1).
|
|
156
|
+
if (ANAPHORA_COUNT_RE.test(String(query))) return null;
|
|
144
157
|
const m = String(query).match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b/i);
|
|
145
158
|
if (!m) return null;
|
|
146
159
|
const noun = m[1].toLowerCase();
|
|
@@ -153,6 +166,31 @@ export function answerCount(graph, query) {
|
|
|
153
166
|
return `${n} ${classNoun(cls, n)}.`;
|
|
154
167
|
}
|
|
155
168
|
|
|
169
|
+
/** ASSERTED-VOCABULARY count (CHATBENCH_006 lever 3): once "every class is a type"
|
|
170
|
+
* is remembered, "how many types are there" counts as many types as there are
|
|
171
|
+
* classes — the asserted object noun inherits the subject class's cardinality.
|
|
172
|
+
* Consulted only when answerCount can't map the noun to a graph class (an unknown
|
|
173
|
+
* kind) AND a session's memory is in hand. Returns the count string or null (no
|
|
174
|
+
* such fact → the honest "I can't count …" from answerCount stands). */
|
|
175
|
+
async function countFromFacts(graph, memoryDir, query) {
|
|
176
|
+
if (!graph || !memoryDir) return null;
|
|
177
|
+
const m = String(query).match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b/i);
|
|
178
|
+
if (!m) return null;
|
|
179
|
+
const asked = m[1].toLowerCase();
|
|
180
|
+
if (COUNT_NOUNS[asked]) return null; // a real graph kind — answerCount owns it
|
|
181
|
+
let normFactTerm;
|
|
182
|
+
try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
|
|
183
|
+
const objVariants = factTermVariants(normFactTerm, asked);
|
|
184
|
+
const isa = (await factRows(memoryDir))
|
|
185
|
+
.filter((f) => ISA_PREDICATES.has(f.predicate) && objVariants.has(f.object));
|
|
186
|
+
// pick the highest-trust asserted subject that maps to a countable graph class
|
|
187
|
+
for (const f of isa.sort((a, b) => (b.trust ?? 0) - (a.trust ?? 0))) {
|
|
188
|
+
const cls = COUNT_NOUNS[String(f.subject).toLowerCase()];
|
|
189
|
+
if (cls) { const n = countClass(graph, cls); return `${n} ${asked}.`; }
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
156
194
|
/** `/stats`: a one-screen overview of the graph — class counts, relationship
|
|
157
195
|
* (predicate) counts, and module/package totals — read straight off the header. */
|
|
158
196
|
export function renderStats(graph) {
|
|
@@ -211,15 +249,43 @@ export function isConversational(query) {
|
|
|
211
249
|
return q.split(/\s+/).filter(Boolean).length <= 3 && !codeish;
|
|
212
250
|
}
|
|
213
251
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
252
|
+
// ---- the response-template library (W1: templates → render path) ----
|
|
253
|
+
// The WORDING of the conversational/orientation surfaces lives in
|
|
254
|
+
// data/templates/responses.jsonl (corpus/templates.mjs) — the template library is
|
|
255
|
+
// load-bearing for these turns. The recognizer sets below stay code: they decide
|
|
256
|
+
// WHICH template answers, never what it says. Loading is lazy + failure-tolerated
|
|
257
|
+
// (chat.mjs ethos: a turn never crashes) — a broken/missing data file degrades to
|
|
258
|
+
// one short honest line, never a throw before the prompt.
|
|
259
|
+
|
|
260
|
+
/** Template ids (data/templates/responses.jsonl) for the surfaces chat renders. */
|
|
261
|
+
const T_GREETING = "conversational-greeting";
|
|
262
|
+
const T_GREETING_BY_PHRASE = {
|
|
263
|
+
"hello there": "conversational-greeting-hello-there",
|
|
264
|
+
"good morning": "conversational-greeting-good-morning",
|
|
265
|
+
"good afternoon": "conversational-greeting-good-afternoon",
|
|
266
|
+
"good evening": "conversational-greeting-good-evening",
|
|
267
|
+
};
|
|
268
|
+
const T_THANKS = "conversational-thanks";
|
|
269
|
+
const T_FAREWELL = "conversational-farewell";
|
|
270
|
+
const T_ORIENTATION = "orientation-friendly";
|
|
271
|
+
const T_WHY_EMPTY = "miss-no-previous-answer";
|
|
272
|
+
|
|
273
|
+
/** The degraded line when the template library itself cannot load — a packaging
|
|
274
|
+
* failure said out loud, never a crashed turn or a silently different answer. */
|
|
275
|
+
const TEMPLATES_UNAVAILABLE = "response templates unavailable — ask a question, or /help for commands.";
|
|
276
|
+
|
|
277
|
+
let templatesPromise = null;
|
|
278
|
+
/** Load data/templates/responses.jsonl once per process; null on failure. */
|
|
279
|
+
function chatTemplates() {
|
|
280
|
+
if (!templatesPromise) templatesPromise = loadTemplates().catch(() => null);
|
|
281
|
+
return templatesPromise;
|
|
282
|
+
}
|
|
283
|
+
/** Strict render through the loaded map; null on any failure (no map / unknown
|
|
284
|
+
* id / missing slot) so every call site degrades explicitly, never half-fills. */
|
|
285
|
+
function tRender(templates, id, slots = {}) {
|
|
286
|
+
if (!templates) return null;
|
|
287
|
+
try { return renderTemplate(id, slots, templates); } catch { return null; }
|
|
288
|
+
}
|
|
223
289
|
|
|
224
290
|
// ---- conversational (ELIZA/Zork-manners) templated layer ----
|
|
225
291
|
// A small CLOSED set of human expressions handled with a TEMPLATED response BEFORE
|
|
@@ -248,16 +314,8 @@ const WHY = new Set([
|
|
|
248
314
|
"elaborate", "tell me more", "more detail", "expand",
|
|
249
315
|
]);
|
|
250
316
|
|
|
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.";
|
|
317
|
+
// (Greeting/thanks/farewell wording moved to data/templates/responses.jsonl — W1.
|
|
318
|
+
// The expression-specific greeting variants map through T_GREETING_BY_PHRASE above.)
|
|
261
319
|
|
|
262
320
|
/** Re-render the last answer in verbose form: the previous query + its full answer
|
|
263
321
|
* plus the ask envelope's traversal receipt and the matched entities (the detail a
|
|
@@ -289,22 +347,31 @@ export function renderVerbose(last) {
|
|
|
289
347
|
function conversationalTurn(line, ctx) {
|
|
290
348
|
const raw = String(line);
|
|
291
349
|
const q = raw.toLowerCase().replace(/[.!?]+$/, "").replace(/\s+/g, " ").trim();
|
|
292
|
-
const
|
|
350
|
+
const t = (id) => tRender(ctx.templates, id) ?? TEMPLATES_UNAVAILABLE;
|
|
351
|
+
const mk = (answer, { end = false, miss = false, via = "template" } = {}) => {
|
|
293
352
|
const ts = new Date().toISOString();
|
|
294
353
|
return {
|
|
295
354
|
answer,
|
|
296
355
|
logLines: [ts, `> ${raw}`, answer, ""],
|
|
297
|
-
record: { type: "turn", ts, query: raw, conversational: true, resolvedIds: [], answeredIds: [], miss },
|
|
356
|
+
record: { type: "turn", ts, query: raw, conversational: true, via, resolvedIds: [], answeredIds: [], miss },
|
|
298
357
|
focus: ctx.focus,
|
|
299
358
|
last: ctx.last, // a conversational turn never overwrites the last real answer
|
|
300
359
|
...(end ? { end: true } : {}),
|
|
301
360
|
};
|
|
302
361
|
};
|
|
303
|
-
if (BYE.has(q)) return mk(
|
|
304
|
-
if (WHY.has(q)) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
362
|
+
if (BYE.has(q)) return mk(t(T_FAREWELL), { end: true });
|
|
363
|
+
if (WHY.has(q)) {
|
|
364
|
+
const v = renderVerbose(ctx.last);
|
|
365
|
+
// The empty-state hint is template wording (via:"template", the data row wins;
|
|
366
|
+
// renderVerbose's own string is the degraded fallback for direct library callers).
|
|
367
|
+
// A real expansion re-renders the LAST ANSWER — its wording is the prior answer's,
|
|
368
|
+
// not a template's, so it carries via:"conversational".
|
|
369
|
+
if (v.empty) return mk(tRender(ctx.templates, T_WHY_EMPTY) ?? v.text, { miss: true });
|
|
370
|
+
return mk(v.text, { via: "conversational" });
|
|
371
|
+
}
|
|
372
|
+
if (GREET.has(q)) return mk(t(T_GREETING_BY_PHRASE[q] || T_GREETING));
|
|
373
|
+
if (THANKS.has(q)) return mk(t(T_THANKS));
|
|
374
|
+
if (q === "help" || q === "?" || HELP_PHRASES.some((re) => re.test(raw))) return mk(t(T_ORIENTATION));
|
|
308
375
|
return null;
|
|
309
376
|
}
|
|
310
377
|
|
|
@@ -348,6 +415,7 @@ export async function helpText() {
|
|
|
348
415
|
["<question>", "ask the graph in plain English (the default for any non-slash line)"],
|
|
349
416
|
...Object.entries(COMMANDS).map(([name, s]) => [`/${name}${s.arg ? (s.optional ? ` [${s.arg}]` : ` <${s.arg}>`) : ""}`, s.help]),
|
|
350
417
|
["/stats", "a one-screen overview: entity counts, relationship counts, packages"],
|
|
418
|
+
["/memory [verbose]", "what tmct remembers: facts, utterances, sessions, folded blocks"],
|
|
351
419
|
["/focus <symbol>", "set the current focus (reused by 'it'/'this' and no-arg entity commands)"],
|
|
352
420
|
["/help", "this list"],
|
|
353
421
|
["/exit", "leave the session (also Ctrl+C / Ctrl+D)"],
|
|
@@ -368,24 +436,487 @@ export async function helpText() {
|
|
|
368
436
|
].join("\n");
|
|
369
437
|
}
|
|
370
438
|
|
|
439
|
+
// ---- W2: memory recall on the miss path (retrieveBlocks → runAsk) ----
|
|
440
|
+
|
|
441
|
+
/** The conservative relevance floor a folded-session block must clear (the
|
|
442
|
+
* retrieveBlocks idf×(1+rank) score) before an honest ask-miss is answered from
|
|
443
|
+
* memory. Calibrated in the small-corpus regime (test/wiring-recall.test.mjs):
|
|
444
|
+
* a genuine re-ask scores ~4, a frame-word coincidence ~1. */
|
|
445
|
+
export const RECALL_MIN_SCORE = 2.0;
|
|
446
|
+
|
|
447
|
+
/** How many blocks the miss path consults (the W2 seam: retrieveBlocks(dir, q, 2)). */
|
|
448
|
+
const RECALL_TOP_K = 2;
|
|
449
|
+
|
|
450
|
+
/** Frame/stop words ignored when checking that a recalled Q genuinely shares
|
|
451
|
+
* vocabulary with the live query — at least one shared CONTENT word is required,
|
|
452
|
+
* so "which …" alone can never masquerade as a memory. */
|
|
453
|
+
const RECALL_STOPWORDS = new Set([
|
|
454
|
+
"the", "a", "an", "and", "or", "for", "with", "about", "into", "from",
|
|
455
|
+
"which", "what", "who", "how", "when", "where", "why",
|
|
456
|
+
"does", "do", "did", "is", "are", "was", "were", "there",
|
|
457
|
+
"me", "my", "we", "i", "you", "it", "this", "that", "in", "of", "to",
|
|
458
|
+
]);
|
|
459
|
+
const recallWords = (s) => new Set(
|
|
460
|
+
String(s).toLowerCase().split(/[^a-z0-9.]+/).filter((w) => w.length >= 3 && !RECALL_STOPWORDS.has(w)),
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
/** Decode a uuidv7 id's leading 48-bit unix-ms timestamp → "YYYY-MM-DD", or null
|
|
464
|
+
* (block ids ARE session uuidv7s — fold.mjs sets block id = session id). */
|
|
465
|
+
function uuidv7Day(id) {
|
|
466
|
+
const hex = String(id || "").replace(/-/g, "").slice(0, 12);
|
|
467
|
+
if (!/^[0-9a-f]{12}$/i.test(hex)) return null;
|
|
468
|
+
const ms = parseInt(hex, 16);
|
|
469
|
+
return ms > 0 && Number.isFinite(ms) ? new Date(ms).toISOString().slice(0, 10) : null;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Pick the recalled block's Q/A pair most relevant to the query (content-word
|
|
473
|
+
* overlap; ties → first). Null when NO pair shares a content word — the block
|
|
474
|
+
* matched on packaging, not substance, so the honest miss must stand. */
|
|
475
|
+
function bestQaPair(blockText, query) {
|
|
476
|
+
const qWords = recallWords(query);
|
|
477
|
+
const pairs = [];
|
|
478
|
+
let open = null;
|
|
479
|
+
for (const line of String(blockText).split("\n")) {
|
|
480
|
+
if (line.startsWith("Q: ")) { open = { q: line.slice(3), a: "" }; pairs.push(open); }
|
|
481
|
+
else if (open && line.startsWith("A: ")) open.a = line.slice(3);
|
|
482
|
+
}
|
|
483
|
+
let best = null;
|
|
484
|
+
let bestScore = 0;
|
|
485
|
+
for (const p of pairs) {
|
|
486
|
+
let score = 0;
|
|
487
|
+
for (const w of recallWords(p.q)) if (qWords.has(w)) score += 1;
|
|
488
|
+
if (score > bestScore) { best = p; bestScore = score; }
|
|
489
|
+
}
|
|
490
|
+
return best;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** W2 seam: consult the folded-session block index for an honest miss. A
|
|
494
|
+
* sufficiently-relevant hit returns the recalled Q/A, framed and cited to its
|
|
495
|
+
* session; anything less returns null and the miss stands unchanged. Lazy +
|
|
496
|
+
* failure-tolerated (chat.mjs ethos): a broken memory store degrades to null. */
|
|
497
|
+
async function recallFromBlocks(memoryDir, query) {
|
|
498
|
+
try {
|
|
499
|
+
const { retrieveBlocks } = await import("./memory/blocks.mjs");
|
|
500
|
+
const hits = await retrieveBlocks(memoryDir, query, RECALL_TOP_K);
|
|
501
|
+
const best = hits[0];
|
|
502
|
+
if (!best || best.score < RECALL_MIN_SCORE || !best.text) return null;
|
|
503
|
+
const pair = bestQaPair(best.text, query);
|
|
504
|
+
if (!pair) return null;
|
|
505
|
+
const day = uuidv7Day(best.id);
|
|
506
|
+
const cite = `session ${String(best.id).slice(0, 8)}${day ? `, ${day}` : ""}`;
|
|
507
|
+
const qa = pair.a ? `Q: ${pair.q}\n A: ${pair.a}` : `Q: ${pair.q}`;
|
|
508
|
+
return `you asked about this before (${cite}):\n ${qa}`;
|
|
509
|
+
} catch {
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ---- W4: asserted Facts → answers (the memory graph's reified triples) ----
|
|
515
|
+
|
|
516
|
+
/** How a stored fact predicate reads in English — one phrase per predicate the
|
|
517
|
+
* two writers (the ACE grammar, the ConceptNet map) actually emit. An unknown
|
|
518
|
+
* predicate renders verbatim rather than being guessed around. */
|
|
519
|
+
const FACT_PREDICATE_PHRASES = {
|
|
520
|
+
"rdfs:subClassOf": "is a kind of",
|
|
521
|
+
"rdf:type": "is a",
|
|
522
|
+
"owl:disjointWith": "is not a",
|
|
523
|
+
"mgx:partOf": "is part of",
|
|
524
|
+
"mgx:hasA": "has",
|
|
525
|
+
"mgx:usedFor": "is used for",
|
|
526
|
+
"mgx:capableOf": "can",
|
|
527
|
+
"mgx:atLocation": "is found in",
|
|
528
|
+
"mgx:causes": "causes",
|
|
529
|
+
"mgx:hasProperty": "is",
|
|
530
|
+
"mgx:madeOf": "is made of",
|
|
531
|
+
"mgx:receivesAction": "can be",
|
|
532
|
+
"mgx:createdBy": "is created by",
|
|
533
|
+
"mgx:mannerOf": "is a way to",
|
|
534
|
+
"mgx:desires": "wants",
|
|
535
|
+
"mgx:locatedNear": "is typically near",
|
|
536
|
+
"mgx:motivatedByGoal": "is motivated by",
|
|
537
|
+
"mgx:obstructedBy": "can be prevented by",
|
|
538
|
+
"mgx:causesDesire": "makes you want to",
|
|
539
|
+
"mgx:hasSubevent": "involves",
|
|
540
|
+
"mgx:hasFirstSubevent": "begins with",
|
|
541
|
+
"mgx:hasLastSubevent": "ends with",
|
|
542
|
+
"mgx:hasPrerequisite": "requires",
|
|
543
|
+
};
|
|
544
|
+
const factPhrase = (f) => `${f.subject} ${FACT_PREDICATE_PHRASES[f.predicate] || f.predicate} ${f.object}`;
|
|
545
|
+
|
|
546
|
+
/** One rendered fact line: "you told me" when the chat asserted it (an ace:chat
|
|
547
|
+
* provenance tag), "i learned" for corpus-only facts — provenance VERBATIM. */
|
|
548
|
+
function renderFactLine(f) {
|
|
549
|
+
const lead = f.provenance.includes("ace:chat") ? "you told me" : "i learned";
|
|
550
|
+
return `${lead}: ${factPhrase(f)}${f.provenance ? ` (source: ${f.provenance})` : ""}`;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** Read every reified Fact out of the memory graph as plain {subject, predicate,
|
|
554
|
+
* object, provenance} rows. Lazy + failure-tolerated: no memory → []. */
|
|
555
|
+
async function memoryFacts(memoryDir) {
|
|
556
|
+
try {
|
|
557
|
+
const { loadMemory } = await import("./memory/core.mjs");
|
|
558
|
+
const m = await loadMemory(memoryDir);
|
|
559
|
+
const out = [];
|
|
560
|
+
for (const ind of m.individuals || []) {
|
|
561
|
+
if (ind?.class !== "Fact") continue;
|
|
562
|
+
const get = (k) => (ind.attributes || []).find((a) => a.key === k)?.value || "";
|
|
563
|
+
out.push({ subject: get("subject"), predicate: get("predicate"), object: get("object"), provenance: get("provenance") });
|
|
564
|
+
}
|
|
565
|
+
return out;
|
|
566
|
+
} catch {
|
|
567
|
+
return [];
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Load memory once and resolve every reified Fact into a TRUST-BEARING row
|
|
572
|
+
* ({subject,predicate,object,provenance,trust,sourceTypes,…}) via core's
|
|
573
|
+
* readFactRows — the seam the answer layer ranks + cites without re-walking the
|
|
574
|
+
* graph shape (Wave-A memory/core.mjs). Lazy + failure-tolerated: no memory → []. */
|
|
575
|
+
async function factRows(memoryDir) {
|
|
576
|
+
try {
|
|
577
|
+
const { loadMemory, readFactRows } = await import("./memory/core.mjs");
|
|
578
|
+
return readFactRows(await loadMemory(memoryDir));
|
|
579
|
+
} catch {
|
|
580
|
+
return [];
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** Spelling variants a question term is matched under (normFactTerm + a naive
|
|
585
|
+
* singular): "caches"/"a cache"/"/c/en/cache" all reach the stored "cache". */
|
|
586
|
+
function factTermVariants(normFactTerm, term) {
|
|
587
|
+
const t = normFactTerm(term);
|
|
588
|
+
const v = new Set([t]);
|
|
589
|
+
if (t.endsWith("es")) v.add(t.slice(0, -2));
|
|
590
|
+
if (t.endsWith("s")) v.add(t.slice(0, -1));
|
|
591
|
+
return v;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** "is a module a component" — the yes/no vocabulary form the graph grammar
|
|
595
|
+
* doesn't parse; checked against the isa-family fact predicates only. */
|
|
596
|
+
const ISA_ASK_RE = /^(?:is|are)\s+(?:an?\s+)?(.+?)\s+(?:a\s+kind\s+of|a\s+type\s+of|an?)\s+(.+?)[?.!\s]*$/i;
|
|
597
|
+
const ISA_PREDICATES = new Set(["rdfs:subClassOf", "rdf:type"]);
|
|
598
|
+
/** "what do you know about caches" — the open recall-everything form. */
|
|
599
|
+
const KNOW_ABOUT_RE = /^what\s+do\s+you\s+know\s+about\s+(.+?)[?.!\s]*$/i;
|
|
600
|
+
/** How many facts a single answer lists before "…and N more". */
|
|
601
|
+
const FACT_ANSWER_CAP = 5;
|
|
602
|
+
|
|
603
|
+
/** W4 seam: answer (or extend) a vocabulary/definition question from the MEMORY
|
|
604
|
+
* graph's Facts. Returns { text, replace } — `replace:false` means the engine's
|
|
605
|
+
* own (schema-docs) answer stands and the fact lines are appended under it —
|
|
606
|
+
* or null when memory holds nothing relevant (misses stay unchanged). */
|
|
607
|
+
async function factAnswer(memoryDir, query, envelope, miss) {
|
|
608
|
+
let normFactTerm;
|
|
609
|
+
try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
|
|
610
|
+
const q = String(query).trim();
|
|
611
|
+
|
|
612
|
+
// (a) meta-shaped questions ("what is a module", "what does cache mean") — the
|
|
613
|
+
// parsed object term, matched against fact SUBJECTS; consulted for hits (append
|
|
614
|
+
// alongside the schema-docs answer) and misses (facts answer alone) alike.
|
|
615
|
+
// When the engine produced NO parse at all (the empty-bootstrap graph
|
|
616
|
+
// short-circuits before parsing), the meta FORM is recognized directly on a
|
|
617
|
+
// miss — same required-article discipline as the grammar's own T5 template.
|
|
618
|
+
let metaTerm = envelope?.parsed?.shape === "meta" ? envelope.parsed.object : null;
|
|
619
|
+
if (!metaTerm && miss && !envelope?.parsed) {
|
|
620
|
+
const m = q.match(/^what\s+(?:is|are)\s+an?\s+(.+?)[?.!\s]*$/i)
|
|
621
|
+
|| q.match(/^what\s+(?:does|do)\s+(.+?)\s+means?[?.!\s]*$/i);
|
|
622
|
+
if (m) metaTerm = m[1];
|
|
623
|
+
}
|
|
624
|
+
if (metaTerm) {
|
|
625
|
+
const variants = factTermVariants(normFactTerm, metaTerm);
|
|
626
|
+
const hits = (await memoryFacts(memoryDir)).filter((f) => variants.has(f.subject));
|
|
627
|
+
if (!hits.length) return null;
|
|
628
|
+
const shown = hits.slice(0, FACT_ANSWER_CAP).map(renderFactLine);
|
|
629
|
+
const extra = hits.length > FACT_ANSWER_CAP ? `\n…and ${hits.length - FACT_ANSWER_CAP} more remembered fact${hits.length - FACT_ANSWER_CAP === 1 ? "" : "s"}.` : "";
|
|
630
|
+
return { text: shown.join("\n") + extra, replace: miss };
|
|
631
|
+
}
|
|
632
|
+
if (!miss) return null;
|
|
633
|
+
|
|
634
|
+
// (b) "is a module a component" — yes iff a remembered isa-family fact says so.
|
|
635
|
+
const isa = q.match(ISA_ASK_RE);
|
|
636
|
+
if (isa) {
|
|
637
|
+
const subj = factTermVariants(normFactTerm, isa[1]);
|
|
638
|
+
const obj = factTermVariants(normFactTerm, isa[2]);
|
|
639
|
+
const hit = (await memoryFacts(memoryDir)).find(
|
|
640
|
+
(f) => ISA_PREDICATES.has(f.predicate) && subj.has(f.subject) && obj.has(f.object),
|
|
641
|
+
);
|
|
642
|
+
if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
|
|
643
|
+
return null; // no remembered fact — the honest miss stands (never a guessed "no")
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// (c) "what do you know about caches" — everything remembered that MENTIONS the
|
|
647
|
+
// term (subject or object), capped.
|
|
648
|
+
const know = q.match(KNOW_ABOUT_RE);
|
|
649
|
+
if (know) {
|
|
650
|
+
const variants = factTermVariants(normFactTerm, know[1]);
|
|
651
|
+
const hits = (await memoryFacts(memoryDir)).filter((f) => variants.has(f.subject) || variants.has(f.object));
|
|
652
|
+
if (!hits.length) return null;
|
|
653
|
+
// echo the STORED spelling ("caches" asked → "cache" known), never a guess
|
|
654
|
+
const term = variants.has(hits[0].subject) ? hits[0].subject : hits[0].object;
|
|
655
|
+
const shown = hits.slice(0, FACT_ANSWER_CAP).map((f) => ` ${renderFactLine(f)}`);
|
|
656
|
+
const extra = hits.length > FACT_ANSWER_CAP ? `\n …and ${hits.length - FACT_ANSWER_CAP} more.` : "";
|
|
657
|
+
return { text: `${hits.length} remembered fact${hits.length === 1 ? "" : "s"} about ${term}:\n${shown.join("\n")}${extra}`, replace: true };
|
|
658
|
+
}
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** "what did i tell you about X" — the multi-turn recall phrasing (a sibling of
|
|
663
|
+
* factAnswer's "what do you know about X" KNOW_ABOUT form): everything remembered
|
|
664
|
+
* that mentions X on either side. */
|
|
665
|
+
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;
|
|
666
|
+
/** "what kind of thing is an X" — the subject-side membership phrasing the grammar
|
|
667
|
+
* doesn't parse: reports X's OWN remembered type (falling back to X's members). */
|
|
668
|
+
const KIND_OF_RE = /^what\s+kind\s+of\s+(?:thing|class|type|category|entity)?\s*(?:is|are)\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
|
|
669
|
+
/** WHOLE-STORE recall (CHATBENCH_006 lever 3): "what did i tell you [last time]",
|
|
670
|
+
* "what facts do you know", "what do you remember" — list EVERY remembered fact
|
|
671
|
+
* (no subject/object term to filter on), cited, higher-trust first. The multi-turn
|
|
672
|
+
* / cross-session assert-recall surfaces that carry no term the grammar can bind. */
|
|
673
|
+
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;
|
|
674
|
+
|
|
675
|
+
/** The singular class-noun of the graph entity a term names ("app/lib/a.mjs" →
|
|
676
|
+
* "module", "Widget" → "class"), via the ask engine's own resolver + the loaded
|
|
677
|
+
* graph's class map — or null on a miss/ambiguity/no-graph. Lets forward
|
|
678
|
+
* membership answer over a graph INSTANCE, not just a bare class word. */
|
|
679
|
+
async function entityClassNoun(graph, term) {
|
|
680
|
+
const ent = await resolveEntity(graph, term);
|
|
681
|
+
if (!ent) return null;
|
|
682
|
+
const cls = (graph?.byId?.get?.(ent.id) || (graph?.individuals || []).find((i) => i?.id === ent.id))?.class;
|
|
683
|
+
return cls && CLASS_LABELS[cls] ? CLASS_LABELS[cls][0] : null;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** ASSERT-RECALL MULTI-TURN READ-BACK (PLAN_CYCLE_4 tail → cycle-005 lever 2):
|
|
687
|
+
* once "every X is a Y" is asserted in an earlier turn, the graded assert-recall
|
|
688
|
+
* cells (B2/C1 assert) query it back across turns in shapes the graph grammar
|
|
689
|
+
* can't parse — so each dies as an honest miss even though "X is a kind of Y" is
|
|
690
|
+
* remembered. This reader answers those declare-then-recall shapes from the
|
|
691
|
+
* reified Facts (readFactRows — trust-bearing), citing each fact's provenance
|
|
692
|
+
* verbatim, higher-trust first:
|
|
693
|
+
* (a) FORWARD membership "is an X a Y" — X a class WORD ("is a module a
|
|
694
|
+
* component") OR a graph INSTANCE ("is app/lib/a.mjs a component", resolved
|
|
695
|
+
* to its class-noun) — yes iff a remembered isa-family fact says so;
|
|
696
|
+
* (b) RECALL "what did i tell you about X" — every remembered fact mentioning X;
|
|
697
|
+
* (c) REVERSE membership — "what is a Y" reports Y's members (object-side), and
|
|
698
|
+
* "what kind of thing is an X" reports X's own type (subject-side first).
|
|
699
|
+
* Miss-only and run AFTER factAnswer returns null, so it never shadows the
|
|
700
|
+
* subject-side answer or a schema hit. Returns { text, replace:true } or null. */
|
|
701
|
+
async function factReadBack(memoryDir, query, envelope, miss, graph = null) {
|
|
702
|
+
if (!miss) return null;
|
|
703
|
+
let normFactTerm;
|
|
704
|
+
try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
|
|
705
|
+
const q = String(query).trim();
|
|
706
|
+
const rows = await factRows(memoryDir);
|
|
707
|
+
if (!rows.length) return null;
|
|
708
|
+
const isa = rows.filter((f) => ISA_PREDICATES.has(f.predicate));
|
|
709
|
+
const byTrust = (a, b) => b.trust - a.trust;
|
|
710
|
+
const renderMany = (hits) => {
|
|
711
|
+
const shown = hits.slice(0, FACT_ANSWER_CAP).map(renderFactLine);
|
|
712
|
+
const n = hits.length - FACT_ANSWER_CAP;
|
|
713
|
+
const extra = n > 0 ? `\n…and ${n} more remembered fact${n === 1 ? "" : "s"}.` : "";
|
|
714
|
+
return { text: shown.join("\n") + extra, replace: true };
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
// (d) WHOLE-STORE recall (CHATBENCH_006 lever 3) — "what did i tell you last time",
|
|
718
|
+
// "what facts do you know": no term to bind, so list every remembered fact,
|
|
719
|
+
// higher-trust first, each cited. Answers the cross-session assert-recall surfaces.
|
|
720
|
+
if (WHOLE_RECALL_RE.test(q)) {
|
|
721
|
+
const hits = (isa.length ? isa : rows).slice().sort(byTrust);
|
|
722
|
+
if (!hits.length) return null;
|
|
723
|
+
return renderMany(hits);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// (a) FORWARD membership — "is an X a Y". X's fact-subject candidates are the
|
|
727
|
+
// term itself (a class word) AND, when it resolves in the graph, its class-noun
|
|
728
|
+
// (an instance) — so "is app/lib/a.mjs a component" answers off "module …".
|
|
729
|
+
const isaAsk = q.match(ISA_ASK_RE);
|
|
730
|
+
if (isaAsk) {
|
|
731
|
+
const objVariants = factTermVariants(normFactTerm, isaAsk[2]);
|
|
732
|
+
const subjCandidates = new Set(factTermVariants(normFactTerm, isaAsk[1]));
|
|
733
|
+
const noun = await entityClassNoun(graph, isaAsk[1]);
|
|
734
|
+
if (noun) for (const v of factTermVariants(normFactTerm, noun)) subjCandidates.add(v);
|
|
735
|
+
const hit = isa
|
|
736
|
+
.filter((f) => subjCandidates.has(f.subject) && objVariants.has(f.object))
|
|
737
|
+
.sort(byTrust)[0];
|
|
738
|
+
if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true };
|
|
739
|
+
return null; // no remembered fact — the honest miss stands (never a guessed "no")
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// (b) RECALL — "what did i tell you about X": every remembered fact mentioning X.
|
|
743
|
+
const told = q.match(TOLD_ABOUT_RE);
|
|
744
|
+
if (told) {
|
|
745
|
+
const variants = factTermVariants(normFactTerm, told[1]);
|
|
746
|
+
const hits = rows.filter((f) => variants.has(f.subject) || variants.has(f.object)).sort(byTrust);
|
|
747
|
+
if (!hits.length) return null;
|
|
748
|
+
const term = variants.has(hits[0].subject) ? hits[0].subject : hits[0].object;
|
|
749
|
+
const shown = hits.slice(0, FACT_ANSWER_CAP).map((f) => ` ${renderFactLine(f)}`);
|
|
750
|
+
const extra = hits.length > FACT_ANSWER_CAP ? `\n …and ${hits.length - FACT_ANSWER_CAP} more.` : "";
|
|
751
|
+
return { text: `${hits.length} remembered fact${hits.length === 1 ? "" : "s"} about ${term}:\n${shown.join("\n")}${extra}`, replace: true };
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// (c) REVERSE / "what kind of thing" membership. The meta form ("what is a Y")
|
|
755
|
+
// comes from the parse when present, else recognized directly on a no-parse miss;
|
|
756
|
+
// "what kind of thing is an X" is recognized regardless (the grammar never parses
|
|
757
|
+
// it as meta). "what is a Y" reports Y's MEMBERS (object-side); "what kind of
|
|
758
|
+
// thing is an X" reports X's own TYPE (subject-side first), so both directions
|
|
759
|
+
// of a single remembered "X is a kind of Y" are queryable.
|
|
760
|
+
let term = envelope?.parsed?.shape === "meta" ? envelope.parsed.object : null;
|
|
761
|
+
let kindOf = false;
|
|
762
|
+
const mk = q.match(KIND_OF_RE);
|
|
763
|
+
if (mk) { term = mk[1]; kindOf = true; }
|
|
764
|
+
else if (!term && !envelope?.parsed) {
|
|
765
|
+
const m = q.match(/^what\s+(?:is|are)\s+an?\s+(.+?)[?.!\s]*$/i);
|
|
766
|
+
if (m) term = m[1];
|
|
767
|
+
}
|
|
768
|
+
if (!term) return null;
|
|
769
|
+
const variants = factTermVariants(normFactTerm, term);
|
|
770
|
+
const subjectHits = isa.filter((f) => variants.has(f.subject)).sort(byTrust);
|
|
771
|
+
const objectHits = isa.filter((f) => variants.has(f.object)).sort(byTrust);
|
|
772
|
+
const hits = kindOf
|
|
773
|
+
? (subjectHits.length ? subjectHits : objectHits)
|
|
774
|
+
: (objectHits.length ? objectHits : subjectHits);
|
|
775
|
+
if (!hits.length) return null;
|
|
776
|
+
return renderMany(hits);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// ---- W5: corpus on-demand — LOCAL tier only, behind an explicit flag ----
|
|
780
|
+
|
|
781
|
+
/** The opt-in env flag: TMCT_CORPUS_LOOKUP=1 lets an unknown-term miss consult
|
|
782
|
+
* the LOCAL committed ConceptNet slice (tier 1 of the corpus tiering policy).
|
|
783
|
+
* Default OFF for this wave.
|
|
784
|
+
*
|
|
785
|
+
* TIER-3 SEAM (documented, NOT implemented): a network lookup (the ConceptNet
|
|
786
|
+
* API for terms the local slice misses, cached down into .tmct/corpus/ per the
|
|
787
|
+
* tier-2 policy) would attach exactly where corpusAside() returns null below —
|
|
788
|
+
* behind its own explicit opt-in flag, never in the default path, and any
|
|
789
|
+
* network failure must degrade to the honest miss ($0-offline is inviolable). */
|
|
790
|
+
export const CORPUS_LOOKUP_FLAG = "TMCT_CORPUS_LOOKUP";
|
|
791
|
+
/** How many corpus surface lines one aside quotes. */
|
|
792
|
+
const CORPUS_ASIDE_CAP = 2;
|
|
793
|
+
|
|
794
|
+
let corpusPromise = null; // the local slice as renderable rows, one load per process
|
|
795
|
+
/** Load the committed slice + relation map once, as { key, surface } rows —
|
|
796
|
+
* `surface` is the map's own canonical sentence ("a cache is used for storing
|
|
797
|
+
* data"), `key` the normFactTerm-normalized subject. Failure → []. */
|
|
798
|
+
function localCorpus() {
|
|
799
|
+
if (!corpusPromise) {
|
|
800
|
+
corpusPromise = (async () => {
|
|
801
|
+
const { loadSlice, loadMap, termText } = await import("./corpus/conceptnet.mjs");
|
|
802
|
+
const { normFactTerm } = await import("./memory/core.mjs");
|
|
803
|
+
const [assertions, map] = await Promise.all([loadSlice(), loadMap()]);
|
|
804
|
+
const rows = [];
|
|
805
|
+
for (const a of assertions) {
|
|
806
|
+
const row = map.get(a.rel);
|
|
807
|
+
if (!row || row.ace === "none" || !row.surface) continue; // non-emissions stay out here too
|
|
808
|
+
const subject = termText(a.start);
|
|
809
|
+
const object = termText(a.end);
|
|
810
|
+
if (!subject || !object) continue;
|
|
811
|
+
rows.push({
|
|
812
|
+
key: normFactTerm(subject),
|
|
813
|
+
surface: String(row.surface).replace("{start}", subject).replace("{end}", object),
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
return rows;
|
|
817
|
+
})().catch(() => []);
|
|
818
|
+
}
|
|
819
|
+
return corpusPromise;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** W5 seam: the grounded aside for an unknown term, or null (which is also
|
|
823
|
+
* where the tier-3 network lookup would attach — see CORPUS_LOOKUP_FLAG). */
|
|
824
|
+
async function corpusAside(term) {
|
|
825
|
+
try {
|
|
826
|
+
const { normFactTerm } = await import("./memory/core.mjs");
|
|
827
|
+
const variants = factTermVariants(normFactTerm, term);
|
|
828
|
+
const rows = (await localCorpus()).filter((r) => variants.has(r.key));
|
|
829
|
+
if (!rows.length) return null;
|
|
830
|
+
const shown = rows.slice(0, CORPUS_ASIDE_CAP).map((r) => r.surface);
|
|
831
|
+
return `the corpus knows: ${shown.join("; ")} (ConceptNet, CC-BY-SA)`;
|
|
832
|
+
} catch {
|
|
833
|
+
return null;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** The explicit recall question forms — "what did i ask before", "what did we
|
|
838
|
+
* talk about", "what have we discussed" — answered from memory, never the graph. */
|
|
839
|
+
const RECALL_ASK_RE = /^what (?:did|have) (?:i|we) (?:ask(?:ed)?(?: you)?|talk(?:ed)? about|discuss(?:ed)?)(?: before| earlier| previously| last time)?[?.!]*$/i;
|
|
840
|
+
|
|
841
|
+
/** Summarize the most recent folded session's questions (block ids are session
|
|
842
|
+
* uuidv7s, so a plain sort is chronological). Null when nothing is folded yet. */
|
|
843
|
+
async function recallSummary(memoryDir) {
|
|
844
|
+
try {
|
|
845
|
+
const { loadBlockIndex, BLOCKS_DIR_REL } = await import("./memory/blocks.mjs");
|
|
846
|
+
const index = await loadBlockIndex(memoryDir);
|
|
847
|
+
const id = Object.keys(index.blocks).sort().at(-1);
|
|
848
|
+
if (!id) return null;
|
|
849
|
+
const text = await readFile(join(memoryDir, BLOCKS_DIR_REL, index.blocks[id].file), "utf8");
|
|
850
|
+
const qs = text.split("\n").filter((l) => l.startsWith("Q: ")).map((l) => l.slice(3)).slice(0, 6);
|
|
851
|
+
if (!qs.length) return null;
|
|
852
|
+
const day = uuidv7Day(id);
|
|
853
|
+
return `last time (session ${String(id).slice(0, 8)}${day ? `, ${day}` : ""}) you asked: ${qs.map((q) => `"${q}"`).join("; ")}`;
|
|
854
|
+
} catch {
|
|
855
|
+
return null;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** "[and/so/…] what about X" — a discourse continuation that re-asks the previous
|
|
860
|
+
* turn's question with X swapped in. */
|
|
861
|
+
const WHAT_ABOUT_RE = /^(?:(?:and|so|but|ok|okay|now|then)\s+)*what about\s+(.+?)[?.!\s]*$/i;
|
|
862
|
+
/** A code-ish name token in a prior query (a path/dotted name, or a CamelCase/
|
|
863
|
+
* Capitalized symbol) — the subject "what about X" replaces. */
|
|
864
|
+
const NAME_TOKEN_RE = /\b[\w-]+(?:[/.][\w-]+)+\b|\b[A-Z][A-Za-z0-9_]*\b/;
|
|
865
|
+
|
|
866
|
+
/** DISCOURSE CONTINUATION (CHATBENCH_006 lever 2): "what about X" carries the PRIOR
|
|
867
|
+
* turn's question shape across the turn boundary — re-asking it with X in place of
|
|
868
|
+
* the previous subject/object. Returns the reconstructed query (parsed like any
|
|
869
|
+
* subject question, so X resolves and becomes the new focus), or null when there's
|
|
870
|
+
* no prior query or no name token to swap (→ the ordinary honest miss stands). */
|
|
871
|
+
function discourseRewrite(query, last) {
|
|
872
|
+
const m = String(query).match(WHAT_ABOUT_RE);
|
|
873
|
+
if (!m || !last?.query) return null;
|
|
874
|
+
const prevQ = String(last.query);
|
|
875
|
+
if (!NAME_TOKEN_RE.test(prevQ)) return null;
|
|
876
|
+
const newSubj = m[1].trim();
|
|
877
|
+
return prevQ.replace(NAME_TOKEN_RE, () => newSubj);
|
|
878
|
+
}
|
|
879
|
+
|
|
371
880
|
/** A bare question → tmct_ask. When a focus is set AND the graph is in hand we
|
|
372
881
|
* call ask() directly to thread the focus as contextId (so a pronoun like "it"
|
|
373
882
|
* resolves to the focus) — building the SAME delimited string dispatchTool emits;
|
|
374
883
|
* otherwise the unchanged dispatchTool path (which also yields the no-graph error).
|
|
375
884
|
* A hit updates the focus to the resolved object. Grammar miss / ToolError → a
|
|
376
885
|
* normal answer, never a crash. */
|
|
377
|
-
async function runAsk(query, { config, source, graph, focus }) {
|
|
886
|
+
async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, env }) {
|
|
378
887
|
const ts = new Date().toISOString();
|
|
888
|
+
// DISCOURSE ANAPHORA (CHATBENCH_006 levers 1+2): a follow-up like "which of those
|
|
889
|
+
// are tested" / "how many of those" / "count them" filters or counts the PREVIOUS
|
|
890
|
+
// answer's entity set. That set is the ids the last dispatched turn cited — carried
|
|
891
|
+
// on `last.detail.matches`. Threading it as ask()'s `prev` is what lets the anaphora
|
|
892
|
+
// node resolve instead of the "needs a previous answer" honest miss.
|
|
893
|
+
const prev = (last?.detail?.matches || []).map((m) => m?.id).filter(Boolean);
|
|
894
|
+
// The query the ENGINE parses: a "what about X" continuation is rewritten to the
|
|
895
|
+
// prior shape with X swapped in; everything else parses verbatim. The record and
|
|
896
|
+
// transcript keep the user's ACTUAL words (`query`), only the parse target changes.
|
|
897
|
+
const askQuery = discourseRewrite(query, last) ?? query;
|
|
898
|
+
// W2: the explicit recall forms are answered from memory's folded blocks, never
|
|
899
|
+
// the graph. Gated on memoryDir — a bare runTurn (no session shell) stays pure.
|
|
900
|
+
if (memoryDir && RECALL_ASK_RE.test(String(query).trim())) {
|
|
901
|
+
const summary = await recallSummary(memoryDir);
|
|
902
|
+
return plainTurn(query, summary ?? "nothing to recall yet — no earlier session has been folded into memory.", {
|
|
903
|
+
via: "recall", miss: !summary, focus,
|
|
904
|
+
});
|
|
905
|
+
}
|
|
379
906
|
let answer;
|
|
380
907
|
let envelope = null;
|
|
381
908
|
try {
|
|
382
909
|
let text;
|
|
383
|
-
if (graph && focus?.id) {
|
|
910
|
+
if (graph && (focus?.id || prev.length)) {
|
|
911
|
+
// Direct ask() when EITHER a focus is set (thread it as contextId so "it"
|
|
912
|
+
// binds) OR the previous turn produced a set to refer back to (thread it as
|
|
913
|
+
// `prev` for the anaphora node). Builds the SAME delimited envelope dispatchTool
|
|
914
|
+
// emits, so the parse below is identical either way.
|
|
384
915
|
const { ask } = await import("./ask.mjs");
|
|
385
|
-
const r = ask(graph,
|
|
916
|
+
const r = ask(graph, askQuery, { contextId: focus?.id ?? null, prev });
|
|
386
917
|
text = `${r.content}${ASK_ENVELOPE_DELIM}${JSON.stringify(r.tmct_ask, null, 2)}`;
|
|
387
918
|
} else {
|
|
388
|
-
text = await dispatchTool("tmct_ask", { query }, { config, source });
|
|
919
|
+
text = await dispatchTool("tmct_ask", { query: askQuery }, { config, source });
|
|
389
920
|
}
|
|
390
921
|
const [content, envJson] = text.split(ASK_ENVELOPE_DELIM);
|
|
391
922
|
answer = content;
|
|
@@ -404,11 +935,52 @@ async function runAsk(query, { config, source, graph, focus }) {
|
|
|
404
935
|
}
|
|
405
936
|
const answeredIds = (envelope?.matches || []).map((m) => m?.id).filter(Boolean);
|
|
406
937
|
const miss = envelope ? !!envelope.miss : true;
|
|
938
|
+
// Answer provenance (W1): "composed" is the ask engine's productive band; the
|
|
939
|
+
// orientation swap below is template wording, so those turns carry via:"template".
|
|
940
|
+
let via = "composed";
|
|
941
|
+
let recordMiss = miss;
|
|
407
942
|
// On a MISS: a conversational miss (a greeting, "what can you do", a very short
|
|
408
943
|
// non-code line) gets the friendly orientation instead of the raw grammar hint. A
|
|
409
944
|
// near-miss STRUCTURAL question keeps the precise hint the engine already produced.
|
|
410
|
-
if (miss && isConversational(query))
|
|
411
|
-
|
|
945
|
+
if (miss && isConversational(query)) {
|
|
946
|
+
answer = tRender(templates, T_ORIENTATION) ?? TEMPLATES_UNAVAILABLE;
|
|
947
|
+
via = "template";
|
|
948
|
+
} else if (memoryDir) {
|
|
949
|
+
// W4: vocabulary/definition questions consult the MEMORY graph's Facts
|
|
950
|
+
// alongside the schema-docs surface — a remembered fact answers a miss (or
|
|
951
|
+
// extends a schema hit), cited with its provenance verbatim. Checked BEFORE
|
|
952
|
+
// recall: a reified fact is stronger evidence than a transcript echo.
|
|
953
|
+
// Subject-side facts first (factAnswer), then the reverse-membership read-back
|
|
954
|
+
// (factReadBack) so an asserted "every X is a Y" answers "what is a Y" too.
|
|
955
|
+
const fact = (await factAnswer(memoryDir, query, envelope, miss))
|
|
956
|
+
?? (await factReadBack(memoryDir, query, envelope, miss, graph));
|
|
957
|
+
if (fact) {
|
|
958
|
+
answer = fact.replace ? fact.text : `${answer}\n${fact.text}`;
|
|
959
|
+
via = "fact";
|
|
960
|
+
recordMiss = false;
|
|
961
|
+
} else if (miss) {
|
|
962
|
+
// W2: after the honest miss is composed, consult the folded-session memory. A
|
|
963
|
+
// relevant enough block ANSWERS — recalled Q/A framed + cited first, with the
|
|
964
|
+
// engine's own miss hint kept below; no hit leaves the miss byte-unchanged.
|
|
965
|
+
const recalled = await recallFromBlocks(memoryDir, query);
|
|
966
|
+
if (recalled) {
|
|
967
|
+
answer = `${recalled}\n\n${answer}`;
|
|
968
|
+
via = "recall";
|
|
969
|
+
recordMiss = false; // memory answered it, cited — no longer a blank
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
// W5 (flag-gated, default OFF): an unknown-term miss may consult the LOCAL
|
|
974
|
+
// committed corpus slice — a hit APPENDS a grounded, licence-cited aside under
|
|
975
|
+
// the honest miss (the miss itself stands; the aside is context, not an answer).
|
|
976
|
+
if (recordMiss && envelope?.parsed?.object && String(env?.[CORPUS_LOOKUP_FLAG] || "") === "1") {
|
|
977
|
+
const aside = await corpusAside(envelope.parsed.object);
|
|
978
|
+
if (aside) {
|
|
979
|
+
answer = `${answer}\n${aside}`;
|
|
980
|
+
via = "corpus";
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
const record = { type: "turn", ts, query, via, resolvedIds, answeredIds, miss: recordMiss };
|
|
412
984
|
const logLines = [ts, `> ${query}`, answer, ""];
|
|
413
985
|
// `detail` feeds why/say-more's verbose re-render: the traversal receipt + the
|
|
414
986
|
// matched entities the terse render trims (see renderVerbose).
|
|
@@ -418,12 +990,12 @@ async function runAsk(query, { config, source, graph, focus }) {
|
|
|
418
990
|
|
|
419
991
|
/** A non-ask, non-dispatch chat turn (count answer, /stats) — the same
|
|
420
992
|
* { answer, logLines, record, focus } shape, recorded like any other turn. */
|
|
421
|
-
function plainTurn(query, answer, { command, miss = false, focus = null } = {}) {
|
|
993
|
+
function plainTurn(query, answer, { command, via = "composed", miss = false, focus = null } = {}) {
|
|
422
994
|
const ts = new Date().toISOString();
|
|
423
995
|
return {
|
|
424
996
|
answer,
|
|
425
997
|
logLines: [ts, `> ${query}`, answer, ""],
|
|
426
|
-
record: { type: "turn", ts, query, ...(command ? { command } : {}), resolvedIds: [], answeredIds: [], miss },
|
|
998
|
+
record: { type: "turn", ts, query, ...(command ? { command } : {}), via, resolvedIds: [], answeredIds: [], miss },
|
|
427
999
|
focus,
|
|
428
1000
|
};
|
|
429
1001
|
}
|
|
@@ -432,7 +1004,7 @@ function plainTurn(query, answer, { command, miss = false, focus = null } = {})
|
|
|
432
1004
|
* the same { answer, logLines, record, focus } shape as runAsk; the record carries
|
|
433
1005
|
* the command name and the resolved entity id (for entity commands) so a
|
|
434
1006
|
* slash-command turn becomes asksAbout graph data wherever it resolves an entity. */
|
|
435
|
-
async function runCommand(line, { config, source, graph, focus }) {
|
|
1007
|
+
async function runCommand(line, { config, source, graph, focus, memoryDir }) {
|
|
436
1008
|
const ts = new Date().toISOString();
|
|
437
1009
|
const sp = line.indexOf(" ");
|
|
438
1010
|
const name = (sp === -1 ? line.slice(1) : line.slice(1, sp)).toLowerCase();
|
|
@@ -440,13 +1012,25 @@ async function runCommand(line, { config, source, graph, focus }) {
|
|
|
440
1012
|
const mk = (answer, { resolvedIds = [], miss = false, newFocus = focus } = {}) => ({
|
|
441
1013
|
answer,
|
|
442
1014
|
logLines: [ts, `> ${line}`, answer, ""],
|
|
443
|
-
record: { type: "turn", ts, query: line, command: name, resolvedIds, answeredIds: [], miss },
|
|
1015
|
+
record: { type: "turn", ts, query: line, command: name, via: "command", resolvedIds, answeredIds: [], miss },
|
|
444
1016
|
focus: newFocus,
|
|
445
1017
|
});
|
|
446
1018
|
|
|
447
1019
|
if (name === "help") return mk(await helpText());
|
|
448
1020
|
if (name === "stats") return graph ? mk(renderStats(graph)) : mk("no graph loaded — /stats needs an index.", { miss: true });
|
|
449
1021
|
|
|
1022
|
+
// /memory [verbose] — what tmct remembers, as text (the ROADMAP "Memory
|
|
1023
|
+
// inspection" surface; the same renderer serves the `tmct memory` CLI).
|
|
1024
|
+
if (name === "memory") {
|
|
1025
|
+
if (!memoryDir) return mk("no memory store here — /memory works inside a repo session.", { miss: true });
|
|
1026
|
+
try {
|
|
1027
|
+
const { inspectMemory } = await import("./memory/inspect.mjs");
|
|
1028
|
+
return mk(await inspectMemory(memoryDir, { verbose: /^(?:-v|--verbose|verbose)$/i.test(argText) }));
|
|
1029
|
+
} catch (e) {
|
|
1030
|
+
return mk(String(e?.message || e), { miss: true }); // a broken store reads as its own clean error
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
450
1034
|
if (name === "focus") {
|
|
451
1035
|
if (!argText) return mk(focus ? `focus is ${focus.label}` : "no focus set — /focus <symbol> to set one.");
|
|
452
1036
|
const ent = await resolveEntity(graph, isPronoun(argText) ? focus?.label : argText);
|
|
@@ -484,16 +1068,21 @@ async function runCommand(line, { config, source, graph, focus }) {
|
|
|
484
1068
|
* any grammar miss / residue / import failure so the query engine keeps first
|
|
485
1069
|
* refusal on everything else. Lazy imports + catch-all: the grammar layer can
|
|
486
1070
|
* never crash a turn (chat.mjs ethos). Writes ONLY under memoryDir/.tmct/memory. */
|
|
487
|
-
async function assertTurn(line, { memoryDir, sessionId, focus }) {
|
|
1071
|
+
async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null }) {
|
|
488
1072
|
try {
|
|
489
1073
|
const { parseAce } = await import("./grammar/ace.mjs");
|
|
490
|
-
|
|
491
|
-
|
|
1074
|
+
// A session handle carries its own loaded lexicon (createSession loads it once);
|
|
1075
|
+
// a bare runTurn (no handle) lazy-loads the cached core lexicon. The lexicon is
|
|
1076
|
+
// immutable, so sharing one reference across concurrent handles is re-entrant.
|
|
1077
|
+
let lex = lexicon;
|
|
1078
|
+
if (!lex) { const { loadLexicon } = await import("./grammar/lexicon.mjs"); lex = loadLexicon(); }
|
|
1079
|
+
const parse = parseAce(line, lex);
|
|
492
1080
|
if (!parse || !parse.triples?.length || parse.residue?.length) return null;
|
|
493
1081
|
const { assertSentence } = await import("./grammar/assert.mjs");
|
|
494
1082
|
const { normFactTerm } = await import("./memory/core.mjs");
|
|
495
1083
|
const ts = new Date().toISOString();
|
|
496
1084
|
const res = await assertSentence(memoryDir, line, {
|
|
1085
|
+
lexicon: lex,
|
|
497
1086
|
provenance: { source: "chat", sessionId, ts },
|
|
498
1087
|
});
|
|
499
1088
|
if (!res || !res.ids?.length) return null;
|
|
@@ -502,7 +1091,7 @@ async function assertTurn(line, { memoryDir, sessionId, focus }) {
|
|
|
502
1091
|
.join("; ");
|
|
503
1092
|
const n = res.ids.length;
|
|
504
1093
|
const answer = `noted — remembered ${n} fact${n === 1 ? "" : "s"}: ${shown}`;
|
|
505
|
-
return plainTurn(line, answer, { command: "assert", focus });
|
|
1094
|
+
return plainTurn(line, answer, { command: "assert", via: "assert", focus });
|
|
506
1095
|
} catch {
|
|
507
1096
|
return null; // grammar unavailable / write failed — fall through to the engine
|
|
508
1097
|
}
|
|
@@ -522,12 +1111,22 @@ async function assertTurn(line, { memoryDir, sessionId, focus }) {
|
|
|
522
1111
|
* subject), `answeredIds` the entity ids an ask answer cited; a slash-command turn
|
|
523
1112
|
* also carries its `command` name. Both drive the mgx:asksAbout graph append.
|
|
524
1113
|
*/
|
|
525
|
-
export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "" } = {}) {
|
|
1114
|
+
export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null } = {}) {
|
|
526
1115
|
const line = String(input ?? "").trim();
|
|
527
|
-
const
|
|
1116
|
+
const templates = await chatTemplates(); // failure-tolerated: null degrades, never throws
|
|
1117
|
+
const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon };
|
|
528
1118
|
// A DISPATCHED turn (count / slash-command / ask) becomes the new "last answer"
|
|
529
1119
|
// that why/say-more re-renders; a conversational turn does not (it preserves it).
|
|
530
|
-
|
|
1120
|
+
// FINISH SEAM (PLAN_RESPONSE_FINISHING §"Where it lives"): every dispatched turn's
|
|
1121
|
+
// result passes through finish() here — the LAST transform in the turn — before its
|
|
1122
|
+
// finished answer becomes the `last` we expand. finish() owns the prose-span
|
|
1123
|
+
// grammar pass (src/finish.mjs); it rewrites result.answer/logLines and leaves the
|
|
1124
|
+
// protected spans (entities, paths, numbers, receipts, provenance) byte-invariant,
|
|
1125
|
+
// so `last` and the transcript stay consistent with what the shell prints.
|
|
1126
|
+
const withLast = (result) => {
|
|
1127
|
+
const finished = finish(result, { graph });
|
|
1128
|
+
return { ...finished, last: { query: line, answer: finished.answer, detail: finished.detail ?? null } };
|
|
1129
|
+
};
|
|
531
1130
|
|
|
532
1131
|
// Conversational layer first (greetings, thanks, help, bye, why/say-more) — these
|
|
533
1132
|
// resolve no entity and carry their own preserved `last`.
|
|
@@ -546,10 +1145,59 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
546
1145
|
// Aggregate/count questions are answered mechanically off the loaded graph header,
|
|
547
1146
|
// BEFORE falling through to the ask engine (focus unchanged — a count names no entity).
|
|
548
1147
|
const count = answerCount(graph, line);
|
|
549
|
-
if (count != null)
|
|
1148
|
+
if (count != null) {
|
|
1149
|
+
// An "I can't count <noun>" from a bare kind may still be answerable from an
|
|
1150
|
+
// ASSERTED vocabulary fact ("every class is a type" → "how many types" = the
|
|
1151
|
+
// class count). countFromFacts declines on a real graph kind, so ordinary
|
|
1152
|
+
// counts are unaffected; it only speaks for a remembered object noun.
|
|
1153
|
+
const viaFact = memoryDir ? await countFromFacts(graph, memoryDir, line) : null;
|
|
1154
|
+
if (viaFact != null) return withLast(plainTurn(line, viaFact, { via: "fact", focus }));
|
|
1155
|
+
return withLast(plainTurn(line, count, { via: "count", focus }));
|
|
1156
|
+
}
|
|
550
1157
|
return withLast(await runAsk(line, ctx));
|
|
551
1158
|
}
|
|
552
1159
|
|
|
1160
|
+
// ---- W3: seedMemory → bootstrap (first run in a graph-less repo) ----
|
|
1161
|
+
|
|
1162
|
+
/** How many corpus facts the first-run bootstrap seeds. Measured curve (dev
|
|
1163
|
+
* laptop, appendFact's read-modify-write per fact): 100→~0.16s, 250→~0.54s,
|
|
1164
|
+
* 500→~1.7s — the full 500 stays inside a session-start budget, so the seed
|
|
1165
|
+
* runs synchronously and complete (no partial-sync cap needed). */
|
|
1166
|
+
export const SEED_LIMIT = 500;
|
|
1167
|
+
|
|
1168
|
+
/** Which predicates the capped seed prefers (stable order — see seedMemory's
|
|
1169
|
+
* `prefer`): the definitional band first, so a bootstrap's 500 facts answer
|
|
1170
|
+
* "what is a cache?"-style vocabulary questions rather than location trivia. */
|
|
1171
|
+
export const SEED_PREFER = ["rdfs:subClassOf", "rdf:type", "mgx:usedFor", "mgx:partOf", "mgx:capableOf"];
|
|
1172
|
+
|
|
1173
|
+
/** The seed marker: its presence means this repo's memory already carries the
|
|
1174
|
+
* corpus seed, so re-runs skip without even reading the slice. */
|
|
1175
|
+
export const SEED_MARKER_REL = join(".tmct", "memory", "corpus-seed.json");
|
|
1176
|
+
|
|
1177
|
+
/** Seed the ConceptNet slice into <repo>/.tmct/memory once. Idempotent twice
|
|
1178
|
+
* over (the marker short-circuits; seedMemory itself content-hashes fact ids)
|
|
1179
|
+
* and failure-tolerated: a missing/broken corpus degrades to the unseeded
|
|
1180
|
+
* bootstrap — never an error before the prompt. Returns seedMemory's
|
|
1181
|
+
* { appended, skipped, total } on a fresh seed, null when skipped/failed. */
|
|
1182
|
+
async function seedBootstrapMemory(repo) {
|
|
1183
|
+
const marker = join(repo, SEED_MARKER_REL);
|
|
1184
|
+
try {
|
|
1185
|
+
await readFile(marker, "utf8");
|
|
1186
|
+
return null; // already seeded — the marker is authoritative
|
|
1187
|
+
} catch { /* no marker → first run */ }
|
|
1188
|
+
try {
|
|
1189
|
+
const { seedMemory } = await import("./corpus/conceptnet.mjs");
|
|
1190
|
+
const res = await seedMemory(repo, { limit: SEED_LIMIT, prefer: SEED_PREFER });
|
|
1191
|
+
await mkdir(dirname(marker), { recursive: true });
|
|
1192
|
+
await writeFile(marker, JSON.stringify({
|
|
1193
|
+
seededAt: new Date().toISOString(), limit: SEED_LIMIT, appended: res.appended, skipped: res.skipped,
|
|
1194
|
+
}) + "\n");
|
|
1195
|
+
return res;
|
|
1196
|
+
} catch {
|
|
1197
|
+
return null; // corpus unavailable — bootstrap proceeds unseeded
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
553
1201
|
/** Trim a focus label for the prompt so a long module path can't run the line off. */
|
|
554
1202
|
const shortLabel = (l) => { const s = String(l); return s.length > 40 ? "…" + s.slice(-39) : s; };
|
|
555
1203
|
const promptFor = (focus) => (focus ? `tmct(${shortLabel(focus.label)})> ` : PROMPT);
|
|
@@ -570,10 +1218,27 @@ const promptFor = (focus) => (focus ? `tmct(${shortLabel(focus.label)})> ` : PRO
|
|
|
570
1218
|
* - opt-in telemetry and the end-of-session close (end lines, final upsert,
|
|
571
1219
|
* stream flush).
|
|
572
1220
|
*
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
* `
|
|
576
|
-
*
|
|
1221
|
+
* THE CALLER-OWNED HANDLE (PLAN_REPOSITORY_INTERFACE §"The in-process lifecycle").
|
|
1222
|
+
* The returned object IS the session handle — created here, disposed by the caller
|
|
1223
|
+
* (`close()`), with NO process-global state. All of a session's between-turn state
|
|
1224
|
+
* lives on the handle: the mutable `focus` and `lastAnswer` (closure-private, read
|
|
1225
|
+
* through getters) and the read-only `memoryDir`, `graph`, `config` and `lexicon`.
|
|
1226
|
+
* - CREATE: `const s = await createSession({ repoPath })` — resolves repo/config,
|
|
1227
|
+
* loads the graph + lexicon once, opens the log/sidecar streams, seeds first-run
|
|
1228
|
+
* memory. Cheap to hold; a session is one repo's worth of chat.
|
|
1229
|
+
* - DISPOSE: `await s.close()` — idempotent; flushes both artifacts and the final
|
|
1230
|
+
* graph upsert (which triggers the memory fold). A dropped handle leaks only its
|
|
1231
|
+
* two write streams, so callers SHOULD close; a second close is a no-op.
|
|
1232
|
+
* - RE-ENTRANCY / CONCURRENCY: two handles never clobber each other. Each owns its
|
|
1233
|
+
* own `focus`/`lastAnswer`/streams/`sessionId`; the only cross-handle sharing is
|
|
1234
|
+
* the IMMUTABLE lexicon (a cached read-only singleton) and the read-through
|
|
1235
|
+
* provider graph — neither is mutated by a turn, so concurrent handles over the
|
|
1236
|
+
* same or different repos run isolated. Proven by test/chat-session.test.mjs.
|
|
1237
|
+
*
|
|
1238
|
+
* Returns { repo, config, graph, lexicon, memoryDir, moduleCount, version, sessionId,
|
|
1239
|
+
* logFile, sidecarFile, bannerLines, empty, focus, lastAnswer, turns, promptFor(),
|
|
1240
|
+
* turn(line), close() }. `turn(line)` runs one dispatched turn through runTurn and the
|
|
1241
|
+
* full sink sequencing, returning { answer, end, prompt }; `close()` is idempotent.
|
|
577
1242
|
*/
|
|
578
1243
|
export async function createSession({
|
|
579
1244
|
repoPath,
|
|
@@ -601,6 +1266,14 @@ export async function createSession({
|
|
|
601
1266
|
const moduleCount = graph.individuals.filter((i) => (i.class || "") === "Module").length;
|
|
602
1267
|
const { version } = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
603
1268
|
|
|
1269
|
+
// Load this handle's lexicon once (the immutable cached core vocabulary the ACE
|
|
1270
|
+
// assert path parses against). Threaded into every turn so the grammar layer never
|
|
1271
|
+
// re-imports per turn; failure-tolerated — a broken lexicon degrades to the lazy
|
|
1272
|
+
// per-turn load inside assertTurn, never an error before the prompt.
|
|
1273
|
+
let lexicon = null;
|
|
1274
|
+
try { const { loadLexicon } = await import("./grammar/lexicon.mjs"); lexicon = loadLexicon(); }
|
|
1275
|
+
catch { lexicon = null; }
|
|
1276
|
+
|
|
604
1277
|
// Opt-in telemetry (default OFF → null → the sink's `tel?.record` is a no-op, and
|
|
605
1278
|
// nothing is written). The conversational session log + sidecar above stay the
|
|
606
1279
|
// authoritative chat record; this is the machine-readable query telemetry.
|
|
@@ -638,12 +1311,23 @@ export async function createSession({
|
|
|
638
1311
|
};
|
|
639
1312
|
|
|
640
1313
|
const empty = graph.individuals.length === 0;
|
|
1314
|
+
// W3: FIRST RUN in a graph-less repo seeds a capped ConceptNet slice into
|
|
1315
|
+
// .tmct/memory so vocabulary questions ("what is a cache?") have something
|
|
1316
|
+
// honest to stand on from turn one. Guarded three ways: only the empty
|
|
1317
|
+
// bootstrap (a fixture/provider graph never seeds), only once (the marker),
|
|
1318
|
+
// and never when TMCT_NO_SEED=1 opts out.
|
|
1319
|
+
let seeded = null;
|
|
1320
|
+
if (empty && String(env.TMCT_NO_SEED || "") !== "1") {
|
|
1321
|
+
seeded = await seedBootstrapMemory(repo);
|
|
1322
|
+
}
|
|
641
1323
|
const bannerLines = [
|
|
642
1324
|
empty
|
|
643
1325
|
// Empty-graph bootstrap: honest-miss messaging, never an error before the prompt.
|
|
644
1326
|
? `tmct chat — ${repo} — no graph loaded — starting empty; ` +
|
|
645
1327
|
`the conversation is remembered to ${DEFAULT_GRAPH_REL} — log ${logFile}`
|
|
646
1328
|
: `tmct chat — ${repo} — ${moduleCount} module(s) — log ${logFile}`,
|
|
1329
|
+
// the honest seed line appears ONLY on the run that actually seeded
|
|
1330
|
+
...(seeded ? [`seeded ${seeded.appended} starter facts from the ConceptNet slice — /memory to inspect`] : []),
|
|
647
1331
|
"pass --repo <path> to target a different repo",
|
|
648
1332
|
"ask a question, or /help for commands (/stats for an overview) — /exit to leave",
|
|
649
1333
|
];
|
|
@@ -654,9 +1338,12 @@ export async function createSession({
|
|
|
654
1338
|
let closed = false;
|
|
655
1339
|
|
|
656
1340
|
return {
|
|
657
|
-
repo, config, graph,
|
|
658
|
-
bannerLines, empty,
|
|
1341
|
+
repo, config, graph, lexicon, memoryDir: repo, moduleCount, version, sessionId,
|
|
1342
|
+
logFile, sidecarFile, bannerLines, empty,
|
|
1343
|
+
// Mutable between-turn state — read-only to the caller, so a shell can render the
|
|
1344
|
+
// prompt/expand-hint without reaching into runTurn's threading.
|
|
659
1345
|
get focus() { return focus; },
|
|
1346
|
+
get lastAnswer() { return last; },
|
|
660
1347
|
get turns() { return turns; },
|
|
661
1348
|
promptFor: () => promptFor(focus),
|
|
662
1349
|
|
|
@@ -664,7 +1351,7 @@ export async function createSession({
|
|
|
664
1351
|
* → telemetry → upsertGraph, in that exact order). Returns { answer, end, prompt }. */
|
|
665
1352
|
async turn(line) {
|
|
666
1353
|
const { answer, logLines, record, focus: nextFocus, last: nextLast, end } =
|
|
667
|
-
await runTurn(line, { config, source, graph, focus, last, memoryDir: repo, sessionId });
|
|
1354
|
+
await runTurn(line, { config, source, graph, focus, last, memoryDir: repo, sessionId, env, lexicon });
|
|
668
1355
|
focus = nextFocus;
|
|
669
1356
|
last = nextLast;
|
|
670
1357
|
await writeLog(logLines.join("\n") + "\n");
|