windhover 0.8.1__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.1 → windhover-0.9.0}/PKG-INFO +9 -2
  2. {windhover-0.8.1 → windhover-0.9.0}/README.md +8 -1
  3. {windhover-0.8.1 → windhover-0.9.0}/pyproject.toml +1 -1
  4. {windhover-0.8.1 → windhover-0.9.0}/tests/test_smoke.py +63 -0
  5. {windhover-0.8.1 → windhover-0.9.0}/windhover/demo_graph.py +19 -3
  6. {windhover-0.8.1 → windhover-0.9.0}/windhover/server.py +37 -3
  7. {windhover-0.8.1 → windhover-0.9.0}/windhover/static/index.html +56 -6
  8. {windhover-0.8.1 → windhover-0.9.0}/windhover/store.py +13 -5
  9. {windhover-0.8.1 → windhover-0.9.0}/windhover/tracer.py +44 -8
  10. {windhover-0.8.1 → windhover-0.9.0}/.github/workflows/ci.yml +0 -0
  11. {windhover-0.8.1 → windhover-0.9.0}/.gitignore +0 -0
  12. {windhover-0.8.1 → windhover-0.9.0}/LICENSE +0 -0
  13. {windhover-0.8.1 → windhover-0.9.0}/SPEC.md +0 -0
  14. {windhover-0.8.1 → windhover-0.9.0}/docs/graph.png +0 -0
  15. {windhover-0.8.1 → windhover-0.9.0}/docs/logo.svg +0 -0
  16. {windhover-0.8.1 → windhover-0.9.0}/docs/runs.png +0 -0
  17. {windhover-0.8.1 → windhover-0.9.0}/docs/social-preview.png +0 -0
  18. {windhover-0.8.1 → windhover-0.9.0}/docs/stats.png +0 -0
  19. {windhover-0.8.1 → windhover-0.9.0}/docs/trace.png +0 -0
  20. {windhover-0.8.1 → windhover-0.9.0}/tests/__init__.py +0 -0
  21. {windhover-0.8.1 → windhover-0.9.0}/windhover/__init__.py +0 -0
  22. {windhover-0.8.1 → windhover-0.9.0}/windhover/config.py +0 -0
  23. {windhover-0.8.1 → windhover-0.9.0}/windhover/extract.py +0 -0
  24. {windhover-0.8.1 → windhover-0.9.0}/windhover/pricing.json +0 -0
  25. {windhover-0.8.1 → windhover-0.9.0}/windhover/static/icon.svg +0 -0
  26. {windhover-0.8.1 → windhover-0.9.0}/windhover/static/manifest.json +0 -0
  27. {windhover-0.8.1 → windhover-0.9.0}/windhover/static/vendor/cytoscape-dagre.js +0 -0
  28. {windhover-0.8.1 → windhover-0.9.0}/windhover/static/vendor/cytoscape.min.js +0 -0
  29. {windhover-0.8.1 → windhover-0.9.0}/windhover/static/vendor/dagre.min.js +0 -0
  30. {windhover-0.8.1 → windhover-0.9.0}/windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
  31. {windhover-0.8.1 → windhover-0.9.0}/windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
  32. {windhover-0.8.1 → 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.1
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.1"
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}
@@ -347,6 +348,8 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
347
348
  .tr-bar.tool{background:var(--chip-tool-t)}
348
349
  .tr-bar.retriever{background:var(--chip-ret-t)}
349
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}
350
353
  .charts .card:nth-child(2) .cbar{background:var(--chip-llm-t)}
351
354
  #cy{background:radial-gradient(circle,var(--border) 1px,transparent 1.5px) 0 0/24px 24px}
352
355
  .legend{font-family:var(--mono);font-size:10.5px;letter-spacing:.06em;text-transform:uppercase}
@@ -390,9 +393,10 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
390
393
  <button data-v="graph" class="on">${ICON.graph} Graph</button>
391
394
  <button data-v="runs">${ICON.list} Runs</button>
392
395
  <button data-v="sessions">${ICON.layers} Sessions</button>
396
+ <button data-v="memory" id="nav-mem" style="display:none">${ICON.mem} Memory</button>
393
397
  <button data-v="stats">${ICON.chart} Stats</button>
394
398
  </nav>
395
- <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>
396
400
  </aside>
397
401
 
398
402
  <main>
@@ -444,6 +448,16 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
444
448
  <div class="card"><div id="sess-wrap"><div class="empty">Loading…</div></div></div>
445
449
  </div>
446
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>
447
461
  <div id="view-stats" style="display:none">
448
462
  <div class="page">
449
463
  <div class="page-head"><h1>Stats</h1><span class="sub">all recorded runs</span></div>
