@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.
@@ -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 quantHasLed = detLed ? null : raw.match(QUANTIFIED_HAS_TEACH_RE);
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
- : (quantHasLed ? quantifiedHasSubject(quantHasLed) : raw.match(/^([\w'-]+)/)?.[1]);
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. NON_DECLARATIVE_OPENER_RE
4589
- // runs even for a quantifier lead "every umm has a thing" isn't a real
4590
- // quantified sentence, just filler that happens to fit the shape.
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 { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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
+ }