windhover 0.8.0__tar.gz → 0.9.0__tar.gz

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 (32) hide show
  1. {windhover-0.8.0 → windhover-0.9.0}/PKG-INFO +9 -2
  2. {windhover-0.8.0 → windhover-0.9.0}/README.md +8 -1
  3. {windhover-0.8.0 → windhover-0.9.0}/pyproject.toml +1 -1
  4. {windhover-0.8.0 → windhover-0.9.0}/tests/test_smoke.py +63 -0
  5. {windhover-0.8.0 → windhover-0.9.0}/windhover/demo_graph.py +19 -3
  6. {windhover-0.8.0 → windhover-0.9.0}/windhover/server.py +37 -3
  7. {windhover-0.8.0 → windhover-0.9.0}/windhover/static/index.html +77 -7
  8. {windhover-0.8.0 → windhover-0.9.0}/windhover/store.py +13 -5
  9. {windhover-0.8.0 → windhover-0.9.0}/windhover/tracer.py +44 -8
  10. {windhover-0.8.0 → windhover-0.9.0}/.github/workflows/ci.yml +0 -0
  11. {windhover-0.8.0 → windhover-0.9.0}/.gitignore +0 -0
  12. {windhover-0.8.0 → windhover-0.9.0}/LICENSE +0 -0
  13. {windhover-0.8.0 → windhover-0.9.0}/SPEC.md +0 -0
  14. {windhover-0.8.0 → windhover-0.9.0}/docs/graph.png +0 -0
  15. {windhover-0.8.0 → windhover-0.9.0}/docs/logo.svg +0 -0
  16. {windhover-0.8.0 → windhover-0.9.0}/docs/runs.png +0 -0
  17. {windhover-0.8.0 → windhover-0.9.0}/docs/social-preview.png +0 -0
  18. {windhover-0.8.0 → windhover-0.9.0}/docs/stats.png +0 -0
  19. {windhover-0.8.0 → windhover-0.9.0}/docs/trace.png +0 -0
  20. {windhover-0.8.0 → windhover-0.9.0}/tests/__init__.py +0 -0
  21. {windhover-0.8.0 → windhover-0.9.0}/windhover/__init__.py +0 -0
  22. {windhover-0.8.0 → windhover-0.9.0}/windhover/config.py +0 -0
  23. {windhover-0.8.0 → windhover-0.9.0}/windhover/extract.py +0 -0
  24. {windhover-0.8.0 → windhover-0.9.0}/windhover/pricing.json +0 -0
  25. {windhover-0.8.0 → windhover-0.9.0}/windhover/static/icon.svg +0 -0
  26. {windhover-0.8.0 → windhover-0.9.0}/windhover/static/manifest.json +0 -0
  27. {windhover-0.8.0 → windhover-0.9.0}/windhover/static/vendor/cytoscape-dagre.js +0 -0
  28. {windhover-0.8.0 → windhover-0.9.0}/windhover/static/vendor/cytoscape.min.js +0 -0
  29. {windhover-0.8.0 → windhover-0.9.0}/windhover/static/vendor/dagre.min.js +0 -0
  30. {windhover-0.8.0 → windhover-0.9.0}/windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
  31. {windhover-0.8.0 → windhover-0.9.0}/windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
  32. {windhover-0.8.0 → windhover-0.9.0}/windhover/static/vendor/fonts/fraunces-600.woff2 +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: windhover
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: Self-hosted, mobile-friendly observability for LangGraph — traces, run history, cost, and a living graph view.
5
5
  Project-URL: Repository, https://github.com/justfeltlikerunning/windhover
6
6
  Author: justfeltlikerunning
