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
Loading comments...