abstractnote 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. abstractnote/__init__.py +18 -0
  2. abstractnote/cli.py +240 -0
  3. abstractnote/flows/__init__.py +2 -0
  4. abstractnote/flows/build.py +970 -0
  5. abstractnote/flows/bundle.py +62 -0
  6. abstractnote/flows/contract.py +88 -0
  7. abstractnote/flows/graph.py +540 -0
  8. abstractnote/flows/json/note-capture.json +2280 -0
  9. abstractnote/flows/json/note-drain.json +2012 -0
  10. abstractnote/flows/json/note-mine.json +2392 -0
  11. abstractnote/flows/json/note-process.json +1853 -0
  12. abstractnote/flows/json/note-recall.json +1876 -0
  13. abstractnote/flows/json/note-reflect.json +2057 -0
  14. abstractnote/flows/prompts_fallback.py +119 -0
  15. abstractnote/flows/runner.py +320 -0
  16. abstractnote/flows/schemas_fallback.py +175 -0
  17. abstractnote/gateway/__init__.py +24 -0
  18. abstractnote/gateway/appspec.py +10 -0
  19. abstractnote/gateway/router.py +571 -0
  20. abstractnote/gateway/tools.py +74 -0
  21. abstractnote/ids.py +177 -0
  22. abstractnote/importers/__init__.py +142 -0
  23. abstractnote/importers/__main__.py +3 -0
  24. abstractnote/importers/agora.py +203 -0
  25. abstractnote/importers/claude_sessions.py +415 -0
  26. abstractnote/importers/cli.py +113 -0
  27. abstractnote/importers/files.py +72 -0
  28. abstractnote/importers/markdown.py +165 -0
  29. abstractnote/ingest/__init__.py +7 -0
  30. abstractnote/ingest/blobs.py +47 -0
  31. abstractnote/ingest/capture.py +265 -0
  32. abstractnote/ingest/extract.py +118 -0
  33. abstractnote/ingest/normalise.py +95 -0
  34. abstractnote/model.py +229 -0
  35. abstractnote/process/__init__.py +5 -0
  36. abstractnote/process/context.py +218 -0
  37. abstractnote/process/merge.py +60 -0
  38. abstractnote/process/prompts/__init__.py +39 -0
  39. abstractnote/process/prompts/extract.md +30 -0
  40. abstractnote/process/prompts/link.md +32 -0
  41. abstractnote/process/prompts/mine_dossier.md +13 -0
  42. abstractnote/process/prompts/mine_findings.md +13 -0
  43. abstractnote/process/prompts/mine_plan.md +13 -0
  44. abstractnote/process/prompts/quick_filing.md +23 -0
  45. abstractnote/process/prompts/reflect.md +10 -0
  46. abstractnote/process/prompts/synthesize.md +17 -0
  47. abstractnote/process/resolve.py +96 -0
  48. abstractnote/process/revise.py +410 -0
  49. abstractnote/process/schemas.py +377 -0
  50. abstractnote/process/vocabulary.py +68 -0
  51. abstractnote/recall/__init__.py +1 -0
  52. abstractnote/recall/changes.py +191 -0
  53. abstractnote/recall/expand.py +379 -0
  54. abstractnote/recall/insights.py +62 -0
  55. abstractnote/recall/reflect.py +202 -0
  56. abstractnote/recall/review.py +191 -0
  57. abstractnote/recall/search.py +205 -0
  58. abstractnote/recall/shapes.py +52 -0
  59. abstractnote/recall/then_now.py +100 -0
  60. abstractnote/store/__init__.py +12 -0
  61. abstractnote/store/archive.py +166 -0
  62. abstractnote/store/embeddings.py +275 -0
  63. abstractnote/store/kg.py +219 -0
  64. abstractnote/store/notebook.py +829 -0
  65. abstractnote/store/notes.py +1190 -0
  66. abstractnote/store/queue.py +159 -0
  67. abstractnote/store/schema.py +348 -0
  68. abstractnote/store/vector_index.py +88 -0
  69. abstractnote/tools/__init__.py +215 -0
  70. abstractnote/tools/capture.py +121 -0
  71. abstractnote/tools/context.py +19 -0
  72. abstractnote/tools/process.py +22 -0
  73. abstractnote/tools/queue.py +23 -0
  74. abstractnote/tools/recall.py +64 -0
  75. abstractnote-0.1.0.dist-info/METADATA +145 -0
  76. abstractnote-0.1.0.dist-info/RECORD +80 -0
  77. abstractnote-0.1.0.dist-info/WHEEL +5 -0
  78. abstractnote-0.1.0.dist-info/entry_points.txt +2 -0
  79. abstractnote-0.1.0.dist-info/licenses/LICENSE +21 -0
  80. abstractnote-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,540 @@