@@ -458,6 +472,7 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
458
472
  <button data-v="graph" class="on">${ICON.graph}<span>Graph</span></button>
459
473
  <button data-v="runs">${ICON.list}<span>Runs</span></button>
460
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>
461
476
  <button data-v="stats">${ICON.chart}<span>Stats</span></button>
462
477
  </div>
463
478
 
@@ -497,6 +512,7 @@ const ICON = {
497
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>',
498
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>',
499
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>',
500
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>',
501
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>',
502
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>',
@@ -673,9 +689,10 @@ function switchView(v){ VIEW=v;
673
689
  if(location.hash!=='#'+v) history.replaceState(null,'','#'+v);
674
690
  $$('.nav button,.bottom-nav button').forEach(b=>b.classList.toggle('on',b.dataset.v===v));
675
691
  $('#crumb').textContent=v[0].toUpperCase()+v.slice(1);
676
- for(const k of ['graph','runs','sessions','stats'])
692
+ for(const k of ['graph','runs','sessions','memory','stats'])
677
693
  $('#view-'+k).style.display=v===k?'':'none';
678
- 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();}
679
696
  if(cy&&v==='graph')setTimeout(()=>cy.resize(),40);
680
697
  }
681
698
  $$('.nav button,.bottom-nav button').forEach(b=>b.onclick=()=>switchView(b.dataset.v));
@@ -700,6 +717,7 @@ async function runNow(input){
700
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):{};
701
718
  if(ev==='node'){ cy.$id(prev).addClass('done').removeClass('running'); cy.$id(d.node).addClass('running');
702
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)); }
703
721
  else if(ev==='interrupt'){ toast('Run paused at an interrupt — awaiting resume'); }
704
722
  else if(ev==='interrupted'){ cy.$id(prev).addClass('running'); toast('Run interrupted — see Runs for the payload'); }
705
723
  else if(ev==='done'){ cy.$id(prev).addClass('done').removeClass('running'); cy.$id('__end__').addClass('done'); toast('Run complete — saved to Runs'); }
@@ -843,6 +861,7 @@ function renderSpans(list,byParent,total,depth){
843
861
  const kids=byParent[s.id]||[];
844
862
  const left=((s.offset_ms||0)/total*100).toFixed(2), width=Math.max(1.2,(s.dur_ms||0)/total*100).toFixed(2);
845
863
  const meta=[s.dur_ms!=null?s.dur_ms+'ms':null,
864
+ s.ttft_ms!=null?'ttft '+s.ttft_ms+'ms':null,
846
865
  s.type==='llm'&&(s.prompt_tokens!=null||s.completion_tokens!=null)?fmtk((s.prompt_tokens||0)+(s.completion_tokens||0))+'t':null,
847
866
  s.cost_usd!=null?'$'+s.cost_usd.toFixed(4):null].filter(Boolean).join(' · ');
848
867
  const did='sp'+s.id;
@@ -850,12 +869,12 @@ function renderSpans(list,byParent,total,depth){
850
869
  <div class="tr-row" onclick="tog('${did}',this)">
851
870
  <div class="tr-main"><span class="indent" style="width:${depth*18}px"></span>
852
871
  <span class="caret">▶</span><span class="chip ${s.type}">${s.type}</span>
853
- <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>
854
873
  <div class="tr-gantt"><div class="tr-bar ${s.type}" style="left:${left}%;width:${width}%"></div></div>
855
874
  <div class="tr-meta">${meta}</div>
856
875
  </div>
857
876
  <div class="tr-detail" id="${did}">
858
- ${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>`:''}
859
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>`:''}
860
879
  ${s.input!=null?kv('input',s.input):''}
861
880
  ${s.output!=null?kv('output',s.output):''}
@@ -1009,6 +1028,37 @@ function fmtk(n){ return n<1000?String(n):(n/1000).toFixed(1)+'k'; }
1009
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'; }
1010
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); }
1011
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
+
1012
1062
  async function loadDatasets(){
1013
1063
  const w=document.getElementById('ds-wrap'); if(!w) return;
1014
1064
  let ds=[]; try{ ds=await(await fetch('/api/datasets')).json(); }catch(e){}
@@ -1027,7 +1077,7 @@ window.runDataset=async(id,name)=>{
1027
1077
  toast('Eval started: '+d.items+' items → session '+d.session);
1028
1078
  };
1029
1079
  window.delDataset=async(id)=>{ await fetch('/api/datasets/'+id,{method:'DELETE'}); loadDatasets(); };
1030
- loadGraph(); liveTopology();
1080
+ loadGraph(); liveTopology(); probeMemory();
1031
1081
  /* deep links: #runs #sessions #stats #run=<id> */
1032
1082
  (function(){ const h=location.hash.slice(1);
1033
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