@polycode-projects/the-mechanical-code-talker 1.8.18 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/viz.mjs CHANGED
@@ -18,43 +18,108 @@
18
18
  // reads the checked-in build artifact (scripts/build-ask-bundle.mjs's
19
19
  // output). bin/tmct.mjs's `viz` mode wires all three together.
20
20
 
21
- import { loadMemory, CREATED_AT_PROP } from "./memory/core.mjs";
22
- import { parseEntities, spiralExpand, mostRecentIndividual, MEMORY_SPIRAL_EXPAND_KINDS, buildVizNodesAndEdges } from "./codegraph.mjs";
21
+ import { loadMemory, CREATED_AT_PROP, normFactTerm } from "./memory/core.mjs";
22
+ import {
23
+ parseEntities, spiralExpand, mostRecentIndividual, MEMORY_SPIRAL_EXPAND_KINDS, MEMORY_FACT_LINK_KINDS,
24
+ buildVizNodesAndEdges, deriveFactTermGraph, pickLegendDimension, edgeKindsFor,
25
+ } from "./codegraph.mjs";
23
26
  import { readFile } from "node:fs/promises";
24
27
  import { fileURLToPath } from "node:url";
25
28
  import { dirname, join } from "node:path";
26
29
 
27
- /** Load the memory graph under `repoDir`, walk it from a seed (an explicit
28
- * `--focus <id>` override, or the most-recently-created individual by
29
- * default) via spiralExpand over the real memory-graph edge-kind inventory,
30
- * and enrich each walked node with the real label/class/timestamp data a
31
- * renderer needs. Returns `{nodes, edges, focus, payload}` `focus` is the
32
- * seed id actually used (null when the graph is empty and no seed could be
33
- * picked); `payload` is the FULL raw graph (every individual, not just the
34
- * walked subset)the embedded "Ask the graph" panel queries the whole
35
- * graph and can re-walk from a new focus, never just the initially rendered
36
- * subgraph, mirroring seonix's own "never the depth-limited display
37
- * sub-graph" precedent. Never throws on a missing/empty memory dir:
38
- * loadMemory's own ENOENT fallback (emptyMemory()) already degrades to zero
39
- * individuals, which this function turns into
40
- * `{nodes: [], edges: [], focus: null, payload}`. */
41
- export async function computeVizGraph(repoDir, { focus } = {}) {
30
+ // PLAN_VIZ_MEMORY.md's page-size strategy: seonix's own three-cap default (200)
31
+ // is the starting point, raised from the code-graph-only SPIRAL_NODE_LIMIT_DEFAULT
32
+ // (12 a MID-tier digest breadth, not a graph-viewer breadth) now that Bug 2's
33
+ // fix makes real concept-relation edges walkable, and re-tuned against a real
34
+ // `init:large`-seeded repo this session (see HANDOVER.md for the measured numbers).
35
+ export const VIZ_NODE_LIMIT_DEFAULT = 300;
36
+ export const VIZ_HUB_DEGREE_DEFAULT = 40; // seonix's own default, ported verbatim (PLAN_VIZ_MEMORY.md)
37
+ export const VIZ_DEPTH_DEFAULT = 3; // mirrors spiralExpand's own SPIRAL_DEPTH_DEFAULT named here so client-side re-walks embed/reuse the SAME resolved value, never spiralExpand's smaller code-graph defaults
38
+
39
+ // edgeKindsFor now lives in codegraph.mjs (re-exported here for existing
40
+ // importers) it moved so the browser bundle's client-side re-walk (which
41
+ // can't import viz.mjs itself, a real-fs-I/O module) can share the SAME
42
+ // kind-combination logic instead of a second hand-rolled copy. See
43
+ // codegraph.mjs's own doc comment on edgeKindsFor for the full reasoning.
44
+ export { edgeKindsFor };
45
+
46
+ /** Load the memory graph under `repoDir`, walk it from a seed, and enrich each
47
+ * walked node with the real label/class/timestamp data a renderer needs.
48
+ * Seed precedence: an explicit `--focus <id>`, then `--term <word>` (Bug 2's
49
+ * companion seed strategy — resolves via `normFactTerm` to the synthetic
50
+ * `term:<word>` node deriveFactTermGraph materializes, so `tmct viz --term
51
+ * dog` reaches "dog"'s WHOLE concept neighbourhood — every Fact mentioning
52
+ * it, and everything THOSE facts connect to — without hunting for a raw
53
+ * `fact:<hash>` id first; a term that matches no Fact falls through to the
54
+ * default below rather than seeding a lone phantom node), then
55
+ * `mostRecentIndividual` by default.
56
+ *
57
+ * Bug 2 fix: the walk runs over BOTH the existing meta/provenance kinds
58
+ * (`MEMORY_SPIRAL_EXPAND_KINDS`) AND the real concept-relation kinds
59
+ * (`deriveFactTermGraph`'s per-predicate + link kinds) together by default
60
+ * (`edgeKindMode: "both"`) — a click on "dog" now reaches "animal"/"tail"/
61
+ * "bark" the same way asking about it in chat would surface those same
62
+ * facts, not just its provenance chain. `edgeKindMode: "meta"` reproduces
63
+ * today's exact byte-identical provenance-only walk (never deleted, just no
64
+ * longer the only option); `"relation"` isolates the concept view alone.
65
+ *
66
+ * `hubDegree` (seonix's third cap, PLAN_VIZ_MEMORY.md): stop expanding
67
+ * THROUGH a node with more than this many connections (still shows the hub
68
+ * itself) — without it a common hypernym could swallow the whole node
69
+ * budget in one hop. `VIZ_HUB_DEGREE_DEFAULT` applies when omitted.
70
+ *
71
+ * Returns `{nodes, edges, focus, payload, legend}` — `focus` is the seed id
72
+ * actually used (null when the graph is empty and no seed could be picked);
73
+ * `payload` is the FULL raw graph (every individual, not just the walked
74
+ * subset) — the embedded "Ask the graph" panel queries the whole graph and
75
+ * can re-walk from a new focus, never just the initially rendered subgraph,
76
+ * mirroring seonix's own "never the depth-limited display sub-graph"
77
+ * precedent; `legend` is `pickLegendDimension`'s precomputed output over the
78
+ * walked node set. Never throws on a missing/empty memory dir: loadMemory's
79
+ * own ENOENT fallback (emptyMemory()) already degrades to zero individuals,
80
+ * which this function turns into `{nodes: [], edges: [], focus: null,
81
+ * payload, legend: null}`. */
82
+ export async function computeVizGraph(repoDir, { focus, term, depth, nodeLimit, hubDegree, edgeKindMode = "both" } = {}) {
42
83
  const payload = await loadMemory(repoDir);
43
84
  const graph = parseEntities(payload);
44
- if (!graph.individuals.length) return { nodes: [], edges: [], focus: null, payload };
85
+ // walkOpts: the RESOLVED options actually used (defaults filled in) always
86
+ // present, even on an empty/seedless graph, so renderVizHtml can embed them
87
+ // for the client-side re-walk (recentre/edge-kind toggle) to stay consistent
88
+ // with whatever this page was generated with, rather than silently falling
89
+ // back to spiralExpand's own much-smaller code-graph defaults (nodeLimit 12).
90
+ const walkOpts = {
91
+ depth: depth != null ? depth : VIZ_DEPTH_DEFAULT,
92
+ nodeLimit: nodeLimit != null ? nodeLimit : VIZ_NODE_LIMIT_DEFAULT,
93
+ hubDegree: hubDegree != null ? hubDegree : VIZ_HUB_DEGREE_DEFAULT,
94
+ edgeKindMode,
95
+ };
96
+ if (!graph.individuals.length) return { nodes: [], edges: [], focus: null, payload, legend: null, walkOpts };
45
97
 
46
- const seedId = focus || mostRecentIndividual(graph, CREATED_AT_PROP)?.id || null;
47
- if (!seedId) return { nodes: [], edges: [], focus: null, payload };
98
+ const { graph: augmented, factRelationKinds } = deriveFactTermGraph(graph);
99
+
100
+ let seedId = focus || null;
101
+ if (!seedId && term) {
102
+ const termId = `term:${normFactTerm(term)}`;
103
+ if (augmented.byId.has(termId)) seedId = termId;
104
+ // no match: falls through to the default seed below, exactly as if
105
+ // --term had never been given — an honest miss never seeds a phantom node.
106
+ }
107
+ if (!seedId) seedId = mostRecentIndividual(graph, CREATED_AT_PROP)?.id || null;
108
+ if (!seedId) return { nodes: [], edges: [], focus: null, payload, legend: null, walkOpts };
48
109
 
49
- const walked = spiralExpand(graph, [], {
50
- kinds: MEMORY_SPIRAL_EXPAND_KINDS,
110
+ const walked = spiralExpand(augmented, [], {
111
+ kinds: edgeKindsFor(edgeKindMode, factRelationKinds),
51
112
  classPredicate: () => true,
52
113
  idNormalizer: (id) => id,
53
114
  seeds: [seedId],
115
+ depth: walkOpts.depth,
116
+ nodeLimit: walkOpts.nodeLimit,
117
+ hubDegree: walkOpts.hubDegree,
54
118
  });
55
119
 
56
- const { nodes, edges } = buildVizNodesAndEdges(graph, walked);
57
- return { nodes, edges, focus: seedId, payload };
120
+ const { nodes, edges } = buildVizNodesAndEdges(augmented, walked);
121
+ const legend = pickLegendDimension(augmented, nodes);
122
+ return { nodes, edges, focus: seedId, payload, legend, walkOpts };
58
123
  }
59
124
 
60
125
  /** Read the checked-in browser ask-engine bundle (scripts/build-ask-bundle.mjs's
@@ -72,6 +137,22 @@ export async function readAskBundle() {
72
137
  }
73
138
  }
74
139
 
140
+ /** Read the checked-in browser MEMORY-ask-engine bundle
141
+ * (scripts/build-ask-bundle.mjs's second output,
142
+ * `src/memory-ask-browser.bundle.js`) — Bug 1 fix's real memory-graph answer
143
+ * engine (chat.mjs's factAnswer), alongside the pre-existing code-graph
144
+ * ask.mjs bundle readAskBundle() already reads. Same never-throws contract:
145
+ * `""` when the bundle hasn't been built yet, so renderVizHtml degrades to
146
+ * whichever engine (if any) IS present rather than a broken page. */
147
+ export async function readMemoryAskBundle() {
148
+ try {
149
+ const here = dirname(fileURLToPath(import.meta.url));
150
+ return await readFile(join(here, "memory-ask-browser.bundle.js"), "utf8");
151
+ } catch {
152
+ return "";
153
+ }
154
+ }
155
+
75
156
  /** Escape untrusted text for safe placement inside HTML content/attributes. */
76
157
  function escapeHtml(s) {
77
158
  return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
@@ -104,11 +185,15 @@ function embedJson(value) {
104
185
  * class/label in the detail panel are click-to-query affordances. Pure
105
186
  * string building — no fs/network, no external <script src>, no CDN, no
106
187
  * fonts. */
107
- export function renderVizHtml({ nodes, edges, focus, payload, askBundle }) {
188
+ export function renderVizHtml({ nodes, edges, focus, payload, askBundle, memoryAskBundle, legend, walkOpts }) {
108
189
  const graphJson = embedJson({ nodes, edges, focus });
109
190
  const payloadJson = embedJson(payload || { individuals: [], objectProperties: [] });
191
+ const legendJson = embedJson(legend || null);
192
+ const walkOptsJson = embedJson(walkOpts || { depth: VIZ_DEPTH_DEFAULT, nodeLimit: VIZ_NODE_LIMIT_DEFAULT, hubDegree: VIZ_HUB_DEGREE_DEFAULT, edgeKindMode: "both" });
110
193
  const title = `tmct viz — ${nodes.length} node${nodes.length === 1 ? "" : "s"}${focus ? ` (seed: ${escapeHtml(focus)})` : ""}`;
111
194
  const hasChat = Boolean(askBundle);
195
+ const hasMemChat = Boolean(memoryAskBundle);
196
+ const hasAnyChat = hasChat || hasMemChat;
112
197
 
113
198
  return `<!doctype html>
114
199
  <html lang="en">
@@ -125,7 +210,7 @@ export function renderVizHtml({ nodes, edges, focus, payload, askBundle }) {
125
210
  #hud { position: absolute; top: 12px; left: 12px; max-width: 42ch; background: rgba(20,22,30,0.82); border: 1px solid rgba(255,255,255,0.12); border-radius: 8px; padding: 10px 12px; font-size: 12.5px; line-height: 1.45; pointer-events: none; }
126
211
  #hud b { color: #fff; }
127
212
  #hud .muted { color: #9aa1b0; }
128
- #controls { position: absolute; top: 12px; left: 50%; transform: translateX(-50%); display: flex; gap: 10px; align-items: center; background: rgba(20,22,30,0.88); border: 1px solid rgba(255,255,255,0.14); border-radius: 8px; padding: 7px 12px; font-size: 12.5px; flex-wrap: wrap; max-width: min(70vw, 640px); }
213
+ #controls { position: absolute; top: 12px; left: 50%; transform: translateX(-50%); display: flex; gap: 10px; align-items: center; background: rgba(20,22,30,0.88); border: 1px solid rgba(255,255,255,0.14); border-radius: 8px; padding: 7px 12px; font-size: 12.5px; flex-wrap: wrap; max-width: min(86vw, 900px); }
129
214
  #controls .grp { display: flex; align-items: center; gap: 5px; }
130
215
  #controls button { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.2); color: #e7e9ee; border-radius: 5px; width: 22px; height: 22px; line-height: 1; cursor: pointer; font-size: 13px; }
131
216
  #controls button:hover:not(:disabled) { background: rgba(255,255,255,0.18); }
@@ -134,6 +219,17 @@ export function renderVizHtml({ nodes, edges, focus, payload, askBundle }) {
134
219
  #controls label.typechk { display: flex; align-items: center; gap: 4px; cursor: pointer; padding: 2px 6px; border-radius: 4px; }
135
220
  #controls label.typechk:hover { background: rgba(255,255,255,0.08); }
136
221
  #controls .swatch { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
222
+ #controls select, #controls input[type="number"], #controls input[type="text"].search { background: #14161e; color: #e7e9ee; border: 1px solid #2a2e42; border-radius: 5px; padding: 2px 5px; font: inherit; font-size: 12px; }
223
+ #controls input[type="number"] { width: 3.6em; }
224
+ #controls input[type="text"].search { width: 9em; }
225
+ #controls .sep { width: 1px; align-self: stretch; background: rgba(255,255,255,0.14); margin: 0 2px; }
226
+ #legend { position: absolute; top: 58px; left: 50%; transform: translateX(-50%); display: none; flex-wrap: wrap; gap: 6px; align-items: center; background: rgba(20,22,30,0.88); border: 1px solid rgba(255,255,255,0.14); border-radius: 8px; padding: 6px 10px; font-size: 11.5px; max-width: min(86vw, 900px); }
227
+ #legend.show { display: flex; }
228
+ #legend select { background: #14161e; color: #e7e9ee; border: 1px solid #2a2e42; border-radius: 5px; padding: 1px 4px; font: inherit; font-size: 11px; }
229
+ #legend .chip { display: flex; align-items: center; gap: 4px; cursor: pointer; padding: 2px 6px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.16); }
230
+ #legend .chip.off { opacity: 0.4; }
231
+ #legend .chip .swatch { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
232
+ #legend .chip .n { color: #9aa1b0; }
137
233
  #panel { position: absolute; top: 12px; right: 12px; width: 280px; max-width: calc(100vw - 24px); background: rgba(20,22,30,0.92); border: 1px solid rgba(255,255,255,0.14); border-radius: 8px; padding: 12px 14px; font-size: 13px; line-height: 1.5; display: none; }
138
234
  #panel.show { display: block; }
139
235
  #panel h2 { margin: 0 0 6px; font-size: 14px; word-break: break-word; }
@@ -161,6 +257,7 @@ export function renderVizHtml({ nodes, edges, focus, payload, askBundle }) {
161
257
  #askresult .q { color: #565f89; font-style: normal; margin-bottom: 3px; }
162
258
  #askresult.miss { color: #a9b1d6; font-style: italic; }
163
259
  #askresult .canon { margin-top: 6px; color: #6b7189; font-size: 11px; font-style: normal; border-top: 1px dashed rgba(255,255,255,0.1); padding-top: 5px; }
260
+ #askresult .src { margin-top: 4px; color: #565f89; font-size: 10.5px; }
164
261
  #ask .hint { color: #6b7189; font-size: 11px; }
165
262
  </style>
166
263
  </head>
@@ -171,22 +268,42 @@ export function renderVizHtml({ nodes, edges, focus, payload, askBundle }) {
171
268
  <div id="controls">
172
269
  <span class="grp"><span class="muted">depth</span><button id="depthdown" title="shallower">&minus;</button><b class="depthval" id="depthval"></b><button id="depthup" title="deeper">+</button></span>
173
270
  <span class="grp" id="typefilters"></span>
271
+ <span class="sep"></span>
272
+ <span class="grp"><span class="muted">edges</span><select id="edgekind" title="which kinds of edge the walk follows">
273
+ <option value="both">both (default)</option>
274
+ <option value="relation">concept relations</option>
275
+ <option value="meta">provenance only</option>
276
+ </select></span>
277
+ <span class="grp"><label title="hide nodes above N connections outright"><input type="checkbox" id="hubhideon"> hub-hide &gt;</label><input type="number" id="hubhideval" min="1"></span>
278
+ <span class="grp"><label title="keep only the top-N neighbours by degree per hop"><input type="checkbox" id="beamon"> beam-prune</label><input type="number" id="beamval" min="1"></span>
279
+ <span class="grp"><span class="muted">labels</span><select id="labelmode">
280
+ <option value="smart">smart</option>
281
+ <option value="all">all names</option>
282
+ <option value="name-source">name + source</option>
283
+ <option value="none">none</option>
284
+ </select></span>
285
+ <span class="sep"></span>
286
+ <span class="grp"><input type="text" class="search" id="search" placeholder="search labels&hellip;" autocomplete="off"><b id="searchcount" class="muted"></b></span>
174
287
  </div>
288
+ <div id="legend"><span class="muted">legend</span><select id="legenddim"></select><span id="legendchips"></span></div>
175
289
  <div id="panel"></div>
176
- <div id="empty"><div><b>No graph data.</b><br>This repo has no <code>.tmct/memory/graph.json</code> yet (or the requested <code>--focus</code> id wasn't found). Run <code>tmct chat</code> in that repo first, then re-run <code>tmct viz</code>.</div></div>
290
+ <div id="empty"><div><b>No graph data.</b><br>This repo has no <code>.tmct/memory/graph.json</code> yet (or the requested <code>--focus</code>/<code>--term</code> wasn't found). Run <code>tmct chat</code> in that repo first, then re-run <code>tmct viz</code>.</div></div>
177
291
  <div id="ask">
178
292
  <h3>Ask the graph</h3>
179
- <div class="row"><input id="askq" type="text" autocomplete="off" placeholder='ask e.g. "where is X mentioned"'${hasChat ? "" : " disabled"}><button id="asksubmit"${hasChat ? "" : " disabled"}>ask</button></div>
180
- <div id="askresult">${hasChat
181
- ? '<span class="hint">running the real tmct engine, client-side, right here &mdash; try &quot;where is &lt;label&gt; mentioned&quot;. Answers re-centre the graph on what they resolve.</span>'
293
+ <div class="row"><input id="askq" type="text" autocomplete="off" placeholder='ask e.g. "what is a dog"'${hasAnyChat ? "" : " disabled"}><button id="asksubmit"${hasAnyChat ? "" : " disabled"}>ask</button></div>
294
+ <div id="askresult">${hasAnyChat
295
+ ? '<span class="hint">running the real tmct engine(s), client-side, right here &mdash; try &quot;what is X&quot; or &quot;where is X mentioned&quot;. Answers re-centre the graph on what they resolve.</span>'
182
296
  : '<span class="hint">chat unavailable &mdash; run <code>npm run build:ask-bundle</code> and re-generate this page.</span>'}</div>
183
297
  </div>
184
298
  </div>
185
299
  <script>
186
300
  const GRAPH = ${graphJson};
187
301
  const PAYLOAD = ${payloadJson};
302
+ const LEGEND = ${legendJson};
303
+ const WALK_OPTS = ${walkOptsJson};
188
304
  </script>
189
305
  ${hasChat ? `<script>\n${askBundle}\n</script>` : ""}
306
+ ${hasMemChat ? `<script>\n${memoryAskBundle}\n</script>` : ""}
190
307
  <script>
191
308
  (function () {
192
309
  "use strict";
@@ -195,10 +312,24 @@ ${hasChat ? `<script>\n${askBundle}\n</script>` : ""}
195
312
  if (!GRAPH.nodes.length) { emptyEl.classList.add("show"); return; }
196
313
 
197
314
  var hasEngine = typeof tmctViz !== "undefined";
315
+ var hasMemEngine = typeof tmctMemoryAsk !== "undefined";
198
316
  var FULL_GRAPH = hasEngine ? tmctViz.parseEntities(PAYLOAD) : null;
317
+ // The term-relation view (Bug 2 fix) over the FULL graph, computed once —
318
+ // recentre()/edge-kind-toggle re-walks reuse it rather than re-deriving it
319
+ // per click. hasEngine-gated: the walk/legend exports only exist in the
320
+ // ask-browser bundle, not the memory-ask one.
321
+ var TERM_GRAPH = hasEngine ? tmctViz.deriveFactTermGraph(FULL_GRAPH) : null;
322
+ // kindsForMode reuses the bundled tmctViz.edgeKindsFor — the SAME function
323
+ // the CLI's own computeVizGraph calls server-side — rather than a second
324
+ // hand-rolled copy of the meta/relation/both combination logic.
325
+ function kindsForMode(mode) {
326
+ return tmctViz.edgeKindsFor(mode, TERM_GRAPH ? TERM_GRAPH.factRelationKinds : []);
327
+ }
328
+ var edgeKindMode = WALK_OPTS.edgeKindMode || "both";
329
+ document.getElementById("edgekind").value = edgeKindMode;
199
330
 
200
331
  // ---- palette: one hue per class, stable across recentres --------------
201
- var PALETTE = ["#7aa2f7", "#bb9af7", "#7dcfff", "#9ece6a", "#e0af68", "#f7768e", "#73daca"];
332
+ var PALETTE = ["#7aa2f7", "#bb9af7", "#7dcfff", "#9ece6a", "#e0af68", "#f7768e", "#73daca", "#c0caf5"];
202
333
  var classColor = new Map();
203
334
  function colorFor(cls) {
204
335
  if (!classColor.has(cls)) classColor.set(cls, PALETTE[classColor.size % PALETTE.length]);
@@ -236,11 +367,128 @@ ${hasChat ? `<script>\n${askBundle}\n</script>` : ""}
236
367
  }
237
368
  renderTypeFilters();
238
369
 
370
+ // ---- legend-as-filter (PLAN_VIZ_MEMORY.md "Auto-picking the filter/legend
371
+ // dimension"): LEGEND.primary names the server's auto-picked dimension
372
+ // (class/predicate/provenance, scored by normalized Shannon entropy over
373
+ // the INITIAL walk); the dropdown lets a user switch dimension without
374
+ // regenerating the page. Bucket COUNTS are always recomputed live over the
375
+ // CURRENTLY visible walk (never the stale initial-generation counts) via
376
+ // the same legendValueFor derivation the CLI's own pickLegendDimension
377
+ // uses — one shared source of truth, never a second hand-rolled copy. ----
378
+ var legendEl = document.getElementById("legend");
379
+ var legendDimEl = document.getElementById("legenddim");
380
+ var legendChipsEl = document.getElementById("legendchips");
381
+ var legendDim = (LEGEND && LEGEND.primary) || "class";
382
+ var legendEnabled = null; // null = no legend filter active (every value passes)
383
+ // collapseBuckets reuses the bundled tmctViz.collapseToTopN (same top-15 +
384
+ // "Other" rule pickLegendDimension uses server-side) rather than a second
385
+ // hand-rolled copy; a tiny inline fallback covers the (untested-in-practice)
386
+ // no-engine case so the legend never hard-crashes if the bundle is absent.
387
+ function collapseBuckets(buckets) {
388
+ if (hasEngine) return tmctViz.collapseToTopN(buckets);
389
+ if (buckets.length <= 20) return buckets;
390
+ var sorted = buckets.slice().sort(function (a, b) { return b.count - a.count; });
391
+ var kept = sorted.slice(0, 15);
392
+ var restCount = sorted.slice(15).reduce(function (s, b) { return s + b.count; }, 0);
393
+ return restCount ? kept.concat([{ value: "Other", count: restCount }]) : kept;
394
+ }
395
+ function legendValueOf(node, dim) {
396
+ if (!hasEngine) return dim === "class" ? (node.class || "(none)") : null;
397
+ return tmctViz.legendValueFor(FULL_GRAPH, node, dim);
398
+ }
399
+ function computeLegendBuckets(dim) {
400
+ var counts = new Map();
401
+ GRAPH.nodes.forEach(function (n) {
402
+ var v = legendValueOf(n, dim);
403
+ if (v == null || v === "") return;
404
+ counts.set(v, (counts.get(v) || 0) + 1);
405
+ });
406
+ var buckets = Array.from(counts.entries()).map(function (e) { return { value: e[0], count: e[1] }; });
407
+ buckets.sort(function (a, b) { return b.count - a.count; });
408
+ return collapseBuckets(buckets);
409
+ }
410
+ function renderLegend() {
411
+ if (!LEGEND) { legendEl.classList.remove("show"); return; }
412
+ var dims = Object.keys(LEGEND.dimensions || { class: 1 });
413
+ legendDimEl.innerHTML = dims.map(function (d) {
414
+ var info = LEGEND.dimensions[d];
415
+ var tag = info && info.qualifies ? "" : " (low signal)";
416
+ return '<option value="' + d + '"' + (d === legendDim ? " selected" : "") + '>' + d + tag + '</option>';
417
+ }).join("");
418
+ var buckets = computeLegendBuckets(legendDim);
419
+ if (!legendEnabled) legendEnabled = new Set(buckets.map(function (b) { return b.value; }));
420
+ legendEl.classList.toggle("show", buckets.length > 0);
421
+ legendChipsEl.innerHTML = buckets.map(function (b) {
422
+ var on = legendEnabled.has(b.value);
423
+ return '<span class="chip' + (on ? "" : " off") + '" data-v="' + esc(b.value) + '" title="click to toggle">'
424
+ + '<span class="swatch" style="background:' + colorFor(legendDim === "class" ? b.value : "__" + legendDim) + '"></span>'
425
+ + esc(b.value) + '<span class="n">' + b.count + '</span></span>';
426
+ }).join("");
427
+ legendChipsEl.querySelectorAll(".chip").forEach(function (chip) {
428
+ chip.addEventListener("click", function () {
429
+ var v = chip.dataset.v;
430
+ if (legendEnabled.has(v)) legendEnabled.delete(v); else legendEnabled.add(v);
431
+ renderLegend();
432
+ applyFilters();
433
+ });
434
+ });
435
+ }
436
+ legendDimEl.addEventListener("change", function () {
437
+ legendDim = legendDimEl.value;
438
+ legendEnabled = null; // fresh "all on" set for the newly selected dimension
439
+ renderLegend();
440
+ applyFilters();
441
+ });
442
+ renderLegend();
443
+
444
+ // ---- hub-hide / beam-prune / search state --------------------------------
445
+ var hubHideOn = false, hubHideVal = WALK_OPTS.hubDegree || 40;
446
+ var beamOn = false, beamVal = 8;
447
+ var labelMode = "smart";
448
+ var searchTerm = "";
449
+ var hubHideOnEl = document.getElementById("hubhideon");
450
+ var hubHideValEl = document.getElementById("hubhideval");
451
+ var beamOnEl = document.getElementById("beamon");
452
+ var beamValEl = document.getElementById("beamval");
453
+ var labelModeEl = document.getElementById("labelmode");
454
+ var searchEl = document.getElementById("search");
455
+ var searchCountEl = document.getElementById("searchcount");
456
+ hubHideValEl.value = hubHideVal;
457
+ beamValEl.value = beamVal;
458
+ hubHideOnEl.addEventListener("change", function () { hubHideOn = hubHideOnEl.checked; applyFilters(); });
459
+ hubHideValEl.addEventListener("change", function () { hubHideVal = Number(hubHideValEl.value) || hubHideVal; applyFilters(); });
460
+ beamOnEl.addEventListener("change", function () { beamOn = beamOnEl.checked; applyFilters(); });
461
+ beamValEl.addEventListener("change", function () { beamVal = Number(beamValEl.value) || beamVal; applyFilters(); });
462
+ labelModeEl.addEventListener("change", function () { labelMode = labelModeEl.value; draw(); });
463
+ searchEl.addEventListener("input", function () { searchTerm = searchEl.value.trim().toLowerCase(); applyFilters(); });
464
+
465
+ // degree over the CURRENTLY displayed edge set (hub-hide/beam-prune are
466
+ // display-time filters, distinct from the generation-time hubDegree cap
467
+ // which only stops the WALK expanding through a hub — both useful, see
468
+ // PLAN_VIZ_MEMORY.md's Controls section). Memoized: draw() runs on every
469
+ // pan/zoom/hover mousemove, and both draw() and visibleNodeIds() (which
470
+ // draw() itself calls) each need it — recomputing an O(edges) map twice per
471
+ // frame during a drag is real, avoidable per-frame cost. Invalidated by
472
+ // relayout() (the ONLY place GRAPH.nodes/edges are mutated, on init and on
473
+ // every recentre()), so a stale cache can never survive a graph change.
474
+ var degCache = null;
475
+ function currentDegrees() {
476
+ if (!degCache) {
477
+ degCache = new Map();
478
+ GRAPH.edges.forEach(function (e) {
479
+ degCache.set(e.source, (degCache.get(e.source) || 0) + 1);
480
+ degCache.set(e.target, (degCache.get(e.target) || 0) + 1);
481
+ });
482
+ }
483
+ return degCache;
484
+ }
485
+
239
486
  // ---- layout: concentric rings keyed on hop, seed at the centre, RE-runnable on recentre ----
240
487
  var RING_GAP = 110;
241
488
  var pos = new Map();
242
489
  var byHopMax = 0;
243
490
  function relayout() {
491
+ degCache = null; // GRAPH.nodes/edges just changed (initial load or recentre()) — invalidate
244
492
  pos = new Map();
245
493
  var byHop = new Map();
246
494
  GRAPH.nodes.forEach(function (n) {
@@ -270,10 +518,47 @@ ${hasChat ? `<script>\n${askBundle}\n</script>` : ""}
270
518
  document.getElementById("depthup").disabled = depthVal >= byHopMax;
271
519
  }
272
520
  function visibleNodeIds() {
521
+ var deg = (hubHideOn || beamOn) ? currentDegrees() : null;
273
522
  var vis = new Set();
274
523
  GRAPH.nodes.forEach(function (n) {
275
- if (n.hop <= depthVal && enabledTypes.has(n.class)) vis.add(n.id);
524
+ if (n.hop > depthVal || !enabledTypes.has(n.class)) return;
525
+ if (legendEnabled) {
526
+ var v = legendValueOf(n, legendDim);
527
+ if (v != null && v !== "" && !legendEnabled.has(v)) return;
528
+ }
529
+ if (hubHideOn && deg && (deg.get(n.id) || 0) > hubHideVal) return;
530
+ vis.add(n.id);
276
531
  });
532
+ // beam-prune: BFS-order pruning — per hop (> 0), keep only the top-N
533
+ // (by CURRENT degree) neighbours; hop 0 (the seed(s)) is always kept.
534
+ if (beamOn && deg) {
535
+ var byHop = new Map();
536
+ vis.forEach(function (id) {
537
+ var n = GRAPH.nodes.find(function (x) { return x.id === id; });
538
+ if (!n || n.hop === 0) return;
539
+ if (!byHop.has(n.hop)) byHop.set(n.hop, []);
540
+ byHop.get(n.hop).push(n);
541
+ });
542
+ byHop.forEach(function (list) {
543
+ list.sort(function (a, b) { return (deg.get(b.id) || 0) - (deg.get(a.id) || 0); });
544
+ list.slice(beamVal).forEach(function (n) { vis.delete(n.id); });
545
+ });
546
+ }
547
+ if (searchTerm) {
548
+ var hits = new Set();
549
+ GRAPH.nodes.forEach(function (n) { if (vis.has(n.id) && String(n.label).toLowerCase().indexOf(searchTerm) !== -1) hits.add(n.id); });
550
+ searchCountEl.textContent = hits.size ? (hits.size + " match" + (hits.size === 1 ? "" : "es")) : "no match";
551
+ // search NARROWS visibility to matches + their direct neighbours, so the
552
+ // hit's own context stays legible instead of collapsing to lone dots.
553
+ var withNeighbours = new Set(hits);
554
+ GRAPH.edges.forEach(function (e) {
555
+ if (hits.has(e.source) && vis.has(e.target)) withNeighbours.add(e.target);
556
+ if (hits.has(e.target) && vis.has(e.source)) withNeighbours.add(e.source);
557
+ });
558
+ vis = new Set(Array.from(vis).filter(function (id) { return withNeighbours.has(id); }));
559
+ } else {
560
+ searchCountEl.textContent = "";
561
+ }
277
562
  return vis;
278
563
  }
279
564
  function applyFilters() { syncDepthUi(); draw(); }
@@ -311,10 +596,47 @@ ${hasChat ? `<script>\n${askBundle}\n</script>` : ""}
311
596
  return { x: (sx * dpr - cx) / (view.scale * dpr), y: (sy * dpr - cy) / (view.scale * dpr) };
312
597
  }
313
598
 
599
+ // Label-density modes (PLAN_VIZ_MEMORY.md Controls): "smart" (seonix's own
600
+ // default) draws a label only for the focus/selection/direct-neighbours/
601
+ // top-20-by-degree; everything else labels on hover only. "all"/"name-source"
602
+ // always draw (name-source appends the Fact's own provenance prefix — trust
603
+ // tier is a first-class concept here unlike seonix's code graph, so this
604
+ // variant has no seonix equivalent). "none" draws no labels at all.
605
+ var hoverId = null;
606
+ canvas.addEventListener("mousemove", function (ev) {
607
+ if (labelMode !== "smart" || dragging) return;
608
+ var rect = canvas.getBoundingClientRect();
609
+ var w = screenToWorld(ev.clientX - rect.left, ev.clientY - rect.top);
610
+ var best = null, bestDist = Infinity;
611
+ GRAPH.nodes.forEach(function (n) {
612
+ var p = pos.get(n.id);
613
+ if (!p) return;
614
+ var dx = p.x - w.x, dy = p.y - w.y, d = Math.sqrt(dx * dx + dy * dy);
615
+ if (d < bestDist) { best = n.id; bestDist = d; }
616
+ });
617
+ var next = bestDist < 24 / view.scale ? best : null;
618
+ if (next !== hoverId) { hoverId = next; draw(); }
619
+ });
620
+ function topDegreeIds(vis, deg, n) {
621
+ var ranked = Array.from(vis).sort(function (a, b) { return (deg.get(b) || 0) - (deg.get(a) || 0); });
622
+ return new Set(ranked.slice(0, n));
623
+ }
624
+ function labelFor(n) {
625
+ var text = String(n.label).slice(0, 40);
626
+ if (labelMode !== "name-source") return text;
627
+ var prov = hasEngine ? tmctViz.legendValueFor(FULL_GRAPH, n, "provenance") : null;
628
+ return prov ? text + " [" + prov + "]" : text;
629
+ }
630
+
314
631
  function draw() {
315
632
  ctx.clearRect(0, 0, canvas.width, canvas.height);
316
633
  ctx.lineWidth = Math.max(1, 1 * dpr);
317
634
  var vis = visibleNodeIds();
635
+ var deg = currentDegrees();
636
+ var smartLabelIds = labelMode === "smart" ? topDegreeIds(vis, deg, 20) : null;
637
+ var searchHits = searchTerm
638
+ ? new Set(GRAPH.nodes.filter(function (n) { return vis.has(n.id) && String(n.label).toLowerCase().indexOf(searchTerm) !== -1; }).map(function (n) { return n.id; }))
639
+ : null;
318
640
  ctx.strokeStyle = "rgba(255,255,255,0.14)";
319
641
  GRAPH.edges.forEach(function (e) {
320
642
  if (!vis.has(e.source) || !vis.has(e.target)) return;
@@ -341,11 +663,20 @@ ${hasChat ? `<script>\n${askBundle}\n</script>` : ""}
341
663
  ctx.lineWidth = Math.max(1.5, 2.5 * dpr); ctx.strokeStyle = "rgba(255,255,255,0.6)";
342
664
  ctx.beginPath(); ctx.arc(sp.x, sp.y, Math.max(1.5, r) + 3 * dpr, 0, Math.PI * 2); ctx.stroke();
343
665
  }
344
- if (view.scale > 0.55) {
666
+ if (searchHits && searchHits.has(n.id)) {
667
+ ctx.lineWidth = Math.max(1.5, 2 * dpr); ctx.strokeStyle = "#e0af68";
668
+ ctx.beginPath(); ctx.arc(sp.x, sp.y, Math.max(1.5, r) + 5 * dpr, 0, Math.PI * 2); ctx.stroke();
669
+ }
670
+ var showLabel = view.scale > 0.55 && labelMode !== "none" && (
671
+ labelMode !== "smart"
672
+ || n.id === selectedId || n.id === GRAPH.focus || n.id === hoverId
673
+ || (smartLabelIds && smartLabelIds.has(n.id))
674
+ );
675
+ if (showLabel) {
345
676
  ctx.font = (11 * dpr) + "px -apple-system, sans-serif";
346
677
  ctx.fillStyle = "rgba(231,233,238," + Math.min(1, 0.55 + view.scale * 0.3) + ")";
347
678
  ctx.textBaseline = "middle";
348
- ctx.fillText(String(n.label).slice(0, 40), sp.x + Math.max(6, r + 4), sp.y);
679
+ ctx.fillText(labelFor(n), sp.x + Math.max(6, r + 4), sp.y);
349
680
  }
350
681
  });
351
682
  }
@@ -389,25 +720,42 @@ ${hasChat ? `<script>\n${askBundle}\n</script>` : ""}
389
720
  view.x = -(minX + maxX) / 2 * view.scale; view.y = -(minY + maxY) / 2 * view.scale;
390
721
  }
391
722
 
392
- // ---- recentre: RE-WALK the FULL graph from a new seed via the real,
723
+ // ---- recentre: RE-WALK the FULL graph (via TERM_GRAPH Bug 2's augmented
724
+ // view, so a recentre reaches real concept-relation edges the same way
725
+ // generation-time computeVizGraph does) from a new seed via the real,
393
726
  // bundled spiralExpand (byte-identical to the CLI's own walk — never a
394
727
  // hand-rolled client-side BFS) and rebuild via the real buildVizNodesAndEdges.
395
- // Used for double-click-to-recentre AND focus-follows-chat-answer. -------
728
+ // Used for double-click-to-recentre, focus-follows-chat-answer, AND the
729
+ // edge-kind toggle (re-walks the SAME seed under a new kind set). Reuses
730
+ // WALK_OPTS (this page's own generation-time depth/nodeLimit/hubDegree) so a
731
+ // client-side re-walk never silently falls back to spiralExpand's smaller
732
+ // code-graph defaults. -------------------------------------------------
733
+ function walkGraph() { return TERM_GRAPH ? TERM_GRAPH.graph : FULL_GRAPH; }
396
734
  function recentre(id) {
397
- if (!hasEngine || !FULL_GRAPH || !FULL_GRAPH.byId.has(id)) return false;
398
- var walked = tmctViz.spiralExpand(FULL_GRAPH, [], {
735
+ if (!hasEngine || !FULL_GRAPH || !walkGraph().byId.has(id)) return false;
736
+ var walked = tmctViz.spiralExpand(walkGraph(), [], {
737
+ kinds: kindsForMode(edgeKindMode),
399
738
  classPredicate: function () { return true; }, idNormalizer: function (i) { return i; }, seeds: [id],
739
+ depth: WALK_OPTS.depth, nodeLimit: WALK_OPTS.nodeLimit, hubDegree: WALK_OPTS.hubDegree,
400
740
  });
401
- var built = tmctViz.buildVizNodesAndEdges(FULL_GRAPH, walked);
741
+ var built = tmctViz.buildVizNodesAndEdges(walkGraph(), walked);
402
742
  GRAPH.nodes = built.nodes; GRAPH.edges = built.edges; GRAPH.focus = id;
403
743
  // classes newly reached that weren't in the initial palette still resolve
404
744
  // via colorFor()'s own on-demand assignment; the checkbox row itself was
405
745
  // already seeded from the FULL graph's classes above, so no rebuild needed.
406
746
  relayout();
407
747
  depthVal = byHopMax;
748
+ legendEnabled = null; // re-derive "all on" for the new node set's own buckets
749
+ renderLegend();
408
750
  fitToVisible();
409
751
  return true;
410
752
  }
753
+ document.getElementById("edgekind").addEventListener("change", function (ev) {
754
+ edgeKindMode = ev.target.value;
755
+ var seed = GRAPH.focus;
756
+ if (seed) recentre(seed);
757
+ draw();
758
+ });
411
759
 
412
760
  // ---- click-to-inspect ------------------------------------------------------
413
761
  var panel = document.getElementById("panel");
@@ -495,37 +843,89 @@ ${hasChat ? `<script>\n${askBundle}\n</script>` : ""}
495
843
  if (best && recentre(best.id)) { selectedId = best.id; showPanel(GRAPH.nodes.filter(function (n) { return n.id === best.id; })[0]); }
496
844
  });
497
845
 
498
- // ---- Ask the graph: a real ask()-powered chat panel over the FULL graph,
499
- // never just the currently-displayed subgraph. A resolved answer's own
500
- // objMatch/matches re-centre the view — focus follows the answer. ---------
846
+ // ---- Ask the graph: TWO real engines over the FULL graph, never just the
847
+ // currently-displayed subgraph (Bug 1 fix, PLAN_VIZ_MEMORY.md) tried in
848
+ // order per query:
849
+ // 1. tmctMemoryAsk.factAnswer (src/chat.mjs's REAL memory-graph answer
850
+ // engine, the same one 'npm run chat' uses) — a Fact/definition-shaped
851
+ // question ("what is a dog", "what is a horse used for") answers HERE,
852
+ // which the code-graph engine below always missed on (Bug 1's whole
853
+ // point). Given an in-memory Backend-B handle carrying the page's own
854
+ // embedded PAYLOAD — ZERO fs I/O (see memory-ask-browser-entry.mjs's own
855
+ // doc comment) — and envelope:null/miss:true, the exact documented
856
+ // "no parse pipeline available" bootstrap path that arms factAnswer's
857
+ // own bare-question regex fallbacks.
858
+ // 2. tmctViz.ask (tmct's code-graph query engine) — generic "where is X
859
+ // mentioned" navigation and code-graph queries, unchanged from before
860
+ // this session. Only reached when (1) is unavailable or didn't hit.
861
+ // A resolved answer's own target re-centres the view — focus follows the
862
+ // answer — for EITHER engine. --------------------------------------------
501
863
  var askInput = document.getElementById("askq");
502
864
  var askBtn = document.getElementById("asksubmit");
503
865
  var askOut = document.getElementById("askresult");
866
+ var memHandle = hasMemEngine ? tmctMemoryAsk.createInMemoryStore() : null;
867
+ if (memHandle) memHandle.payload = PAYLOAD;
868
+
869
+ // Light, best-effort focus-follow for a memory-engine hit: factAnswer
870
+ // returns rendered TEXT, not a resolved entity id (unlike ask.mjs's
871
+ // envelope/matches) — strip a leading question-word crust and try the
872
+ // remainder as a term id. An honest "don't recentre" on no match, never a
873
+ // wrong guess.
874
+ function guessTermIdFromQuery(query) {
875
+ if (!hasEngine || !hasMemEngine) return null;
876
+ var stripped = String(query).toLowerCase()
877
+ .replace(/^(what|where|who|which|does|do|is|are)\b/, "")
878
+ .replace(/\b(is|are|used for|do|does|mean|means|a|an|the)\b/g, " ")
879
+ .replace(/[?.!]+$/, "")
880
+ .replace(/\s+/g, " ")
881
+ .trim();
882
+ if (!stripped) return null;
883
+ var id = "term:" + tmctMemoryAsk.normFactTerm(stripped);
884
+ return walkGraph().byId.has(id) ? id : null;
885
+ }
504
886
 
505
887
  function runAsk(query) {
506
- if (!hasEngine) return;
507
888
  askOut.classList.remove("miss");
508
- var t = tmctViz.ask(FULL_GRAPH, query, { contextId: selectedId });
509
- var envelope = t.tmct_ask || {};
510
- askOut.classList.toggle("miss", !!envelope.miss);
511
- var canon = envelope.canonical
512
- ? '<div class="canon">read as: ' + esc(envelope.canonical.english) + "</div>"
513
- : "";
514
- askOut.innerHTML = '<div class="q">&quot;' + esc(query) + '&quot;</div>' + esc(t.content) + canon;
515
- // Focus-follows-answer: prefer the resolved objMatch (the term the
516
- // question was actually ABOUT), else the first real match — either way,
517
- // only if it's a genuine individual in the graph, never a guess.
518
- var targetId = (envelope.parsed && envelope.parsed.object && (envelope.matches || [])[0] && envelope.matches[0].id)
519
- || (envelope.matches && envelope.matches[0] && envelope.matches[0].id)
520
- || null;
521
- if (targetId && recentre(targetId)) { selectedId = targetId; }
522
- draw();
889
+ (async function () {
890
+ if (memHandle) {
891
+ var fact = null;
892
+ try { fact = await tmctMemoryAsk.factAnswer(memHandle, query, null, true, {}); } catch { fact = null; }
893
+ if (fact && fact.text) {
894
+ askOut.innerHTML = '<div class="q">&quot;' + esc(query) + '&quot;</div>' + esc(fact.text)
895
+ + '<div class="src">answered from the full embedded memory graph (not just what\\'s currently drawn)</div>';
896
+ var termId = guessTermIdFromQuery(query);
897
+ if (termId && recentre(termId)) selectedId = termId;
898
+ draw();
899
+ return;
900
+ }
901
+ }
902
+ if (!hasEngine) {
903
+ askOut.classList.add("miss");
904
+ askOut.innerHTML = '<div class="q">&quot;' + esc(query) + '&quot;</div>no answer engine available.';
905
+ return;
906
+ }
907
+ var t = tmctViz.ask(FULL_GRAPH, query, { contextId: selectedId });
908
+ var envelope = t.tmct_ask || {};
909
+ askOut.classList.toggle("miss", !!envelope.miss);
910
+ var canon = envelope.canonical
911
+ ? '<div class="canon">read as: ' + esc(envelope.canonical.english) + "</div>"
912
+ : "";
913
+ askOut.innerHTML = '<div class="q">&quot;' + esc(query) + '&quot;</div>' + esc(t.content) + canon;
914
+ // Focus-follows-answer: prefer the resolved objMatch (the term the
915
+ // question was actually ABOUT), else the first real match — either way,
916
+ // only if it's a genuine individual in the graph, never a guess.
917
+ var targetId = (envelope.parsed && envelope.parsed.object && (envelope.matches || [])[0] && envelope.matches[0].id)
918
+ || (envelope.matches && envelope.matches[0] && envelope.matches[0].id)
919
+ || null;
920
+ if (targetId && recentre(targetId)) { selectedId = targetId; }
921
+ draw();
922
+ })();
523
923
  }
524
924
  function askAndPopulate(query) {
525
925
  askInput.value = query;
526
926
  runAsk(query);
527
927
  }
528
- if (hasEngine) {
928
+ if (hasEngine || hasMemEngine) {
529
929
  askBtn.addEventListener("click", function () { var q = askInput.value.trim(); if (q) runAsk(q); });
530
930
  askInput.addEventListener("keydown", function (ev) { if (ev.key === "Enter") { var q = askInput.value.trim(); if (q) runAsk(q); } });
531
931
  }