1
+ """A small VisualFlow graph builder for the AbstractNote flow generator.
2
+
3
+ Modelled on AbstractFlow's ``scripts/wf_common.py`` (node/edge shapes, catalog pins,
4
+ the anti-blob ``set_vars`` idiom, per-field subflow pins) but self-contained, because
5
+ AbstractFlow is not a Python package and ``abstractnote bundle`` must run anywhere the
6
+ package is installed. Every node type used here is a catalog type
7
+ (abstractflow/docs/workflow-node-catalog.md); the compiler refuses anything else.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
15
+
16
+ Pin = Tuple[str, str] # (pin id, pin type)
17
+ Src = Tuple[str, str] # (node id, output pin id)
18
+
19
+ EXEC_IN = {"id": "exec-in", "label": "", "type": "execution"}
20
+ EXEC_OUT = {"id": "exec-out", "label": "", "type": "execution"}
21
+
22
+ # Pins the executor rewrites on an exec-lane code node's output record (see
23
+ # wf_common._EXEC_RESERVED_OUTPUT_PINS): a named output pin may never use them.
24
+ EXEC_RESERVED_OUTPUT_PINS = frozenset({"success", "output", "result", "error", "execution"})
25
+
26
+ # Box model shared with AbstractFlow's canvas and audit_flow_graph.py.
27
+ NODE_WIDTH = 320.0
28
+ NODE_CHROME_H = 90.0
29
+ PIN_ROW_H = 26.0
30
+ MIN_HEIGHT = 220.0
31
+ COLUMN_GAP_X = 160.0
32
+ NODE_GAP_Y = 100.0
33
+
34
+ _PLACEHOLDER = re.compile(r"\{\{\s*(.*?)\s*\}\}")
35
+
36
+
37
+ def pin(pin_id: str, pin_type: str, label: Optional[str] = None) -> Dict[str, Any]:
38
+ return {"id": pin_id, "label": pin_id if label is None else label, "type": pin_type}
39
+
40
+
41
+ def template_variables(template: str) -> set[str]:
42
+ """Root variable names a ``{{var.path | filter}}`` template reads."""
43
+ out: set[str] = set()
44
+ for expr in _PLACEHOLDER.findall(template or ""):
45
+ root = expr.split("|", 1)[0].strip().split(".", 1)[0].strip()
46
+ if root:
47
+ out.add(root)
48
+ return out
49
+
50
+
51
+ class Flow:
52
+ """One VisualFlow document under construction."""
53
+
54
+ def __init__(self, flow_id: str, name: str, description: str, *, interfaces: Sequence[str] = ()) -> None:
55
+ self.doc: Dict[str, Any] = {
56
+ "id": flow_id,
57
+ "name": name,
58
+ "description": description,
59
+ "interfaces": list(interfaces),
60
+ "nodes": [],
61
+ "edges": [],
62
+ "entryNode": "start",
63
+ }
64
+ self._ids: set[str] = set()
65
+
66
+ # ------------------------------------------------------------------ raw
67
+
68
+ def node(
69
+ self,
70
+ node_id: str,
71
+ node_type: str,
72
+ label: str,
73
+ *,
74
+ inputs: Iterable[Dict[str, Any]] = (),
75
+ outputs: Iterable[Dict[str, Any]] = (),
76
+ pin_defaults: Optional[Dict[str, Any]] = None,
77
+ extra: Optional[Dict[str, Any]] = None,
78
+ ) -> str:
79
+ if node_id in self._ids:
80
+ raise ValueError(f"{self.doc['id']}: duplicate node id {node_id!r}")
81
+ self._ids.add(node_id)
82
+ data: Dict[str, Any] = {
83
+ "nodeType": node_type,
84
+ "label": label,
85
+ "inputs": list(inputs),
86
+ "outputs": list(outputs),
87
+ }
88
+ if pin_defaults:
89
+ data["pinDefaults"] = pin_defaults
90
+ if extra:
91
+ data.update(extra)
92
+ self.doc["nodes"].append({"id": node_id, "type": node_type, "position": {"x": 0, "y": 0}, "data": data})
93
+ return node_id
94
+
95
+ def edge(self, source: str, source_handle: str, target: str, target_handle: str) -> None:
96
+ eid = f"e-{source}-{source_handle}-{target}-{target_handle}".replace(":", "-")
97
+ self.doc["edges"].append(
98
+ {"id": eid, "source": source, "sourceHandle": source_handle, "target": target, "targetHandle": target_handle}
99
+ )
100
+
101
+ def then(self, source: str, target: str, handle: str = "exec-out") -> None:
102
+ self.edge(source, handle, target, "exec-in")
103
+
104
+ def wire(self, src: Src, target: str, target_pin: str) -> None:
105
+ self.edge(src[0], src[1], target, target_pin)
106
+
107
+ # ------------------------------------------------------------ catalog
108
+
109
+ def start(self, pins: Sequence[Pin], defaults: Dict[str, Any]) -> str:
110
+ return self.node(
111
+ "start", "on_flow_start", "Start",
112
+ outputs=[EXEC_OUT, *(pin(p, t) for p, t in pins)],
113
+ pin_defaults=dict(defaults),
114
+ )
115
+
116
+ def end(self, node_id: str, label: str, values: Dict[str, Tuple[str, Any]]) -> str:
117
+ """on_flow_end; `values` = {pin: (type, Src or literal)}. Each pin becomes run.output[pin]."""
118
+ inputs = [EXEC_IN]
119
+ literals: Dict[str, Any] = {}
120
+ wires: List[Tuple[str, Src]] = []
121
+ for pid, (ptype, value) in values.items():
122
+ inputs.append(pin(pid, ptype))
123
+ if isinstance(value, tuple):
124
+ wires.append((pid, value))
125
+ else:
126
+ literals[pid] = value
127
+ nid = self.node(node_id, "on_flow_end", label, inputs=inputs, pin_defaults=literals or None)
128
+ for pid, src in wires:
129
+ self.wire(src, nid, pid)
130
+ return nid
131
+
132
+ def code(
133
+ self,
134
+ node_id: str,
135
+ label: str,
136
+ body: str,
137
+ *,
138
+ inputs: Dict[str, Tuple[str, Any]],
139
+ outputs: Sequence[Pin],
140
+ exec_lane: bool = True,
141
+ ) -> str:
142
+ """A sandboxed code node. `inputs` = {pin: (type, Src or literal default)}.
143
+
144
+ Named outputs map the returned dict's keys onto pins. On the exec lane the node runs
145
+ once, in order; the reserved record keys are refused as pin names.
146
+ """
147
+ if exec_lane:
148
+ clash = sorted(p for p, _ in outputs if p in EXEC_RESERVED_OUTPUT_PINS)
149
+ if clash:
150
+ raise ValueError(f"{self.doc['id']}.{node_id}: output pins {clash} are reserved on exec code nodes")
151
+ defaults: Dict[str, Any] = {"permissions": "sandbox"}
152
+ wires: List[Tuple[str, Src]] = []
153
+ in_pins = [EXEC_IN] if exec_lane else []
154
+ for pid, (ptype, value) in inputs.items():
155
+ in_pins.append(pin(pid, ptype))
156
+ if isinstance(value, tuple):
157
+ wires.append((pid, value))
158
+ else:
159
+ defaults[pid] = value
160
+ in_pins.append(pin("permissions", "string"))
161
+ out_pins = ([EXEC_OUT] if exec_lane else []) + [pin(p, t) for p, t in outputs]
162
+ nid = self.node(
163
+ node_id, "code", label, inputs=in_pins, outputs=out_pins, pin_defaults=defaults,
164
+ extra={"functionName": "transform", "codeBody": body.strip() + "\n"},
165
+ )
166
+ for pid, src in wires:
167
+ self.wire(src, nid, pid)
168
+ return nid
169
+
170
+ def tool_calls(self, node_id: str, label: str, tool_name: str, calls: Src) -> str:
171
+ nid = self.node(
172
+ node_id, "tool_calls", label,
173
+ inputs=[EXEC_IN, pin("tool_calls", "array"), pin("allowed_tools", "array")],
174
+ outputs=[EXEC_OUT, pin("results", "array"), pin("success", "boolean")],
175
+ pin_defaults={"allowed_tools": [tool_name]},
176
+ extra={"effectConfig": {"allowed_tools": [tool_name]}},
177
+ )
178
+ self.wire(calls, nid, "tool_calls")
179
+ return nid
180
+
181
+ def loop_tool_step(
182
+ self,
183
+ step_id: str,
184
+ label: str,
185
+ tool_name: str,
186
+ args: Dict[str, Tuple[str, Any]],
187
+ outputs: Sequence[Pin],
188
+ after: Tuple[str, str],
189
+ ) -> Tuple[str, Tuple[str, str]]:
190
+ """A tool step INSIDE a loop body (while / ForEach): it never raises.
191
+
192
+ Inside a control block the runtime turns a node exception into "branch complete" and runs
193
+ the next iteration (compiler ``wrapped``: ``plan.complete_output`` -> return to the active
194
+ control node), so a raising result node would make a drain spin until the while guard. Here
195
+ the result node reports ``failed`` / ``failure`` instead, an ``if`` routes a failure to its
196
+ own ``on_flow_end`` (an end node terminates the run even inside a loop) with
197
+ ``{success: false, error, response}`` = the tool's sentence. Returns ``(out node id, the
198
+ (node, handle) the loop body continues from)``.
199
+ """
200
+ out_id = self._tool_nodes(step_id, label, tool_name, args, outputs, after, raising=False)
201
+ check = self.if_(f"{step_id}_failed", f"{label}: failed?", (out_id, "failed"))
202
+ self.then(out_id, check)
203
+ self.end(f"{step_id}_stopped", f"Stopped: {label.lower()} failed", {
204
+ "response": ("string", (out_id, "failure")), "error": ("string", (out_id, "failure")),
205
+ "success": ("boolean", False),
206
+ })
207
+ self.then(check, f"{step_id}_stopped", "true")
208
+ return out_id, (check, "false")
209
+
210
+ def tool_step(
211
+ self,
212
+ step_id: str,
213
+ label: str,
214
+ tool_name: str,
215
+ args: Dict[str, Tuple[str, Any]],
216
+ outputs: Sequence[Pin],
217
+ after: Tuple[str, str],
218
+ ) -> str:
219
+ """One deterministic note tool call, visible as three nodes:
220
+
221
+ ``<id>_args`` (code: one input pin per tool argument -> the {name, arguments} call; an
222
+ unset (None) or blank ("") value is left out so the tool's own default applies: the
223
+ runtime type-checks arguments before the call and refuses "" for an integer),
224
+ ``<id>`` (tool_calls, allowlist = this one tool), ``<id>_out`` (code: the tool's
225
+ result object spread onto named pins, plus ``tool_output`` = the whole object; a
226
+ failed call raises its own plain sentence and ends the run with that error).
227
+ Returns the ``_out`` node id; the exec chain continues from its ``exec-out``.
228
+ Never use it inside a loop body: use ``loop_tool_step``.
229
+ """
230
+ return self._tool_nodes(step_id, label, tool_name, args, outputs, after, raising=True)
231
+
232
+ def _tool_nodes(self, step_id: str, label: str, tool_name: str, args: Dict[str, Tuple[str, Any]],
233
+ outputs: Sequence[Pin], after: Tuple[str, str], *, raising: bool) -> str:
234
+ arg_names = list(args)
235
+ lines = ["arguments = {}"]
236
+ for a in arg_names:
237
+ ident = a
238
+ lines.append(f"if {ident} is not None and {ident} != \"\":")
239
+ lines.append(f" arguments[{json.dumps(a)}] = {ident}")
240
+ lines.append(
241
+ "return {\"tool_calls\": [{\"name\": " + json.dumps(tool_name)
242
+ + ", \"call_id\": " + json.dumps(step_id) + ", \"arguments\": arguments}]}"
243
+ )
244
+ args_id = self.code(
245
+ f"{step_id}_args", f"{label}: arguments", "\n".join(lines),
246
+ inputs=args, outputs=[("tool_calls", "array")],
247
+ )
248
+ self.then(after[0], args_id, after[1])
249
+ call_id = self.tool_calls(step_id, label, tool_name, (args_id, "tool_calls"))
250
+ self.then(args_id, call_id)
251
+ out_body = (
252
+ "r = results[0] if isinstance(results, list) and results else None\n"
253
+ "failure = None\n"
254
+ "out = None\n"
255
+ "if not isinstance(r, dict):\n"
256
+ f" failure = {json.dumps(tool_name + ' returned no result.')}\n"
257
+ "elif not r.get(\"success\"):\n"
258
+ " failure = str(r.get(\"error\") or " + json.dumps(tool_name + " failed.") + ")\n"
259
+ "else:\n"
260
+ " out = r.get(\"output\")\n"
261
+ " if not isinstance(out, dict):\n"
262
+ f" failure = {json.dumps(tool_name + ' returned something other than an object.')}\n"
263
+ )
264
+ if raising:
265
+ out_body += (
266
+ "if failure is not None:\n"
267
+ " raise ValueError(failure)\n"
268
+ "return dict(out, tool_output=out)\n"
269
+ )
270
+ extra_out: List[Pin] = [("tool_output", "object")]
271
+ else:
272
+ out_body += (
273
+ "if failure is not None:\n"
274
+ " return {\"failed\": True, \"failure\": failure}\n"
275
+ "return dict(out, tool_output=out, failed=False, failure=None)\n"
276
+ )
277
+ extra_out = [("tool_output", "object"), ("failed", "boolean"), ("failure", "string")]
278
+ out_id = self.code(
279
+ f"{step_id}_out", f"{label}: result", out_body,
280
+ inputs={"results": ("array", (call_id, "results"))},
281
+ outputs=[*outputs, *extra_out],
282
+ )
283
+ self.then(call_id, out_id)
284
+ return out_id
285
+
286
+ def template(self, node_id: str, label: str, template: str, variables: Dict[str, Src]) -> str:
287
+ used = template_variables(template)
288
+ unknown = sorted(used - set(variables))
289
+ if unknown:
290
+ raise ValueError(
291
+ f"{self.doc['id']}.{node_id}: the prompt uses {unknown}, which this flow does not provide "
292
+ f"(it provides {sorted(variables)})"
293
+ )
294
+ nid = self.node(
295
+ node_id, "string_template", label,
296
+ inputs=[pin("template", "string"), *(pin(v, "any") for v in variables)],
297
+ outputs=[pin("result", "string")],
298
+ pin_defaults={"template": template},
299
+ )
300
+ for v, src in variables.items():
301
+ self.wire(src, nid, v)
302
+ return nid
303
+
304
+ def llm(self, node_id: str, label: str, *, schema: Dict[str, Any], prompt: Src, provider: Src, model: Src,
305
+ system: str, temperature: float = 0.2) -> str:
306
+ nid = self.node(
307
+ node_id, "llm_call", label,
308
+ inputs=[
309
+ EXEC_IN, pin("provider", "provider_text"), pin("model", "model"), pin("system", "string"),
310
+ pin("prompt", "string"), pin("tools", "tools"), pin("temperature", "number"),
311
+ pin("resp_schema", "json_schema"),
312
+ ],
313
+ outputs=[
314
+ EXEC_OUT, pin("response", "string"), pin("data", "object"), pin("success", "boolean"),
315
+ pin("meta", "object"), pin("tool_calls", "array"),
316
+ ],
317
+ # tools = [] as a DECLARED default so no host-injected tool list reaches a judgement call.
318
+ pin_defaults={"tools": [], "system": system, "temperature": temperature, "resp_schema": schema},
319
+ )
320
+ self.wire(prompt, nid, "prompt")
321
+ self.wire(provider, nid, "provider")
322
+ self.wire(model, nid, "model")
323
+ return nid
324
+
325
+ def break_object(self, node_id: str, label: str, obj: Src, fields: Sequence[Pin]) -> str:
326
+ nid = self.node(
327
+ node_id, "break_object", label,
328
+ inputs=[pin("object", "object")],
329
+ outputs=[pin(f, t) for f, t in fields],
330
+ extra={"breakConfig": {"selectedPaths": [f for f, _ in fields]}},
331
+ )
332
+ self.wire(obj, nid, "object")
333
+ return nid
334
+
335
+ def answer_user(self, node_id: str, label: str, message: Src) -> str:
336
+ nid = self.node(
337
+ node_id, "answer_user", label,
338
+ inputs=[EXEC_IN, pin("message", "string"), pin("level", "string")],
339
+ outputs=[EXEC_OUT, pin("message", "string")],
340
+ pin_defaults={"level": "message"},
341
+ )
342
+ self.wire(message, nid, "message")
343
+ return nid
344
+
345
+ def if_(self, node_id: str, label: str, condition: Src) -> str:
346
+ nid = self.node(
347
+ node_id, "if", label,
348
+ inputs=[EXEC_IN, pin("condition", "boolean")],
349
+ outputs=[pin("true", "execution"), pin("false", "execution")],
350
+ )
351
+ self.wire(condition, nid, "condition")
352
+ return nid
353
+
354
+ def switch(self, node_id: str, label: str, value: Src, cases: Sequence[str]) -> str:
355
+ nid = self.node(
356
+ node_id, "switch", label,
357
+ inputs=[EXEC_IN, pin("value", "string")],
358
+ outputs=[*(pin(f"case:{c}", "execution", c) for c in cases), pin("default", "execution")],
359
+ extra={"switchConfig": {"cases": [{"id": c, "value": c} for c in cases]}},
360
+ )
361
+ self.wire(value, nid, "value")
362
+ return nid
363
+
364
+ def subflow(self, node_id: str, label: str, flow_id: str, inputs: Dict[str, Tuple[str, Src]],
365
+ outputs: Sequence[Pin]) -> str:
366
+ nid = self.node(
367
+ node_id, "subflow", label,
368
+ inputs=[EXEC_IN, pin("inherit_context", "boolean"), *(pin(p, t) for p, (t, _) in inputs.items())],
369
+ outputs=[EXEC_OUT, pin("output", "object"), pin("child_output", "object"),
370
+ *(pin(p, t) for p, t in outputs)],
371
+ pin_defaults={"inherit_context": False},
372
+ extra={"subflowId": flow_id},
373
+ )
374
+ for p, (_, src) in inputs.items():
375
+ self.wire(src, nid, p)
376
+ return nid
377
+
378
+ def child_check(self, node_id: str, label: str, subflow_id: str) -> str:
379
+ """After a subflow (NOT inside a loop body): end the run with the child's own sentence when
380
+ the child failed (a child that dies or whose node raised returns ``success: false``)."""
381
+ nid = self.code(
382
+ node_id, label,
383
+ "o = output if isinstance(output, dict) else {}\n"
384
+ "c = child_output if isinstance(child_output, dict) else {}\n"
385
+ "if c.get(\"success\") is False or o.get(\"success\") is False:\n"
386
+ " err = str(c.get(\"error\") or o.get(\"error\") or \"The step could not finish.\")\n"
387
+ " for prefix in (\"Runtime error: \", \"Execution error: \"):\n"
388
+ " if err.startswith(prefix):\n"
389
+ " err = err[len(prefix):]\n"
390
+ " raise ValueError(err)\n"
391
+ "return {\"checked\": True}",
392
+ inputs={"output": ("object", (subflow_id, "output")), "child_output": ("object", (subflow_id, "child_output"))},
393
+ outputs=[("checked", "boolean")],
394
+ )
395
+ self.then(subflow_id, nid)
396
+ return nid
397
+
398
+ def get_var(self, node_id: str, name: str, default: Any) -> str:
399
+ return self.node(
400
+ node_id, "get_var", f"Get {name}",
401
+ inputs=[pin("name", "string"), pin("default", "any")],
402
+ outputs=[pin("value", "any")],
403
+ pin_defaults={"name": name, "default": default},
404
+ )
405
+
406
+ def set_var(self, node_id: str, label: str, name: str, value: Any) -> str:
407
+ return self.node(
408
+ node_id, "set_var", label,
409
+ inputs=[EXEC_IN, pin("name", "string"), pin("value", "any")],
410
+ outputs=[EXEC_OUT, pin("value", "any")],
411
+ pin_defaults={"name": name, "value": value},
412
+ )
413
+
414
+ def set_vars(self, node_id: str, label: str, updates: Src) -> str:
415
+ nid = self.node(
416
+ node_id, "set_vars", label,
417
+ inputs=[EXEC_IN, pin("updates", "object")],
418
+ outputs=[EXEC_OUT, pin("updates", "object")],
419
+ )
420
+ self.wire(updates, nid, "updates")
421
+ return nid
422
+
423
+ def add_to_list(self, node_id: str, label: str, name: str, item: Src) -> str:
424
+ nid = self.node(
425
+ node_id, "add_to_list", label,
426
+ inputs=[EXEC_IN, pin("name", "string"), pin("item", "any"), pin("items", "array")],
427
+ outputs=[EXEC_OUT, pin("list", "array")],
428
+ pin_defaults={"name": name},
429
+ )
430
+ self.wire(item, nid, "item")
431
+ return nid
432
+
433
+ def while_(self, node_id: str, label: str, condition: Src) -> str:
434
+ nid = self.node(
435
+ node_id, "while", label,
436
+ inputs=[EXEC_IN, pin("condition", "boolean")],
437
+ outputs=[pin("loop", "execution"), pin("done", "execution"), pin("index", "number"), pin("item", "any")],
438
+ )
439
+ self.wire(condition, nid, "condition")
440
+ return nid
441
+
442
+ def for_each(self, node_id: str, label: str, items: Src) -> str:
443
+ nid = self.node(
444
+ node_id, "loop", label,
445
+ inputs=[EXEC_IN, pin("items", "array")],
446
+ outputs=[pin("loop", "execution"), pin("done", "execution"), pin("item", "any"), pin("index", "number")],
447
+ )
448
+ self.wire(items, nid, "items")
449
+ return nid
450
+
451
+ # ------------------------------------------------------------- finish
452
+
453
+ def finish(self, *, automation_defaults: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
454
+ if automation_defaults is not None:
455
+ self.doc["automation_defaults"] = automation_defaults
456
+ self._check_edges()
457
+ layout(self.doc)
458
+ return self.doc
459
+
460
+ def _check_edges(self) -> None:
461
+ pins: Dict[str, Tuple[set, set, str]] = {}
462
+ for n in self.doc["nodes"]:
463
+ d = n["data"]
464
+ pins[n["id"]] = ({p["id"] for p in d["inputs"]}, {p["id"] for p in d["outputs"]}, d["nodeType"])
465
+ problems = []
466
+ seen_data_targets: set = set()
467
+ for e in self.doc["edges"]:
468
+ s, t = e["source"], e["target"]
469
+ if s not in pins or t not in pins:
470
+ problems.append(f"edge {e['id']}: unknown node")
471
+ continue
472
+ if e["sourceHandle"] not in pins[s][1] and not (pins[s][2] == "subflow" and e["sourceHandle"] == "child_output"):
473
+ problems.append(f"edge {e['id']}: {s} has no output pin {e['sourceHandle']}")
474
+ if e["targetHandle"] not in pins[t][0]:
475
+ problems.append(f"edge {e['id']}: {t} has no input pin {e['targetHandle']}")
476
+ if e["targetHandle"] != "exec-in":
477
+ key = (t, e["targetHandle"])
478
+ if key in seen_data_targets:
479
+ problems.append(f"edge {e['id']}: {t}.{e['targetHandle']} has two data wires")
480
+ seen_data_targets.add(key)
481
+ if problems:
482
+ raise ValueError(f"{self.doc['id']}: " + "; ".join(problems))
483
+
484
+
485
+ def _node_height(node: Dict[str, Any]) -> float:
486
+ d = node["data"]
487
+ rows = max(
488
+ len([p for p in d.get("inputs") or [] if p.get("type") != "execution"]),
489
+ len([p for p in d.get("outputs") or [] if p.get("type") != "execution"]),
490
+ )
491
+ return max(NODE_CHROME_H + PIN_ROW_H * rows, MIN_HEIGHT)
492
+
493
+
494
+ def _is_exec_edge(edge: Dict[str, Any], exec_targets: Dict[str, set]) -> bool:
495
+ return edge["targetHandle"] == "exec-in"
496
+
497
+
498
+ def layout(doc: Dict[str, Any]) -> None:
499
+ """Left-to-right columns by exec depth; pure nodes one column left of their first consumer.
500
+
501
+ Each column is stacked top-down with the canvas box model, so no two boxes overlap.
502
+ """
503
+ nodes = {n["id"]: n for n in doc["nodes"]}
504
+ exec_children: Dict[str, List[str]] = {}
505
+ data_consumers: Dict[str, List[str]] = {}
506
+ for e in doc["edges"]:
507
+ if e["targetHandle"] == "exec-in":
508
+ exec_children.setdefault(e["source"], []).append(e["target"])
509
+ else:
510
+ data_consumers.setdefault(e["source"], []).append(e["target"])
511
+ depth: Dict[str, int] = {"start": 0} if "start" in nodes else {}
512
+ queue = list(depth)
513
+ while queue:
514
+ cur = queue.pop(0)
515
+ for child in exec_children.get(cur, []):
516
+ if child not in depth:
517
+ depth[child] = depth[cur] + 1
518
+ queue.append(child)
519
+ # Pure nodes: left of their earliest-placed consumer (iterate until stable).
520
+ for _ in range(len(nodes)):
521
+ changed = False
522
+ for nid in nodes:
523
+ if nid in depth:
524
+ continue
525
+ placed = [depth[c] for c in data_consumers.get(nid, []) if c in depth]
526
+ if placed:
527
+ depth[nid] = max(min(placed) - 1, 0)
528
+ changed = True
529
+ if not changed:
530
+ break
531
+ for nid in nodes:
532
+ depth.setdefault(nid, 0)
533
+ columns: Dict[int, List[str]] = {}
534
+ for nid in [n["id"] for n in doc["nodes"]]:
535
+ columns.setdefault(depth[nid], []).append(nid)
536
+ for col, ids in columns.items():
537
+ y = 0.0
538
+ for nid in ids:
539
+ nodes[nid]["position"] = {"x": col * (NODE_WIDTH + COLUMN_GAP_X), "y": y}
540
+ y += _node_height(nodes[nid]) + NODE_GAP_Y