EXTENSION for TreeOS
understanding
A tree with 500 nodes cannot fit in an AI context window. The AI needs to know what the tree contains without reading every note on every node. Understanding solves this by building a compressed encoding of the entire tree from the bottom up. Leaves first, then their parents, then the parents of parents, until the root holds a single encoding that captures the semantic meaning of the full tree. The mechanism is layered compression. A shadow tree of UnderstandingNode documents mirrors the real tree. Each understanding node holds a perspectiveStates map keyed by run ID. A run begins by building the shadow topology: depth from root, subtree height, merge layer for each node. Leaf nodes with content are sent to the LLM for individual summarization. Empty leaves auto-commit with a placeholder. Once all children of a parent are complete at their own merge layer, the parent merges their summaries into one cohesive encoding. The process repeats upward until the root node merges everything. Runs are incremental. The contribution snapshot on each understanding node records how many contributions existed when it was last compressed. On a re-run, nodes whose contribution count has changed are marked dirty. Dirtiness propagates upward to the root. Only dirty nodes recompress. A 500-node tree where 3 nodes changed reprocesses those 3 nodes plus their ancestor chain, not all 500. Structural changes (added, removed, moved nodes) are detected by comparing old and new topology. Each run carries a perspective: a lens for compression. The default is semantic compression while maintaining meaning. A custom perspective like financial summary or technical architecture compresses the same tree differently. The root encoding and perspective are stored in encodingHistory on the run document. enrichContext injects the latest completed encoding at every node in the tree so the AI always has the compressed understanding of the full structure in its context window. The auto-run job and the dreams extension trigger understanding runs on schedules. The pipeline uses OrchestratorRuntime for session lifecycle, lock management, and LLM calls.
v1.0.1 by TreeOS Site 0 downloads 14 files 5,751 lines 168.1 KB published 38d ago
treeos ext install understanding
View changelog

Manifest

Provides

  • 2 models
  • routes
  • tools
  • jobs
  • orchestrator
  • 4 CLI commands
  • 1 energy actions

Requires

  • services: llm, session, chat, orchestrator, mcp, contributions, hooks
  • models: Node, Contribution

Optional

  • services: energy
  • extensions: html-rendering, treeos-base
SHA256: 0e21795790305bc8f1e0eaf4931f1739f13ee18540ff27a1634a417fa05e52d9

Dependents

1 package depend on this

PackageTypeRelationship
dreams v1.0.1extensionneeds

CLI Commands

CommandMethodDescription
understandPOSTStart an understanding run (-i incremental)
understandingsGETList understanding runs
understand-status <runId>GETCheck progress of a run
understand-stop <runId>POSTStop a running understanding run

Hooks

Listens To

  • enrichContext

Source Code

