@polycode-projects/the-mechanical-code-talker 2.9.6 → 2.10.1
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 +42 -4
- package/bin/tmct.mjs +16 -1
- package/package.json +6 -1
- package/src/adapters/memory/export-jsonl.mjs +38 -0
- package/src/domain/ask.mjs +1 -1
- package/src/domain/cli-verbs.mjs +2 -1
- package/src/domain/code-explorer-hints.mjs +176 -0
- package/src/services/adventure-viz.mjs +29 -21
- package/src/services/chat-page-viz.mjs +39 -1
- package/src/services/chat.mjs +217 -8
- package/src/services/code-explorer-viz.mjs +343 -0
- package/src/services/import-file.mjs +69 -7
- package/src/services/ledger-viz.mjs +6 -1
- package/src/services/spider-fly-viz.mjs +59 -3
- package/src/services/spider-fly.mjs +5 -2
- package/src/surfaces/web/chat-browser-entry.mjs +12 -1
- package/src/surfaces/web/code-explorer-browser-entry.mjs +79 -0
- package/src/surfaces/web/memory-ask-browser.bundle.js +94 -92
- package/src/tools/definitions.mjs +7 -0
- package/src/tools/handlers/index.mjs +2 -0
- package/src/tools/handlers/tmct-export.mjs +26 -0
package/src/services/chat.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { join, dirname } from "node:path";
|
|
|
21
21
|
import { dispatchTool, loadGraph, TOOLS } from "../tools/server.mjs";
|
|
22
22
|
import { ToolError } from "../adapters/config.mjs";
|
|
23
23
|
import { parseEntities, edgesOfKind, moduleCountOf, renderAuthorCard, renderAuthorTouches, renderCommitAuthor, resolveSymbol, renderCompare } from "../domain/codegraph.mjs";
|
|
24
|
-
import { classDisplayName } from "../domain/ask.mjs";
|
|
24
|
+
import { classDisplayName, DYNAMIC_TAIL_OK_RE } from "../domain/ask.mjs";
|
|
25
25
|
import { uuidv7 } from "../adapters/uuid.mjs";
|
|
26
26
|
import * as defaultSource from "../adapters/source.mjs";
|
|
27
27
|
import { loadTemplates, render as renderTemplate } from "../adapters/corpus/templates.mjs";
|
|
@@ -811,7 +811,7 @@ async function answerMemoryCount(memoryDir, query) {
|
|
|
811
811
|
// the bare "how many do you know" (no explicit noun) defaults to remembered facts
|
|
812
812
|
if (/\bhow many(?:\s+(?:things?|facts?))?\s+(?:do|d'?)\s+(?:you|u)\s+know\b/.test(q)) cls = "Fact";
|
|
813
813
|
if (!cls) {
|
|
814
|
-
const m = q.match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b(.*)$/);
|
|
814
|
+
const m = q.match(/\b(?:how many|number of|count(?:\s+the)?)\s+(?:all\s+)?([a-z]+)\b(.*)$/);
|
|
815
815
|
if (m) {
|
|
816
816
|
cls = MEMORY_COUNT_NOUNS[m[1]] || null;
|
|
817
817
|
tail = m[2].trim();
|
|
@@ -844,6 +844,160 @@ async function answerMemoryCount(memoryDir, query) {
|
|
|
844
844
|
return said((mem.individuals || []).filter((i) => (i.class || "") === cls).length);
|
|
845
845
|
}
|
|
846
846
|
|
|
847
|
+
// ---- memory-store LIST + meta-class count ("list facts", "list utterances",
|
|
848
|
+
// "how many sessions are there") — the same reified individuals answerMemoryCount
|
|
849
|
+
// tallies, but enumerated, and reaching the meta-classes (Session/Source/Rule) the
|
|
850
|
+
// count lane skips. dynamicClassQuery (ask.mjs) already answers these when handed a
|
|
851
|
+
// memory-shaped graph, but the chat path hands ask() the CODE graph, so the store's
|
|
852
|
+
// own individuals were never reachable from a chat turn. This reads the store
|
|
853
|
+
// directly, mirroring answerMemoryCount's own lazy/failure-tolerated load. ----
|
|
854
|
+
|
|
855
|
+
/** Chat-phrasing nouns → the memory-store class they name. Fact/Utterance are
|
|
856
|
+
* shared with answerMemoryCount (which owns their counts); the meta-classes are
|
|
857
|
+
* reachable only here. */
|
|
858
|
+
const MEMORY_CLASS_QUERY_NOUNS = {
|
|
859
|
+
fact: "Fact", facts: "Fact",
|
|
860
|
+
utterance: "Utterance", utterances: "Utterance",
|
|
861
|
+
session: "Session", sessions: "Session",
|
|
862
|
+
source: "Source", sources: "Source",
|
|
863
|
+
rule: "Rule", rules: "Rule",
|
|
864
|
+
};
|
|
865
|
+
const MEMORY_CLASS_PLURALS = {
|
|
866
|
+
Fact: "facts", Utterance: "utterances", Session: "sessions", Source: "sources", Rule: "rules",
|
|
867
|
+
};
|
|
868
|
+
const MEMORY_CLASS_LIST_TRIGGER_RE = /^(?:list|show(?:\s+me)?)\s+(?:all\s+|the\s+)?([a-z][a-z-]*)\s*(.*)$/i;
|
|
869
|
+
const MEMORY_CLASS_COUNT_TRIGGER_RE = /^(?:how\s+many|number\s+of|count(?:\s+the)?)\s+(?:all\s+)?([a-z][a-z-]*)\s*(.*)$/i;
|
|
870
|
+
|
|
871
|
+
/** One display line per stored individual of a class: a Fact reads back through
|
|
872
|
+
* the same renderFactLine every other fact list uses; the other classes show
|
|
873
|
+
* their own label. */
|
|
874
|
+
function memoryClassLine(cls, ind, factByLabel) {
|
|
875
|
+
if (cls === "Fact") {
|
|
876
|
+
const row = factByLabel.get(ind.id);
|
|
877
|
+
if (row) return renderFactLine(row);
|
|
878
|
+
}
|
|
879
|
+
return String(ind.label || ind.id || "").trim();
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** Recognise "list <memory-class>" (any class) and "how many <meta-class>"
|
|
883
|
+
* (Session/Source/Rule — Fact/Utterance counts stay with answerMemoryCount) and
|
|
884
|
+
* answer off the store. Returns { text, pending } or null (→ the next lane owns
|
|
885
|
+
* it). A real restrictor tail declines rather than answering a shorter question
|
|
886
|
+
* nobody asked. */
|
|
887
|
+
async function answerMemoryClassQuery(memoryDir, query) {
|
|
888
|
+
if (!memoryDir) return null;
|
|
889
|
+
const q = String(query).trim();
|
|
890
|
+
const listM = q.match(MEMORY_CLASS_LIST_TRIGGER_RE);
|
|
891
|
+
const countM = listM ? null : q.match(MEMORY_CLASS_COUNT_TRIGGER_RE);
|
|
892
|
+
const m = listM || countM;
|
|
893
|
+
if (!m) return null;
|
|
894
|
+
const cls = MEMORY_CLASS_QUERY_NOUNS[m[1].toLowerCase()];
|
|
895
|
+
if (!cls) return null;
|
|
896
|
+
// Fact/Utterance counts carry answerMemoryCount's own about-tail discipline;
|
|
897
|
+
// never re-answer them from this simpler lane.
|
|
898
|
+
if (countM && (cls === "Fact" || cls === "Utterance")) return null;
|
|
899
|
+
const plural = MEMORY_CLASS_PLURALS[cls];
|
|
900
|
+
const tail = (m[2] || "").trim();
|
|
901
|
+
if (!DYNAMIC_TAIL_OK_RE.test(tail)) {
|
|
902
|
+
const verb = listM ? "list" : "count";
|
|
903
|
+
return {
|
|
904
|
+
text: `I can ${verb} the ${plural} I hold, but not the "${tail}" part of that question — `
|
|
905
|
+
+ `so I won't answer as if you hadn't asked it. `
|
|
906
|
+
+ `Ask "${verb} ${plural}" for all of them.`,
|
|
907
|
+
miss: true,
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
let loadMemory;
|
|
911
|
+
let readFactRows;
|
|
912
|
+
try { ({ loadMemory, readFactRows } = await import("../adapters/memory/core.mjs")); } catch { return null; }
|
|
913
|
+
let mem;
|
|
914
|
+
try { mem = await loadMemory(memoryDir); } catch { return null; }
|
|
915
|
+
const inds = (mem.individuals || []).filter((i) => (i.class || "") === cls);
|
|
916
|
+
if (countM) return { text: `${inds.length} ${inds.length === 1 ? plural.replace(/s$/, "") : plural}.` };
|
|
917
|
+
if (!inds.length) return { text: `I don't have any ${plural} stored yet.`, miss: true };
|
|
918
|
+
const factByLabel = cls === "Fact" ? new Map(readFactRows(mem).map((r) => [r.id, r])) : new Map();
|
|
919
|
+
const lines = inds.map((ind) => memoryClassLine(cls, ind, factByLabel));
|
|
920
|
+
const shown = lines.slice(0, FACT_ANSWER_CAP);
|
|
921
|
+
const rest = lines.slice(FACT_ANSWER_CAP);
|
|
922
|
+
const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
|
|
923
|
+
return {
|
|
924
|
+
text: shown.join("\n") + extra,
|
|
925
|
+
...(rest.length ? { pending: { items: rest, noun: plural } } : {}),
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// "how many animals are there" — a real count of a TAUGHT class's members
|
|
930
|
+
// (every "X is a kind of animal" fact), distinct from answerQuantifierRecall's
|
|
931
|
+
// literal quantifier lookup. Placed ahead of it in runTurn: HOW_MANY_ARE_RE
|
|
932
|
+
// reads "there" as a second noun and answers "I was never told a quantifier",
|
|
933
|
+
// stealing the phrasing before a member count ever runs.
|
|
934
|
+
const TAUGHT_CLASS_COUNT_RE = /^how\s+many\s+([a-z][\w-]*)\s*(.*)$/i;
|
|
935
|
+
|
|
936
|
+
/** Count the taught members of a class named by a plain noun ("how many animals
|
|
937
|
+
* are there" → every "X is a kind of animal"). Declines (null) for a real
|
|
938
|
+
* code-countable class (answerCount owns it) or a class nothing was taught
|
|
939
|
+
* about, so structural counts and the quantifier lane are unaffected. */
|
|
940
|
+
async function answerTaughtClassCount(memoryDir, query, biasByBundle = {}, cache = null) {
|
|
941
|
+
if (!memoryDir) return null;
|
|
942
|
+
const m = String(query).trim().match(TAUGHT_CLASS_COUNT_RE);
|
|
943
|
+
if (!m) return null;
|
|
944
|
+
if (!DYNAMIC_TAIL_OK_RE.test((m[2] || "").trim())) return null;
|
|
945
|
+
const asked = m[1].toLowerCase();
|
|
946
|
+
if (COUNT_NOUNS[asked]) return null; // a real graph-countable class — answerCount owns it
|
|
947
|
+
let normFactTerm;
|
|
948
|
+
try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
|
|
949
|
+
const rows = await factRows(memoryDir, cache);
|
|
950
|
+
const isa = rows.filter((f) => ISA_PREDICATES.has(f.predicate));
|
|
951
|
+
const variants = factTermVariants(normFactTerm, asked);
|
|
952
|
+
const members = rankByBiasThenTrust(isa.filter((f) => variants.has(f.object)), biasByBundle);
|
|
953
|
+
if (!members.length) return null; // nothing taught under this class name — later lanes own it
|
|
954
|
+
// A member whose SUBJECT is itself a countable graph class ("every class is a
|
|
955
|
+
// component") is an asserted-vocabulary cardinality, not a member enumeration —
|
|
956
|
+
// countFromFacts counts the real class, so defer to it rather than tallying the
|
|
957
|
+
// one class-level fact.
|
|
958
|
+
if (members.some((f) => COUNT_NOUNS[String(f.subject).toLowerCase()])) return null;
|
|
959
|
+
return `${members.length} ${members.length === 1 ? asked.replace(/s$/, "") : asked}.`;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// "list all animals" / "list the animals" — enumerate a taught class's members,
|
|
963
|
+
// with its OWN trigger rather than the "what is an animal" definition lane's
|
|
964
|
+
// leftovers: at scale the definition lane fills its cap with forward corpus facts
|
|
965
|
+
// before the reverse-membership listing ever shows, and the conversational
|
|
966
|
+
// orientation lane claims the bare "list …" phrasing before factReadBack runs.
|
|
967
|
+
const MEMBERSHIP_LIST_RE = /^(?:list|show(?:\s+me)?)\s+(?:all\s+|the\s+)?([a-z][\w-]*)\s*(.*)$/i;
|
|
968
|
+
|
|
969
|
+
/** List the taught members of a class named by a plain noun ("list all animals"
|
|
970
|
+
* → every "X is a kind of animal"). Declines (null) for a code-countable class
|
|
971
|
+
* or a class nothing was taught about; declines with a message for a real
|
|
972
|
+
* restrictor tail rather than answering as if it weren't there. */
|
|
973
|
+
async function answerMembershipList(memoryDir, query, biasByBundle = {}, cache = null) {
|
|
974
|
+
if (!memoryDir) return null;
|
|
975
|
+
const m = String(query).trim().match(MEMBERSHIP_LIST_RE);
|
|
976
|
+
if (!m) return null;
|
|
977
|
+
const asked = m[1].toLowerCase();
|
|
978
|
+
if (COUNT_NOUNS[asked]) return null; // a real graph-countable class — the code list lane owns it
|
|
979
|
+
const tail = (m[2] || "").trim();
|
|
980
|
+
let normFactTerm;
|
|
981
|
+
try { ({ normFactTerm } = await import("../adapters/memory/core.mjs")); } catch { return null; }
|
|
982
|
+
const rows = await factRows(memoryDir, cache);
|
|
983
|
+
const isa = rows.filter((f) => ISA_PREDICATES.has(f.predicate));
|
|
984
|
+
const variants = factTermVariants(normFactTerm, asked);
|
|
985
|
+
const members = rankByBiasThenTrust(isa.filter((f) => variants.has(f.object)), biasByBundle);
|
|
986
|
+
if (!members.length) return null; // nothing taught under this class name — later lanes own it
|
|
987
|
+
if (!DYNAMIC_TAIL_OK_RE.test(tail)) {
|
|
988
|
+
return {
|
|
989
|
+
text: `I can list the ${asked}, but not the "${tail}" part of that question — `
|
|
990
|
+
+ `so I won't answer as if you hadn't asked it. Ask "list ${asked}" for all of them.`,
|
|
991
|
+
miss: true,
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
const lines = members.map(renderFactLine);
|
|
995
|
+
const shown = lines.slice(0, FACT_ANSWER_CAP);
|
|
996
|
+
const rest = lines.slice(FACT_ANSWER_CAP);
|
|
997
|
+
const extra = rest.length ? `\n…and ${rest.length} more — say 'more' to see them.` : "";
|
|
998
|
+
return { text: shown.join("\n") + extra, ...(rest.length ? { pending: { items: rest, noun: asked } } : {}) };
|
|
999
|
+
}
|
|
1000
|
+
|
|
847
1001
|
/** `/stats`: a one-screen overview of the graph — class counts, relationship
|
|
848
1002
|
* (predicate) counts, and module/package totals — read straight off the header. */
|
|
849
1003
|
export function renderStats(graph) {
|
|
@@ -2975,6 +3129,13 @@ const quantifiedHasSubject = (m) => (/^all$/i.test(m[1]) ? singularizeSurface(m[
|
|
|
2975
3129
|
const quantifiedHasObject = (m) => (/^all$/i.test(m[1])
|
|
2976
3130
|
? m[3].replace(/[\w'-]+$/, (w) => singularizeSurface(w))
|
|
2977
3131
|
: m[3]);
|
|
3132
|
+
/** The determiner-led possession teach ("the tower has 3 disks", "the robot has
|
|
3133
|
+
* 2 arms", "my car has 4 wheels") — the closed has/have verb pins the split the
|
|
3134
|
+
* same way the universal quantifier pins QUANTIFIED_HAS_TEACH_RE's, so a leading
|
|
3135
|
+
* definite/possessive determiner needs no verb-position guessing. The subject is
|
|
3136
|
+
* the single noun between the determiner and the verb; a two-token subject stays
|
|
3137
|
+
* declined, like the preposition-pinned frame, because nothing names its head. */
|
|
3138
|
+
const DETERMINER_HAS_TEACH_RE = /^(?:the|an?|my|your|our|their|his|her|its)\s+([\w'-]+)\s+(?:has|have|had)\s+(.+?)[.!?]*$/i;
|
|
2978
3139
|
/** Verbs owned by an earlier, more specific recognizer in this lane — is/are
|
|
2979
3140
|
* (class-membership/property, above) and owns/maintains (ownership, above).
|
|
2980
3141
|
* generalVerbTeach declines outright on these so it can never race a more
|
|
@@ -3142,10 +3303,15 @@ async function generalVerbTeach(payload) {
|
|
|
3142
3303
|
// declines here exactly as it always has.
|
|
3143
3304
|
if (GENERAL_VERB_DETERMINER_RE.test(subjectRaw)) {
|
|
3144
3305
|
const quantHas = p.match(QUANTIFIED_HAS_TEACH_RE);
|
|
3306
|
+
const detHas = !quantHas ? p.match(DETERMINER_HAS_TEACH_RE) : null;
|
|
3145
3307
|
if (quantHas) {
|
|
3146
3308
|
subjectRaw = quantifiedHasSubject(quantHas);
|
|
3147
3309
|
verbRaw = "has";
|
|
3148
3310
|
objectRaw = quantifiedHasObject(quantHas);
|
|
3311
|
+
} else if (detHas) {
|
|
3312
|
+
subjectRaw = detHas[1];
|
|
3313
|
+
verbRaw = "has";
|
|
3314
|
+
objectRaw = detHas[2];
|
|
3149
3315
|
} else {
|
|
3150
3316
|
const det = p.match(GENERAL_VERB_DETERMINER_TEACH_RE);
|
|
3151
3317
|
if (!det) return null; // not a bare-name subject, and no preposition to pin the verb
|
|
@@ -4579,17 +4745,20 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
|
|
|
4579
4745
|
// of THAT subject — the same word generalVerbTeach will store — and leave
|
|
4580
4746
|
// every other sentence reading its first word exactly as before.
|
|
4581
4747
|
const detLed = raw.match(GENERAL_VERB_DETERMINER_TEACH_RE);
|
|
4582
|
-
const
|
|
4748
|
+
const detHasLed = detLed ? null : raw.match(DETERMINER_HAS_TEACH_RE);
|
|
4749
|
+
const quantHasLed = (detLed || detHasLed) ? null : raw.match(QUANTIFIED_HAS_TEACH_RE);
|
|
4583
4750
|
const subjectWord = detLed ? detLed[1].split(/\s+/).pop()
|
|
4584
|
-
: (
|
|
4751
|
+
: (detHasLed ? detHasLed[1]
|
|
4752
|
+
: (quantHasLed ? quantifiedHasSubject(quantHasLed) : raw.match(/^([\w'-]+)/)?.[1]));
|
|
4585
4753
|
// The quantifier lead ("every … has …") is itself a strong declarative
|
|
4586
4754
|
// signal, so it overrides the single-token POS gate: a noun that doubles
|
|
4587
4755
|
// as a verb ("every overbid has a gouger" — wink tags "overbid" VERB)
|
|
4588
|
-
// used to be a SILENT no-op and a later miss.
|
|
4589
|
-
//
|
|
4590
|
-
//
|
|
4756
|
+
// used to be a SILENT no-op and a later miss. A determiner-led possession
|
|
4757
|
+
// ("the tower has 3 disks") pins the same way, so it gets the same override.
|
|
4758
|
+
// NON_DECLARATIVE_OPENER_RE runs even for these leads — "every umm has a
|
|
4759
|
+
// thing" isn't a real quantified sentence, just filler that fits the shape.
|
|
4591
4760
|
if (subjectWord && !NON_DECLARATIVE_OPENER_RE.test(subjectWord)
|
|
4592
|
-
&& (quantHasLed || (await subjectIsNounOrPropn(subjectWord)))) {
|
|
4761
|
+
&& (quantHasLed || detHasLed || (await subjectIsNounOrPropn(subjectWord)))) {
|
|
4593
4762
|
// A PLURAL explicit-capability surface ("wrens can hum") whose
|
|
4594
4763
|
// SINGULAR is a grounded term stores under the singular first — the
|
|
4595
4764
|
// spelling the grounding fact and every query-side variant fold use —
|
|
@@ -13429,6 +13598,46 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
13429
13598
|
return withLast(plainTurn(workingLine, memCount, { via: "count", focus }), "get a count of a memory-store kind");
|
|
13430
13599
|
}
|
|
13431
13600
|
}
|
|
13601
|
+
// "list facts"/"list utterances"/"how many sessions are there" — enumerate the
|
|
13602
|
+
// stored individuals answerMemoryCount only tallies, and reach the meta-classes
|
|
13603
|
+
// (Session/Source/Rule) it skips. Placed here so ask()'s CODE-graph lanes never
|
|
13604
|
+
// steal the phrasing; declines cleanly for a code-graph noun or a real restrictor.
|
|
13605
|
+
if (memoryDir) {
|
|
13606
|
+
const memClass = await answerMemoryClassQuery(memoryDir, workingLine);
|
|
13607
|
+
if (memClass != null) {
|
|
13608
|
+
const goal = "list or count a memory-store kind (facts/utterances/sessions/sources/rules)";
|
|
13609
|
+
note(trace, `goal: ${goal}`);
|
|
13610
|
+
note(trace, "lane: answerMemoryClassQuery — matched a memory-store class noun, answered off the .tmct/memory store's own individuals");
|
|
13611
|
+
const turn = plainTurn(workingLine, memClass.text, { via: memClass.miss ? "miss" : "fact", miss: !!memClass.miss, focus });
|
|
13612
|
+
if (memClass.pending) turn.detail = { traversal: null, matches: [], pending: memClass.pending };
|
|
13613
|
+
return withLast(turn, goal);
|
|
13614
|
+
}
|
|
13615
|
+
}
|
|
13616
|
+
// "how many animals are there" — count a taught class's members, ahead of the
|
|
13617
|
+
// quantifier lane (which reads "there" as a second noun and answers "I was never
|
|
13618
|
+
// told a quantifier" for the exact same phrasing).
|
|
13619
|
+
if (memoryDir) {
|
|
13620
|
+
const taughtCount = await answerTaughtClassCount(memoryDir, workingLine, biasByBundle, factRowsCache);
|
|
13621
|
+
if (taughtCount != null) {
|
|
13622
|
+
note(trace, 'goal: count the taught members of a class ("how many animals are there")');
|
|
13623
|
+
note(trace, "lane: answerTaughtClassCount — matched a plain-noun count over taught isa-facts whose OBJECT is that class");
|
|
13624
|
+
return withLast(plainTurn(workingLine, taughtCount, { via: "count", focus }), "count a taught class's members");
|
|
13625
|
+
}
|
|
13626
|
+
}
|
|
13627
|
+
// "list all animals"/"list the animals" — enumerate a taught class's members
|
|
13628
|
+
// from its own trigger, ahead of the conversational orientation lane that would
|
|
13629
|
+
// otherwise claim the bare "list …" phrasing.
|
|
13630
|
+
if (memoryDir) {
|
|
13631
|
+
const memberList = await answerMembershipList(memoryDir, workingLine, biasByBundle, factRowsCache);
|
|
13632
|
+
if (memberList != null) {
|
|
13633
|
+
const goal = "list the taught members of a class";
|
|
13634
|
+
note(trace, `goal: ${goal}`);
|
|
13635
|
+
note(trace, "lane: answerMembershipList — matched a bare 'list <noun>' over taught isa-facts whose OBJECT is that class");
|
|
13636
|
+
const turn = plainTurn(workingLine, memberList.text, { via: memberList.miss ? "miss" : "fact", miss: !!memberList.miss, focus });
|
|
13637
|
+
if (memberList.pending) turn.detail = { traversal: null, matches: [], pending: memberList.pending };
|
|
13638
|
+
return withLast(turn, goal);
|
|
13639
|
+
}
|
|
13640
|
+
}
|
|
13432
13641
|
// "how many Xs are Ys" — a taught-quantifier RECALL, checked explicitly
|
|
13433
13642
|
// ahead of answerCount. Its own authority gate declines for anything
|
|
13434
13643
|
// answerCount should own, so ordinary structural counts are unaffected.
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// code-explorer-viz.mjs — the code-graph "ledger" the desktop shell renders:
|
|
2
|
+
// each import/call/contains edge read back as a plain sentence, a hint rail of
|
|
3
|
+
// suggested next queries, and a live chat dock over the same graph. The two
|
|
4
|
+
// derivations are pure so the shell, the packaging script, and the unit tests
|
|
5
|
+
// all share one code path; renderCodeExplorerHtml builds one self-contained
|
|
6
|
+
// document with no external requests.
|
|
7
|
+
//
|
|
8
|
+
// The channel is deliberately thin: this is the SAME ledger-pattern UI the
|
|
9
|
+
// browser ledger page uses, refocused on a code graph, and it stays servable
|
|
10
|
+
// as a plain page — only the Electron shell around it (electron/) is desktop.
|
|
11
|
+
|
|
12
|
+
import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText } from "./viz-theme.mjs";
|
|
13
|
+
import { generateCodeHints } from "../domain/code-explorer-hints.mjs";
|
|
14
|
+
|
|
15
|
+
// Third-person verb for each stored relation kind, symbol grain folded onto its
|
|
16
|
+
// coarse sibling. A kind with no row here reads back as itself, never breaking
|
|
17
|
+
// the sentence.
|
|
18
|
+
const EDGE_PHRASE = new Map([
|
|
19
|
+
["imports", "imports"],
|
|
20
|
+
["calls", "calls"], ["callsSymbol", "calls"],
|
|
21
|
+
["contains", "contains"],
|
|
22
|
+
["defines", "defines"],
|
|
23
|
+
["inherits", "inherits from"],
|
|
24
|
+
["tests", "tests"],
|
|
25
|
+
["touches", "touches"], ["touchesSymbol", "touches"],
|
|
26
|
+
["cochange", "co-changes with"],
|
|
27
|
+
["reexports", "re-exports"],
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
export function edgePhrase(kind) {
|
|
31
|
+
return EDGE_PHRASE.get(String(kind || "")) || String(kind || "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const LEDGER_ROW_LIMIT_DEFAULT = 4000;
|
|
35
|
+
|
|
36
|
+
/** Pure derivation over an entities payload (individuals + objectProperties).
|
|
37
|
+
* Returns { rows, terms, focus, stats, meta } — rows are one readable
|
|
38
|
+
* sentence per example edge, terms is the degree-ranked label index, and the
|
|
39
|
+
* neighbourhood of `focus` survives a row cap first so a huge graph degrades
|
|
40
|
+
* to "local + rest" rather than truncating the centre. */
|
|
41
|
+
export function computeCodeLedger(payload, { focus = null, rowLimit = LEDGER_ROW_LIMIT_DEFAULT } = {}) {
|
|
42
|
+
const individuals = Array.isArray(payload?.individuals) ? payload.individuals : [];
|
|
43
|
+
const classOf = new Map();
|
|
44
|
+
for (const ind of individuals) if (ind?.label && !classOf.has(ind.label)) classOf.set(ind.label, ind.class || "");
|
|
45
|
+
|
|
46
|
+
const groups = Array.isArray(payload?.objectProperties) ? payload.objectProperties : [];
|
|
47
|
+
const rows = [];
|
|
48
|
+
const degree = new Map();
|
|
49
|
+
const kindCounts = new Map();
|
|
50
|
+
const bumpTerm = (t) => { if (t) degree.set(t, (degree.get(t) || 0) + 1); };
|
|
51
|
+
for (const g of groups) {
|
|
52
|
+
if (!g?.predicate) continue;
|
|
53
|
+
const kind = String(g.predicate);
|
|
54
|
+
kindCounts.set(kind, (kindCounts.get(kind) || 0) + (Number(g.count) || 0));
|
|
55
|
+
for (const e of Array.isArray(g.examples) ? g.examples : []) {
|
|
56
|
+
const s = e?.subjectLabel || e?.subject;
|
|
57
|
+
const o = e?.objectLabel || e?.object;
|
|
58
|
+
if (!s || !o) continue;
|
|
59
|
+
rows.push({ s, kind, phrase: edgePhrase(kind), o, sClass: classOf.get(s) || "", oClass: classOf.get(o) || "" });
|
|
60
|
+
bumpTerm(s); bumpTerm(o);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const terms = [...degree.entries()]
|
|
65
|
+
.map(([term, deg]) => ({ term, degree: deg, class: classOf.get(term) || "" }))
|
|
66
|
+
.sort((a, b) => b.degree - a.degree || a.term.localeCompare(b.term));
|
|
67
|
+
|
|
68
|
+
let focusTerm = focus && degree.has(focus) ? focus : null;
|
|
69
|
+
if (!focusTerm && terms.length) focusTerm = terms[0].term;
|
|
70
|
+
|
|
71
|
+
const classCounts = new Map();
|
|
72
|
+
for (const ind of individuals) if (ind?.class) classCounts.set(ind.class, (classCounts.get(ind.class) || 0) + 1);
|
|
73
|
+
const stats = {
|
|
74
|
+
individuals: individuals.length,
|
|
75
|
+
edges: rows.length,
|
|
76
|
+
classes: [...classCounts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])),
|
|
77
|
+
kinds: [...kindCounts.entries()].filter(([, c]) => c > 0).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const total = rows.length;
|
|
81
|
+
let shown = rows;
|
|
82
|
+
if (total > rowLimit) {
|
|
83
|
+
const near = new Set([focusTerm]);
|
|
84
|
+
for (const r of rows) { if (r.s === focusTerm) near.add(r.o); if (r.o === focusTerm) near.add(r.s); }
|
|
85
|
+
const inHood = (r) => near.has(r.s) || near.has(r.o);
|
|
86
|
+
shown = [...rows.filter(inHood), ...rows.filter((r) => !inHood(r))].slice(0, rowLimit);
|
|
87
|
+
}
|
|
88
|
+
return { rows: shown, terms, focus: focusTerm, stats, meta: { shown: shown.length, total, truncated: shown.length < total } };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Everything the page embeds, derived once from a payload: the ledger, the
|
|
92
|
+
* degree-ranked terms, the suggested queries, and the focus symbol. */
|
|
93
|
+
export function computeCodeExplorerData(payload, opts = {}) {
|
|
94
|
+
const ledger = computeCodeLedger(payload, opts);
|
|
95
|
+
const { focus, hints } = generateCodeHints(payload, { focus: ledger.focus });
|
|
96
|
+
return { payload, ledger, hints, focus: ledger.focus || focus, meta: { title: opts.title || "code graph" } };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const CLIENT_JS = String.raw`
|
|
100
|
+
(function () {
|
|
101
|
+
var DATA = window.__CODE_EXPLORER__;
|
|
102
|
+
var api = window.tmctCodeExplorer || null;
|
|
103
|
+
var els = {
|
|
104
|
+
focus: document.getElementById("focus-name"),
|
|
105
|
+
ledger: document.getElementById("ledger"),
|
|
106
|
+
hints: document.getElementById("hints"),
|
|
107
|
+
stats: document.getElementById("stats"),
|
|
108
|
+
log: document.getElementById("chat-log"),
|
|
109
|
+
form: document.getElementById("chat-form"),
|
|
110
|
+
input: document.getElementById("chat-input"),
|
|
111
|
+
dockNote: document.getElementById("dock-note"),
|
|
112
|
+
source: document.getElementById("source-name"),
|
|
113
|
+
};
|
|
114
|
+
var session = null;
|
|
115
|
+
|
|
116
|
+
function esc(s) {
|
|
117
|
+
return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
|
|
118
|
+
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function renderStats(data) {
|
|
123
|
+
var s = data.ledger.stats;
|
|
124
|
+
var parts = [s.individuals + " individuals", s.edges + " edges"];
|
|
125
|
+
var cls = s.classes.slice(0, 4).map(function (c) { return c[1] + " " + c[0]; });
|
|
126
|
+
els.stats.textContent = parts.concat(cls).join(" · ");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function renderFocusRows(data) {
|
|
130
|
+
var focus = data.focus;
|
|
131
|
+
var rows = data.ledger.rows;
|
|
132
|
+
var near = rows.filter(function (r) { return r.s === focus || r.o === focus; });
|
|
133
|
+
var rest = rows.filter(function (r) { return r.s !== focus && r.o !== focus; });
|
|
134
|
+
var ordered = near.concat(rest);
|
|
135
|
+
els.ledger.innerHTML = ordered.map(function (r) {
|
|
136
|
+
var hot = (r.s === focus || r.o === focus) ? " row-focus" : "";
|
|
137
|
+
return '<li class="row' + hot + '">'
|
|
138
|
+
+ '<button class="term" data-term="' + esc(r.s) + '">' + esc(r.s) + '</button> '
|
|
139
|
+
+ '<span class="verb">' + esc(r.phrase) + '</span> '
|
|
140
|
+
+ '<button class="term" data-term="' + esc(r.o) + '">' + esc(r.o) + '</button>'
|
|
141
|
+
+ '</li>';
|
|
142
|
+
}).join("") || '<li class="row muted">no edges in this graph.</li>';
|
|
143
|
+
els.focus.textContent = focus || "—";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function renderHints(data) {
|
|
147
|
+
els.hints.innerHTML = data.hints.map(function (h) {
|
|
148
|
+
return '<button class="hint" data-q="' + esc(h.text) + '" title="' + esc(h.rationale) + '">'
|
|
149
|
+
+ esc(h.text) + '</button>';
|
|
150
|
+
}).join("") || '<span class="muted">nothing to suggest for this graph.</span>';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function focusOn(term) {
|
|
154
|
+
if (!api || !api.computeCodeExplorerData) return;
|
|
155
|
+
DATA = api.computeCodeExplorerData(DATA.payload, { focus: term, title: DATA.meta.title });
|
|
156
|
+
window.__CODE_EXPLORER__ = DATA;
|
|
157
|
+
mountView(DATA);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function mountView(data) {
|
|
161
|
+
renderStats(data);
|
|
162
|
+
renderFocusRows(data);
|
|
163
|
+
renderHints(data);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function appendLog(role, text) {
|
|
167
|
+
var div = document.createElement("div");
|
|
168
|
+
div.className = "turn turn-" + role;
|
|
169
|
+
div.innerHTML = '<span class="who">' + (role === "you" ? "you" : "tmct") + '</span><span class="said">' + esc(text) + '</span>';
|
|
170
|
+
els.log.appendChild(div);
|
|
171
|
+
els.log.scrollTop = els.log.scrollHeight;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function ensureSession() {
|
|
175
|
+
if (session || !api || !api.createCodeExplorerSession) return session;
|
|
176
|
+
var winkLoaded = true;
|
|
177
|
+
if (api.registerWinkModel && window.__WINK_LOADER__) {
|
|
178
|
+
try { var mod = await window.__WINK_LOADER__(); api.registerWinkModel(function () { return mod; }); }
|
|
179
|
+
catch (e) { winkLoaded = false; }
|
|
180
|
+
}
|
|
181
|
+
session = api.createCodeExplorerSession({ graphPayload: DATA.payload });
|
|
182
|
+
return session;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function ask(q) {
|
|
186
|
+
appendLog("you", q);
|
|
187
|
+
var s = await ensureSession();
|
|
188
|
+
if (!s) { appendLog("tmct", "the live dock is not loaded on this page."); return; }
|
|
189
|
+
var res = await s.turn(q);
|
|
190
|
+
appendLog("tmct", res.answer);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Delegate term + hint clicks.
|
|
194
|
+
document.addEventListener("click", function (ev) {
|
|
195
|
+
var t = ev.target.closest ? ev.target.closest("[data-term]") : null;
|
|
196
|
+
if (t) { focusOn(t.getAttribute("data-term")); return; }
|
|
197
|
+
var h = ev.target.closest ? ev.target.closest("[data-q]") : null;
|
|
198
|
+
if (h) { els.input.value = h.getAttribute("data-q"); els.input.focus(); if (api) ask(h.getAttribute("data-q")); return; }
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
if (els.form) {
|
|
202
|
+
els.form.addEventListener("submit", function (ev) {
|
|
203
|
+
ev.preventDefault();
|
|
204
|
+
var q = els.input.value.trim();
|
|
205
|
+
if (!q) return;
|
|
206
|
+
els.input.value = "";
|
|
207
|
+
ask(q);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Desktop pickers, present only under the Electron shell.
|
|
212
|
+
function wirePicker(id, method, updateSource) {
|
|
213
|
+
var btn = document.getElementById(id);
|
|
214
|
+
if (!btn) return;
|
|
215
|
+
if (!window.tmctDesktop || typeof window.tmctDesktop[method] !== "function") { btn.disabled = true; return; }
|
|
216
|
+
btn.addEventListener("click", async function () {
|
|
217
|
+
btn.disabled = true;
|
|
218
|
+
try {
|
|
219
|
+
var picked = await window.tmctDesktop[method]();
|
|
220
|
+
if (picked && picked.payload) {
|
|
221
|
+
session = null;
|
|
222
|
+
DATA = (api && api.computeCodeExplorerData)
|
|
223
|
+
? api.computeCodeExplorerData(picked.payload, { title: picked.name || "code graph" })
|
|
224
|
+
: DATA;
|
|
225
|
+
window.__CODE_EXPLORER__ = DATA;
|
|
226
|
+
if (updateSource && els.source) els.source.textContent = picked.name || "(loaded graph)";
|
|
227
|
+
els.log.innerHTML = "";
|
|
228
|
+
mountView(DATA);
|
|
229
|
+
}
|
|
230
|
+
} finally { btn.disabled = false; }
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
wirePicker("open-graph", "openGraph", true);
|
|
234
|
+
wirePicker("open-repo", "openRepo", true);
|
|
235
|
+
|
|
236
|
+
if (!api) {
|
|
237
|
+
if (els.dockNote) els.dockNote.textContent = "static view — the live chat dock is unavailable on this page.";
|
|
238
|
+
if (els.input) els.input.disabled = true;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
mountView(DATA);
|
|
242
|
+
})();
|
|
243
|
+
`;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* One self-contained HTML document for the code explorer. `data` is
|
|
247
|
+
* computeCodeExplorerData's output. `bundleInline` inlines the dock engine
|
|
248
|
+
* (for a single-file page / a data: URL); otherwise `bundleAvailable` links
|
|
249
|
+
* `./code-explorer.bundle.js`. `winkLoaderInline` optionally inlines a wink
|
|
250
|
+
* model loader as `window.__WINK_LOADER__`.
|
|
251
|
+
*/
|
|
252
|
+
export function renderCodeExplorerHtml(data, { bundleInline = "", bundleAvailable = false, winkLoaderInline = "", sourceName = "demo code graph" } = {}) {
|
|
253
|
+
const payloadJson = embedJson(data.payload);
|
|
254
|
+
const dataJson = embedJson({ ledger: data.ledger, hints: data.hints, focus: data.focus, meta: data.meta });
|
|
255
|
+
const title = escapeHtml(data.meta?.title || "code explorer");
|
|
256
|
+
|
|
257
|
+
return `<!doctype html>
|
|
258
|
+
<html lang="en">
|
|
259
|
+
<head>
|
|
260
|
+
<meta charset="utf-8">
|
|
261
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
262
|
+
<title>tmct code explorer</title>
|
|
263
|
+
<style>
|
|
264
|
+
${THEME_TOKENS_CSS}
|
|
265
|
+
* { box-sizing: border-box; }
|
|
266
|
+
body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${SERIF_STACK}; }
|
|
267
|
+
header { display: flex; align-items: baseline; gap: 1rem; flex-wrap: wrap; padding: 0.8rem 1.1rem; border-bottom: 1px solid var(--line); }
|
|
268
|
+
header h1 { font-size: 1.05rem; margin: 0; font-weight: 600; }
|
|
269
|
+
header .sub { color: var(--muted); font-size: 0.85rem; }
|
|
270
|
+
header .pickers { margin-left: auto; display: flex; gap: 0.5rem; }
|
|
271
|
+
button { font: inherit; cursor: pointer; }
|
|
272
|
+
button:disabled { cursor: default; opacity: 0.5; }
|
|
273
|
+
.pickers button { background: var(--card); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: 0.35rem 0.7rem; font-size: 0.85rem; }
|
|
274
|
+
#stats { padding: 0.4rem 1.1rem; color: var(--muted); font-size: 0.8rem; font-family: ${MONO_STACK}; border-bottom: 1px solid var(--line); }
|
|
275
|
+
main { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(260px, 1fr); gap: 0; align-items: stretch; }
|
|
276
|
+
@media (max-width: 720px) { main { grid-template-columns: 1fr; } }
|
|
277
|
+
.ledger-pane { padding: 0.6rem 1.1rem 2rem; min-height: 60vh; }
|
|
278
|
+
.rail { border-left: 1px solid var(--line); padding: 0.6rem 1rem 2rem; display: flex; flex-direction: column; gap: 1rem; }
|
|
279
|
+
h2 { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 0 0 0.4rem; }
|
|
280
|
+
ul.rows { list-style: none; margin: 0; padding: 0; }
|
|
281
|
+
.row { padding: 0.28rem 0.4rem; border-radius: 5px; font-size: 0.95rem; line-height: 1.5; }
|
|
282
|
+
.row-focus { background: var(--corpus-soft); }
|
|
283
|
+
.row.muted, .muted { color: var(--muted); }
|
|
284
|
+
.term { background: none; border: none; padding: 0; color: var(--corpus); font-family: ${MONO_STACK}; font-size: 0.85rem; text-decoration: underline; text-decoration-color: var(--line); }
|
|
285
|
+
.term:hover { text-decoration-color: var(--corpus); }
|
|
286
|
+
.verb { color: var(--muted); }
|
|
287
|
+
.hints { display: flex; flex-direction: column; gap: 0.35rem; }
|
|
288
|
+
.hint { text-align: left; background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: 0.35rem 0.55rem; font-size: 0.85rem; color: var(--ink); }
|
|
289
|
+
.hint:hover { border-color: var(--corpus); }
|
|
290
|
+
.dock { display: flex; flex-direction: column; gap: 0.4rem; }
|
|
291
|
+
#chat-log { display: flex; flex-direction: column; gap: 0.4rem; max-height: 40vh; overflow-y: auto; }
|
|
292
|
+
.turn { font-size: 0.9rem; line-height: 1.45; }
|
|
293
|
+
.turn .who { display: block; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }
|
|
294
|
+
.turn-you .said { color: var(--ink); }
|
|
295
|
+
.turn-tmct .said { color: var(--taught); white-space: pre-wrap; }
|
|
296
|
+
#chat-form { display: flex; gap: 0.4rem; }
|
|
297
|
+
#chat-input { flex: 1; font: inherit; padding: 0.4rem 0.5rem; border: 1px solid var(--line); border-radius: 6px; background: var(--card); color: var(--ink); }
|
|
298
|
+
#chat-form button { background: var(--corpus); color: #fff; border: none; border-radius: 6px; padding: 0.4rem 0.8rem; }
|
|
299
|
+
#dock-note { color: var(--muted); font-size: 0.78rem; }
|
|
300
|
+
.focus-line { font-family: ${MONO_STACK}; font-size: 0.85rem; }
|
|
301
|
+
</style>
|
|
302
|
+
</head>
|
|
303
|
+
<body>
|
|
304
|
+
<header>
|
|
305
|
+
<h1>tmct code explorer</h1>
|
|
306
|
+
<span class="sub">source: <span id="source-name">${escapeHtml(sourceName)}</span></span>
|
|
307
|
+
<div class="pickers">
|
|
308
|
+
<button id="open-graph">Open graph…</button>
|
|
309
|
+
<button id="open-repo">Open repo…</button>
|
|
310
|
+
</div>
|
|
311
|
+
</header>
|
|
312
|
+
<div id="stats"></div>
|
|
313
|
+
<main>
|
|
314
|
+
<section class="ledger-pane">
|
|
315
|
+
<h2>Facts around <span class="focus-line" id="focus-name">—</span></h2>
|
|
316
|
+
<ul class="rows" id="ledger"></ul>
|
|
317
|
+
</section>
|
|
318
|
+
<aside class="rail">
|
|
319
|
+
<div>
|
|
320
|
+
<h2>Try asking</h2>
|
|
321
|
+
<div class="hints" id="hints"></div>
|
|
322
|
+
</div>
|
|
323
|
+
<div class="dock">
|
|
324
|
+
<h2>Chat</h2>
|
|
325
|
+
<div id="chat-log"></div>
|
|
326
|
+
<form id="chat-form">
|
|
327
|
+
<input id="chat-input" type="text" autocomplete="off" placeholder="ask about this graph…">
|
|
328
|
+
<button type="submit">Ask</button>
|
|
329
|
+
</form>
|
|
330
|
+
<div id="dock-note"></div>
|
|
331
|
+
</div>
|
|
332
|
+
</aside>
|
|
333
|
+
</main>
|
|
334
|
+
<script>window.__CODE_EXPLORER__ = Object.assign({ payload: ${payloadJson} }, ${dataJson});</script>
|
|
335
|
+
${winkLoaderInline ? `<script>\n${embedScriptText(winkLoaderInline)}\n</script>` : ""}
|
|
336
|
+
${bundleInline ? `<script>\n${embedScriptText(bundleInline)}\n</script>` : ""}
|
|
337
|
+
${bundleAvailable && !bundleInline ? `<script src="./code-explorer.bundle.js"></script>` : ""}
|
|
338
|
+
<script>
|
|
339
|
+
${embedScriptText(CLIENT_JS)}
|
|
340
|
+
</script>
|
|
341
|
+
</body>
|
|
342
|
+
</html>`;
|
|
343
|
+
}
|