@@ -90,7 +90,14 @@ graph.invoke(input, config={
90
90
  status/tag/session filters, bookmarks, pagination, CSV/JSON export.
91
91
  - **Sessions** — group runs into threads/batches; roll-up tokens, cost, errors.
92
92
  - **Scores** — attach numeric evals to runs (API or UI): eval harnesses, LLM-as-judge, human review.
93
- - **Live tail** — open a running run and watch its spans arrive in real time.
93
+ - **Live tail** — open a running run and watch its spans arrive in real time; nodes can push
94
+ progress via LangGraph's `get_stream_writer()`.
95
+ - **Custom events** — `dispatch_custom_event("name", {...})` anywhere in your app lands as an
96
+ event marker in the trace, parented to the node that fired it.
97
+ - **Retries + TTFT** — tenacity retries badge the span (`↻2`); streaming LLM calls record
98
+ time-to-first-token; cache-read / reasoning token details show on the model line.
99
+ - **Memory browser** — graphs compiled with a LangGraph `Store` get a Memory view: browse
100
+ namespaces and search long-term memory items.
94
101
  - **Time-travel** — checkpointed graphs get a per-thread checkpoint browser: state, writes,
95
102
  and next-nodes at every superstep (`get_state_history`).
96
103
  - **Run diff** — compare any two runs node-by-node: identical vs differing outputs,
@@ -71,7 +71,14 @@ graph.invoke(input, config={
71
71
  status/tag/session filters, bookmarks, pagination, CSV/JSON export.
72
72
  - **Sessions** — group runs into threads/batches; roll-up tokens, cost, errors.
73
73
  - **Scores** — attach numeric evals to runs (API or UI): eval harnesses, LLM-as-judge, human review.
74
- - **Live tail** — open a running run and watch its spans arrive in real time.
74
+ - **Live tail** — open a running run and watch its spans arrive in real time; nodes can push
75
+ progress via LangGraph's `get_stream_writer()`.
76
+ - **Custom events** — `dispatch_custom_event("name", {...})` anywhere in your app lands as an
77
+ event marker in the trace, parented to the node that fired it.
78
+ - **Retries + TTFT** — tenacity retries badge the span (`↻2`); streaming LLM calls record
79
+ time-to-first-token; cache-read / reasoning token details show on the model line.
80
+ - **Memory browser** — graphs compiled with a LangGraph `Store` get a Memory view: browse
81
+ namespaces and search long-term memory items.
75
82
  - **Time-travel** — checkpointed graphs get a per-thread checkpoint browser: state, writes,
76
83
  and next-nodes at every superstep (`get_state_history`).
77
84
  - **Run diff** — compare any two runs node-by-node: identical vs differing outputs,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "windhover"
7
- version = "0.8.0"
7
+ version = "0.9.0"
8
8
  description = "Self-hosted, mobile-friendly observability for LangGraph — traces, run history, cost, and a living graph view."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -299,6 +299,67 @@ def test_datasets_and_scoring():
299
299
  print("datasets + scoring OK")
300
300
 
301
301
 
302
+ def test_ttft_retry_custom_event_usage_detail():
303
+ p = tempfile.mktemp(suffix=".db")
304
+ s = Store(p)
305
+ tr = SpanBuilder(db_sink(s), run_name="deep")
306
+ tr.on_chain_start({}, {"q": 1}, run_id="root", parent_run_id=None)
307
+ tr.on_chat_model_start({"kwargs": {"model": "gpt-4o"}}, [[]],
308
+ run_id="llm1", parent_run_id="root")
309
+ time.sleep(0.02)
310
+ tr.on_llm_new_token("Hel", run_id="llm1") # -> ttft stamped
311
+ tr.on_llm_new_token("lo", run_id="llm1") # ignored (only first counts)
312
+
313
+ class RS: # tenacity RetryCallState stand-in
314
+ attempt_number = 3
315
+ tr.on_retry(RS(), run_id="llm1")
316
+
317
+ class Msg:
318
+ content = "Hello"
319
+ usage_metadata = {"input_tokens": 900, "output_tokens": 40,
320
+ "input_token_details": {"cache_read": 700},
321
+ "output_token_details": {"reasoning": 12}}
322
+ class Gen:
323
+ text = "Hello"; message = Msg()
324
+ class Resp:
325
+ llm_output = None; generations = [[Gen()]]
326
+ tr.on_llm_end(Resp(), run_id="llm1")
327
+ tr.on_custom_event("checkpoint-saved", {"rows": 42}, run_id="root")
328
+ tr.on_chain_end({}, run_id="root")
329
+
330
+ d = s.run_detail(tr.run_id)
331
+ llm = [x for x in d["spans"] if x["type"] == "llm"][0]
332
+ assert llm["ttft_ms"] is not None and llm["ttft_ms"] >= 15
333
+ assert llm["retries"] == 3
334
+ assert llm["usage_detail"] == {"input_cache_read": 700, "output_reasoning": 12}
335
+ assert llm["prompt_tokens"] == 900
336
+ ev = [x for x in d["spans"] if x["type"] == "event"]
337
+ assert ev and ev[0]["name"] == "checkpoint-saved" and ev[0]["output"] == {"rows": 42}
338
+ print("ttft/retry/custom-event/usage-detail OK")
339
+
340
+
341
+ def test_demo_memory_and_events_end_to_end():
342
+ """Demo graph writes long-term memory + dispatches a custom event — both
343
+ must be observable."""
344
+ import importlib
345
+ import windhover.demo_graph as dg
346
+ importlib.reload(dg) # fresh InMemoryStore per test
347
+ p = tempfile.mktemp(suffix=".db")
348
+ s = Store(p)
349
+ tr = SpanBuilder(db_sink(s), run_name="demo")
350
+ dg.graph.invoke({"n": 7}, config={"callbacks": [tr],
351
+ "configurable": {"thread_id": "mem-t"}})
352
+ time.sleep(.1)
353
+ d = s.run_detail(tr.run_id)
354
+ ev = [x for x in d["spans"] if x["type"] == "event"]
355
+ assert ev and ev[0]["name"] == "summary-ready", [x["type"] for x in d["spans"]]
356
+ assert dg.graph.store is not None
357
+ items = dg.graph.store.search(("demo", "summaries"))
358
+ assert items and items[0].value["n"] == 21 # n=7 -> grow x3
359
+ assert ("demo", "summaries") in dg.graph.store.list_namespaces()
360
+ print("demo memory + custom event end-to-end OK")
361
+
362
+
302
363
  if __name__ == "__main__":
303
364
  test_cost(); print("cost OK")
304
365
  test_store_roundtrip()
@@ -314,4 +375,6 @@ if __name__ == "__main__":
314
375
  test_auth_check()
315
376
  test_thread_capture_and_history()
316
377
  test_datasets_and_scoring()
378
+ test_ttft_retry_custom_event_usage_detail()
379
+ test_demo_memory_and_events_end_to_end()
317
380
  print("ALL SMOKE TESTS PASSED")
@@ -28,7 +28,22 @@ def grow(s): time.sleep(.3); return {"n": s["n"] * 3}
28
28
  def parity(s): time.sleep(.4); return {"notes": ["even" if s["n"] % 2 == 0 else "odd"]}
29
29
  def sign(s): time.sleep(.25); return {"notes": ["positive" if s["n"] > 0 else "non-positive"]}
30
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"])]}
31
+ def summarize(s):
32
+ time.sleep(.1)
33
+ try: # progress + custom events + long-term memory, all observable in Windhover
34
+ from langgraph.config import get_stream_writer, get_store
35
+ writer = get_stream_writer()
36
+ writer({"stage": "summarize", "pct": 50})
37
+ from langchain_core.callbacks import dispatch_custom_event
38
+ dispatch_custom_event("summary-ready", {"n": s["n"], "parts": len(s["notes"])})
39
+ store = get_store()
40
+ if store is not None:
41
+ store.put(("demo", "summaries"), f"n-{s['n']}",
42
+ {"n": s["n"], "notes": s["notes"]})
43
+ except Exception:
44
+ pass
45
+ time.sleep(.1)
46
+ return {"notes": [f"n={s['n']}: " + ", ".join(s["notes"])]}
32
47
 
33
48
 
34
49
  _g = StateGraph(State)
@@ -45,8 +60,9 @@ _g.add_edge("sign", "summarize")
45
60
  _g.add_edge("magnitude", "summarize")
46
61
  _g.add_edge("summarize", END)
47
62
 
48
- try: # checkpointer makes the Time-travel view demoable; optional dependency surface
63
+ try: # checkpointer + store make Time-travel and Memory demoable
49
64
  from langgraph.checkpoint.memory import MemorySaver
50
- graph = _g.compile(checkpointer=MemorySaver())
65
+ from langgraph.store.memory import InMemoryStore
66
+ graph = _g.compile(checkpointer=MemorySaver(), store=InMemoryStore())
51
67
  except Exception:
52
68
  graph = _g.compile()
@@ -186,9 +186,13 @@ async def api_run(request: Request):
186
186
  def worker():
187
187
  try:
188
188
  interrupted = False
189
- for update in graph.stream(payload, config=config,
190
- stream_mode="updates"):
191
- for node in update:
189
+ for mode, chunk in graph.stream(payload, config=config,
190
+ stream_mode=["updates", "custom"]):
191
+ if mode == "custom":
192
+ # get_stream_writer() output from inside a node -> live progress
193
+ q.put(("progress", {"data": _trunc(chunk, 600)}))
194
+ continue
195
+ for node in chunk:
192
196
  if node == "__interrupt__":
193
197
  interrupted = True
194
198
  q.put(("interrupt", {"run_id": run_id}))
@@ -340,6 +344,36 @@ def api_thread_history(thread_id: str, limit: int = 80):
340
344
  return JSONResponse({"thread_id": thread_id, "steps": steps})
341
345
 
342
346
 
347
+ @app.get("/api/memory/namespaces")
348
+ def api_memory_namespaces():
349
+ """LangGraph long-term memory (Store) browser — namespaces."""
350
+ st = getattr(graph, "store", None) if graph is not None else None
351
+ if st is None:
352
+ return JSONResponse({"error": "no local graph with a store"}, 404)
353
+ try:
354
+ return JSONResponse({"namespaces": [list(ns) for ns in st.list_namespaces()]})
355
+ except Exception as e:
356
+ return JSONResponse({"error": str(e)}, 500)
357
+
358
+
359
+ @app.get("/api/memory/items")
360
+ def api_memory_items(namespace: str, query: str = None, limit: int = 50):
361
+ """Items in one namespace (dot-separated); optional semantic/text query."""
362
+ st = getattr(graph, "store", None) if graph is not None else None
363
+ if st is None:
364
+ return JSONResponse({"error": "no local graph with a store"}, 404)
365
+ try:
366
+ ns = tuple(namespace.split(".")) if namespace else ()
367
+ items = st.search(ns, query=query, limit=max(1, min(limit, 200)))
368
+ return JSONResponse({"namespace": namespace, "items": [
369
+ {"key": i.key, "value": _trunc(i.value, 3000),
370
+ "created_at": str(getattr(i, "created_at", "") or "") or None,
371
+ "updated_at": str(getattr(i, "updated_at", "") or "") or None,
372
+ "score": getattr(i, "score", None)} for i in items]})
373
+ except Exception as e:
374
+ return JSONResponse({"error": str(e)}, 500)
375
+
376
+
343
377
  @app.get("/api/datasets")
344
378
  def api_datasets():
345
379
  return JSONResponse([{k: d[k] for k in ("id", "name", "n_items", "created_ms")}
@@ -236,6 +236,7 @@ window.pickDiff=async(id)=>{
236
236
  .chip.tool{background:var(--chip-tool);color:var(--chip-tool-t)}
237
237
  .chip.retriever{background:var(--chip-ret);color:var(--chip-ret-t)}
238
238
  .chip.interrupt{background:var(--warn-weak);color:var(--warn)}
239
+ .chip.event{background:var(--surface2);color:var(--text2);border:1px dashed var(--border2)}
239
240
  .tr-name{font-weight:600;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
240
241
  .tr-name.err{color:var(--err)}
241
242
  .tr-gantt{height:6px;background:var(--surface2);border-radius:3px;position:relative;overflow:hidden}
@@ -250,6 +251,12 @@ window.pickDiff=async(id)=>{
250
251
  .code .ln{color:var(--muted);text-align:right;user-select:none;position:sticky;left:-12px;background:var(--surface2);padding-left:12px}
251
252
  .code .cl{white-space:pre}
252
253
  .code .errline{background:var(--err-weak)}
254
+ .py-k{color:var(--accent-text);font-weight:600}
255
+ .py-s{color:var(--ok)}
256
+ .py-c{color:var(--muted);font-style:italic}
257
+ .py-n{color:var(--chip-tool-t)}
258
+ .py-d{color:var(--chip-llm-t)}
259
+ .py-f{font-weight:700}
253
260
  .code .cl.errline{box-shadow:inset 3px 0 0 var(--err);padding-left:6px;margin-left:-6px}
254
261
  .errhint{font:11px var(--mono);color:var(--err);margin-top:3px;max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
255
262
  .diffgrid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
@@ -341,6 +348,8 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
341
348
  .tr-bar.tool{background:var(--chip-tool-t)}
342
349
  .tr-bar.retriever{background:var(--chip-ret-t)}
343
350
  .tr-bar.interrupt{background:var(--warn)}
351
+ .tr-bar.event{background:var(--border2)}
352
+ .badge-retry{font-family:var(--mono);font-size:10px;color:var(--warn);background:var(--warn-weak);border-radius:5px;padding:1px 6px;margin-left:6px}
344
353
  .charts .card:nth-child(2) .cbar{background:var(--chip-llm-t)}
345
354
  #cy{background:radial-gradient(circle,var(--border) 1px,transparent 1.5px) 0 0/24px 24px}
346
355
  .legend{font-family:var(--mono);font-size:10.5px;letter-spacing:.06em;text-transform:uppercase}
@@ -384,9 +393,10 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
384
393
  <button data-v="graph" class="on">${ICON.graph} Graph</button>
385
394
  <button data-v="runs">${ICON.list} Runs</button>
386
395
  <button data-v="sessions">${ICON.layers} Sessions</button>
396
+ <button data-v="memory" id="nav-mem" style="display:none">${ICON.mem} Memory</button>
387
397
  <button data-v="stats">${ICON.chart} Stats</button>
388
398
  </nav>
389
- <div class="side-foot"><span class="live-dot" id="live-dot" title="live topology"></span><span id="foot-note">watching</span><span class="spacer"></span><span class="mono" id="ver">v0.8</span></div>
399
+ <div class="side-foot"><span class="live-dot" id="live-dot" title="live topology"></span><span id="foot-note">watching</span><span class="spacer"></span><span class="mono" id="ver">v0.9</span></div>
390
400
  </aside>
391
401
 
392
402
  <main>
@@ -438,6 +448,16 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
438
448
  <div class="card"><div id="sess-wrap"><div class="empty">Loading…</div></div></div>
439
449
  </div>
440
450
  </div>
451
+ <div id="view-memory" style="display:none">
452
+ <div class="page">
453
+ <div class="page-head"><h1>Memory</h1><span class="sub">LangGraph long-term store</span></div>
454
+ <div class="rtools">
455
+ <select id="mem-ns"></select>
456
+ <input type="search" id="mem-q" placeholder="Search this namespace…">
457
+ </div>
458
+ <div class="card"><div id="mem-wrap"><div class="empty">Loading…</div></div></div>
459
+ </div>
460
+ </div>
441
461
  <div id="view-stats" style="display:none">
442
462
  <div class="page">
443
463
  <div class="page-head"><h1>Stats</h1><span class="sub">all recorded runs</span></div>
@@ -452,6 +472,7 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
452
472
  <button data-v="graph" class="on">${ICON.graph}<span>Graph</span></button>
453
473
  <button data-v="runs">${ICON.list}<span>Runs</span></button>
454
474
  <button data-v="sessions">${ICON.layers}<span>Sessions</span></button>
475
+ <button data-v="memory" class="nav-mem" style="display:none">${ICON.mem}<span>Memory</span></button>
455
476
  <button data-v="stats">${ICON.chart}<span>Stats</span></button>
456
477
  </div>
457
478
 
@@ -491,6 +512,7 @@ const ICON = {
491
512
  list:'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M8 6h12M8 12h12M8 18h12"/><circle cx="4" cy="6" r="1.1" fill="currentColor" stroke="none"/><circle cx="4" cy="12" r="1.1" fill="currentColor" stroke="none"/><circle cx="4" cy="18" r="1.1" fill="currentColor" stroke="none"/></svg>',
492
513
  chart:'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 20V9M10 20V4M16 20v-7M21 20H3"/></svg>',
493
514
  layers:'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><path d="M12 3 3 8l9 5 9-5-9-5z"/><path d="M3 13l9 5 9-5"/></svg>',
515
+ mem:'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><ellipse cx="12" cy="5.5" rx="7.5" ry="2.8"/><path d="M4.5 5.5v6c0 1.6 3.4 2.8 7.5 2.8s7.5-1.2 7.5-2.8v-6"/><path d="M4.5 11.5v6c0 1.6 3.4 2.8 7.5 2.8s7.5-1.2 7.5-2.8v-6"/></svg>',
494
516
  play:'<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M7 4.8v14.4c0 .8.9 1.3 1.6.9l11-7.2c.6-.4.6-1.4 0-1.8l-11-7.2c-.7-.4-1.6.1-1.6.9z"/></svg>',
495
517
  moon:'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M20 13.5A8 8 0 1 1 10.5 4 6.5 6.5 0 0 0 20 13.5z"/></svg>',
496
518
  x:'<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>',
@@ -581,9 +603,23 @@ function tbLines(err,src){ const out=new Set();
581
603
  if(m[1].split('/').pop()===base){ const ln=+m[2];
582
604
  if(ln>=src.line_start&&ln<=src.line_end) out.add(ln); } }
583
605
  return out; }
606
+ const PY_TOKEN=/(#.*$)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')|(@\w+)|\b(def|class|return|if|elif|else|for|while|try|except|finally|raise|with|as|import|from|lambda|pass|break|continue|and|or|not|in|is|None|True|False|global|nonlocal|yield|async|await|del|assert|self)\b|(\b\d+(?:\.\d+)?\b)/g;
607
+ function pyHi(line){ let out='',last=0,defNext=false;
608
+ for(const m of line.matchAll(PY_TOKEN)){
609
+ out+=esc(line.slice(last,m.index)); last=m.index+m[0].length;
610
+ const t=esc(m[0]);
611
+ if(m[1]) out+=`<span class="py-c">${t}</span>`;
612
+ else if(m[2]) out+=`<span class="py-s">${t}</span>`;
613
+ else if(m[3]) out+=`<span class="py-d">${t}</span>`;
614
+ else if(m[4]) out+=`<span class="py-k">${t}</span>`;
615
+ else out+=`<span class="py-n">${t}</span>`;
616
+ }
617
+ out+=esc(line.slice(last));
618
+ // bold the name right after def/class
619
+ return out.replace(/(<span class="py-k">(?:def|class)<\/span> )([A-Za-z_]\w*)/,'$1<span class="py-f">$2</span>'); }
584
620
  function codeBlock(src,hi){ const lines=src.code.replace(/\n$/,'').split('\n');
585
621
  return `<div class="code">${lines.map((l,i)=>{ const ln=src.line_start+i, h=hi.has(ln);
586
- return `<span class="ln ${h?'errline':''}">${ln}</span><span class="cl ${h?'errline':''}">${esc(l)||' '}</span>`;
622
+ return `<span class="ln ${h?'errline':''}">${ln}</span><span class="cl ${h?'errline':''}">${pyHi(l)||' '}</span>`;
587
623
  }).join('')}</div>`; }
588
624
 
589
625
  /* ---------- node pane (tap a node on the graph) ---------- */
@@ -653,9 +689,10 @@ function switchView(v){ VIEW=v;
653
689
  if(location.hash!=='#'+v) history.replaceState(null,'','#'+v);
654
690
  $$('.nav button,.bottom-nav button').forEach(b=>b.classList.toggle('on',b.dataset.v===v));
655
691
  $('#crumb').textContent=v[0].toUpperCase()+v.slice(1);
656
- for(const k of ['graph','runs','sessions','stats'])
692
+ for(const k of ['graph','runs','sessions','memory','stats'])
657
693
  $('#view-'+k).style.display=v===k?'':'none';
658
- if(v==='runs')loadRuns(); if(v==='sessions')loadSessions(); if(v==='stats'){loadStats();loadDatasets();}
694
+ if(v==='runs')loadRuns(); if(v==='sessions')loadSessions(); if(v==='memory')loadMemory();
695
+ if(v==='stats'){loadStats();loadDatasets();}
659
696
  if(cy&&v==='graph')setTimeout(()=>cy.resize(),40);
660
697
  }
661
698
  $$('.nav button,.bottom-nav button').forEach(b=>b.onclick=()=>switchView(b.dataset.v));
@@ -680,6 +717,7 @@ async function runNow(input){
680
717
  for(const p of ps){ const ev=(p.match(/event: (.*)/)||[])[1],dm=(p.match(/data: (.*)/s)||[])[1]; if(!ev)continue; const d=dm?JSON.parse(dm):{};
681
718
  if(ev==='node'){ cy.$id(prev).addClass('done').removeClass('running'); cy.$id(d.node).addClass('running');
682
719
  cy.edges(`[source="${prev}"][target="${d.node}"]`).addClass('flow'); prev=d.node; }
720
+ else if(ev==='progress'){ toast('progress: '+JSON.stringify(d.data).slice(0,120)); }
683
721
  else if(ev==='interrupt'){ toast('Run paused at an interrupt — awaiting resume'); }
684
722
  else if(ev==='interrupted'){ cy.$id(prev).addClass('running'); toast('Run interrupted — see Runs for the payload'); }
685
723
  else if(ev==='done'){ cy.$id(prev).addClass('done').removeClass('running'); cy.$id('__end__').addClass('done'); toast('Run complete — saved to Runs'); }
@@ -823,6 +861,7 @@ function renderSpans(list,byParent,total,depth){
823
861
  const kids=byParent[s.id]||[];
824
862
  const left=((s.offset_ms||0)/total*100).toFixed(2), width=Math.max(1.2,(s.dur_ms||0)/total*100).toFixed(2);
825
863
  const meta=[s.dur_ms!=null?s.dur_ms+'ms':null,
864
+ s.ttft_ms!=null?'ttft '+s.ttft_ms+'ms':null,
826
865
  s.type==='llm'&&(s.prompt_tokens!=null||s.completion_tokens!=null)?fmtk((s.prompt_tokens||0)+(s.completion_tokens||0))+'t':null,
827
866
  s.cost_usd!=null?'$'+s.cost_usd.toFixed(4):null].filter(Boolean).join(' · ');
828
867
  const did='sp'+s.id;
@@ -830,12 +869,12 @@ function renderSpans(list,byParent,total,depth){
830
869
  <div class="tr-row" onclick="tog('${did}',this)">
831
870
  <div class="tr-main"><span class="indent" style="width:${depth*18}px"></span>
832
871
  <span class="caret">▶</span><span class="chip ${s.type}">${s.type}</span>
833
- <span class="tr-name ${s.status==='error'?'err':''}">${esc(s.name||s.type)}</span></div>
872
+ <span class="tr-name ${s.status==='error'?'err':''}">${esc(s.name||s.type)}</span>${s.retries?`<span class="badge-retry">↻${s.retries}</span>`:''}</div>
834
873
  <div class="tr-gantt"><div class="tr-bar ${s.type}" style="left:${left}%;width:${width}%"></div></div>
835
874
  <div class="tr-meta">${meta}</div>
836
875
  </div>
837
876
  <div class="tr-detail" id="${did}">
838
- ${s.model?`<div class="kv"><div class="k">model</div><pre>${esc(s.model)}${s.prompt_tokens!=null?` · ${s.prompt_tokens} in / ${s.completion_tokens||0} out`:''}</pre></div>`:''}
877
+ ${s.model?`<div class="kv"><div class="k">model</div><pre>${esc(s.model)}${s.prompt_tokens!=null?` · ${s.prompt_tokens} in / ${s.completion_tokens||0} out`:''}${s.usage_detail?' · '+esc(Object.entries(s.usage_detail).map(([k,v])=>`${v} ${k.replace('input_','').replace('output_','')}`).join(', ')):''}</pre></div>`:''}
839
878
  ${s.error?`<div class="kv"><div class="k">error${s.type==='node'?`<button class="copy" onclick="showSpanSource('${s.id}')">view source</button>`:''}</div><pre class="err">${esc(s.error)}</pre><div id="src-${s.id}"></div></div>`:''}
840
879
  ${s.input!=null?kv('input',s.input):''}
841
880
  ${s.output!=null?kv('output',s.output):''}
@@ -989,6 +1028,37 @@ function fmtk(n){ return n<1000?String(n):(n/1000).toFixed(1)+'k'; }
989
1028
  function ago(ms){ const s=(Date.now()-ms)/1000; if(s<60)return Math.round(s)+'s ago'; if(s<3600)return Math.round(s/60)+'m ago'; if(s<86400)return Math.round(s/3600)+'h ago'; return Math.round(s/86400)+'d ago'; }
990
1029
  let tT; function toast(m){ const t=$('#toast'); t.textContent=m; t.classList.add('show'); clearTimeout(tT); tT=setTimeout(()=>t.classList.remove('show'),2600); }
991
1030
 
1031
+ let MEM_NS=[];
1032
+ async function probeMemory(){
1033
+ try{ const r=await fetch('/api/memory/namespaces'); if(!r.ok) return;
1034
+ MEM_NS=(await r.json()).namespaces||[];
1035
+ document.getElementById('nav-mem').style.display='';
1036
+ const m=document.querySelector('.bottom-nav .nav-mem'); if(m)m.style.display='';
1037
+ }catch(e){}
1038
+ }
1039
+ async function loadMemory(){
1040
+ const sel=document.getElementById('mem-ns');
1041
+ try{ const r=await fetch('/api/memory/namespaces'); MEM_NS=(await r.json()).namespaces||[]; }catch(e){}
1042
+ const cur=sel.value;
1043
+ sel.innerHTML=MEM_NS.map(ns=>`<option value="${esc(ns.join('.'))}">${esc(ns.join(' / '))}</option>`).join('');
1044
+ if(cur) sel.value=cur;
1045
+ const ns=sel.value;
1046
+ const w=document.getElementById('mem-wrap');
1047
+ if(!ns){ w.innerHTML='<div class="empty"><div class="t">No memories yet</div>Nodes write with <span class="mono">get_store().put(namespace, key, value)</span> — items appear here.</div>'; return; }
1048
+ const q=document.getElementById('mem-q').value.trim();
1049
+ const p=new URLSearchParams({namespace:ns}); if(q)p.set('query',q);
1050
+ let d={items:[]}; try{ d=await(await fetch('/api/memory/items?'+p)).json(); }catch(e){}
1051
+ w.innerHTML=(d.items||[]).length?`<div style="overflow:auto"><table>
1052
+ <thead><tr><th>Key</th><th>Value</th><th class="r">Updated</th></tr></thead>
1053
+ <tbody>${d.items.map(i=>`<tr>
1054
+ <td class="mono" style="font-weight:600;white-space:nowrap">${esc(i.key)}</td>
1055
+ <td><pre style="margin:0;white-space:pre-wrap;word-break:break-word;font:12px/1.5 var(--mono);max-width:640px">${esc(JSON.stringify(i.value,null,1)).slice(0,600)}</pre></td>
1056
+ <td class="r" style="color:var(--muted);white-space:nowrap">${i.updated_at?esc(String(i.updated_at).slice(0,19).replace('T',' ')):'—'}</td></tr>`).join('')}</tbody></table></div>`
1057
+ :'<div class="empty">Nothing in this namespace'+(q?' matching that query':'')+'.</div>';
1058
+ }
1059
+ document.addEventListener('change',e=>{ if(e.target&&e.target.id==='mem-ns') loadMemory(); });
1060
+ let memQT; document.addEventListener('input',e=>{ if(e.target&&e.target.id==='mem-q'){ clearTimeout(memQT); memQT=setTimeout(loadMemory,300); } });
1061
+
992
1062
  async function loadDatasets(){
993
1063
  const w=document.getElementById('ds-wrap'); if(!w) return;
994
1064
  let ds=[]; try{ ds=await(await fetch('/api/datasets')).json(); }catch(e){}
@@ -1007,7 +1077,7 @@ window.runDataset=async(id,name)=>{
1007
1077
  toast('Eval started: '+d.items+' items → session '+d.session);
1008
1078
  };
1009
1079
  window.delDataset=async(id)=>{ await fetch('/api/datasets/'+id,{method:'DELETE'}); loadDatasets(); };
1010
- loadGraph(); liveTopology();
1080
+ loadGraph(); liveTopology(); probeMemory();
1011
1081
  /* deep links: #runs #sessions #stats #run=<id> */
1012
1082
  (function(){ const h=location.hash.slice(1);
1013
1083
  if(h==='runs'||h==='sessions'||h==='stats') switchView(h);
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
  import json, os, sqlite3, threading, time, uuid
14
14
  from typing import Any, Optional
15
15
 
16
- SCHEMA_VERSION = 5
16
+ SCHEMA_VERSION = 6
17
17
  _lock = threading.Lock()
18
18
 
19
19
 
@@ -68,6 +68,11 @@ class Store:
68
68
  c.execute("ALTER TABLE runs ADD COLUMN bookmarked INTEGER DEFAULT 0")
69
69
  if "thread_id" not in cols:
70
70
  c.execute("ALTER TABLE runs ADD COLUMN thread_id TEXT")
71
+ scols = [r[1] for r in c.execute("PRAGMA table_info(spans)").fetchall()]
72
+ for col, typ in (("retries", "INTEGER"), ("ttft_ms", "INTEGER"),
73
+ ("usage_detail", "TEXT")):
74
+ if col not in scols:
75
+ c.execute(f"ALTER TABLE spans ADD COLUMN {col} {typ}")
71
76
  try:
72
77
  c.execute("SELECT count(*) FROM json_each('[1]')")
73
78
  self.has_json1 = True
@@ -133,15 +138,18 @@ class Store:
133
138
  with _lock, self._conn() as c:
134
139
  c.execute("""INSERT OR REPLACE INTO spans
135
140
  (id,run_id,parent_id,seq,type,name,status,started_ms,ended_ms,offset_ms,
136
- dur_ms,input,output,model,prompt_tokens,completion_tokens,cost_usd,error)
137
- VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
141
+ dur_ms,input,output,model,prompt_tokens,completion_tokens,cost_usd,error,
142
+ retries,ttft_ms,usage_detail)
143
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
138
144
  (s["id"], s["run_id"], s.get("parent_id"), s.get("seq", 0), s["type"],
139
145
  s.get("name"), s.get("status", "ok"), s.get("started_ms"), s.get("ended_ms"),
140
146
  s.get("offset_ms"), s.get("dur_ms"),
141
147
  json.dumps(s.get("input"), default=str) if s.get("input") is not None else None,
142
148
  json.dumps(s.get("output"), default=str) if s.get("output") is not None else None,
143
149
  s.get("model"), s.get("prompt_tokens"), s.get("completion_tokens"),
144
- s.get("cost_usd"), s.get("error")))
150
+ s.get("cost_usd"), s.get("error"),
151
+ s.get("retries"), s.get("ttft_ms"),
152
+ json.dumps(s.get("usage_detail")) if s.get("usage_detail") else None))
145
153
  if self.has_fts:
146
154
  c.execute("DELETE FROM span_fts WHERE span_id=?", (s["id"],))
147
155
  c.execute("INSERT INTO span_fts(text,span_id,run_id) VALUES(?,?,?)",
@@ -316,7 +324,7 @@ class Store:
316
324
  for k in ("input", "tags"):
317
325
  d[k] = json.loads(d[k]) if d.get(k) else None
318
326
  for s in spans:
319
- for k in ("input", "output"):
327
+ for k in ("input", "output", "usage_detail"):
320
328
  s[k] = json.loads(s[k]) if s.get(k) else None
321
329
  d["spans"] = spans
322
330
  d["scores"] = scores
@@ -77,24 +77,31 @@ def _model_name(serialized: dict, kw: dict) -> str:
77
77
  return ids[-1] if ids else "llm"
78
78
 
79
79
 
80
- def _usage(response: Any) -> tuple[Optional[int], Optional[int]]:
80
+ def _usage(response: Any) -> tuple[Optional[int], Optional[int], Optional[dict]]:
81
+ """(prompt, completion, detail) — detail carries cache_read/cache_creation/
82
+ reasoning token counts when the provider reports them."""
81
83
  # OpenAI-style aggregate
82
84
  out = getattr(response, "llm_output", None) or {}
83
85
  for key in ("token_usage", "usage"):
84
86
  u = out.get(key) if isinstance(out, dict) else None
85
87
  if u:
86
88
  return (u.get("prompt_tokens") or u.get("input_tokens"),
87
- u.get("completion_tokens") or u.get("output_tokens"))
89
+ u.get("completion_tokens") or u.get("output_tokens"), None)
88
90
  # per-generation usage_metadata (chat models)
89
91
  try:
90
92
  for gens in response.generations:
91
93
  for g in gens:
92
94
  um = getattr(getattr(g, "message", None), "usage_metadata", None)
93
95
  if um:
94
- return um.get("input_tokens"), um.get("output_tokens")
96
+ detail = {}
97
+ for side in ("input_token_details", "output_token_details"):
98
+ for k, v in (um.get(side) or {}).items():
99
+ if v:
100
+ detail[f"{side.split('_')[0]}_{k}"] = v
101
+ return um.get("input_tokens"), um.get("output_tokens"), detail or None
95
102
  except Exception:
96
103
  pass
97
- return None, None
104
+ return None, None, None
98
105
 
99
106
 
100
107
  def _gen_text(response: Any) -> str:
@@ -142,7 +149,7 @@ class SpanBuilder(BaseCallbackHandler):
142
149
  return int((t - (self._t0 or t)) * 1000)
143
150
 
144
151
  def _finish_span(self, info: dict, *, output=None, status="ok", error=None,
145
- model=None, pt=None, ct=None):
152
+ model=None, pt=None, ct=None, usage_detail=None):
146
153
  now = time.time()
147
154
  sid = info["span_id"]
148
155
  self._emit({"kind": "span", "id": sid, "run_id": self.run_id,
@@ -153,7 +160,10 @@ class SpanBuilder(BaseCallbackHandler):
153
160
  "dur_ms": int((now - info["t"]) * 1000),
154
161
  "input": info.get("input"), "output": output, "model": model,
155
162
  "prompt_tokens": pt, "completion_tokens": ct,
156
- "cost_usd": cost_of(model, pt, ct), "error": error})
163
+ "cost_usd": cost_of(model, pt, ct), "error": error,
164
+ "retries": info.get("retries"),
165
+ "ttft_ms": int(info["ttft"] * 1000) if info.get("ttft") is not None else None,
166
+ "usage_detail": usage_detail})
157
167
  self._seq += 1
158
168
 
159
169
  # -- chains / nodes --
@@ -235,15 +245,41 @@ class SpanBuilder(BaseCallbackHandler):
235
245
  info = self._open.pop(run_id, None)
236
246
  if not info:
237
247
  return
238
- pt, ct = _usage(response)
248
+ pt, ct, detail = _usage(response)
239
249
  self._finish_span(info, output=_gen_text(response)[:4000],
240
- model=info["name"], pt=pt, ct=ct)
250
+ model=info["name"], pt=pt, ct=ct, usage_detail=detail)
241
251
 
242
252
  def on_llm_error(self, error, *, run_id=None, **kw):
243
253
  info = self._open.pop(run_id, None)
244
254
  if info:
245
255
  self._finish_span(info, status="error", error=_err_text(error), model=info["name"])
246
256
 
257
+ def on_llm_new_token(self, token, *, run_id=None, **kw):
258
+ # streaming: first token stamps time-to-first-token
259
+ info = self._open.get(run_id)
260
+ if info is not None and "ttft" not in info:
261
+ info["ttft"] = time.time() - info["t"]
262
+
263
+ # -- retries (tenacity, e.g. rate limits) --
264
+ def on_retry(self, retry_state, *, run_id=None, **kw):
265
+ info = self._open.get(run_id)
266
+ if info is not None:
267
+ info["retries"] = getattr(retry_state, "attempt_number", None) or \
268
+ (info.get("retries") or 0) + 1
269
+
270
+ # -- custom events (langchain dispatch_custom_event) --
271
+ def on_custom_event(self, name, data, *, run_id=None, **kw):
272
+ now = time.time()
273
+ self._emit({"kind": "span", "id": uuid.uuid4().hex[:12], "run_id": self.run_id,
274
+ "parent_id": self._span_of.get(run_id),
275
+ "seq": self._seq, "type": "event", "name": str(name), "status": "ok",
276
+ "started_ms": int(now * 1000), "ended_ms": int(now * 1000),
277
+ "offset_ms": self._rel(now), "dur_ms": 0,
278
+ "input": None, "output": _trunc(data), "model": None,
279
+ "prompt_tokens": None, "completion_tokens": None,
280
+ "cost_usd": None, "error": None})
281
+ self._seq += 1
282
+
247
283
  # -- retrievers (LangChain RAG) --
248
284
  def on_retriever_start(self, serialized, query, *, run_id=None, parent_run_id=None, **kw):
249
285
  sid = uuid.uuid4().hex[:12]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes