فهرست منبع

docs: share one path helper on the smartbotic global instead of copying it per node

fszontagh 1 ماه پیش
والد
کامیت
d78fbe2669
1فایلهای تغییر یافته به همراه240 افزوده شده و 343 حذف شده
  1. 240 343
      docs/superpowers/plans/2026-08-04-tier-1-nodes.md

+ 240 - 343
docs/superpowers/plans/2026-08-04-tier-1-nodes.md

@@ -15,7 +15,7 @@
 - Nodes **throw** on failure; they never return `success: false`.
 - Nodes **throw** on failure; they never return `success: false`.
 - Node config values arrive **pre-evaluated** - `WorkflowEngine::evaluateExpressions` walks the config before `execute()`, and a config string that is exactly one `{{...}}` keeps its native type. Nodes must not re-interpolate.
 - Node config values arrive **pre-evaluated** - `WorkflowEngine::evaluateExpressions` walks the config before `execute()`, and a config string that is exactly one `{{...}}` keeps its native type. Nodes must not re-interpolate.
 - All eleven nodes live in `nodes/core/`.
 - All eleven nodes live in `nodes/core/`.
-- No imports between node files - each node is stored as one blob, so shared helpers are copied per file.
+- No imports between node files - each node is stored as one blob. Anything several nodes need lives on the `smartbotic` global, provided by the C++ script engine, not copied per file.
 - Agreed field-path semantics for every new node: dot paths, `[n]` and bare numeric indices, `.length` on arrays, `undefined` for anything missing.
 - Agreed field-path semantics for every new node: dot paths, `[n]` and bare numeric indices, `.length` on arrays, `undefined` for anything missing.
 - QuickJS here is Bellard's build with **no Intl** - no named timezones.
 - QuickJS here is Bellard's build with **no Intl** - no named timezones.
 - Never write an em-dash or en-dash in any file, commit message or comment.
 - Never write an em-dash or en-dash in any file, commit message or comment.
@@ -514,7 +514,192 @@ git commit -m "feat: the runner understands a node whose ports come from its con
 
 
 ---
 ---
 
 
-### Task 5: Render derived ports and named inputs
+### Task 5: One path helper for every node
+
+Seven of the eleven nodes need to read a value out of nested data by dotted path. `if-condition.js` and `loop.js` each already carry their own copy with **different** semantics - `loop.js` silently skips a leading `data.` segment, `if-condition.js` does not - which is the sort of divergence that makes a workflow behave differently depending on which node reads the path. Rather than adding seven more copies, the helper goes on the `smartbotic` global once, beside the `utils.pick` and `utils.omit` that are already there.
+
+The two existing nodes are deliberately left alone: changing how `if-condition` or `loop` resolves a path would alter workflows people are already running.
+
+**Files:**
+- Modify: `src/runner/engine/script_engine.cpp:1550` (after the `utils.pick` registration, before `utils.omit`)
+- Create: `tests/nodes/utils-get-field-value.json`
+
+**Interfaces:**
+- Produces: `smartbotic.utils.getFieldValue(data, path)`, consumed by Tasks 9, 10, 12, 13, 14 and 15. Returns `undefined` for anything missing. Supports dotted paths, `key[n]` and bare numeric segments for arrays, and `.length` on an array. An empty or missing path returns `data` unchanged. No leading-`data.` special case - the path is taken literally.
+
+- [ ] **Step 1: Register the helper**
+
+The surrounding registrations are C lambdas passed to `JS_NewCFunction`, each freeing every `JSValue` and `CString` it creates. Match that exactly - QuickJS is refcounted and a missed `JS_FreeValue` is a leak in a long-running runner.
+
+Insert after the closing `}, "pick", 2));` at line 1550, before the `// utils.omit` comment:
+
+```cpp
+    // utils.getFieldValue(data, path) - Read a nested value by dotted path.
+    // Supports a.b.c, arrays via key[0] or a bare numeric segment, and .length
+    // on an array. Returns undefined for anything missing.
+    JS_SetPropertyStr(ctx, utils, "getFieldValue", JS_NewCFunction(ctx, [](JSContext* ctx, JSValue this_val, int argc, JSValue* argv) -> JSValue {
+        if (argc < 1) {
+            return JS_ThrowTypeError(ctx, "utils.getFieldValue requires a value and a path");
+        }
+
+        if (argc < 2 || JS_IsUndefined(argv[1]) || JS_IsNull(argv[1])) {
+            return JS_DupValue(ctx, argv[0]);
+        }
+
+        const char* path_c = JS_ToCString(ctx, argv[1]);
+        if (!path_c) {
+            return JS_EXCEPTION;
+        }
+        std::string path(path_c);
+        JS_FreeCString(ctx, path_c);
+
+        if (path.empty()) {
+            return JS_DupValue(ctx, argv[0]);
+        }
+
+        std::vector<std::string> keys;
+        size_t start = 0;
+        while (true) {
+            size_t dot = path.find('.', start);
+            if (dot == std::string::npos) {
+                keys.push_back(path.substr(start));
+                break;
+            }
+            keys.push_back(path.substr(start, dot - start));
+            start = dot + 1;
+        }
+
+        JSValue current = JS_DupValue(ctx, argv[0]);
+
+        for (const auto& raw_key : keys) {
+            if (JS_IsUndefined(current) || JS_IsNull(current)) {
+                JS_FreeValue(ctx, current);
+                return JS_UNDEFINED;
+            }
+
+            bool is_array = JS_IsArray(ctx, current);
+
+            if (raw_key == "length" && is_array) {
+                JSValue length = JS_GetPropertyStr(ctx, current, "length");
+                JS_FreeValue(ctx, current);
+                current = length;
+                continue;
+            }
+
+            // key[3] - step into the property, then index it
+            std::string key = raw_key;
+            bool has_index = false;
+            int64_t index = 0;
+            size_t bracket = raw_key.find('[');
+            if (bracket != std::string::npos && raw_key.back() == ']') {
+                std::string index_text = raw_key.substr(bracket + 1, raw_key.size() - bracket - 2);
+                if (!index_text.empty() &&
+                    index_text.find_first_not_of("0123456789") == std::string::npos) {
+                    key = raw_key.substr(0, bracket);
+                    index = std::stoll(index_text);
+                    has_index = true;
+                }
+            }
+
+            if (!key.empty()) {
+                bool numeric = key.find_first_not_of("0123456789") == std::string::npos;
+                if (numeric && is_array) {
+                    JSValue element = JS_GetPropertyUint32(ctx, current, (uint32_t)std::stoll(key));
+                    JS_FreeValue(ctx, current);
+                    current = element;
+                } else {
+                    if (!JS_IsObject(current)) {
+                        JS_FreeValue(ctx, current);
+                        return JS_UNDEFINED;
+                    }
+                    JSValue next = JS_GetPropertyStr(ctx, current, key.c_str());
+                    JS_FreeValue(ctx, current);
+                    current = next;
+                }
+            }
+
+            if (has_index) {
+                if (!JS_IsArray(ctx, current)) {
+                    JS_FreeValue(ctx, current);
+                    return JS_UNDEFINED;
+                }
+                JSValue element = JS_GetPropertyUint32(ctx, current, (uint32_t)index);
+                JS_FreeValue(ctx, current);
+                current = element;
+            }
+        }
+
+        return current;
+    }, "getFieldValue", 2));
+```
+
+If `<vector>` or `<string>` is not already included in this file, add it - check the existing includes first, since `utils.omit` already uses `std::vector<std::string>`, so both are almost certainly present.
+
+- [ ] **Step 2: Build and restart**
+
+Run:
+```bash
+cmake --build build -j$(nproc)
+pkill -f smartbotic-webserver; pkill -f smartbotic-runner; sleep 1
+(./build/smartbotic-webserver >/tmp/webserver.log 2>&1 &)
+(./build/smartbotic-runner >/tmp/runner.log 2>&1 &)
+sleep 3
+ss -ltnp | grep -E '8090|9011|9012'
+```
+Expected: compiles clean, all three ports listening again.
+
+- [ ] **Step 3: Write the verification case**
+
+A single `code` node exercising every path form, so a regression in the helper shows up as a concrete wrong value rather than a vague node failure.
+
+```json
+{
+  "name": "verify-utils-get-field-value",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Probe", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "const d = { a: { b: { c: 7 } }, list: [{ id: 'x' }, { id: 'y' }], flat: [10, 20, 30] };\nconst g = smartbotic.utils.getFieldValue;\nreturn {\n  nested: g(d, 'a.b.c'),\n  bracket: g(d, 'list[1].id'),\n  numeric: g(d, 'flat.2'),\n  length: g(d, 'flat.length'),\n  missing: g(d, 'a.nope.c') === undefined,\n  throughNull: g({ a: null }, 'a.b') === undefined,\n  emptyPath: g(d, '').a.b.c,\n  notAnObject: g(d, 'a.b.c.d') === undefined\n};"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"}
+  ],
+  "expect": {
+    "n2": {"status": "completed", "output": {"result": {
+      "nested": 7,
+      "bracket": "y",
+      "numeric": 30,
+      "length": 3,
+      "missing": true,
+      "throughNull": true,
+      "emptyPath": 7,
+      "notAnObject": true
+    }}}
+  }
+}
+```
+
+- [ ] **Step 4: Run it**
+
+Run: `python3 scripts/verify-node.py tests/nodes/utils-get-field-value.json`
+Expected: `PASS`.
+
+A JSON `null` coming back where `undefined` was expected means the helper returned a QuickJS undefined that got serialized as null - check that the missing-path branches return `JS_UNDEFINED` rather than `JS_NULL`. The case compares against `undefined` inside the script precisely so this distinction is caught in JavaScript, before serialization.
+
+- [ ] **Step 5: Re-run the baseline**
+
+Run: `python3 scripts/verify-node.py tests/nodes/baseline-if-condition.json`
+Expected: `PASS`. `if-condition` keeps its own copy of the helper and must be untouched by this task.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/runner/engine/script_engine.cpp tests/nodes/utils-get-field-value.json
+git commit -m "feat: one path helper on the smartbotic global, for every node to share"
+```
+
+---
+
+### Task 6: Render derived ports and named inputs
 
 
 Ports are derived at **render time** from `data.dynamicOutputs` and `data.config`, not precomputed. `data.config` already updates when a node's config is saved, so the ports follow a rule being added or removed with no extra wiring.
 Ports are derived at **render time** from `data.dynamicOutputs` and `data.config`, not precomputed. `data.config` already updates when a node's config is saved, so the ports follow a rule being added or removed with no extra wiring.
 
 
@@ -524,7 +709,7 @@ Ports are derived at **render time** from `data.dynamicOutputs` and `data.config
 
 
 **Interfaces:**
 **Interfaces:**
 - Consumes: `dynamicOutputs` on the REST node payload from Task 3.
 - Consumes: `dynamicOutputs` on the REST node payload from Task 3.
-- Produces: `resolveOutputs(staticOutputs, dynamicOutputs, config): NodeOutput[]`, exported from `WorkflowNode.tsx` for reuse in Task 6.
+- Produces: `resolveOutputs(staticOutputs, dynamicOutputs, config): NodeOutput[]`, exported from `WorkflowNode.tsx` for reuse in Task 7.
 
 
 - [ ] **Step 1: Extend the types**
 - [ ] **Step 1: Extend the types**
 
 
@@ -656,7 +841,7 @@ Add the `inputs` binding beside `outputs` in the component body:
 - [ ] **Step 4: Check it compiles**
 - [ ] **Step 4: Check it compiles**
 
 
 Run: `cd webui && npm run lint && npm run build`
 Run: `cd webui && npm run lint && npm run build`
-Expected: no TypeScript errors. `resolveOutputs` is exported but not yet used elsewhere - that is fine, it is consumed in Task 6.
+Expected: no TypeScript errors. `resolveOutputs` is exported but not yet used elsewhere - that is fine, it is consumed in Task 7.
 
 
 - [ ] **Step 5: Commit**
 - [ ] **Step 5: Commit**
 
 
@@ -667,7 +852,7 @@ git commit -m "feat(webui): draw ports a node derives from its config, and named
 
 
 ---
 ---
 
 
-### Task 6: Wire derived ports through the editor
+### Task 7: Wire derived ports through the editor
 
 
 Three sites build a node's `data`. Each needs to pass `dynamicOutputs` and `inputs` through rather than only the static `outputs`. Saving a config that removes a rule must also drop the edges that pointed at the vanished port.
 Three sites build a node's `data`. Each needs to pass `dynamicOutputs` and `inputs` through rather than only the static `outputs`. Saving a config that removes a rule must also drop the edges that pointed at the vanished port.
 
 
@@ -675,7 +860,7 @@ Three sites build a node's `data`. Each needs to pass `dynamicOutputs` and `inpu
 - Modify: `webui/src/pages/WorkflowEditorPage.tsx:894-918` (view mode), `:968-992` (load), `:1307-1331` (addNode), `:1628-1641` (saveNodeConfig)
 - Modify: `webui/src/pages/WorkflowEditorPage.tsx:894-918` (view mode), `:968-992` (load), `:1307-1331` (addNode), `:1628-1641` (saveNodeConfig)
 
 
 **Interfaces:**
 **Interfaces:**
-- Consumes: `resolveOutputs` from Task 5.
+- Consumes: `resolveOutputs` from Task 6.
 
 
 - [ ] **Step 1: Pass the definition through at all three construction sites**
 - [ ] **Step 1: Pass the definition through at all three construction sites**
 
 
@@ -749,7 +934,7 @@ git commit -m "feat(webui): derived ports follow a node's config, and stale edge
 
 
 ---
 ---
 
 
-### Task 7: Set / Edit Fields
+### Task 8: Set / Edit Fields
 
 
 The node the roadmap calls the single biggest win. Because the engine pre-evaluates config, this node's whole job is placing already-computed values at the right paths.
 The node the roadmap calls the single biggest win. Because the engine pre-evaluates config, this node's whole job is placing already-computed values at the right paths.
 
 
@@ -944,17 +1129,17 @@ git commit -m "feat: a Set Fields node, so shaping data stops needing a Code nod
 
 
 ---
 ---
 
 
-### Task 8: Switch
+### Task 9: Switch
 
 
-The first node with derived ports. Its passing test is the proof that Tasks 2 through 6 work end to end.
+The first node with derived ports. Its passing test is the proof that Tasks 2 through 7 work end to end.
 
 
 **Files:**
 **Files:**
 - Create: `nodes/core/switch.js`
 - Create: `nodes/core/switch.js`
 - Create: `tests/nodes/switch.json`
 - Create: `tests/nodes/switch.json`
 
 
 **Interfaces:**
 **Interfaces:**
-- Consumes: the `dynamicOutputs` parsing and rendering from Tasks 2-6.
-- Produces: `getFieldValue(data, path)` - the agreed path helper, copied verbatim into Tasks 9, 11, 12.
+- Consumes: the `dynamicOutputs` parsing and rendering from Tasks 2-4 and 6-7.
+- Consumes: `smartbotic.utils.getFieldValue(data, path)` from Task 5.
 
 
 - [ ] **Step 1: Write the node**
 - [ ] **Step 1: Write the node**
 
 