1// jobs/understandingAutoRun.js
2// Daily job: creates and runs a navigation-focused understanding pass per tree.
3// Produces per-node encodings that enhance tree summaries for librarian/scout navigation.
4
5import log from "../../seed/log.js";
6import Node from "../../seed/models/node.js";
7import User from "../../seed/models/user.js";
8import { findOrCreateUnderstandingRun } from "./core.js";
9import { orchestrateUnderstanding } from "./pipeline.js";
10import { userHasLlm } from "../../seed/llm/conversation.js";
11
12// ─────────────────────────────────────────────────────────────────────────
13// CONFIG
14// ─────────────────────────────────────────────────────────────────────────
15
16const NAV_PERSPECTIVE =
17  "Summarize this section as if it is a node inside a larger knowledge tree. " +
18  "Write from a perspective that understands this content will sit between a parent above and possible branches below. " +
19  "Compress the meaning upward (what this contributes to the bigger picture) while preserving clarity downward " +
20  "(what direction this section points toward). Emphasize the core idea, remove detail noise.";
21
22const MIN_TREE_CHILDREN = 2; // skip trees with fewer than this many children
23
24// ─────────────────────────────────────────────────────────────────────────
25// STATE
26// ─────────────────────────────────────────────────────────────────────────
27
28let jobTimer = null;
29
30// ─────────────────────────────────────────────────────────────────────────
31// SINGLE TREE HANDLER
32// ─────────────────────────────────────────────────────────────────────────
33
34async function processTree(rootNode) {
35  const rootId = rootNode._id.toString();
36  const userId = rootNode.rootOwner.toString();
37
38  // Skip tiny trees
39  if (!rootNode.children || rootNode.children.length < MIN_TREE_CHILDREN) {
40 log.debug("Understanding", `  Skipping "${rootNode.name}" — too few children (${rootNode.children?.length || 0})`);
41    return;
42  }
43
44  // Resolve username
45  const user = await User.findById(userId).select("username").lean();
46  if (!user) {
47 log.warn("Understanding", ` Understanding auto-run: no user for tree ${rootId}`);
48    return;
49  }
50
51  // Skip if owner has no LLM and root has no LLM assigned
52  const hasRootLlm = !!(rootNode.llmDefault && rootNode.llmDefault !== "none");
53  if (!hasRootLlm && !await userHasLlm(userId)) {
54 log.debug("Understanding", `  Skipping understanding for "${rootNode.name}" — owner has no LLM connection`);
55    return;
56  }
57
58 log.debug("Understanding", `  Understanding auto-run: starting for tree "${rootNode.name}" [${rootId.slice(0, 8)}]`);
59
60  try {
61    // Find existing run or create a new one
62    const run = await findOrCreateUnderstandingRun(
63      rootId,
64      userId,
65      NAV_PERSPECTIVE,
66      true, // wasAi
67    );
68
69    // Run the orchestrator
70    await orchestrateUnderstanding({
71      rootId,
72      userId,
73      username: user.username,
74      runId: run.understandingRunId,
75      source: "background",
76    });
77
78 log.debug("Understanding", `  Understanding auto-run complete for tree "${rootNode.name}"`);
79  } catch (err) {
80 log.error("Understanding", ` Understanding auto-run failed for tree "${rootNode.name}":`, err.message);
81  }
82}
83
84// ─────────────────────────────────────────────────────────────────────────
85// JOB RUN
86// ─────────────────────────────────────────────────────────────────────────
87
88export async function runUnderstandingAutoJob() {
89 log.verbose("Understanding", " Understanding auto-run job starting...");
90
91  try {
92    // Find all root nodes (trees)
93    const rootNodes = await Node.find({ rootOwner: { $nin: [null, "SYSTEM"] } })
94      .select("_id name rootOwner children llmAssignments")
95      .lean();
96
97    if (rootNodes.length === 0) {
98 log.verbose("Understanding", " No trees found — skipping.");
99      return;
100    }
101
102    // Pick the biggest tree (most children) for now
103    const biggest = rootNodes.reduce((best, node) =>
104      (node.children?.length || 0) > (best.children?.length || 0) ? node : best,
105    );
106
107 log.debug("Understanding", `  Targeting biggest tree: "${biggest.name}" (${biggest.children?.length || 0} children)`);
108    await processTree(biggest);
109  } catch (err) {
110 log.error("Understanding", " Understanding auto-run job error:", err.message);
111  }
112}
113
114// ─────────────────────────────────────────────────────────────────────────
115// START / STOP
116// ─────────────────────────────────────────────────────────────────────────
117
118export function startUnderstandingAutoJob({ intervalMs = 24 * 60 * 60 * 1000 } = {}) {
119  if (jobTimer) clearInterval(jobTimer);
120
121 log.info("Understanding", ` Understanding auto-run job started (interval: ${intervalMs / 1000}s)`);
122  jobTimer = setInterval(runUnderstandingAutoJob, intervalMs);
123  return jobTimer;
124}
125
126export function stopUnderstandingAutoJob() {
127  if (jobTimer) {
128    clearInterval(jobTimer);
129    jobTimer = null;
130 log.info("Understanding", "⏹ Understanding auto-run job stopped");
131  }
132}
133
1import UnderstandingRun from "./understandingRun.js";
2import UnderstandingNode from "./understandingNode.js";
3import { getNotes } from "../../seed/tree/notes.js";
4import { getDescendantIds } from "../../seed/tree/treeFetch.js";
5
6// Services wired from init() via setServices()
7let Node = null;
8let Contribution = null;
9let logContribution = async () => {};
10let useEnergy = async () => ({ energyUsed: 0 });
11
12export function setServices({ models, contributions }) {
13  Node = models.Node;
14  Contribution = models.Contribution;
15  if (contributions?.logContribution) logContribution = contributions.logContribution;
16}
17export function setEnergyService(energy) { useEnergy = energy.useEnergy; }
18function containsHtml(str) {
19  return /<[a-zA-Z\/][^>]*>/.test(str);
20}
21/**
22 * Creates the shadow understanding tree and computes merge rules.
23 * Runs ONCE per UnderstandingRun.
24 */
25export async function createUnderstandingRun(
26  rootNodeId,
27  userId,
28  perspective = "general",
29  wasAi = false,
30  chatId = null,
31  sessionId = null,
32) {
33  const descendantIds = await getDescendantIds(rootNodeId, { maxResults: 10000 });
34  const nodes = await Node.find({ _id: { $in: descendantIds } }).lean();
35  const nodeById = new Map(nodes.map((n) => [String(n._id), n]));
36
37  const rootNode = nodeById.get(String(rootNodeId));
38  if (!rootNode) throw new Error("Root node not found");
39  const MAX_PERSPECTIVE_LENGTH = 400; // adjust as you want
40
41  perspective = (perspective || "").trim();
42  if (containsHtml(perspective)) {
43    throw new Error("Perspective cannot contain HTML tags");
44  }
45  if (!perspective) {
46    perspective = "semantically compress while maintaining meaning";
47  }
48
49  // hard clamp
50  if (perspective.length > MAX_PERSPECTIVE_LENGTH) {
51    perspective = perspective.slice(0, MAX_PERSPECTIVE_LENGTH);
52  }
53  const run = await UnderstandingRun.create({
54    userId,
55    rootNodeId,
56    perspective,
57    nodeMap: {},
58    topology: {},
59  });
60
61  const nodeMap = new Map();
62  const topology = new Map();
63
64  const createdUNodes = new Set(); // 👈 track new ones
65
66  const rootUNodeId = await buildRunTree({
67    realNode: rootNode,
68    nodeById,
69    nodeMap,
70    topology,
71    parentUNodeId: null,
72    depth: 0,
73    createdUNodes,
74  });
75
76  const maxDepth = computeDerivedTopology(rootUNodeId, topology);
77  const { energyUsed } = await useEnergy({
78    userId,
79    action: "understanding",
80    payload: createdUNodes.size, // 2 energy per node
81  });
82  run.nodeMap = Object.fromEntries(nodeMap);
83  run.topology = Object.fromEntries(topology);
84  run.maxDepth = maxDepth;
85  await run.save();
86  await logContribution({
87    userId: userId,
88    nodeId: rootNodeId,
89    wasAi,
90    chatId,
91    sessionId,
92    energyUsed,
93
94    action: "understanding",
95    nodeVersion: "0",
96    understandingMeta: {
97      stage: "createRun",
98      understandingRunId: run._id,
99      rootNodeId,
100      nodeCount: nodeMap.size,
101      perspective,
102    },
103  });
104  return {
105    understandingRunId: run._id,
106    perspective,
107    nodeCount: nodeMap.size,
108    maxDepth,
109    realRootNode: rootNodeId,
110  };
111}
112
113/**
114 * Find an existing completed run with the same perspective, or create a new one.
115 * Used by auto-jobs and dream pipeline for incremental re-runs.
116 */
117export async function findOrCreateUnderstandingRun(
118  rootNodeId,
119  userId,
120  perspective = "general",
121  wasAi = false,
122  chatId = null,
123  sessionId = null,
124) {
125  const existing = await UnderstandingRun.findOne({
126    rootNodeId: String(rootNodeId),
127    perspective,
128    status: "completed",
129  })
130    .sort({ lastCompletedAt: -1 })
131    .lean();
132
133  if (existing) {
134    return {
135      understandingRunId: existing._id,
136      perspective: existing.perspective,
137      nodeCount: existing.nodeMap
138        ? existing.nodeMap instanceof Map
139          ? existing.nodeMap.size
140          : Object.keys(existing.nodeMap).length
141        : 0,
142      maxDepth: existing.maxDepth,
143      realRootNode: rootNodeId,
144      reused: true,
145    };
146  }
147
148  return createUnderstandingRun(
149    rootNodeId,
150    userId,
151    perspective,
152    wasAi,
153    chatId,
154    sessionId,
155  );
156}
157
158async function buildRunTree({
159  realNode,
160  nodeById,
161  nodeMap,
162  topology,
163  parentUNodeId,
164  depth,
165  createdUNodes,
166}) {
167  let uNode = await UnderstandingNode.findOne({
168    realNodeId: String(realNode._id),
169  });
170
171  let isNew = false;
172
173  if (!uNode) {
174    uNode = await UnderstandingNode.create({
175      realNodeId: String(realNode._id),
176    });
177    isNew = true;
178  }
179
180  if (isNew) {
181    createdUNodes.add(String(uNode._id));
182  }
183
184  const uNodeId = String(uNode._id);
185  nodeMap.set(String(realNode._id), uNodeId);
186
187  topology.set(uNodeId, {
188    parent: parentUNodeId,
189    children: [],
190    depthFromRoot: depth,
191    subtreeHeight: 0,
192    mergeLayer: 0,
193  });
194
195  for (const childId of realNode.children || []) {
196    const childRealNode = nodeById.get(String(childId));
197    if (!childRealNode) continue;
198
199    const childUNodeId = await buildRunTree({
200      realNode: childRealNode,
201      nodeById,
202      nodeMap,
203      topology,
204      parentUNodeId: uNodeId,
205      depth: depth + 1,
206      createdUNodes,
207    });
208
209    topology.get(uNodeId).children.push(childUNodeId);
210  }
211
212  return uNodeId;
213}
214
215function computeDerivedTopology(uNodeId, topology) {
216  const node = topology.get(uNodeId);
217
218  if (!node.children.length) {
219    node.subtreeHeight = 0;
220    node.mergeLayer = 0;
221    return node.depthFromRoot;
222  }
223
224  let maxDepth = node.depthFromRoot;
225  let maxChildHeight = 0;
226
227  for (const childId of node.children) {
228    const childDepth = computeDerivedTopology(childId, topology);
229    const child = topology.get(childId);
230    maxDepth = Math.max(maxDepth, childDepth);
231    maxChildHeight = Math.max(maxChildHeight, child.subtreeHeight);
232  }
233
234  node.subtreeHeight = maxChildHeight + 1;
235  // mergeLayer = subtreeHeight = the single layer at which this node merges
236  node.mergeLayer = node.subtreeHeight;
237
238  return maxDepth;
239}
240
241/* ================================================================
242 * Safe accessor for perspectiveStates — after .lean() Mongoose
243 * Maps may be plain objects OR Map instances depending on version.
244 * ================================================================ */
245function getPS(node, runId) {
246  const ps = node?.perspectiveStates;
247  if (!ps) return null;
248  if (ps instanceof Map) return ps.get(runId) || ps.get(String(runId));
249  return ps[runId] || ps[String(runId)] || null;
250}
251
252/* ================================================================
253 * getNextCompressionPayloadForLLM
254 *
255 * KEY FIX: Merge readiness now checks that ALL children are
256 * COMPLETE (at their own mergeLayer), not just min child layer.
257 * Each non-leaf node merges exactly ONCE, at its own mergeLayer.
258 * ================================================================ */
259export async function getNextCompressionPayloadForLLM(
260  understandingRunId,
261  userId,
262) {
263  const run = await UnderstandingRun.findById(understandingRunId).lean();
264  if (!run) throw new Error("UnderstandingRun not found");
265
266  const runId = String(run._id);
267  const perspective = run.perspective;
268  const topology = new Map(
269    Object.entries(run.topology || {}).map(([k, v]) => [String(k), v]),
270  );
271  const uNodeIds = Object.values(run.nodeMap || {}).map(String);
272
273  /* ============================================================
274   * 0) COMPLETION CHECK — is the root already fully summarized?
275   * ============================================================ */
276  const rootUNodeId = findRootUNodeId(topology);
277
278  if (rootUNodeId) {
279    const rootNode = await UnderstandingNode.findById(rootUNodeId).lean();
280    const rootTopo = topology.get(rootUNodeId);
281    const rootState = getPS(rootNode, runId);
282
283    if (
284      rootState &&
285      rootTopo &&
286      rootState.currentLayer >= rootTopo.mergeLayer
287    ) {
288      return null; // COMPLETE
289    }
290  }
291
292  /* ============================================================
293   * 0b) Retry pending merge if exists
294   * ============================================================ */
295  if (
296    run.pendingMerge &&
297    run.pendingMerge.targetNodeId &&
298    typeof run.pendingMerge.layer === "number"
299  ) {
300    const uNodeId = String(run.pendingMerge.targetNodeId);
301    const layer = run.pendingMerge.layer;
302
303    const node = await UnderstandingNode.findById(uNodeId).lean();
304    if (node) {
305      const existingState = getPS(node, runId);
306      if (existingState && existingState.currentLayer >= layer) {
307        // Already committed — clear and fall through
308        await UnderstandingRun.findByIdAndUpdate(understandingRunId, {
309          $unset: { pendingMerge: "" },
310        });
311      } else {
312        const topo = topology.get(uNodeId);
313        const realNode = await Node.findById(node.realNodeId).lean();
314
315        if (topo && realNode) {
316          const freshUNodes = await UnderstandingNode.find({
317            _id: { $in: uNodeIds },
318          }).lean();
319          const freshById = new Map(freshUNodes.map((n) => [String(n._id), n]));
320
321          return buildMergePayload({
322            understandingRunId: runId,
323            rootNodeId: run.rootNodeId,
324            perspective,
325            node,
326            realNode,
327            topo,
328            nextLayer: layer,
329            byId: freshById,
330          });
331        }
332      }
333    }
334
335    // Stale — clear
336    await UnderstandingRun.findByIdAndUpdate(understandingRunId, {
337      $unset: { pendingMerge: "" },
338    });
339  }
340
341  /* ============================================================
342   * 1) LEAF PHASE — auto-commit empty leaves, return first with content
343   * ============================================================ */
344  let autoCommittedAny = false;
345  const uNodes = await UnderstandingNode.find({
346    _id: { $in: uNodeIds },
347  }).lean();
348
349  for (const n of uNodes) {
350    const topo = topology.get(String(n._id));
351    if (!topo) continue;
352    if (topo.children.length !== 0) continue;
353
354    const state = getPS(n, runId);
355    if (state) continue;
356
357    const realNode = await Node.findById(n.realNodeId).lean();
358    if (!realNode) {
359      await autoCommitLeaf(
360        n._id,
361        runId,
362        perspective,
363        "(node deleted)",
364        userId,
365        true,
366      );
367      autoCommittedAny = true;
368      continue;
369    }
370
371    const notesResult = await getNotes({
372      nodeId: realNode._id,
373      version: realNode.prestige,
374    });
375    const notes = notesResult.notes || [];
376
377    if (notes.length === 0) {
378      await autoCommitLeaf(
379        n._id,
380        runId,
381        perspective,
382        "(no notes)",
383        userId,
384        true,
385      );
386      autoCommittedAny = true;
387      continue;
388    }
389
390    // Leaf with content — send to LLM
391    return {
392      understandingRunId: runId,
393      rootNodeId: run.rootNodeId,
394      mode: "leaf",
395      target: {
396        understandingNodeId: String(n._id),
397        realNodeId: String(n.realNodeId),
398        perspective,
399        targetLayer: 0,
400      },
401      inputs: [
402        {
403          realNodeId: realNode._id,
404          nodeName: realNode.name,
405          nodeType: realNode.type || null,
406          notes: notes.map((note) => ({
407            content: note.content,
408            username: note.username,
409            createdAt: note.createdAt,
410          })),
411        },
412      ],
413    };
414  }
415
416  /* ============================================================
417   * 2) MERGE PHASE
418   *
419   * KEY FIX: A parent is ready when ALL its children are COMPLETE
420   * (each child's currentLayer >= its own mergeLayer).
421   *
422   * The parent then merges at its OWN mergeLayer (exactly once).
423   * This is what was broken — the old code used minChildLayer+1,
424   * which got stuck when children had different merge layers.
425   * ============================================================ */
426  const mergeUNodes = autoCommittedAny
427    ? await UnderstandingNode.find({ _id: { $in: uNodeIds } }).lean()
428    : uNodes;
429  const mergeById = new Map(mergeUNodes.map((n) => [String(n._id), n]));
430
431  const readyParents = [];
432
433  for (const node of mergeUNodes) {
434    const nid = String(node._id);
435    const topo = topology.get(nid);
436    if (!topo || topo.children.length === 0) continue;
437
438    // Check: is this parent already complete?
439    const parentState = getPS(node, runId);
440    if (parentState && parentState.currentLayer >= topo.mergeLayer) continue;
441
442    // Check: are ALL children COMPLETE (at their own merge layers)?
443    let allChildrenComplete = true;
444    for (const cid of topo.children) {
445      const child = mergeById.get(String(cid));
446      const childTopo = topology.get(String(cid));
447      const childState = getPS(child, runId);
448
449      if (
450        !childState ||
451        !childTopo ||
452        childState.currentLayer < childTopo.mergeLayer
453      ) {
454        allChildrenComplete = false;
455        break;
456      }
457    }
458
459    if (!allChildrenComplete) continue;
460
461    // This parent is ready — it merges at its own mergeLayer
462    readyParents.push({
463      node,
464      nextLayer: topo.mergeLayer,
465      depthFromRoot: topo.depthFromRoot,
466    });
467  }
468
469  if (readyParents.length === 0) return null;
470
471  // Deepest first (bottom-up), then lowest layer as tiebreak
472  readyParents.sort((a, b) => {
473    if (a.nextLayer !== b.nextLayer) return a.nextLayer - b.nextLayer;
474    return b.depthFromRoot - a.depthFromRoot;
475  });
476
477  const pick = readyParents[0];
478
479  // Persist pending merge (single node)
480  await UnderstandingRun.findByIdAndUpdate(understandingRunId, {
481    $set: {
482      pendingMerge: {
483        layer: pick.nextLayer,
484        targetNodeId: String(pick.node._id),
485      },
486    },
487  });
488
489  const realNode = await Node.findById(pick.node.realNodeId).lean();
490  if (!realNode) return null;
491
492  const topo = topology.get(String(pick.node._id));
493
494  return buildMergePayload({
495    understandingRunId: runId,
496    rootNodeId: run.rootNodeId,
497    perspective,
498    node: pick.node,
499    realNode,
500    topo,
501    nextLayer: pick.nextLayer,
502    byId: mergeById,
503  });
504}
505
506/* ================================================================
507 * Helpers
508 * ================================================================ */
509
510function findRootUNodeId(topology) {
511  for (const [uid, topo] of topology) {
512    if (topo.parent === null || topo.parent === undefined) {
513      return uid;
514    }
515  }
516  return null;
517}
518
519function buildMergePayload({
520  understandingRunId,
521  rootNodeId,
522  perspective,
523  node,
524  realNode,
525  topo,
526  nextLayer,
527  byId,
528}) {
529  const childSummaries = topo.children.map((cid) => {
530    const child = byId.get(String(cid));
531    const childState = getPS(child, understandingRunId);
532    return {
533      understandingNodeId: child?._id,
534      realNodeId: child?.realNodeId,
535      summary: childState?.encoding ?? "",
536      currentLayer: childState?.currentLayer,
537    };
538  });
539
540  return {
541    understandingRunId,
542    rootNodeId,
543    mode: "merge",
544    target: {
545      perspective,
546      understandingNodeId: String(node._id),
547      nextLayer,
548    },
549    inputs: [
550      {
551        understandingNodeId: node._id,
552        realNodeId: realNode._id,
553        nodeName: realNode.name,
554        nodeType: realNode.type || null,
555        nextLayer,
556        childSummaries,
557      },
558    ],
559  };
560}
561
562async function autoCommitLeaf(
563  understandingNodeId,
564  understandingRunId,
565  perspective,
566  encoding,
567  userId,
568  wasAi,
569) {
570  const node = await UnderstandingNode.findById(understandingNodeId);
571  if (!node) return;
572
573  const existing = node.perspectiveStates?.get(understandingRunId);
574  if (existing) return;
575
576  const contribCount = await Contribution.countDocuments({
577    nodeId: node.realNodeId,
578    action: { $ne: "understanding" },
579  });
580
581  node.perspectiveStates.set(understandingRunId, {
582    understandingRunId,
583    perspective,
584    encoding,
585    currentLayer: 0,
586    updatedAt: new Date(),
587    contributionSnapshot: contribCount,
588  });
589  /*
590  await logContribution({
591    userId,
592    nodeId: node.realNodeId,
593    wasAi: true,
594    action: "understanding",
595    nodeVersion: "0",
596    understandingMeta: {
597      stage: "processStep",
598      understandingRunId,
599      understandingNodeId,
600      layer: 0,
601      mode: "leaf",
602    },
603  });*/ //dont log since these are auto-commits for empty nodes, not real contributions
604
605  await node.save();
606}
607
608/* ================================================================
609 * commitCompressionResult
610 * ================================================================ */
611export async function commitCompressionResult({
612  mode,
613  understandingRunId,
614  encoding,
615  understandingNodeId,
616  currentLayer,
617  userId,
618  wasAi = true,
619  chatId = null,
620  sessionId = null,
621}) {
622  const run = await UnderstandingRun.findById(understandingRunId).lean();
623  if (!run) throw new Error("UnderstandingRun not found");
624
625  const perspective = run.perspective;
626
627  /* ---- LEAF ---- */
628  if (mode === "leaf") {
629    if (!understandingNodeId) {
630      throw new Error("understandingNodeId required for leaf commit");
631    }
632
633    const node = await UnderstandingNode.findById(understandingNodeId);
634    if (!node) throw new Error("UnderstandingNode not found");
635
636    const existing = node.perspectiveStates?.get(understandingRunId);
637    if (existing) return; // idempotent
638
639    const contribCount = await Contribution.countDocuments({
640      nodeId: node.realNodeId,
641      action: { $ne: "understanding" },
642    });
643
644    node.perspectiveStates.set(understandingRunId, {
645      understandingRunId,
646      perspective,
647      encoding,
648      currentLayer: 0,
649      updatedAt: new Date(),
650      contributionSnapshot: contribCount,
651    });
652    const { energyUsed } = await useEnergy({
653      userId,
654      action: "understanding",
655      payload: 1,
656    });
657
658    await logContribution({
659      userId,
660      nodeId: node.realNodeId,
661      wasAi,
662      chatId,
663      sessionId,
664      energyUsed,
665      action: "understanding",
666      nodeVersion: "0",
667      understandingMeta: {
668        stage: "processStep",
669        understandingRunId,
670        understandingNodeId,
671        layer: currentLayer,
672        mode: "leaf",
673      },
674    });
675
676    await node.save();
677    return;
678  }
679
680  /* ---- MERGE ---- */
681  if (mode === "merge") {
682    const pending = run.pendingMerge;
683
684    if (!pending || typeof pending.layer !== "number") {
685      throw new Error("No pending merge found on this run");
686    }
687
688    if (pending.layer !== currentLayer) {
689      throw new Error(
690        `Layer mismatch: pending=${pending.layer}, got=${currentLayer}`,
691      );
692    }
693
694    const targetId = understandingNodeId || pending.targetNodeId;
695    if (!targetId) {
696      throw new Error("No target node for merge commit");
697    }
698
699    if (
700      understandingNodeId &&
701      pending.targetNodeId &&
702      String(understandingNodeId) !== String(pending.targetNodeId)
703    ) {
704      throw new Error(
705        `Target mismatch: pending=${pending.targetNodeId}, got=${understandingNodeId}`,
706      );
707    }
708
709    const node = await UnderstandingNode.findById(targetId);
710    if (!node) throw new Error("Target UnderstandingNode not found");
711
712    const existing = node.perspectiveStates?.get(understandingRunId);
713    if (existing && existing.currentLayer >= currentLayer) {
714      // idempotent
715    } else {
716      const contribCount = await Contribution.countDocuments({
717        nodeId: node.realNodeId,
718        action: { $ne: "understanding" },
719      });
720
721      node.perspectiveStates.set(understandingRunId, {
722        understandingRunId,
723        perspective,
724        encoding,
725        currentLayer,
726        updatedAt: new Date(),
727        contributionSnapshot: contribCount,
728      });
729      const { energyUsed } = await useEnergy({
730        userId,
731        action: "understanding",
732        payload: 1,
733      });
734
735      await logContribution({
736        userId,
737        nodeId: node.realNodeId,
738        wasAi,
739        chatId,
740        sessionId,
741        energyUsed,
742        action: "understanding",
743        nodeVersion: "0",
744        understandingMeta: {
745          stage: "processStep",
746          understandingRunId,
747          understandingNodeId,
748          layer: currentLayer,
749          mode: "merge",
750        },
751      });
752
753      await node.save();
754    }
755
756    await UnderstandingRun.findByIdAndUpdate(understandingRunId, {
757      $unset: { pendingMerge: "" },
758    });
759
760    return;
761  }
762
763  throw new Error(`Unknown commit mode: ${mode}`);
764}
765
766/* ================================================================
767 * INCREMENTAL RUN SUPPORT
768 * ================================================================ */
769
770/**
771 * Rebuild the run's topology from the current real tree.
772 * Detects added/removed/moved nodes. Returns the set of structurally dirty uNodeIds.
773 */
774async function refreshRunTopology(run) {
775  const runId = String(run._id);
776  const descendantIds = await getDescendantIds(run.rootNodeId, { maxResults: 10000 });
777  const nodes = await Node.find({ _id: { $in: descendantIds } }).lean();
778  const nodeById = new Map(nodes.map((n) => [String(n._id), n]));
779
780  const rootNode = nodeById.get(String(run.rootNodeId));
781  if (!rootNode) throw new Error("Root node not found during topology refresh");
782
783  const newNodeMap = new Map();
784  const newTopology = new Map();
785  const createdUNodes = new Set();
786
787  await buildRunTree({
788    realNode: rootNode,
789    nodeById,
790    nodeMap: newNodeMap,
791    topology: newTopology,
792    parentUNodeId: null,
793    depth: 0,
794    createdUNodes,
795  });
796
797  const rootUNodeId = newNodeMap.get(String(rootNode._id));
798  computeDerivedTopology(rootUNodeId, newTopology);
799
800  // Build old topology map for comparison
801  const oldTopology = new Map(
802    Object.entries(
803      run.topology instanceof Map
804        ? Object.fromEntries(run.topology)
805        : run.topology || {},
806    ).map(([k, v]) => [String(k), v]),
807  );
808  const oldNodeMap = new Map(
809    Object.entries(
810      run.nodeMap instanceof Map
811        ? Object.fromEntries(run.nodeMap)
812        : run.nodeMap || {},
813    ).map(([k, v]) => [String(k), String(v)]),
814  );
815
816  const structurallyDirty = new Set();
817
818  // Detect removed nodes — clear their perspectiveState for this run
819  for (const [, oldUNodeId] of oldNodeMap) {
820    if (!newTopology.has(oldUNodeId)) {
821      const uNode = await UnderstandingNode.findById(oldUNodeId);
822      if (uNode && uNode.perspectiveStates?.has(runId)) {
823        uNode.perspectiveStates.delete(runId);
824        await uNode.save();
825      }
826    }
827  }
828
829  // Detect moved nodes (parent changed)
830  for (const [uNodeId, newTopo] of newTopology) {
831    const oldTopo = oldTopology.get(uNodeId);
832    if (
833      oldTopo &&
834      String(oldTopo.parent || "") !== String(newTopo.parent || "")
835    ) {
836      structurallyDirty.add(uNodeId);
837    }
838  }
839
840  // Save updated topology
841  await UnderstandingRun.findByIdAndUpdate(runId, {
842    nodeMap: Object.fromEntries(newNodeMap),
843    topology: Object.fromEntries(newTopology),
844    maxDepth: computeDerivedTopology(rootUNodeId, newTopology),
845  });
846
847  return { newTopology, structurallyDirty };
848}
849
850/**
851 * Detect dirty nodes by comparing contribution counts, then propagate
852 * dirtiness upward and clear stale perspectiveStates.
853 */
854async function markDirtyNodes(run, topology, structurallyDirty = new Set()) {
855  const runId = String(run._id);
856
857  // Get all realNodeIds in this run
858  const nodeMapEntries = Object.entries(
859    run.nodeMap instanceof Map
860      ? Object.fromEntries(run.nodeMap)
861      : run.nodeMap || {},
862  );
863  const realNodeIds = nodeMapEntries.map(([realId]) => realId);
864  const realToUNode = new Map(
865    nodeMapEntries.map(([realId, uId]) => [realId, String(uId)]),
866  );
867
868  // Batch query current contribution counts (excluding understanding contributions)
869  const contribCounts = await Contribution.aggregate([
870    {
871      $match: {
872        nodeId: { $in: realNodeIds },
873        action: { $ne: "understanding" },
874      },
875    },
876    { $group: { _id: "$nodeId", count: { $sum: 1 } } },
877  ]);
878  const countMap = new Map(contribCounts.map((c) => [c._id, c.count]));
879
880  // Load all understanding nodes for this run
881  const uNodeIds = [...new Set(nodeMapEntries.map(([, uId]) => String(uId)))];
882  const uNodes = await UnderstandingNode.find({ _id: { $in: uNodeIds } });
883  const uNodeById = new Map(uNodes.map((n) => [String(n._id), n]));
884
885  const dirtySet = new Set(structurallyDirty);
886
887  // Check each node for content changes
888  for (const [realId, uId] of realToUNode) {
889    const uNode = uNodeById.get(uId);
890    if (!uNode) continue;
891
892    const state = uNode.perspectiveStates?.get(runId);
893    if (!state) {
894      // No perspectiveState = new node or never processed — already dirty
895      dirtySet.add(uId);
896      continue;
897    }
898
899    const currentCount = countMap.get(realId) || 0;
900    const storedCount = state.contributionSnapshot;
901
902    if (
903      storedCount === null ||
904      storedCount === undefined ||
905      storedCount !== currentCount
906    ) {
907      dirtySet.add(uId);
908    }
909  }
910
911  // Propagate dirtiness upward to root
912  const propagated = new Set(dirtySet);
913  for (const uId of dirtySet) {
914    let current = uId;
915    while (current) {
916      const topo = topology.get(current);
917      if (!topo || !topo.parent) break;
918      const parentId = String(topo.parent);
919      if (propagated.has(parentId)) break; // already propagated
920      propagated.add(parentId);
921      current = parentId;
922    }
923  }
924
925  // Clear perspectiveStates for all dirty nodes (so compression loop picks them up)
926  const bulkOps = [];
927  for (const uId of propagated) {
928    const uNode = uNodeById.get(uId);
929    if (!uNode) continue;
930
931    if (uNode.perspectiveStates?.has(runId)) {
932      uNode.perspectiveStates.delete(runId);
933      bulkOps.push(uNode.save());
934    }
935  }
936  if (bulkOps.length > 0) await Promise.all(bulkOps);
937
938  // Clear pendingMerge if it's in the dirty set
939  if (
940    run.pendingMerge?.targetNodeId &&
941    propagated.has(String(run.pendingMerge.targetNodeId))
942  ) {
943    await UnderstandingRun.findByIdAndUpdate(runId, {
944      $unset: { pendingMerge: "" },
945    });
946  }
947
948  return { dirtyCount: propagated.size, totalNodes: uNodeIds.length };
949}
950
951/**
952 * Prepare an existing run for incremental re-processing.
953 * Rebuilds topology from current tree and marks dirty nodes.
954 */
955export async function prepareIncrementalRun(understandingRunId, userId) {
956  const run = await UnderstandingRun.findById(understandingRunId).lean();
957  if (!run) throw new Error("UnderstandingRun not found");
958
959  const { newTopology, structurallyDirty } = await refreshRunTopology(run);
960  const { dirtyCount, totalNodes } = await markDirtyNodes(
961    run,
962    newTopology,
963    structurallyDirty,
964  );
965
966  return { dirtyCount, totalNodes };
967}
968
969/**
970 * Fetch all understanding runs for a given root node.
971 */
972export async function listUnderstandingRuns(rootNodeId) {
973  const root = await Node.findById(rootNodeId).select("_id name userId").lean();
974
975  if (!root) throw new Error("Root not found");
976
977  const runs = await UnderstandingRun.find({ rootNodeId })
978    .sort({ createdAt: -1 })
979    .lean();
980
981  return {
982    rootNodeId: root._id,
983    rootName: root.name,
984    understandings: runs.map((r) => ({
985      understandingRunId: r._id,
986      perspective: r.perspective,
987      createdAt: r.createdAt,
988    })),
989  };
990}
991
1/* ─────────────────────────────────────────────── */
2/* HTML renderers for understanding pages           */
3/* ─────────────────────────────────────────────── */
4
5import { page } from "../html-rendering/html/layout.js";
6import { esc, rainbow } from "../html-rendering/html/utils.js";
7
8/* =========================================================
9   1. renderUnderstandingRun
10   GET /root/:nodeId/understandings/run/:runId
11   ========================================================= */
12export function renderUnderstandingRun({
13  run,
14  qs,
15  nodes,
16  completed,
17  dirtyNodes,
18  dirtyCount,
19  totalNodes,
20  completedCount,
21  progressPercent,
22  rootFinalEncoding,
23  previousFinalEncoding,
24  rootIsCompleted,
25  tree,
26  createdDate,
27  renderTreeFn,
28}) {
29  const renderTree = (node, depth = 0) => {
30    if (!node) return "";
31
32    const isCompleted = completed[node._id];
33    const isDirty = dirtyNodes[node._id];
34    const encoding = node.encoding || "";
35    const layer = node.layer ?? "-";
36    const isLeaf = node.childCount === 0;
37    const statusEmoji = isDirty ? "🔄" : isCompleted ? "✅" : "⏳";
38    const typeLabel = isLeaf ? "Leaf" : `${node.childCount} children`;
39    const dirtyLabel = isDirty ? " · <span style=\"color:#ffcc00;\">changed</span>" : "";
40
41    const encodingPreview = encoding
42      ? esc(
43          encoding.length > 120 ? encoding.slice(0, 120) + "…" : encoding,
44        )
45      : "";
46
47    let html = `
48          <div class="tree-item" style="margin-left: ${depth * 20}px; animation-delay: ${0.05 * depth}s;">
49            <div class="tree-pane ${isCompleted ? "complete" : "pending"}" onclick="togglePane('${node._id}')">
50              <div class="pane-header">
51                <div class="pane-left">
52                  <span class="pane-status">${statusEmoji}</span>
53                  <div class="pane-title-group">
54<span class="pane-name">${esc(node.name)}</span>
55                    <span class="pane-meta">${typeLabel} · Layer ${layer}/${node.mergeLayer}${dirtyLabel}</span>
56                  </div>
57                </div>
58                <span class="pane-chevron" id="chev-${node._id}">▸</span>
59              </div>
60              ${encodingPreview ? `<div class="pane-preview">${encodingPreview}</div>` : ""}
61            </div>
62
63            <div class="pane-body" id="body-${node._id}">
64              <div class="pane-detail-grid">
65                <a href="/api/v1/node/${node.realNodeId}${qs}" class="pane-id-link" onclick="event.stopPropagation();">
66                  📄 View Node
67                </a>
68                <a href="/api/v1/root/${run.rootNodeId}/understandings/${node._id}${qs}" class="pane-id-link" onclick="event.stopPropagation();">
69                  🧠 Understanding
70                </a>
71              </div>
72              ${
73                encoding
74                  ? `
75              <div class="pane-encoding">
76                <div class="pane-encoding-label">Encoding</div>
77<pre>${esc(encoding)}</pre>
78              </div>
79              `
80                  : `<div class="pane-encoding-label" style="margin-top:8px;">No encoding yet</div>`
81              }
82            </div>
83          </div>
84        `;
85
86    for (const child of node.childNodes || []) {
87      html += renderTree(child, depth + 1);
88    }
89
90    return html;
91  };
92
93  const css = `
94        /* =========================================================
95       GLASS BUTTONS
96       ========================================================= */
97    .glass-btn,
98    .back-link {
99      position: relative;
100      overflow: hidden;
101      padding: 10px 20px;
102      border-radius: 980px;
103      display: inline-flex;
104      align-items: center;
105      justify-content: center;
106      white-space: nowrap;
107      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
108      backdrop-filter: blur(22px) saturate(140%);
109      -webkit-backdrop-filter: blur(22px) saturate(140%);
110      color: white;
111      text-decoration: none;
112      font-family: inherit;
113      font-size: 15px;
114      font-weight: 600;
115      letter-spacing: -0.2px;
116      border: 1px solid rgba(255, 255, 255, 0.28);
117      box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
118        inset 0 1px 0 rgba(255, 255, 255, 0.25);
119      cursor: pointer;
120      transition: background 0.3s cubic-bezier(0.4, 0, 0.2, 1),
121        transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
122        box-shadow 0.3s ease;
123    }
124
125    .glass-btn::before,
126    .back-link::before {
127      content: "";
128      position: absolute;
129      inset: -40%;
130      background:
131        radial-gradient(120% 60% at 0% 0%, rgba(255, 255, 255, 0.35), transparent 60%),
132        linear-gradient(120deg, transparent 30%, rgba(255, 255, 255, 0.25), transparent 70%);
133      opacity: 0;
134      transform: translateX(-30%) translateY(-10%);
135      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
136      pointer-events: none;
137    }
138
139    .glass-btn:hover,
140    .back-link:hover {
141      background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
142      transform: translateY(-2px);
143    }
144
145    .glass-btn:hover::before,
146    .back-link:hover::before {
147      opacity: 1;
148      transform: translateX(30%) translateY(10%);
149    }
150
151    .glass-btn:active,
152    .back-link:active {
153      background: rgba(var(--glass-water-rgb), 0.45);
154      transform: translateY(0);
155    }
156
157    /* =========================================================
158       GLASS CARDS
159       ========================================================= */
160    .glass-card {
161      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
162      backdrop-filter: blur(22px) saturate(140%);
163      -webkit-backdrop-filter: blur(22px) saturate(140%);
164      border-radius: 16px;
165      padding: 28px;
166      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
167        inset 0 1px 0 rgba(255, 255, 255, 0.25);
168      border: 1px solid rgba(255, 255, 255, 0.28);
169      margin-bottom: 24px;
170      animation: fadeInUp 0.6s ease-out both;
171      position: relative;
172      overflow: hidden;
173    }
174
175    .glass-card::before {
176      content: "";
177      position: absolute;
178      inset: 0;
179      border-radius: inherit;
180      background: linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.05));
181      pointer-events: none;
182    }
183
184    .glass-card h1 {
185      font-size: 28px;
186      font-weight: 600;
187      letter-spacing: -0.5px;
188      line-height: 1.3;
189      margin-bottom: 8px;
190      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
191      color: white;
192    }
193
194    .glass-card h2 {
195      font-size: 18px;
196      font-weight: 600;
197      color: white;
198      margin-bottom: 16px;
199      letter-spacing: -0.3px;
200      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
201    }
202
203    /* =========================================================
204       NAV
205       ========================================================= */
206    .back-nav {
207      display: flex;
208      gap: 12px;
209      margin-bottom: 20px;
210      flex-wrap: wrap;
211      animation: fadeInUp 0.5s ease-out;
212    }
213
214    /* =========================================================
215       META
216       ========================================================= */
217    .run-meta {
218      display: grid;
219      grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
220      gap: 12px;
221      margin-top: 16px;
222    }
223
224    .meta-item {
225      display: flex;
226      flex-direction: column;
227      gap: 4px;
228    }
229
230    .meta-label {
231      font-size: 12px;
232      font-weight: 600;
233      text-transform: uppercase;
234      letter-spacing: 0.5px;
235      color: rgba(255, 255, 255, 0.7);
236    }
237
238    .meta-value {
239      font-size: 16px;
240      font-weight: 600;
241      color: white;
242    }
243
244    .id-chip {
245      display: inline-flex;
246      align-items: center;
247      gap: 8px;
248      padding: 6px 12px;
249      background: rgba(255, 255, 255, 0.15);
250      border: 1px solid rgba(255, 255, 255, 0.2);
251      border-radius: 8px;
252      cursor: pointer;
253      transition: all 0.2s;
254    }
255
256    .id-chip:hover {
257      background: rgba(255, 255, 255, 0.25);
258    }
259
260    .id-chip code {
261      font-size: 12px;
262      font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
263      color: white;
264      background: transparent;
265    }
266
267    .id-chip .copy-icon {
268      font-size: 14px;
269      opacity: 0.7;
270      transition: opacity 0.2s;
271    }
272
273    .id-chip:hover .copy-icon { opacity: 1; }
274
275    /* =========================================================
276       PERSPECTIVE
277       ========================================================= */
278    .perspective-text {
279      font-size: 16px;
280      font-weight: 600;
281      color: white;
282      font-style: italic;
283      line-height: 1.5;
284    }
285
286    /* =========================================================
287       PROGRESS
288       ========================================================= */
289    .progress-row {
290      display: flex;
291      justify-content: space-between;
292      align-items: center;
293      margin-bottom: 16px;
294    }
295
296    .progress-label {
297      font-size: 16px;
298      font-weight: 600;
299      color: white;
300      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
301    }
302
303    .progress-pct {
304      font-size: 24px;
305      font-weight: 700;
306      color: white;
307      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
308    }
309
310    .progress-track {
311      height: 14px;
312      background: rgba(0, 0, 0, 0.2);
313      border-radius: 7px;
314      overflow: hidden;
315      border: 1px solid rgba(255, 255, 255, 0.15);
316    }
317
318    .progress-fill {
319      height: 100%;
320      background: rgba(255, 255, 255, 0.6);
321      border-radius: 7px;
322      transition: width 0.6s ease;
323      box-shadow: 0 0 12px rgba(255, 255, 255, 0.3);
324    }
325
326    .progress-sub {
327      margin-top: 10px;
328      text-align: center;
329      font-size: 13px;
330      color: rgba(255, 255, 255, 0.7);
331    }
332
333    /* =========================================================
334       FINAL ENCODING
335       ========================================================= */
336    .final-card {
337      border-left: 5px solid rgba(72, 187, 120, 0.8);
338    }
339
340    .final-card pre {
341      background: rgba(0, 0, 0, 0.25);
342      color: rgba(255, 255, 255, 0.9);
343      padding: 16px;
344      border-radius: 10px;
345      font-size: 13px;
346      line-height: 1.7;
347      font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
348      white-space: pre-wrap;
349      word-break: break-word;
350      border: 1px solid rgba(255, 255, 255, 0.1);
351      margin-top: 10px;
352    }
353
354    /* =========================================================
355       TREE PANES
356       ========================================================= */
357    .tree-item {
358      margin-bottom: 6px;
359      animation: fadeInUp 0.4s ease-out both;
360    }
361
362    .tree-pane {
363      background: rgba(var(--glass-water-rgb), 0.22);
364      backdrop-filter: blur(18px) saturate(130%);
365      -webkit-backdrop-filter: blur(18px) saturate(130%);
366      border: 1px solid rgba(255, 255, 255, 0.22);
367      border-radius: 12px;
368      padding: 14px 18px;
369      cursor: pointer;
370      transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
371      position: relative;
372      overflow: hidden;
373    }
374
375    .tree-pane::before {
376      content: "";
377      position: absolute;
378      inset: 0;
379      border-radius: inherit;
380      background: linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.03));
381      pointer-events: none;
382    }
383
384    .tree-pane:hover {
385      background: rgba(var(--glass-water-rgb), 0.32);
386      transform: translateX(4px);
387      box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
388    }
389
390    .tree-pane.complete {
391      border-left: 3px solid rgba(72, 187, 120, 0.7);
392    }
393
394    .tree-pane.pending {
395      border-left: 3px solid rgba(255, 255, 255, 0.25);
396    }
397
398    .pane-header {
399      display: flex;
400      align-items: center;
401      justify-content: space-between;
402      gap: 12px;
403    }
404
405    .pane-left {
406      display: flex;
407      align-items: center;
408      gap: 12px;
409      flex: 1;
410      min-width: 0;
411    }
412
413    .pane-status {
414      font-size: 18px;
415      flex-shrink: 0;
416    }
417
418    .pane-title-group {
419      display: flex;
420      flex-direction: column;
421      gap: 2px;
422      min-width: 0;
423    }
424
425    .pane-name {
426      font-size: 15px;
427      font-weight: 600;
428      color: white;
429      letter-spacing: -0.2px;
430      white-space: nowrap;
431      overflow: hidden;
432      text-overflow: ellipsis;
433    }
434
435    .pane-meta {
436      font-size: 12px;
437      color: rgba(255, 255, 255, 0.6);
438      font-weight: 500;
439    }
440
441    .pane-chevron {
442      color: rgba(255, 255, 255, 0.5);
443      font-size: 14px;
444      transition: transform 0.25s ease;
445      flex-shrink: 0;
446    }
447
448    .pane-chevron.open {
449      transform: rotate(90deg);
450    }
451
452    .pane-preview {
453      margin-top: 8px;
454      font-size: 13px;
455      color: rgba(255, 255, 255, 0.55);
456      line-height: 1.5;
457      font-style: italic;
458    }
459
460    /* Expanded body */
461    .pane-body {
462      display: none;
463      margin-top: 8px;
464      padding: 16px 18px;
465      background: rgba(var(--glass-water-rgb), 0.18);
466      backdrop-filter: blur(14px) saturate(120%);
467      -webkit-backdrop-filter: blur(14px) saturate(120%);
468      border: 1px solid rgba(255, 255, 255, 0.18);
469      border-radius: 10px;
470      animation: fadeInUp 0.25s ease-out;
471    }
472
473    .pane-body.open { display: block; }
474
475    .pane-detail-grid {
476      display: flex;
477      gap: 10px;
478      flex-wrap: wrap;
479      margin-bottom: 12px;
480    }
481
482    .pane-id-link {
483      display: inline-flex;
484      align-items: center;
485      gap: 6px;
486      padding: 8px 14px;
487      background: rgba(255, 255, 255, 0.12);
488      border: 1px solid rgba(255, 255, 255, 0.2);
489      border-radius: 8px;
490      color: white;
491      text-decoration: none;
492      font-size: 13px;
493      font-weight: 600;
494      transition: all 0.2s;
495    }
496
497    .pane-id-link:hover {
498      background: rgba(255, 255, 255, 0.22);
499      transform: translateY(-1px);
500    }
501
502    .pane-encoding-label {
503      font-size: 12px;
504      font-weight: 600;
505      text-transform: uppercase;
506      letter-spacing: 0.5px;
507      color: rgba(255, 255, 255, 0.6);
508    }
509
510    .pane-encoding pre {
511      background: rgba(0, 0, 0, 0.25);
512      color: rgba(255, 255, 255, 0.85);
513      padding: 14px;
514      border-radius: 8px;
515      font-size: 13px;
516      line-height: 1.6;
517      font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
518      white-space: pre-wrap;
519      word-break: break-word;
520      border: 1px solid rgba(255, 255, 255, 0.08);
521      margin-top: 8px;
522    }
523
524    /* =========================================================
525       RESPONSIVE
526       ========================================================= */
527    @media (max-width: 640px) {
528      body { padding: 16px; }
529      .container { max-width: 100%; }
530      .glass-card { padding: 20px; }
531      .glass-card h1 { font-size: 24px; }
532      .run-meta { grid-template-columns: 1fr; }
533      .back-nav { flex-direction: column; }
534      .back-link { width: 100%; justify-content: center; }
535      .pane-detail-grid { flex-direction: column; }
536      .pane-id-link { width: 100%; justify-content: center; }
537      .pane-name { font-size: 14px; }
538    }
539
540    .process-btn {
541      margin-top: 14px;
542      padding: 10px 24px;
543      border-radius: 980px;
544      border: 1px solid rgba(72, 187, 178, 0.4);
545      background: rgba(72, 187, 178, 0.25);
546      color: white;
547      font-weight: 600;
548      font-size: 14px;
549      font-family: inherit;
550      cursor: pointer;
551      transition: all 0.3s;
552      width: 100%;
553    }
554    .process-btn:hover:not(:disabled) {
555      background: rgba(72, 187, 178, 0.4);
556      transform: translateY(-1px);
557    }
558    .process-btn:disabled {
559      opacity: 0.6;
560      cursor: not-allowed;
561    }
562    .process-status {
563      margin-top: 8px;
564      font-size: 13px;
565      font-weight: 500;
566      display: none;
567    }`;
568
569  const body = `
570  <div class="container">
571
572    <div class="back-nav">
573      <a href="/api/v1/root/${run.rootNodeId}${qs}" class="back-link">← Back to Tree</a>
574      <a href="/api/v1/root/${run.rootNodeId}/understandings${qs}" class="back-link">🧠 All Runs</a>
575    </div>
576
577    <!-- Header -->
578    <div class="glass-card" style="animation-delay: 0.1s;">
579      <h1>🧠 Understanding Run</h1>
580      <div class="run-meta">
581        <div class="meta-item">
582          <div class="meta-label">Run ID</div>
583          <div class="meta-value">
584            <div class="id-chip" onclick="copyText('${run._id}', this)">
585              <code>${run._id}</code>
586              <span class="copy-icon">📋</span>
587            </div>
588          </div>
589        </div>
590        <div class="meta-item">
591          <div class="meta-label">Depth</div>
592          <div class="meta-value">${run.maxDepth}</div>
593        </div>
594        <div class="meta-item">
595          <div class="meta-label">Nodes</div>
596          <div class="meta-value">${totalNodes}</div>
597        </div>
598        <div class="meta-item">
599          <div class="meta-label">Created</div>
600          <div class="meta-value">${createdDate}</div>
601        </div>
602        <div class="meta-item">
603          <div class="meta-label">Status</div>
604          <div class="meta-value">${run.status === "running" ? "🔄 Running" : dirtyCount > 0 ? "🔄 Refresh" : rootFinalEncoding ? "✅ Completed" : "🆕 New"}</div>
605        </div>
606        ${run.lastCompletedAt ? `
607        <div class="meta-item">
608          <div class="meta-label">Last Completed</div>
609          <div class="meta-value">${new Date(run.lastCompletedAt).toLocaleString()}</div>
610        </div>
611        ` : ""}
612        ${(run.encodingHistory?.length || 0) > 0 ? `
613        <div class="meta-item">
614          <div class="meta-label">Runs</div>
615          <div class="meta-value">${run.encodingHistory.length}</div>
616        </div>
617        ` : ""}
618      </div>
619    </div>
620
621    <!-- Perspective -->
622    <div class="glass-card" style="animation-delay: 0.15s; padding: 20px 28px;">
623      <div class="meta-label" style="margin-bottom: 8px;">Perspective</div>
624<div class="perspective-text">${esc(run.perspective)}</div>
625    </div>
626
627    ${
628      rootFinalEncoding
629        ? `
630    <!-- Final Understanding -->
631    <div class="glass-card final-card" style="animation-delay: 0.2s;">
632      <div class="meta-label" style="margin-bottom: 4px;">${dirtyCount > 0 ? "🔄 Previous Understanding (needs refresh)" : "✅ Final Understanding"}</div>
633<pre>${esc(rootFinalEncoding)}</pre>
634    </div>
635    `
636        : previousFinalEncoding
637          ? `
638    <!-- Previous Understanding from history -->
639    <div class="glass-card final-card" style="animation-delay: 0.2s;">
640      <div class="meta-label" style="margin-bottom: 4px;">📜 Previous Understanding</div>
641<pre>${esc(previousFinalEncoding)}</pre>
642    </div>
643    `
644          : ""
645    }
646
647    <!-- Progress -->
648    <div class="glass-card" style="animation-delay: 0.25s;">
649      <div class="progress-row">
650        <div class="progress-label">Progress</div>
651        <div class="progress-pct">${progressPercent}%</div>
652      </div>
653      <div class="progress-track">
654        <div class="progress-fill" style="width: ${progressPercent}%;"></div>
655      </div>
656      <div class="progress-sub">${completedCount} of ${totalNodes} nodes compressed${dirtyCount > 0 ? ` · <span style="color:#ffcc00;">${dirtyCount} changed</span>` : ""}</div>
657      ${
658        !rootIsCompleted || dirtyCount > 0 || run.status === "running"
659          ? `
660      <div style="display: flex; gap: 10px; flex-wrap: wrap;">
661        <button id="processBtn" class="process-btn" onclick="startProcess()"${run.status === "running" ? " disabled" : ""}>
662          <span id="processBtnLabel">${run.status === "running" ? "⏳ Processing…" : dirtyCount > 0 && rootIsCompleted ? `🔄 Reprocess (${dirtyCount} changed)` : "🧠 Process"}</span>
663        </button>
664        <button id="stopBtn" class="process-btn" style="background: rgba(239,68,68,0.35); ${run.status !== "running" ? "display:none;" : ""}" onclick="stopProcess()">
665          <span id="stopBtnLabel">⏹ Stop</span>
666        </button>
667      </div>
668      <div id="processStatus" class="process-status"></div>
669      `
670          : ""
671      }
672    </div>
673
674    <!-- Tree -->
675    <div class="glass-card" style="animation-delay: 0.3s;">
676      <h2>Compression Tree</h2>
677      ${tree ? renderTree(tree) : "<p style='color: rgba(255,255,255,0.6);'>No tree available</p>"}
678    </div>
679
680    ${
681      (run.encodingHistory?.length || 0) > 1 || (run.encodingHistory?.length === 1 && rootFinalEncoding)
682        ? `
683    <!-- Encoding History -->
684    <div class="glass-card" style="animation-delay: 0.35s;">
685      <h2>Previous Understandings</h2>
686      ${run.encodingHistory
687        .slice()
688        .reverse()
689        .filter((e, i) => !(i === 0 && rootFinalEncoding && e.encoding === rootFinalEncoding))
690        .map((e, i) => `
691        <div class="tree-pane complete" style="margin-bottom: 8px; cursor: pointer;" onclick="togglePane('hist-${i}')">
692          <div class="pane-header">
693            <div class="pane-left">
694              <span class="pane-status">📜</span>
695              <div class="pane-title-group">
696                <span class="pane-name">${new Date(e.completedAt).toLocaleString()}</span>
697                <span class="pane-meta">${e.encoding ? e.encoding.length + ' chars' : 'empty'}</span>
698              </div>
699            </div>
700            <span class="pane-chevron" id="chev-hist-${i}">▸</span>
701          </div>
702        </div>
703        <div class="pane-body" id="body-hist-${i}">
704          <div class="pane-encoding">
705<pre>${esc(e.encoding || '(empty)')}</pre>
706          </div>
707        </div>
708      `).join('')}
709    </div>
710    `
711        : ""
712    }
713
714  </div>
715`;
716
717  const js = `
718        function togglePane(id) {
719      const body = document.getElementById('body-' + id);
720      const chev = document.getElementById('chev-' + id);
721      if (!body) return;
722      body.classList.toggle('open');
723      chev?.classList.toggle('open');
724    }
725
726    function copyText(text, el) {
727      navigator.clipboard.writeText(text).then(() => {
728        const icon = el.querySelector('.copy-icon');
729        if (icon) {
730          icon.textContent = '✔️';
731          setTimeout(() => icon.textContent = '📋', 900);
732        }
733      });
734    }
735
736    async function startProcess() {
737      var btn = document.getElementById('processBtn');
738      var label = document.getElementById('processBtnLabel');
739      var stopBtn = document.getElementById('stopBtn');
740      var status = document.getElementById('processStatus');
741      if (!btn) return;
742
743      btn.disabled = true;
744      label.textContent = '⏳ Processing…';
745      if (stopBtn) stopBtn.style.display = '';
746      status.style.display = 'block';
747      status.style.color = 'rgba(255,255,255,0.6)';
748      status.textContent = 'Running understanding orchestrator — this may take a while…';
749
750      try {
751        var res = await fetch('/api/v1/root/${run.rootNodeId}/understandings/run/${run._id}/orchestrate', {
752          method: 'POST',
753          headers: { 'Content-Type': 'application/json' },
754          body: JSON.stringify({ source: 'user' }),
755        });
756        var data = await res.json();
757        var result = data.data || data;
758        if (data.status === 'ok' && result.success) {
759          status.style.color = 'rgba(72, 187, 120, 0.9)';
760          status.textContent = result.alreadyComplete
761            ? '✓ Already complete'
762            : '✓ Done — ' + (result.nodesProcessed || 0) + ' nodes processed';
763          label.textContent = '✅ Complete';
764          if (stopBtn) stopBtn.style.display = 'none';
765          setTimeout(function() { location.reload(); }, 1500);
766        } else {
767          status.style.color = 'rgba(255, 107, 107, 0.9)';
768          status.textContent = '✕ ' + ((data.error && data.error.message) || data.error || 'Failed');
769          label.textContent = '🧠 Retry';
770          btn.disabled = false;
771          if (stopBtn) stopBtn.style.display = 'none';
772        }
773      } catch (err) {
774        status.style.color = 'rgba(255, 107, 107, 0.9)';
775        status.textContent = '✕ Network error';
776        label.textContent = '🧠 Retry';
777        btn.disabled = false;
778        if (stopBtn) stopBtn.style.display = 'none';
779        if (refreshBtn) refreshBtn.disabled = false;
780      }
781    }
782
783    async function stopProcess() {
784      var btn = document.getElementById('stopBtn');
785      var label = document.getElementById('stopBtnLabel');
786      var status = document.getElementById('processStatus');
787      if (!btn) return;
788
789      btn.disabled = true;
790      label.textContent = '⏹ Stopping…';
791
792      try {
793        var res = await fetch('/api/v1/root/${run.rootNodeId}/understandings/run/${run._id}/stop', {
794          method: 'POST',
795          headers: { 'Content-Type': 'application/json' },
796        });
797        var data = await res.json();
798        if (data.status === 'ok') {
799          status.style.display = 'block';
800          status.style.color = 'rgba(255, 180, 50, 0.9)';
801          status.textContent = 'Stopped';
802          setTimeout(function() { location.reload(); }, 1000);
803        } else {
804          status.style.display = 'block';
805          status.style.color = 'rgba(255, 107, 107, 0.9)';
806          status.textContent = (data.error && data.error.message) || data.error || 'Could not stop';
807          label.textContent = '⏹ Stop';
808          btn.disabled = false;
809        }
810      } catch (err) {
811        status.style.display = 'block';
812        status.style.color = 'rgba(255, 107, 107, 0.9)';
813        status.textContent = 'Network error';
814        label.textContent = '⏹ Stop';
815        btn.disabled = false;
816      }
817    }`;
818
819  return page({ title: `Understanding · ${esc(run.perspective.slice(0, 40))}`, css, body, js });
820}
821
822
823/* =========================================================
824   2. renderUnderstandingNode
825   GET /root/:nodeId/understandings/:understandingNodeId
826   ========================================================= */
827export function renderUnderstandingNode({
828  data,
829  nodeId,
830  qs,
831  encodingHistory,
832  hasEncodings,
833  backTreeUrl,
834  backUnderstandingsUrl,
835  realNodeUrl,
836}) {
837  const css = `
838        /* =========================================================
839       GLASS BUTTONS
840       ========================================================= */
841    .back-link {
842      position: relative;
843      overflow: hidden;
844      padding: 10px 20px;
845      border-radius: 980px;
846      display: inline-flex;
847      align-items: center;
848      justify-content: center;
849      white-space: nowrap;
850      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
851      backdrop-filter: blur(22px) saturate(140%);
852      -webkit-backdrop-filter: blur(22px) saturate(140%);
853      color: white;
854      text-decoration: none;
855      font-family: inherit;
856      font-size: 15px;
857      font-weight: 600;
858      letter-spacing: -0.2px;
859      border: 1px solid rgba(255, 255, 255, 0.28);
860      box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
861        inset 0 1px 0 rgba(255, 255, 255, 0.25);
862      cursor: pointer;
863      transition: background 0.3s cubic-bezier(0.4, 0, 0.2, 1),
864        transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
865        box-shadow 0.3s ease;
866    }
867
868    .back-link::before {
869      content: "";
870      position: absolute;
871      inset: -40%;
872      background:
873        radial-gradient(120% 60% at 0% 0%, rgba(255, 255, 255, 0.35), transparent 60%),
874        linear-gradient(120deg, transparent 30%, rgba(255, 255, 255, 0.25), transparent 70%);
875      opacity: 0;
876      transform: translateX(-30%) translateY(-10%);
877      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
878      pointer-events: none;
879    }
880
881    .back-link:hover {
882      background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
883      transform: translateY(-2px);
884    }
885
886    .back-link:hover::before {
887      opacity: 1;
888      transform: translateX(30%) translateY(10%);
889    }
890
891    .back-link:active {
892      background: rgba(var(--glass-water-rgb), 0.45);
893      transform: translateY(0);
894    }
895
896    /* =========================================================
897       GLASS CARDS
898       ========================================================= */
899    .glass-card {
900      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
901      backdrop-filter: blur(22px) saturate(140%);
902      -webkit-backdrop-filter: blur(22px) saturate(140%);
903      border-radius: 16px;
904      padding: 28px;
905      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
906        inset 0 1px 0 rgba(255, 255, 255, 0.25);
907      border: 1px solid rgba(255, 255, 255, 0.28);
908      margin-bottom: 24px;
909      animation: fadeInUp 0.6s ease-out both;
910      position: relative;
911      overflow: hidden;
912    }
913
914    .glass-card::before {
915      content: "";
916      position: absolute;
917      inset: 0;
918      border-radius: inherit;
919      background: linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.05));
920      pointer-events: none;
921    }
922
923    .glass-card h1 {
924      font-size: 28px;
925      font-weight: 600;
926      letter-spacing: -0.5px;
927      line-height: 1.3;
928      margin-bottom: 12px;
929      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
930      color: white;
931    }
932
933    .glass-card h1 a {
934      color: white;
935      text-decoration: none;
936      transition: opacity 0.2s;
937    }
938
939    .glass-card h1 a:hover { opacity: 0.8; }
940
941    .glass-card h2 {
942      font-size: 18px;
943      font-weight: 600;
944      color: white;
945      margin-bottom: 16px;
946      letter-spacing: -0.3px;
947      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
948      display: flex;
949      align-items: center;
950      gap: 10px;
951    }
952
953    /* =========================================================
954       NAV
955       ========================================================= */
956    .back-nav {
957      display: flex;
958      gap: 12px;
959      margin-bottom: 20px;
960      flex-wrap: wrap;
961      animation: fadeInUp 0.5s ease-out;
962    }
963
964    /* =========================================================
965       ID ROWS
966       ========================================================= */
967    .id-row {
968      display: flex;
969      align-items: center;
970      gap: 10px;
971      flex-wrap: wrap;
972      margin-top: 10px;
973      padding: 10px 14px;
974      background: rgba(255, 255, 255, 0.1);
975      border: 1px solid rgba(255, 255, 255, 0.18);
976      border-radius: 10px;
977    }
978
979    .id-label {
980      font-size: 12px;
981      font-weight: 600;
982      text-transform: uppercase;
983      letter-spacing: 0.5px;
984      color: rgba(255, 255, 255, 0.6);
985      flex-shrink: 0;
986    }
987
988    .id-row code {
989      font-size: 12px;
990      font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
991      color: white;
992      background: transparent;
993      word-break: break-all;
994      flex: 1;
995    }
996
997    .id-row a {
998      color: white;
999      text-decoration: none;
1000      transition: opacity 0.2s;
1001    }
1002
1003    .id-row a:hover { opacity: 0.8; }
1004
1005    .copy-btn {
1006      background: rgba(255, 255, 255, 0.15);
1007      border: 1px solid rgba(255, 255, 255, 0.2);
1008      cursor: pointer;
1009      padding: 4px 8px;
1010      border-radius: 6px;
1011      font-size: 14px;
1012      opacity: 0.7;
1013      transition: all 0.2s;
1014      flex-shrink: 0;
1015      color: white;
1016    }
1017
1018    .copy-btn:hover {
1019      opacity: 1;
1020      background: rgba(255, 255, 255, 0.25);
1021      transform: scale(1.05);
1022    }
1023
1024    .copy-btn::before { display: none; }
1025
1026    /* =========================================================
1027       CONTEXT META
1028       ========================================================= */
1029    .context-grid {
1030      display: grid;
1031      grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
1032      gap: 14px;
1033      margin-top: 12px;
1034    }
1035
1036    .ctx-item {
1037      display: flex;
1038      flex-direction: column;
1039      gap: 4px;
1040    }
1041
1042    .ctx-label {
1043      font-size: 12px;
1044      font-weight: 600;
1045      text-transform: uppercase;
1046      letter-spacing: 0.5px;
1047      color: rgba(255, 255, 255, 0.6);
1048    }
1049
1050    .ctx-value {
1051      font-size: 16px;
1052      font-weight: 600;
1053      color: white;
1054    }
1055
1056    .ctx-value code {
1057      font-size: 11px;
1058      font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
1059      color: rgba(255, 255, 255, 0.8);
1060      background: transparent;
1061    }
1062
1063    /* =========================================================
1064       ENCODING CARDS
1065       ========================================================= */
1066    .encoding-list {
1067      display: flex;
1068      flex-direction: column;
1069      gap: 14px;
1070    }
1071
1072    .enc-card {
1073      padding: 18px 20px;
1074      background: rgba(var(--glass-water-rgb), 0.22);
1075      backdrop-filter: blur(18px) saturate(130%);
1076      -webkit-backdrop-filter: blur(18px) saturate(130%);
1077      border: 1px solid rgba(255, 255, 255, 0.22);
1078      border-left: 4px solid rgba(255, 255, 255, 0.3);
1079      border-radius: 12px;
1080      transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
1081      position: relative;
1082      overflow: hidden;
1083    }
1084
1085    .enc-card::before {
1086      content: "";
1087      position: absolute;
1088      inset: 0;
1089      border-radius: inherit;
1090      background: linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.02));
1091      pointer-events: none;
1092    }
1093
1094    .enc-card:hover {
1095      background: rgba(var(--glass-water-rgb), 0.32);
1096      transform: translateX(4px);
1097      box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
1098    }
1099
1100    .enc-card.current {
1101      border-left-color: rgba(72, 187, 120, 0.7);
1102    }
1103
1104    .enc-header {
1105      display: flex;
1106      justify-content: space-between;
1107      align-items: flex-start;
1108      gap: 12px;
1109      flex-wrap: wrap;
1110      margin-bottom: 12px;
1111    }
1112
1113    .enc-meta { flex: 1; min-width: 200px; }
1114
1115    .enc-run-row {
1116      font-size: 13px;
1117      color: rgba(255, 255, 255, 0.7);
1118      margin-bottom: 4px;
1119      display: flex;
1120      align-items: center;
1121      gap: 8px;
1122      flex-wrap: wrap;
1123    }
1124
1125    .enc-run-row strong { color: rgba(255, 255, 255, 0.9); }
1126
1127    .enc-run-row a {
1128      color: white;
1129      text-decoration: none;
1130    }
1131
1132    .enc-run-row a:hover { opacity: 0.8; }
1133
1134    .enc-run-row code {
1135      font-size: 11px;
1136      font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
1137      color: rgba(255, 255, 255, 0.7);
1138      background: transparent;
1139      cursor: pointer;
1140    }
1141
1142    .enc-details {
1143      font-size: 15px;
1144      color: rgba(255, 255, 255, 0.6);
1145      margin-bottom: 6px;
1146      font-style: italic;
1147    }
1148
1149    .status-pill {
1150      display: inline-flex;
1151      align-items: center;
1152      gap: 4px;
1153      padding: 6px 14px;
1154      border-radius: 980px;
1155      font-size: 13px;
1156      font-weight: 600;
1157      border: 1px solid rgba(255, 255, 255, 0.2);
1158      flex-shrink: 0;
1159    }
1160
1161    .status-pill.complete {
1162      background: rgba(72, 187, 120, 0.25);
1163      color: white;
1164    }
1165
1166    .status-pill.pending {
1167      background: rgba(245, 124, 0, 0.25);
1168      color: white;
1169    }
1170
1171    .current-badge {
1172      display: inline-flex;
1173      align-items: center;
1174      gap: 4px;
1175      padding: 3px 10px;
1176      background: rgba(72, 187, 120, 0.35);
1177      border: 1px solid rgba(72, 187, 120, 0.4);
1178      color: white;
1179      border-radius: 6px;
1180      font-size: 12px;
1181      font-weight: 600;
1182    }
1183
1184    .enc-content {
1185      background: rgba(0, 0, 0, 0.2);
1186      padding: 16px;
1187      border-radius: 10px;
1188      font-size: 14px;
1189      line-height: 1.7;
1190      color: rgba(255, 255, 255, 0.85);
1191      white-space: pre-wrap;
1192      word-wrap: break-word;
1193      border: 1px solid rgba(255, 255, 255, 0.08);
1194      margin-bottom: 10px;
1195    }
1196
1197    .enc-content.in-progress {
1198      background: rgba(245, 124, 0, 0.12);
1199      border-color: rgba(245, 124, 0, 0.2);
1200      color: rgba(255, 255, 255, 0.6);
1201      font-style: italic;
1202    }
1203
1204    .enc-footer {
1205      font-size: 12px;
1206      color: rgba(255, 255, 255, 0.4);
1207    }
1208
1209    /* =========================================================
1210       NOTES
1211       ========================================================= */
1212    .notes-list {
1213      display: flex;
1214      flex-direction: column;
1215      gap: 12px;
1216    }
1217
1218    .note-card {
1219      padding: 14px 18px;
1220      border-radius: 12px;
1221      border-left: 3px solid rgba(245, 124, 0, 0.6);
1222      background: rgba(245, 124, 0, 0.12);
1223      border: 1px solid rgba(245, 124, 0, 0.2);
1224      border-left-width: 3px;
1225      border-left-color: rgba(245, 124, 0, 0.6);
1226      transition: all 0.2s;
1227      position: relative;
1228      overflow: hidden;
1229    }
1230
1231    .note-card::before {
1232      content: "";
1233      position: absolute;
1234      inset: 0;
1235      border-radius: inherit;
1236      background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.01));
1237      pointer-events: none;
1238    }
1239
1240    .note-card:hover {
1241      transform: translateX(4px);
1242      box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
1243    }
1244
1245    .note-header {
1246      display: flex;
1247      align-items: center;
1248      gap: 8px;
1249      margin-bottom: 8px;
1250      font-size: 13px;
1251      font-weight: 600;
1252      color: rgba(255, 255, 255, 0.65);
1253    }
1254
1255    .note-avatar {
1256      width: 24px; height: 24px;
1257      border-radius: 50%;
1258      background: rgba(245, 124, 0, 0.5);
1259      color: white;
1260      display: flex;
1261      align-items: center;
1262      justify-content: center;
1263      font-size: 12px;
1264      flex-shrink: 0;
1265    }
1266
1267    .note-content {
1268      font-size: 14px;
1269      line-height: 1.6;
1270      color: rgba(255, 255, 255, 0.85);
1271      white-space: pre-wrap;
1272      word-wrap: break-word;
1273    }
1274
1275    .empty-state {
1276      text-align: center;
1277      padding: 40px;
1278      color: rgba(255, 255, 255, 0.5);
1279      font-style: italic;
1280    }
1281
1282    /* =========================================================
1283       RESPONSIVE
1284       ========================================================= */
1285    @media (max-width: 640px) {
1286      body { padding: 16px; }
1287      .container { max-width: 100%; }
1288      .glass-card { padding: 20px; }
1289      .glass-card h1 { font-size: 24px; }
1290      .back-nav { flex-direction: column; }
1291      .back-link { width: 100%; justify-content: center; }
1292      .context-grid { grid-template-columns: 1fr; }
1293      .enc-header { flex-direction: column; }
1294      .id-row code { font-size: 11px; }
1295    }`;
1296
1297  const body = `
1298  <div class="container">
1299
1300    <div class="back-nav">
1301      <a href="${backTreeUrl}" class="back-link">← Back to Tree</a>
1302      <a href="${backUnderstandingsUrl}" class="back-link">🧠 All Runs</a>
1303    </div>
1304
1305    <!-- Header -->
1306    <div class="glass-card" style="animation-delay: 0.1s;">
1307<h1><a href="${realNodeUrl}">🧠 ${esc(data.realNode.name)}</a></h1>
1308      <div style="font-size: 14px; font-weight: 600; color: rgba(255,255,255,0.55); letter-spacing: 0.3px; text-transform: uppercase; margin-bottom: 8px;">Understanding Node</div>
1309
1310      <div class="id-row">
1311        <span class="id-label">Understanding</span>
1312        <code id="uNodeId">${data.understandingNodeId}</code>
1313        <button class="copy-btn" onclick="copyId('uNodeId', this)">📋</button>
1314      </div>
1315
1316      <div class="id-row">
1317        <span class="id-label">Real Node</span>
1318        <a href="${realNodeUrl}"><code id="realId">${data.realNode.id}</code></a>
1319        <button class="copy-btn" onclick="copyId('realId', this)">📋</button>
1320      </div>
1321    </div>
1322
1323    ${
1324      data.runContext
1325        ? `
1326    <!-- Run Context -->
1327    <div class="glass-card" style="animation-delay: 0.15s;">
1328      <h2>Current Run Context</h2>
1329      <div class="context-grid">
1330        <div class="ctx-item">
1331          <div class="ctx-label">Run ID</div>
1332          <div class="ctx-value"><code>${data.runContext.runId}</code></div>
1333        </div>
1334        <div class="ctx-item">
1335          <div class="ctx-label">Depth</div>
1336          <div class="ctx-value">${data.runContext.structure?.depthFromRoot ?? "-"}</div>
1337        </div>
1338        <div class="ctx-item">
1339          <div class="ctx-label">Merge Layer</div>
1340          <div class="ctx-value">${data.runContext.structure?.mergeLayer ?? "-"}</div>
1341        </div>
1342        <div class="ctx-item">
1343          <div class="ctx-label">Children</div>
1344          <div class="ctx-value">${data.runContext.structure?.childrenCount ?? 0}</div>
1345        </div>
1346      </div>
1347    </div>
1348    `
1349        : ""
1350    }
1351
1352    <!-- Encoding History -->
1353    <div class="glass-card" style="animation-delay: ${data.runContext ? "0.2s" : "0.15s"};">
1354      <h2>📚 Compression History</h2>
1355
1356      ${
1357        hasEncodings
1358          ? `
1359        <div class="encoding-list">
1360          ${encodingHistory
1361            .map((e, i) => {
1362              const runUrl = `/api/v1/root/${nodeId}/understandings/run/${e.runId}${qs}`;
1363              const runIdClean = e.runId.replace(/-/g, "");
1364              return `
1365            <div class="enc-card ${e.isCurrentRun ? "current" : ""}" style="animation: fadeInUp 0.4s ease-out both; animation-delay: ${0.25 + i * 0.06}s;">
1366              <div class="enc-header">
1367                <div class="enc-meta">
1368                  <div class="enc-run-row">
1369                    <strong>Run:</strong>
1370                    <a href="${runUrl}"><code id="rid_${runIdClean}">${e.runId}</code></a>
1371                    <button class="copy-btn" onclick="copyId('rid_${runIdClean}', this)">📋</button>
1372                    ${e.isCurrentRun ? '<span class="current-badge">⭐ Current</span>' : ""}
1373                  </div>
1374                  <div class="enc-details">
1375${esc(e.perspective)} · Layer ${e.currentLayer}
1376                  </div>
1377                </div>
1378                ${
1379                  e.encoding
1380                    ? '<span class="status-pill complete">✅ Completed</span>'
1381                    : '<span class="status-pill pending">⏳ In Progress</span>'
1382                }
1383              </div>
1384
1385              ${
1386                e.encoding
1387                  ? `<div class="enc-content">${esc(e.encoding)}</div>`
1388                  : `<div class="enc-content in-progress">Compression in progress…</div>`
1389              }
1390
1391              <div class="enc-footer">
1392                Updated: ${new Date(e.updatedAt).toLocaleString()}
1393              </div>
1394            </div>
1395          `;
1396            })
1397            .join("")}
1398        </div>
1399      `
1400          : `
1401        <div class="empty-state">No compression history yet</div>
1402      `
1403      }
1404    </div>
1405
1406    ${
1407      !hasEncodings && data.notesToBeCompressed.length > 0
1408        ? `
1409    <!-- Notes -->
1410    <div class="glass-card" style="animation-delay: 0.25s;">
1411      <h2>📝 Notes to be Compressed</h2>
1412      <div class="notes-list">
1413        ${data.notesToBeCompressed
1414          .map(
1415            (n, i) => `
1416          <div class="note-card" style="animation: fadeInUp 0.4s ease-out both; animation-delay: ${0.3 + i * 0.06}s;">
1417            <div class="note-header">
1418              <div class="note-avatar">👤</div>
1419              <span>@${esc(n.username)}</span>
1420            </div>
1421            <div class="note-content">${esc(n.content)}</div>
1422          </div>
1423        `,
1424          )
1425          .join("")}
1426      </div>
1427    </div>
1428    `
1429        : ""
1430    }
1431
1432  </div>
1433`;
1434
1435  const js = `
1436        function copyId(elemId, btn) {
1437      const el = document.getElementById(elemId);
1438      if (!el) return;
1439      navigator.clipboard.writeText(el.textContent.trim()).then(() => {
1440        btn.textContent = '✔️';
1441        setTimeout(() => btn.textContent = '📋', 900);
1442      });
1443    }`;
1444
1445  return page({ title: `Understanding · ${esc(data.realNode.name)}`, css, body, js });
1446}
1447
1448
1449/* =========================================================
1450   3. renderUnderstandingsList
1451   GET /root/:nodeId/understandings
1452   ========================================================= */
1453export function renderUnderstandingsList({
1454  data,
1455  nodeId,
1456  queryString,
1457  runCards,
1458}) {
1459  const css = `
1460        /* =========================================================
1461       GLASS BUTTONS
1462       ========================================================= */
1463    .back-link,
1464    .create-btn {
1465      position: relative;
1466      overflow: hidden;
1467      padding: 10px 20px;
1468      border-radius: 980px;
1469      display: inline-flex;
1470      align-items: center;
1471      justify-content: center;
1472      white-space: nowrap;
1473      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
1474      backdrop-filter: blur(22px) saturate(140%);
1475      -webkit-backdrop-filter: blur(22px) saturate(140%);
1476      color: white;
1477      text-decoration: none;
1478      font-family: inherit;
1479      font-size: 15px;
1480      font-weight: 600;
1481      letter-spacing: -0.2px;
1482      border: 1px solid rgba(255, 255, 255, 0.28);
1483      box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
1484        inset 0 1px 0 rgba(255, 255, 255, 0.25);
1485      cursor: pointer;
1486      transition: background 0.3s cubic-bezier(0.4, 0, 0.2, 1),
1487        transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
1488        box-shadow 0.3s ease;
1489    }
1490
1491    .back-link::before,
1492    .create-btn::before {
1493      content: "";
1494      position: absolute;
1495      inset: -40%;
1496      background:
1497        radial-gradient(120% 60% at 0% 0%, rgba(255, 255, 255, 0.35), transparent 60%),
1498        linear-gradient(120deg, transparent 30%, rgba(255, 255, 255, 0.25), transparent 70%);
1499      opacity: 0;
1500      transform: translateX(-30%) translateY(-10%);
1501      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
1502      pointer-events: none;
1503    }
1504
1505    .back-link:hover,
1506    .create-btn:hover {
1507      background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
1508      transform: translateY(-2px);
1509    }
1510
1511    .back-link:hover::before,
1512    .create-btn:hover::before {
1513      opacity: 1;
1514      transform: translateX(30%) translateY(10%);
1515    }
1516
1517    .back-link:active,
1518    .create-btn:active {
1519      background: rgba(var(--glass-water-rgb), 0.45);
1520      transform: translateY(0);
1521    }
1522
1523    .create-btn {
1524      --glass-water-rgb: 72, 187, 178;
1525      --glass-alpha: 0.34;
1526      --glass-alpha-hover: 0.46;
1527    }
1528
1529    /* =========================================================
1530       GLASS CARDS
1531       ========================================================= */
1532    .glass-card {
1533      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
1534      backdrop-filter: blur(22px) saturate(140%);
1535      -webkit-backdrop-filter: blur(22px) saturate(140%);
1536      border-radius: 16px;
1537      padding: 28px;
1538      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
1539        inset 0 1px 0 rgba(255, 255, 255, 0.25);
1540      border: 1px solid rgba(255, 255, 255, 0.28);
1541      margin-bottom: 24px;
1542      animation: fadeInUp 0.6s ease-out both;
1543      position: relative;
1544      overflow: hidden;
1545    }
1546
1547    .glass-card::before {
1548      content: "";
1549      position: absolute;
1550      inset: 0;
1551      border-radius: inherit;
1552      background: linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.05));
1553      pointer-events: none;
1554    }
1555
1556    .glass-card h1 {
1557      font-size: 28px;
1558      font-weight: 600;
1559      letter-spacing: -0.5px;
1560      line-height: 1.3;
1561      margin-bottom: 8px;
1562      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1563      color: white;
1564    }
1565
1566    .glass-card h2 {
1567      font-size: 18px;
1568      font-weight: 600;
1569      color: white;
1570      margin-bottom: 16px;
1571      letter-spacing: -0.3px;
1572      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1573    }
1574
1575    /* =========================================================
1576       NAV
1577       ========================================================= */
1578    .back-nav {
1579      display: flex;
1580      gap: 12px;
1581      margin-bottom: 20px;
1582      flex-wrap: wrap;
1583      animation: fadeInUp 0.5s ease-out;
1584    }
1585
1586    /* =========================================================
1587       HEADER META
1588       ========================================================= */
1589    .header-sub {
1590      font-size: 14px;
1591      color: rgba(255, 255, 255, 0.65);
1592      line-height: 1.5;
1593      margin-bottom: 16px;
1594    }
1595
1596    .root-chip {
1597      display: inline-flex;
1598      align-items: center;
1599      gap: 8px;
1600      padding: 8px 14px;
1601      background: rgba(255, 255, 255, 0.12);
1602      border: 1px solid rgba(255, 255, 255, 0.2);
1603      border-radius: 10px;
1604      font-size: 14px;
1605      font-weight: 600;
1606      color: white;
1607    }
1608
1609    /* =========================================================
1610       RUN CARDS
1611       ========================================================= */
1612    .runs-list {
1613      display: flex;
1614      flex-direction: column;
1615      gap: 10px;
1616      margin-bottom: 24px;
1617    }
1618
1619    .run-card {
1620      display: block;
1621      text-decoration: none;
1622      padding: 22px 24px;
1623      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
1624      backdrop-filter: blur(22px) saturate(140%);
1625      -webkit-backdrop-filter: blur(22px) saturate(140%);
1626      border: 1px solid rgba(255, 255, 255, 0.28);
1627      border-radius: 16px;
1628      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
1629        inset 0 1px 0 rgba(255, 255, 255, 0.25);
1630      transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
1631      position: relative;
1632      overflow: hidden;
1633      animation: fadeInUp 0.5s ease-out both;
1634    }
1635
1636    .run-card::before {
1637      content: "";
1638      position: absolute;
1639      inset: 0;
1640      border-radius: inherit;
1641      background: linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.02));
1642      pointer-events: none;
1643    }
1644
1645    .run-card:hover {
1646      background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
1647      transform: translateY(-2px);
1648      box-shadow: 0 12px 36px rgba(0, 0, 0, 0.18),
1649        inset 0 1px 0 rgba(255, 255, 255, 0.3);
1650    }
1651
1652    .run-card-header {
1653      display: flex;
1654      align-items: center;
1655      justify-content: space-between;
1656      gap: 12px;
1657      margin-bottom: 8px;
1658    }
1659
1660    .run-perspective {
1661      font-size: 17px;
1662      font-weight: 600;
1663      color: white;
1664      letter-spacing: -0.3px;
1665      line-height: 1.4;
1666      flex: 1;
1667      min-width: 0;
1668      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1669    }
1670
1671    .run-chevron {
1672      color: rgba(255, 255, 255, 0.4);
1673      font-size: 18px;
1674      flex-shrink: 0;
1675      transition: transform 0.25s ease, color 0.25s ease;
1676    }
1677
1678    .run-card:hover .run-chevron {
1679      color: rgba(255, 255, 255, 0.8);
1680      transform: translateX(4px);
1681    }
1682
1683    .run-card-meta {
1684      font-size: 13px;
1685      color: rgba(255, 255, 255, 0.55);
1686      font-weight: 500;
1687      display: flex;
1688      align-items: center;
1689      gap: 6px;
1690      flex-wrap: wrap;
1691    }
1692
1693    .run-sep { opacity: 0.4; }
1694
1695    .empty-state {
1696      text-align: center;
1697      padding: 40px 20px;
1698      color: rgba(255, 255, 255, 0.5);
1699      font-style: italic;
1700      font-size: 15px;
1701    }
1702
1703    /* =========================================================
1704       CREATE FORM
1705       ========================================================= */
1706    .create-form {
1707      display: flex;
1708      gap: 12px;
1709      align-items: stretch;
1710      flex-wrap: wrap;
1711    }
1712
1713    .create-input {
1714      flex: 1;
1715      min-width: 200px;
1716      padding: 12px 16px;
1717      font-size: 15px;
1718      border-radius: 12px;
1719      border: 2px solid rgba(255, 255, 255, 0.3);
1720      background: rgba(255, 255, 255, 0.15);
1721      color: white;
1722      font-family: inherit;
1723      font-weight: 500;
1724      transition: all 0.2s;
1725    }
1726
1727    .create-input::placeholder {
1728      color: rgba(255, 255, 255, 0.45);
1729    }
1730
1731    .create-input:focus {
1732      outline: none;
1733      border-color: rgba(255, 255, 255, 0.6);
1734      background: rgba(255, 255, 255, 0.25);
1735      box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15);
1736      transform: translateY(-2px);
1737    }
1738
1739    /* =========================================================
1740       RESPONSIVE
1741       ========================================================= */
1742    @media (max-width: 640px) {
1743      body { padding: 16px; }
1744      .container { max-width: 100%; }
1745      .glass-card { padding: 20px; }
1746      .glass-card h1 { font-size: 24px; }
1747      .back-nav { flex-direction: column; }
1748      .back-link { width: 100%; justify-content: center; }
1749      .create-form { flex-direction: column; }
1750      .create-input { width: 100%; min-width: 0; }
1751      .create-btn { width: 100%; }
1752    }`;
1753
1754  const body = `
1755  <div class="container">
1756
1757    <div class="back-nav">
1758      <a href="/api/v1/root/${nodeId}${queryString}" class="back-link">← Back to Tree</a>
1759    </div>
1760
1761    <!-- Header -->
1762    <div class="glass-card" style="animation-delay: 0.1s;">
1763      <h1>🧠 Understanding Runs</h1>
1764      <div class="header-sub">
1765        Each perspective reveals different insights from the same tree.
1766      </div>
1767      <div class="root-chip">${data.rootName}</div>
1768    </div>
1769
1770    <!-- Runs -->
1771    ${
1772      data.understandings.length
1773        ? `
1774      <div class="runs-list">
1775        ${runCards}
1776      </div>
1777    `
1778        : `
1779      <div class="glass-card" style="animation-delay: 0.15s;">
1780        <div class="empty-state">No understanding runs yet. Create one below.</div>
1781      </div>
1782    `
1783    }
1784
1785    <!-- Create -->
1786    <div class="glass-card" style="animation-delay: 0.2s;">
1787      <h2>New Understanding</h2>
1788      <form method="POST" action="/api/v1/root/${nodeId}/understandings${queryString}" class="create-form">
1789        <input
1790          type="text"
1791          name="perspective"
1792          placeholder="Enter a perspective…"
1793          class="create-input"
1794          required
1795        />
1796        <button type="submit" class="create-btn">Create</button>
1797      </form>
1798    </div>
1799
1800  </div>`;
1801
1802  return page({ title: `Understandings · ${data.rootName}`, css, body });
1803}
1804
1805
1806/* =========================================================
1807   4. renderRunNodeView
1808   GET /root/:nodeId/understandings/run/:runId/:understandingNodeId
1809   ========================================================= */
1810export function renderRunNodeView({
1811  data,
1812  nodeId,
1813  runId,
1814  qs,
1815  isLeaf,
1816  isCompleted,
1817  childEncodings,
1818  chats,
1819  finalMessage,
1820  inputsHtml,
1821  inputsSectionTitle,
1822  backTreeUrl,
1823  backRunUrl,
1824}) {
1825  const css = `
1826        /* =========================================================
1827       GLASS BUTTONS
1828       ========================================================= */
1829    .back-link {
1830      position: relative;
1831      overflow: hidden;
1832      padding: 10px 20px;
1833      border-radius: 980px;
1834      display: inline-flex;
1835      align-items: center;
1836      justify-content: center;
1837      white-space: nowrap;
1838      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
1839      backdrop-filter: blur(22px) saturate(140%);
1840      -webkit-backdrop-filter: blur(22px) saturate(140%);
1841      color: white;
1842      text-decoration: none;
1843      font-family: inherit;
1844      font-size: 15px;
1845      font-weight: 600;
1846      letter-spacing: -0.2px;
1847      border: 1px solid rgba(255, 255, 255, 0.28);
1848      box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
1849        inset 0 1px 0 rgba(255, 255, 255, 0.25);
1850      cursor: pointer;
1851      transition: background 0.3s cubic-bezier(0.4, 0, 0.2, 1),
1852        transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
1853        box-shadow 0.3s ease;
1854    }
1855
1856    .back-link::before {
1857      content: "";
1858      position: absolute;
1859      inset: -40%;
1860      background:
1861        radial-gradient(120% 60% at 0% 0%, rgba(255, 255, 255, 0.35), transparent 60%),
1862        linear-gradient(120deg, transparent 30%, rgba(255, 255, 255, 0.25), transparent 70%);
1863      opacity: 0;
1864      transform: translateX(-30%) translateY(-10%);
1865      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
1866      pointer-events: none;
1867    }
1868
1869    .back-link:hover {
1870      background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
1871      transform: translateY(-2px);
1872    }
1873
1874    .back-link:hover::before {
1875      opacity: 1;
1876      transform: translateX(30%) translateY(10%);
1877    }
1878
1879    .back-link:active {
1880      background: rgba(var(--glass-water-rgb), 0.45);
1881      transform: translateY(0);
1882    }
1883
1884    /* =========================================================
1885       GLASS CARDS
1886       ========================================================= */
1887    .glass-card {
1888      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
1889      backdrop-filter: blur(22px) saturate(140%);
1890      -webkit-backdrop-filter: blur(22px) saturate(140%);
1891      border-radius: 16px;
1892      padding: 28px;
1893      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
1894        inset 0 1px 0 rgba(255, 255, 255, 0.25);
1895      border: 1px solid rgba(255, 255, 255, 0.28);
1896      margin-bottom: 24px;
1897      animation: fadeInUp 0.6s ease-out both;
1898      position: relative;
1899      overflow: hidden;
1900    }
1901
1902    .glass-card::before {
1903      content: "";
1904      position: absolute;
1905      inset: 0;
1906      border-radius: inherit;
1907      background: linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.05));
1908      pointer-events: none;
1909    }
1910
1911    .glass-card h1 {
1912      font-size: 28px;
1913      font-weight: 600;
1914      letter-spacing: -0.5px;
1915      line-height: 1.3;
1916      margin-bottom: 12px;
1917      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1918      color: white;
1919    }
1920
1921    .glass-card h2 {
1922      font-size: 18px;
1923      font-weight: 600;
1924      color: white;
1925      margin-bottom: 16px;
1926      letter-spacing: -0.3px;
1927      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1928      display: flex;
1929      align-items: center;
1930      gap: 10px;
1931    }
1932
1933    /* =========================================================
1934       NAV
1935       ========================================================= */
1936    .back-nav {
1937      display: flex;
1938      gap: 12px;
1939      margin-bottom: 20px;
1940      flex-wrap: wrap;
1941      animation: fadeInUp 0.5s ease-out;
1942    }
1943
1944    /* =========================================================
1945       META
1946       ========================================================= */
1947    .header-meta {
1948      display: flex;
1949      align-items: center;
1950      gap: 12px;
1951      flex-wrap: wrap;
1952    }
1953
1954    .node-type-badge {
1955      display: inline-flex;
1956      align-items: center;
1957      gap: 6px;
1958      padding: 6px 14px;
1959      border-radius: 980px;
1960      font-size: 13px;
1961      font-weight: 600;
1962      background: rgba(255, 255, 255, 0.12);
1963      border: 1px solid rgba(255, 255, 255, 0.2);
1964      color: rgba(255, 255, 255, 0.85);
1965    }
1966
1967    .status-pill {
1968      display: inline-flex;
1969      align-items: center;
1970      gap: 8px;
1971      padding: 8px 18px;
1972      border-radius: 980px;
1973      font-size: 14px;
1974      font-weight: 600;
1975      border: 1px solid rgba(255, 255, 255, 0.25);
1976    }
1977
1978    .status-pill.complete {
1979      background: rgba(72, 187, 120, 0.3);
1980      color: white;
1981      box-shadow: 0 0 20px rgba(72, 187, 120, 0.15);
1982    }
1983
1984    .status-pill.processing {
1985      background: rgba(255, 160, 122, 0.3);
1986      color: white;
1987      animation: breathe 2.5s ease-in-out infinite;
1988    }
1989
1990    @keyframes breathe {
1991      0%, 100% { box-shadow: 0 0 12px rgba(255, 160, 122, 0.2); }
1992      50% { box-shadow: 0 0 28px rgba(255, 160, 122, 0.4); }
1993    }
1994
1995    .perspective-chip {
1996      font-size: 13px;
1997      color: rgba(255, 255, 255, 0.8);
1998      padding: 6px 14px;
1999      background: rgba(255, 255, 255, 0.12);
2000      border: 1px solid rgba(255, 255, 255, 0.2);
2001      border-radius: 8px;
2002      font-style: italic;
2003    }
2004
2005    .spinner {
2006      display: inline-block;
2007      width: 14px; height: 14px;
2008      border: 2px solid rgba(255, 255, 255, 0.3);
2009      border-top-color: white;
2010      border-radius: 50%;
2011      animation: spin 0.8s linear infinite;
2012    }
2013
2014    @keyframes spin { to { transform: rotate(360deg); } }
2015
2016    /* =========================================================
2017       PROCESSING
2018       ========================================================= */
2019    .processing-card {
2020      display: flex;
2021      align-items: center;
2022      justify-content: center;
2023      gap: 14px;
2024      padding: 22px;
2025      animation: breathe 2.5s ease-in-out infinite;
2026    }
2027
2028    .processing-dots { display: flex; gap: 6px; }
2029
2030    .dot {
2031      width: 8px; height: 8px;
2032      border-radius: 50%;
2033      background: rgba(255, 255, 255, 0.7);
2034      animation: bounce 1.4s ease-in-out infinite;
2035    }
2036
2037    .dot:nth-child(1) { animation-delay: 0s; }
2038    .dot:nth-child(2) { animation-delay: 0.2s; }
2039    .dot:nth-child(3) { animation-delay: 0.4s; }
2040
2041    @keyframes bounce {
2042      0%, 80%, 100% { transform: scale(0); opacity: 0.4; }
2043      40% { transform: scale(1); opacity: 1; }
2044    }
2045
2046    .processing-text {
2047      font-weight: 600;
2048      color: rgba(255, 255, 255, 0.85);
2049      font-size: 14px;
2050    }
2051
2052    /* =========================================================
2053       ENCODING REVEAL
2054       ========================================================= */
2055    .encoding-card {
2056      border-left: 5px solid rgba(72, 187, 120, 0.7);
2057    }
2058
2059    .encoding-text {
2060      background: rgba(0, 0, 0, 0.25);
2061      color: rgba(255, 255, 255, 0.9);
2062      padding: 20px;
2063      border-radius: 12px;
2064      font-size: 14px;
2065      line-height: 1.8;
2066      font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
2067      white-space: pre-wrap;
2068      word-break: break-word;
2069      border: 1px solid rgba(255, 255, 255, 0.1);
2070      position: relative;
2071      overflow: hidden;
2072      min-height: 48px;
2073    }
2074
2075    .encoding-text.revealing::after {
2076      content: "▊";
2077      color: rgba(72, 187, 120, 0.9);
2078      animation: blink 0.6s step-end infinite;
2079      font-weight: 300;
2080    }
2081
2082    @keyframes blink { 50% { opacity: 0; } }
2083
2084    .encoding-text.revealing::before {
2085      content: "";
2086      position: absolute;
2087      top: 0; left: 0; right: 0;
2088      height: 2px;
2089      background: linear-gradient(90deg,
2090        transparent,
2091        rgba(72, 187, 120, 0.6),
2092        rgba(255, 255, 255, 0.8),
2093        rgba(72, 187, 120, 0.6),
2094        transparent
2095      );
2096      animation: scanDown 2s ease-in-out infinite;
2097      z-index: 1;
2098    }
2099
2100    @keyframes scanDown {
2101      0% { top: 0; opacity: 1; }
2102      100% { top: 100%; opacity: 0; }
2103    }
2104
2105    .encoding-card.revealed {
2106      animation: glowPulse 1.5s ease-out;
2107    }
2108
2109    @keyframes glowPulse {
2110      0% { box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 0 40px rgba(72, 187, 120, 0.4); }
2111      100% { box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.25); }
2112    }
2113
2114    /* =========================================================
2115       CHAT MESSAGES (leaf inputs)
2116       ========================================================= */
2117    .chat-list {
2118      display: flex;
2119      flex-direction: column;
2120      gap: 12px;
2121    }
2122
2123    .chat-msg {
2124      padding: 14px 18px;
2125      border-radius: 12px;
2126      transition: all 0.2s;
2127      position: relative;
2128      overflow: hidden;
2129    }
2130
2131    .chat-msg::before {
2132      content: "";
2133      position: absolute;
2134      inset: 0;
2135      border-radius: inherit;
2136      background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.02));
2137      pointer-events: none;
2138    }
2139
2140    .chat-msg:hover {
2141      transform: translateX(4px);
2142      box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
2143    }
2144
2145    .chat-msg.user {
2146      background: rgba(245, 124, 0, 0.15);
2147      border: 1px solid rgba(245, 124, 0, 0.2);
2148      border-left: 3px solid rgba(245, 124, 0, 0.7);
2149    }
2150
2151    .chat-msg.assistant {
2152      background: rgba(72, 187, 120, 0.12);
2153      border: 1px solid rgba(72, 187, 120, 0.2);
2154      border-left: 3px solid rgba(72, 187, 120, 0.7);
2155    }
2156
2157    .chat-head {
2158      display: flex;
2159      align-items: center;
2160      gap: 8px;
2161      margin-bottom: 8px;
2162      font-size: 13px;
2163      font-weight: 600;
2164      color: rgba(255, 255, 255, 0.7);
2165    }
2166
2167    .chat-avatar {
2168      width: 24px; height: 24px;
2169      border-radius: 50%;
2170      display: flex;
2171      align-items: center;
2172      justify-content: center;
2173      font-size: 12px;
2174      flex-shrink: 0;
2175    }
2176
2177    .chat-msg.user .chat-avatar { background: rgba(245, 124, 0, 0.5); color: white; }
2178    .chat-msg.assistant .chat-avatar { background: rgba(72, 187, 120, 0.5); color: white; }
2179
2180    .chat-body {
2181      font-size: 14px;
2182      line-height: 1.6;
2183      color: rgba(255, 255, 255, 0.85);
2184      white-space: pre-wrap;
2185      word-wrap: break-word;
2186    }
2187
2188    /* =========================================================
2189       CHILD ENCODING PANES (merge inputs)
2190       ========================================================= */
2191    .child-encodings-list {
2192      display: flex;
2193      flex-direction: column;
2194      gap: 10px;
2195    }
2196
2197    .child-encoding-pane {
2198      border-radius: 12px;
2199      overflow: hidden;
2200    }
2201
2202    .child-pane-header {
2203      display: flex;
2204      align-items: center;
2205      justify-content: space-between;
2206      gap: 12px;
2207      padding: 14px 18px;
2208      background: rgba(var(--glass-water-rgb), 0.22);
2209      backdrop-filter: blur(18px) saturate(130%);
2210      -webkit-backdrop-filter: blur(18px) saturate(130%);
2211      border: 1px solid rgba(255, 255, 255, 0.22);
2212      border-radius: 12px;
2213      cursor: pointer;
2214      transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
2215      position: relative;
2216      overflow: hidden;
2217    }
2218
2219    .child-pane-header::before {
2220      content: "";
2221      position: absolute;
2222      inset: 0;
2223      border-radius: inherit;
2224      background: linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.02));
2225      pointer-events: none;
2226    }
2227
2228    .child-pane-header:hover {
2229      background: rgba(var(--glass-water-rgb), 0.32);
2230      transform: translateX(4px);
2231      box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
2232    }
2233
2234    .child-pane-left {
2235      display: flex;
2236      align-items: center;
2237      gap: 12px;
2238      min-width: 0;
2239      flex: 1;
2240    }
2241
2242    .child-status { font-size: 16px; flex-shrink: 0; }
2243
2244    .child-pane-info {
2245      display: flex;
2246      flex-direction: column;
2247      gap: 2px;
2248      min-width: 0;
2249    }
2250
2251    .child-pane-name {
2252      font-size: 15px;
2253      font-weight: 600;
2254      color: white;
2255      letter-spacing: -0.2px;
2256      white-space: nowrap;
2257      overflow: hidden;
2258      text-overflow: ellipsis;
2259    }
2260
2261    .child-pane-meta {
2262      font-size: 12px;
2263      color: rgba(255, 255, 255, 0.55);
2264      font-weight: 500;
2265    }
2266
2267    .child-chevron {
2268      color: rgba(255, 255, 255, 0.5);
2269      font-size: 14px;
2270      transition: transform 0.25s ease;
2271      flex-shrink: 0;
2272    }
2273
2274    .child-chevron.open { transform: rotate(90deg); }
2275
2276    .child-pane-body {
2277      display: none;
2278      margin-top: 6px;
2279      padding: 16px 18px;
2280      background: rgba(var(--glass-water-rgb), 0.15);
2281      backdrop-filter: blur(14px) saturate(120%);
2282      -webkit-backdrop-filter: blur(14px) saturate(120%);
2283      border: 1px solid rgba(255, 255, 255, 0.15);
2284      border-radius: 10px;
2285      animation: fadeInUp 0.25s ease-out;
2286    }
2287
2288    .child-pane-body.open { display: block; }
2289
2290    .child-encoding-text {
2291      background: rgba(0, 0, 0, 0.2);
2292      color: rgba(255, 255, 255, 0.85);
2293      padding: 14px;
2294      border-radius: 8px;
2295      font-size: 13px;
2296      line-height: 1.6;
2297      font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
2298      white-space: pre-wrap;
2299      word-break: break-word;
2300      border: 1px solid rgba(255, 255, 255, 0.08);
2301      margin: 0;
2302    }
2303
2304    .child-detail-link {
2305      display: inline-flex;
2306      align-items: center;
2307      gap: 6px;
2308      margin-top: 12px;
2309      padding: 8px 14px;
2310      background: rgba(255, 255, 255, 0.1);
2311      border: 1px solid rgba(255, 255, 255, 0.18);
2312      border-radius: 8px;
2313      color: white;
2314      text-decoration: none;
2315      font-size: 13px;
2316      font-weight: 600;
2317      transition: all 0.2s;
2318    }
2319
2320    .child-detail-link:hover {
2321      background: rgba(255, 255, 255, 0.2);
2322      transform: translateY(-1px);
2323    }
2324
2325    /* =========================================================
2326       EMPTY / MISC
2327       ========================================================= */
2328    .empty-state {
2329      text-align: center;
2330      padding: 40px;
2331      color: rgba(255, 255, 255, 0.5);
2332      font-style: italic;
2333    }
2334
2335    /* =========================================================
2336       REFRESH TOAST
2337       ========================================================= */
2338    .refresh-toast {
2339      position: fixed;
2340      bottom: 24px;
2341      right: 24px;
2342      background: rgba(var(--glass-water-rgb), 0.5);
2343      backdrop-filter: blur(22px) saturate(140%);
2344      -webkit-backdrop-filter: blur(22px) saturate(140%);
2345      padding: 12px 20px;
2346      border-radius: 980px;
2347      border: 1px solid rgba(255, 255, 255, 0.25);
2348      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
2349      font-size: 13px;
2350      font-weight: 600;
2351      color: white;
2352      display: flex;
2353      align-items: center;
2354      gap: 10px;
2355      animation: fadeInUp 0.5s ease-out;
2356      z-index: 100;
2357    }
2358
2359    /* =========================================================
2360       RESPONSIVE
2361       ========================================================= */
2362    @media (max-width: 640px) {
2363      body { padding: 16px; }
2364      .container { max-width: 100%; }
2365      .glass-card { padding: 20px; }
2366      .glass-card h1 { font-size: 24px; }
2367      .back-nav { flex-direction: column; }
2368      .back-link { width: 100%; justify-content: center; }
2369      .header-meta { flex-direction: column; align-items: flex-start; }
2370      .refresh-toast { bottom: 16px; right: 16px; left: 16px; }
2371      .child-pane-header { padding: 12px 14px; }
2372    }`;
2373
2374  const body = `
2375  <div class="container">
2376
2377    <div class="back-nav">
2378      <a href="${backTreeUrl}" class="back-link">← Back to Tree</a>
2379      <a href="${backRunUrl}" class="back-link">🧠 Run Progress</a>
2380    </div>
2381
2382    <!-- Header -->
2383    <div class="glass-card" style="animation-delay: 0.1s;">
2384<h1>${esc(data.realNode.name)}</h1>
2385      <div class="header-meta">
2386        <span class="node-type-badge">${isLeaf ? "🍃 Leaf" : "🔗 Merge · " + childEncodings.length + " children"}</span>
2387        <span class="status-pill ${isCompleted ? "complete" : "processing"}">
2388          ${
2389            isCompleted
2390              ? "✅ Compressed"
2391              : '<span class="spinner"></span> Processing'
2392          }
2393        </span>
2394<span class="perspective-chip">${esc(data.perspective)}</span>
2395      </div>
2396    </div>
2397
2398    ${
2399      !isCompleted
2400        ? `
2401    <div class="glass-card processing-card" style="animation-delay: 0.15s;">
2402      <div class="processing-dots">
2403        <div class="dot"></div>
2404        <div class="dot"></div>
2405        <div class="dot"></div>
2406      </div>
2407      <span class="processing-text">${isLeaf ? "Compressing notes…" : "Merging child encodings…"}</span>
2408    </div>
2409    `
2410        : ""
2411    }
2412
2413    ${
2414      isCompleted
2415        ? `
2416    <div class="glass-card encoding-card" id="encodingCard" style="animation-delay: 0.2s;">
2417      <h2>✨ ${isLeaf ? "Compressed Understanding" : "Merged Understanding"}</h2>
2418      <div class="encoding-text revealing" id="encodingText"></div>
2419    </div>
2420    `
2421        : ""
2422    }
2423
2424    <!-- Inputs -->
2425    <div class="glass-card" style="animation-delay: ${isCompleted ? "0.35s" : "0.2s"};">
2426      <h2>${inputsSectionTitle}</h2>
2427      ${inputsHtml}
2428    </div>
2429
2430    ${
2431      !isCompleted
2432        ? `
2433    <div class="refresh-toast">
2434      <span class="spinner"></span>
2435      Checking for updates…
2436    </div>
2437    `
2438        : ""
2439    }
2440
2441  </div>
2442`;
2443
2444  const js = isCompleted
2445    ? `
2446        // Typewriter reveal
2447    (function() {
2448      const full = ${JSON.stringify(finalMessage)};
2449      const el = document.getElementById('encodingText');
2450      const card = document.getElementById('encodingCard');
2451      if (!el || !full) return;
2452
2453      let i = 0;
2454      const speed = Math.max(8, Math.min(30, 2000 / full.length));
2455
2456      function type() {
2457        if (i < full.length) {
2458          const chunk = Math.min(3, full.length - i);
2459          el.textContent += full.slice(i, i + chunk);
2460          i += chunk;
2461          el.scrollTop = el.scrollHeight;
2462          requestAnimationFrame(() => setTimeout(type, speed));
2463        } else {
2464          el.classList.remove('revealing');
2465          card.classList.add('revealed');
2466        }
2467      }
2468
2469      setTimeout(type, 700);
2470    })();
2471
2472    // Toggle child panes
2473    function toggleChild(id) {
2474      const body = document.getElementById('cbody-' + id);
2475      const chev = document.getElementById('cchev-' + id);
2476      if (!body) return;
2477      body.classList.toggle('open');
2478      chev?.classList.toggle('open');
2479    }`
2480    : `
2481        function toggleChild(id) {
2482      const body = document.getElementById('cbody-' + id);
2483      const chev = document.getElementById('cchev-' + id);
2484      if (!body) return;
2485      body.classList.toggle('open');
2486      chev?.classList.toggle('open');
2487    }
2488
2489    // Auto-refresh while processing
2490    (function() {
2491      let count = 0;
2492      function check() {
2493        if (count++ >= 100) return;
2494        setTimeout(() => window.location.reload(), 3000 + Math.random() * 1000);
2495      }
2496      check();
2497    })();`;
2498
2499  return page({ title: `${esc(data.realNode.name)} – Compression`, css, body, js });
2500}
2501
2502/* =========================================================
2503   Helper: build inputsHtml for renderRunNodeView
2504   ========================================================= */
2505export function buildRunNodeInputsHtml({
2506  isLeaf,
2507  isCompleted,
2508  chats,
2509  childEncodings,
2510  nodeId,
2511  runId,
2512  qs,
2513}) {
2514  let inputsHtml = "";
2515
2516  if (isLeaf) {
2517    if (chats.length) {
2518      inputsHtml = `
2519            <div class="chat-list">
2520              ${chats
2521                .map(
2522                  (c, i) => `
2523                <div class="chat-msg ${c.role}" style="animation: fadeInUp 0.4s ease-out both; animation-delay: ${0.4 + i * 0.06}s;">
2524                  <div class="chat-head">
2525                    <div class="chat-avatar">${c.role === "assistant" ? "🤖" : "👤"}</div>
2526<span>@${esc(c.username)}</span>
2527                  </div>
2528<div class="chat-body">${esc(c.content)}</div>
2529                </div>
2530              `,
2531                )
2532                .join("")}
2533            </div>
2534          `;
2535    } else {
2536      inputsHtml = `<div class="empty-state">No notes on this node</div>`;
2537    }
2538  } else {
2539    if (childEncodings.length) {
2540      inputsHtml = `
2541            <div class="child-encodings-list">
2542              ${childEncodings
2543                .map(
2544                  (child, i) => `
2545                <div class="child-encoding-pane" style="animation: fadeInUp 0.4s ease-out both; animation-delay: ${0.4 + i * 0.08}s;">
2546                  <div class="child-pane-header" onclick="toggleChild('${child.understandingNodeId}')">
2547                    <div class="child-pane-left">
2548                      <span class="child-status">${child.isComplete ? "✅" : "⏳"}</span>
2549                      <div class="child-pane-info">
2550<span class="child-pane-name">${esc(child.name)}</span>
2551                        <span class="child-pane-meta">Layer ${child.currentLayer ?? "-"}/${child.mergeLayer ?? "-"}</span>
2552                      </div>
2553                    </div>
2554                    <span class="child-chevron" id="cchev-${child.understandingNodeId}">▸</span>
2555                  </div>
2556                  <div class="child-pane-body" id="cbody-${child.understandingNodeId}">
2557                    ${
2558                      child.encoding
2559                        ? `
2560                        <pre class="child-encoding-text">${esc(child.encoding)}</pre>
2561                    `
2562                        : `
2563                      <div class="empty-state" style="padding: 16px;">No encoding yet</div>
2564                    `
2565                    }
2566                    <a href="/api/v1/root/${nodeId}/understandings/run/${runId}/${child.understandingNodeId}${qs}" class="child-detail-link" onclick="event.stopPropagation();">
2567                      View Details →
2568                    </a>
2569                  </div>
2570                </div>
2571              `,
2572                )
2573                .join("")}
2574            </div>
2575          `;
2576    } else {
2577      inputsHtml = `<div class="empty-state">No child nodes found</div>`;
2578    }
2579  }
2580
2581  const inputsSectionTitle = isLeaf
2582    ? isCompleted
2583      ? "📝 Original Notes"
2584      : "🔄 Notes Being Compressed"
2585    : isCompleted
2586      ? "🔗 Merged Child Encodings"
2587      : "🔗 Child Encodings Being Merged";
2588
2589  return { inputsHtml, inputsSectionTitle };
2590}
2591
2592/* =========================================================
2593   Helper: build runCards for renderUnderstandingsList
2594   ========================================================= */
2595export function buildRunCards({ understandings, nodeId, queryString }) {
2596  return understandings
2597    .map(
2598      (r, i) => `
2599        <a href="/api/v1/root/${nodeId}/understandings/run/${r._id}${queryString}"
2600           class="run-card"
2601           style="animation-delay: ${0.15 + i * 0.06}s;">
2602          <div class="run-card-header">
2603            <span class="run-perspective">${r.perspective}</span>
2604            <span class="run-chevron">→</span>
2605          </div>
2606          <div class="run-card-meta">
2607            <span>Depth ${r.maxDepth ?? "-"}</span>
2608            <span class="run-sep">·</span>
2609            <span>${new Date(r.createdAt).toLocaleString()}</span>
2610          </div>
2611        </a>
2612      `,
2613    )
2614    .join("");
2615}
2616
1import log from "../../seed/log.js";
2import UnderstandingRun from "./understandingRun.js";
3import UnderstandingNode from "./understandingNode.js";
4import tools from "./tools.js";
5
6import understand from "./modes/understand.js";
7import understandSummarize from "./modes/understandSummarize.js";
8
9/**
10 * Build a Map of nodeId -> encoding string from the latest understanding run.
11 * Called by tree-orchestrator, raw-ideas, dreams when building tree summaries.
12 * The seed's buildDeepTreeSummary accepts this map as a parameter.
13 */
14async function getEncodingMap(rootId) {
15  try {
16    const latestRun = await UnderstandingRun.findOne({
17      rootNodeId: rootId,
18      perspective: { $regex: /^Summarize this section/ },
19    }).sort({ createdAt: -1 }).select("_id nodeMap").lean();
20
21    if (!latestRun) return null;
22
23    // Scope to nodes in this run only. nodeMap has realNodeId -> understandingNodeId.
24    const runId = String(latestRun._id);
25    const uNodeIds = latestRun.nodeMap instanceof Map
26      ? [...latestRun.nodeMap.values()]
27      : Object.values(latestRun.nodeMap || {});
28
29    if (uNodeIds.length === 0) return null;
30
31    const uNodes = await UnderstandingNode.find({ _id: { $in: uNodeIds } })
32      .select("realNodeId perspectiveStates").lean();
33
34    const map = new Map();
35    for (const uNode of uNodes) {
36      const state = uNode.perspectiveStates?.get?.(runId)
37        || (uNode.perspectiveStates && uNode.perspectiveStates[runId]);
38      if (state?.encoding) {
39        map.set(uNode.realNodeId, state.encoding);
40      }
41    }
42    return map.size > 0 ? map : null;
43  } catch {
44    return null;
45  }
46}
47
48export async function init(core) {
49  // Wire core services into core.js and routes.js
50  const understanding = await import("./core.js");
51  understanding.setServices({ models: core.models, contributions: core.contributions });
52  if (core.energy) understanding.setEnergyService(core.energy);
53
54  const { default: router, setModels, resolveHtmlAuth } = await import("./routes.js");
55  setModels(core.models);
56  resolveHtmlAuth();
57
58  const orchestrator = await import("./pipeline.js");
59
60  // Register understanding modes + LLM slot mappings
61  core.modes.registerMode("tree:understand", understand, "understanding");
62  core.modes.registerMode("tree:understand-summarize", understandSummarize, "understanding");
63  if (core.llm?.registerModeAssignment) {
64    core.llm.registerModeAssignment("tree:understand", "understanding");
65    core.llm.registerModeAssignment("tree:understand-summarize", "understanding");
66    core.llm.registerRootLlmSlot?.("understanding");
67  }
68
69  // Inject latest understanding encoding into every AI prompt at this tree.
70  core.hooks.register("enrichContext", async ({ context, node, meta }) => {
71    if (!node?.rootOwner || node.rootOwner === "SYSTEM") return;
72    const rootId = meta?.rootId || (node.rootOwner && node.rootOwner !== "SYSTEM" ? node._id : null);
73    if (!rootId) return;
74    try {
75      const latestRun = await UnderstandingRun.findOne({
76        rootNodeId: String(rootId),
77        status: "completed",
78      }).sort({ lastCompletedAt: -1 }).select("encodingHistory perspective").lean();
79      if (!latestRun?.encodingHistory?.length) return;
80      const latest = latestRun.encodingHistory[latestRun.encodingHistory.length - 1];
81      if (latest?.encoding) {
82        context.understanding = `[Understanding: ${latestRun.perspective || "general"}] ${latest.encoding}`;
83      }
84    } catch (err) { log.debug("Understanding", "Failed to enrich context with understanding:", err.message); }
85  }, "understanding");
86
87  // Register navigation for understanding tools (if treeos-base installed)
88  try {
89    const { getExtension } = await import("../loader.js");
90    const base = getExtension("treeos-base");
91    if (!base?.exports?.registerToolNavigations) throw 0;
92    base.exports.registerToolNavigations({
93      "understanding-create": ({ args, withToken: t }) =>
94        t(`/api/v1/root/${args.rootId || args.rootNodeId}/understandings?html`),
95      "understanding-list": ({ args, withToken: t }) =>
96        t(`/api/v1/root/${args.rootNodeId}/understandings?html`),
97      "understanding-process": ({ args, withToken: t }) => {
98        const rid = args.rootNodeId;
99        const uid = args.understandingRunId;
100        const unid = args.understandingNodeId || args.previousResult?.understandingNodeId;
101        if (!rid || !uid) return null;
102        return unid
103          ? t(`/api/v1/root/${rid}/understandings/run/${uid}/${unid}?html`)
104          : t(`/api/v1/root/${rid}/understandings/run/${uid}?html`);
105      },
106    });
107  } catch {}
108
109  // Register tree quick link
110  try {
111    const { getExtension } = await import("../loader.js");
112    const treeos = getExtension("treeos-base");
113    treeos?.exports?.registerSlot?.("tree-quick-links", "understanding", ({ rootId, queryString }) =>
114      `<a href="/api/v1/root/${rootId}/understandings${queryString}" class="back-link">Understandings</a>`,
115      { priority: 35 }
116    );
117  } catch {}
118
119  return {
120    models: { UnderstandingRun, UnderstandingNode },
121    router,
122    tools,
123    exports: {
124      orchestrateUnderstanding: orchestrator.orchestrateUnderstanding,
125      createUnderstandingRun: understanding.createUnderstandingRun,
126      findOrCreateUnderstandingRun: understanding.findOrCreateUnderstandingRun,
127      prepareIncrementalRun: understanding.prepareIncrementalRun,
128      getEncodingMap,
129    },
130  };
131}
132
1export default {
2  name: "understanding",
3  version: "1.0.1",
4  builtFor: "TreeOS",
5  description:
6    "A tree with 500 nodes cannot fit in an AI context window. The AI needs to know what the " +
7    "tree contains without reading every note on every node. Understanding solves this by " +
8    "building a compressed encoding of the entire tree from the bottom up. Leaves first, then " +
9    "their parents, then the parents of parents, until the root holds a single encoding that " +
10    "captures the semantic meaning of the full tree." +
11    "\n\n" +
12    "The mechanism is layered compression. A shadow tree of UnderstandingNode documents " +
13    "mirrors the real tree. Each understanding node holds a perspectiveStates map keyed by " +
14    "run ID. A run begins by building the shadow topology: depth from root, subtree height, " +
15    "merge layer for each node. Leaf nodes with content are sent to the LLM for individual " +
16    "summarization. Empty leaves auto-commit with a placeholder. Once all children of a " +
17    "parent are complete at their own merge layer, the parent merges their summaries into " +
18    "one cohesive encoding. The process repeats upward until the root node merges everything." +
19    "\n\n" +
20    "Runs are incremental. The contribution snapshot on each understanding node records how " +
21    "many contributions existed when it was last compressed. On a re-run, nodes whose " +
22    "contribution count has changed are marked dirty. Dirtiness propagates upward to the " +
23    "root. Only dirty nodes recompress. A 500-node tree where 3 nodes changed reprocesses " +
24    "those 3 nodes plus their ancestor chain, not all 500. Structural changes (added, " +
25    "removed, moved nodes) are detected by comparing old and new topology." +
26    "\n\n" +
27    "Each run carries a perspective: a lens for compression. The default is semantic " +
28    "compression while maintaining meaning. A custom perspective like financial summary or " +
29    "technical architecture compresses the same tree differently. The root encoding and " +
30    "perspective are stored in encodingHistory on the run document. enrichContext injects " +
31    "the latest completed encoding at every node in the tree so the AI always has the " +
32    "compressed understanding of the full structure in its context window. The auto-run " +
33    "job and the dreams extension trigger understanding runs on schedules. The pipeline " +
34    "uses OrchestratorRuntime for session lifecycle, lock management, and LLM calls.",
35
36  // Required: won't load without these
37  needs: {
38    services: ["llm", "session", "chat", "orchestrator", "mcp", "contributions", "hooks"],
39    models: ["Node", "Contribution"],
40  },
41
42  optional: {
43    services: ["energy"],
44    extensions: ["html-rendering", "treeos-base"],
45  },
46
47  provides: {
48    hooks: {
49      listens: ["enrichContext"],
50    },
51    models: {
52      UnderstandingRun: "./understandingRun.js",
53      UnderstandingNode: "./understandingNode.js",
54    },
55    routes: "./routes.js",
56    tools: true,
57    jobs: "./autoRunJob.js",
58    orchestrator: "./pipeline.js",
59    energyActions: {
60      understanding: { cost: 1, unit: "per-node" },
61    },
62    sessionTypes: {
63      UNDERSTANDING_ORCHESTRATE: "understanding-orchestrate",
64    },
65    cli: [
66      { command: "understand", scope: ["tree"], description: "Start an understanding run (-i incremental)", method: "POST", endpoint: "/root/:rootId/understandings" },
67      { command: "understandings", scope: ["tree"], description: "List understanding runs", method: "GET", endpoint: "/root/:rootId/understandings" },
68      { command: "understand-status <runId>", scope: ["tree"], description: "Check progress of a run", method: "GET", endpoint: "/root/:rootId/understandings/run/:runId" },
69      { command: "understand-stop <runId>", scope: ["tree"], description: "Stop a running understanding run", method: "POST", endpoint: "/root/:rootId/understandings/run/:runId/stop" },
70    ],
71  },
72};
73
1import mongoose from "mongoose";
2import { v4 as uuidv4 } from "uuid";
3
4const PerspectiveStateSchema = new mongoose.Schema(
5  {
6    understandingRunId: {
7      type: String,
8      required: true,
9      index: true,
10    },
11
12    perspective: {
13      type: String,
14      required: true,
15    },
16
17    encoding: {
18      type: String,
19      default: "",
20    },
21
22    currentLayer: {
23      type: Number,
24      required: true,
25    },
26
27    updatedAt: {
28      type: Date,
29      default: Date.now,
30    },
31
32    contributionSnapshot: {
33      type: Number,
34      default: null,
35    },
36  },
37  { _id: false }
38);
39
40const UnderstandingNodeSchema = new mongoose.Schema({
41  _id: {
42    type: String,
43    default: uuidv4,
44  },
45
46  // canonical link to real node
47  realNodeId: {
48    type: String,
49    ref: "Node",
50    unique: true,
51    index: true,
52    required: true,
53  },
54
55  /**
56   * Run-specific semantic state
57   * Map<understandingRunId, PerspectiveState>
58   */
59  perspectiveStates: {
60    type: Map,
61    of: PerspectiveStateSchema,
62    default: {},
63  },
64
65  createdAt: {
66    type: Date,
67    default: Date.now,
68  },
69});
70
71export default mongoose.model("UnderstandingNode", UnderstandingNodeSchema);
72
1import mongoose from "mongoose";
2import { v4 as uuidv4 } from "uuid";
3
4const RunNodeTopologySchema = new mongoose.Schema(
5  {
6    parent: {
7      type: String,
8      ref: "UnderstandingNode",
9      default: null,
10    },
11
12    children: {
13      type: [String],
14      ref: "UnderstandingNode",
15      default: [],
16    },
17
18    depthFromRoot: {
19      type: Number,
20      required: true,
21    },
22
23    subtreeHeight: {
24      type: Number,
25      required: true,
26    },
27
28    mergeLayer: {
29      type: Number,
30      required: true,
31    },
32  },
33  { _id: false },
34);
35
36const UnderstandingRunSchema = new mongoose.Schema({
37  _id: {
38    type: String,
39    default: uuidv4,
40  },
41
42  // real root node
43  rootNodeId: {
44    type: String,
45    ref: "Node",
46    required: true,
47  },
48
49  perspective: {
50    type: String,
51    default: "general",
52  },
53
54  /**
55   * realNodeId -> understandingNodeId
56   */
57  nodeMap: {
58    type: Map,
59    of: String,
60  },
61  pendingMerge: {
62    type: Object,
63    default: null,
64  },
65
66  /**
67   * understandingNodeId -> topology
68   */
69  topology: {
70    type: Map,
71    of: RunNodeTopologySchema,
72    default: {},
73  },
74
75  maxDepth: {
76    type: Number,
77    index: true,
78  },
79  userId: {
80    type: String,
81    ref: "User",
82    required: true,
83  },
84  status: {
85    type: String,
86    enum: ["running", "completed"],
87    default: "completed",
88  },
89
90  lastCompletedAt: {
91    type: Date,
92    default: null,
93  },
94
95  encodingHistory: [{
96    encoding: { type: String },
97    completedAt: { type: Date },
98    _id: false,
99  }],
100
101  createdAt: {
102    type: Date,
103    default: Date.now,
104  },
105});
106
107export default mongoose.model("UnderstandingRun", UnderstandingRunSchema);
108
1// extensions/understanding/modes/understand.js
2export default {
3  name: "tree:understand",
4  emoji: "🧠",
5  label: "Understand",
6  bigMode: "tree",
7  maxMessagesBeforeLoop: 100,
8  preserveContextOnSwitch: true,
9
10  toolNames: [
11    "understanding-list",
12    "understanding-create",
13    "understanding-process",
14  ],
15
16  buildSystemPrompt({ username, userId, rootId }) {
17    return `
18You are in UNDERSTAND mode. Root: ${rootId}
19
20══════════════════════════════════════════════
21FIRST: CHECK USER MESSAGE
22══════════════════════════════════════════════
23If user provides a UUID/run ID (like "continue 196eee77-..."):
24  → Skip to PHASE 2 immediately using that ID
25  → Do NOT call understanding-list or understanding-create
26
27Otherwise → go to PHASE 1
28
29══════════════════════════════════════════════
30PHASE 1 — SETUP (only if no run ID provided)
31══════════════════════════════════════════════
321. Call understanding-list(rootNodeId="${rootId}")
332. Show user the list, ask: pick a number or type new perspective
343. If they pick existing → use that understandingRunId → go to PHASE 2
354. If they type new text → call understanding-create → go to PHASE 2
36
37NEVER call understanding-create unless user explicitly asks.
38
39══════════════════════════════════════════════
40PHASE 2 — SUMMARIZATION LOOP
41══════════════════════════════════════════════
42
43STEP 1: Call understanding-process(understandingRunId, rootNodeId="${rootId}")
44        (No previousResult on the first call.)
45
46STEP 2: The tool returns data + instructions telling you what to summarize
47        and the EXACT parameters for the next call.
48
49STEP 3: Write a summary from the data. Then IMMEDIATELY CALL
50        understanding-process again, passing your summary in
51        previousResult.encoding along with the other fields exactly
52        as instructed.
53
54STEP 4: Go to STEP 2. Repeat until done:true.
55
56⚠️ CRITICAL LOOP RULES ⚠️
57- After EVERY tool response, your VERY NEXT ACTION must be another
58  understanding-process tool call. No exceptions.
59- Do NOT output the summary to chat. It goes in previousResult.encoding.
60- Do NOT output the nextCall JSON to chat. USE it to make the call.
61- Do NOT pause, ask the user, or explain between steps.
62- Copy ALL IDs and numbers exactly. Never invent or modify them.
63- Continue until the tool returns done:true.
64- If you get an error, retry once with corrected parameters.
65
66When done:true → tell the user "Understanding complete." and stop.
67
68══════════════════════════════════════════════
69SETUP RULES
70══════════════════════════════════════════════
71- If user gives you a run ID, USE IT directly. Don't list, don't create.
72- ONLY create when user explicitly says "new" or provides perspective text.
73`.trim();
74  },
75};
76
1// extensions/understanding/modes/understandSummarize.js
2// Tool-less mode used by the understand orchestrator.
3// The LLM receives content to summarize and outputs only the summary text.
4
5export default {
6  name: "tree:understand-summarize",
7  emoji: "🧠",
8  label: "Understand (Summarize)",
9  bigMode: "tree",
10  hidden: true,
11  toolNames: [],
12
13  buildSystemPrompt({ perspective, nodeType }) {
14    const typeHint = nodeType
15      ? `\nNode type: ${nodeType}. Factor this into your summary. A goal node's summary should highlight what's being aimed for. A plan should highlight strategy. A task should highlight what needs doing. Knowledge should highlight what's understood. A resource should highlight what's available. Identity should highlight who/what this serves.`
16      : "";
17
18    return `
19You are a semantic compression engine. Summarize content through a specific perspective.
20
21Perspective: "${perspective || "general"}"${typeHint}
22
23The perspective defines WHAT to extract and emphasize. "general" compresses meaning.
24A perspective like "actionable next steps" extracts tasks. "emotional tone" extracts feeling.
25"technical architecture" extracts structure. The perspective is the lens. Apply it.
26
27RULES:
28- Output ONLY the summary text. No preamble, no JSON, no markdown fences.
29- Be concise but complete for the given perspective. 1-3 sentences.
30- Never repeat the node name.
31- Never output placeholders like "(no notes)". If content is sparse, infer from context.
32- When merging child summaries, synthesize through the perspective lens, don't list children.
33- If the node has a type, let that shape emphasis: goals emphasize direction, tasks emphasize work, knowledge emphasizes understanding, resources emphasize capability.
34- Just output the summary directly.
35`.trim();
36  },
37};
38
1// Understanding pipeline.
2// Autonomous understanding: creates/resumes a run, loops through all nodes,
3// uses LLM for summarization only (no tool calling), commits results.
4// Uses OrchestratorRuntime for session lifecycle, lock management, and LLM calls.
5
6import log from "../../seed/log.js";
7import { WS } from "../../seed/protocol.js";
8import { OrchestratorRuntime } from "../../seed/orchestrators/runtime.js";
9import { emitNavigate, emitToUser } from "../../seed/ws/websocket.js";
10import {
11  setActiveNavigator,
12  getSession,
13  updateSessionMeta,
14  SESSION_TYPES,
15} from "../../seed/ws/sessionRegistry.js";
16import {
17  getNextCompressionPayloadForLLM,
18  commitCompressionResult,
19  prepareIncrementalRun,
20} from "./core.js";
21import UnderstandingRun from "./understandingRun.js";
22import UnderstandingNode from "./understandingNode.js";
23
24// LLM_PRIORITY accessed via core.llm.LLM_PRIORITY in the caller,
25// but the pipeline constructs its own runtime so we import directly.
26let LLM_PRIORITY;
27try {
28  ({ LLM_PRIORITY } = await import("../../seed/llm/conversation.js"));
29} catch {
30  LLM_PRIORITY = { BACKGROUND: 4 };
31}
32
33// ─────────────────────────────────────────────────────────────────────────
34// HELPERS
35// ─────────────────────────────────────────────────────────────────────────
36
37function buildSummarizationPrompt(payload) {
38  const input = payload.inputs[0];
39
40  if (payload.mode === "leaf") {
41    const notesText = input.notes
42      .map((n) => `[${n.username}] ${n.content}`)
43      .join("\n");
44    const typeLabel = input.nodeType ? ` (type: ${input.nodeType})` : "";
45    return `Summarize the notes for node "${input.nodeName}"${typeLabel}:\n\n${notesText}`;
46  }
47
48  if (payload.mode === "merge") {
49    const nonEmpty = input.childSummaries.filter(
50      (cs) => cs.summary && cs.summary.trim(),
51    );
52    if (nonEmpty.length === 0) {
53      return `Node "${input.nodeName}" has ${input.childSummaries.length} empty children. Write a one-sentence summary of what this section likely covers based on its name alone.`;
54    }
55    const childText = nonEmpty
56      .map((cs, i) => `[Child ${i + 1}] ${cs.summary}`)
57      .join("\n\n");
58    const typeLabel = input.nodeType ? ` (type: ${input.nodeType})` : "";
59    return `Merge these child summaries into one cohesive summary for "${input.nodeName}"${typeLabel}:\n\n${childText}`;
60  }
61
62  throw new Error(`Unknown payload mode: ${payload.mode}`);
63}
64
65/**
66 * Load the root understanding node's final encoding for a completed run.
67 */
68async function getRootEncoding(run) {
69  const topology =
70    run.topology instanceof Map
71      ? run.topology
72      : new Map(Object.entries(run.topology || {}));
73
74  let rootUNodeId = null;
75  for (const [uid, topo] of topology) {
76    if (topo.parent === null || topo.parent === undefined) {
77      rootUNodeId = uid;
78      break;
79    }
80  }
81  if (!rootUNodeId) return null;
82
83  const rootNode = await UnderstandingNode.findById(rootUNodeId).lean();
84  if (!rootNode) return null;
85
86  const ps = rootNode.perspectiveStates;
87  if (!ps) return null;
88
89  const runId = String(run._id);
90  const state =
91    ps instanceof Map
92      ? ps.get(runId) || ps.get(String(runId))
93      : ps[runId] || ps[String(runId)];
94
95  return state?.encoding || null;
96}
97
98// ─────────────────────────────────────────────────────────────────────────
99// MAIN ORCHESTRATOR
100// ─────────────────────────────────────────────────────────────────────────
101
102export async function orchestrateUnderstanding({
103  rootId,
104  userId,
105  username,
106  runId,
107  source = "orchestrator",
108  fromSite = false,
109  sessionId: externalSessionId,
110  rootChatId: externalRootChatId,
111  startingChainIndex,
112}) {
113  // Load and validate run
114  const existingRun = await UnderstandingRun.findById(runId).lean();
115  if (!existingRun) {
116    return { success: false, error: "Understanding run not found" };
117  }
118  if (String(existingRun.rootNodeId) !== String(rootId)) {
119    return { success: false, error: "Run does not belong to this root" };
120  }
121
122  const understandingRunId = String(existingRun._id);
123  const runPerspective = existingRun.perspective;
124  const nodeCount = existingRun.nodeMap
125    ? existingRun.nodeMap instanceof Map
126      ? existingRun.nodeMap.size
127      : Object.keys(existingRun.nodeMap).length
128    : 0;
129
130  // Prepare incremental run
131  const { dirtyCount, totalNodes } = await prepareIncrementalRun(understandingRunId, userId);
132  log.debug("Understanding", `Incremental prep: ${dirtyCount}/${totalNodes} nodes dirty`);
133
134  await UnderstandingRun.findByIdAndUpdate(understandingRunId, { status: "running" });
135
136  // Check if already complete before spinning up resources
137  const firstPayload = await getNextCompressionPayloadForLLM(understandingRunId, userId);
138  if (!firstPayload) {
139    await UnderstandingRun.findByIdAndUpdate(understandingRunId, {
140      status: "completed",
141      lastCompletedAt: new Date(),
142    });
143    const rootEncoding = await getRootEncoding(existingRun);
144    return {
145      success: true,
146      alreadyComplete: true,
147      understandingRunId,
148      perspective: runPerspective,
149      nodeCount,
150      nodesProcessed: 0,
151      rootEncoding,
152    };
153  }
154
155  const isChainStep = !!externalSessionId;
156  const isSite = fromSite && !isChainStep;
157  const visitorId = `understand:${rootId}:${Date.now()}`;
158
159  // OrchestratorRuntime handles: session, MCP, chat, lock, cleanup.
160  // Lock namespace "understand" with the run ID as key prevents concurrent runs.
161  const rt = new OrchestratorRuntime({
162    rootId,
163    userId,
164    username,
165    visitorId,
166    sessionType: SESSION_TYPES.UNDERSTANDING_ORCHESTRATE,
167    description: `Understanding: ${runPerspective}`,
168    modeKeyForLlm: "tree:understand",
169    source,
170    slot: "understanding",
171    llmPriority: LLM_PRIORITY?.BACKGROUND || 4,
172    lockNamespace: "understand",
173    lockKey: understandingRunId,
174  });
175
176  if (isChainStep) {
177    await rt.attach({
178      sessionId: externalSessionId,
179      mainChatId: externalRootChatId || null,
180      llmProvider: undefined,
181      chainIndex: startingChainIndex || 1,
182      connectMcp: true,
183    });
184  } else {
185    const ok = await rt.init(`Understanding tree ${rootId} (${runPerspective})`);
186    if (!ok) {
187      return { success: false, error: "This understanding run is already being processed" };
188    }
189  }
190
191  // Store runId in session meta so the stop route can find it
192  updateSessionMeta(rt.sessionId, { runId: understandingRunId });
193
194  if (isSite) {
195    setActiveNavigator(userId, rt.sessionId);
196    const sess = getSession(rt.sessionId);
197    emitToUser(userId, WS.NAVIGATOR_SESSION, {
198      sessionId: rt.sessionId,
199      type: sess?.type || SESSION_TYPES.UNDERSTANDING_ORCHESTRATE,
200      description: sess?.description || `Understanding: ${runPerspective}`,
201    });
202  }
203
204  let nodesProcessed = 0;
205
206  log.verbose("Understanding", `Understand orchestrator started for run ${understandingRunId} (${runPerspective}, ${nodeCount} nodes)`);
207
208  try {
209    rt.trackStep("tree:understand", {
210      input: `Run ${understandingRunId} (${runPerspective})`,
211      output: { understandingRunId, perspective: runPerspective, nodeCount },
212    });
213
214    // Compression loop
215    let emptyRetries = 0;
216    const MAX_EMPTY_RETRIES = 3;
217    let lastEmptyNodeId = null;
218
219    while (true) {
220      if (rt.aborted) throw new Error("Session stopped");
221
222      const payload = await getNextCompressionPayloadForLLM(understandingRunId, userId);
223      if (!payload) break;
224
225      const prompt = buildSummarizationPrompt(payload);
226
227      // rt.runStep handles: switchMode, processMessage, lock renewal,
228      // abort check, chain tracking. Returns { parsed, raw, llmProvider }.
229      const { parsed: summary, raw: result } = await rt.runStep("tree:understand-summarize", {
230        prompt,
231        modeCtx: {
232          perspective: runPerspective,
233          nodeType: payload.inputs?.[0]?.nodeType || null,
234          clearHistory: true,
235        },
236        input: `${payload.mode}: ${payload.inputs[0]?.nodeName || "unknown"}`,
237      });
238
239      // rt.runStep returns parsed via parseJsonSafe. For plain text summaries,
240      // parseJsonSafe returns the string as-is. Extract the text.
241      const summaryText = typeof summary === "string"
242        ? summary.trim()
243        : (result?.answer || result?.content || "").trim();
244
245      if (!summaryText) {
246        const nodeId = payload.target.understandingNodeId;
247        if (nodeId === lastEmptyNodeId) {
248          emptyRetries++;
249        } else {
250          emptyRetries = 1;
251          lastEmptyNodeId = nodeId;
252        }
253
254        log.warn("Understanding", `Empty summary for node ${nodeId}, attempt ${emptyRetries}/${MAX_EMPTY_RETRIES}`);
255
256        if (emptyRetries >= MAX_EMPTY_RETRIES) {
257          await UnderstandingRun.findByIdAndUpdate(understandingRunId, {
258            $unset: { pendingMerge: "" },
259          });
260          await commitCompressionResult({
261            mode: payload.mode,
262            understandingRunId,
263            encoding: "(empty)",
264            understandingNodeId: nodeId,
265            currentLayer: payload.mode === "leaf" ? 0 : payload.target.nextLayer,
266            userId,
267            wasAi: true,
268            chatId: rt.mainChatId,
269            sessionId: rt.sessionId,
270          });
271          nodesProcessed++;
272          log.warn("Understanding", `Committed placeholder for stuck node ${nodeId}, moving on`);
273          emptyRetries = 0;
274          lastEmptyNodeId = null;
275        }
276        continue;
277      }
278
279      emptyRetries = 0;
280      lastEmptyNodeId = null;
281
282      await commitCompressionResult({
283        mode: payload.mode,
284        understandingRunId,
285        encoding: summaryText,
286        understandingNodeId: payload.target.understandingNodeId,
287        currentLayer: payload.mode === "leaf" ? 0 : payload.target.nextLayer,
288        userId,
289        wasAi: true,
290        chatId: rt.mainChatId,
291        sessionId: rt.sessionId,
292      });
293
294      nodesProcessed++;
295      updateSessionMeta(rt.sessionId, { nodeId: payload.target.realNodeId || rootId });
296
297      if (isSite) {
298        emitNavigate({
299          userId,
300          url: `/api/v1/root/${rootId}/understandings/run/${understandingRunId}/${payload.target.understandingNodeId}?html`,
301          sessionId: rt.sessionId,
302        });
303      }
304
305      log.debug("Understanding", `  ${payload.mode} node ${payload.inputs[0]?.nodeName} (${nodesProcessed}/${nodeCount})`);
306    }
307
308    // Finalize
309    const finalRun = await UnderstandingRun.findById(understandingRunId).lean();
310    const rootEncoding = await getRootEncoding(finalRun);
311
312    const completedAt = new Date();
313    await UnderstandingRun.findByIdAndUpdate(
314      understandingRunId,
315      rootEncoding
316        ? {
317            status: "completed",
318            lastCompletedAt: completedAt,
319            $push: { encodingHistory: { encoding: rootEncoding, completedAt } },
320          }
321        : { status: "completed", lastCompletedAt: completedAt },
322    );
323
324    rt.setResult(
325      rootEncoding || `Processed ${nodesProcessed} nodes`,
326      "tree:understand",
327    );
328
329    if (isSite) {
330      emitNavigate({
331        userId,
332        url: `/api/v1/root/${rootId}/understandings/run/${understandingRunId}?html`,
333        sessionId: rt.sessionId,
334      });
335    }
336
337    log.verbose("Understanding", `Understanding complete for root ${rootId} (${nodesProcessed} nodes processed)`);
338
339    return {
340      success: true,
341      understandingRunId,
342      perspective: runPerspective,
343      nodeCount,
344      nodesProcessed,
345      rootEncoding,
346    };
347  } catch (err) {
348    const isAbort = rt.aborted || err.name === "AbortError" || err.message?.includes("aborted");
349    if (isAbort) {
350      log.info("Understanding", `Run ${understandingRunId} stopped by user (${nodesProcessed} nodes processed)`);
351    } else {
352      log.error("Understanding", `Understanding orchestration error for root ${rootId}:`, err.message);
353      rt.setError(err.message, "tree:understand");
354    }
355    return { success: isAbort, stopped: isAbort, error: isAbort ? null : err.message };
356  } finally {
357    try {
358      const currentRun = await UnderstandingRun.findById(understandingRunId).select("status").lean();
359      if (currentRun?.status === "running") {
360        await UnderstandingRun.findByIdAndUpdate(understandingRunId, { status: "completed" });
361      }
362    } catch (err) { log.debug("Understanding", "Failed to finalize run status:", err.message); }
363    // rt.cleanup() releases the lock, finalizes chat, closes MCP, ends session.
364    await rt.cleanup();
365  }
366}
367
1import log from "../../seed/log.js";
2import express from "express";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import authenticate from "../../seed/middleware/authenticate.js";
5import {
6  createUnderstandingRun,
7  findOrCreateUnderstandingRun,
8} from "./core.js";
9import UnderstandingRun from "./understandingRun.js";
10import UnderstandingNode from "./understandingNode.js";
11import { getNotes } from "../../seed/tree/notes.js";
12import { userHasLlm } from "../../seed/llm/conversation.js";
13import { orchestrateUnderstanding } from "./pipeline.js";
14import { getSessionsForUser, endSession, SESSION_TYPES } from "../../seed/ws/sessionRegistry.js";
15import { renderUnderstandingRun, renderUnderstandingNode, renderUnderstandingsList, renderRunNodeView, buildRunCards, buildRunNodeInputsHtml } from "./html.js";
16import { getExtension } from "../loader.js";
17
18let htmlAuth = authenticate;
19export function resolveHtmlAuth() {
20  const htmlExt = getExtension("html-rendering");
21  if (htmlExt?.exports?.urlAuth) htmlAuth = htmlExt.exports.urlAuth;
22}
23
24// Models wired from init via setModels
25let Node = null;
26let Contribution = null;
27export function setModels(models) {
28  Node = models.Node;
29  Contribution = models.Contribution;
30}
31
32const router = express.Router();
33
34function buildQueryString(req) {
35  const allowedParams = ["token", "html"];
36
37  const filtered = Object.entries(req.query)
38    .filter(([key]) => allowedParams.includes(key))
39    .map(([key, val]) =>
40      val === "" ? key : `${key}=${encodeURIComponent(val)}`,
41    )
42    .join("&");
43
44  return filtered ? `?${filtered}` : "";
45}
46
47router.post("/root/:nodeId/understandings", authenticate, async (req, res) => {
48  try {
49    if (req.authType === "shareToken" || req.authType === "publicAccess") {
50      return sendError(res, 403, ERR.FORBIDDEN, "Share tokens cannot create understanding runs");
51    }
52    const { nodeId } = req.params;
53    const { perspective = "general", incremental = false } = req.body;
54    const userId = req.userId;
55
56    const rootNode = await Node.findById(nodeId).lean();
57    if (!rootNode) {
58      return sendError(res, 404, ERR.NODE_NOT_FOUND, "Root node not found");
59    }
60
61    // Only tree owner or contributors can create understanding runs
62    const isOwner = rootNode.rootOwner?.toString() === userId;
63    const isContributor = (rootNode.contributors || []).some(c => c.toString() === userId);
64    if (!isOwner && !isContributor) {
65      return sendError(res, 403, ERR.FORBIDDEN, "Only tree owner or contributors can create understanding runs");
66    }
67
68    // Check LLM access — tree owner needs an LLM or root must have one assigned
69    const hasUserLlm = await userHasLlm(userId);
70    const hasRootLlm = !!(rootNode.llmDefault && rootNode.llmDefault !== "none");
71    if (!hasUserLlm && !hasRootLlm) {
72      return sendError(res, 503, ERR.LLM_NOT_CONFIGURED, "No LLM connection. Visit /setup to set one up.");
73    }
74
75    const result = incremental
76      ? await findOrCreateUnderstandingRun(nodeId, userId, perspective)
77      : await createUnderstandingRun(nodeId, userId, perspective);
78    if ("html" in req.query) {
79      return res.redirect(
80        `/api/v1/root/${nodeId}/understandings/run/${
81          result.understandingRunId
82        }?token=${req.query.token ?? ""}&html`,
83      );
84    }
85    return sendOk(res, {
86      rootNodeId: nodeId,
87      ...result,
88    }, 201);
89  } catch (err) {
90 log.error("Understanding", "Error creating understanding run:", err);
91    return sendError(res, 500, ERR.INTERNAL, err.message);
92  }
93});
94
95router.get(
96  "/root/:nodeId/understandings/run/:runId",
97  htmlAuth,
98  async (req, res) => {
99    try {
100      const { runId, nodeId } = req.params;
101      const qs = buildQueryString(req);
102
103      const run = await UnderstandingRun.findById(runId).lean();
104      if (!run) {
105        return sendError(res, 404, ERR.NODE_NOT_FOUND, "UnderstandingRun not found");
106      }
107
108      const topology = new Map(
109        Object.entries(run.topology || {}).map(([k, v]) => [String(k), v]),
110      );
111
112      const uNodeIds = Object.values(run.nodeMap ?? {}).map(String);
113
114      const nodes = await UnderstandingNode.find({
115        _id: { $in: uNodeIds },
116      })
117        .select("_id realNodeId perspectiveStates")
118        .lean();
119
120      const byId = new Map(nodes.map((n) => [String(n._id), n]));
121
122      // Load real node names for display
123      const realNodeIds = nodes.map((n) => n.realNodeId);
124      const realNodes = await Node.find({ _id: { $in: realNodeIds } })
125        .select("_id name")
126        .lean();
127      const realNameById = new Map(
128        realNodes.map((n) => [String(n._id), n.name]),
129      );
130
131      // Safe perspectiveStates accessor
132      const getPS = (node, rid) => {
133        const ps = node?.perspectiveStates;
134        if (!ps) return null;
135        if (ps instanceof Map) return ps.get(rid) || ps.get(String(rid));
136        return ps[rid] || ps[String(rid)] || null;
137      };
138
139      const ridStr = String(run._id);
140
141      // Completion check
142      const completed = {};
143      for (const node of nodes) {
144        const topo = topology.get(String(node._id));
145        const state = getPS(node, ridStr);
146        completed[node._id] =
147          !!state && !!topo && state.currentLayer >= topo.mergeLayer;
148      }
149
150      // Dirty node detection — compare contribution snapshots to current counts
151      const contribCounts = await Contribution.aggregate([
152        {
153          $match: {
154            nodeId: { $in: realNodeIds },
155            action: { $ne: "understanding" },
156          },
157        },
158        { $group: { _id: "$nodeId", count: { $sum: 1 } } },
159      ]);
160      const countMap = new Map(contribCounts.map((c) => [c._id, c.count]));
161
162      const dirtyNodes = {};
163      let dirtyCount = 0;
164      for (const node of nodes) {
165        const state = getPS(node, ridStr);
166        const currentCount = countMap.get(node.realNodeId) || 0;
167        const storedCount = state?.contributionSnapshot;
168        const isDirty =
169          !state ||
170          storedCount === null ||
171          storedCount === undefined ||
172          storedCount !== currentCount;
173        dirtyNodes[node._id] = isDirty;
174        if (isDirty) dirtyCount++;
175      }
176
177      // Propagate dirty status up the parent chain
178      for (const [uNodeId, isDirty] of Object.entries(dirtyNodes)) {
179        if (!isDirty) continue;
180        let parentId = topology.get(String(uNodeId))?.parent;
181        while (parentId) {
182          if (dirtyNodes[parentId]) break; // already dirty, ancestors will be too
183          dirtyNodes[parentId] = true;
184          dirtyCount++;
185          parentId = topology.get(String(parentId))?.parent;
186        }
187      }
188
189      // JSON mode
190      const wantHtml = Object.prototype.hasOwnProperty.call(req.query, "html");
191      if (!wantHtml || !getExtension("html-rendering")) {
192        return sendOk(res, {
193          understandingRunId: run._id,
194          rootNodeId: run.rootNodeId,
195          perspective: run.perspective,
196          maxDepth: run.maxDepth,
197          status: run.status || "completed",
198          createdAt: run.createdAt,
199          lastCompletedAt: run.lastCompletedAt || null,
200          encodingHistory: run.encodingHistory || [],
201          nodeMap: run.nodeMap ?? {},
202          completed,
203          dirtyNodes,
204          dirtyCount,
205          nodes,
206          topology: run.topology,
207        });
208      }
209
210      // Build tree
211      const buildTree = (uNodeId) => {
212        const node = byId.get(String(uNodeId));
213        const topo = topology.get(String(uNodeId));
214        if (!node || !topo) return null;
215
216        const state = getPS(node, ridStr);
217
218        return {
219          ...node,
220          name: realNameById.get(String(node.realNodeId)) || "Untitled",
221          depthFromRoot: topo.depthFromRoot,
222          mergeLayer: topo.mergeLayer,
223          childCount: topo.children.length,
224          encoding: state?.encoding || "",
225          layer: state?.currentLayer ?? "-",
226          childNodes: topo.children.map(buildTree).filter(Boolean),
227        };
228      };
229
230      const rootEntry = [...topology.entries()].find(
231        ([, topo]) => topo.parent === null,
232      );
233
234      let rootFinalEncoding = null;
235      let rootIsCompleted = false;
236
237      if (rootEntry) {
238        const rootUNodeId = rootEntry[0];
239        rootIsCompleted = !!completed[rootUNodeId];
240        const rootNode = byId.get(String(rootUNodeId));
241        const rootState = getPS(rootNode, ridStr);
242        if (rootState?.encoding) rootFinalEncoding = rootState.encoding;
243      }
244
245      // Previous final encoding from encoding history (last completed snapshot)
246      const previousFinalEncoding =
247        run.encodingHistory?.length > 0
248          ? run.encodingHistory[run.encodingHistory.length - 1].encoding
249          : null;
250
251      const tree = rootEntry ? buildTree(rootEntry[0]) : null;
252
253      // Progress
254      const totalNodes = nodes.length;
255      const completedCount = Object.values(completed).filter(Boolean).length;
256      const progressPercent =
257        totalNodes > 0 ? Math.round((completedCount / totalNodes) * 100) : 0;
258
259      const createdDate = new Date(run.createdAt).toLocaleString();
260
261      return res.send(
262        renderUnderstandingRun({
263          run,
264          qs,
265          nodes,
266          completed,
267          dirtyNodes,
268          dirtyCount,
269          totalNodes,
270          completedCount,
271          progressPercent,
272          rootFinalEncoding,
273          previousFinalEncoding,
274          rootIsCompleted,
275          tree,
276          createdDate,
277        }),
278      );
279    } catch (err) {
280 log.error("Understanding", "Error fetching UnderstandingRun:", err);
281      return sendError(res, 500, ERR.INTERNAL, err.message);
282    }
283  },
284);
285
286router.get(
287  "/root/:nodeId/understandings/:understandingNodeId",
288  htmlAuth,
289  async (req, res) => {
290    try {
291      const { understandingNodeId, nodeId } = req.params;
292      const { runId } = req.query;
293
294      const uNode =
295        await UnderstandingNode.findById(understandingNodeId).lean();
296      if (!uNode) {
297        return sendError(res, 404, ERR.NODE_NOT_FOUND, "UnderstandingNode not found");
298      }
299
300      const realNode = await Node.findById(uNode.realNodeId)
301        .select("name metadata")
302        .lean();
303
304      const _pMeta1 = realNode?.metadata instanceof Map ? realNode.metadata.get("prestige") : realNode?.metadata?.prestige;
305      const realNodePrestige = _pMeta1?.current || 0;
306
307      let run = null;
308      let structure = null;
309
310      if (runId) {
311        run = await UnderstandingRun.findById(runId).lean();
312        if (!run) {
313          return sendError(res, 404, ERR.NODE_NOT_FOUND, "UnderstandingRun not found");
314        }
315
316        const topo = run.topology?.[understandingNodeId];
317        if (topo) {
318          structure = {
319            depthFromRoot: topo.depthFromRoot,
320            mergeLayer: topo.mergeLayer,
321            childrenCount: topo.children.length,
322          };
323        }
324      }
325
326      const notesResult = await getNotes({
327        nodeId: realNode._id,
328      });
329
330      const encodingHistory = Object.entries(uNode.perspectiveStates || {}).map(
331        ([stateRunId, state]) => {
332          const isCurrentRun = runId && stateRunId === runId;
333          const isCompleted =
334            run &&
335            run.topology?.[understandingNodeId] &&
336            state.currentLayer === run.topology[understandingNodeId].mergeLayer;
337
338          return {
339            runId: stateRunId,
340            perspective: state.perspective,
341            currentLayer: state.currentLayer,
342            encoding: state.encoding,
343            updatedAt: state.updatedAt,
344            isCurrentRun,
345            isCompleted,
346          };
347        },
348      );
349
350      const data = {
351        understandingNodeId: uNode._id,
352        realNode: {
353          id: uNode.realNodeId,
354          name: realNode?.name ?? "Unknown",
355        },
356        runContext: run
357          ? {
358              runId: run._id,
359              perspective: run.perspective,
360              structure,
361            }
362          : null,
363        encodingHistory,
364        createdAt: uNode.createdAt,
365        notesToBeCompressed: (notesResult?.notes ?? []).map((n) => ({
366          content: n.content,
367          username: n.username,
368          createdAt: n.createdAt,
369        })),
370      };
371
372      const wantHtml = Object.prototype.hasOwnProperty.call(req.query, "html");
373      if (!wantHtml || !getExtension("html-rendering")) {
374        return sendOk(res, data);
375      }
376
377      const qs = buildQueryString(req);
378      const hasEncodings = encodingHistory.length > 0;
379
380      const backTreeUrl = `/api/v1/root/${nodeId}${qs}`;
381      const backUnderstandingsUrl = `/api/v1/root/${nodeId}/understandings${qs}`;
382      const realNodeUrl = `/api/v1/node/${data.realNode.id}${qs}`;
383
384      return res.send(
385        renderUnderstandingNode({
386          data,
387          nodeId,
388          qs,
389          encodingHistory,
390          hasEncodings,
391          backTreeUrl,
392          backUnderstandingsUrl,
393          realNodeUrl,
394        }),
395      );
396    } catch (err) {
397 log.error("Understanding", "Error fetching UnderstandingNode:", err);
398      return sendError(res, 500, ERR.INTERNAL, err.message);
399    }
400  },
401);
402router.get("/root/:nodeId/understandings", htmlAuth, async (req, res) => {
403  try {
404    const { nodeId } = req.params;
405    const queryString = buildQueryString(req);
406
407    const root = await Node.findById(nodeId).select("_id name userId").lean();
408    if (!root) {
409      return sendError(res, 404, ERR.TREE_NOT_FOUND, "Root not found");
410    }
411
412    const runs = await UnderstandingRun.find({ rootNodeId: nodeId })
413      .sort({ createdAt: -1 })
414      .lean();
415
416    const data = {
417      rootNodeId: root._id,
418      rootName: root.name,
419      understandings: runs.map((r) => ({
420        _id: r._id,
421        perspective: r.perspective,
422        maxDepth: r.maxDepth,
423        createdAt: r.createdAt,
424      })),
425    };
426
427    const wantHtml = "html" in req.query;
428    if (!wantHtml || !getExtension("html-rendering")) {
429      return sendOk(res, data);
430    }
431
432    const runCards = buildRunCards({
433      understandings: data.understandings,
434      nodeId,
435      queryString,
436    });
437
438    return res.send(
439      renderUnderstandingsList({
440        data,
441        nodeId,
442        queryString,
443        runCards,
444      }),
445    );
446  } catch (err) {
447 log.error("Understanding", "Error fetching understandings:", err);
448    sendError(res, 500, ERR.INTERNAL, err.message);
449  }
450});
451
452router.get(
453  "/root/:nodeId/understandings/run/:runId/:understandingNodeId",
454  htmlAuth,
455  async (req, res) => {
456    try {
457      const { runId, understandingNodeId, nodeId } = req.params;
458      const qs = buildQueryString(req);
459
460      const root = await Node.findById(nodeId).select("_id name userId").lean();
461      if (!root) {
462        return sendError(res, 404, ERR.TREE_NOT_FOUND, "Root not found");
463      }
464
465      const uNode =
466        await UnderstandingNode.findById(understandingNodeId).lean();
467      if (!uNode) {
468        return sendError(res, 404, ERR.NODE_NOT_FOUND, "UnderstandingNode not found");
469      }
470
471      const realNode = await Node.findById(uNode.realNodeId)
472        .select("name metadata")
473        .lean();
474
475      const _pMeta2 = realNode?.metadata instanceof Map ? realNode.metadata.get("prestige") : realNode?.metadata?.prestige;
476      const realNodePrestige2 = _pMeta2?.current || 0;
477
478      const run = await UnderstandingRun.findById(runId).lean();
479      if (!run) {
480        return sendError(res, 404, ERR.NODE_NOT_FOUND, "UnderstandingRun not found");
481      }
482
483      // Safe perspectiveStates accessor
484      const getPS = (node, rid) => {
485        const ps = node?.perspectiveStates;
486        if (!ps) return null;
487        if (ps instanceof Map) return ps.get(rid) || ps.get(String(rid));
488        return ps[rid] || ps[String(rid)] || null;
489      };
490
491      const ridStr = String(runId);
492      const state = getPS(uNode, ridStr);
493      const finalMessage = state?.encoding ?? null;
494      const isCompleted = Boolean(finalMessage);
495
496      /* =========================
497         Determine leaf vs merge from topology
498         ========================= */
499      const topology = new Map(
500        Object.entries(run.topology || {}).map(([k, v]) => [String(k), v]),
501      );
502      const topo = topology.get(String(understandingNodeId));
503      const childIds = topo?.children || [];
504      const isLeaf = childIds.length === 0;
505
506      /* =========================
507         Leaf: load notes
508         Merge: load child encodings
509         ========================= */
510      let chats = [];
511      let childEncodings = [];
512
513      if (isLeaf) {
514        const notesResult = await getNotes({
515          nodeId: realNode._id,
516        });
517
518        chats = (notesResult?.notes ?? []).map((n) => ({
519          role: n.username === "assistant" ? "assistant" : "user",
520          content: n.content,
521          username: n.username,
522          createdAt: n.createdAt,
523        }));
524      } else {
525        // Load child understanding nodes
526        const childUNodes = await UnderstandingNode.find({
527          _id: { $in: childIds.map(String) },
528        }).lean();
529
530        // Load their real node names
531        const childRealIds = childUNodes.map((n) => n.realNodeId);
532        const childRealNodes = await Node.find({ _id: { $in: childRealIds } })
533          .select("_id name")
534          .lean();
535        const childNameById = new Map(
536          childRealNodes.map((n) => [String(n._id), n.name]),
537        );
538
539        childEncodings = childUNodes.map((child) => {
540          const childState = getPS(child, ridStr);
541          const childTopo = topology.get(String(child._id));
542          return {
543            understandingNodeId: child._id,
544            realNodeId: child.realNodeId,
545            name: childNameById.get(String(child.realNodeId)) || "Untitled",
546            encoding: childState?.encoding ?? null,
547            currentLayer: childState?.currentLayer ?? null,
548            mergeLayer: childTopo?.mergeLayer ?? null,
549            isComplete:
550              childState &&
551              childTopo &&
552              childState.currentLayer >= childTopo.mergeLayer,
553          };
554        });
555      }
556
557      /* =========================
558         JSON Response
559         ========================= */
560      const data = {
561        runId,
562        understandingNodeId,
563        realNode: {
564          id: uNode.realNodeId,
565          name: realNode?.name ?? "Unknown",
566        },
567        perspective: run.perspective,
568        finalMessage,
569        isLeaf,
570        chats: isLeaf ? chats : [],
571        childEncodings: isLeaf ? [] : childEncodings,
572        isCompleted,
573        updatedAt: state?.updatedAt ?? null,
574      };
575
576      const wantHtml = Object.prototype.hasOwnProperty.call(req.query, "html");
577      if (!wantHtml || !getExtension("html-rendering")) {
578        return sendOk(res, data);
579      }
580
581      /* =========================
582         HTML Rendering
583         ========================= */
584      const backTreeUrl = `/api/v1/root/${nodeId}${qs}`;
585      const backRunUrl = `/api/v1/root/${nodeId}/understandings/run/${runId}${qs}`;
586
587      const { inputsHtml, inputsSectionTitle } = buildRunNodeInputsHtml({
588        isLeaf,
589        isCompleted,
590        chats,
591        childEncodings,
592        nodeId,
593        runId,
594        qs,
595      });
596
597      return res.send(
598        renderRunNodeView({
599          data,
600          nodeId,
601          runId,
602          qs,
603          isLeaf,
604          isCompleted,
605          childEncodings,
606          chats,
607          finalMessage,
608          inputsHtml,
609          inputsSectionTitle,
610          backTreeUrl,
611          backRunUrl,
612        }),
613      );
614    } catch (err) {
615 log.error("Understanding", "Error fetching run node view:", err);
616      return sendError(res, 500, ERR.INTERNAL, err.message);
617    }
618  },
619);
620
621// ─────────────────────────────────────────────────────────────────────────
622// Orchestration endpoints (moved from routes/api/orchestrate.js)
623// ─────────────────────────────────────────────────────────────────────────
624
625router.post("/root/:nodeId/understandings/run/:runId/orchestrate", authenticate, async (req, res) => {
626  if (req.authType === "shareToken" || req.authType === "publicAccess") {
627    return sendError(res, 403, ERR.FORBIDDEN, "Share tokens cannot trigger understanding runs");
628  }
629  const { nodeId, runId } = req.params;
630  const userId = req.userId;
631  const username = req.username;
632  const fromSite = req.body?.source === "user";
633
634  const rootNode = await Node.findById(nodeId).select("rootOwner contributors llmDefault metadata").lean();
635  const isOwner = rootNode?.rootOwner?.toString() === userId;
636  const isContributor = (rootNode?.contributors || []).some(c => c.toString() === userId);
637  if (!isOwner && !isContributor) {
638    return sendError(res, 403, ERR.FORBIDDEN, "Only tree owner or contributors can run understandings");
639  }
640  const hasRootLlm = !!(rootNode?.llmDefault && rootNode.llmDefault !== "none");
641  if (!hasRootLlm && !(await userHasLlm(userId))) {
642    return sendError(res, 503, ERR.LLM_NOT_CONFIGURED, "No LLM connection. Visit /setup to set one up.");
643  }
644
645  try {
646    const result = await orchestrateUnderstanding({ rootId: nodeId, userId, username, runId, fromSite });
647    if ("html" in req.query && result.success) {
648      return res.redirect(`/api/v1/root/${nodeId}/understandings/run/${runId}?token=${req.query.token ?? ""}&html`);
649    }
650    return sendOk(res, result);
651  } catch (err) {
652 log.error("Understanding", "Understanding orchestration error:", err.message);
653    return sendError(res, 500, ERR.INTERNAL, err.message);
654  }
655});
656
657router.post("/root/:nodeId/understandings/run/:runId/stop", authenticate, async (req, res) => {
658  if (req.authType === "shareToken" || req.authType === "publicAccess") {
659    return sendError(res, 403, ERR.FORBIDDEN, "Share tokens cannot stop understanding runs");
660  }
661  const { runId } = req.params;
662  const userId = req.userId;
663  const sessions = getSessionsForUser(userId);
664  const match = sessions.find((s) => s.type === SESSION_TYPES.UNDERSTANDING_ORCHESTRATE && s.meta?.runId === runId);
665  if (!match) return sendError(res, 404, ERR.NODE_NOT_FOUND, "No active session found for this run");
666  endSession(match.sessionId);
667  return sendOk(res);
668});
669
670export default router;
671
1import { z } from "zod";
2import mongoose from "mongoose";
3import {
4  createUnderstandingRun,
5  listUnderstandingRuns,
6  getNextCompressionPayloadForLLM,
7  commitCompressionResult,
8} from "./core.js";
9
10export default [
11  {
12    name: "understanding-create",
13    description: "Create an understanding run (shadow tree + merge rules).",
14    schema: {
15      rootNodeId: z.string().describe("Root node to build understanding from."),
16      perspective: z
17        .string()
18        .optional()
19        .default("general")
20        .describe("Perspective for this understanding run."),
21      userId: z.string().describe("Injected by server. Ignore."),
22      chatId: z
23        .string()
24        .nullable()
25        .optional()
26        .describe("Injected by server. Ignore."),
27      sessionId: z
28        .string()
29        .nullable()
30        .optional()
31        .describe("Injected by server. Ignore."),
32    },
33    annotations: {
34      readOnlyHint: false,
35      destructiveHint: false,
36      idempotentHint: false,
37      openWorldHint: false,
38    },
39    handler: async ({ rootNodeId, perspective, userId, chatId, sessionId }) => {
40      const result = await createUnderstandingRun(
41        rootNodeId,
42        userId,
43        perspective,
44        true,
45        chatId,
46        sessionId,
47      );
48
49      return {
50        content: [
51          {
52            type: "text",
53            text: JSON.stringify(
54              {
55                message: "Understanding run created",
56                ...result,
57              },
58              null,
59              2,
60            ),
61          },
62        ],
63      };
64    },
65  },
66  {
67    name: "understanding-list",
68    description: "Lists existing understanding runs (perspectives) for a given root node.",
69    schema: {
70      rootNodeId: z
71        .string()
72        .describe("Root node ID to list understandings for."),
73      userId: z.string().describe("Injected by server. Ignore."),
74      chatId: z
75        .string()
76        .nullable()
77        .optional()
78        .describe("Injected by server. Ignore."),
79      sessionId: z
80        .string()
81        .nullable()
82        .optional()
83        .describe("Injected by server. Ignore."),
84    },
85    annotations: {
86      readOnlyHint: true,
87      destructiveHint: false,
88      idempotentHint: true,
89      openWorldHint: false,
90    },
91    handler: async ({ rootNodeId }) => {
92      try {
93        const data = await listUnderstandingRuns(rootNodeId);
94
95        return {
96          content: [
97            {
98              type: "text",
99              text: JSON.stringify(data, null, 2),
100            },
101          ],
102        };
103      } catch (err) {
104        return {
105          content: [
106            {
107              type: "text",
108              text: `Failed to list understandings: ${err.message}`,
109            },
110          ],
111          isError: true,
112        };
113      }
114    },
115  },
116  {
117    name: "understanding-next",
118    description: "Get the next summarization payload for the LLM.",
119    schema: {
120      understandingRunId: z.string().describe("UnderstandingRun ID."),
121      rootNodeId: z.string().describe("Root node of this understanding run"),
122    },
123    annotations: {
124      readOnlyHint: true,
125      destructiveHint: false,
126      idempotentHint: false,
127      openWorldHint: false,
128    },
129    handler: async ({ understandingRunId, rootNodeId }) => {
130      // 1. Load run to get perspective (authoritative)
131      const run = await mongoose.models.UnderstandingRun.findById(understandingRunId).lean();
132      if (!run) {
133        return {
134          content: [
135            {
136              type: "text",
137              text: JSON.stringify(
138                { error: "UnderstandingRun not found" },
139                null,
140                2,
141              ),
142            },
143          ],
144        };
145      }
146
147      // 2. Get next compression payload (pure logic)
148      const payload = await getNextCompressionPayloadForLLM(understandingRunId);
149
150      if (!payload) {
151        return {
152          content: [
153            {
154              type: "text",
155              text: JSON.stringify(
156                {
157                  done: true,
158                  message: "No more summarization steps remaining.",
159                },
160                null,
161                2,
162              ),
163            },
164          ],
165        };
166      }
167
168      // 3. Build explicit LLM instructions (THIS IS THE KEY)
169      const instructions = `
170You are performing a summarization step for an "understanding run".
171
172Perspective:
173"${run.perspective}"
174CRITICAL RULES:
175- You MUST NOT invent or guess any IDs or layer numbers.
176- For LEAF mode:
177  - Use mode = "leaf"
178  - Use understandingNodeId exactly as provided in target.understandingNodeId
179  - Do NOT provide currentLayer (it will be assumed as 0)
180- For MERGE mode:
181  - Use mode = "merge"
182  - You MUST set currentLayer EXACTLY equal to target.nextLayer
183  - Do NOT change or recompute the layer number
184
185Summarization Rules:
186- Summarize STRICTLY from this perspective.
187- Ignore information not relevant to this perspective.
188- Preserve key facts, definitions, procedures, and distinctions.
189- Do NOT add new information.
190- Do NOT speculate or infer beyond the inputs.
191- Output must be suitable for hierarchical merging.
192
193Return ONLY the summary text. The system will handle structure.
194Then IMMEDIATELY call understanding-capture with:
195  mode: "${payload.mode}"
196  understandingRunId: "${understandingRunId}"
197  rootNodeId: "${rootNodeId}"
198  ${
199    payload.mode === "leaf"
200      ? `understandingNodeId: "${payload.target.understandingNodeId}"`
201      : `currentLayer: ${payload.target.nextLayer}`
202  }
203  encoding: <your summary>
204`.trim();
205
206      // 4. Attach instructions to payload (LLM-facing)
207      const llmPayload = {
208        ...payload,
209        instructions,
210      };
211
212      // 5. Return to LLM
213      return {
214        content: [
215          {
216            type: "text",
217            text: JSON.stringify(llmPayload, null, 2),
218          },
219        ],
220      };
221    },
222  },
223  {
224    name: "understanding-capture",
225    description: "capture a summarized understanding result.",
226    schema: {
227      mode: z.enum(["leaf", "merge"]),
228
229      understandingRunId: z.string(),
230
231      // leaf only
232      understandingNodeId: z.string().optional(),
233      rootNodeId: z.string().describe("Root node of this understanding run"),
234
235      // merge only
236      currentLayer: z
237        .number()
238        .optional("EXACTLY equal to target.nextLayer from next"),
239
240      encoding: z.string(),
241    },
242    annotations: {
243      readOnlyHint: true,
244      destructiveHint: false,
245      idempotentHint: false,
246      openWorldHint: false,
247    },
248    handler: async ({
249      mode,
250      understandingRunId,
251      understandingNodeId,
252      currentLayer,
253      encoding,
254      rootNodeId,
255      userId,
256      chatId,
257      sessionId,
258    }) => {
259      await commitCompressionResult({
260        mode,
261        understandingRunId,
262        understandingNodeId,
263        currentLayer,
264        encoding,
265        userId,
266        wasAi: true,
267        chatId,
268        sessionId,
269      });
270
271      return {
272        content: [
273          {
274            type: "text",
275            text: JSON.stringify(
276              {
277                message: "Understanding captured successfully",
278                mode,
279                understandingRunId,
280                understandingNodeId,
281                currentLayer,
282              },
283              null,
284              2,
285            ),
286          },
287        ],
288      };
289    },
290  },
291  {
292    name: "understanding-process",
293    description:
294      "Process understanding: commits previous summary (if any) and returns next task. IMMEDIATELY call this tool again with your summary — do not output to chat.",
295    schema: {
296      understandingRunId: z.string().describe("The understanding run ID"),
297      rootNodeId: z.string().describe("Root node ID"),
298      previousResult: z
299        .object({
300          mode: z.enum(["leaf", "merge"]),
301          encoding: z.string().describe("Your summary text goes here"),
302          understandingNodeId: z
303            .string()
304            .optional()
305            .describe("From target.understandingNodeId"),
306          currentLayer: z
307            .number()
308            .optional()
309            .describe("Required for merge mode — from target.nextLayer"),
310        })
311        .optional()
312        .describe(
313          "Omit on first call. Include your summary from previous task on subsequent calls.",
314        ),
315      userId: z.string().describe("Injected by server. Ignore."),
316      chatId: z
317        .string()
318        .nullable()
319        .optional()
320        .describe("Injected by server. Ignore."),
321      sessionId: z
322        .string()
323        .nullable()
324        .optional()
325        .describe("Injected by server. Ignore."),
326    },
327    annotations: {
328      readOnlyHint: false,
329      destructiveHint: false,
330      idempotentHint: false,
331      openWorldHint: false,
332    },
333    handler: async ({
334      understandingRunId,
335      rootNodeId,
336      previousResult,
337      userId,
338      chatId,
339      sessionId,
340    }) => {
341      // 1. Commit previous result if provided
342      if (previousResult) {
343        try {
344          await commitCompressionResult({
345            mode: previousResult.mode,
346            understandingRunId,
347            encoding: previousResult.encoding,
348            understandingNodeId: previousResult.understandingNodeId,
349            currentLayer: previousResult.currentLayer,
350            userId,
351            wasAi: true,
352            chatId,
353            sessionId,
354          });
355        } catch (err) {
356          return {
357            content: [
358              {
359                type: "text",
360                text: JSON.stringify(
361                  {
362                    error: "Failed to commit previous result",
363                    details: err.message,
364                    action: "Fix the parameters and retry.",
365                  },
366                  null,
367                  2,
368                ),
369              },
370            ],
371          };
372        }
373      }
374
375      // 2. Load run
376      const run = await mongoose.models.UnderstandingRun.findById(understandingRunId).lean();
377      if (!run) {
378        return {
379          content: [
380            {
381              type: "text",
382              text: JSON.stringify(
383                { error: "UnderstandingRun not found" },
384                null,
385                2,
386              ),
387            },
388          ],
389        };
390      }
391
392      // 3. Get next payload
393      const payload = await getNextCompressionPayloadForLLM(
394        understandingRunId,
395        userId,
396      );
397
398      // 4. Done
399      if (!payload) {
400        return {
401          content: [
402            {
403              type: "text",
404              text: JSON.stringify(
405                {
406                  done: true,
407                  understandingRunId,
408                  message:
409                    "Understanding complete. All nodes summarized to root.",
410                },
411                null,
412                2,
413              ),
414            },
415          ],
416        };
417      }
418
419      // 5. Build response — INSTRUCTION FIRST then data.
420      //    LLMs attend to the beginning. Leading with a clear action
421      //    prevents the model from outputting JSON as chat text.
422      const isLeaf = payload.mode === "leaf";
423
424      const lines = [
425        `ACTION: Summarize, then CALL understanding-process. Do NOT write to chat.`,
426        ``,
427        `Perspective: "${run.perspective}"`,
428        `Mode: ${payload.mode}`,
429        ``,
430        `Your next tool call MUST be:`,
431        `  understanding-process(`,
432        `    understandingRunId: "${understandingRunId}",`,
433        `    rootNodeId: "${rootNodeId}",`,
434        `    previousResult: {`,
435        `      mode: "${payload.mode}",`,
436        `      encoding: "<YOUR SUMMARY>",`,
437        `      understandingNodeId: "${payload.target.understandingNodeId}"${isLeaf ? "" : ","}`,
438      ];
439
440      if (!isLeaf) {
441        lines.push(`      currentLayer: ${payload.target.nextLayer}`);
442      }
443
444      lines.push(`    }`);
445      lines.push(`  )`);
446      lines.push(``);
447
448      if (isLeaf) {
449        lines.push(`Summarize these notes:`);
450      } else {
451        lines.push(
452          `Merge these child summaries into one summary for node "${payload.inputs[0]?.nodeName}":`,
453        );
454      }
455
456      lines.push(``);
457      lines.push(JSON.stringify(payload.inputs, null, 2));
458
459      return {
460        content: [
461          {
462            type: "text",
463            text: lines.join("\n"),
464          },
465        ],
466      };
467    },
468  },
469];
470
1export { default } from "./models/understandingNode.js";
2
1export { default } from "./models/understandingRun.js";
2

Versions

Version Published Downloads
1.0.1 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star understanding

Comments

Loading comments...

Post comments from the CLI: treeos ext comment understanding "your comment"
Max 3 comments per extension. One star and one flag per user.