gini-toolkit 6.0.1.dev0__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 (278) hide show
  1. gini/__init__.py +12 -0
  2. gini/__main__.py +107 -0
  3. gini/_version.py +24 -0
  4. gini/agent/__init__.py +17 -0
  5. gini/agent/agent_gamemaster.py +140 -0
  6. gini/agent/api.py +291 -0
  7. gini/agent/ask.py +123 -0
  8. gini/agent/authoring.py +72 -0
  9. gini/agent/blackboard.py +114 -0
  10. gini/agent/contracts.py +142 -0
  11. gini/agent/domains.py +91 -0
  12. gini/agent/embed.py +123 -0
  13. gini/agent/gamemaster.py +256 -0
  14. gini/agent/kb.py +148 -0
  15. gini/agent/lesson_resolver.py +261 -0
  16. gini/agent/llm/__init__.py +5 -0
  17. gini/agent/llm/backend.py +43 -0
  18. gini/agent/llm/fake.py +25 -0
  19. gini/agent/llm/ollama.py +206 -0
  20. gini/agent/loop.py +258 -0
  21. gini/agent/mcp_server.py +86 -0
  22. gini/agent/meaning.py +225 -0
  23. gini/agent/mission.py +210 -0
  24. gini/agent/mission_controller.py +208 -0
  25. gini/agent/narration.py +116 -0
  26. gini/agent/notifier.py +86 -0
  27. gini/agent/personas.py +79 -0
  28. gini/agent/reasoning.py +172 -0
  29. gini/agent/recall.py +248 -0
  30. gini/agent/session.py +79 -0
  31. gini/agent/teaching_center.py +482 -0
  32. gini/agent/tools/__init__.py +3 -0
  33. gini/agent/tools/registry.py +193 -0
  34. gini/agent/twin/__init__.py +28 -0
  35. gini/agent/twin/authoring.py +71 -0
  36. gini/agent/twin/contracts.py +54 -0
  37. gini/agent/twin/dialectic.py +189 -0
  38. gini/agent/twin/harness.py +93 -0
  39. gini/agent/twin/justify.py +156 -0
  40. gini/agent/twin/learner.py +64 -0
  41. gini/agent/twin/mission.py +60 -0
  42. gini/agent/twin/os_coach.py +79 -0
  43. gini/agent/twin/salience.py +30 -0
  44. gini/agent/understand.py +250 -0
  45. gini/agent/verifiers.py +106 -0
  46. gini/agent/wizard.py +178 -0
  47. gini/agent/xv6_pack.py +74 -0
  48. gini/app/__init__.py +3 -0
  49. gini/app/context.py +368 -0
  50. gini/app/paths.py +121 -0
  51. gini/data/README.md +21 -0
  52. gini/domain/__init__.py +9 -0
  53. gini/domain/assembly.py +209 -0
  54. gini/domain/authoring.py +353 -0
  55. gini/domain/blueprints.py +5 -0
  56. gini/domain/capabilities.py +177 -0
  57. gini/domain/catalog.py +85 -0
  58. gini/domain/certify.py +201 -0
  59. gini/domain/compose.py +413 -0
  60. gini/domain/composition.py +88 -0
  61. gini/domain/concepts.py +383 -0
  62. gini/domain/connection_rules.py +269 -0
  63. gini/domain/constraints.py +153 -0
  64. gini/domain/content.py +59 -0
  65. gini/domain/cpu_journey.py +89 -0
  66. gini/domain/devices.py +747 -0
  67. gini/domain/diagnose.py +201 -0
  68. gini/domain/element_guide.py +327 -0
  69. gini/domain/explain.py +90 -0
  70. gini/domain/fingerprint.py +201 -0
  71. gini/domain/firewall.py +34 -0
  72. gini/domain/flowlog.py +61 -0
  73. gini/domain/flowtable.py +179 -0
  74. gini/domain/fragment_yaml.py +230 -0
  75. gini/domain/fragments.py +169 -0
  76. gini/domain/games/__init__.py +2 -0
  77. gini/domain/games/paging_games.py +119 -0
  78. gini/domain/games/policy_game.py +86 -0
  79. gini/domain/games/process_game.py +48 -0
  80. gini/domain/games/thrash_game.py +75 -0
  81. gini/domain/games/translate_game.py +60 -0
  82. gini/domain/games/trap_game.py +86 -0
  83. gini/domain/grader.py +155 -0
  84. gini/domain/grouping.py +67 -0
  85. gini/domain/legality.py +103 -0
  86. gini/domain/lesson.py +241 -0
  87. gini/domain/lexicon.py +150 -0
  88. gini/domain/machine_state.py +410 -0
  89. gini/domain/missions/networking/basic-lan.yaml +32 -0
  90. gini/domain/missions/networking/cache-in-front.yaml +23 -0
  91. gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
  92. gini/domain/missions/networking/drive-load.yaml +20 -0
  93. gini/domain/missions/networking/fix-the-address.yaml +75 -0
  94. gini/domain/missions/networking/fix-the-lan.yaml +43 -0
  95. gini/domain/missions/networking/inspect-flows.yaml +16 -0
  96. gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
  97. gini/domain/missions/networking/least-privilege.yaml +21 -0
  98. gini/domain/missions/networking/load-balanced-web.yaml +29 -0
  99. gini/domain/missions/networking/observe-it.yaml +24 -0
  100. gini/domain/missions/networking/put-in-vpc.yaml +30 -0
  101. gini/domain/missions/networking/reachability-boundary.yaml +56 -0
  102. gini/domain/missions/networking/sdn-reactive.yaml +35 -0
  103. gini/domain/missions/networking/send-request.yaml +19 -0
  104. gini/domain/missions/networking/serverless-api.yaml +25 -0
  105. gini/domain/missions/networking/service-chain.yaml +33 -0
  106. gini/domain/missions/os/lottery-fix.yaml +19 -0
  107. gini/domain/missions/os/priority-fix.yaml +24 -0
  108. gini/domain/missions.py +111 -0
  109. gini/domain/modulechain.py +36 -0
  110. gini/domain/objectives.py +488 -0
  111. gini/domain/os_zoo.py +79 -0
  112. gini/domain/paging_sim.py +141 -0
  113. gini/domain/pricing.py +199 -0
  114. gini/domain/probes.py +226 -0
  115. gini/domain/profile.py +142 -0
  116. gini/domain/recipes.py +738 -0
  117. gini/domain/riders.py +309 -0
  118. gini/domain/router_modules.py +224 -0
  119. gini/domain/routetable.py +67 -0
  120. gini/domain/scoring.py +76 -0
  121. gini/domain/staging.py +122 -0
  122. gini/domain/syscall_builder.py +144 -0
  123. gini/domain/topic_cloud.py +62 -0
  124. gini/domain/topology.py +213 -0
  125. gini/domain/vocabulary.py +51 -0
  126. gini/domain/xv6.py +808 -0
  127. gini/domain/xv6_fs.py +250 -0
  128. gini/domain/xv6_runner.py +113 -0
  129. gini/domain/xv6_vm.py +385 -0
  130. gini/gloader.py +17 -0
  131. gini/runtime/__init__.py +18 -0
  132. gini/runtime/cloudfabric_agent.py +370 -0
  133. gini/runtime/console.py +68 -0
  134. gini/runtime/control.py +70 -0
  135. gini/runtime/frame.py +138 -0
  136. gini/runtime/gbridge.py +638 -0
  137. gini/runtime/grouter.py +223 -0
  138. gini/runtime/hostsim.py +90 -0
  139. gini/runtime/shuttle.py +348 -0
  140. gini/runtime/switch.py +109 -0
  141. gini/runtime/transport.py +77 -0
  142. gini/runtime/xv6_bridge.py +312 -0
  143. gini/server/__init__.py +22 -0
  144. gini/server/__main__.py +74 -0
  145. gini/server/app.py +140 -0
  146. gini/server/auth.py +82 -0
  147. gini/server/policy.py +57 -0
  148. gini/server/session.py +23 -0
  149. gini/services/__init__.py +15 -0
  150. gini/services/boardflash.py +248 -0
  151. gini/services/boardsetup.py +374 -0
  152. gini/services/cloud_catalog.py +143 -0
  153. gini/services/compiler.py +1858 -0
  154. gini/services/discovery.py +324 -0
  155. gini/services/gloader.py +183 -0
  156. gini/services/orchestrator.py +1460 -0
  157. gini/services/persistence.py +28 -0
  158. gini/services/probe_runner.py +149 -0
  159. gini/services/project.py +217 -0
  160. gini/services/remote.py +93 -0
  161. gini/services/rider_runner.py +96 -0
  162. gini/services/rider_session.py +171 -0
  163. gini/services/shadow_store.py +52 -0
  164. gini/services/terminal.py +45 -0
  165. gini/setup/__init__.py +17 -0
  166. gini/setup/cli.py +109 -0
  167. gini/setup/images.py +33 -0
  168. gini/setup/marker.py +43 -0
  169. gini/setup/runtime.py +69 -0
  170. gini/ui/__init__.py +3 -0
  171. gini/ui/assets/app_icon.icns +0 -0
  172. gini/ui/assets/app_icon.ico +0 -0
  173. gini/ui/assets/app_icon.png +0 -0
  174. gini/ui/assets/app_icon_1024.png +0 -0
  175. gini/ui/assets/cue/_w.txt +1 -0
  176. gini/ui/assets/cue/ai.png +0 -0
  177. gini/ui/assets/cue/canvas.png +0 -0
  178. gini/ui/assets/cue/cloud.png +0 -0
  179. gini/ui/assets/cue/cost.png +0 -0
  180. gini/ui/assets/cue/dark/ai.png +0 -0
  181. gini/ui/assets/cue/dark/canvas.png +0 -0
  182. gini/ui/assets/cue/dark/cloud.png +0 -0
  183. gini/ui/assets/cue/dark/cost.png +0 -0
  184. gini/ui/assets/cue/dark/metrics.png +0 -0
  185. gini/ui/assets/cue/dark/router.png +0 -0
  186. gini/ui/assets/cue/dark/run.png +0 -0
  187. gini/ui/assets/cue/dark/serverless.png +0 -0
  188. gini/ui/assets/cue/dark/settings.png +0 -0
  189. gini/ui/assets/cue/dark/welcome.png +0 -0
  190. gini/ui/assets/cue/dark/wizard.png +0 -0
  191. gini/ui/assets/cue/ginibrand/ai.png +0 -0
  192. gini/ui/assets/cue/ginibrand/canvas.png +0 -0
  193. gini/ui/assets/cue/ginibrand/cloud.png +0 -0
  194. gini/ui/assets/cue/ginibrand/cost.png +0 -0
  195. gini/ui/assets/cue/ginibrand/metrics.png +0 -0
  196. gini/ui/assets/cue/ginibrand/router.png +0 -0
  197. gini/ui/assets/cue/ginibrand/run.png +0 -0
  198. gini/ui/assets/cue/ginibrand/serverless.png +0 -0
  199. gini/ui/assets/cue/ginibrand/settings.png +0 -0
  200. gini/ui/assets/cue/ginibrand/welcome.png +0 -0
  201. gini/ui/assets/cue/ginibrand/wizard.png +0 -0
  202. gini/ui/assets/cue/highcontrast/ai.png +0 -0
  203. gini/ui/assets/cue/highcontrast/canvas.png +0 -0
  204. gini/ui/assets/cue/highcontrast/cloud.png +0 -0
  205. gini/ui/assets/cue/highcontrast/cost.png +0 -0
  206. gini/ui/assets/cue/highcontrast/metrics.png +0 -0
  207. gini/ui/assets/cue/highcontrast/router.png +0 -0
  208. gini/ui/assets/cue/highcontrast/run.png +0 -0
  209. gini/ui/assets/cue/highcontrast/serverless.png +0 -0
  210. gini/ui/assets/cue/highcontrast/settings.png +0 -0
  211. gini/ui/assets/cue/highcontrast/welcome.png +0 -0
  212. gini/ui/assets/cue/highcontrast/wizard.png +0 -0
  213. gini/ui/assets/cue/light/ai.png +0 -0
  214. gini/ui/assets/cue/light/canvas.png +0 -0
  215. gini/ui/assets/cue/light/cloud.png +0 -0
  216. gini/ui/assets/cue/light/cost.png +0 -0
  217. gini/ui/assets/cue/light/metrics.png +0 -0
  218. gini/ui/assets/cue/light/router.png +0 -0
  219. gini/ui/assets/cue/light/run.png +0 -0
  220. gini/ui/assets/cue/light/serverless.png +0 -0
  221. gini/ui/assets/cue/light/settings.png +0 -0
  222. gini/ui/assets/cue/light/welcome.png +0 -0
  223. gini/ui/assets/cue/light/wizard.png +0 -0
  224. gini/ui/assets/cue/metrics.png +0 -0
  225. gini/ui/assets/cue/router.png +0 -0
  226. gini/ui/assets/cue/run.png +0 -0
  227. gini/ui/assets/cue/serverless.png +0 -0
  228. gini/ui/assets/cue/settings.png +0 -0
  229. gini/ui/assets/cue/welcome.png +0 -0
  230. gini/ui/assets/cue/wizard.png +0 -0
  231. gini/ui/assistant.py +2111 -0
  232. gini/ui/author_dialog.py +184 -0
  233. gini/ui/board_dialog.py +247 -0
  234. gini/ui/branding.py +21 -0
  235. gini/ui/canvas.py +2007 -0
  236. gini/ui/chat_panel.py +7 -0
  237. gini/ui/cpu_journey.py +212 -0
  238. gini/ui/cpu_lab.py +306 -0
  239. gini/ui/cue_cards.py +214 -0
  240. gini/ui/dashboard.py +222 -0
  241. gini/ui/diagnose_game.py +336 -0
  242. gini/ui/fingerprint_lab.py +219 -0
  243. gini/ui/flash_dialog.py +244 -0
  244. gini/ui/flow_layout.py +63 -0
  245. gini/ui/fragment_manager.py +1415 -0
  246. gini/ui/game_catalog.py +184 -0
  247. gini/ui/game_renderers.py +340 -0
  248. gini/ui/games_lab.py +90 -0
  249. gini/ui/inspector.py +1055 -0
  250. gini/ui/live_metrics.py +130 -0
  251. gini/ui/machine_lab.py +1412 -0
  252. gini/ui/main_window.py +3153 -0
  253. gini/ui/memory_lab.py +371 -0
  254. gini/ui/mission_panel.py +302 -0
  255. gini/ui/mode_indicator.py +227 -0
  256. gini/ui/palette.py +112 -0
  257. gini/ui/peripherals.py +218 -0
  258. gini/ui/process_tree.py +130 -0
  259. gini/ui/reset_dialog.py +179 -0
  260. gini/ui/router_lab.py +776 -0
  261. gini/ui/run_button.py +183 -0
  262. gini/ui/settings_dialog.py +234 -0
  263. gini/ui/signin_dialog.py +111 -0
  264. gini/ui/storage_lab.py +219 -0
  265. gini/ui/syscall_builder.py +235 -0
  266. gini/ui/syscall_lab.py +152 -0
  267. gini/ui/theme/__init__.py +5 -0
  268. gini/ui/theme/icons.py +145 -0
  269. gini/ui/theme/manager.py +291 -0
  270. gini/ui/theme/tokens.py +194 -0
  271. gini/ui/trap_lab.py +270 -0
  272. gini/ui/worker_host.py +102 -0
  273. gini/ui/zoo_lab.py +112 -0
  274. gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
  275. gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
  276. gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
  277. gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
  278. gini_toolkit-6.0.1.dev0.dist-info/top_level.txt +1 -0
