1// Services wired from init() via setServices()
2let Node = null;
3let logContribution = async () => {};
4let useEnergy = async () => ({ energyUsed: 0 });
5let _metadata = null;
6
7export function setServices({ models, contributions, metadata }) {
8 Node = models.Node;
9 logContribution = contributions.logContribution;
10 if (metadata) _metadata = metadata;
11}
12export function setEnergyService(energy) { useEnergy = energy.useEnergy; }
13
14async function findNodeById(id) { return Node.findById(id).populate("children"); }
15
16const SYSTEM_KEY_PREFIX = "_auto";
17
18function containsHtml(str) {
19 return /<[a-zA-Z\/][^>]*>/.test(str);
20}
21
22function assertUserWritableKey(rawKey) {
23 if (typeof rawKey !== "string") throw new Error("Invalid key");
24 const key = rawKey.trim();
25 if (!key.length) throw new Error("Key cannot be empty");
26 if (key.startsWith(SYSTEM_KEY_PREFIX)) throw new Error("This key is reserved for system use and cannot be set by users");
27 if (key.includes("\0") || key.includes("\n")) throw new Error("Invalid key format");
28 if (key.length > 128) throw new Error("Key is too long");
29 if (containsHtml(key)) throw new Error("Key cannot contain HTML tags");
30 return key;
31}
32
33function findExistingKey(obj, incomingKey) {
34 if (!obj) return null;
35 const lower = incomingKey.toLowerCase();
36 const keys = obj instanceof Map ? obj.keys() : Object.keys(obj);
37 for (const existingKey of keys) {
38 if (existingKey.toLowerCase() === lower) return existingKey;
39 }
40 return null;
41}
42
43const truncate6 = (n) => Math.trunc(n * 1e6) / 1e6;
44
45function getNodeValues(node) {
46 return { ..._metadata.getExtMeta(node, "values") };
47}
48
49async function setNodeValues(node, values) {
50 await _metadata.setExtMeta(node, "values", values);
51}
52
53function getNodeGoals(node) {
54 return { ..._metadata.getExtMeta(node, "goals") };
55}
56
57async function setValueForNode({
58 nodeId,
59 key,
60 value,
61 userId,
62 wasAi = false,
63 chatId = null,
64 sessionId = null,
65}) {
66 key = assertUserWritableKey(key);
67 if (key.length > 60) throw new Error("Title must be 60 characters or less");
68
69 let numericValue = Number(value);
70 if (isNaN(numericValue) || (typeof value === "string" && value.includes("e"))) {
71 throw new Error("Value must be a valid number");
72 }
73 if (Math.abs(numericValue) > 10_000_000_000) throw new Error("Number must be less than 10 billion");
74 numericValue = truncate6(numericValue);
75
76 const node = await findNodeById(nodeId);
77 if (!node) throw new Error("Node not found");
78 if (node.systemRole) throw new Error("Cannot modify system nodes");
79
80 const values = getNodeValues(node);
81 const existingKey = findExistingKey(values, key);
82 const finalKey = existingKey ?? key;
83
84 const { energyUsed } = await useEnergy({ userId, action: "editValue" });
85
86 values[finalKey] = numericValue;
87 await _metadata.setExtMeta(node, "values", values);
88
89 await logContribution({
90 userId, nodeId, wasAi, chatId, sessionId,
91 action: "editValue",
92 valueEdited: { [finalKey]: numericValue },
93 nodeVersion: "0",
94 energyUsed,
95 });
96
97 return { message: "Value updated successfully." };
98}
99
100async function setGoalForNode({
101 nodeId,
102 key,
103 goal,
104 userId,
105 wasAi = false,
106 chatId = null,
107 sessionId = null,
108}) {
109 key = assertUserWritableKey(key);
110 if (key.length > 60) throw new Error("Title must be 60 characters or less");
111
112 let numericGoal = Number(goal);
113 if (isNaN(numericGoal) || (typeof goal === "string" && goal.includes("e"))) {
114 throw new Error("Goal must be a valid number");
115 }
116 if (Math.abs(numericGoal) > 10_000_000_000) throw new Error("Number must be less than 10 billion");
117 numericGoal = truncate6(numericGoal);
118
119 const node = await findNodeById(nodeId);
120 if (!node) throw new Error("Node not found");
121 if (node.systemRole) throw new Error("Cannot modify system nodes");
122
123 const values = getNodeValues(node);
124 const valueKey = findExistingKey(values, key);
125 if (!valueKey) throw new Error("Goal must match an existing value");
126
127 const goals = getNodeGoals(node);
128 const existingKey = findExistingKey(goals, key);
129 const finalKey = existingKey ?? key;
130
131 const { energyUsed } = await useEnergy({ userId, action: "editGoal" });
132
133 goals[finalKey] = numericGoal;
134 await _metadata.setExtMeta(node, "goals", goals);
135
136 await logContribution({
137 userId, nodeId, wasAi, chatId, sessionId,
138 action: "editGoal",
139 goalEdited: { [finalKey]: numericGoal },
140 nodeVersion: "0",
141 energyUsed,
142 });
143
144 return { message: "Goal updated successfully." };
145}
146
147function stripAutoPrefixFromObject(obj) {
148 const out = {};
149 for (const [key, value] of Object.entries(obj)) {
150 if (key.startsWith("_auto__")) {
151 out[`AUTO_${key.slice("_auto__".length)}`] = value;
152 } else {
153 out[key] = value;
154 }
155 }
156 return out;
157}
158
159function mergeSummedValues(target, source) {
160 const entries = source instanceof Map ? source.entries() : Object.entries(source);
161 for (const [key, value] of entries) {
162 const numeric = Number(value);
163 if (isNaN(numeric)) continue;
164 const existingKey = findExistingKey(target, key);
165 const finalKey = existingKey ?? key;
166 target[finalKey] = (target[finalKey] ?? 0) + numeric;
167 }
168}
169
170function collectNodeValues(node) {
171 const values = getNodeValues(node);
172 return values;
173}
174
175async function getGlobalValuesTreeAndFlat(rootNodeId) {
176 const root = await findNodeById(rootNodeId);
177 if (!root) throw new Error("Node not found");
178
179 const flatTotals = {};
180
181 async function build(node) {
182 const localValues = collectNodeValues(node);
183 const accumulated = { ...localValues };
184
185 const children = [];
186 for (const childId of node.children || []) {
187 const child = await findNodeById(childId);
188 if (!child) continue;
189
190 const childResult = await build(child);
191 children.push(childResult.node);
192 mergeSummedValues(accumulated, childResult.accumulated);
193 }
194
195 mergeSummedValues(flatTotals, localValues);
196
197 return {
198 node: {
199 nodeId: node._id.toString(),
200 nodeName: node.name,
201 localValues: stripAutoPrefixFromObject(localValues),
202 totalValues: stripAutoPrefixFromObject(accumulated),
203 children,
204 },
205 accumulated,
206 };
207 }
208
209 const { node: tree } = await build(root);
210
211 return {
212 flat: stripAutoPrefixFromObject(flatTotals),
213 tree,
214 };
215}
216
217export {
218 setValueForNode,
219 setGoalForNode,
220 getGlobalValuesTreeAndFlat,
221 getNodeValues,
222 setNodeValues,
223 getNodeGoals,
224 stripAutoPrefixFromObject,
225 collectNodeValues,
226 findExistingKey,
227};
228
1/* ------------------------------------------------- */
2/* HTML renderer for values page */
3/* ------------------------------------------------- */
4
5import { baseStyles } from "../html-rendering/html/baseStyles.js";
6import { getExtension } from "../loader.js";
7
8function valuesResolveSlots(slotName, ctx) {
9 try {
10 return getExtension("treeos-base")?.exports?.resolveSlots?.(slotName, ctx) || "";
11 } catch { return ""; }
12}
13
14function isAutoKey(key) {
15 return key.startsWith("_auto__");
16}
17
18function formatAutoKeyName(key) {
19 return key
20 .replace(/^_auto__/, "")
21 .replace(/_/g, " ")
22 .toUpperCase();
23}
24
25function formatAutoValue(key, value) {
26 if (value == null) return "";
27
28 // SOL auto key
29 if (key === "_auto__sol") {
30 return Number(value / 1e9)
31 .toFixed(9)
32 .replace(/\.?0+$/, "");
33 }
34
35 return value;
36}
37
38export function renderValues({
39 nodeId,
40 version,
41 nodeName,
42 nodeVersion,
43 allKeys,
44 values,
45 goals,
46 queryString,
47 token,
48}) {
49 const parsedVersion = Number(version);
50
51 const tableRows =
52 allKeys.length > 0
53 ? allKeys
54 .map((key) => {
55 const isAuto = isAutoKey(key);
56 const displayName = isAuto ? formatAutoKeyName(key) : key;
57 const displayValue = isAuto
58 ? formatAutoValue(key, values[key])
59 : (values[key] ?? "");
60
61 return `
62 <tr>
63 <td><code>${displayName}</code></td>
64
65 <td>
66 ${
67 isAuto
68 ? `<code data-full="${displayValue}">${displayValue}</code>`
69 : `
70 <form
71 method="POST"
72 action="/api/v1/node/${nodeId}/${parsedVersion}/value?token=${token}&html"
73 class="value-form"
74 >
75 <input type="hidden" name="key" value="${key}" />
76 <input
77 type="number"
78 name="value"
79 value="${displayValue}"
80 data-original="${displayValue}"
81 step="any"
82 placeholder="0"
83 />
84 <button type="submit" class="save-btn" style="display:none;">Save</button>
85 </form>
86 `
87 }
88 </td>
89
90 <!-- GOALS ARE ALWAYS EDITABLE -->
91 <td>
92 <form
93 method="POST"
94 action="/api/v1/node/${nodeId}/${parsedVersion}/goal?token=${token}&html"
95 class="value-form"
96 >
97 <input type="hidden" name="key" value="${key}" />
98 <input
99 type="number"
100 name="goal"
101 value="${goals[key] ?? ""}"
102 data-original="${goals[key] ?? ""}"
103 step="any"
104 placeholder="0"
105 />
106 <button type="submit" class="save-btn" style="display:none;">Save</button>
107 </form>
108 </td>
109 </tr>
110 `;
111 })
112 .join("")
113 : `
114 <tr>
115 <td colspan="3" class="empty-state">
116 No values or goals set yet. Add one below to get started! 👇
117 </td>
118 </tr>
119 `;
120
121 return `
122 <!DOCTYPE html>
123 <html lang="en">
124 <head>
125 <meta charset="UTF-8">
126 <meta name="viewport" content="width=device-width, initial-scale=1.0">
127 <meta name="theme-color" content="#667eea">
128 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
129 <title>${nodeName} — Values & Goals</title>
130<style>
131${baseStyles}
132
133/* =========================================================
134 UNIFIED GLASS BUTTON SYSTEM
135 ========================================================= */
136
137.glass-btn,
138button,
139.back-link,
140.value-form button {
141 position: relative;
142 overflow: hidden;
143
144 padding: 10px 20px;
145 border-radius: 980px;
146
147 display: inline-flex;
148 align-items: center;
149 justify-content: center;
150 white-space: nowrap;
151
152 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
153 backdrop-filter: blur(22px) saturate(140%);
154 -webkit-backdrop-filter: blur(22px) saturate(140%);
155
156 color: white;
157 text-decoration: none;
158 font-family: inherit;
159
160 font-size: 15px;
161 font-weight: 600;
162 letter-spacing: -0.2px;
163
164 border: 1px solid rgba(255, 255, 255, 0.28);
165
166 box-shadow:
167 0 8px 24px rgba(0, 0, 0, 0.12),
168 inset 0 1px 0 rgba(255, 255, 255, 0.25);
169
170 cursor: pointer;
171
172 transition:
173 background 0.3s cubic-bezier(0.4, 0, 0.2, 1),
174 transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
175 box-shadow 0.3s ease;
176}
177
178/* Liquid light layer */
179.glass-btn::before,
180button::before,
181.back-link::before,
182.value-form button::before {
183 content: "";
184 position: absolute;
185 inset: -40%;
186
187 background:
188 radial-gradient(
189 120% 60% at 0% 0%,
190 rgba(255, 255, 255, 0.35),
191 transparent 60%
192 ),
193 linear-gradient(
194 120deg,
195 transparent 30%,
196 rgba(255, 255, 255, 0.25),
197 transparent 70%
198 );
199
200 opacity: 0;
201 transform: translateX(-30%) translateY(-10%);
202 transition:
203 opacity 0.35s ease,
204 transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
205
206 pointer-events: none;
207}
208
209/* Hover motion */
210.glass-btn:hover,
211button:hover,
212.back-link:hover,
213.value-form button:hover {
214 background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
215 transform: translateY(-2px);
216}
217
218.glass-btn:hover::before,
219button:hover::before,
220.back-link:hover::before,
221.value-form button:hover::before {
222 opacity: 1;
223 transform: translateX(30%) translateY(10%);
224}
225
226/* Active press */
227.glass-btn:active,
228button:active,
229.value-form button:active {
230 background: rgba(var(--glass-water-rgb), 0.45);
231 transform: translateY(0);
232}
233
234/* Button variants */
235.save-btn {
236 padding: 8px 14px;
237 font-size: 13px;
238 --glass-water-rgb: 72, 187, 178;
239 --glass-alpha: 0.34;
240 --glass-alpha-hover: 0.46;
241}
242
243.add-button {
244 --glass-water-rgb: 72, 187, 178;
245 --glass-alpha: 0.34;
246 --glass-alpha-hover: 0.46;
247 font-weight: 600;
248}
249
250/* =========================================================
251 CONTENT CARDS - UPDATED TO MATCH ROOT ROUTE
252 ========================================================= */
253
254.header {
255 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
256 backdrop-filter: blur(22px) saturate(140%);
257 -webkit-backdrop-filter: blur(22px) saturate(140%);
258 border-radius: 16px;
259 padding: 28px;
260 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
261 inset 0 1px 0 rgba(255, 255, 255, 0.25);
262 border: 1px solid rgba(255, 255, 255, 0.28);
263 margin-bottom: 24px;
264 animation: fadeInUp 0.6s ease-out;
265 animation-fill-mode: both;
266 animation-delay: 0.1s;
267 position: relative;
268 overflow: hidden;
269}
270
271.header::before {
272 content: "";
273 position: absolute;
274 inset: 0;
275 border-radius: inherit;
276 background: linear-gradient(
277 180deg,
278 rgba(255, 255, 255, 0.18),
279 rgba(255, 255, 255, 0.05)
280 );
281 pointer-events: none;
282}
283
284.header h1 {
285 font-size: 28px;
286 font-weight: 600;
287 letter-spacing: -0.5px;
288 line-height: 1.3;
289 margin-bottom: 8px;
290 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
291 color: white;
292 word-break: break-word;
293}
294
295.header h1 a {
296 color: white;
297 text-decoration: none;
298 transition: opacity 0.2s;
299}
300
301.header h1 a:hover {
302 opacity: 0.8;
303}
304
305.section-title {
306 font-size: 18px;
307 font-weight: 600;
308 color: white;
309 letter-spacing: -0.3px;
310 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
311 margin-bottom: 20px;
312}
313
314/* =========================================================
315 NAV
316 ========================================================= */
317
318.back-nav {
319 display: flex;
320 gap: 12px;
321 margin-bottom: 20px;
322 flex-wrap: wrap;
323 animation: fadeInUp 0.5s ease-out;
324}
325
326/* =========================================================
327 BADGES & NODE ID
328 ========================================================= */
329
330.version-badge {
331 display: inline-block;
332 padding: 6px 14px;
333 background: rgba(255, 255, 255, 0.25);
334 backdrop-filter: blur(10px);
335 color: white;
336 border-radius: 20px;
337 font-size: 14px;
338 font-weight: 600;
339 margin-top: 8px;
340 border: 1px solid rgba(255, 255, 255, 0.3);
341}
342
343.node-id-container {
344 display: flex;
345 align-items: center;
346 gap: 8px;
347 margin-top: 12px;
348 flex-wrap: wrap;
349 padding: 10px 14px;
350 background: rgba(255, 255, 255, 0.15);
351 border-radius: 8px;
352 border: 1px solid rgba(255, 255, 255, 0.2);
353 width: 100%;
354}
355
356code {
357 background: transparent;
358 padding: 0;
359 border-radius: 0;
360 font-size: 13px;
361 font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
362 color: white;
363 word-break: break-all;
364 flex: 1;
365 min-width: 0;
366 overflow-wrap: break-word;
367}
368
369#copyNodeIdBtn {
370 background: rgba(255, 255, 255, 0.2);
371 border: 1px solid rgba(255, 255, 255, 0.3);
372 cursor: pointer;
373 padding: 6px 10px;
374 border-radius: 6px;
375 opacity: 1;
376 font-size: 16px;
377 transition: all 0.2s;
378 flex-shrink: 0;
379}
380
381#copyNodeIdBtn:hover {
382 background: rgba(255, 255, 255, 0.3);
383 transform: scale(1.1);
384}
385
386#copyNodeIdBtn::before {
387 display: none;
388}
389
390/* =========================================================
391 TABLE - NO BACKGROUND PANEL
392 ========================================================= */
393
394.table-section {
395 background: transparent;
396 border: none;
397 box-shadow: none;
398 padding: 0;
399 margin-bottom: 24px;
400}
401
402table {
403 width: 100%;
404 border-collapse: separate;
405 border-spacing: 0 8px;
406 background: transparent;
407 margin-top: 0;
408}
409
410thead th {
411 padding: 0 0 12px 0;
412 font-size: 13px;
413 font-weight: 600;
414 color: rgba(255, 255, 255, 0.7);
415 text-transform: uppercase;
416 letter-spacing: 0.5px;
417 text-align: left;
418 border: none;
419 background: transparent;
420}
421
422/* Hide Key and Value headers, keep only Goal */
423thead th:nth-child(1),
424thead th:nth-child(2),
425thead th:nth-child(3) {
426 opacity: 0;
427 pointer-events: none;
428}
429
430tbody tr {
431 background: rgba(var(--glass-water-rgb), 0.15);
432 backdrop-filter: blur(10px) saturate(120%);
433 -webkit-backdrop-filter: blur(10px) saturate(120%);
434 border-radius: 10px;
435 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08),
436 inset 0 1px 0 rgba(255, 255, 255, 0.2);
437 border: 1px solid rgba(255, 255, 255, 0.22);
438 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
439 position: relative;
440 overflow: hidden;
441}
442
443tbody tr::before {
444 content: "";
445 position: absolute;
446 inset: 0;
447 border-radius: inherit;
448 background: linear-gradient(
449 180deg,
450 rgba(255, 255, 255, 0.15),
451 rgba(255, 255, 255, 0.04)
452 );
453 pointer-events: none;
454}
455
456tbody tr:hover {
457 background: rgba(var(--glass-water-rgb), 0.22);
458 transform: translateY(-1px);
459 box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12),
460 inset 0 1px 0 rgba(255, 255, 255, 0.25);
461}
462
463td {
464 padding: 16px 20px;
465 border-bottom: none;
466 color: rgba(255, 255, 255, 0.85);
467 word-break: break-word;
468 background: transparent;
469 position: relative;
470}
471
472td code {
473 background: transparent;
474 padding: 0;
475 border-radius: 0;
476 font-weight: 600;
477 border: none;
478 color: rgba(255, 255, 255, 0.85);
479 font-size: 14px;
480 display: inline-block;
481 max-width: 150px;
482 overflow: hidden;
483 text-overflow: ellipsis;
484 white-space: nowrap;
485 cursor: help;
486 position: relative;
487}
488
489/* Tooltip for full number on hover */
490td code::after {
491 content: attr(data-full);
492 position: absolute;
493 bottom: 100%;
494 left: 50%;
495 transform: translateX(-50%);
496 background: rgba(0, 0, 0, 0.9);
497 color: white;
498 padding: 6px 10px;
499 border-radius: 6px;
500 font-size: 12px;
501 white-space: nowrap;
502 opacity: 0;
503 pointer-events: none;
504 transition: opacity 0.2s;
505 margin-bottom: 5px;
506 z-index: 1000;
507 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
508}
509
510td code::before {
511 content: '';
512 position: absolute;
513 bottom: 100%;
514 left: 50%;
515 transform: translateX(-50%);
516 border: 5px solid transparent;
517 border-top-color: rgba(0, 0, 0, 0.9);
518 opacity: 0;
519 pointer-events: none;
520 transition: opacity 0.2s;
521 margin-bottom: -5px;
522 z-index: 1000;
523}
524
525td code:hover::after,
526td code:hover::before {
527 opacity: 1;
528}
529
530/* Mobile tap behavior */
531td code.show-tooltip::after,
532td code.show-tooltip::before {
533 opacity: 1;
534}
535
536.add-row {
537 background: rgba(var(--glass-water-rgb), 0.12);
538 margin-top: 4px;
539}
540
541.add-row:hover {
542 background: rgba(var(--glass-water-rgb), 0.18);
543}
544
545.add-row td {
546 padding: 16px 20px;
547}
548
549/* =========================================================
550 FORMS
551 ========================================================= */
552
553.value-form {
554 display: flex;
555 gap: 8px;
556 align-items: center;
557 flex-wrap: wrap;
558}
559
560.value-form input[type="text"],
561.value-form input[type="number"] {
562 padding: 8px 12px;
563 font-size: 14px;
564 border-radius: 8px;
565 border: 2px solid rgba(255, 255, 255, 0.3);
566 transition: all 0.2s;
567 background: rgba(255, 255, 255, 0.15);
568 color: white;
569 font-family: inherit;
570 font-weight: 500;
571 min-width: 0;
572}
573
574.value-form input[type="text"]::placeholder,
575.value-form input[type="number"]::placeholder {
576 color: rgba(255, 255, 255, 0.5);
577}
578
579.value-form input[type="text"]:focus,
580.value-form input[type="number"]:focus {
581 outline: none;
582 border-color: rgba(255, 255, 255, 0.6);
583 background: rgba(255, 255, 255, 0.25);
584 box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15);
585 transform: translateY(-2px);
586}
587
588.value-form input[type="text"] {
589 flex: 1;
590 min-width: 120px;
591}
592
593.value-form input[type="number"] {
594 width: 100px;
595}
596
597/* =========================================================
598 EMPTY STATE
599 ========================================================= */
600
601.empty-state {
602 text-align: center;
603 padding: 40px 20px;
604 color: rgba(255, 255, 255, 0.7);
605 font-style: italic;
606}
607
608/* =========================================================
609 RESPONSIVE
610 ========================================================= */
611
612@media (max-width: 640px) {
613 body {
614 padding: 16px;
615 }
616
617 .container {
618 max-width: 100%;
619 }
620
621 .header {
622 padding: 20px;
623 }
624
625 .header h1 {
626 font-size: 24px;
627 }
628
629 .back-nav {
630 flex-direction: column;
631 }
632
633 .back-link {
634 width: 100%;
635 justify-content: center;
636 }
637
638 th, td {
639 padding: 10px 8px;
640 font-size: 13px;
641 }
642
643 .value-form {
644 flex-direction: column;
645 align-items: stretch;
646 width: 100%;
647 }
648
649 .value-form input[type="text"],
650 .value-form input[type="number"],
651 .value-form button {
652 width: 100%;
653 }
654
655 code {
656 font-size: 12px;
657 word-break: break-all;
658 }
659}
660
661@media (min-width: 641px) and (max-width: 1024px) {
662 .container {
663 max-width: 700px;
664 }
665}
666</style>
667
668 </head>
669 <body>
670 <div class="container">
671 <!-- Back Navigation -->
672 <div class="back-nav">
673 <a href="/api/v1/root/${nodeId}${queryString}" class="back-link">
674 ← Back to Tree
675 </a>
676 <a href="/api/v1/node/${nodeId}/${nodeVersion}${queryString}" class="back-link">
677 Back to Version
678 </a>
679 ${valuesResolveSlots("values-nav-links", { nodeId, version: parsedVersion, queryString })}
680 </div>
681
682 <!-- Header -->
683 <div class="header">
684 <h1>
685 <a href="/api/v1/node/${nodeId}/${nodeVersion}${queryString}">
686 ${nodeName}
687 </a>
688 </h1>
689
690 <span class="version-badge">Version ${nodeVersion}</span>
691
692 <div class="node-id-container">
693 <code id="nodeIdCode">${nodeId}</code>
694 <button id="copyNodeIdBtn" title="Copy ID">📋</button>
695 </div>
696
697 <!-- Values & Goals Title -->
698 <div class="section-title" style="margin-top: 24px; margin-bottom: 0;">Values & Goals</div>
699 </div>
700
701 <!-- Table Section (no background) -->
702 <div class="table-section">
703 <table>
704 <thead>
705 <tr>
706 <th>Key</th>
707 <th>Value</th>
708 <th>Goal</th>
709 </tr>
710 </thead>
711 <tbody>
712 ${tableRows}
713
714 <!-- Add New Row -->
715 <tr class="add-row">
716 <td colspan="3">
717 <form
718 method="POST"
719 action="/api/v1/node/${nodeId}/${parsedVersion}/value?token=${token}&html"
720 class="value-form"
721 >
722 <input
723 type="text"
724 name="key"
725 placeholder="New key"
726 required
727 />
728 <input
729 type="number"
730 name="value"
731 value="0"
732 step="any"
733 placeholder="0"
734 />
735 <button type="submit" class="add-button">
736 + Add Value
737 </button>
738 </form>
739 </td>
740 </tr>
741 </tbody>
742 </table>
743 </div>
744 </div>
745
746 <script>
747 const btn = document.getElementById("copyNodeIdBtn");
748 const code = document.getElementById("nodeIdCode");
749
750 if (btn && code) {
751 btn.addEventListener("click", () => {
752 navigator.clipboard.writeText(code.textContent).then(() => {
753 btn.textContent = "✔️";
754 setTimeout(() => (btn.textContent = "📋"), 900);
755 });
756 });
757 }
758
759 // Mobile tap to show tooltip
760 document.querySelectorAll("td code[data-full]").forEach((codeEl) => {
761 let tapTimeout;
762
763 codeEl.addEventListener("click", (e) => {
764 e.stopPropagation();
765
766 // Remove show-tooltip from all other elements
767 document.querySelectorAll("td code.show-tooltip").forEach((el) => {
768 if (el !== codeEl) el.classList.remove("show-tooltip");
769 });
770
771 // Toggle tooltip
772 codeEl.classList.toggle("show-tooltip");
773
774 // Auto-hide after 3 seconds
775 clearTimeout(tapTimeout);
776 if (codeEl.classList.contains("show-tooltip")) {
777 tapTimeout = setTimeout(() => {
778 codeEl.classList.remove("show-tooltip");
779 }, 3000);
780 }
781 });
782 });
783
784 // Hide tooltip when clicking elsewhere
785 document.addEventListener("click", () => {
786 document.querySelectorAll("td code.show-tooltip").forEach((el) => {
787 el.classList.remove("show-tooltip");
788 });
789 });
790 </script>
791
792 <script>
793 // Handle save button visibility for ALL value and goal forms
794 document.querySelectorAll(".value-form").forEach((form) => {
795 // Skip the "Add New" form at the bottom
796 if (form.querySelector('input[name="key"][type="text"]')) {
797 return;
798 }
799
800 // Get the number input (either value or goal)
801 const input = form.querySelector("input[type='number']");
802 const button = form.querySelector(".save-btn");
803
804 if (!input || !button) return;
805
806 // Get the original value from data attribute
807 const original = input.dataset.original ?? "";
808
809 function updateButton() {
810 // Compare current value with original
811 const currentValue = input.value.trim();
812 const originalValue = original.trim();
813 const changed = currentValue !== originalValue;
814
815 // Show button only if value changed
816 if (changed) {
817 button.style.display = "inline-flex";
818 } else {
819 button.style.display = "none";
820 }
821 }
822
823 // Set initial state (button should be hidden)
824 updateButton();
825
826 // Watch for changes in real-time
827 input.addEventListener("input", updateButton);
828
829 // Also check on blur to handle edge cases
830 input.addEventListener("blur", updateButton);
831 });
832 </script>
833 </body>
834 </html>
835 `;
836}
837
1import express from "express";
2import log from "../../seed/log.js";
3import Node from "../../seed/models/node.js";
4import { sendError, ERR } from "../../seed/protocol.js";
5import urlAuth from "../html-rendering/urlAuth.js";
6import { htmlOnly, buildQS, tokenQS } from "../html-rendering/htmlHelpers.js";
7import { getExtension } from "../loader.js";
8import { renderValuesPage } from "./pages/values.js";
9
10export default function buildValuesHtmlRoutes() {
11 const router = express.Router();
12
13 router.get("/root/:nodeId/values", urlAuth, htmlOnly, async (req, res) => {
14 try {
15 const { nodeId } = req.params;
16 let result = { flat: [], tree: {} };
17 try {
18 const values = getExtension("values");
19 if (values?.exports?.getGlobalValuesTreeAndFlat) {
20 result = await values.exports.getGlobalValuesTreeAndFlat(nodeId);
21 }
22 } catch {}
23
24 return res.send(renderValuesPage({
25 nodeId, queryString: buildQS(req), result,
26 }));
27 } catch (err) {
28 log.error("HTML", "Values page render error:", err.message);
29 sendError(res, 500, ERR.INTERNAL, err.message);
30 }
31 });
32
33 return router;
34}
35
1import getTools from "./tools.js";
2import { setServices, setEnergyService, setValueForNode, setGoalForNode, getGlobalValuesTreeAndFlat, getNodeValues, setNodeValues } from "./core.js";
3
4export async function init(core) {
5 setServices({ models: core.models, contributions: core.contributions, metadata: core.metadata });
6 if (core.energy) setEnergyService(core.energy);
7
8 const { default: router, setNodeModel, resolveHtmlAuth } = await import("./routes.js");
9 setNodeModel(core.models.Node);
10 resolveHtmlAuth();
11 core.hooks.register("enrichContext", async ({ context, node, meta }) => {
12 const values = meta.values || {};
13 const goals = meta.goals || {};
14 if (Object.keys(values).length > 0) context.values = values;
15 if (Object.keys(goals).length > 0) context.goals = goals;
16 }, "values");
17
18 try {
19 const { getExtension } = await import("../loader.js");
20 const htmlExt = getExtension("html-rendering");
21 if (htmlExt) {
22 const { default: buildHtmlRoutes } = await import("./htmlRoutes.js");
23 htmlExt.router.use("/", buildHtmlRoutes());
24 }
25 } catch {}
26
27 // Register navigation for value/goal tools (if treeos-base installed)
28 try {
29 const { getExtension } = await import("../loader.js");
30 const base = getExtension("treeos-base");
31 if (base?.exports?.registerToolNavigations) {
32 const vUrl = ({ args, withToken: t }) => t(`/api/v1/node/${args.nodeId}?html`);
33 base.exports.registerToolNavigations({
34 "edit-node-value": vUrl,
35 "edit-node-goal": vUrl,
36 });
37 }
38 } catch {}
39
40 // Register tree quick link
41 try {
42 const { getExtension } = await import("../loader.js");
43 const base = getExtension("treeos-base");
44 base?.exports?.registerSlot?.("tree-quick-links", "values", ({ rootId, queryString }) =>
45 `<a href="/api/v1/root/${rootId}/values${queryString}" class="back-link">Global Values</a>`,
46 { priority: 30 }
47 );
48 base?.exports?.registerSlot?.("version-quick-links", "values", ({ nodeId, version, qs }) =>
49 `<a href="/api/v1/node/${nodeId}/${version}/values${qs}">Values / Goals</a>`,
50 { priority: 10 }
51 );
52 } catch {}
53
54 return {
55 router,
56 tools: getTools(),
57 modeTools: [
58 { modeKey: "tree:edit", toolNames: ["edit-node-value", "edit-node-goal"] },
59 { modeKey: "tree:be", toolNames: ["edit-node-value"] },
60 { modeKey: "tree:librarian", toolNames: ["edit-node-value"] },
61 ],
62 exports: { setValueForNode, setGoalForNode, getGlobalValuesTreeAndFlat, getNodeValues, setNodeValues },
63 };
64}
65
1export default {
2 name: "values",
3 version: "1.0.1",
4 builtFor: "TreeOS",
5 description:
6 "Not everything in a tree is text. Some things are numbers. Revenue, hours logged, calories, " +
7 "word count, completion percentage, test coverage, budget remaining. Values attaches named " +
8 "numeric key-value pairs to any node. Goals attaches target numbers to those same keys. " +
9 "Together they answer two questions at any position in the tree: where are we, and where " +
10 "are we trying to get." +
11 "\n\n" +
12 "Values accumulate upward. The tree-wide endpoint walks from root through every descendant, " +
13 "summing values at each level. A root node with three project branches shows the total " +
14 "hours across all projects without anyone manually adding them. Each node reports both " +
15 "local values (what was set directly on this node) and total values (local plus all " +
16 "descendants). The flat summary at the root shows every value key and its tree-wide sum." +
17 "\n\n" +
18 "Keys are case-insensitive and merge automatically. If one node has Revenue and another " +
19 "has revenue, they accumulate together. Keys starting with _auto are reserved for system " +
20 "use and cannot be set by users. Values are truncated to six decimal places. Goals must " +
21 "reference an existing value key. You cannot set a goal for a key that has no value yet." +
22 "\n\n" +
23 "enrichContext injects values and goals at every node so the AI knows the quantitative " +
24 "state. The AI at a node with budget: 5000 and a goal of budget: 10000 understands " +
25 "progress without asking. When prestige fires, it resets all values to zero through the " +
26 "exported setValueForNode function. The previous values live in the prestige snapshot. " +
27 "The new version starts counting from scratch.",
28
29 needs: {
30 services: ["contributions", "hooks"],
31 models: ["Node"],
32 },
33
34 optional: {
35 extensions: ["energy", "html-rendering", "treeos-base"],
36 },
37
38 provides: {
39 models: {},
40 routes: "./routes.js",
41 tools: true,
42 jobs: false,
43 orchestrator: false,
44 sessionTypes: {},
45
46 energyActions: {
47 editValue: { cost: 1 },
48 editGoal: { cost: 1 },
49 },
50
51 cli: [
52 { command: "values", scope: ["tree"], description: "Show values for current node", method: "GET", endpoint: "/node/:nodeId/values" },
53 { command: "value <key> <value>", scope: ["tree"], description: "Set a value on current node", method: "POST", endpoint: "/node/:nodeId/value" },
54 { command: "goal <key> <goal>", scope: ["tree"], description: "Set a goal for a value", method: "POST", endpoint: "/node/:nodeId/goal" },
55 ],
56 hooks: {
57 fires: [],
58 listens: ["enrichContext"],
59 },
60 },
61};
62
1/* ------------------------------------------------- */
2/* Values page (extracted from root.js) */
3/* ------------------------------------------------- */
4
5import { page } from "../../html-rendering/html/layout.js";
6
7export function renderValuesPage({ nodeId, queryString, result }) {
8 const rootNodeName = result.tree.nodeName || "Unknown";
9
10 const flatSummary =
11 Object.entries(result.flat).length > 0
12 ? Object.entries(result.flat)
13 .sort(([, a], [, b]) => b - a)
14 .map(
15 ([key, value]) => `
16 <div class="value-card">
17 <div class="value-key">${key}</div>
18 <div class="value-amount">${value.toLocaleString()}</div>
19 </div>
20 `,
21 )
22 .join("")
23 : `<div class="empty-state-small">No values yet</div>`;
24
25 function renderTree(node, depth = 0) {
26 const hasChildren = node.children && node.children.length > 0;
27 const hasLocalValues =
28 node.localValues && Object.keys(node.localValues).length > 0;
29 const hasTotalValues =
30 node.totalValues && Object.keys(node.totalValues).length > 0;
31
32 let localValuesHtml = "";
33 if (hasLocalValues) {
34 localValuesHtml = Object.entries(node.localValues)
35 .map(
36 ([k, v]) => `
37 <div class="node-value-item" title="${k}: ${v.toLocaleString()}">
38 <span class="value-key-small">${k}</span>
39 <span class="value-amount-small">${v.toLocaleString()}</span>
40 </div>
41 `,
42 )
43 .join("");
44 }
45
46 let totalValuesHtml = "";
47 if (hasTotalValues) {
48 totalValuesHtml = Object.entries(node.totalValues)
49 .map(
50 ([k, v]) => `
51 <div class="node-value-item" title="${k}: ${v.toLocaleString()}">
52 <span class="value-key-small">${k}</span>
53 <span class="value-amount-small">${v.toLocaleString()}</span>
54 </div>
55 `,
56 )
57 .join("");
58 }
59
60 const childrenHtml = hasChildren
61 ? node.children.map((c) => renderTree(c, depth + 1)).join("")
62 : "";
63
64 const valueCount = Math.max(
65 Object.keys(node.localValues || {}).length,
66 Object.keys(node.totalValues || {}).length,
67 );
68
69 return `
70 <div class="tree-node" data-depth="${depth}">
71 <div class="tree-node-header ${hasChildren ? "has-children" : ""}">
72 ${
73 hasChildren
74 ? `<button class="tree-toggle" onclick="toggleNode(this)" aria-label="Toggle children">▼</button>`
75 : '<span class="tree-spacer"></span>'
76 }
77 <div class="tree-node-info">
78 <a href="/api/v1/node/${
79 node.nodeId
80 }${queryString}" class="tree-node-name" title="${node.nodeName}">
81 ${node.nodeName}
82 </a>
83 ${
84 valueCount > 0
85 ? `<span class="value-count">${valueCount} value${
86 valueCount !== 1 ? "s" : ""
87 }</span>`
88 : ""
89 }
90 </div>
91 </div>
92
93 ${
94 hasLocalValues || hasTotalValues
95 ? `
96 <div class="tree-node-values local-values">
97 ${
98 localValuesHtml ||
99 '<div class="empty-values">No local values</div>'
100 }
101 </div>
102 <div class="tree-node-values total-values" style="display: none;">
103 ${
104 totalValuesHtml ||
105 '<div class="empty-values">No total values</div>'
106 }
107 </div>
108 `
109 : ""
110 }
111
112 ${
113 hasChildren
114 ? `
115 <div class="tree-children">
116 ${childrenHtml}
117 </div>
118 `
119 : ""
120 }
121 </div>
122 `;
123 }
124
125 const css = `
126 body { color: white; }
127 .container { max-width: 1000px; }
128
129 /* Glass Card Base */
130 .glass-card {
131 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
132 backdrop-filter: blur(22px) saturate(140%);
133 -webkit-backdrop-filter: blur(22px) saturate(140%);
134 border-radius: 16px;
135 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
136 inset 0 1px 0 rgba(255, 255, 255, 0.25);
137 border: 1px solid rgba(255, 255, 255, 0.28);
138 position: relative;
139 overflow: hidden;
140 }
141
142 .glass-card::before {
143 content: "";
144 position: absolute;
145 inset: 0;
146 border-radius: inherit;
147 background: linear-gradient(
148 180deg,
149 rgba(255,255,255,0.18),
150 rgba(255,255,255,0.05)
151 );
152 pointer-events: none;
153 }
154
155 /* Header */
156 .header {
157 padding: 28px;
158 margin-bottom: 24px;
159 animation: fadeInUp 0.6s ease-out;
160 animation-delay: 0.1s;
161 animation-fill-mode: both;
162 }
163
164 .header h1 {
165 font-size: 28px;
166 font-weight: 700;
167 color: white;
168 margin-bottom: 8px;
169 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
170 letter-spacing: -0.5px;
171 }
172
173 .header h1::before {
174 content: '\uD83D\uDC8E ';
175 font-size: 26px;
176 }
177
178 .header-subtitle {
179 font-size: 14px;
180 color: rgba(255, 255, 255, 0.85);
181 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
182 }
183
184 /* Section */
185 .section {
186 padding: 28px;
187 margin-bottom: 24px;
188 animation: fadeInUp 0.6s ease-out;
189 animation-fill-mode: both;
190 }
191
192 .section:nth-child(3) { animation-delay: 0.2s; }
193 .section:nth-child(4) { animation-delay: 0.3s; }
194
195 .section-title {
196 font-size: 20px;
197 font-weight: 600;
198 color: white;
199 margin-bottom: 20px;
200 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
201 letter-spacing: -0.3px;
202 }
203
204 /* Flat Summary Cards */
205 .flat-grid {
206 display: grid;
207 grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
208 gap: 16px;
209 }
210
211 .value-card {
212 background: rgba(255, 255, 255, 0.15);
213 backdrop-filter: blur(10px);
214 padding: 20px;
215 border-radius: 12px;
216 border: 1px solid rgba(255, 255, 255, 0.2);
217 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
218 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1),
219 inset 0 1px 0 rgba(255, 255, 255, 0.25);
220 position: relative;
221 overflow: hidden;
222 }
223
224 .value-card::before {
225 content: "";
226 position: absolute;
227 inset: 0;
228 border-radius: inherit;
229 background: linear-gradient(
230 180deg,
231 rgba(255,255,255,0.15),
232 rgba(255,255,255,0.05)
233 );
234 pointer-events: none;
235 }
236
237 .value-card:hover {
238 transform: translateY(-4px);
239 box-shadow: 0 12px 28px rgba(0, 0, 0, 0.2);
240 background: rgba(255, 255, 255, 0.25);
241 border-color: rgba(255, 255, 255, 0.4);
242 }
243
244 .value-key {
245 font-size: 14px;
246 font-weight: 600;
247 color: white;
248 text-transform: uppercase;
249 letter-spacing: 0.5px;
250 margin-bottom: 8px;
251 word-break: break-all;
252 overflow-wrap: break-word;
253 hyphens: auto;
254 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
255 position: relative;
256 z-index: 1;
257 }
258
259 .value-amount {
260 font-size: 32px;
261 font-weight: 700;
262 color: white;
263 font-family: 'SF Mono', Monaco, monospace;
264 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
265 position: relative;
266 z-index: 1;
267 }
268
269 /* Tree View */
270 .tree-container {
271 position: relative;
272 }
273
274 .tree-node {
275 position: relative;
276 margin-bottom: 4px;
277 }
278
279 .tree-node-header {
280 display: flex;
281 align-items: center;
282 gap: 12px;
283 padding: 12px 16px;
284 background: rgba(255, 255, 255, 0.12);
285 border-radius: 8px;
286 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
287 border: 1px solid rgba(255, 255, 255, 0.15);
288 }
289
290 .tree-node-header:hover {
291 background: rgba(255, 255, 255, 0.2);
292 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
293 transform: translateX(4px);
294 border-color: rgba(255, 255, 255, 0.3);
295 }
296
297 .tree-toggle {
298 width: 24px;
299 height: 24px;
300 background: rgba(255, 255, 255, 0.2);
301 border: 1px solid rgba(255, 255, 255, 0.25);
302 border-radius: 6px;
303 cursor: pointer;
304 display: flex;
305 align-items: center;
306 justify-content: center;
307 font-size: 12px;
308 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
309 flex-shrink: 0;
310 color: white;
311 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
312 }
313
314 .tree-toggle:hover {
315 background: rgba(255, 255, 255, 0.3);
316 transform: scale(1.1);
317 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
318 }
319
320 .tree-toggle.collapsed {
321 transform: rotate(-90deg);
322 }
323
324 .tree-toggle.collapsed:hover {
325 transform: rotate(-90deg) scale(1.1);
326 }
327
328 .tree-spacer {
329 width: 24px;
330 flex-shrink: 0;
331 }
332
333 .tree-node-info {
334 flex: 1;
335 display: flex;
336 align-items: center;
337 gap: 12px;
338 flex-wrap: wrap;
339 min-width: 0;
340 }
341
342 .tree-node-name {
343 font-size: 15px;
344 font-weight: 600;
345 color: white;
346 text-decoration: none;
347 transition: all 0.2s;
348 overflow: hidden;
349 text-overflow: ellipsis;
350 white-space: nowrap;
351 max-width: 300px;
352 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
353 }
354
355 .tree-node-name:hover {
356 text-shadow: 0 0 10px rgba(255, 255, 255, 0.8);
357 transform: translateX(2px);
358 }
359
360 .value-count {
361 font-size: 12px;
362 color: white;
363 padding: 2px 8px;
364 background: rgba(255, 255, 255, 0.15);
365 border-radius: 10px;
366 border: 1px solid rgba(255, 255, 255, 0.2);
367 flex-shrink: 0;
368 text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
369 }
370
371 .tree-node-values {
372 display: grid;
373 grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
374 gap: 8px;
375 margin: 12px 0 12px 36px;
376 padding: 12px;
377 background: rgba(255, 255, 255, 0.1);
378 border-radius: 8px;
379 border-left: 3px solid rgba(255, 255, 255, 0.4);
380 }
381
382 .tree-node-values.total-values {
383 border-left-color: rgba(16, 185, 129, 0.6);
384 }
385
386 .node-value-item {
387 display: flex;
388 flex-direction: column;
389 align-items: flex-start;
390 gap: 4px;
391 padding: 10px 12px;
392 background: rgba(255, 255, 255, 0.15);
393 border-radius: 6px;
394 transition: all 0.2s;
395 min-height: 60px;
396 cursor: help;
397 overflow: hidden;
398 border: 1px solid rgba(255, 255, 255, 0.15);
399 }
400
401 .node-value-item:hover {
402 background: rgba(255, 255, 255, 0.25);
403 transform: translateY(-2px);
404 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
405 }
406
407 .value-key-small {
408 font-size: 11px;
409 font-weight: 600;
410 color: white;
411 letter-spacing: 0.3px;
412 overflow: hidden;
413 text-overflow: ellipsis;
414 white-space: nowrap;
415 max-width: 100%;
416 line-height: 1.3;
417 text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
418 }
419
420 .value-amount-small {
421 font-size: 16px;
422 font-weight: 700;
423 color: white;
424 font-family: 'SF Mono', Monaco, monospace;
425 overflow: hidden;
426 text-overflow: ellipsis;
427 white-space: nowrap;
428 max-width: 100%;
429 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
430 }
431
432 .empty-values {
433 font-size: 13px;
434 color: rgba(255, 255, 255, 0.6);
435 font-style: italic;
436 padding: 8px;
437 }
438
439 .tree-children {
440 margin-left: 20px;
441 padding-left: 12px;
442 border-left: 2px solid rgba(255, 255, 255, 0.2);
443 margin-top: 4px;
444 transition: all 0.3s;
445 }
446
447 .tree-children.collapsed {
448 display: none;
449 }
450
451 /* Empty States */
452 .empty-state-small {
453 text-align: center;
454 padding: 40px;
455 color: rgba(255, 255, 255, 0.7);
456 font-style: italic;
457 }
458
459 /* Controls */
460 .tree-controls {
461 display: flex;
462 gap: 12px;
463 margin-bottom: 16px;
464 flex-wrap: wrap;
465 }
466
467 .btn-control {
468 padding: 8px 16px;
469 background: rgba(255, 255, 255, 0.15);
470 border: 1px solid rgba(255, 255, 255, 0.2);
471 border-radius: 980px;
472 font-size: 14px;
473 font-weight: 600;
474 color: white;
475 cursor: pointer;
476 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
477 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1),
478 inset 0 1px 0 rgba(255, 255, 255, 0.2);
479 position: relative;
480 overflow: hidden;
481 }
482
483 .btn-control::before {
484 content: "";
485 position: absolute;
486 inset: -40%;
487 background: radial-gradient(
488 120% 60% at 0% 0%,
489 rgba(255, 255, 255, 0.3),
490 transparent 60%
491 );
492 opacity: 0;
493 transform: translateX(-30%) translateY(-10%);
494 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
495 pointer-events: none;
496 }
497
498 .btn-control:hover {
499 background: rgba(255, 255, 255, 0.25);
500 transform: translateY(-2px);
501 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
502 }
503
504 .btn-control:hover::before {
505 opacity: 1;
506 transform: translateX(30%) translateY(10%);
507 }
508
509 .btn-control.active {
510 background: rgba(255, 255, 255, 0.3);
511 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2),
512 inset 0 1px 0 rgba(255, 255, 255, 0.4);
513 }
514
515 .controls-group {
516 display: flex;
517 gap: 8px;
518 background: rgba(255, 255, 255, 0.1);
519 padding: 4px;
520 border-radius: 980px;
521 border: 1px solid rgba(255, 255, 255, 0.2);
522 }
523
524 .controls-group .btn-control {
525 border: none;
526 background: transparent;
527 box-shadow: none;
528 }
529
530 .controls-group .btn-control:hover {
531 background: rgba(255, 255, 255, 0.2);
532 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
533 }
534
535 .controls-group .btn-control.active {
536 background: rgba(255, 255, 255, 0.25);
537 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
538 }
539
540 /* Responsive */
541 @media (max-width: 640px) {
542 body {
543 padding: 16px;
544 }
545
546 .header,
547 .section {
548 padding: 20px;
549 }
550
551 .header h1 {
552 font-size: 24px;
553 }
554
555 .flat-grid {
556 grid-template-columns: 1fr;
557 }
558
559 .tree-children {
560 margin-left: 20px;
561 padding-left: 12px;
562 }
563
564 .tree-node-values {
565 margin-left: 36px;
566 grid-template-columns: 1fr;
567 }
568
569 .back-nav {
570 flex-direction: column;
571 }
572
573 .back-link {
574 justify-content: center;
575 }
576
577 .value-amount {
578 font-size: 24px;
579 }
580
581 .tree-node-name {
582 max-width: 200px;
583 }
584
585 .tree-controls {
586 flex-direction: column;
587 }
588
589 .controls-group {
590 width: 100%;
591 }
592
593 .controls-group .btn-control {
594 flex: 1;
595 text-align: center;
596 }
597 }
598
599 @media (min-width: 641px) and (max-width: 1024px) {
600 .container {
601 max-width: 800px;
602 }
603
604 .flat-grid {
605 grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
606 }
607 }
608`;
609
610 const body = `
611 <div class="container">
612 <!-- Back Navigation -->
613 <div class="back-nav">
614 <a href="/api/v1/root/${nodeId}${queryString}" class="back-link">
615 <- Back to Tree
616 </a>
617 </div>
618
619 <!-- Header -->
620 <div class="glass-card header">
621 <h1>Global Values</h1>
622 <div class="header-subtitle">Cumulative values across all nodes</div>
623 </div>
624
625 <!-- Flat Summary -->
626 <div class="glass-card section">
627 <div class="section-title">Total Summary</div>
628 <div class="flat-grid">
629 ${flatSummary}
630 </div>
631 </div>
632
633 <!-- Tree View -->
634 <div class="glass-card section">
635 <div class="tree-controls">
636 <div class="controls-group">
637 <button class="btn-control active" id="showLocalBtn" onclick="showLocalValues()">
638 Local Values
639 </button>
640 <button class="btn-control" id="showTotalBtn" onclick="showTotalValues()">
641 Total Values
642 </button>
643 </div>
644 <button class="btn-control" onclick="expandAll()">Expand All</button>
645 <button class="btn-control" onclick="collapseAll()">Collapse All</button>
646 </div>
647 <div class="tree-container">
648 ${renderTree(result.tree)}
649 </div>
650 </div>
651 </div>
652`;
653
654 const js = `
655 let currentView = 'local';
656
657 function showLocalValues() {
658 currentView = 'local';
659 document.getElementById('showLocalBtn').classList.add('active');
660 document.getElementById('showTotalBtn').classList.remove('active');
661
662 document.querySelectorAll('.local-values').forEach(el => {
663 el.style.display = 'grid';
664 });
665 document.querySelectorAll('.total-values').forEach(el => {
666 el.style.display = 'none';
667 });
668 }
669
670 function showTotalValues() {
671 currentView = 'total';
672 document.getElementById('showTotalBtn').classList.add('active');
673 document.getElementById('showLocalBtn').classList.remove('active');
674
675 document.querySelectorAll('.local-values').forEach(el => {
676 el.style.display = 'none';
677 });
678 document.querySelectorAll('.total-values').forEach(el => {
679 el.style.display = 'grid';
680 });
681 }
682
683 function toggleNode(button) {
684 button.classList.toggle('collapsed');
685 const treeNode = button.closest('.tree-node');
686 const children = treeNode.querySelector('.tree-children');
687 if (children) {
688 children.classList.toggle('collapsed');
689 }
690 }
691
692 function expandAll() {
693 document.querySelectorAll('.tree-toggle').forEach(btn => {
694 btn.classList.remove('collapsed');
695 });
696 document.querySelectorAll('.tree-children').forEach(children => {
697 children.classList.remove('collapsed');
698 });
699 }
700
701 function collapseAll() {
702 document.querySelectorAll('.tree-toggle').forEach(btn => {
703 btn.classList.add('collapsed');
704 });
705 document.querySelectorAll('.tree-children').forEach(children => {
706 children.classList.add('collapsed');
707 });
708 }
709`;
710
711 return page({
712 title: `Global Values - ${rootNodeName}`,
713 css,
714 body,
715 js,
716 });
717}
718
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 { setValueForNode, setGoalForNode, getGlobalValuesTreeAndFlat, getNodeValues, getNodeGoals } from "./core.js";
6import { renderValues } from "./html.js";
7import { getExtension } from "../loader.js";
8
9let htmlAuth = authenticate;
10export function resolveHtmlAuth() {
11 const htmlExt = getExtension("html-rendering");
12 if (htmlExt?.exports?.urlAuth) htmlAuth = htmlExt.exports.urlAuth;
13}
14
15// Node model is wired via setServices() in core.js before routes are used.
16// Import here for the findNodeById helper used in GET routes.
17let _Node = null;
18export function setNodeModel(Node) { _Node = Node; }
19async function findNodeById(id) { return _Node.findById(id).populate("children"); }
20
21const router = express.Router();
22
23router.post("/node/:nodeId/value", authenticate, async (req, res) => {
24 try {
25 const { nodeId } = req.params;
26 const { key, value } = req.body;
27
28 await setValueForNode({ nodeId, key, value, userId: req.userId });
29
30 if ("html" in req.query) {
31 return res.redirect(`/api/v1/node/${nodeId}/values?token=${req.query.token ?? ""}&html`);
32 }
33 sendOk(res);
34 } catch (err) {
35 sendError(res, 400, ERR.INVALID_INPUT, err.message);
36 }
37});
38
39router.post("/node/:nodeId/goal", authenticate, async (req, res) => {
40 try {
41 const { nodeId } = req.params;
42 const { key, goal } = req.body;
43
44 await setGoalForNode({ nodeId, key, goal, userId: req.userId });
45
46 if ("html" in req.query) {
47 return res.redirect(`/api/v1/node/${nodeId}/values?token=${req.query.token ?? ""}&html`);
48 }
49 sendOk(res);
50 } catch (err) {
51 sendError(res, 400, ERR.INVALID_INPUT, err.message);
52 }
53});
54
55// Shared handler for both /node/:nodeId/values and /node/:nodeId/:version/values.
56// Values/goals are stored on the node itself, not version-snapshotted, so the
57// version segment is consumed but unused. It exists for URL consistency with
58// notes/contributions on the version detail page.
59async function nodeValuesHandler(req, res) {
60 try {
61 const { nodeId } = req.params;
62 const version = req.params.version ?? 0;
63
64 const node = await findNodeById(nodeId);
65 if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
66
67 const values = getNodeValues(node);
68 const goals = getNodeGoals(node);
69
70 const allKeys = Array.from(new Set([...Object.keys(values), ...Object.keys(goals)])).sort();
71
72 const wantHtml = Object.prototype.hasOwnProperty.call(req.query, "html");
73 if (!wantHtml || !getExtension("html-rendering")) {
74 return sendOk(res, { nodeId, values, goals });
75 }
76
77 const filtered = Object.entries(req.query)
78 .filter(([key]) => ["token", "html"].includes(key))
79 .map(([key, val]) => (val === "" ? key : `${key}=${val}`))
80 .join("&");
81 const queryString = filtered ? `?${filtered}` : "";
82
83 return res.send(renderValues({
84 nodeId,
85 version,
86 nodeName: node.name || nodeId,
87 nodeVersion: version,
88 allKeys,
89 values,
90 goals,
91 queryString,
92 token: req.query.token ?? "",
93 }));
94 } catch (err) {
95 log.error("Values", "Error in node values handler:", err);
96 sendError(res, 500, ERR.INTERNAL, err.message);
97 }
98}
99
100router.get("/node/:nodeId/values", htmlAuth, nodeValuesHandler);
101router.get("/node/:nodeId/:version/values", htmlAuth, nodeValuesHandler);
102
103router.get("/root/:rootId/values", authenticate, async (req, res) => {
104 try {
105 const result = await getGlobalValuesTreeAndFlat(req.params.rootId);
106 sendOk(res, result);
107 } catch (err) {
108 sendError(res, 400, ERR.INVALID_INPUT, err.message);
109 }
110});
111
112export default router;
113
1import { z } from "zod";
2import { setValueForNode, setGoalForNode } from "./core.js";
3
4export default function getTools() {
5 return [
6 {
7 name: "edit-node-value",
8 description: "Update a numeric value on a node. Values track quantitative state (strength, progress, count, etc.).",
9 schema: {
10 nodeId: z.string().describe("The unique ID of the node to edit."),
11 key: z.string().describe("The key of the value to modify."),
12 value: z.number().describe("The numeric value to assign."),
13 userId: z.string().describe("The ID of the user performing the edit."),
14 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
15 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
16 },
17 annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
18 handler: async ({ nodeId, key, value, userId, chatId, sessionId }) => {
19 const result = await setValueForNode({ nodeId, key, value, userId, wasAi: true, chatId, sessionId });
20 return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
21 },
22 },
23 {
24 name: "edit-node-goal",
25 description: "Set a goal for a value on a node. A goal is the target number a value needs to reach.",
26 schema: {
27 nodeId: z.string().describe("The unique ID of the node to edit."),
28 key: z.string().describe("The key of the goal (must match an existing value key)."),
29 goal: z.number().describe("The numeric goal value."),
30 userId: z.string().describe("The ID of the user performing the edit."),
31 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
32 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
33 },
34 annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
35 handler: async ({ nodeId, key, goal, userId, chatId, sessionId }) => {
36 try {
37 const result = await setGoalForNode({ nodeId, key, goal, userId, wasAi: true, chatId, sessionId });
38 return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
39 } catch (err) {
40 return { content: [{ type: "text", text: `Failed to update goal: ${err.message}` }] };
41 }
42 },
43 },
44 ];
45}
46
Loading comments...