EXTENSION for TreeOS
values
Not everything in a tree is text. Some things are numbers. Revenue, hours logged, calories, word count, completion percentage, test coverage, budget remaining. Values attaches named numeric key-value pairs to any node. Goals attaches target numbers to those same keys. Together they answer two questions at any position in the tree: where are we, and where are we trying to get. Values accumulate upward. The tree-wide endpoint walks from root through every descendant, summing values at each level. A root node with three project branches shows the total hours across all projects without anyone manually adding them. Each node reports both local values (what was set directly on this node) and total values (local plus all descendants). The flat summary at the root shows every value key and its tree-wide sum. Keys are case-insensitive and merge automatically. If one node has Revenue and another has revenue, they accumulate together. Keys starting with _auto are reserved for system use and cannot be set by users. Values are truncated to six decimal places. Goals must reference an existing value key. You cannot set a goal for a key that has no value yet. enrichContext injects values and goals at every node so the AI knows the quantitative state. The AI at a node with budget: 5000 and a goal of budget: 10000 understands progress without asking. When prestige fires, it resets all values to zero through the exported setValueForNode function. The previous values live in the prestige snapshot. The new version starts counting from scratch.
v1.0.1 by TreeOS Site 0 downloads 8 files 2,104 lines 55.6 KB published 38d ago
treeos ext install values
View changelog

Manifest

Provides

  • routes
  • tools
  • 3 CLI commands
  • 2 energy actions

Requires

  • services: contributions, hooks
  • models: Node

Optional

  • extensions: energy, html-rendering, treeos-base
SHA256: eb53cfe759980904a03a7ef5c91a3f8111489af971a57b3ef4e4fa70f4a5cd9a

CLI Commands

CommandMethodDescription
valuesGETShow values for current node
value <key> <value>POSTSet a value on current node
goal <key> <goal>POSTSet a goal for a value

Hooks

Listens To

  • enrichContext

Source Code

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

Versions

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

Comments

Loading comments...

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