@polycode-projects/the-mechanical-code-talker 3.0.2 → 3.0.3
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/bin/tmct.mjs +24 -1
- package/package.json +1 -1
- package/src/domain/hash.mjs +6 -1
- package/src/domain/router/goal-reasoner.mjs +6 -2
- package/src/services/chat.mjs +151 -66
- package/src/services/ledger-viz.mjs +56 -7
- package/src/services/research-viz.mjs +106 -6
- package/src/surfaces/web/digest-client.mjs +73 -0
- package/src/surfaces/web/ledger-browser-entry.mjs +2 -1
- package/src/surfaces/web/memory-ask-browser-entry.mjs +6 -1
- package/src/surfaces/web/memory-ask-browser.bundle.js +141 -141
- package/src/surfaces/web/research-browser-entry.mjs +20 -1
package/bin/tmct.mjs
CHANGED
|
@@ -1316,7 +1316,30 @@ async function main() {
|
|
|
1316
1316
|
// an honest "chat unavailable" note instead of the dock (e.g. a fresh
|
|
1317
1317
|
// checkout before the bundle's first build).
|
|
1318
1318
|
const memoryAskBundle = await readMemoryAskBundle();
|
|
1319
|
-
|
|
1319
|
+
// The digest structure table, embedded so the page reads a term back as a
|
|
1320
|
+
// narrative client-side on refocus (over the store this page carries) — the
|
|
1321
|
+
// same mechanism the demo ledger uses, sharing the memory-ask engine's own
|
|
1322
|
+
// browser digest helper. The initial focus also gets a node-side digest as
|
|
1323
|
+
// the fallback for a page whose engine bundle never loads.
|
|
1324
|
+
const { readDigestStructures, digestTermFromRows } = await import("../src/adapters/corpus/digest-bank.mjs");
|
|
1325
|
+
const { readFactRows: readVizFactRows } = await import("../src/adapters/memory/core.mjs");
|
|
1326
|
+
const digestStructures = readDigestStructures();
|
|
1327
|
+
let focusDigest = null;
|
|
1328
|
+
if (data.focus && data.payload) {
|
|
1329
|
+
const allFactRows = readVizFactRows(data.payload);
|
|
1330
|
+
const focusRows = allFactRows.filter((r) => r.subject === data.focus);
|
|
1331
|
+
const article = focusRows.length ? digestTermFromRows(data.focus, focusRows, allFactRows, { budget: 8 }) : null;
|
|
1332
|
+
if (article && article.paragraphs.length) {
|
|
1333
|
+
focusDigest = {
|
|
1334
|
+
term: data.focus,
|
|
1335
|
+
paragraphs: article.paragraphs,
|
|
1336
|
+
sources: [...new Set(article.sources.map((s) => s.provenance).filter(Boolean))],
|
|
1337
|
+
facts: (article.detail.facts || []).map((f) => ({ subject: f.subject, predicate: f.predicate, object: f.object })),
|
|
1338
|
+
factCount: article.detail.factCount,
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
await writeFile(outPath, renderLedgerHtml({ ...data, memoryAskBundle, digestStructures, focusDigest }), "utf8");
|
|
1320
1343
|
process.stdout.write(
|
|
1321
1344
|
`tmct viz — wrote ${data.meta.shown} fact row(s) around ${data.focus ? `'${data.focus}'` : "no focus"} to ${outPath}\n`,
|
|
1322
1345
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/the-mechanical-code-talker",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; indexes a repo on request (tmct index) or reads any producer's graph.",
|
package/src/domain/hash.mjs
CHANGED
|
@@ -129,7 +129,10 @@ export function normFactTerm(t) {
|
|
|
129
129
|
* Closed on purpose — a table, not an inflection rule. Entries are added when
|
|
130
130
|
* a curated predicate's surface phrase is a plain verb ("causes", "wants",
|
|
131
131
|
* "requires", "involves") whose lemma the teach lane would otherwise mint
|
|
132
|
-
* unrelated
|
|
132
|
+
* unrelated — or when the participle+preposition teach frame mints a
|
|
133
|
+
* hyphenated spelling of a curated relation with the SAME arguments in the
|
|
134
|
+
* SAME direction ("X is used for Y" → mgx:used-for, the curated mgx:usedFor).
|
|
135
|
+
* Relations that invert their arguments (mgx:ownedBy,
|
|
133
136
|
* mgx:createdBy) are absent: "X owns Y" and "X is owned by Y" are not the
|
|
134
137
|
* same fact, so folding them would store a lie. */
|
|
135
138
|
const CANONICAL_FACT_PREDICATE = new Map([
|
|
@@ -138,6 +141,8 @@ const CANONICAL_FACT_PREDICATE = new Map([
|
|
|
138
141
|
["mgx:want", "mgx:desires"],
|
|
139
142
|
["mgx:require", "mgx:hasPrerequisite"],
|
|
140
143
|
["mgx:involve", "mgx:hasSubevent"],
|
|
144
|
+
["mgx:used-for", "mgx:usedFor"],
|
|
145
|
+
["mgx:made-of", "mgx:madeOf"],
|
|
141
146
|
]);
|
|
142
147
|
|
|
143
148
|
/** Normalize a fact PREDICATE: whitespace-collapse + cap (normText's storage
|
|
@@ -192,13 +192,17 @@ export async function goalReason(request, tools, ctx, { driver = "goal-0.8.1", r
|
|
|
192
192
|
|
|
193
193
|
// GLOBAL mode has no bound focus to prove relevance, so also screen the request against
|
|
194
194
|
// ask.mjs's NL grammar: it must parse to a shape naming the candidate rule's focusClass.
|
|
195
|
+
// The refusal reads as plain language — it is user-facing chat text (/plan renders the
|
|
196
|
+
// why verbatim), so it names what the planner CAN plan about, never the grammar machinery
|
|
197
|
+
// that decided it.
|
|
195
198
|
let domainRelevant = applicable;
|
|
196
199
|
if (mode === "global" && applicable.length) {
|
|
197
200
|
const requestClass = parsedEntityType(parseQuery(request));
|
|
198
201
|
domainRelevant = applicable.filter((r) => requestClass === r.focusClass);
|
|
199
202
|
if (!domainRelevant.length) {
|
|
200
|
-
const classes = [...new Set(applicable.map((r) => r.focusClass))]
|
|
201
|
-
|
|
203
|
+
const classes = [...new Set(applicable.map((r) => r.focusClass))];
|
|
204
|
+
const plain = classes.map((c) => `${String(c).toLowerCase()}s`).join(" or ");
|
|
205
|
+
return refuse(`open-world: I can only plan toward goals about things this graph knows (${plain} here), and this request doesn't read as being about ${plain} — I won't invent a goal for it — escalate`, driver);
|
|
202
206
|
}
|
|
203
207
|
}
|
|
204
208
|
|
package/src/services/chat.mjs
CHANGED
|
@@ -5946,6 +5946,10 @@ async function presuppositionNudge(query, { graph, memoryDir }) {
|
|
|
5946
5946
|
* WALL_MISS_RE: the suppression keys on the PREVIOUS answer matching it, so this
|
|
5947
5947
|
* text self-limits — a third consecutive miss re-offers the tailored hint. */
|
|
5948
5948
|
const WALL_REPEAT_ONELINER = "still couldn't parse that — /help lists every query shape.";
|
|
5949
|
+
/** The graph-less bootstrap wall's opening line — shared with the teach-offer
|
|
5950
|
+
* collapse below, which treats this wall (like the shortened generic wall)
|
|
5951
|
+
* as text a term-specific offer REPLACES rather than stacks under. */
|
|
5952
|
+
const NO_GRAPH_BOOTSTRAP_WALL_LEAD = "I can't answer that as a code question — no code graph is loaded in this session.";
|
|
5949
5953
|
|
|
5950
5954
|
/** The orientation-repeat one-liner. The conversational
|
|
5951
5955
|
* orientation branch sits OUTSIDE the composed-only wall-shortening gate (it
|
|
@@ -7330,8 +7334,8 @@ function withDeducedGoal(res, envelope, query) {
|
|
|
7330
7334
|
}
|
|
7331
7335
|
|
|
7332
7336
|
async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle = {}, cache = null, focusLabel = null) {
|
|
7333
|
-
let normFactTerm;
|
|
7334
|
-
try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
|
|
7337
|
+
let normFactTerm; let normFactPredicate;
|
|
7338
|
+
try { ({ normFactTerm, normFactPredicate } = await import("../adapters/memory/core.mjs")); } catch { return null; }
|
|
7335
7339
|
const q = String(query).trim();
|
|
7336
7340
|
|
|
7337
7341
|
// (a-pre) "what is used for riding" / "what can be used for riding" / "what
|
|
@@ -7553,7 +7557,29 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
|
|
|
7553
7557
|
// matched against fact subjects, and — the actual bug — the result is
|
|
7554
7558
|
// FILTERED to just that one predicate (mgx:usedFor) instead of every
|
|
7555
7559
|
// relation about the subject undifferentiated.
|
|
7556
|
-
const
|
|
7560
|
+
const split = splitMetaPredicate(metaTerm);
|
|
7561
|
+
const { predicate } = split;
|
|
7562
|
+
// A leading article survives the T5/BARE_WHATIS capture ("what is the car
|
|
7563
|
+
// used for" → "the car") — stripped the same way normFactTerm strips it,
|
|
7564
|
+
// so the article never decides whether the subject matches.
|
|
7565
|
+
let subject = split.subject.replace(/^(?:the|an?)\s+/i, "").trim() || split.subject;
|
|
7566
|
+
// A predicate-shaped ask whose subject is the session anaphor ("what is
|
|
7567
|
+
// it used for") resolves against the standing focus, exactly as the
|
|
7568
|
+
// IS_ADJECTIVE/ISA yes/no readers resolve theirs. With no focus standing
|
|
7569
|
+
// the pronoun is named and declined (the cold-pronoun voice) — never a
|
|
7570
|
+
// fact lookup on the literal word "it", and never a teach-offer for it.
|
|
7571
|
+
let focusSubstituted = false;
|
|
7572
|
+
if (predicate && IS_ADJECTIVE_PRONOUN_RE.test(subject)) {
|
|
7573
|
+
if (!focusLabel) {
|
|
7574
|
+
const tail = String(FACT_PREDICATE_PHRASES[predicate] || "").replace(/^(?:is|are)\s+/, "");
|
|
7575
|
+
return {
|
|
7576
|
+
text: `not sure what "${subject.toLowerCase()}" refers to yet — name the subject directly, e.g. "what is a <name>${tail ? ` ${tail}` : ""}".`,
|
|
7577
|
+
replace: miss, miss: true, selfContainedMiss: true,
|
|
7578
|
+
};
|
|
7579
|
+
}
|
|
7580
|
+
subject = focusLabel;
|
|
7581
|
+
focusSubstituted = true;
|
|
7582
|
+
}
|
|
7557
7583
|
const variants = factTermVariants(normFactTerm, subject);
|
|
7558
7584
|
// factRows (trust+sourceIds-bearing), not the plain memoryFacts shape — the
|
|
7559
7585
|
// bias-weighted ranking below needs each hit's sourceIds to resolve which
|
|
@@ -7562,16 +7588,23 @@ async function factAnswerReaders(memoryDir, query, envelope, miss, biasByBundle
|
|
|
7562
7588
|
// back where it's hidden or that it's the objective (WORLD_INTERNAL_PREDICATES).
|
|
7563
7589
|
const subjectHits = (await factRows(memoryDir, cache))
|
|
7564
7590
|
.filter((f) => variants.has(f.subject) && !WORLD_INTERNAL_PREDICATES.has(f.predicate));
|
|
7565
|
-
|
|
7591
|
+
// Matched through normFactPredicate, so a fact stored under a minted
|
|
7592
|
+
// spelling of the same relation ("mgx:used-for", from the participle
|
|
7593
|
+
// teach frame, in a store written before the spellings converged) is
|
|
7594
|
+
// found by the curated spelling it means.
|
|
7595
|
+
let hits = predicate ? subjectHits.filter((f) => normFactPredicate(f.predicate) === predicate) : subjectHits;
|
|
7566
7596
|
if (!hits.length) {
|
|
7567
|
-
// The subject itself is known
|
|
7568
|
-
//
|
|
7569
|
-
//
|
|
7570
|
-
//
|
|
7571
|
-
|
|
7597
|
+
// The subject itself is known — as a fact subject, or as the standing
|
|
7598
|
+
// focus a pronoun just resolved to — but not under this specific
|
|
7599
|
+
// relation: an honest, specific "no" rather than falling through to
|
|
7600
|
+
// the generic "isn't a term in this graph's own vocabulary" wall
|
|
7601
|
+
// (which would be actively misleading here: the subject IS a known
|
|
7602
|
+
// term).
|
|
7603
|
+
if (predicate && (subjectHits.length || focusSubstituted)) {
|
|
7572
7604
|
return {
|
|
7573
7605
|
text: `I don't have any "${FACT_PREDICATE_PHRASES[predicate]}" facts about ${subject}.`,
|
|
7574
7606
|
replace: miss,
|
|
7607
|
+
...(subjectHits.length ? {} : { miss: true }),
|
|
7575
7608
|
};
|
|
7576
7609
|
}
|
|
7577
7610
|
// The term names nothing as a fact SUBJECT, but may exist only as the
|
|
@@ -8421,6 +8454,14 @@ const HAS_METHOD_OPEN_RE = /^what\s+methods\s+does\s+([\w'-]+)\s+have[?.!\s]*$/i
|
|
|
8421
8454
|
* cascade/orientation nudge that already handles it. */
|
|
8422
8455
|
const IS_ADJECTIVE_YESNO_RE = /^(?:is|are|was|were)\s+(.+?)\s+([A-Za-z][\w-]*)[?.!\s]*$/i;
|
|
8423
8456
|
const IS_ADJECTIVE_PRONOUN_RE = /^(?:it|this|that)$/i;
|
|
8457
|
+
/** A backtracked subject that is really a cross-turn temporal comparison —
|
|
8458
|
+
* a bindable form followed by a comparison word ("that before chat.mjs
|
|
8459
|
+
* was", from "was that before chat.mjs was touched"). The comparison lane
|
|
8460
|
+
* owns the closed-participle family; a cousin with a participle outside
|
|
8461
|
+
* that set still lands here, and offering to teach a fact about "that
|
|
8462
|
+
* before chat.mjs was" is a category error, so the property readers
|
|
8463
|
+
* decline it the way they decline a personal-pronoun subject. */
|
|
8464
|
+
const BINDABLE_COMPARISON_SUBJECT_RE = /^(?:it|this|that)(?:\s+one)?\s+(?:before|after)\b/i;
|
|
8424
8465
|
/** IS_ADJECTIVE_YESNO_RE's
|
|
8425
8466
|
* subject capture is unbounded/unrestricted (see its own docblock above), so
|
|
8426
8467
|
* a pronoun-subject IDENTITY question ("are you happy", "are you like
|
|
@@ -8637,6 +8678,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
|
|
|
8637
8678
|
// "you", so `subject` itself would already carry the pronoun verbatim).
|
|
8638
8679
|
if (subject && !/^there\b/i.test(subject) && !envelope?.parsed
|
|
8639
8680
|
&& !IS_ADJECTIVE_YESNO_PRONOUN_SUBJECT_RE.test(rawSubject)
|
|
8681
|
+
&& !BINDABLE_COMPARISON_SUBJECT_RE.test(rawSubject)
|
|
8640
8682
|
&& !PLACE_ADVERB_OBJECT_RE.test(emptyIsAdj[2].trim())) {
|
|
8641
8683
|
return unknownAdjectiveOffer(subject, emptyIsAdj[2].trim().toLowerCase());
|
|
8642
8684
|
}
|
|
@@ -9697,7 +9739,7 @@ async function factReadBackReaders(memoryDir, query, envelope, miss, graph = nul
|
|
|
9697
9739
|
// this whole reader decline HONESTLY — no fact lookup, no teach-offer —
|
|
9698
9740
|
// and fall through to whatever handles identity/small-talk questions
|
|
9699
9741
|
// instead, rather than special-casing a return here.
|
|
9700
|
-
const subject = IS_ADJECTIVE_YESNO_PRONOUN_SUBJECT_RE.test(rawSubject) ? null
|
|
9742
|
+
const subject = IS_ADJECTIVE_YESNO_PRONOUN_SUBJECT_RE.test(rawSubject) || BINDABLE_COMPARISON_SUBJECT_RE.test(rawSubject) ? null
|
|
9701
9743
|
: IS_ADJECTIVE_PRONOUN_RE.test(rawSubject) ? (focusLabel || null) : rawSubject;
|
|
9702
9744
|
const adjective = isAdj[2].trim().toLowerCase();
|
|
9703
9745
|
if (subject) {
|
|
@@ -11940,35 +11982,51 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
11940
11982
|
// the two ISO dates compare with both sides cited. Checked BEFORE the ask
|
|
11941
11983
|
// engine (the same precedence RENAME_HISTORY_RE takes, below) so the
|
|
11942
11984
|
// sentence never reaches the keyword-spot strategy's multi-token patient
|
|
11943
|
-
// guard
|
|
11944
|
-
//
|
|
11985
|
+
// guard. A form this shape that CANNOT compose still ends here, with a
|
|
11986
|
+
// specific miss naming what's missing (no referent for the form, an
|
|
11987
|
+
// undated referent, no graph, an undatable clause) — falling through used
|
|
11988
|
+
// to hand the sentence to the teach-offer cascade, which read "that before
|
|
11989
|
+
// X was" as a subject to learn facts about.
|
|
11945
11990
|
{
|
|
11946
|
-
const cmp =
|
|
11991
|
+
const cmp = String(query).trim().match(TEMPORAL_COMPARISON_RE);
|
|
11947
11992
|
if (cmp) {
|
|
11948
11993
|
const [, form, cmpOp, clauseSubject, participle] = cmp;
|
|
11949
|
-
const
|
|
11950
|
-
const
|
|
11951
|
-
|
|
11952
|
-
|
|
11953
|
-
|
|
11954
|
-
|
|
11955
|
-
|
|
11956
|
-
|
|
11957
|
-
|
|
11958
|
-
: "";
|
|
11959
|
-
if (clauseDay) {
|
|
11960
|
-
const holds = cmpOp.toLowerCase() === "before" ? refDay < clauseDay : refDay > clauseDay;
|
|
11961
|
-
const relation = refDay < clauseDay ? "came before" : refDay > clauseDay ? "came after" : "landed on the same day as";
|
|
11962
|
-
const verb = participle.toLowerCase();
|
|
11963
|
-
const text = `${holds ? "Yes" : "No"} — ${bound.referent.label} (${refDay}) ${relation} ${clauseSubject} was last ${verb} (${freshCommit.label}, ${clauseDay}).`;
|
|
11964
|
-
note(trace, "goal: compare a prior answer's dated referent against a freshly read event (cross-turn temporal composition)");
|
|
11965
|
-
note(trace, `lane: TEMPORAL_COMPARISON_RE — "${form}" bound ${bound.referent.label} (${refDay}) through the discourse record; the embedded clause re-ran as its own when-question`);
|
|
11966
|
-
const turn = plainTurn(query, text, { via: "composed", miss: false, focus });
|
|
11967
|
-
const cited = [graph.byId?.get?.(bound.referent.ids[0]), freshCommit].filter(Boolean);
|
|
11968
|
-
turn.detail = { traversal: `discourse ${bound.referent.ref} (${refDay}) vs last-${verb} of ${clauseSubject} (${clauseDay})`, matches: cited };
|
|
11969
|
-
return turn;
|
|
11970
|
-
}
|
|
11994
|
+
const verb = participle.toLowerCase();
|
|
11995
|
+
const refMiss = (text) => {
|
|
11996
|
+
note(trace, "goal: compare a prior answer's dated referent against a freshly read event (cross-turn temporal composition)");
|
|
11997
|
+
note(trace, `lane: TEMPORAL_COMPARISON_RE — "${form}" could not compose a comparison; a specific miss names why, never the teach-offer cascade`);
|
|
11998
|
+
return plainTurn(query, text, { via: "miss", miss: true, focus });
|
|
11999
|
+
};
|
|
12000
|
+
const bound = discourseHolder ? bindDiscourseForm(discourseHolder.record, form) : null;
|
|
12001
|
+
if (!bound?.referent) {
|
|
12002
|
+
return refMiss(`I don't have a referent for "${form}" yet — nothing answered earlier in this conversation binds it. Ask about the event first (e.g. "when was ${clauseSubject} last ${verb}"), then ask the comparison again.`);
|
|
11971
12003
|
}
|
|
12004
|
+
const refDay = String(bound.referent.attrs?.date || "").slice(0, 10);
|
|
12005
|
+
if (!refDay) {
|
|
12006
|
+
return refMiss(`"${form}" refers to ${bound.referent.label}, but I have no date on record for it — so I can't place it before or after ${clauseSubject} was ${verb}.`);
|
|
12007
|
+
}
|
|
12008
|
+
if (!graph) {
|
|
12009
|
+
return refMiss(`"${form}" refers to ${bound.referent.label} (${refDay}), but I need a code graph to date when ${clauseSubject} was last ${verb} — no code graph is loaded.`);
|
|
12010
|
+
}
|
|
12011
|
+
const { ask } = await import("../domain/ask.mjs");
|
|
12012
|
+
const fresh = ask(graph, `when was ${clauseSubject} ${participle}`);
|
|
12013
|
+
const freshHit = (!fresh?.tmct_ask?.miss && !fresh?.tmct_ask?.ambiguous) ? fresh?.tmct_ask?.matches?.[0] : null;
|
|
12014
|
+
const freshCommit = freshHit?.id ? graph.byId?.get?.(freshHit.id) : null;
|
|
12015
|
+
const clauseDay = freshCommit?.class === "Commit"
|
|
12016
|
+
? String((freshCommit.attributes || []).find((a) => a.key === "date")?.value || "").slice(0, 10)
|
|
12017
|
+
: "";
|
|
12018
|
+
if (!clauseDay) {
|
|
12019
|
+
return refMiss(`"${form}" refers to ${bound.referent.label} (${refDay}), but I couldn't date when ${clauseSubject} was last ${verb} in this index — so I can't compare the two.`);
|
|
12020
|
+
}
|
|
12021
|
+
const holds = cmpOp.toLowerCase() === "before" ? refDay < clauseDay : refDay > clauseDay;
|
|
12022
|
+
const relation = refDay < clauseDay ? "came before" : refDay > clauseDay ? "came after" : "landed on the same day as";
|
|
12023
|
+
const text = `${holds ? "Yes" : "No"} — ${bound.referent.label} (${refDay}) ${relation} ${clauseSubject} was last ${verb} (${freshCommit.label}, ${clauseDay}).`;
|
|
12024
|
+
note(trace, "goal: compare a prior answer's dated referent against a freshly read event (cross-turn temporal composition)");
|
|
12025
|
+
note(trace, `lane: TEMPORAL_COMPARISON_RE — "${form}" bound ${bound.referent.label} (${refDay}) through the discourse record; the embedded clause re-ran as its own when-question`);
|
|
12026
|
+
const turn = plainTurn(query, text, { via: "composed", miss: false, focus });
|
|
12027
|
+
const cited = [graph.byId?.get?.(bound.referent.ids[0]), freshCommit].filter(Boolean);
|
|
12028
|
+
turn.detail = { traversal: `discourse ${bound.referent.ref} (${refDay}) vs last-${verb} of ${clauseSubject} (${clauseDay})`, matches: cited };
|
|
12029
|
+
return turn;
|
|
11972
12030
|
}
|
|
11973
12031
|
}
|
|
11974
12032
|
// RENAME HISTORY — "what was X called before" and its siblings. The index
|
|
@@ -12182,7 +12240,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
12182
12240
|
answer = (!graph || noCodeGraph(graph)) && (!config || e?.emptyGraph || /^cannot read graph artifact\b/.test(thrown))
|
|
12183
12241
|
// A browser session has no `tmct init in a repo` to reach for, so its
|
|
12184
12242
|
// fallback drops that CLI-only remedy and keeps just the teach pointer.
|
|
12185
|
-
?
|
|
12243
|
+
? `${NO_GRAPH_BOOTSTRAP_WALL_LEAD} ${vocabHint
|
|
12186
12244
|
|| (browser
|
|
12187
12245
|
? "I can still remember and answer taught facts (try \"every bug is an issue\")."
|
|
12188
12246
|
: "I can still remember and answer taught facts (try \"every bug is an issue\"), or run `tmct init` in a repo to index one.")}`
|
|
@@ -12671,6 +12729,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
12671
12729
|
if (fallback) bareMetaHit = { text: fallback.text, replace: true };
|
|
12672
12730
|
}
|
|
12673
12731
|
const coldPronounDecline = focus?.label ? null : coldPronounDeclineText(query);
|
|
12732
|
+
let selfContainedMiss = false;
|
|
12674
12733
|
if (bareMetaHit?.reference) {
|
|
12675
12734
|
// The bare-form reference hit mirrors (4h): the cited answer replaces the
|
|
12676
12735
|
// miss, the turn is no longer recorded as one, and the article's grounded
|
|
@@ -12794,6 +12853,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
12794
12853
|
// (the isa ladder's "I can't confirm that" closers) — the turn record
|
|
12795
12854
|
// keeps miss=true and via stays untouched, so miss-rate metrics and
|
|
12796
12855
|
// recall's own miss-gated lanes see it exactly like the wall it replaced.
|
|
12856
|
+
// One flagged `selfContainedMiss` already names its own recovery, so
|
|
12857
|
+
// the empty-graph orientation pointer below stays off it — a pronoun
|
|
12858
|
+
// decline with an index pointer under it is two answers to one turn.
|
|
12859
|
+
if (fact.selfContainedMiss) selfContainedMiss = true;
|
|
12797
12860
|
if (!fact.miss) {
|
|
12798
12861
|
via = "fact";
|
|
12799
12862
|
recordMiss = false;
|
|
@@ -13142,6 +13205,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
13142
13205
|
// WALL KINDNESS: a second consecutive wall collapses to a one-liner whose
|
|
13143
13206
|
// text does NOT match WALL_MISS_RE — self-limiting, so a third consecutive
|
|
13144
13207
|
// miss re-offers the tailored hint instead of droning.
|
|
13208
|
+
let genericWallMiss = false;
|
|
13145
13209
|
if (miss && recordMiss && via === "composed" && WALL_MISS_RE.test(answer)) {
|
|
13146
13210
|
const repeat = last?.answer && WALL_MISS_RE.test(String(last.answer));
|
|
13147
13211
|
// A GRAPH-LESS session's wall must not hand a vocabulary question a list
|
|
@@ -13152,33 +13216,17 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
13152
13216
|
? `I couldn't read that as a question I can answer. ${vocabHint} Type /help for all query shapes.`
|
|
13153
13217
|
: shortMissHint(query));
|
|
13154
13218
|
via = "miss";
|
|
13219
|
+
genericWallMiss = true;
|
|
13155
13220
|
note(trace, `lane: (5) SHORT TAILORED MISS — every lane above declined; ${repeat ? "REPEAT collapsed to one-liner (wall kindness)" : "the full grammar wall was shortened + tailored to the query's keywords"}`);
|
|
13156
13221
|
}
|
|
13157
|
-
//
|
|
13158
|
-
//
|
|
13159
|
-
//
|
|
13160
|
-
//
|
|
13161
|
-
//
|
|
13162
|
-
//
|
|
13163
|
-
//
|
|
13164
|
-
|
|
13165
|
-
if (recordMiss && (via === "composed" || via === "miss")
|
|
13166
|
-
&& noCodeGraph(graph) && !/--repo|tmct init|no code graph/i.test(answer)) {
|
|
13167
|
-
if (adventureLive) {
|
|
13168
|
-
answer = `${answer}\n(I don't know that yet — you can teach me: say "remember: <thing> is a <kind>". Or ask the world: "look", "where is the key", "talk to the butler".)`;
|
|
13169
|
-
note(trace, "intermediate: HONEST-EMPTY POLISH — a live adventure miss points at the teach lane and the world asides, not the --repo remedy");
|
|
13170
|
-
} else if (browser) {
|
|
13171
|
-
answer = `${answer}\n(I don't know that yet — you can teach me: say "remember: <thing> is a <kind>".)`;
|
|
13172
|
-
note(trace, "intermediate: HONEST-EMPTY POLISH — a browser miss points at the teach lane, not the CLI-only --repo remedy");
|
|
13173
|
-
} else {
|
|
13174
|
-
answer = `${answer}\n(this repo has no code graph — index it with \`tmct index\`, point me at a \`.tmct/graph.json\` with \`--repo <path>\`, or run \`npm run example:mini\`.)`;
|
|
13175
|
-
note(trace, "intermediate: HONEST-EMPTY POLISH — the loaded graph has 0 modules, so the dead-end got a tmct index/--repo pointer appended");
|
|
13176
|
-
}
|
|
13177
|
-
}
|
|
13178
|
-
// TEACH-OFFER: a "what is X" miss where X is genuinely unknown EVERYWHERE —
|
|
13179
|
-
// not a real graph entity, not a schema/vocab term, and not already in
|
|
13180
|
-
// memory — gets a short offer appended UNDER the existing miss text, never
|
|
13181
|
-
// replacing it.
|
|
13222
|
+
// TEACH-OFFER (computed first, applied after the polish below): a "what is
|
|
13223
|
+
// X" miss where X is genuinely unknown EVERYWHERE — not a real graph
|
|
13224
|
+
// entity, not a schema/vocab term, and not already in memory. Computed
|
|
13225
|
+
// ahead of the empty-graph polish because the two are alternative
|
|
13226
|
+
// recoveries for the same dead-end: a miss that is about to offer the
|
|
13227
|
+
// teach lane must not ALSO grow an index-this-repo pointer, or one
|
|
13228
|
+
// unparsed turn stacks three separate messages.
|
|
13229
|
+
let teachOffer = null;
|
|
13182
13230
|
if (recordMiss && (via === "composed" || via === "miss") && memoryDir) {
|
|
13183
13231
|
// "what do you know about X" is its OWN sibling shape — checked FIRST,
|
|
13184
13232
|
// without a resolveEntity(graph) gate: it's inherently a MEMORY question,
|
|
@@ -13189,7 +13237,12 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
13189
13237
|
const offerSrc = expandContractions(String(query).trim());
|
|
13190
13238
|
const knowAboutTerm = offerSrc.match(KNOW_ABOUT_RE)?.[1]?.trim();
|
|
13191
13239
|
const offerTerm = knowAboutTerm || metaTermOf(offerSrc, envelope);
|
|
13192
|
-
|
|
13240
|
+
// A term that LEADS with a bindable anaphor ("it used for", from an
|
|
13241
|
+
// unresolved "what is it used for") is a pronoun that failed to bind,
|
|
13242
|
+
// not a teachable subject — offering to learn facts about it would echo
|
|
13243
|
+
// the garble back as an invitation to store it.
|
|
13244
|
+
const anaphorLedTerm = offerTerm && /^(?:it|this|that|these|those|them)\b/i.test(offerTerm.trim());
|
|
13245
|
+
if (offerTerm && !anaphorLedTerm) {
|
|
13193
13246
|
let normFactTerm;
|
|
13194
13247
|
try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { normFactTerm = null; }
|
|
13195
13248
|
if (normFactTerm) {
|
|
@@ -13198,14 +13251,46 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
13198
13251
|
if (!ent) {
|
|
13199
13252
|
const variants = factTermVariants(normFactTerm, offerTerm);
|
|
13200
13253
|
const known = (await memoryFacts(memoryDir)).some((f) => variants.has(f.subject) || variants.has(f.object));
|
|
13201
|
-
if (!known)
|
|
13202
|
-
answer = `${answer}\n${unknownVocabTermOffer(cleanTerm)}`;
|
|
13203
|
-
note(trace, `intermediate: TEACH-OFFER — "${cleanTerm}" is unknown to both the graph and memory, so the miss got an offer to learn appended`);
|
|
13204
|
-
}
|
|
13254
|
+
if (!known) teachOffer = unknownVocabTermOffer(cleanTerm);
|
|
13205
13255
|
}
|
|
13206
13256
|
}
|
|
13207
13257
|
}
|
|
13208
13258
|
}
|
|
13259
|
+
// #4 HONEST-EMPTY POLISH — an empty CODE graph: any still-standing engine
|
|
13260
|
+
// dead-end (an honest empty, the short miss, the bootstrap note) carries the
|
|
13261
|
+
// exit toward a real graph, unless it already points there — or unless the
|
|
13262
|
+
// turn already names its own recovery (a self-contained decline, or a
|
|
13263
|
+
// teach-offer about to land). Only when genuinely empty. The CLI keeps the
|
|
13264
|
+
// --repo/example pointer verbatim; a browser or a live adventure has no
|
|
13265
|
+
// such command to reach for, so each gets a teach-forward pointer (and the
|
|
13266
|
+
// adventure also names the world asides that are guaranteed to hit).
|
|
13267
|
+
// A live adventure keeps its polish even beside a teach-offer: the world
|
|
13268
|
+
// asides ("look", "talk to the butler") are guidance the offer can't carry.
|
|
13269
|
+
const adventureLive = !!planHolder?.state?.adventure;
|
|
13270
|
+
if (recordMiss && (via === "composed" || via === "miss") && !selfContainedMiss
|
|
13271
|
+
&& (adventureLive || !teachOffer)
|
|
13272
|
+
&& noCodeGraph(graph) && !/--repo|tmct init|no code graph/i.test(answer)) {
|
|
13273
|
+
if (adventureLive) {
|
|
13274
|
+
answer = `${answer}\n(I don't know that yet — you can teach me: say "remember: <thing> is a <kind>". Or ask the world: "look", "where is the key", "talk to the butler".)`;
|
|
13275
|
+
note(trace, "intermediate: HONEST-EMPTY POLISH — a live adventure miss points at the teach lane and the world asides, not the --repo remedy");
|
|
13276
|
+
} else if (browser) {
|
|
13277
|
+
answer = `${answer}\n(I don't know that yet — you can teach me: say "remember: <thing> is a <kind>".)`;
|
|
13278
|
+
note(trace, "intermediate: HONEST-EMPTY POLISH — a browser miss points at the teach lane, not the CLI-only --repo remedy");
|
|
13279
|
+
} else {
|
|
13280
|
+
answer = `${answer}\n(this repo has no code graph — index it with \`tmct index\`, point me at a \`.tmct/graph.json\` with \`--repo <path>\`, or run \`npm run example:mini\`.)`;
|
|
13281
|
+
note(trace, "intermediate: HONEST-EMPTY POLISH — the loaded graph has 0 modules, so the dead-end got a tmct index/--repo pointer appended");
|
|
13282
|
+
}
|
|
13283
|
+
}
|
|
13284
|
+
if (teachOffer) {
|
|
13285
|
+
// On a GENERIC wall (the shortened "couldn't read that", or the
|
|
13286
|
+
// graph-less bootstrap wall) the offer IS the whole answer — the wall
|
|
13287
|
+
// names no term, so keeping it above the offer stacks two messages
|
|
13288
|
+
// where one carries everything. A receipt-bearing specific miss keeps
|
|
13289
|
+
// the offer appended beneath it, unchanged.
|
|
13290
|
+
const genericWall = genericWallMiss || answer.startsWith(NO_GRAPH_BOOTSTRAP_WALL_LEAD);
|
|
13291
|
+
answer = genericWall ? teachOffer : `${answer}\n${teachOffer}`;
|
|
13292
|
+
note(trace, `intermediate: TEACH-OFFER — the term is unknown to both the graph and memory, so the miss ${genericWall ? "collapsed to the offer to learn" : "got an offer to learn appended"}`);
|
|
13293
|
+
}
|
|
13209
13294
|
// COLLISION RESTORE (pairs with relaxedTeachCollision, above): if nothing in
|
|
13210
13295
|
// the would-miss cascade actually stored/answered anything, fall back to
|
|
13211
13296
|
// the ORIGINAL ask-engine answer computed before this turn was forced into
|
|
@@ -504,8 +504,8 @@ function sparkCaptionHtml(stats) {
|
|
|
504
504
|
* different questions (did this render even offer the reference; did the
|
|
505
505
|
* browser actually manage to load it), and both must hold for the live
|
|
506
506
|
* path to run. */
|
|
507
|
-
export function renderLedgerHtml({ rows, terms, edges, focus, contradictions, worthALook, payload, meta, memoryAskBundle, stats, ledgerBundleAvailable = false, focusDigest = null } = {}) {
|
|
508
|
-
const ledgerJson = embedJson({ rows: rows || [], terms: terms || [], edges: edges || [], focus: focus || null, contradictions: contradictions || [], worthALook: worthALook || null, meta: meta || { shown: 0, total: 0, truncated: false }, focusDigest: focusDigest || null });
|
|
507
|
+
export function renderLedgerHtml({ rows, terms, edges, focus, contradictions, worthALook, payload, meta, memoryAskBundle, stats, ledgerBundleAvailable = false, focusDigest = null, digestStructures = [] } = {}) {
|
|
508
|
+
const ledgerJson = embedJson({ rows: rows || [], terms: terms || [], edges: edges || [], focus: focus || null, contradictions: contradictions || [], worthALook: worthALook || null, meta: meta || { shown: 0, total: 0, truncated: false }, focusDigest: focusDigest || null, digestStructures: Array.isArray(digestStructures) ? digestStructures : [] });
|
|
509
509
|
const payloadJson = embedJson(payload || { individuals: [], objectProperties: [] });
|
|
510
510
|
const shown = meta?.shown ?? (rows || []).length;
|
|
511
511
|
const title = `tmct ledger — ${shown} fact${shown === 1 ? "" : "s"}${focus ? ` (focus: ${escapeHtml(focus)})` : ""}`;
|
|
@@ -644,6 +644,12 @@ ${THEME_TOKENS_CSS}
|
|
|
644
644
|
.focuscard .focusdigest { margin-top: .7rem; font-size: .92rem; line-height: 1.5; }
|
|
645
645
|
.focuscard .focusdigest p { margin: 0 0 .4rem; }
|
|
646
646
|
.focuscard .focusdigest .dgsrc { font-family: ${MONO_STACK}; font-size: .7rem; color: var(--muted); margin-top: .2rem; }
|
|
647
|
+
.focuscard .focusdigest .dgfacts { margin-top: .5rem; }
|
|
648
|
+
.focuscard .focusdigest .dgfacts > summary { font-family: ${MONO_STACK}; font-size: .7rem; color: var(--corpus); cursor: pointer; }
|
|
649
|
+
.focuscard .focusdigest .dgfacts[open] > summary { margin-bottom: .35rem; }
|
|
650
|
+
.focuscard .focusdigest .dgfactlist { border-top: 1px solid var(--line); }
|
|
651
|
+
.focuscard .focusdigest .dgfact { font-size: .72rem; padding: .22rem 0; border-bottom: 1px solid var(--line); word-break: break-word; }
|
|
652
|
+
.focuscard .focusdigest .dgfact:last-child { border-bottom: none; }
|
|
647
653
|
.group { margin: 1.1rem 0; }
|
|
648
654
|
.group h3 { font-family: ${MONO_STACK}; font-size: .68rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); font-weight: 400; margin: 0 0 .35rem; display: flex; align-items: baseline; gap: .5rem; }
|
|
649
655
|
.group h3::after { content: ""; flex: 1; border-top: 1px solid var(--line); transform: translateY(-.2em); }
|
|
@@ -808,6 +814,30 @@ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` :
|
|
|
808
814
|
let trail = focus ? [{ term: focus, label: null }] : [];
|
|
809
815
|
const sel = { prov: new Set(), fam: new Set(), rec: new Set() };
|
|
810
816
|
|
|
817
|
+
// The store the client-side digest reads. Defaults to the page's embedded
|
|
818
|
+
// PAYLOAD (a static viz page never changes it); once a live dock session
|
|
819
|
+
// exists it points at that session's store, which teach/research grow in
|
|
820
|
+
// place, so a digest of a just-taught term reads the fresh facts.
|
|
821
|
+
let getLivePayload = () => PAYLOAD;
|
|
822
|
+
// Whichever loaded bundle carries the browser digest helper — the demo
|
|
823
|
+
// ledger's own live engine (tmctLedger) or the committed memory-ask engine
|
|
824
|
+
// the CLI viz page inlines (tmctMemoryAsk). Null before either loads, and the
|
|
825
|
+
// focus card then keeps whatever server-computed digest it shipped.
|
|
826
|
+
const digestHelper = () =>
|
|
827
|
+
(typeof tmctLedger !== "undefined" && tmctLedger && tmctLedger.digestTermFromPayloadBrowser)
|
|
828
|
+
|| (typeof tmctMemoryAsk !== "undefined" && tmctMemoryAsk && tmctMemoryAsk.digestTermFromPayloadBrowser)
|
|
829
|
+
|| null;
|
|
830
|
+
// The digest for one term, computed live in the browser from the embedded
|
|
831
|
+
// structure table, or null when no structures were embedded, no engine has
|
|
832
|
+
// loaded, or the term holds nothing to compose.
|
|
833
|
+
function clientDigest(term) {
|
|
834
|
+
const structures = LEDGER.digestStructures;
|
|
835
|
+
const helper = digestHelper();
|
|
836
|
+
if (!term || !helper || !structures || !structures.length) return null;
|
|
837
|
+
try { return helper(getLivePayload(), term, structures, { budget: 8 }); }
|
|
838
|
+
catch { return null; }
|
|
839
|
+
}
|
|
840
|
+
|
|
811
841
|
const recOf = (r) => {
|
|
812
842
|
const t = Date.parse(r.createdAt);
|
|
813
843
|
if (!Number.isFinite(t)) return "older";
|
|
@@ -863,13 +893,23 @@ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` :
|
|
|
863
893
|
const srcs = new Set(all.map((r) => r.src.split(" | ")[0]));
|
|
864
894
|
const dates = all.map((r) => r.createdAt).filter(Boolean).sort();
|
|
865
895
|
const klass = all.find((r) => r.s === focus && r.family === "is-a");
|
|
866
|
-
// The digest paragraph
|
|
867
|
-
//
|
|
868
|
-
//
|
|
869
|
-
|
|
896
|
+
// The digest paragraph recomputes client-side on every focus, from the
|
|
897
|
+
// embedded structure table over the current store — so a refocus reads the
|
|
898
|
+
// new term back rather than losing the card. The server-computed focus
|
|
899
|
+
// digest is the fallback for the initial focus alone, kept for a page whose
|
|
900
|
+
// engine bundle never loads (its client digest would come back null).
|
|
901
|
+
const dg = clientDigest(focus)
|
|
902
|
+
|| ((focus === LEDGER.focus && LEDGER.focusDigest && LEDGER.focusDigest.paragraphs && LEDGER.focusDigest.paragraphs.length) ? LEDGER.focusDigest : null);
|
|
903
|
+
const dgFacts = (dg && Array.isArray(dg.facts)) ? dg.facts : [];
|
|
904
|
+
const factsEscapeHtml = dgFacts.length
|
|
905
|
+
? '<details class="dgfacts"><summary>show the facts (' + dgFacts.length + ')</summary><div class="dgfactlist">' +
|
|
906
|
+
dgFacts.map((f) => '<div class="dgfact"><span class="mono">' + esc(String(f.subject || "")) + " " + esc(String(f.predicate || "")) + " " + esc(String(f.object || "")) + "</span></div>").join("") +
|
|
907
|
+
"</div></details>"
|
|
908
|
+
: "";
|
|
870
909
|
const digestHtml = dg
|
|
871
910
|
? '<div class="focusdigest">' + dg.paragraphs.map((p) => "<p>" + esc(p) + "</p>").join("") +
|
|
872
|
-
(dg.sources && dg.sources.length ? '<p class="dgsrc">sources: ' + esc(dg.sources.join("; ")) + "</p>" : "") +
|
|
911
|
+
(dg.sources && dg.sources.length ? '<p class="dgsrc">(sources: ' + esc(dg.sources.join("; ")) + ")</p>" : "") +
|
|
912
|
+
factsEscapeHtml + "</div>"
|
|
873
913
|
: "";
|
|
874
914
|
let html = '<div class="focuscard"><div class="term">' + esc(focus) + "</div>" +
|
|
875
915
|
'<div class="klass">' + (klass ? esc(klass.phrase + " " + klass.o) : "no class recorded") + "</div>" +
|
|
@@ -1009,6 +1049,11 @@ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` :
|
|
|
1009
1049
|
rows: freshData.rows, terms: freshData.terms, edges: freshData.edges,
|
|
1010
1050
|
focus: freshData.focus, contradictions: freshData.contradictions,
|
|
1011
1051
|
worthALook: freshData.worthALook, meta: freshData.meta,
|
|
1052
|
+
// The embedded structure table is build-time data a re-derivation never
|
|
1053
|
+
// recomputes; carry it across so a digest of the just-taught term still
|
|
1054
|
+
// has a table to compose from. The stale server focusDigest is dropped —
|
|
1055
|
+
// the client recompute over the grown store supersedes it.
|
|
1056
|
+
digestStructures: LEDGER.digestStructures,
|
|
1012
1057
|
};
|
|
1013
1058
|
rebuildIndexes();
|
|
1014
1059
|
el("dash").outerHTML = dashboardHtml(freshData.stats, { fresh: true });
|
|
@@ -1080,6 +1125,10 @@ ${ledgerBundleAvailable ? `<script src="./ledger-browser.bundle.js"></script>` :
|
|
|
1080
1125
|
if (session) return session;
|
|
1081
1126
|
await tryLoadWink();
|
|
1082
1127
|
session = await tmctLedger.createLedgerSession({ seedPayload: PAYLOAD });
|
|
1128
|
+
// From here the live store is the source of truth for the digest, so a
|
|
1129
|
+
// digest of a term taught this session reads its fresh facts — the store
|
|
1130
|
+
// grows in place, so this one closure stays current.
|
|
1131
|
+
getLivePayload = () => session.memoryDir.payload;
|
|
1083
1132
|
return session;
|
|
1084
1133
|
}
|
|
1085
1134
|
|