windhover 0.7.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.
windhover/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Windhover — self-hosted LangGraph observability."""
2
+ from .tracer import WindhoverTracer, SpanBuilder
3
+
4
+ __version__ = "0.3.0"
5
+ __all__ = ["WindhoverTracer", "SpanBuilder", "__version__"]
windhover/config.py ADDED
@@ -0,0 +1,32 @@
1
+ """Windhover configuration — one place, read from the environment."""
2
+ from __future__ import annotations
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ PKG_DIR = Path(__file__).resolve().parent
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Config:
12
+ graph_ref: str # "module:attr" of a compiled graph ("" = ingest-only)
13
+ graph_dir: str # import path for the graph module
14
+ db_path: str
15
+ host: str
16
+ port: int
17
+ watch: bool # live-topology file watcher
18
+ pricing_path: str
19
+ retention_days: int # 0 = keep runs forever
20
+
21
+ @classmethod
22
+ def from_env(cls) -> "Config":
23
+ return cls(
24
+ graph_ref=os.environ.get("WINDHOVER_GRAPH", ""),
25
+ graph_dir=os.environ.get("WINDHOVER_GRAPH_DIR", os.getcwd()),
26
+ db_path=os.environ.get("WINDHOVER_DB", str(PKG_DIR.parent / "windhover.db")),
27
+ host=os.environ.get("WINDHOVER_HOST", "0.0.0.0"),
28
+ port=int(os.environ.get("WINDHOVER_PORT", "8090")),
29
+ watch=os.environ.get("WINDHOVER_WATCH", "1") not in ("0", "false", "no"),
30
+ pricing_path=os.environ.get("WINDHOVER_PRICING", str(PKG_DIR / "pricing.json")),
31
+ retention_days=int(os.environ.get("WINDHOVER_RETENTION_DAYS", "0") or 0),
32
+ )
@@ -0,0 +1,48 @@
1
+ """Self-contained demo graph so Windhover runs out of the box:
2
+
3
+ WINDHOVER_GRAPH=windhover.demo_graph:graph python -m windhover.server
4
+
5
+ No external services. Edit this file while Windhover runs to watch the topology
6
+ update itself in the UI. Includes a parallel fan-out (grow -> parity/sign/
7
+ magnitude -> summarize) so the graph view and trace show concurrent branches;
8
+ `notes` uses an additive reducer so the branches can write it concurrently.
9
+ """
10
+ import operator
11
+ import time
12
+ from typing import Annotated, TypedDict
13
+ from langgraph.graph import StateGraph, START, END
14
+
15
+
16
+ class State(TypedDict):
17
+ n: int
18
+ notes: Annotated[list, operator.add]
19
+
20
+
21
+ def seed(s):
22
+ time.sleep(.2)
23
+ n = s.get("n", 1)
24
+ if n < 0:
25
+ raise ValueError(f"n must be non-negative, got {n} — the demo guard tripped")
26
+ return {"n": n}
27
+ def grow(s): time.sleep(.3); return {"n": s["n"] * 3}
28
+ def parity(s): time.sleep(.4); return {"notes": ["even" if s["n"] % 2 == 0 else "odd"]}
29
+ def sign(s): time.sleep(.25); return {"notes": ["positive" if s["n"] > 0 else "non-positive"]}
30
+ def magnitude(s): time.sleep(.35); return {"notes": ["big" if abs(s["n"]) > 100 else "small"]}
31
+ def summarize(s): time.sleep(.2); return {"notes": [f"n={s['n']}: " + ", ".join(s["notes"])]}
32
+
33
+
34
+ _g = StateGraph(State)
35
+ for name, fn in [("seed", seed), ("grow", grow), ("parity", parity),
36
+ ("sign", sign), ("magnitude", magnitude), ("summarize", summarize)]:
37
+ _g.add_node(name, fn)
38
+ _g.add_edge(START, "seed")
39
+ _g.add_edge("seed", "grow")
40
+ _g.add_edge("grow", "parity")
41
+ _g.add_edge("grow", "sign")
42
+ _g.add_edge("grow", "magnitude")
43
+ _g.add_edge("parity", "summarize")
44
+ _g.add_edge("sign", "summarize")
45
+ _g.add_edge("magnitude", "summarize")
46
+ _g.add_edge("summarize", END)
47
+
48
+ graph = _g.compile()
windhover/extract.py ADDED
@@ -0,0 +1,103 @@
1
+ """Extract a compiled graph's topology, input schema, and per-node source as
2
+ JSON. Run as a subprocess so the live watcher always sees current-on-disk code
3
+ without importlib.reload fragility.
4
+
5
+ python -m windhover.extract "module:attr" "/graph/dir"
6
+ """
7
+ import sys, json, importlib, inspect, functools
8
+
9
+
10
+ def topology(graph, xray: bool = False) -> dict:
11
+ g = graph.get_graph(xray=True) if xray else graph.get_graph()
12
+ nodes = [{"id": nid, "label": str(getattr(n, "name", nid)).replace("__", ""),
13
+ "terminal": nid.endswith(("__start__", "__end__"))}
14
+ for nid, n in g.nodes.items()]
15
+ edges = [{"id": f"e{i}", "source": e.source, "target": e.target,
16
+ "conditional": bool(getattr(e, "conditional", False))}
17
+ for i, e in enumerate(g.edges)]
18
+ return {"nodes": nodes, "edges": edges}
19
+
20
+
21
+ def input_schema(graph) -> dict:
22
+ for meth in ("get_input_jsonschema", "get_input_schema"):
23
+ try:
24
+ s = getattr(graph, meth)()
25
+ return s if isinstance(s, dict) else s.model_json_schema()
26
+ except Exception:
27
+ continue
28
+ return {}
29
+
30
+
31
+ def _unwrap(fn):
32
+ """Peel partials, decorators, and bound methods down to the user function."""
33
+ seen = set()
34
+ while id(fn) not in seen:
35
+ seen.add(id(fn))
36
+ if isinstance(fn, functools.partial):
37
+ fn = fn.func
38
+ elif hasattr(fn, "__wrapped__"):
39
+ fn = fn.__wrapped__
40
+ elif inspect.ismethod(fn):
41
+ fn = fn.__func__
42
+ else:
43
+ break
44
+ return fn
45
+
46
+
47
+ def _node_callable(graph, name):
48
+ """Best-effort: the user callable backing a LangGraph node. The compiled
49
+ graph keeps the builder's node specs; RunnableCallable exposes .func/.afunc."""
50
+ candidates = (
51
+ lambda: graph.builder.nodes[name].runnable.func,
52
+ lambda: graph.builder.nodes[name].runnable.afunc,
53
+ lambda: graph.builder.nodes[name].runnable,
54
+ )
55
+ for get in candidates:
56
+ try:
57
+ fn = get()
58
+ except Exception:
59
+ continue
60
+ if fn is not None:
61
+ return _unwrap(fn)
62
+ return None
63
+
64
+
65
+ def sources(graph) -> dict:
66
+ """{node: {file, line_start, line_end, code}} for every node we can trace
67
+ to real source. Missing nodes simply aren't in the map — never an error."""
68
+ out = {}
69
+ builder = getattr(graph, "builder", None)
70
+ for name in (getattr(builder, "nodes", None) or {}):
71
+ fn = _node_callable(graph, name)
72
+ if fn is None:
73
+ continue
74
+ for target in (fn, type(fn)): # class-based runnables: show the class
75
+ try:
76
+ lines, start = inspect.getsourcelines(target)
77
+ out[name] = {"file": inspect.getsourcefile(target) or "",
78
+ "line_start": start,
79
+ "line_end": start + len(lines) - 1,
80
+ "code": "".join(lines)[:20000]}
81
+ break
82
+ except Exception:
83
+ continue
84
+ return out
85
+
86
+
87
+ def load(ref: str, dir_: str):
88
+ sys.path.insert(0, dir_)
89
+ mod, attr = ref.split(":")
90
+ return getattr(importlib.import_module(mod), attr)
91
+
92
+
93
+ if __name__ == "__main__":
94
+ ref, dir_ = sys.argv[1], sys.argv[2]
95
+ g = load(ref, dir_)
96
+ out = {"topology": topology(g), "schema": input_schema(g), "sources": sources(g)}
97
+ try: # subgraph x-ray view, only when it actually differs
98
+ tx = topology(g, xray=True)
99
+ if tx != out["topology"]:
100
+ out["topology_xray"] = tx
101
+ except Exception:
102
+ pass
103
+ print(json.dumps(out))
windhover/pricing.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "_comment": "USD per 1M tokens {input,output}. Longest-prefix match on model name. Miss -> cost null. Add your own.",
3
+ "gpt-5": {"input": 1.25, "output": 10},
4
+ "gpt-5-mini": {"input": 0.25, "output": 2},
5
+ "gpt-4o": {"input": 2.5, "output": 10},
6
+ "gpt-4o-mini": {"input": 0.15, "output": 0.6},
7
+ "o1": {"input": 15, "output": 60},
8
+ "o3-mini": {"input": 1.1, "output": 4.4},
9
+ "claude-opus-4": {"input": 15, "output": 75},
10
+ "claude-sonnet-4": {"input": 3, "output": 15},
11
+ "claude-3-5-haiku": {"input": 0.8, "output": 4},
12
+ "claude-3-5-sonnet": {"input": 3, "output": 15},
13
+ "gemini-2.5-pro": {"input": 1.25, "output": 10},
14
+ "gemini-2.5-flash": {"input": 0.3, "output": 2.5},
15
+ "deepseek-chat": {"input": 0.27, "output": 1.1}
16
+ }
windhover/server.py ADDED
@@ -0,0 +1,333 @@
1
+ """Windhover HTTP server — observes a LangGraph graph and any app that traces to it.
2
+
3
+ Runs execute on a worker thread with a DB-sink tracer, so a full span tree
4
+ (nodes -> LLM/tool children, tokens, cost) persists even if the browser leaves.
5
+ A background watcher re-extracts topology in a subprocess when the graph's source
6
+ changes and pushes it to the UI (the "living graph"). Runs use the imported graph
7
+ (restart to run new code); the *view* always reflects current-on-disk topology.
8
+ """
9
+ from __future__ import annotations
10
+ import sys, json, time, hashlib, threading, queue, subprocess, importlib, traceback
11
+ from pathlib import Path
12
+ from fastapi import FastAPI, Request
13
+ from fastapi.responses import StreamingResponse, JSONResponse, FileResponse
14
+ from fastapi.staticfiles import StaticFiles
15
+
16
+ from .config import Config
17
+ from .store import Store
18
+ from .tracer import SpanBuilder, db_sink, apply_to_store
19
+
20
+ cfg = Config.from_env()
21
+ store = Store(cfg.db_path)
22
+ STATIC = Path(__file__).parent / "static"
23
+
24
+ graph = None
25
+ if cfg.graph_ref:
26
+ sys.path.insert(0, cfg.graph_dir)
27
+ _m, _a = cfg.graph_ref.split(":")
28
+ graph = getattr(importlib.import_module(_m), _a)
29
+
30
+ app = FastAPI(title="Windhover")
31
+
32
+
33
+ # ---- topology manager (subprocess extraction, mtime-cached) ---------------
34
+ class Topo:
35
+ def __init__(self):
36
+ self.lock = threading.Lock()
37
+ self.sig = None
38
+ self.data = {"topology": {"nodes": [], "edges": []}, "schema": {}, "sources": {}}
39
+ self.hash = ""
40
+ self.version = 0
41
+
42
+ def _signature(self) -> float:
43
+ if not cfg.graph_ref:
44
+ return 0.0
45
+ try:
46
+ return max((p.stat().st_mtime for p in Path(cfg.graph_dir).glob("*.py")),
47
+ default=0.0)
48
+ except Exception:
49
+ return 0.0
50
+
51
+ def refresh(self, force=False) -> None:
52
+ if not cfg.graph_ref:
53
+ return
54
+ sig = self._signature()
55
+ with self.lock:
56
+ if not force and sig == self.sig:
57
+ return
58
+ self.sig = sig
59
+ try:
60
+ out = subprocess.run(
61
+ [sys.executable, "-m", "windhover.extract", cfg.graph_ref, cfg.graph_dir],
62
+ capture_output=True, text=True, timeout=20,
63
+ cwd=str(Path(__file__).parent.parent))
64
+ data = json.loads(out.stdout)
65
+ except Exception:
66
+ return
67
+ h = hashlib.sha1(json.dumps(data["topology"], sort_keys=True).encode()).hexdigest()[:12]
68
+ with self.lock:
69
+ changed = h != self.hash
70
+ self.data, self.hash = data, h
71
+ if changed:
72
+ self.version += 1
73
+
74
+ def get(self) -> dict:
75
+ with self.lock:
76
+ return {**self.data["topology"], "graph": cfg.graph_ref, "hash": self.hash,
77
+ "version": self.version, "xray": self.data.get("topology_xray")}
78
+
79
+ def schema(self) -> dict:
80
+ with self.lock:
81
+ return self.data.get("schema", {})
82
+
83
+ def sources(self) -> dict:
84
+ with self.lock:
85
+ return self.data.get("sources", {})
86
+
87
+
88
+ TOPO = Topo()
89
+ TOPO.refresh(force=True)
90
+
91
+
92
+ def _watch_loop():
93
+ while cfg.watch and cfg.graph_ref:
94
+ time.sleep(2)
95
+ try:
96
+ TOPO.refresh()
97
+ except Exception:
98
+ pass
99
+
100
+
101
+ if cfg.watch and cfg.graph_ref:
102
+ threading.Thread(target=_watch_loop, daemon=True).start()
103
+
104
+
105
+ def _retention_loop():
106
+ while True:
107
+ try:
108
+ res = store.prune(cfg.retention_days)
109
+ if res["pruned_runs"]:
110
+ print(f"[windhover] retention: pruned {res['pruned_runs']} runs "
111
+ f"older than {cfg.retention_days}d")
112
+ except Exception as e:
113
+ print(f"[windhover] retention error: {e}")
114
+ time.sleep(6 * 3600)
115
+
116
+
117
+ if cfg.retention_days > 0:
118
+ threading.Thread(target=_retention_loop, daemon=True).start()
119
+
120
+
121
+ def _template(schema: dict) -> dict:
122
+ out = {}
123
+ for k, p in (schema.get("properties") or {}).items():
124
+ out[k] = {"array": [], "object": {}, "string": "", "integer": 0,
125
+ "number": 0, "boolean": False}.get(p.get("type"))
126
+ return out
127
+
128
+
129
+ # ---- endpoints ------------------------------------------------------------
130
+ @app.get("/api/graph")
131
+ def api_graph():
132
+ return JSONResponse(TOPO.get())
133
+
134
+
135
+ @app.get("/api/schema")
136
+ def api_schema():
137
+ s = TOPO.schema()
138
+ return JSONResponse({"schema": s, "template": _template(s)})
139
+
140
+
141
+ def _sse(ev: str, data: dict) -> str:
142
+ return f"event: {ev}\ndata: {json.dumps(data)}\n\n"
143
+
144
+
145
+ @app.post("/api/run")
146
+ async def api_run(request: Request):
147
+ if graph is None:
148
+ return JSONResponse({"error": "no local graph (WINDHOVER_GRAPH unset)"}, 400)
149
+ try:
150
+ payload = await request.json()
151
+ except Exception:
152
+ payload = {}
153
+ session = payload.pop("_session", None)
154
+ tags = payload.pop("_tags", None)
155
+ tracer = SpanBuilder(db_sink(store), run_name=cfg.graph_ref, session=session, tags=tags)
156
+ run_id = tracer.run_id
157
+ q: "queue.Queue" = queue.Queue()
158
+
159
+ def worker():
160
+ try:
161
+ interrupted = False
162
+ for update in graph.stream(payload, config={"callbacks": [tracer]},
163
+ stream_mode="updates"):
164
+ for node in update:
165
+ if node == "__interrupt__":
166
+ interrupted = True
167
+ q.put(("interrupt", {"run_id": run_id}))
168
+ else:
169
+ q.put(("node", {"node": node}))
170
+ q.put(("interrupted" if interrupted else "done", {"run_id": run_id}))
171
+ except Exception as e:
172
+ # tracer already recorded the error span/close; surface to the client too
173
+ q.put(("error", {"message": str(e), "trace": traceback.format_exc()[-600:]}))
174
+ q.put((None, None))
175
+
176
+ threading.Thread(target=worker, daemon=True).start()
177
+
178
+ def stream():
179
+ yield _sse("start", {"run_id": run_id})
180
+ while True:
181
+ ev, data = q.get()
182
+ if ev is None:
183
+ break
184
+ yield _sse(ev, data)
185
+ return StreamingResponse(stream(), media_type="text/event-stream")
186
+
187
+
188
+ @app.post("/api/ingest")
189
+ async def api_ingest(request: Request):
190
+ ev = await request.json()
191
+ apply_to_store(store, ev, source="ingest")
192
+ return {"ok": True, "run_id": ev.get("run_id")}
193
+
194
+
195
+ def _run_filters(limit: int, offset: int, q, status, graph, session, tag,
196
+ bookmarked, since_ms, until_ms) -> dict:
197
+ return dict(limit=max(1, min(limit, 500)), offset=max(0, offset),
198
+ q=q or None, status=status or None, graph=graph or None,
199
+ session=session or None, tag=tag or None,
200
+ bookmarked=bool(bookmarked) or None,
201
+ since_ms=since_ms, until_ms=until_ms)
202
+
203
+
204
+ @app.get("/api/runs")
205
+ def api_runs(limit: int = 50, offset: int = 0, q: str = None, status: str = None,
206
+ graph: str = None, session: str = None, tag: str = None,
207
+ bookmarked: int = 0, since_ms: int = None, until_ms: int = None):
208
+ return JSONResponse(store.runs(**_run_filters(
209
+ limit, offset, q, status, graph, session, tag, bookmarked, since_ms, until_ms)))
210
+
211
+
212
+ @app.get("/api/sessions")
213
+ def api_sessions(limit: int = 100):
214
+ return JSONResponse(store.sessions(limit=limit))
215
+
216
+
217
+ @app.get("/api/runs/{run_id}")
218
+ def api_run_detail(run_id: str):
219
+ d = store.run_detail(run_id)
220
+ return JSONResponse(d) if d else JSONResponse({"error": "not found"}, 404)
221
+
222
+
223
+ @app.patch("/api/runs/{run_id}")
224
+ async def api_run_patch(request: Request, run_id: str):
225
+ body = await request.json()
226
+ tags = body.get("tags")
227
+ if tags is not None and not isinstance(tags, list):
228
+ return JSONResponse({"error": "tags must be a list"}, 400)
229
+ ok = store.update_run_meta(run_id, tags=tags, bookmarked=body.get("bookmarked"))
230
+ return JSONResponse({"ok": ok}) if ok else JSONResponse({"error": "not found"}, 404)
231
+
232
+
233
+ @app.post("/api/runs/{run_id}/scores")
234
+ async def api_score_add(request: Request, run_id: str):
235
+ body = await request.json()
236
+ try:
237
+ name, value = str(body["name"]).strip(), float(body["value"])
238
+ except (KeyError, TypeError, ValueError):
239
+ return JSONResponse({"error": "requires name (str) and value (number)"}, 400)
240
+ if not name:
241
+ return JSONResponse({"error": "name must be non-empty"}, 400)
242
+ sc = store.add_score(run_id, name, value, comment=body.get("comment"),
243
+ source=body.get("source", "api"))
244
+ return JSONResponse(sc) if sc else JSONResponse({"error": "run not found"}, 404)
245
+
246
+
247
+ @app.delete("/api/scores/{score_id}")
248
+ def api_score_delete(score_id: str):
249
+ return JSONResponse({"ok": store.delete_score(score_id)})
250
+
251
+
252
+ @app.get("/api/export")
253
+ def api_export(format: str = "json", limit: int = 10000, q: str = None,
254
+ status: str = None, graph: str = None, session: str = None,
255
+ tag: str = None, bookmarked: int = 0,
256
+ since_ms: int = None, until_ms: int = None):
257
+ data = store.runs(**{**_run_filters(limit, 0, q, status, graph, session, tag,
258
+ bookmarked, since_ms, until_ms),
259
+ "limit": max(1, min(limit, 100_000))})["runs"]
260
+ if format == "csv":
261
+ import csv, io
262
+ cols = ["id", "graph", "source", "status", "session", "tags", "started_ms",
263
+ "duration_ms", "node_count", "llm_calls", "prompt_tokens",
264
+ "completion_tokens", "total_tokens", "cost_usd", "error"]
265
+ buf = io.StringIO()
266
+ w = csv.writer(buf)
267
+ w.writerow(cols)
268
+ for r in data:
269
+ w.writerow([json.dumps(r[k]) if isinstance(r.get(k), (list, dict))
270
+ else r.get(k) for k in cols])
271
+ return StreamingResponse(iter([buf.getvalue()]), media_type="text/csv",
272
+ headers={"Content-Disposition": "attachment; filename=windhover-runs.csv"})
273
+ return JSONResponse(data)
274
+
275
+
276
+ @app.get("/api/nodes/{name}")
277
+ def api_node(name: str, limit: int = 25):
278
+ return JSONResponse(store.node_history(name, limit))
279
+
280
+
281
+ @app.get("/api/nodes/{name}/source")
282
+ def api_node_source(name: str):
283
+ src = TOPO.sources().get(name)
284
+ if src:
285
+ return JSONResponse(src)
286
+ return JSONResponse({"error": "no source available for this node "
287
+ "(external-only run, or a runnable inspect can't trace)"}, 404)
288
+
289
+
290
+ @app.get("/api/stats")
291
+ def api_stats(days: int = 30):
292
+ return JSONResponse(store.stats(days=max(1, min(days, 365))))
293
+
294
+
295
+ @app.get("/api/events")
296
+ async def api_events(request: Request):
297
+ async def gen():
298
+ import asyncio
299
+ seen = -1
300
+ yield _sse("hello", {"version": TOPO.version})
301
+ while True:
302
+ if await request.is_disconnected():
303
+ break
304
+ if TOPO.version != seen:
305
+ seen = TOPO.version
306
+ yield _sse("topology", {"version": seen})
307
+ else:
308
+ yield ": ping\n\n"
309
+ await asyncio.sleep(2)
310
+ return StreamingResponse(gen(), media_type="text/event-stream")
311
+
312
+
313
+ @app.get("/manifest.json")
314
+ def manifest():
315
+ return FileResponse(STATIC / "manifest.json")
316
+
317
+
318
+ @app.get("/")
319
+ def index():
320
+ # no-cache so UI upgrades take effect on next load (vendor assets still cache)
321
+ return FileResponse(STATIC / "index.html", headers={"Cache-Control": "no-cache"})
322
+
323
+
324
+ app.mount("/static", StaticFiles(directory=STATIC), name="static")
325
+
326
+
327
+ def main():
328
+ import uvicorn
329
+ uvicorn.run(app, host=cfg.host, port=cfg.port)
330
+
331
+
332
+ if __name__ == "__main__":
333
+ main()
@@ -0,0 +1,8 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
2
+ <defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
3
+ <stop offset="0" stop-color="#A32C3E"/><stop offset="1" stop-color="#571017"/></linearGradient></defs>
4
+ <rect width="512" height="512" rx="112" fill="url(#bg)"/>
5
+ <g transform="translate(56.0 116.7) scale(0.62893)">
6
+ <path d="M312.90,425.29 C310.64,423.25 306.72,418.86 304.18,415.54 C301.65,412.22 298.88,408.93 298.03,408.23 C295.97,406.53 289.90,407.25 282.33,410.11 C274.09,413.23 271.34,412.33 268.20,405.50 C262.04,392.12 261.63,391.51 258.52,391.16 C256.88,390.98 253.06,391.35 250.02,391.99 C242.01,393.68 239.51,392.45 236.41,385.25 C230.11,370.65 228.49,369.21 220.40,371.02 C217.79,371.61 214.35,371.80 212.76,371.45 C209.08,370.64 206.01,365.75 206.00,360.69 C206.00,357.52 206.81,356.01 210.82,351.69 C213.47,348.84 217.71,343.80 220.23,340.50 C222.75,337.20 226.92,332.48 229.51,330.00 C232.09,327.52 236.81,322.12 240.01,318.00 C243.20,313.88 249.27,307.12 253.49,303.00 C257.72,298.88 263.24,292.80 265.78,289.50 C268.31,286.20 272.76,280.90 275.66,277.73 C282.46,270.28 283.78,266.15 281.64,258.90 C278.68,248.89 277.41,247.67 267.38,245.23 C260.71,243.60 251.53,239.67 230.50,229.45 C215.10,221.96 200.47,214.74 198.00,213.41 C195.53,212.08 184.95,206.76 174.50,201.60 C164.05,196.44 153.25,191.06 150.50,189.65 C147.75,188.24 137.08,182.79 126.78,177.55 C114.19,171.14 107.49,167.16 106.33,165.38 C104.65,162.82 104.67,162.68 106.91,160.87 C108.24,159.80 110.80,159.00 112.91,159.00 C116.69,159.00 119.00,157.60 119.00,155.31 C119.00,154.60 114.84,151.96 109.75,149.44 C99.17,144.20 67.99,123.81 66.21,120.97 C65.56,119.93 65.31,118.35 65.66,117.45 C66.19,116.07 67.32,115.93 73.06,116.51 C81.49,117.35 84.00,116.65 84.00,113.45 C84.00,111.44 81.40,109.29 68.75,100.84 C52.97,90.30 37.70,78.18 35.86,74.74 C35.05,73.23 35.18,72.40 36.40,71.18 C37.85,69.72 38.96,69.82 49.38,72.36 C62.67,75.61 67.35,75.59 67.81,72.29 C68.05,70.60 65.53,67.46 57.31,59.22 C46.57,48.44 41.78,42.47 40.57,38.36 C39.71,35.46 42.49,32.71 45.36,33.60 C46.53,33.97 60.99,41.01 77.47,49.25 C93.96,57.49 112.41,66.03 118.47,68.23 C146.15,78.27 183.73,90.19 202.92,95.00 C219.11,99.06 221.00,99.92 227.79,106.30 C230.93,109.26 235.53,113.06 238.00,114.75 C240.47,116.45 245.43,120.83 249.00,124.50 C252.57,128.17 257.52,132.55 260.00,134.25 C262.48,135.94 267.05,139.73 270.18,142.66 C277.53,149.58 279.88,149.68 287.17,143.41 C290.10,140.89 295.20,137.43 298.50,135.71 C303.95,132.88 305.53,132.56 315.72,132.19 C331.48,131.62 338.30,133.73 347.42,142.00 C356.10,149.87 358.09,149.94 365.82,142.66 C368.95,139.73 373.52,135.94 376.00,134.25 C378.48,132.55 383.43,128.17 387.00,124.49 C390.57,120.82 395.98,116.11 399.00,114.01 C402.02,111.92 406.07,108.54 408.00,106.51 C412.56,101.70 416.56,99.78 427.50,97.09 C442.99,93.30 489.94,78.25 517.53,68.23 C523.59,66.03 542.04,57.49 558.53,49.25 C575.01,41.01 589.47,33.97 590.64,33.60 C592.03,33.17 593.37,33.51 594.43,34.57 C597.63,37.77 594.44,42.92 579.49,58.73 C573.72,64.83 569.00,70.52 569.00,71.38 C569.00,74.33 572.54,75.17 578.74,73.67 C591.83,70.50 597.95,69.72 599.51,71.01 C603.83,74.59 599.88,78.43 573.00,96.80 C566.67,101.12 558.72,106.13 555.32,107.93 C548.71,111.43 547.22,113.35 549.22,115.76 C550.25,117.00 552.19,117.16 560.13,116.64 C568.29,116.10 569.88,116.24 570.38,117.54 C570.70,118.39 570.44,119.93 569.79,120.97 C568.01,123.81 536.83,144.20 526.25,149.44 C521.16,151.96 517.00,154.60 517.00,155.31 C517.00,157.60 519.31,159.00 523.09,159.00 C525.20,159.00 527.76,159.80 529.09,160.87 C533.98,164.83 532.25,165.87 461.87,201.06 C425.53,219.23 394.15,235.20 392.12,236.54 C388.01,239.26 374.07,244.22 365.58,245.98 C361.45,246.84 359.57,247.82 358.19,249.82 C355.43,253.80 353.00,260.60 353.00,264.32 C353.00,267.82 357.09,275.09 360.98,278.50 C362.23,279.60 365.86,283.88 369.04,288.00 C372.21,292.12 378.27,298.88 382.50,303.00 C386.73,307.12 392.80,313.88 396.00,318.00 C399.19,322.12 404.37,327.98 407.50,331.00 C415.03,338.28 427.28,352.69 429.35,356.70 C431.73,361.32 431.39,364.53 428.13,368.18 C425.01,371.67 422.07,372.14 412.93,370.56 C406.63,369.48 403.40,372.27 401.99,380.00 C400.84,386.26 398.56,389.89 394.54,391.86 C392.28,392.97 390.71,393.00 386.11,392.02 C383.00,391.36 379.12,390.98 377.48,391.16 C374.37,391.51 373.96,392.12 367.80,405.50 C364.66,412.33 361.91,413.23 353.67,410.11 C346.10,407.25 340.03,406.53 337.97,408.23 C337.12,408.93 334.35,412.22 331.82,415.54 C326.95,421.91 319.68,429.00 318.00,429.00 C317.45,429.00 315.16,427.33 312.90,425.29 z M323.60,201.25 C329.52,190.76 330.86,189.21 336.20,186.68 C344.02,182.98 348.00,178.02 348.00,172.00 C348.00,168.25 346.43,165.00 344.62,165.00 C343.90,165.00 340.29,167.93 336.61,171.50 C332.93,175.07 329.50,178.00 328.99,178.00 C328.48,178.00 326.47,180.49 324.52,183.54 C322.57,186.58 320.28,189.34 319.42,189.67 C317.02,190.59 313.64,188.28 310.95,183.86 C308.20,179.35 294.66,167.00 292.47,167.00 C289.94,167.00 287.69,171.70 288.33,175.64 C289.11,180.46 291.29,182.65 298.80,186.21 C305.24,189.26 306.28,190.41 312.40,201.25 C314.64,205.22 315.56,206.00 318.00,206.00 C320.44,206.00 321.36,205.22 323.60,201.25 z" fill="#fff"/>
7
+ </g>
8
+ </svg>