system-diff 0.2.0__py3-none-any.whl → 0.3.4__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.
system_diff/__init__.py CHANGED
@@ -5,4 +5,4 @@
5
5
  bp = extract.extract(DirSource(Path(".")), cfg)
6
6
  svg = graph.system_svg(bp, cfg)
7
7
  """
8
- __version__ = "0.2.0"
8
+ __version__ = "0.3.4"
system_diff/activity.py CHANGED
@@ -25,6 +25,15 @@ MAX_BYTES, KEEP = 256 * 1024, 400 # past MAX_BYTES, only the newest KEEP event
25
25
  EDIT, READ, PROMPT, STOP = "edit", "read", "prompt", "stop"
26
26
 
27
27
 
28
+ SYSTEM_PROMPTS = ("<task-notification", "<command-name", "<command-message", "<local-command", "<system-reminder",
29
+ "<bash-input", "<bash-stdout", "<user-memory-input")
30
+
31
+
32
+ def is_system(text: str) -> bool:
33
+ """A prompt the person did not type: a background task's notice, a slash command's echo, a reminder."""
34
+ return (text or "").lstrip().startswith(SYSTEM_PROMPTS)
35
+
36
+
28
37
  def record(root: Path, event: Dict[str, Any]) -> None:
29
38
  """Add one event, e.g. {"event": "edit", "path": "app/api/routes.py"}, stamped with the time."""
30
39
  path = git_dir(root) / LOG
@@ -75,7 +84,7 @@ def now(evs: Iterable[dict], code: Code) -> Dict[str, Any]:
75
84
  touched = []
76
85
  for e in evs:
77
86
  kind = e.get("event")
78
- if kind == PROMPT:
87
+ if kind == PROMPT and not is_system(e.get("text", "")):
79
88
  start, out["prompt"], out["working"] = e["at"], e.get("text", ""), True
80
89
  elif kind == STOP:
81
90
  out["said"], out["working"] = e.get("text", ""), False
@@ -107,7 +116,7 @@ def feed(evs: Iterable[dict], code: Code, cfg: Dict[str, Any], most: int = 14) -
107
116
  out: List[Dict[str, Any]] = []
108
117
  for e in sorted(evs, key=lambda e: e["at"]):
109
118
  kind, path = e.get("event"), e.get("path", "")
110
- if kind == PROMPT:
119
+ if kind == PROMPT and not is_system(e.get("text", "")):
111
120
  out.append({"at": e["at"], "kind": kind, "text": f"You asked: {e.get('text', '')}"})
112
121
  elif kind in (READ, EDIT) and path and e.get("source") != "files":
113
122
  part = part_of(path, code)
@@ -196,9 +205,10 @@ def ago(at: float, clock: Optional[float] = None) -> str:
196
205
 
197
206
  # ── state.json: the latest diff in a few words ───────────────────────────────
198
207
 
199
- def write_state(root: Path, since: Optional[dict], label_since: str = "checkpoint", turn: Optional[dict] = None) -> None:
208
+ def write_state(root: Path, since: Optional[dict], label_since: str = "checkpoint", turn: Optional[dict] = None,
209
+ noted: Optional[List[str]] = None) -> None:
200
210
  """Keep the diff since the checkpoint, and this turn's, in a few words and as sentences (learn.story); None for
201
- nothing new."""
211
+ nothing new. `noted`: the turn's design notes (design.py) by their text, for telling what the next edit adds."""
202
212
  from .learn import story
203
213
 
204
214
  def brief(d: Optional[dict]) -> Dict[str, Any]:
@@ -208,6 +218,8 @@ def write_state(root: Path, since: Optional[dict], label_since: str = "checkpoin
208
218
  "told": [t["text"] for t in told][:500], # every sentence, for telling what the next edit adds
209
219
  "headline": (d or {}).get("headline", "No change to the system's structure.")}
210
220
  state = {"at": round(time.time(), 3), "since": label_since, **brief(since), "turn": brief(turn) if turn else None}