@@ -1031,49 +1216,6 @@ const outputSchema = {
     }
     }
 };
 };
 
 
-function getFieldValue(data, path) {
-    if (!path) return data;
-
-    const keys = String(path).split('.');
-    let value = data;
-
-    for (const key of keys) {
-        if (value === null || value === undefined) return undefined;
-
-        if (key === 'length' && Array.isArray(value)) {
-            value = value.length;
-            continue;
-        }
-
-        const arrayMatch = key.match(/^(.+)\[(\d+)\]$/);
-        if (arrayMatch) {
-            const objKey = arrayMatch[1];
-            const index = arrayMatch[2];
-            if (objKey && typeof value === 'object' && objKey in value) {
-                value = value[objKey];
-            }
-            if (Array.isArray(value)) {
-                value = value[parseInt(index, 10)];
-                continue;
-            }
-            return undefined;
-        }
-
-        if (/^\d+$/.test(key) && Array.isArray(value)) {
-            value = value[parseInt(key, 10)];
-            continue;
-        }
-
-        if (typeof value === 'object' && key in value) {
-            value = value[key];
-        } else {
-            return undefined;
-        }
-    }
-
-    return value;
-}
-
 function ruleMatches(fieldValue, rule) {
 function ruleMatches(fieldValue, rule) {
     const compare = rule.value;
     const compare = rule.value;
 
 
@@ -1116,7 +1258,7 @@ function ruleMatches(fieldValue, rule) {
 async function execute(config, input, context) {
 async function execute(config, input, context) {
     const rules = Array.isArray(config.rules) ? config.rules : [];
     const rules = Array.isArray(config.rules) ? config.rules : [];
     const data = input;
     const data = input;
-    const fieldValue = getFieldValue(data, config.field);
+    const fieldValue = smartbotic.utils.getFieldValue(data, config.field);
 
 
     for (let i = 0; i < rules.length; i++) {
     for (let i = 0; i < rules.length; i++) {
         if (ruleMatches(fieldValue, rules[i] || {})) {
         if (ruleMatches(fieldValue, rules[i] || {})) {
@@ -1212,7 +1354,7 @@ git commit -m "feat: a Switch node that grows an output per rule"
 
 
 ---
 ---
 
 
-### Task 9: Filter
+### Task 10: Filter
 
 
 Both ports carry data at once. That works because this node emits **no** `_activeBranch` - the engine then reads `output[sourceOutput]` for each named connection independently.
 Both ports carry data at once. That works because this node emits **no** `_activeBranch` - the engine then reads `output[sourceOutput]` for each named connection independently.
 
 
@@ -1221,7 +1363,7 @@ Both ports carry data at once. That works because this node emits **no** `_activ
 - Create: `tests/nodes/filter.json`
 - Create: `tests/nodes/filter.json`
 
 
 **Interfaces:**
 **Interfaces:**
-- Consumes: `getFieldValue` from Task 8 (copied, not imported).
+- Consumes: `smartbotic.utils.getFieldValue(data, path)` from Task 5.
 
 
 - [ ] **Step 1: Write the node**
 - [ ] **Step 1: Write the node**
 
 
@@ -1297,56 +1439,13 @@ const outputSchema = {
     }
     }
 };
 };
 
 
-function getFieldValue(data, path) {
-    if (!path) return data;
-
-    const keys = String(path).split('.');
-    let value = data;
-
-    for (const key of keys) {
-        if (value === null || value === undefined) return undefined;
-
-        if (key === 'length' && Array.isArray(value)) {
-            value = value.length;
-            continue;
-        }
-
-        const arrayMatch = key.match(/^(.+)\[(\d+)\]$/);
-        if (arrayMatch) {
-            const objKey = arrayMatch[1];
-            const index = arrayMatch[2];
-            if (objKey && typeof value === 'object' && objKey in value) {
-                value = value[objKey];
-            }
-            if (Array.isArray(value)) {
-                value = value[parseInt(index, 10)];
-                continue;
-            }
-            return undefined;
-        }
-
-        if (/^\d+$/.test(key) && Array.isArray(value)) {
-            value = value[parseInt(key, 10)];
-            continue;
-        }
-
-        if (typeof value === 'object' && key in value) {
-            value = value[key];
-        } else {
-            return undefined;
-        }
-    }
-
-    return value;
-}
-
 function isEmpty(value) {
 function isEmpty(value) {
     return value === undefined || value === null || value === '' ||
     return value === undefined || value === null || value === '' ||
         (Array.isArray(value) && value.length === 0);
         (Array.isArray(value) && value.length === 0);
 }
 }
 
 
 function conditionHolds(item, condition) {
 function conditionHolds(item, condition) {
-    const fieldValue = getFieldValue(item, condition.field);
+    const fieldValue = smartbotic.utils.getFieldValue(item, condition.field);
     const compare = condition.value;
     const compare = condition.value;
 
 
     switch (condition.operator) {
     switch (condition.operator) {
@@ -1394,7 +1493,7 @@ async function execute(config, input, context) {
     if (Array.isArray(inputField)) {
     if (Array.isArray(inputField)) {
         items = inputField;
         items = inputField;
     } else {
     } else {
-        items = getFieldValue(input, inputField || 'data');
+        items = smartbotic.utils.getFieldValue(input, inputField || 'data');
     }
     }
 
 
     if (!Array.isArray(items)) {
     if (!Array.isArray(items)) {
@@ -1484,16 +1583,16 @@ git commit -m "feat: a Filter node, with the dropped items kept on their own out
 
 
 ---
 ---
 
 
-### Task 10: Merge
+### Task 11: Merge
 
 
-The first node with two input handles. Its passing test proves the `const inputs` work from Tasks 2 and 5.
+The first node with two input handles. Its passing test proves the `const inputs` work from Tasks 2 and 6.
 
 
 **Files:**
 **Files:**
 - Create: `nodes/core/merge.js`
 - Create: `nodes/core/merge.js`
 - Create: `tests/nodes/merge.json`
 - Create: `tests/nodes/merge.json`
 
 
 **Interfaces:**
 **Interfaces:**
-- Consumes: `const inputs` parsing from Task 2, handle rendering from Task 5.
+- Consumes: `const inputs` parsing from Task 2, handle rendering from Task 6.
 
 
 - [ ] **Step 1: Write the node**
 - [ ] **Step 1: Write the node**
 
 
@@ -1659,7 +1758,7 @@ git commit -m "feat: a Merge node, and two input handles for it to use"
 
 
 ---
 ---
 
 
-### Task 11: Split Out and Aggregate
+### Task 12: Split Out and Aggregate
 
 
 A pair, because each is the other's inverse and they are tested against each other.
 A pair, because each is the other's inverse and they are tested against each other.
 
 
@@ -1668,7 +1767,7 @@ A pair, because each is the other's inverse and they are tested against each oth
 - Create: `tests/nodes/split-aggregate.json`
 - Create: `tests/nodes/split-aggregate.json`
 
 
 **Interfaces:**
 **Interfaces:**
-- Consumes: `getFieldValue` from Task 8 (copied into both files).
+- Consumes: `smartbotic.utils.getFieldValue(data, path)` from Task 5.
 
 
 - [ ] **Step 1: Write split-out**
 - [ ] **Step 1: Write split-out**
 
 
@@ -1727,49 +1826,6 @@ const outputSchema = {
     }
     }
 };
 };
 
 
-function getFieldValue(data, path) {
-    if (!path) return data;
-
-    const keys = String(path).split('.');
-    let value = data;
-
-    for (const key of keys) {
-        if (value === null || value === undefined) return undefined;
-
-        if (key === 'length' && Array.isArray(value)) {
-            value = value.length;
-            continue;
-        }
-
-        const arrayMatch = key.match(/^(.+)\[(\d+)\]$/);
-        if (arrayMatch) {
-            const objKey = arrayMatch[1];
-            const index = arrayMatch[2];
-            if (objKey && typeof value === 'object' && objKey in value) {
-                value = value[objKey];
-            }
-            if (Array.isArray(value)) {
-                value = value[parseInt(index, 10)];
-                continue;
-            }
-            return undefined;
-        }
-
-        if (/^\d+$/.test(key) && Array.isArray(value)) {
-            value = value[parseInt(key, 10)];
-            continue;
-        }
-
-        if (typeof value === 'object' && key in value) {
-            value = value[key];
-        } else {
-            return undefined;
-        }
-    }
-
-    return value;
-}
-
 function setByPath(target, path, value) {
 function setByPath(target, path, value) {
     const keys = String(path).split('.');
     const keys = String(path).split('.');
     let cursor = target;
     let cursor = target;
@@ -1795,7 +1851,7 @@ async function execute(config, input, context) {
     if (Array.isArray(field)) {
     if (Array.isArray(field)) {
         list = field;
         list = field;
     } else {
     } else {
-        list = getFieldValue(input, field);
+        list = smartbotic.utils.getFieldValue(input, field);
     }
     }
 
 
     if (!Array.isArray(list)) {
     if (!Array.isArray(list)) {
@@ -1822,7 +1878,7 @@ async function execute(config, input, context) {
         } else if (include === 'selected') {
         } else if (include === 'selected') {
             for (const path of includeFields) {
             for (const path of includeFields) {
                 if (path) {
                 if (path) {
-                    setByPath(item, path, getFieldValue(parent, path));
+                    setByPath(item, path, smartbotic.utils.getFieldValue(parent, path));
                 }
                 }
             }
             }
         }
         }
@@ -1894,49 +1950,6 @@ const outputSchema = {
     }
     }
 };
 };
 
 
-function getFieldValue(data, path) {
-    if (!path) return data;
-
-    const keys = String(path).split('.');
-    let value = data;
-
-    for (const key of keys) {
-        if (value === null || value === undefined) return undefined;
-
-        if (key === 'length' && Array.isArray(value)) {
-            value = value.length;
-            continue;
-        }
-
-        const arrayMatch = key.match(/^(.+)\[(\d+)\]$/);
-        if (arrayMatch) {
-            const objKey = arrayMatch[1];
-            const index = arrayMatch[2];
-            if (objKey && typeof value === 'object' && objKey in value) {
-                value = value[objKey];
-            }
-            if (Array.isArray(value)) {
-                value = value[parseInt(index, 10)];
-                continue;
-            }
-            return undefined;
-        }
-
-        if (/^\d+$/.test(key) && Array.isArray(value)) {
-            value = value[parseInt(key, 10)];
-            continue;
-        }
-
-        if (typeof value === 'object' && key in value) {
-            value = value[key];
-        } else {
-            return undefined;
-        }
-    }
-
-    return value;
-}
-
 async function execute(config, input, context) {
 async function execute(config, input, context) {
     const inputField = config.inputField;
     const inputField = config.inputField;
     const outputField = config.outputField || 'items';
     const outputField = config.outputField || 'items';
@@ -1947,7 +1960,7 @@ async function execute(config, input, context) {
     if (Array.isArray(inputField)) {
     if (Array.isArray(inputField)) {
         items = inputField;
         items = inputField;
     } else {
     } else {
-        items = getFieldValue(input, inputField || 'data');
+        items = smartbotic.utils.getFieldValue(input, inputField || 'data');
     }
     }
 
 
     if (!Array.isArray(items)) {
     if (!Array.isArray(items)) {
@@ -1955,7 +1968,7 @@ async function execute(config, input, context) {
     }
     }
 
 
     const values = fieldToAggregate
     const values = fieldToAggregate
-        ? items.map(function (item) { return getFieldValue(item, fieldToAggregate); })
+        ? items.map(function (item) { return smartbotic.utils.getFieldValue(item, fieldToAggregate); })
         : items;
         : items;
 
 
     if (!groupBy) {
     if (!groupBy) {
@@ -1968,7 +1981,7 @@ async function execute(config, input, context) {
     const groups = {};
     const groups = {};
     const order = [];
     const order = [];
     for (let i = 0; i < items.length; i++) {
     for (let i = 0; i < items.length; i++) {
-        const key = String(getFieldValue(items[i], groupBy));
+        const key = String(smartbotic.utils.getFieldValue(items[i], groupBy));
         if (groups[key] === undefined) {
         if (groups[key] === undefined) {
             groups[key] = [];
             groups[key] = [];
             order.push(key);
             order.push(key);
@@ -2039,7 +2052,7 @@ git commit -m "feat: Split Out and Aggregate, so Loop is not the only way to res
 
 
 ---
 ---
 
 
-### Task 12: Sort, Limit and Dedupe
+### Task 13: Sort, Limit and Dedupe
 
 
 One node. The three are always used together and each alone is ten lines.
 One node. The three are always used together and each alone is ten lines.
 
 
@@ -2048,7 +2061,7 @@ One node. The three are always used together and each alone is ten lines.
 - Create: `tests/nodes/sort-limit-dedupe.json`
 - Create: `tests/nodes/sort-limit-dedupe.json`
 
 
 **Interfaces:**
 **Interfaces:**
-- Consumes: `getFieldValue` from Task 8 (copied).
+- Consumes: `smartbotic.utils.getFieldValue(data, path)` from Task 5.
 
 
 - [ ] **Step 1: Write the node**
 - [ ] **Step 1: Write the node**
 
 
@@ -2131,49 +2144,6 @@ const outputSchema = {
     }
     }
 };
 };
 
 
-function getFieldValue(data, path) {
-    if (!path) return data;
-
-    const keys = String(path).split('.');
-    let value = data;
-
-    for (const key of keys) {
-        if (value === null || value === undefined) return undefined;
-
-        if (key === 'length' && Array.isArray(value)) {
-            value = value.length;
-            continue;
-        }
-
-        const arrayMatch = key.match(/^(.+)\[(\d+)\]$/);
-        if (arrayMatch) {
-            const objKey = arrayMatch[1];
-            const index = arrayMatch[2];
-            if (objKey && typeof value === 'object' && objKey in value) {
-                value = value[objKey];
-            }
-            if (Array.isArray(value)) {
-                value = value[parseInt(index, 10)];
-                continue;
-            }
-            return undefined;
-        }
-
-        if (/^\d+$/.test(key) && Array.isArray(value)) {
-            value = value[parseInt(key, 10)];
-            continue;
-        }
-
-        if (typeof value === 'object' && key in value) {
-            value = value[key];
-        } else {
-            return undefined;
-        }
-    }
-
-    return value;
-}
-
 function compareValues(left, right, compareAs) {
 function compareValues(left, right, compareAs) {
     if (compareAs === 'number' || (compareAs === 'auto' && typeof left === 'number' && typeof right === 'number')) {
     if (compareAs === 'number' || (compareAs === 'auto' && typeof left === 'number' && typeof right === 'number')) {
         const a = Number(left);
         const a = Number(left);
@@ -2199,7 +2169,7 @@ async function execute(config, input, context) {
     if (Array.isArray(inputField)) {
     if (Array.isArray(inputField)) {
         items = inputField;
         items = inputField;
     } else {
     } else {
-        items = getFieldValue(input, inputField || 'data');
+        items = smartbotic.utils.getFieldValue(input, inputField || 'data');
     }
     }
 
 
     if (!Array.isArray(items)) {
     if (!Array.isArray(items)) {
@@ -2213,7 +2183,7 @@ async function execute(config, input, context) {
         const seen = {};
         const seen = {};
         const unique = [];
         const unique = [];
         for (const item of working) {
         for (const item of working) {
-            const key = String(getFieldValue(item, dedupeBy));
+            const key = String(smartbotic.utils.getFieldValue(item, dedupeBy));
             if (seen[key] === true) {
             if (seen[key] === true) {
                 removedDuplicates++;
                 removedDuplicates++;
                 continue;
                 continue;
@@ -2229,8 +2199,8 @@ async function execute(config, input, context) {
             for (const rule of sortBy) {
             for (const rule of sortBy) {
                 if (!rule || !rule.field) continue;
                 if (!rule || !rule.field) continue;
                 const order = compareValues(
                 const order = compareValues(
-                    getFieldValue(left, rule.field),
-                    getFieldValue(right, rule.field),
+                    smartbotic.utils.getFieldValue(left, rule.field),
+                    smartbotic.utils.getFieldValue(right, rule.field),
                     rule.type || 'auto'
                     rule.type || 'auto'
                 );
                 );
                 if (order !== 0) {
                 if (order !== 0) {
@@ -2306,14 +2276,14 @@ git commit -m "feat: sorting, top-N and dedupe in one node"
 
 
 ---
 ---
 
 
-### Task 13: Template and JSON
+### Task 14: Template and JSON
 
 
 **Files:**
 **Files:**
 - Create: `nodes/core/template.js`, `nodes/core/json.js`
 - Create: `nodes/core/template.js`, `nodes/core/json.js`
 - Create: `tests/nodes/template-json.json`
 - Create: `tests/nodes/template-json.json`
 
 
 **Interfaces:**
 **Interfaces:**
-- Consumes: `getFieldValue` from Task 8 (copied into `json.js`).
+- Consumes: `smartbotic.utils.getFieldValue(data, path)` from Task 5.
 
 
 - [ ] **Step 1: Write template**
 - [ ] **Step 1: Write template**
 
 
@@ -2450,55 +2420,12 @@ const outputSchema = {
     }
     }
 };
 };
 
 
-function getFieldValue(data, path) {
-    if (!path) return data;
-
-    const keys = String(path).split('.');
-    let value = data;
-
-    for (const key of keys) {
-        if (value === null || value === undefined) return undefined;
-
-        if (key === 'length' && Array.isArray(value)) {
-            value = value.length;
-            continue;
-        }
-
-        const arrayMatch = key.match(/^(.+)\[(\d+)\]$/);
-        if (arrayMatch) {
-            const objKey = arrayMatch[1];
-            const index = arrayMatch[2];
-            if (objKey && typeof value === 'object' && objKey in value) {
-                value = value[objKey];
-            }
-            if (Array.isArray(value)) {
-                value = value[parseInt(index, 10)];
-                continue;
-            }
-            return undefined;
-        }
-
-        if (/^\d+$/.test(key) && Array.isArray(value)) {
-            value = value[parseInt(key, 10)];
-            continue;
-        }
-
-        if (typeof value === 'object' && key in value) {
-            value = value[key];
-        } else {
-            return undefined;
-        }
-    }
-
-    return value;
-}
-
 async function execute(config, input, context) {
 async function execute(config, input, context) {
     const operation = config.operation || 'parse';
     const operation = config.operation || 'parse';
     const outputField = config.outputField || 'value';
     const outputField = config.outputField || 'value';
     const onError = config.onError || 'throw';
     const onError = config.onError || 'throw';
 
 
-    const source = getFieldValue(input, config.inputField || 'data');
+    const source = smartbotic.utils.getFieldValue(input, config.inputField || 'data');
     const result = {};
     const result = {};
 
 
     if (operation === 'stringify') {
     if (operation === 'stringify') {
@@ -2509,7 +2436,7 @@ async function execute(config, input, context) {
     }
     }
 
 
     if (operation === 'extract') {
     if (operation === 'extract') {
-        result[outputField] = getFieldValue(source, config.path);
+        result[outputField] = smartbotic.utils.getFieldValue(source, config.path);
         return result;
         return result;
     }
     }
 
 
@@ -2588,7 +2515,7 @@ git commit -m "feat: Template and JSON nodes"
 
 
 ---
 ---
 
 
-### Task 14: Date and Time
+### Task 15: Date and Time
 
 
 Offsets only. QuickJS here has no `Intl`, so `Europe/Budapest` cannot be resolved and the node says so in its description rather than pretending.
 Offsets only. QuickJS here has no `Intl`, so `Europe/Budapest` cannot be resolved and the node says so in its description rather than pretending.
 
 
@@ -2684,49 +2611,6 @@ const UNIT_MS = {
     weeks: 604800000
     weeks: 604800000
 };
 };
 
 
-function getFieldValue(data, path) {
-    if (!path) return data;
-
-    const keys = String(path).split('.');
-    let value = data;
-
-    for (const key of keys) {
-        if (value === null || value === undefined) return undefined;
-
-        if (key === 'length' && Array.isArray(value)) {
-            value = value.length;
-            continue;
-        }
-
-        const arrayMatch = key.match(/^(.+)\[(\d+)\]$/);
-        if (arrayMatch) {
-            const objKey = arrayMatch[1];
-            const index = arrayMatch[2];
-            if (objKey && typeof value === 'object' && objKey in value) {
-                value = value[objKey];
-            }
-            if (Array.isArray(value)) {
-                value = value[parseInt(index, 10)];
-                continue;
-            }
-            return undefined;
-        }
-
-        if (/^\d+$/.test(key) && Array.isArray(value)) {
-            value = value[parseInt(key, 10)];
-            continue;
-        }
-
-        if (typeof value === 'object' && key in value) {
-            value = value[key];
-        } else {
-            return undefined;
-        }
-    }
-
-    return value;
-}
-
 function toTimestamp(value, label) {
 function toTimestamp(value, label) {
     if (value === undefined || value === null || value === '') {
     if (value === undefined || value === null || value === '') {
         throw new Error('Date and Time: no date found at "' + label + '"');
         throw new Error('Date and Time: no date found at "' + label + '"');
@@ -2804,7 +2688,7 @@ async function execute(config, input, context) {
         return result;
         return result;
     }
     }
 
 
-    const raw = getFieldValue(input, config.inputField || 'data');
+    const raw = smartbotic.utils.getFieldValue(input, config.inputField || 'data');
     const timestamp = toTimestamp(raw, config.inputField || 'data');
     const timestamp = toTimestamp(raw, config.inputField || 'data');
 
 
     if (operation === 'parse') {
     if (operation === 'parse') {
@@ -2821,7 +2705,7 @@ async function execute(config, input, context) {
 
 
     if (operation === 'diff') {
     if (operation === 'diff') {
         const other = toTimestamp(
         const other = toTimestamp(
-            getFieldValue(input, config.secondField),
+            smartbotic.utils.getFieldValue(input, config.secondField),
             config.secondField || 'secondField'
             config.secondField || 'secondField'
         );
         );
         result[outputField] = (timestamp - other) / step;
         result[outputField] = (timestamp - other) / step;
@@ -2889,7 +2773,7 @@ git commit -m "feat: a Date and Time node, honest about having no timezone datab
 
 
 ---
 ---
 
 
-### Task 15: Stop and Error, and the docs
+### Task 16: Stop and Error, and the docs
 
 
 **Files:**
 **Files:**
 - Create: `nodes/core/stop-and-error.js`
 - Create: `nodes/core/stop-and-error.js`
@@ -3013,7 +2897,20 @@ well-stocked, those matter more than any ten integrations.
 
 
 - [ ] **Step 5: Document the two new declarations**
 - [ ] **Step 5: Document the two new declarations**
 
 
-In `docs/nodes.md`, after the "Custom Outputs" section (line 167), add:
+In `docs/nodes.md`, in the "Utilities" section, add the shared path helper to the listed `smartbotic.utils` calls:
+
+```javascript
+// Read a nested value by dotted path
+const city = smartbotic.utils.getFieldValue(input, 'data.user.address.city');
+const second = smartbotic.utils.getFieldValue(input, 'data.items[1].id');
+const howMany = smartbotic.utils.getFieldValue(input, 'data.items.length');
+```
+
+Missing paths return `undefined`. Arrays are reached with `key[0]` or a bare
+numeric segment, and `.length` works on an array. The path is taken literally,
+with no special handling of a leading `data.` segment.
+
+Then, after the "Custom Outputs" section (line 167), add:
 
 
 ````markdown
 ````markdown
 ## Outputs That Come From Config
 ## Outputs That Come From Config
@@ -3088,6 +2985,6 @@ git commit -m "feat: a Stop and Error node, and docs for the two new port declar
 
 
 **If a branch test routes wrongly.** Print the node's full output from the harness listing. `_activeBranch` must name a port that also exists as a key on the returned object - `{_activeBranch: 'case1', case1: data}`. A missing key falls back to `output.data` (`workflow_engine.cpp:1243`), which looks like success but carries the wrong payload.
 **If a branch test routes wrongly.** Print the node's full output from the harness listing. `_activeBranch` must name a port that also exists as a key on the returned object - `{_activeBranch: 'case1', case1: data}`. A missing key falls back to `output.data` (`workflow_engine.cpp:1243`), which looks like success but carries the wrong payload.
 
 
-**If the editor draws no ports for switch.** Check the REST payload first (`curl .../nodes/switch | jq .dynamicOutputs`). If it is there, the break is in `resolveOutputs` or in the three editor sites from Task 6, not in the C++.
+**If the editor draws no ports for switch.** Check the REST payload first (`curl .../nodes/switch | jq .dynamicOutputs`). If it is there, the break is in `resolveOutputs` or in the three editor sites from Task 7, not in the C++.
 
 
 **Restarting services.** Always both, always after any C++ build. The runner caches node definitions over a gRPC stream, so a stale runner will happily execute the previous version of a node and the failure looks like a node bug.
 **Restarting services.** Always both, always after any C++ build. The runner caches node definitions over a gRPC stream, so a stale runner will happily execute the previous version of a node and the failure looks like a node bug.