@polycode-projects/the-mechanical-code-talker 0.2.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 +416 -3
- package/bin/tmct.mjs +308 -12
- package/corpus/README.md +52 -0
- package/corpus/conceptnet/LICENSE-NOTICE +37 -0
- package/corpus/conceptnet/README.md +103 -0
- package/corpus/conceptnet/fetch-slice.mjs +136 -0
- package/corpus/conceptnet/filter-dump.mjs +89 -0
- package/corpus/conceptnet/slice.jsonl +14258 -0
- package/data/phrasebook/software-phrases.txt +231 -0
- package/data/templates/grammar-rules.toml +89 -0
- package/data/templates/responses.jsonl +68 -0
- package/package.json +40 -3
- package/src/ask-nlp.mjs +22 -10
- package/src/ask-vocab.mjs +35 -1
- package/src/ask.mjs +171 -494
- package/src/chat.mjs +709 -81
- package/src/corpus/conceptnet-map.toml +251 -0
- package/src/corpus/conceptnet.mjs +167 -0
- package/src/corpus/templates.mjs +188 -0
- package/src/finish.mjs +443 -0
- package/src/grammar/ace.mjs +341 -0
- package/src/grammar/assert.mjs +40 -0
- package/src/grammar/lexicon-core.json +287 -0
- package/src/grammar/lexicon.mjs +202 -0
- package/src/hash.mjs +32 -0
- package/src/index.mjs +21 -5
- package/src/init.mjs +264 -0
- package/src/interpret/fuzzy.mjs +89 -0
- package/src/interpret/merge.mjs +148 -0
- package/src/interpret/normalize.mjs +151 -0
- package/src/interpret/pipeline.mjs +112 -0
- package/src/interpret/strategies/grammar.mjs +137 -0
- package/src/interpret/strategies/keywords.mjs +241 -0
- package/src/interpret/strategies/noise-strip.mjs +114 -0
- package/src/memory/blocks.mjs +221 -0
- package/src/memory/core.mjs +533 -0
- 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 +137 -4
- package/src/source.mjs +44 -5
- package/src/syllogise.mjs +0 -0
- package/src/toml-config.mjs +14 -0
- package/src/tui/app.mjs +173 -0
- package/src/wink-model.mjs +74 -0
- package/bin/cli.mjs +0 -226
package/src/chat.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// chat.mjs — `tmct chat`: a full interactive client over the tmct code-graph.
|
|
2
2
|
// Any BARE line is a plain-English question dispatched through the mechanical
|
|
3
|
-
// tmct_ask engine (the EXACT path bin/
|
|
3
|
+
// tmct_ask engine (the EXACT path bin/tmct.mjs's `cli tmct_ask` fallback uses),
|
|
4
4
|
// so chat is the same zero-model engine with a readline shell around it, plus:
|
|
5
5
|
//
|
|
6
6
|
// - SLASH-COMMANDS to reach every richer tool dispatchTool (server.mjs) serves —
|
|
@@ -31,10 +31,15 @@
|
|
|
31
31
|
// focus }) so tests exercise it directly; every ask.mjs import is LAZY and
|
|
32
32
|
// failure-tolerated, so concurrent evolution of the engine can never crash a turn
|
|
33
33
|
// (worst case a turn records fewer ids / an honest miss hint, never wrong data).
|
|
34
|
+
//
|
|
35
|
+
// createSession(…) is the SESSION SINK every shell shares: it owns the artifact
|
|
36
|
+
// files, the per-turn writeLog → writeSidecar → upsertGraph sequencing (order is
|
|
37
|
+
// load-bearing — see its docblock), telemetry, and the close. runChat is the
|
|
38
|
+
// readline shell over it; src/tui/app.mjs is the Ink shell over the same sink.
|
|
34
39
|
|
|
35
|
-
import { join } from "node:path";
|
|
40
|
+
import { join, dirname } from "node:path";
|
|
36
41
|
import { createWriteStream } from "node:fs";
|
|
37
|
-
import { mkdir, readFile } from "node:fs/promises";
|
|
42
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
38
43
|
import { createInterface } from "node:readline/promises";
|
|
39
44
|
import { spawnSync } from "node:child_process";
|
|
40
45
|
import { dispatchTool } from "./server.mjs";
|
|
@@ -44,6 +49,8 @@ import { SESSIONS_DIR_REL, appendSessionToGraph } from "./sessions.mjs";
|
|
|
44
49
|
import { uuidv7 } from "./uuid.mjs";
|
|
45
50
|
import { createTelemetry } from "./telemetry.mjs";
|
|
46
51
|
import * as defaultSource from "./source.mjs";
|
|
52
|
+
import { loadTemplates, render as renderTemplate } from "./corpus/templates.mjs";
|
|
53
|
+
import { finish } from "./finish.mjs";
|
|
47
54
|
|
|
48
55
|
// uuidv7 lives in ./uuid.mjs (shared with telemetry + the bench stamp); re-exported
|
|
49
56
|
// here because callers/tests still import it from chat.mjs.
|
|
@@ -206,15 +213,43 @@ export function isConversational(query) {
|
|
|
206
213
|
return q.split(/\s+/).filter(Boolean).length <= 3 && !codeish;
|
|
207
214
|
}
|
|
208
215
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
+
}
|
|
218
253
|
|
|
219
254
|
// ---- conversational (ELIZA/Zork-manners) templated layer ----
|
|
220
255
|
// A small CLOSED set of human expressions handled with a TEMPLATED response BEFORE
|
|
@@ -243,16 +278,8 @@ const WHY = new Set([
|
|
|
243
278
|
"elaborate", "tell me more", "more detail", "expand",
|
|
244
279
|
]);
|
|
245
280
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const GREETING_LINES = {
|
|
249
|
-
"hello there": 'Hello there. (A hollow voice says, "fool.") Ask me about this codebase, or /help.',
|
|
250
|
-
"good morning": "Good morning. Ask me about this codebase, or /help.",
|
|
251
|
-
"good afternoon": "Good afternoon. Ask me about this codebase, or /help.",
|
|
252
|
-
"good evening": "Good evening. Ask me about this codebase, or /help.",
|
|
253
|
-
};
|
|
254
|
-
const ACK = "Any time. Ask another, or /help for what I can do.";
|
|
255
|
-
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.)
|
|
256
283
|
|
|
257
284
|
/** Re-render the last answer in verbose form: the previous query + its full answer
|
|
258
285
|
* plus the ask envelope's traversal receipt and the matched entities (the detail a
|
|
@@ -284,22 +311,31 @@ export function renderVerbose(last) {
|
|
|
284
311
|
function conversationalTurn(line, ctx) {
|
|
285
312
|
const raw = String(line);
|
|
286
313
|
const q = raw.toLowerCase().replace(/[.!?]+$/, "").replace(/\s+/g, " ").trim();
|
|
287
|
-
const
|
|
314
|
+
const t = (id) => tRender(ctx.templates, id) ?? TEMPLATES_UNAVAILABLE;
|
|
315
|
+
const mk = (answer, { end = false, miss = false, via = "template" } = {}) => {
|
|
288
316
|
const ts = new Date().toISOString();
|
|
289
317
|
return {
|
|
290
318
|
answer,
|
|
291
319
|
logLines: [ts, `> ${raw}`, answer, ""],
|
|
292
|
-
record: { type: "turn", ts, query: raw, conversational: true, resolvedIds: [], answeredIds: [], miss },
|
|
320
|
+
record: { type: "turn", ts, query: raw, conversational: true, via, resolvedIds: [], answeredIds: [], miss },
|
|
293
321
|
focus: ctx.focus,
|
|
294
322
|
last: ctx.last, // a conversational turn never overwrites the last real answer
|
|
295
323
|
...(end ? { end: true } : {}),
|
|
296
324
|
};
|
|
297
325
|
};
|
|
298
|
-
if (BYE.has(q)) return mk(
|
|
299
|
-
if (WHY.has(q)) {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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));
|
|
303
339
|
return null;
|
|
304
340
|
}
|
|
305
341
|
|
|
@@ -315,7 +351,7 @@ export function gitToplevel(cwd = process.cwd()) {
|
|
|
315
351
|
return null;
|
|
316
352
|
}
|
|
317
353
|
|
|
318
|
-
/** Mirror bin/
|
|
354
|
+
/** Mirror bin/tmct.mjs's configFor: an explicit repo pins the artifact path; no
|
|
319
355
|
* repo falls back to the cwd/env-derived default. */
|
|
320
356
|
function configFor(repoPath) {
|
|
321
357
|
return repoPath ? { graphFile: join(repoPath, DEFAULT_GRAPH_REL) } : loadConfig();
|
|
@@ -343,6 +379,7 @@ export async function helpText() {
|
|
|
343
379
|
["<question>", "ask the graph in plain English (the default for any non-slash line)"],
|
|
344
380
|
...Object.entries(COMMANDS).map(([name, s]) => [`/${name}${s.arg ? (s.optional ? ` [${s.arg}]` : ` <${s.arg}>`) : ""}`, s.help]),
|
|
345
381
|
["/stats", "a one-screen overview: entity counts, relationship counts, packages"],
|
|
382
|
+
["/memory [verbose]", "what tmct remembers: facts, utterances, sessions, folded blocks"],
|
|
346
383
|
["/focus <symbol>", "set the current focus (reused by 'it'/'this' and no-arg entity commands)"],
|
|
347
384
|
["/help", "this list"],
|
|
348
385
|
["/exit", "leave the session (also Ctrl+C / Ctrl+D)"],
|
|
@@ -363,14 +400,360 @@ export async function helpText() {
|
|
|
363
400
|
].join("\n");
|
|
364
401
|
}
|
|
365
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
|
+
|
|
366
741
|
/** A bare question → tmct_ask. When a focus is set AND the graph is in hand we
|
|
367
742
|
* call ask() directly to thread the focus as contextId (so a pronoun like "it"
|
|
368
743
|
* resolves to the focus) — building the SAME delimited string dispatchTool emits;
|
|
369
744
|
* otherwise the unchanged dispatchTool path (which also yields the no-graph error).
|
|
370
745
|
* A hit updates the focus to the resolved object. Grammar miss / ToolError → a
|
|
371
746
|
* normal answer, never a crash. */
|
|
372
|
-
async function runAsk(query, { config, source, graph, focus }) {
|
|
747
|
+
async function runAsk(query, { config, source, graph, focus, templates, memoryDir, env }) {
|
|
373
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
|
+
}
|
|
374
757
|
let answer;
|
|
375
758
|
let envelope = null;
|
|
376
759
|
try {
|
|
@@ -399,11 +782,52 @@ async function runAsk(query, { config, source, graph, focus }) {
|
|
|
399
782
|
}
|
|
400
783
|
const answeredIds = (envelope?.matches || []).map((m) => m?.id).filter(Boolean);
|
|
401
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;
|
|
402
789
|
// On a MISS: a conversational miss (a greeting, "what can you do", a very short
|
|
403
790
|
// non-code line) gets the friendly orientation instead of the raw grammar hint. A
|
|
404
791
|
// near-miss STRUCTURAL question keeps the precise hint the engine already produced.
|
|
405
|
-
if (miss && isConversational(query))
|
|
406
|
-
|
|
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 };
|
|
407
831
|
const logLines = [ts, `> ${query}`, answer, ""];
|
|
408
832
|
// `detail` feeds why/say-more's verbose re-render: the traversal receipt + the
|
|
409
833
|
// matched entities the terse render trims (see renderVerbose).
|
|
@@ -413,12 +837,12 @@ async function runAsk(query, { config, source, graph, focus }) {
|
|
|
413
837
|
|
|
414
838
|
/** A non-ask, non-dispatch chat turn (count answer, /stats) — the same
|
|
415
839
|
* { answer, logLines, record, focus } shape, recorded like any other turn. */
|
|
416
|
-
function plainTurn(query, answer, { command, miss = false, focus = null } = {}) {
|
|
840
|
+
function plainTurn(query, answer, { command, via = "composed", miss = false, focus = null } = {}) {
|
|
417
841
|
const ts = new Date().toISOString();
|
|
418
842
|
return {
|
|
419
843
|
answer,
|
|
420
844
|
logLines: [ts, `> ${query}`, answer, ""],
|
|
421
|
-
record: { type: "turn", ts, query, ...(command ? { command } : {}), resolvedIds: [], answeredIds: [], miss },
|
|
845
|
+
record: { type: "turn", ts, query, ...(command ? { command } : {}), via, resolvedIds: [], answeredIds: [], miss },
|
|
422
846
|
focus,
|
|
423
847
|
};
|
|
424
848
|
}
|
|
@@ -427,7 +851,7 @@ function plainTurn(query, answer, { command, miss = false, focus = null } = {})
|
|
|
427
851
|
* the same { answer, logLines, record, focus } shape as runAsk; the record carries
|
|
428
852
|
* the command name and the resolved entity id (for entity commands) so a
|
|
429
853
|
* slash-command turn becomes asksAbout graph data wherever it resolves an entity. */
|
|
430
|
-
async function runCommand(line, { config, source, graph, focus }) {
|
|
854
|
+
async function runCommand(line, { config, source, graph, focus, memoryDir }) {
|
|
431
855
|
const ts = new Date().toISOString();
|
|
432
856
|
const sp = line.indexOf(" ");
|
|
433
857
|
const name = (sp === -1 ? line.slice(1) : line.slice(1, sp)).toLowerCase();
|
|
@@ -435,13 +859,25 @@ async function runCommand(line, { config, source, graph, focus }) {
|
|
|
435
859
|
const mk = (answer, { resolvedIds = [], miss = false, newFocus = focus } = {}) => ({
|
|
436
860
|
answer,
|
|
437
861
|
logLines: [ts, `> ${line}`, answer, ""],
|
|
438
|
-
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 },
|
|
439
863
|
focus: newFocus,
|
|
440
864
|
});
|
|
441
865
|
|
|
442
866
|
if (name === "help") return mk(await helpText());
|
|
443
867
|
if (name === "stats") return graph ? mk(renderStats(graph)) : mk("no graph loaded — /stats needs an index.", { miss: true });
|
|
444
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
|
+
|
|
445
881
|
if (name === "focus") {
|
|
446
882
|
if (!argText) return mk(focus ? `focus is ${focus.label}` : "no focus set — /focus <symbol> to set one.");
|
|
447
883
|
const ent = await resolveEntity(graph, isPronoun(argText) ? focus?.label : argText);
|
|
@@ -475,6 +911,39 @@ async function runCommand(line, { config, source, graph, focus }) {
|
|
|
475
911
|
return mk(answer);
|
|
476
912
|
}
|
|
477
913
|
|
|
914
|
+
/** A declarative ACE-grammar sentence → assert into memory + confirm; null on
|
|
915
|
+
* any grammar miss / residue / import failure so the query engine keeps first
|
|
916
|
+
* refusal on everything else. Lazy imports + catch-all: the grammar layer can
|
|
917
|
+
* never crash a turn (chat.mjs ethos). Writes ONLY under memoryDir/.tmct/memory. */
|
|
918
|
+
async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null }) {
|
|
919
|
+
try {
|
|
920
|
+
const { parseAce } = await import("./grammar/ace.mjs");
|
|
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);
|
|
927
|
+
if (!parse || !parse.triples?.length || parse.residue?.length) return null;
|
|
928
|
+
const { assertSentence } = await import("./grammar/assert.mjs");
|
|
929
|
+
const { normFactTerm } = await import("./memory/core.mjs");
|
|
930
|
+
const ts = new Date().toISOString();
|
|
931
|
+
const res = await assertSentence(memoryDir, line, {
|
|
932
|
+
lexicon: lex,
|
|
933
|
+
provenance: { source: "chat", sessionId, ts },
|
|
934
|
+
});
|
|
935
|
+
if (!res || !res.ids?.length) return null;
|
|
936
|
+
const shown = res.triples
|
|
937
|
+
.map((t) => `${normFactTerm(t.subject)} ${t.predicate} ${normFactTerm(t.object)}`)
|
|
938
|
+
.join("; ");
|
|
939
|
+
const n = res.ids.length;
|
|
940
|
+
const answer = `noted — remembered ${n} fact${n === 1 ? "" : "s"}: ${shown}`;
|
|
941
|
+
return plainTurn(line, answer, { command: "assert", via: "assert", focus });
|
|
942
|
+
} catch {
|
|
943
|
+
return null; // grammar unavailable / write failed — fall through to the engine
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
478
947
|
/**
|
|
479
948
|
* One chat turn: input → { answer, logLines, record, focus }. Pure of any
|
|
480
949
|
* TTY/stream concerns so tests exercise it directly. A leading `/` routes to a
|
|
@@ -489,12 +958,22 @@ async function runCommand(line, { config, source, graph, focus }) {
|
|
|
489
958
|
* subject), `answeredIds` the entity ids an ask answer cited; a slash-command turn
|
|
490
959
|
* also carries its `command` name. Both drive the mgx:asksAbout graph append.
|
|
491
960
|
*/
|
|
492
|
-
export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null } = {}) {
|
|
961
|
+
export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null } = {}) {
|
|
493
962
|
const line = String(input ?? "").trim();
|
|
494
|
-
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 };
|
|
495
965
|
// A DISPATCHED turn (count / slash-command / ask) becomes the new "last answer"
|
|
496
966
|
// that why/say-more re-renders; a conversational turn does not (it preserves it).
|
|
497
|
-
|
|
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
|
+
};
|
|
498
977
|
|
|
499
978
|
// Conversational layer first (greetings, thanks, help, bye, why/say-more) — these
|
|
500
979
|
// resolve no entity and carry their own preserved `last`.
|
|
@@ -502,28 +981,106 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
502
981
|
if (convo) return convo;
|
|
503
982
|
|
|
504
983
|
if (line.startsWith("/")) return withLast(await runCommand(line, ctx));
|
|
984
|
+
// Declarative ACE sentences ("every module is a artifact") ASSERT into tmct's
|
|
985
|
+
// own memory and confirm — they are statements to remember, not graph queries.
|
|
986
|
+
// Gated on memoryDir: only a session shell provides a write target, so a bare
|
|
987
|
+
// runTurn (tests, library callers) stays pure and falls through to the engine.
|
|
988
|
+
if (memoryDir) {
|
|
989
|
+
const asserted = await assertTurn(line, ctx);
|
|
990
|
+
if (asserted) return withLast(asserted);
|
|
991
|
+
}
|
|
505
992
|
// Aggregate/count questions are answered mechanically off the loaded graph header,
|
|
506
993
|
// BEFORE falling through to the ask engine (focus unchanged — a count names no entity).
|
|
507
994
|
const count = answerCount(graph, line);
|
|
508
|
-
if (count != null) return withLast(plainTurn(line, count, { focus }));
|
|
995
|
+
if (count != null) return withLast(plainTurn(line, count, { via: "count", focus }));
|
|
509
996
|
return withLast(await runAsk(line, ctx));
|
|
510
997
|
}
|
|
511
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
|
+
|
|
512
1040
|
/** Trim a focus label for the prompt so a long module path can't run the line off. */
|
|
513
1041
|
const shortLabel = (l) => { const s = String(l); return s.length > 40 ? "…" + s.slice(-39) : s; };
|
|
514
1042
|
const promptFor = (focus) => (focus ? `tmct(${shortLabel(focus.label)})> ` : PROMPT);
|
|
515
1043
|
|
|
516
1044
|
/**
|
|
517
|
-
* The
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
*
|
|
521
|
-
*
|
|
1045
|
+
* The SESSION SINK — everything a chat shell (readline below, the Ink TUI, any
|
|
1046
|
+
* future surface) must share so the on-disk session contract stays identical no
|
|
1047
|
+
* matter what draws the screen:
|
|
1048
|
+
*
|
|
1049
|
+
* - repo/config resolution (git root default, --repo override) + the one-time
|
|
1050
|
+
* graph load and banner strings;
|
|
1051
|
+
* - the transcript log + structured sidecar file creation and per-turn
|
|
1052
|
+
* writeLog → writeSidecar → upsertGraph sequencing. THE ORDER IS LOAD-BEARING:
|
|
1053
|
+
* the memory side-write (sessions.mjs) recovers each turn's ANSWER text by
|
|
1054
|
+
* re-reading the transcript keyed by turnKey(record.ts, query), so the log
|
|
1055
|
+
* line must be flushed before the graph upsert runs, and logLines[0] must be
|
|
1056
|
+
* the record's ts (runTurn guarantees that);
|
|
1057
|
+
* - opt-in telemetry and the end-of-session close (end lines, final upsert,
|
|
1058
|
+
* stream flush).
|
|
1059
|
+
*
|
|
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.
|
|
522
1081
|
*/
|
|
523
|
-
export async function
|
|
1082
|
+
export async function createSession({
|
|
524
1083
|
repoPath,
|
|
525
|
-
input = process.stdin,
|
|
526
|
-
output = process.stdout,
|
|
527
1084
|
source = defaultSource,
|
|
528
1085
|
env = process.env,
|
|
529
1086
|
cwd = process.cwd(),
|
|
@@ -548,7 +1105,15 @@ export async function runChat({
|
|
|
548
1105
|
const moduleCount = graph.individuals.filter((i) => (i.class || "") === "Module").length;
|
|
549
1106
|
const { version } = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
550
1107
|
|
|
551
|
-
//
|
|
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
|
+
|
|
1116
|
+
// Opt-in telemetry (default OFF → null → the sink's `tel?.record` is a no-op, and
|
|
552
1117
|
// nothing is written). The conversational session log + sidecar above stay the
|
|
553
1118
|
// authoritative chat record; this is the machine-readable query telemetry.
|
|
554
1119
|
const tel = createTelemetry({ env, config, surface: "chat" });
|
|
@@ -562,7 +1127,7 @@ export async function runChat({
|
|
|
562
1127
|
const sidecarFile = join(sessionsDir, `session-${sessionId}.jsonl`);
|
|
563
1128
|
const stream = createWriteStream(logFile, { flags: "a" });
|
|
564
1129
|
const sidecar = createWriteStream(sidecarFile, { flags: "a" });
|
|
565
|
-
// Awaited writes: each chunk is handed to the OS before the
|
|
1130
|
+
// Awaited writes: each chunk is handed to the OS before the turn completes, so a
|
|
566
1131
|
// killed session keeps everything up to the last completed turn — in both files.
|
|
567
1132
|
const flush = (s, text) =>
|
|
568
1133
|
new Promise((resolve, reject) => s.write(text, (e) => (e ? reject(e) : resolve())));
|
|
@@ -584,35 +1149,50 @@ export async function runChat({
|
|
|
584
1149
|
catch { /* best-effort — see above */ }
|
|
585
1150
|
};
|
|
586
1151
|
|
|
587
|
-
const
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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);
|
|
594
1161
|
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
1162
|
+
const bannerLines = [
|
|
1163
|
+
empty
|
|
1164
|
+
// Empty-graph bootstrap: honest-miss messaging, never an error before the prompt.
|
|
1165
|
+
? `tmct chat — ${repo} — no graph loaded — starting empty; ` +
|
|
1166
|
+
`the conversation is remembered to ${DEFAULT_GRAPH_REL} — log ${logFile}`
|
|
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`] : []),
|
|
1170
|
+
"pass --repo <path> to target a different repo",
|
|
1171
|
+
"ask a question, or /help for commands (/stats for an overview) — /exit to leave",
|
|
1172
|
+
];
|
|
603
1173
|
|
|
604
1174
|
let turns = 0;
|
|
605
1175
|
let focus = null; // the current focus entity ({id,label}) — threaded turn to turn
|
|
606
1176
|
let last = null; // the last dispatched answer ({query,answer,detail}) — why/say-more re-renders it
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
1177
|
+
let closed = false;
|
|
1178
|
+
|
|
1179
|
+
return {
|
|
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.
|
|
1184
|
+
get focus() { return focus; },
|
|
1185
|
+
get lastAnswer() { return last; },
|
|
1186
|
+
get turns() { return turns; },
|
|
1187
|
+
promptFor: () => promptFor(focus),
|
|
1188
|
+
|
|
1189
|
+
/** One dispatched turn through the FULL sink sequencing (writeLog → writeSidecar
|
|
1190
|
+
* → telemetry → upsertGraph, in that exact order). Returns { answer, end, prompt }. */
|
|
1191
|
+
async turn(line) {
|
|
1192
|
+
const { answer, logLines, record, focus: nextFocus, last: nextLast, end } =
|
|
1193
|
+
await runTurn(line, { config, source, graph, focus, last, memoryDir: repo, sessionId, env, lexicon });
|
|
613
1194
|
focus = nextFocus;
|
|
614
1195
|
last = nextLast;
|
|
615
|
-
output.write(answer + "\n");
|
|
616
1196
|
await writeLog(logLines.join("\n") + "\n");
|
|
617
1197
|
await writeSidecar(record);
|
|
618
1198
|
turnRecords.push(record);
|
|
@@ -625,18 +1205,66 @@ export async function runChat({
|
|
|
625
1205
|
});
|
|
626
1206
|
await upsertGraph(record.ts);
|
|
627
1207
|
turns += 1;
|
|
628
|
-
|
|
1208
|
+
return { answer, end: Boolean(end), prompt: promptFor(focus) };
|
|
1209
|
+
},
|
|
1210
|
+
|
|
1211
|
+
/** End-of-session close: end lines in both artifacts, the final graph upsert
|
|
1212
|
+
* (which also triggers the memory fold), stream flush. Idempotent. */
|
|
1213
|
+
async close() {
|
|
1214
|
+
if (closed) return;
|
|
1215
|
+
closed = true;
|
|
1216
|
+
const endIso = new Date().toISOString();
|
|
1217
|
+
await writeLog(`${endIso}\n> /exit\nsession end ${endIso}\n`);
|
|
1218
|
+
await writeSidecar({ type: "end", ts: endIso });
|
|
1219
|
+
await upsertGraph(endIso);
|
|
1220
|
+
await new Promise((resolve) => stream.end(resolve));
|
|
1221
|
+
await new Promise((resolve) => sidecar.end(resolve));
|
|
1222
|
+
},
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* The interactive readline shell over createSession — the `--plain` surface and
|
|
1228
|
+
* the scripted-test surface. Streams are injectable so tests run sessions
|
|
1229
|
+
* without a TTY. A repo with NO graph artifact is not an error: the session
|
|
1230
|
+
* starts from the empty bootstrap graph (the banner says so honestly) and the
|
|
1231
|
+
* first turn's fold-in creates .tmct/graph.json from the conversation itself.
|
|
1232
|
+
* Returns { logFile, sidecarFile, turns } once the session ends.
|
|
1233
|
+
*/
|
|
1234
|
+
export async function runChat({
|
|
1235
|
+
repoPath,
|
|
1236
|
+
input = process.stdin,
|
|
1237
|
+
output = process.stdout,
|
|
1238
|
+
source = defaultSource,
|
|
1239
|
+
env = process.env,
|
|
1240
|
+
cwd = process.cwd(),
|
|
1241
|
+
gitRoot = gitToplevel,
|
|
1242
|
+
} = {}) {
|
|
1243
|
+
const session = await createSession({ repoPath, source, env, cwd, gitRoot });
|
|
1244
|
+
|
|
1245
|
+
const dim = (s) => (env.NO_COLOR || !output.isTTY ? s : `\x1b[2m${s}\x1b[0m`);
|
|
1246
|
+
for (const line of session.bannerLines) output.write(dim(line) + "\n");
|
|
1247
|
+
|
|
1248
|
+
const rl = createInterface({ input, output, prompt: PROMPT });
|
|
1249
|
+
rl.on("SIGINT", () => rl.close()); // Ctrl+C behaves like /exit (clean close, log flushed)
|
|
1250
|
+
let closed = false;
|
|
1251
|
+
rl.on("close", () => { closed = true; });
|
|
1252
|
+
const prompt = () => { if (!closed) rl.prompt(); }; // input may end while a turn is in flight
|
|
1253
|
+
|
|
1254
|
+
prompt();
|
|
1255
|
+
for await (const raw of rl) { // Ctrl+D / closed stdin ends the iteration cleanly
|
|
1256
|
+
const line = raw.trim();
|
|
1257
|
+
if (line === "/exit") break;
|
|
1258
|
+
if (line) {
|
|
1259
|
+
const { answer, end, prompt: nextPrompt } = await session.turn(line);
|
|
1260
|
+
output.write(answer + "\n");
|
|
1261
|
+
rl.setPrompt(nextPrompt);
|
|
629
1262
|
if (end) break; // a conversational "bye"/"goodbye" — clean end, same as /exit
|
|
630
1263
|
}
|
|
631
1264
|
prompt();
|
|
632
1265
|
}
|
|
633
1266
|
rl.close();
|
|
634
1267
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
await writeSidecar({ type: "end", ts: endIso });
|
|
638
|
-
await upsertGraph(endIso);
|
|
639
|
-
await new Promise((resolve) => stream.end(resolve));
|
|
640
|
-
await new Promise((resolve) => sidecar.end(resolve));
|
|
641
|
-
return { logFile, sidecarFile, turns };
|
|
1268
|
+
await session.close();
|
|
1269
|
+
return { logFile: session.logFile, sidecarFile: session.sidecarFile, turns: session.turns };
|
|
642
1270
|
}
|