221
+ if state["turn"] is not None:
222
+ state["turn"]["noted"] = list(noted or [])[:500]
211
223
  _replace(git_dir(root) / STATE, json.dumps(state, ensure_ascii=False) + "\n")
212
224
 
213
225
 
@@ -215,7 +227,7 @@ def new_turn(root: Path) -> None:
215
227
  """A prompt was sent: the turn has changed nothing yet."""
216
228
  state = read_state(root)
217
229
  if state:
218
- state["turn"] = {"changes": 0, "short": "", "story": [], "count": 0, "told": [],
230
+ state["turn"] = {"changes": 0, "short": "", "story": [], "count": 0, "told": [], "noted": [],
219
231
  "headline": "No change to the system's structure."}
220
232
  _replace(git_dir(root) / STATE, json.dumps(state, ensure_ascii=False) + "\n")
221
233
 
system_diff/agent.py CHANGED
@@ -18,7 +18,7 @@ import sys
18
18
  from pathlib import Path
19
19
  from typing import Any, Callable, Dict, Iterable, List, Optional
20
20
 
21
- from . import activity, companion, config, learn
21
+ from . import activity, companion, config, design, learn
22
22
  from .compare import compare_worktree, mark_turn, turn_sha
23
23
  from .extract import Code
24
24
  from .sources import git
@@ -122,6 +122,10 @@ def on_start(root: Path, payload: Dict[str, Any], env=os.environ) -> Optional[st
122
122
 
123
123
 
124
124
  def on_prompt(root: Path, payload: Dict[str, Any]) -> None:
125
+ """The person sent a prompt: a new turn starts. A notice Claude Code sends as a prompt (a background task done)
126
+ is not one: the turn goes on."""
127
+ if activity.is_system(payload.get("prompt") or ""):
128
+ return
125
129
  mark_turn(root)
126
130
  activity.new_turn(root)
127
131
  activity.record(root, {"event": activity.PROMPT, "text": _clip(payload.get("prompt"), 300),
@@ -155,10 +159,25 @@ def on_tool(root: Path, payload: Dict[str, Any]) -> Optional[str]:
155
159
  last = activity.read_state(root).get("turn") or {}
156
160
  before = last.get("told") or [s["text"] for s in last.get("story") or []]
157
161
  since, turn = compare_worktree(root, ["checkpoint", "turn"])
158
- activity.write_state(root, since.diff, since.base, turn.diff)
162
+ notes = design.Reading(turn).notes()
163
+ record_step(root, path, part, last.get("noted") or [], notes)
164
+ activity.write_state(root, since.diff, since.base, turn.diff, noted=[n["text"] for n in notes])
159
165
  return step_message(before, learn.story(turn.diff))
160
166
 
161
167
 
168
+ def record_step(root: Path, path: str, part: str, before: Iterable[str], notes: List[Dict[str, Any]]) -> None:
169
+ """What one edit did to the system's design, kept as a step of what just happened: each change it made, said as
170
+ a design note with why it matters, and each change of the turn it undid."""
171
+ had = set(before)
172
+ now = {n["text"] for n in notes}
173
+ items = [{"text": n["text"], "why": n["why"], "sign": n["sign"], "status": n["status"], "weight": n["weight"],
174
+ "change": n.get("change")} for n in notes if n["text"] not in had]
175
+ items += [{"text": t, "why": "undone: the turn no longer makes this change", "sign": "↺", "status": "muted"}
176
+ for t in sorted(had - now)]
177
+ if items:
178
+ activity.record(root, {"event": "change", "path": path, "part": part, "items": items, "source": "hook"})
179
+
180
+
162
181
  def on_stop(root: Path, payload: Dict[str, Any]) -> Optional[str]:
163
182
  turn_known = turn_sha(root) is not None
164
183
  found = compare_worktree(root, ["checkpoint", "turn"] if turn_known else ["checkpoint"])
@@ -0,0 +1,324 @@
1
+ """The system as a system designer draws it, C4-style, rather than as a map of every module: who uses it (ACTORS,
2
+ people and programs), where they come in (ENTRY POINTS), the containers that do the work with their COMPONENTS listed
3
+ inside, the data it keeps (STORES) and the systems outside it; joined by FLOWS named for what travels on them
4
+ (HTTPS, MCP, SQL, a terminal). Two levels:
5
+
6
+ system every actor, entry point, container, store and outside system; a flow between two boxes stands for
7
+ all the connections between what they hold
8
+ container one container's components as boxes with the connections between them, and every box outside it
9
+ they connect to (an entry point, another container, a store), with the flows to it
10
+
11
+ Declared in system-diff.toml where the project says so: `[[actors]]` (id, label, kind person|program, uses = [part
12
+ ids], via = "HTTPS"), a component's `group` (its container) and `about` (one line on what it does), an outside
13
+ system's `used_by` and `via` where no setting names it. Inferred where it does not: the front end's people, an API's
14
+ callers, an MCP server's AI agents, a command line's developer, from the technology each entry point imports; the
15
+ parts of a crowded column held in one container named for it.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from collections import defaultdict
20
+ from typing import Any, Dict, List, Optional, Set, Tuple
21
+
22
+ from . import graph
23
+ from .design import kinds
24
+
25
+ COLUMNS_FIRST = [("actors", "Who uses it")]
26
+ SYSTEM_COLUMN = {"logic": "core", "data": "core", "store": "outside", "external": "outside"} # four, at the top level
27
+ MAIN_FLOWS = 4 # flows drawn from each box at the system level, strongest first
28
+ CROWDED = 7 # more parts than this in a column, none grouped: they go in one container
29
+ PROTOCOL = (("postgres", "SQL"), ("mysql", "SQL"), ("maria", "SQL"), ("sqlite", "SQL"), ("database", "SQL"),
30
+ ("redis", "Redis"), ("valkey", "Redis"), ("mongo", "MongoDB"), ("smtp", "SMTP"), ("mail", "SMTP"),
31
+ ("kafka", "Kafka"), ("rabbit", "AMQP"), ("s3", "S3 API"), ("minio", "S3 API"), ("git", "git"),
32
+ ("elastic", "HTTP"), ("search", "HTTP"))
33
+ ACTOR_BY_TECH = ( # an entry point importing this is used by: (actor id, label, kind, via)
34
+ ("mcp", ("agents", "AI agents", "program", "MCP")),
35
+ ("fastapi", ("clients", "API clients", "program", "HTTPS")),
36
+ ("flask", ("clients", "API clients", "program", "HTTPS")),
37
+ ("django", ("clients", "API clients", "program", "HTTPS")),
38
+ ("express", ("clients", "API clients", "program", "HTTPS")),
39
+ ("http.server", ("browser", "Browser", "person", "HTTP")),
40
+ ("argparse", ("developer", "Developer", "person", "terminal")),
41
+ ("click", ("developer", "Developer", "person", "terminal")),
42
+ ("typer", ("developer", "Developer", "person", "terminal")),
43
+ ("textual", ("developer", "Developer", "person", "terminal")),
44
+ )
45
+
46
+
47
+ def protocol(label: str, shape: str = "") -> str:
48
+ low = label.lower()
49
+ hit = next((p for key, p in PROTOCOL if key in low), "")
50
+ return hit or ("SQL" if shape == "db" else "HTTPS")
51
+
52
+
53
+ def _meta(cfg: Dict[str, Any]) -> Dict[str, dict]:
54
+ return {c["id"]: c for c in cfg.get("components", []) if "id" in c}
55
+
56
+
57
+ def groups_of(bp: dict, cfg: Dict[str, Any], kind: Dict[str, str]) -> Dict[str, str]:
58
+ """Part -> its container ("g:<name>"), as declared; a crowded column with none declared is one container."""
59
+ meta = _meta(cfg)
60
+ out = {cid: "g:" + meta[cid]["group"] for cid in bp["components"] if meta.get(cid, {}).get("group")}
61
+ by_col: Dict[str, List[str]] = defaultdict(list)
62
+ for cid in bp["components"]:
63
+ if cid not in out and not bp["components"][cid].get("quiet"):
64
+ by_col[kind.get(cid, "logic")].append(cid)
65
+ heading = dict(cfg["picture"]["columns"])
66
+ for col, parts in by_col.items():
67
+ if col in ("logic", "data") and len(parts) > CROWDED and not any(kind.get(p) == col for p in out):
68
+ for p in parts:
69
+ out[p] = "g:" + heading.get(col, col)
70
+ return out
71
+
72
+
73
+ def _stack(bp: dict, part: str, extra: Optional[Dict[str, Dict[str, int]]]) -> Set[str]:
74
+ names = set((bp.get("stack") or {}).get(part, {})) | set((extra or {}).get(part, {}))
75
+ return names | {n.split(".")[0] for n in names}
76
+
77
+
78
+ def actors(bp: dict, cfg: Dict[str, Any], kind: Dict[str, str],
79
+ stack: Optional[Dict[str, Dict[str, int]]] = None) -> Tuple[List[dict], List[dict]]:
80
+ """The actors and their flows into the entry points: as declared, else inferred from what each entry point is
81
+ built with. A front end's people use it in a browser; an API a front end calls has the front end as its caller."""
82
+ declared = cfg.get("actors") or []
83
+ nodes, flows = [], []
84
+ if declared:
85
+ for a in declared:
86
+ aid = "a:" + a["id"]
87
+ nodes.append({"id": aid, "col": "actors", "type": "actor", "title": a.get("label", a["id"]),
88
+ "sub": a.get("about", a.get("kind", "")), "status": "same"})
89
+ for use in a.get("uses", []):
90
+ part, _, via = str(use).partition(":")
91
+ if part in bp["components"]:
92
+ flows.append({"from": aid, "to": part, "label": via or a.get("via", ""), "kind": "call"})
93
+ return nodes, flows
94
+ called_by_ui = {key.split(">")[1] for key in bp["api"]}
95
+ found: Dict[str, dict] = {}
96
+ for cid in sorted(bp["components"], key=lambda c: graph.part_order(bp["components"][c], c)):
97
+ k = kind.get(cid)
98
+ if k == "ui":
99
+ hit = ("people", "People", "person", "browser")
100
+ elif k == "interface":
101
+ names = _stack(bp, cid, stack)
102
+ hit = next((a for tech, a in ACTOR_BY_TECH if tech in names), None)
103
+ if hit and hit[0] == "clients" and cid in called_by_ui:
104
+ hit = None # its front end is its caller, drawn as a flow already
105
+ if hit is None and any(o == cid for o in bp["routes"].values()) and cid not in called_by_ui:
106
+ hit = ("clients", "API clients", "program", "HTTPS")
107
+ else:
108
+ continue
109
+ if hit:
110
+ aid = "a:" + hit[0]
111
+ found.setdefault(aid, {"id": aid, "col": "actors", "type": "actor", "title": hit[1], "sub": hit[2],
112
+ "status": "same"})
113
+ flows.append({"from": aid, "to": cid, "label": hit[3], "kind": "call"})
114
+ return list(found.values()), flows
115
+
116
+
117
+ def _entry_sub(bp: dict, cid: str, cfg: Dict[str, Any]) -> str:
118
+ about = _meta(cfg).get(cid, {}).get("about", "")
119
+ routes = sum(1 for o in bp["routes"].values() if o == cid)
120
+ if routes:
121
+ return f"{routes} endpoint{'' if routes == 1 else 's'}" + (f" · {about}" if about else "")
122
+ return about or graph.files_word(bp["components"][cid]["files"])
123
+
124
+
125
+ def outside_nodes(bp: dict, cfg: Dict[str, Any], before: Optional[dict] = None) -> Tuple[List[dict], List[dict]]:
126
+ """Stores and outside systems with the flows from the parts that use them: those the settings name (the
127
+ blueprint's), and those the config ties to parts itself (`used_by`)."""
128
+ nodes: Dict[str, dict] = {}
129
+ flows = []
130
+ for side in [s for s in (before, bp) if s]:
131
+ for x, e in side["externals"].items():
132
+ db = e.get("shape") == "db"
133
+ nodes["x:" + x] = {"id": "x:" + x, "col": "store" if db else "external", "type": "store" if db else "host",
134
+ "title": e["label"], "status": "same", "sub": ""}
135
+ for x, e in bp["externals"].items():
136
+ tables = len(bp["tables"]) if x == bp.get("store", "postgres") else 0
137
+ nodes["x:" + x]["sub"] = f"{tables} tables" if tables else f"used by {len(e['used_by'])}"
138
+ for u in e["used_by"]:
139
+ flows.append({"from": u, "to": "x:" + x, "label": protocol(e["label"], e.get("shape", "")),
140
+ "kind": "data" if e.get("shape") == "db" else "network"})
141
+ for e in cfg.get("externals", []):
142
+ users = [u for u in e.get("used_by", []) if u in bp["components"]]
143
+ if not users or "x:" + e["id"] in nodes and "x:" + e["id"] in {f["to"] for f in flows}:
144
+ continue
145
+ db = e.get("shape") in ("db", "files")
146
+ nodes["x:" + e["id"]] = {"id": "x:" + e["id"], "col": "store" if db else "external",
147
+ "type": "store" if db else "host", "title": e["label"], "status": "same",
148
+ "sub": e.get("about", "")}
149
+ for u in users:
150
+ flows.append({"from": u, "to": "x:" + e["id"], "label": e.get("via") or protocol(e["label"], e.get("shape", "")),
151
+ "kind": "data" if db else "network"})
152
+ return list(nodes.values()), flows
153
+
154
+
155
+ def _status(marks: dict, nid: str) -> str:
156
+ return marks["status"].get(nid, "same")
157
+
158
+
159
+ def system(cmp, marks: Optional[dict] = None, agent: Optional[str] = None,
160
+ stack: Optional[Dict[str, Dict[str, int]]] = None) -> Dict[str, Any]:
161
+ """The system level: {"columns", "nodes", "edges"}, ready for canvas.layered. A container's node lists its
162
+ components as `items`; a flow names the boxes it joins and, where one end is a component in a container, that
163
+ component (`from_item`, `to_item`), so a picked component lights its own flows."""
164
+ bp, before, cfg = cmp.after, cmp.before, cmp.cfg
165
+ marks = marks or graph.change_marks(cmp.diff, before, bp)
166
+ kind = {**kinds(before, cfg), **kinds(bp, cfg)}
167
+ group = {**groups_of(before, cfg, kind), **groups_of(bp, cfg, kind)}
168
+ meta = _meta(cfg)
169
+ comps = {**before["components"], **bp["components"]}
170
+ gone = set(before["components"]) - set(bp["components"])
171
+
172
+ nodes: Dict[str, dict] = {}
173
+ box_of: Dict[str, str] = {}
174
+ for cid in sorted(comps, key=lambda c: graph.part_order(comps[c], c)):
175
+ c = comps[cid]
176
+ if c.get("quiet"):
177
+ continue
178
+ st = "removed" if cid in gone else _status(marks, cid)
179
+ col = kind.get(cid, "logic")
180
+ if cid in group:
181
+ gid = group[cid]
182
+ box = nodes.setdefault(gid, {"id": gid, "col": col, "type": "group", "title": gid[2:], "sub": "",
183
+ "status": "same", "items": []})
184
+ box["items"].append({"id": cid, "title": c["label"], "status": st, "agent": cid == agent,
185
+ "hub": bool(c.get("hub"))})
186
+ box_of[cid] = gid
187
+ else:
188
+ nodes[cid] = {"id": cid, "col": col, "type": "component", "title": c["label"], "status": st,
189
+ "agent": cid == agent, "sub": (_entry_sub(bp, cid, cfg) if cid in bp["components"]
190
+ and col in ("interface", "ui") else
191
+ meta.get(cid, {}).get("about", "")
192
+ or graph.files_word(c["files"]))}
193
+ box_of[cid] = cid
194
+ for box in nodes.values():
195
+ if box["type"] == "group":
196
+ items = box["items"]
197
+ box["sub"] = f"{len(items)} " + ("entry points" if box["col"] in ("interface", "ui") else "components")
198
+ if any(i["status"] != "same" for i in items):
199
+ box["status"] = "changed"
200
+ box["agent"] = any(i["agent"] for i in items)
201
+
202
+ acts, flows = actors(bp, cfg, kind, stack)
203
+ outs, out_flows = outside_nodes(bp, cfg, before)
204
+ for n in acts + outs:
205
+ n["status"] = _status(marks, n["id"])
206
+ nodes[n["id"]] = n
207
+ box_of[n["id"]] = n["id"]
208
+ flows += out_flows
209
+
210
+ # every connection between parts, lifted to the boxes that hold them; within one box it is not drawn
211
+ now = graph.connections(bp)
212
+ for (s, t), (k, _n) in now.items():
213
+ if k == "call" and not t.startswith("x:") and not comps.get(t, {}).get("hub"):
214
+ flows.append({"from": s, "to": t, "label": "HTTP" if f"{s}>{t}" in bp["api"] else "", "kind": "call"})
215
+ for (s, t), (k, st) in marks["edges"].items():
216
+ if st == "removed" and (s, t) not in now:
217
+ flows.append({"from": s, "to": t, "label": "", "kind": k, "status": "removed"})
218
+
219
+ edges: Dict[Tuple[str, str], dict] = {}
220
+ for f in flows:
221
+ a, b = box_of.get(f["from"]), box_of.get(f["to"])
222
+ if not a or not b or a == b:
223
+ continue
224
+ st = f.get("status") or marks["edges"].get((f["from"], f["to"]), (f["kind"], "same"))[1]
225
+ e = edges.setdefault((a, b), {"from": a, "to": b, "kind": f["kind"], "label": f.get("label", ""),
226
+ "status": "same", "pairs": []})
227
+ e["pairs"].append([f["from"], f["to"]])
228
+ if st != "same" and (e["status"] == "same" or st == "removed"):
229
+ e["status"] = st
230
+ if f.get("label") and f["label"] not in e["label"].split(", "):
231
+ e["label"] = f"{e['label']}, {f['label']}" if e["label"] else f["label"]
232
+ for e in edges.values(): # one component at an end: the flow meets it on its row
233
+ if len(e["label"]) > 16:
234
+ e["label"] = e["label"].split(", ")[0] + " +"
235
+ froms = {p[0] for p in e["pairs"]}
236
+ tos = {p[1] for p in e["pairs"]}
237
+ if nodes[e["from"]]["type"] == "group" and len(froms) == 1:
238
+ e["from_item"] = next(iter(froms))
239
+ if nodes[e["to"]]["type"] == "group" and len(tos) == 1:
240
+ e["to_item"] = next(iter(tos))
241
+ heading = dict(cfg["picture"]["columns"])
242
+ for n in nodes.values():
243
+ n["col"] = SYSTEM_COLUMN.get(n["col"], n["col"])
244
+ # the main flows: not one entry point starting another (one program's wiring), and from each box its strongest
245
+ # few; every flow that changed stays. The rest show in a box's own view.
246
+ kept: Dict[str, List[dict]] = defaultdict(list)
247
+ for e in sorted(edges.values(), key=lambda e: (e["status"] == "same", -len(e["pairs"]), e["to"])):
248
+ same_col = nodes[e["from"]]["col"] == nodes[e["to"]]["col"] in ("interface", "ui", "actors")
249
+ if e["status"] != "same" or (not same_col and len(kept[e["from"]]) < MAIN_FLOWS):
250
+ kept[e["from"]].append(e)
251
+ edges = {(e["from"], e["to"]): e for es in kept.values() for e in es}
252
+ columns = COLUMNS_FIRST + [("ui", heading.get("ui", "What people use")),
253
+ ("interface", heading.get("interface", "Entry points")),
254
+ ("core", "Inside the system"), ("outside", "Data and outside")]
255
+ return {"columns": columns, "nodes": list(nodes.values()), "edges": list(edges.values())}
256
+
257
+
258
+ def container(cmp, box: str, marks: Optional[dict] = None, agent: Optional[str] = None,
259
+ stack: Optional[Dict[str, Dict[str, int]]] = None) -> Optional[Dict[str, Any]]:
260
+ """One box zoomed in on: a container's components (or a lone part) as boxes, with the connections between them
261
+ and every box outside it that they connect to, each flow as at the system level."""
262
+ whole = system(cmp, marks, agent, stack)
263
+ nodes = {n["id"]: n for n in whole["nodes"]}
264
+ if box not in nodes:
265
+ holder = next((n["id"] for n in whole["nodes"] if any(i["id"] == box for i in n.get("items", []))), None)
266
+ if holder is None:
267
+ return None
268
+ box = holder
269
+ me = nodes[box]
270
+ inside = [i["id"] for i in me.get("items", [])] or [box]
271
+ item = {i["id"]: i for i in me.get("items", [])}
272
+ bp, before, cfg = cmp.after, cmp.before, cmp.cfg
273
+ marks = marks or graph.change_marks(cmp.diff, before, bp)
274
+ kind = {**kinds(before, cfg), **kinds(bp, cfg)}
275
+ comps = {**before["components"], **bp["components"]}
276
+ out_nodes: Dict[str, dict] = {}
277
+ for cid in inside:
278
+ if cid in item:
279
+ i = item[cid]
280
+ out_nodes[cid] = {"id": cid, "col": kind.get(cid, me["col"]), "type": "component", "title": i["title"],
281
+ "status": i["status"], "agent": i["agent"],
282
+ "sub": _meta(cfg).get(cid, {}).get("about", "") or graph.files_word(comps[cid]["files"])}
283
+ else:
284
+ out_nodes[cid] = dict(me)
285
+ edges = []
286
+ # inside: component to component
287
+ now = graph.connections(bp)
288
+ for (s, t), (k, _n) in sorted(now.items()):
289
+ if s in inside and t in inside and s != t and k == "call":
290
+ edges.append({"from": s, "to": t, "kind": "call", "label": "",
291
+ "status": marks["edges"].get((s, t), (k, "same"))[1]})
292
+ # out of and into the box: the system level's flows, met at their components
293
+ for e in whole["edges"]:
294
+ if box not in (e["from"], e["to"]):
295
+ continue
296
+ other = e["to"] if e["from"] == box else e["from"]
297
+ on = nodes[other]
298
+ out_nodes.setdefault(other, {**on, "items": None, "type": "component" if on["type"] == "group" else on["type"]}
299
+ if on["type"] == "group" else dict(on))
300
+ if on["type"] == "group":
301
+ out_nodes[other]["sub"] = f"{len(on['items'])} components"
302
+ for s, t in e["pairs"]:
303
+ a = s if s in inside else other
304
+ b = t if t in inside else other
305
+ if a != b:
306
+ edges.append({"from": a, "to": b, "kind": e["kind"], "label": e["label"],
307
+ "status": marks["edges"].get((s, t), (e["kind"], e["status"]))[1]
308
+ if e["status"] == "same" else e["status"]})
309
+ seen, uniq = set(), []
310
+ for e in edges:
311
+ if (e["from"], e["to"]) not in seen:
312
+ seen.add((e["from"], e["to"]))
313
+ uniq.append(e)
314
+ for n in out_nodes.values():
315
+ if n["id"] in comps and n["id"] in kind:
316
+ n["col"] = kind[n["id"]]
317
+ elif n["id"].startswith("x:"):
318
+ n["col"] = "store" if n.get("type") == "store" else "external"
319
+ elif n["id"].startswith("g:"):
320
+ first = next((i["id"] for i in nodes[n["id"]].get("items", [])), "")
321
+ n["col"] = kind.get(first, "logic")
322
+ columns = COLUMNS_FIRST + [tuple(c) for c in cfg["picture"]["columns"]]
323
+ return {"columns": columns, "nodes": list(out_nodes.values()), "edges": uniq, "box": box,
324
+ "title": me["title"]}