gini/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """GINI gBuilder — next-generation topology builder for networks + cloud."""
2
+
3
+ # Version is derived from git tags at build time by setuptools-scm, which writes _version.py into
4
+ # the package. Running from a source checkout without a build falls back to a dev marker.
5
+ try:
6
+ from ._version import version as __version__
7
+ except Exception: # noqa: BLE001 — no built _version.py (raw source tree)
8
+ try:
9
+ from importlib.metadata import version as _v
10
+ __version__ = _v("gini-toolkit")
11
+ except Exception: # noqa: BLE001
12
+ __version__ = "0.0.0+unknown"
gini/__main__.py ADDED
@@ -0,0 +1,107 @@
1
+ """Launch gBuilder.
2
+
3
+ python -m gini # run the app
4
+ python -m gini --demo # run with a sample hybrid topology
5
+ QT_QPA_PLATFORM=offscreen python -m gini --selftest # headless smoke test
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+
11
+
12
+ def build_demo(api) -> None:
13
+ """A small hybrid networks + cloud topology for first-run / selftest."""
14
+ r1 = api.add_device("router", x=-260, y=-160)
15
+ s1 = api.add_device("switch", x=-260, y=-20)
16
+ h1 = api.add_device("host", x=-380, y=120)
17
+ api.add_device("host", x=-160, y=120)
18
+ api.connect(r1["name"], s1["name"])
19
+ api.connect(s1["name"], h1["name"])
20
+
21
+ vpc = api.add_device("vpc", x=160, y=-180)
22
+ lb = api.add_device("load_balancer", x=160, y=-40)
23
+ inst = api.add_device("instance", x=40, y=110)
24
+ api.add_device("instance", x=280, y=110)
25
+ db = api.add_device("database", x=160, y=240)
26
+ api.connect(vpc["name"], lb["name"])
27
+ api.connect(lb["name"], inst["name"])
28
+ api.connect(inst["name"], db["name"])
29
+ api.connect(r1["name"], vpc["name"], "hybrid uplink")
30
+
31
+
32
+ def _apply_branding(app) -> None:
33
+ """Name + icon so the taskbar/dock shows the GINI mascot (not the Python launcher)."""
34
+ from .ui.branding import app_icon, icon_path
35
+ app.setApplicationName("gBuilder")
36
+ app.setApplicationDisplayName("gBuilder")
37
+ app.setOrganizationName("GINI")
38
+ app.setWindowIcon(app_icon())
39
+ if sys.platform == "win32": # make Windows group + show OUR icon, not python.exe's
40
+ try:
41
+ import ctypes
42
+ ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("GINI.gBuilder")
43
+ except Exception:
44
+ pass
45
+ elif sys.platform == "darwin": # set the Dock icon when run from source (needs pyobjc)
46
+ try:
47
+ from AppKit import NSApplication, NSImage
48
+ img = NSImage.alloc().initByReferencingFile_(icon_path())
49
+ NSApplication.sharedApplication().setApplicationIconImage_(img)
50
+ except Exception:
51
+ pass # no pyobjc → bundle a .app for a permanent Dock icon
52
+
53
+
54
+ def _setup_preflight() -> None:
55
+ """Soft check: Demo mode always works; live Run needs the container runtime + images from
56
+ `gini-setup`. We only nudge here — never block — so the app is explorable immediately."""
57
+ try:
58
+ from . import __version__
59
+ from .setup import marker
60
+ if not marker.is_setup_done():
61
+ print("[gini] Runtime not set up yet — Demo mode works now; run `gini-setup` to enable "
62
+ "live Run (installs Docker/Colima + pulls images).")
63
+ elif marker.needs_update(__version__):
64
+ print(f"[gini] App is {__version__} but images were set up for {marker.setup_version()} "
65
+ "— run `gini-setup --update` to refresh them.")
66
+ except Exception:
67
+ pass
68
+
69
+
70
+ def main() -> int:
71
+ args = set(sys.argv[1:])
72
+ from PySide6.QtWidgets import QApplication
73
+
74
+ app = QApplication.instance() or QApplication(sys.argv)
75
+ _apply_branding(app)
76
+ from .ui import MainWindow
77
+
78
+ win = MainWindow(app)
79
+
80
+ if "--demo" in args or "--selftest" in args:
81
+ build_demo(win.api)
82
+
83
+ if "--selftest" in args:
84
+ s = win.api.summary()
85
+ assert s["devices"] >= 9, s
86
+ assert s["links"] >= 6, s
87
+ explanation = win.api.explain_topology()
88
+ assert "elements" in explanation
89
+ # exercise theme swap + node creation paths
90
+ win.theme.set_theme("Light")
91
+ win.theme.set_theme("GINI Brand")
92
+ print("SELFTEST OK:", s)
93
+ print("EXPLAIN:", explanation)
94
+ return 0
95
+
96
+ if not ({"--demo", "--selftest"} & args):
97
+ win.restore_last_project() # reopen last session's project
98
+
99
+ _setup_preflight() # non-blocking: Demo always works
100
+ win.show()
101
+ from PySide6.QtCore import QTimer
102
+ QTimer.singleShot(450, win.maybe_start_tour) # feature tour, once the window is painted
103
+ return app.exec()
104
+
105
+
106
+ if __name__ == "__main__":
107
+ raise SystemExit(main())
gini/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '6.0.1.dev0'
22
+ __version_tuple__ = version_tuple = (6, 0, 1, 'dev0')
23
+
24
+ __commit_id__ = commit_id = None
gini/agent/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """Agent package.
2
+
3
+ `GiniAPI` is exposed lazily: importing it pulls in `app.context` -> PySide6, and headless consumers
4
+ (the Teaching Center server, CI, scripts) need the pure pieces of this package — `authoring`,
5
+ `teaching_center`, `lesson_resolver` — WITHOUT a GUI dependency. `from gini.agent import GiniAPI`
6
+ still works exactly as before; it just doesn't happen at package-import time.
7
+ """
8
+
9
+
10
+ def __getattr__(name): # PEP 562 lazy attribute
11
+ if name == "GiniAPI":
12
+ from .api import GiniAPI
13
+ return GiniAPI
14
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
15
+
16
+
17
+ __all__ = ["GiniAPI"]
@@ -0,0 +1,140 @@
1
+ """AgentGameMaster — a drop-in for `GameMaster` whose reasoning runs through the multi-agent stack
2
+ (blackboard + personas), so the live mission loop is *exercised* by the new architecture without
3
+ touching MissionController's proven mechanics (panel, guided steps, scoring, completion, flags).
4
+
5
+ It exposes the exact method surface the controller drives — `brief_line`, `present_step`,
6
+ `react_reply`, `decide`, `flag_note` — but:
7
+ • grounds every line in the blackboard's live objective verdicts (fed from the mission's own
8
+ results, so no double evaluation);
9
+ • routes a student question (decide with an utterance) through the full MissionAgent turn
10
+ (classify → understand → reason → critic), and a quiet canvas-reaction through the Reasoning
11
+ persona alone (fast, no extra model calls per drop);
12
+ • returns a `contracts.Move` whose `kind == "quiet"` is what the controller treats as silence.
13
+
14
+ Missions stay LLM-gated: with `llm=None` every method degrades to the authored text (inert), matching
15
+ `GameMaster`.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from .blackboard import Blackboard
20
+ from .contracts import Move, Notification
21
+ from .meaning import MissionAgent
22
+ from .personas import PersonaRunner
23
+ from .reasoning import REASONING
24
+
25
+
26
+ class AgentGameMaster:
27
+ def __init__(self, lesson, llm=None, *, persona: str | None = None,
28
+ twin_enabled: bool = False) -> None:
29
+ self.lesson = lesson
30
+ self.llm = llm
31
+ self.persona = persona or getattr(lesson, "persona", "coach")
32
+ self.runner = PersonaRunner(llm)
33
+ self.bb = Blackboard()
34
+ self.bb.load_lesson(lesson)
35
+ # Reasoning 2.0: the Twin audits covered turns. Model-gated — with no LLM the persona
36
+ # answers are authored text and a coverage dialogue would only add noise.
37
+ twin = None
38
+ if twin_enabled and llm is not None:
39
+ from .twin.dialectic import Twin
40
+ twin = Twin()
41
+ self.agent = MissionAgent(self.runner, self.bb, lesson, twin=twin)
42
+ self.reasoning = self.agent.reasoning
43
+ self._prev_met: int | None = None
44
+ self._get_world = None # bound by the controller (for the explainer)
45
+
46
+ def bind_world(self, getter) -> None:
47
+ """The controller hands us a way to read the live world, so the Reasoning persona can ground
48
+ its 'why is this red' diagnosis in the actual board (the predicate explainer)."""
49
+ self._get_world = getter
50
+
51
+ def _sync_world(self) -> None:
52
+ if self._get_world is not None:
53
+ try:
54
+ self.bb._world = self._get_world()
55
+ except Exception:
56
+ self.bb._world = None
57
+
58
+ # -- narration (Reasoning persona, grounded in mission intent) ----------- #
59
+ def brief_line(self) -> str:
60
+ if self.llm is None:
61
+ return self.lesson.brief
62
+ return self.runner.call(
63
+ REASONING, context=self.reasoning._intent(),
64
+ task=f"Brief the student on this mission in 1-2 short lines, in character, ending by "
65
+ f"starting the clock. The task: {self.lesson.brief}") or self.lesson.brief
66
+
67
+ def present_step(self, step, index: int, total: int, *, acked: str = "") -> str:
68
+ if self.llm is None:
69
+ return step.say
70
+ lead = (f"The student just finished the previous beat ({acked}); acknowledge it in a few "
71
+ "words, then ") if acked else ""
72
+ return self.runner.call(
73
+ REASONING, context=self.reasoning._context(),
74
+ task=f"This is a guided lab, beat {index}/{total}. {lead}give the student THIS one "
75
+ f"instruction in 1-2 short lines — just this step: {step.say}") or step.say
76
+
77
+ def react_reply(self, step, utterance: str, results) -> str:
78
+ self.bb.ingest_results(results)
79
+ if self.llm is None:
80
+ return "Good — let's continue."
81
+ return self.runner.call(
82
+ REASONING, context=self.reasoning._context(),
83
+ task=f"The student was asked: {step.say!r} and replied: {utterance!r}. Respond in 1-2 "
84
+ "short lines — affirm or gently correct, grounded in the goal; then we move on."
85
+ ) or "Good — let's continue."
86
+
87
+ def run_note(self, failed) -> str:
88
+ """React to a Run whose live checks FAILED — grounded so the model can't claim a false win."""
89
+ joined = "; ".join(failed)
90
+ if self.llm is None:
91
+ return f"The live check didn't pass yet: {joined}. Check the real connection."
92
+ return self.runner.call(
93
+ REASONING, context=self.reasoning._context(),
94
+ task=f"The student ran the system and a LIVE check FAILED: {joined}. In ONE short line, "
95
+ "tell them the live verification did not pass yet and to check the actual "
96
+ "connection — do NOT claim it succeeded.") or f"The live check didn't pass: {joined}."
97
+
98
+ def flag_note(self, reasons) -> str:
99
+ joined = "; ".join(reasons)
100
+ if self.llm is None:
101
+ return "Heads up — " + joined
102
+ return self.runner.call(
103
+ REASONING, context=self.reasoning._context(),
104
+ task=f"The student just placed something off-task: {joined}. In ONE short in-character "
105
+ "line, flag it and tell them to fix it — do NOT remove it for them."
106
+ ) or ("Heads up — " + joined)
107
+
108
+ # -- the reasoning turn (what the controller calls on change / ask) ------ #
109
+ def decide(self, mission, results, *, utterance: str = "", world_digest: str = "") -> Move:
110
+ if self.llm is None:
111
+ return Move("quiet")
112
+ self.bb.ingest_results(results) # sync truth from the mission's own eval
113
+ self._sync_world() # give the explainer the live board
114
+
115
+ if utterance: # a question → full trio (grounded + audited)
116
+ return self.agent.turn(utterance=utterance)
117
+
118
+ met = sum(1 for r in results if r.met)
119
+ first = self._prev_met is None
120
+ prev = self._prev_met
121
+ self._prev_met = met
122
+
123
+ if mission.complete:
124
+ trigger = Notification("mission_complete", salience=1.0)
125
+ elif getattr(mission, "state", "") == "done":
126
+ trigger = Notification("objective_unmet", salience=0.4) # ran out — encourage
127
+ elif self.lesson.help == "none":
128
+ return Move("quiet") # proctored: silent except completion
129
+ elif not first and met > prev:
130
+ trigger = Notification("objective_met", salience=0.6)
131
+ elif not first and met < prev:
132
+ trigger = Notification("objective_unmet", salience=0.4)
133
+ else:
134
+ return Move("quiet") # no salient change → stay quiet
135
+
136
+ return self.reasoning.react(trigger) # quiet reaction path: Reasoning only (fast)
137
+
138
+ # parity with GameMaster's convenience method
139
+ def observe(self, mission, results) -> Move:
140
+ return self.decide(mission, results)
gini/agent/api.py ADDED
@@ -0,0 +1,291 @@
1
+ """GiniAPI — the programmatic surface AI agents drive.
2
+
3
+ This is the single, UI-independent entry point for *building*, *inspecting*, and
4
+ *explaining* topologies. The in-app assistant calls it directly; the MCP server
5
+ (`mcp_server.py`) wraps these same methods as tools for external agents like Claude.
6
+
7
+ The `explain_*` methods are deterministic (rule-based) so they work with no LLM and
8
+ are unit-testable; an LLM assistant can layer richer narration on top of them.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from ..app import AppContext
13
+ from ..domain import Category, all_devices, devices
14
+ from ..domain.topology import DeviceInstance
15
+
16
+
17
+ class GiniAPI:
18
+ def __init__(self, ctx: AppContext) -> None:
19
+ self.ctx = ctx
20
+
21
+ # -- resolution --------------------------------------------------------- #
22
+ def _resolve(self, ref: str) -> DeviceInstance:
23
+ t = self.ctx.topology
24
+ if ref in t.devices:
25
+ return t.devices[ref]
26
+ d = t.find_by_name(ref)
27
+ if d is None:
28
+ raise KeyError(f"no device named or id'd {ref!r}")
29
+ return d
30
+
31
+ # -- catalog ------------------------------------------------------------ #
32
+ def list_device_types(self) -> list[dict]:
33
+ return [
34
+ {"key": d.key, "label": d.label, "category": d.category.value,
35
+ "cloud": d.cloud, "description": d.description}
36
+ for d in all_devices()
37
+ ]
38
+
39
+ # -- build -------------------------------------------------------------- #
40
+ def add_device(self, type_key: str, name: str | None = None,
41
+ x: float = 0.0, y: float = 0.0,
42
+ properties: dict | None = None) -> dict:
43
+ if type_key not in devices.REGISTRY:
44
+ raise KeyError(f"unknown device type {type_key!r}")
45
+ inst = self.ctx.add_device(type_key, x=x, y=y, name=name, properties=properties)
46
+ return self._device_dict(inst)
47
+
48
+ def connect(self, a: str, b: str, label: str = "") -> dict:
49
+ da, db = self._resolve(a), self._resolve(b)
50
+ # ctx.connect auto-attaches Source/Sink riders (dotted edge) and links everything else —
51
+ # same behaviour as the canvas, so agent/recipe wiring matches what a teacher would draw.
52
+ link = self.ctx.connect(da.id, db.id, label)
53
+ return {"id": link.id, "source": da.name, "target": db.name, "label": label}
54
+
55
+ def remove_device(self, ref: str) -> None:
56
+ self.ctx.remove_device(self._resolve(ref).id)
57
+
58
+ def set_property(self, ref: str, key: str, value: str) -> dict:
59
+ d = self._resolve(ref)
60
+ d.properties[key] = value
61
+ if key == "Name":
62
+ self.ctx.topology.rename(d.id, value)
63
+ self.ctx.bus.device_changed.emit(d.id)
64
+ # The advisory lint reads PROPERTIES, not just shape — "No BoardID set",
65
+ # duplicate BoardID, overlapping PhysicalSubnet — but it only re-ran on
66
+ # topology_changed. So a warning raised when an element was dropped (every
67
+ # property still at its default) was never re-evaluated once the student
68
+ # filled the property in: a GINI32 board showed the amber "!" for a missing
69
+ # BoardID while simultaneously reporting itself online, which is impossible,
70
+ # since the relay matches boards BY that id. The badge was simply stale.
71
+ # This is debounced (120 ms) and text fields commit on editingFinished, so
72
+ # re-linting here costs nothing per edit.
73
+ self.ctx.bus.topology_changed.emit()
74
+ return self._device_dict(d)
75
+
76
+ # -- manual addressing -------------------------------------------------- #
77
+ def set_manual_addressing(self, on: bool) -> None:
78
+ """Toggle manual addressing: stop auto-assigning IPs and honor static_ips
79
+ (auto-filling any interface left blank)."""
80
+ self.ctx.topology.manual_addressing = bool(on)
81
+ self.ctx.bus.topology_changed.emit()
82
+
83
+ def set_interface_ip(self, ref: str, link_id: str, ip: str) -> dict:
84
+ """Set (or clear, with an empty string) a device's static IP on one interface
85
+ (identified by the link it sits on). Honored only in manual addressing mode."""
86
+ d = self._resolve(ref)
87
+ bare = (ip or "").strip().split("/")[0]
88
+ si = dict(getattr(d, "static_ips", None) or {})
89
+ if bare:
90
+ si[link_id] = bare
91
+ else:
92
+ si.pop(link_id, None)
93
+ d.static_ips = si
94
+ self.ctx.bus.topology_changed.emit()
95
+ return self._device_dict(d)
96
+
97
+ # -- recipes (Wizard blueprints) ---------------------------------------- #
98
+ def list_recipes(self) -> list[dict]:
99
+ from ..domain.recipes import RECIPES
100
+ return [{"id": r.id, "name": r.name, "summary": r.summary,
101
+ "teaches": r.teaches, "intent": list(r.intent)} for r in RECIPES]
102
+
103
+ def suggest_recipes(self, query: str) -> list[dict]:
104
+ """Rank recipes against a free-text intent (deterministic; the LLM mirrors this
105
+ but can also explain). Returns the matching recipes, best first."""
106
+ from ..domain.recipes import suggest_recipes
107
+ return [{"id": r.id, "name": r.name, "summary": r.summary, "teaches": r.teaches}
108
+ for r in suggest_recipes(query)]
109
+
110
+ def apply_recipe(self, recipe_id: str) -> dict:
111
+ """Instantiate a curated blueprint onto the canvas — add its elements (laid out
112
+ on a grid), set their properties, and connect them. Deterministic: no LLM in this
113
+ path, so the result is always a valid, known-good topology."""
114
+ from ..domain.recipes import get_recipe
115
+ r = get_recipe(recipe_id)
116
+ if r is None:
117
+ raise KeyError(f"unknown recipe {recipe_id!r}")
118
+ # offset the layout below anything already on the canvas
119
+ base_y = 0.0
120
+ if self.ctx.topology.devices:
121
+ base_y = max(d.y for d in self.ctx.topology.devices.values()) + 170.0
122
+ refs: dict[str, str] = {}
123
+ for el in r.elements:
124
+ x = 60.0 + el.col * 220.0
125
+ y = base_y + 40.0 + el.row * 150.0
126
+ d = self.add_device(el.type_key, x=x, y=y, properties=el.props or None)
127
+ refs[el.ref] = d["id"]
128
+ # box containment (VPC / Subnet / Region): set parent_id so the compiler
129
+ # reads membership. Parents precede children in element order.
130
+ parent = getattr(el, "parent", "")
131
+ if parent and parent in refs:
132
+ dev = self.ctx.topology.devices.get(d["id"])
133
+ if dev is not None:
134
+ dev.parent_id = refs[parent]
135
+ links = 0
136
+ for a, b in r.links:
137
+ self.connect(refs[a], refs[b])
138
+ links += 1
139
+ return {"recipe": r.id, "name": r.name, "added": list(refs.values()),
140
+ "links": links}
141
+
142
+ # -- inspect ------------------------------------------------------------ #
143
+ def get_topology(self) -> dict:
144
+ return self.ctx.topology.to_dict()
145
+
146
+ def inspect(self, ref: str) -> dict:
147
+ d = self._resolve(ref)
148
+ out = self._device_dict(d)
149
+ out["neighbors"] = [n.name for n in self.ctx.topology.neighbors(d.id)]
150
+ out["degree"] = self.ctx.topology.degree(d.id)
151
+ return out
152
+
153
+ def summary(self) -> dict:
154
+ t = self.ctx.topology
155
+ return {
156
+ "name": t.name,
157
+ "devices": len(t.devices),
158
+ "links": len(t.links),
159
+ "by_category": t.counts_by_category(),
160
+ }
161
+
162
+ # -- explain (deterministic narration) ---------------------------------- #
163
+ def explain_topology(self) -> str:
164
+ t = self.ctx.topology
165
+ if not t.devices:
166
+ return "The canvas is empty. Drag a device from the palette to begin."
167
+ counts = t.counts_by_category()
168
+ has_net = any(c in counts for c in (Category.NETWORKING.value, Category.SDN.value))
169
+ has_cloud = any(k in counts for k in (
170
+ Category.CONTAINERS.value, Category.CLOUD_NETWORK.value,
171
+ Category.STORAGE.value, Category.SERVERLESS.value))
172
+
173
+ parts = [f"This topology “{t.name}” has {len(t.devices)} elements "
174
+ f"and {len(t.links)} links."]
175
+ cat_bits = [f"{n} {cat.lower()}" for cat, n in counts.items()]
176
+ parts.append("It spans " + ", ".join(cat_bits) + ".")
177
+ if has_net and has_cloud:
178
+ parts.append("It is a hybrid experiment combining classic network devices "
179
+ "with cloud-computing primitives.")
180
+ elif has_cloud:
181
+ parts.append("This is primarily a cloud-computing scenario.")
182
+ else:
183
+ parts.append("This is primarily a computer-networking scenario.")
184
+
185
+ # connectivity notes
186
+ isolated = [d.name for d in t.devices.values() if t.degree(d.id) == 0]
187
+ if isolated:
188
+ parts.append("Not yet connected: " + ", ".join(isolated) + ".")
189
+ hubs = sorted(t.devices.values(), key=lambda d: t.degree(d.id), reverse=True)
190
+ if hubs and t.degree(hubs[0].id) >= 2:
191
+ parts.append(f"{hubs[0].name} is the most connected node "
192
+ f"({t.degree(hubs[0].id)} links).")
193
+ return " ".join(parts)
194
+
195
+ def context_digest(self) -> str:
196
+ """A compact, ground-truth snapshot of the current canvas for the AI: every
197
+ device with its type, IP/subnet, and what it connects to. Injected into the
198
+ model each turn so the assistant always knows the topology without a tool call.
199
+ """
200
+ t = self.ctx.topology
201
+ if not t.devices:
202
+ return "The canvas is currently empty (no devices placed yet)."
203
+ try:
204
+ from ..services.compiler import address_map
205
+ addr = address_map(t)
206
+ except Exception:
207
+ addr = {}
208
+ lines = [f'Topology "{t.name}": {len(t.devices)} devices, {len(t.links)} links.']
209
+ subnets: set[str] = set()
210
+ for d in t.devices.values():
211
+ info = addr.get(d.name, {})
212
+ ifaces = info.get("interfaces", [])
213
+ ip_bits = []
214
+ for itf in ifaces:
215
+ if itf.get("ip"):
216
+ ip_bits.append(itf["ip"])
217
+ if itf.get("subnet"):
218
+ subnets.add(itf["subnet"])
219
+ ip_s = (" — " + ", ".join(ip_bits)) if ip_bits else ""
220
+ nbrs = [n.name for n in t.neighbors(d.id)]
221
+ conn = ("connected to " + ", ".join(nbrs)) if nbrs else "not connected yet"
222
+ gw = next((itf.get("gateway") for itf in ifaces if itf.get("gateway")), None)
223
+ gw_s = f", gateway {gw}" if gw else ""
224
+ lines.append(f"- {d.name} ({d.type.label}){ip_s} — {conn}{gw_s}")
225
+ if subnets:
226
+ lines.append("Subnets: " + ", ".join(sorted(subnets)) + ".")
227
+ return "\n".join(lines)
228
+
229
+ def explain_element_type(self, type_key: str) -> str:
230
+ """Teaching guide for a palette element TYPE (what it is + when to use it).
231
+ Falls back to the catalog description if the element isn't in the guide."""
232
+ from ..domain.devices import REGISTRY
233
+ from ..domain.element_guide import guide_for
234
+ dt = REGISTRY.get(type_key)
235
+ if dt is None:
236
+ return f"I don't recognize the element '{type_key}'."
237
+ return guide_for(type_key) or f"{dt.label} ({dt.category.value}). {dt.description}"
238
+
239
+ def trace_path(self, src: str, dst: str) -> list[str]:
240
+ """The hop-by-hop device path a packet takes from src to dst (by name).
241
+
242
+ Shortest path over the topology graph — which is exactly the forwarding path,
243
+ since routing follows it. Returns device names (e.g. [M1, S1, R1, R2, S2, M5])
244
+ or [] if there's no path. Drives the tutor's packet animation.
245
+ """
246
+ from collections import deque
247
+ s, d = self._resolve(src), self._resolve(dst)
248
+ t = self.ctx.topology
249
+ nbrs: dict[str, list] = {}
250
+ for l in t.links.values():
251
+ nbrs.setdefault(l.source_id, []).append(l.target_id)
252
+ nbrs.setdefault(l.target_id, []).append(l.source_id)
253
+ prev: dict[str, str | None] = {s.id: None}
254
+ q = deque([s.id])
255
+ while q:
256
+ cur = q.popleft()
257
+ if cur == d.id:
258
+ break
259
+ for nb in nbrs.get(cur, []):
260
+ if nb not in prev:
261
+ prev[nb] = cur
262
+ q.append(nb)
263
+ if d.id not in prev:
264
+ return []
265
+ path, cur = [], d.id
266
+ while cur is not None:
267
+ path.append(t.devices[cur].name)
268
+ cur = prev[cur]
269
+ path.reverse()
270
+ return path
271
+
272
+ def explain_device(self, ref: str) -> str:
273
+ d = self._resolve(ref)
274
+ dt = d.type
275
+ nbrs = [n.name for n in self.ctx.topology.neighbors(d.id)]
276
+ msg = f"{d.name} is a {dt.label} ({dt.category.value}). {dt.description}"
277
+ props = {k: v for k, v in d.properties.items() if v and k != "Name"}
278
+ if props:
279
+ msg += " Configured: " + ", ".join(f"{k}={v}" for k, v in props.items()) + "."
280
+ msg += (f" It connects to {', '.join(nbrs)}." if nbrs
281
+ else " It is not connected to anything yet.")
282
+ return msg
283
+
284
+ # -- helpers ------------------------------------------------------------ #
285
+ @staticmethod
286
+ def _device_dict(d: DeviceInstance) -> dict:
287
+ return {
288
+ "id": d.id, "name": d.name, "type": d.type_key,
289
+ "category": d.type.category.value,
290
+ "x": d.x, "y": d.y, "properties": dict(d.properties),
291
+ }