windhover 0.8.1__tar.gz → 0.10.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.10.0}/PKG-INFO +14 -4
  2. {windhover-0.8.1 → windhover-0.10.0}/README.md +13 -3
  3. {windhover-0.8.1 → windhover-0.10.0}/pyproject.toml +1 -1
  4. {windhover-0.8.1 → windhover-0.10.0}/tests/test_smoke.py +128 -0
  5. {windhover-0.8.1 → windhover-0.10.0}/windhover/demo_graph.py +28 -4
  6. {windhover-0.8.1 → windhover-0.10.0}/windhover/server.py +134 -20
  7. {windhover-0.8.1 → windhover-0.10.0}/windhover/static/index.html +195 -7
  8. {windhover-0.8.1 → windhover-0.10.0}/windhover/store.py +13 -5
  9. {windhover-0.8.1 → windhover-0.10.0}/windhover/tracer.py +76 -10
  10. {windhover-0.8.1 → windhover-0.10.0}/.github/workflows/ci.yml +0 -0
  11. {windhover-0.8.1 → windhover-0.10.0}/.gitignore +0 -0
  12. {windhover-0.8.1 → windhover-0.10.0}/LICENSE +0 -0
  13. {windhover-0.8.1 → windhover-0.10.0}/SPEC.md +0 -0
  14. {windhover-0.8.1 → windhover-0.10.0}/docs/graph.png +0 -0
  15. {windhover-0.8.1 → windhover-0.10.0}/docs/logo.svg +0 -0
  16. {windhover-0.8.1 → windhover-0.10.0}/docs/runs.png +0 -0
  17. {windhover-0.8.1 → windhover-0.10.0}/docs/social-preview.png +0 -0
  18. {windhover-0.8.1 → windhover-0.10.0}/docs/stats.png +0 -0
  19. {windhover-0.8.1 → windhover-0.10.0}/docs/trace.png +0 -0
  20. {windhover-0.8.1 → windhover-0.10.0}/tests/__init__.py +0 -0
  21. {windhover-0.8.1 → windhover-0.10.0}/windhover/__init__.py +0 -0
  22. {windhover-0.8.1 → windhover-0.10.0}/windhover/config.py +0 -0
  23. {windhover-0.8.1 → windhover-0.10.0}/windhover/extract.py +0 -0
  24. {windhover-0.8.1 → windhover-0.10.0}/windhover/pricing.json +0 -0
  25. {windhover-0.8.1 → windhover-0.10.0}/windhover/static/icon.svg +0 -0
  26. {windhover-0.8.1 → windhover-0.10.0}/windhover/static/manifest.json +0 -0
  27. {windhover-0.8.1 → windhover-0.10.0}/windhover/static/vendor/cytoscape-dagre.js +0 -0
  28. {windhover-0.8.1 → windhover-0.10.0}/windhover/static/vendor/cytoscape.min.js +0 -0
  29. {windhover-0.8.1 → windhover-0.10.0}/windhover/static/vendor/dagre.min.js +0 -0
  30. {windhover-0.8.1 → windhover-0.10.0}/windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
  31. {windhover-0.8.1 → windhover-0.10.0}/windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
  32. {windhover-0.8.1 → windhover-0.10.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.10.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
@@ -81,8 +81,11 @@ graph.invoke(input, config={
81
81
  - **Clickable graph** — tap a node for health, latency, wiring, its **source code**, and recent executions with payloads.
82
82
  - **Error forensics** — failed runs show the full traceback; the failing node turns red on the
83
83
  graph, and the node's source renders with the **throwing line highlighted**.
84
- - **Human-in-the-loop aware** — a graph paused on `interrupt()` shows an amber **interrupted**
85
- status plus the payload it's asking a human about.
84
+ - **Human-in-the-loop console** — a paused graph shows an amber **interrupted** status with the
85
+ question it's asking; answer it (`Command(resume=…)`), redirect it (`Command(goto=…)`), set
86
+ static breakpoints per run (`interrupt_before`), **edit state** at any checkpoint
87
+ (`update_state`), or **fork** a thread from any historical checkpoint — all from the UI,
88
+ all pure LangGraph primitives.
86
89
  - **State evolution** — every trace shows which state keys each node wrote, in order.
87
90
  - **X-ray** — graphs with subgraphs get a canvas toggle that expands composite nodes
88
91
  (`get_graph(xray=True)`).
@@ -90,7 +93,14 @@ graph.invoke(input, config={
90
93
  status/tag/session filters, bookmarks, pagination, CSV/JSON export.
91
94
  - **Sessions** — group runs into threads/batches; roll-up tokens, cost, errors.
92
95
  - **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.
96
+ - **Live tail** — open a running run and watch its spans arrive in real time; nodes can push
97
+ progress via LangGraph's `get_stream_writer()`.
98
+ - **Custom events** — `dispatch_custom_event("name", {...})` anywhere in your app lands as an
99
+ event marker in the trace, parented to the node that fired it.
100
+ - **Retries + TTFT** — tenacity retries badge the span (`↻2`); streaming LLM calls record
101
+ time-to-first-token; cache-read / reasoning token details show on the model line.
102
+ - **Memory browser** — graphs compiled with a LangGraph `Store` get a Memory view: browse
103
+ namespaces and search long-term memory items.
94
104
  - **Time-travel** — checkpointed graphs get a per-thread checkpoint browser: state, writes,
95
105
  and next-nodes at every superstep (`get_state_history`).
96
106
  - **Run diff** — compare any two runs node-by-node: identical vs differing outputs,
@@ -62,8 +62,11 @@ graph.invoke(input, config={
62
62
  - **Clickable graph** — tap a node for health, latency, wiring, its **source code**, and recent executions with payloads.
63
63
  - **Error forensics** — failed runs show the full traceback; the failing node turns red on the
64
64
  graph, and the node's source renders with the **throwing line highlighted**.
65
- - **Human-in-the-loop aware** — a graph paused on `interrupt()` shows an amber **interrupted**
66
- status plus the payload it's asking a human about.
65
+ - **Human-in-the-loop console** — a paused graph shows an amber **interrupted** status with the
66
+ question it's asking; answer it (`Command(resume=…)`), redirect it (`Command(goto=…)`), set
67
+ static breakpoints per run (`interrupt_before`), **edit state** at any checkpoint
68
+ (`update_state`), or **fork** a thread from any historical checkpoint — all from the UI,
69
+ all pure LangGraph primitives.
67
70
  - **State evolution** — every trace shows which state keys each node wrote, in order.
68
71
  - **X-ray** — graphs with subgraphs get a canvas toggle that expands composite nodes
69
72
  (`get_graph(xray=True)`).
@@ -71,7 +74,14 @@ graph.invoke(input, config={
71
74
  status/tag/session filters, bookmarks, pagination, CSV/JSON export.
72
75
  - **Sessions** — group runs into threads/batches; roll-up tokens, cost, errors.
73
76
  - **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.
77
+ - **Live tail** — open a running run and watch its spans arrive in real time; nodes can push
78
+ progress via LangGraph's `get_stream_writer()`.
79
+ - **Custom events** — `dispatch_custom_event("name", {...})` anywhere in your app lands as an
80
+ event marker in the trace, parented to the node that fired it.
81
+ - **Retries + TTFT** — tenacity retries badge the span (`↻2`); streaming LLM calls record
82
+ time-to-first-token; cache-read / reasoning token details show on the model line.
83
+ - **Memory browser** — graphs compiled with a LangGraph `Store` get a Memory view: browse
84
+ namespaces and search long-term memory items.
75
85
  - **Time-travel** — checkpointed graphs get a per-thread checkpoint browser: state, writes,
76
86
  and next-nodes at every superstep (`get_state_history`).
77
87
  - **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.10.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,130 @@ 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
+
363
+ def test_hitl_interrupt_resume():
364
+ """Dynamic interrupt pauses (status interrupted); Command(resume) finishes."""
365
+ from typing import TypedDict
366
+ from langgraph.graph import StateGraph, START, END
367
+ from langgraph.checkpoint.memory import MemorySaver
368
+ from langgraph.types import Command, interrupt
369
+ p = tempfile.mktemp(suffix=".db")
370
+ s = Store(p)
371
+
372
+ class St(TypedDict):
373
+ x: int
374
+ def gate(st):
375
+ ok = interrupt({"question": f"allow x={st['x']}?"})
376
+ return {"x": st["x"] if ok else 0}
377
+ g = StateGraph(St)
378
+ g.add_node("gate", gate)
379
+ g.add_edge(START, "gate"); g.add_edge("gate", END)
380
+ app = g.compile(checkpointer=MemorySaver())
381
+ cfgc = {"configurable": {"thread_id": "hitl-1"}}
382
+
383
+ tr1 = SpanBuilder(db_sink(s), run_name="hitl")
384
+ out = app.invoke({"x": 5}, config={"callbacks": [tr1], **cfgc})
385
+ assert "__interrupt__" in out
386
+ time.sleep(.05)
387
+ assert s.run_detail(tr1.run_id)["status"] == "interrupted"
388
+
389
+ tr2 = SpanBuilder(db_sink(s), run_name="hitl")
390
+ out2 = app.invoke(Command(resume=True), config={"callbacks": [tr2], **cfgc})
391
+ assert out2["x"] == 5
392
+ time.sleep(.05)
393
+ assert s.run_detail(tr2.run_id)["status"] == "done"
394
+ print("HITL interrupt/resume OK")
395
+
396
+
397
+ def test_static_breakpoint_and_state_edit():
398
+ """interrupt_before pauses with pending next; update_state edit sticks."""
399
+ from typing import TypedDict
400
+ from langgraph.graph import StateGraph, START, END
401
+ from langgraph.checkpoint.memory import MemorySaver
402
+ p = tempfile.mktemp(suffix=".db")
403
+ s = Store(p)
404
+
405
+ class St(TypedDict):
406
+ x: int
407
+ g = StateGraph(St)
408
+ g.add_node("inc", lambda st: {"x": st["x"] + 1})
409
+ g.add_node("mul", lambda st: {"x": st["x"] * 10})
410
+ g.add_edge(START, "inc"); g.add_edge("inc", "mul"); g.add_edge("mul", END)
411
+ app = g.compile(checkpointer=MemorySaver())
412
+ cfgc = {"configurable": {"thread_id": "bp-1"}}
413
+
414
+ tr = SpanBuilder(db_sink(s), run_name="bp")
415
+ for _ in app.stream({"x": 1}, config={"callbacks": [tr], **cfgc},
416
+ stream_mode="updates", interrupt_before=["mul"]):
417
+ pass
418
+ st = app.get_state(cfgc)
419
+ assert st.next == ("mul",), st.next # paused before mul
420
+ app.update_state(cfgc, {"x": 100}) # human edits the state
421
+ out = app.invoke(None, config=cfgc) # continue
422
+ assert out["x"] == 1000, out # 100 * 10 — edit took effect
423
+ print("static breakpoint + state edit OK")
424
+
425
+
302
426
  if __name__ == "__main__":
303
427
  test_cost(); print("cost OK")
304
428
  test_store_roundtrip()
@@ -314,4 +438,8 @@ if __name__ == "__main__":
314
438
  test_auth_check()
315
439
  test_thread_capture_and_history()
316
440
  test_datasets_and_scoring()
441
+ test_ttft_retry_custom_event_usage_detail()
442
+ test_demo_memory_and_events_end_to_end()
443
+ test_hitl_interrupt_resume()
444
+ test_static_breakpoint_and_state_edit()
317
445
  print("ALL SMOKE TESTS PASSED")
@@ -24,11 +24,34 @@ def seed(s):
24
24
  if n < 0:
25
25
  raise ValueError(f"n must be non-negative, got {n} — the demo guard tripped")
26
26
  return {"n": n}
27
- def grow(s): time.sleep(.3); return {"n": s["n"] * 3}
27
+ def grow(s):
28
+ time.sleep(.3)
29
+ grown = s["n"] * 3
30
+ if grown > 500: # human-in-the-loop: big growth needs approval
31
+ from langgraph.types import interrupt
32
+ approved = interrupt({"question": f"grow {s['n']} -> {grown}? (true/false)"})
33
+ if not approved:
34
+ return {"n": s["n"]}
35
+ return {"n": grown}
28
36
  def parity(s): time.sleep(.4); return {"notes": ["even" if s["n"] % 2 == 0 else "odd"]}
29
37
  def sign(s): time.sleep(.25); return {"notes": ["positive" if s["n"] > 0 else "non-positive"]}
30
38
  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"])]}
39
+ def summarize(s):
40
+ time.sleep(.1)
41
+ try: # progress + custom events + long-term memory, all observable in Windhover
42
+ from langgraph.config import get_stream_writer, get_store
43
+ writer = get_stream_writer()
44
+ writer({"stage": "summarize", "pct": 50})
45
+ from langchain_core.callbacks import dispatch_custom_event
46
+ dispatch_custom_event("summary-ready", {"n": s["n"], "parts": len(s["notes"])})
47
+ store = get_store()
48
+ if store is not None:
49
+ store.put(("demo", "summaries"), f"n-{s['n']}",
50
+ {"n": s["n"], "notes": s["notes"]})
51
+ except Exception:
52
+ pass
53
+ time.sleep(.1)
54
+ return {"notes": [f"n={s['n']}: " + ", ".join(s["notes"])]}
32
55
 
33
56
 
34
57
  _g = StateGraph(State)
@@ -45,8 +68,9 @@ _g.add_edge("sign", "summarize")
45
68
  _g.add_edge("magnitude", "summarize")
46
69
  _g.add_edge("summarize", END)
47
70
 
48
- try: # checkpointer makes the Time-travel view demoable; optional dependency surface
71
+ try: # checkpointer + store make Time-travel and Memory demoable
49
72
  from langgraph.checkpoint.memory import MemorySaver
50
- graph = _g.compile(checkpointer=MemorySaver())
73
+ from langgraph.store.memory import InMemoryStore
74
+ graph = _g.compile(checkpointer=MemorySaver(), store=InMemoryStore())
51
75
  except Exception:
52
76
  graph = _g.compile()
@@ -163,37 +163,41 @@ def _sse(ev: str, data: dict) -> str:
163
163
  return f"event: {ev}\ndata: {json.dumps(data)}\n\n"
164
164
 
165
165
 
166
- @app.post("/api/run")
167
- async def api_run(request: Request):
168
- if graph is None:
169
- return JSONResponse({"error": "no local graph (WINDHOVER_GRAPH unset)"}, 400)
170
- try:
171
- payload = await request.json()
172
- except Exception:
173
- payload = {}
174
- session = payload.pop("_session", None)
175
- tags = payload.pop("_tags", None)
176
- thread = payload.pop("_thread", None)
177
- tracer = SpanBuilder(db_sink(store), run_name=cfg.graph_ref, session=session, tags=tags)
166
+ def _stream_execution(graph_input, config, tracer, stream_kwargs=None):
167
+ """Shared SSE executor for /api/run and /api/threads/…/resume. Detects both
168
+ dynamic interrupts (__interrupt__ updates) and static breakpoints (stream
169
+ ends with pending next-nodes) and corrects the run status accordingly."""
178
170
  run_id = tracer.run_id
179
- config = {"callbacks": [tracer]}
180
- if thread or getattr(graph, "checkpointer", None) is not None:
181
- # a checkpointed graph needs a thread; default to the run id so
182
- # time-travel works out of the box
183
- config["configurable"] = {"thread_id": thread or run_id}
184
171
  q: "queue.Queue" = queue.Queue()
185
172
 
186
173
  def worker():
187
174
  try:
188
175
  interrupted = False
189
- for update in graph.stream(payload, config=config,
190
- stream_mode="updates"):
191
- for node in update:
176
+ for mode, chunk in graph.stream(graph_input, config=config,
177
+ stream_mode=["updates", "custom"],
178
+ **(stream_kwargs or {})):
179
+ if mode == "custom":
180
+ # get_stream_writer() output from inside a node -> live progress
181
+ q.put(("progress", {"data": _trunc(chunk, 600)}))
182
+ continue
183
+ for node in chunk:
192
184
  if node == "__interrupt__":
193
185
  interrupted = True
194
186
  q.put(("interrupt", {"run_id": run_id}))
195
187
  else:
196
188
  q.put(("node", {"node": node}))
189
+ if not interrupted and getattr(graph, "checkpointer", None) is not None:
190
+ try: # static breakpoint: stream ended but nodes are pending
191
+ st = graph.get_state(config)
192
+ if st.next:
193
+ interrupted = True
194
+ q.put(("interrupt", {"run_id": run_id,
195
+ "next": list(st.next)}))
196
+ except Exception:
197
+ pass
198
+ if interrupted:
199
+ # tracer closed the run as done on root end; correct it
200
+ store.close_run(run_id, "interrupted", int(time.time() * 1000))
197
201
  q.put(("interrupted" if interrupted else "done", {"run_id": run_id}))
198
202
  except Exception as e:
199
203
  # tracer already recorded the error span/close; surface to the client too
@@ -212,6 +216,86 @@ async def api_run(request: Request):
212
216
  return StreamingResponse(stream(), media_type="text/event-stream")
213
217
 
214
218
 
219
+ @app.post("/api/run")
220
+ async def api_run(request: Request):
221
+ if graph is None:
222
+ return JSONResponse({"error": "no local graph (WINDHOVER_GRAPH unset)"}, 400)
223
+ try:
224
+ payload = await request.json()
225
+ except Exception:
226
+ payload = {}
227
+ session = payload.pop("_session", None)
228
+ tags = payload.pop("_tags", None)
229
+ thread = payload.pop("_thread", None)
230
+ pause_before = payload.pop("_interrupt_before", None)
231
+ pause_after = payload.pop("_interrupt_after", None)
232
+ tracer = SpanBuilder(db_sink(store), run_name=cfg.graph_ref, session=session, tags=tags)
233
+ config = {"callbacks": [tracer]}
234
+ if thread or getattr(graph, "checkpointer", None) is not None:
235
+ # a checkpointed graph needs a thread; default to the run id so
236
+ # time-travel works out of the box
237
+ config["configurable"] = {"thread_id": thread or tracer.run_id}
238
+ sk = {}
239
+ if pause_before:
240
+ sk["interrupt_before"] = [str(n) for n in pause_before]
241
+ if pause_after:
242
+ sk["interrupt_after"] = [str(n) for n in pause_after]
243
+ return _stream_execution(payload, config, tracer, sk)
244
+
245
+
246
+ @app.post("/api/threads/{thread_id}/resume")
247
+ async def api_thread_resume(request: Request, thread_id: str):
248
+ """Human-in-the-loop: answer an interrupt (Command(resume=…)), redirect
249
+ (Command(goto=…)), continue past a static breakpoint (no body), or fork
250
+ from an earlier checkpoint (checkpoint_id)."""
251
+ if graph is None or getattr(graph, "checkpointer", None) is None:
252
+ return JSONResponse({"error": "no local graph with a checkpointer"}, 400)
253
+ try:
254
+ body = await request.json()
255
+ except Exception:
256
+ body = {}
257
+ try:
258
+ from langgraph.types import Command
259
+ except Exception:
260
+ return JSONResponse({"error": "langgraph.types.Command unavailable"}, 500)
261
+ if "value" in body:
262
+ graph_input = Command(resume=body["value"])
263
+ elif body.get("goto"):
264
+ graph_input = Command(goto=str(body["goto"]), update=body.get("update"))
265
+ else:
266
+ graph_input = None # plain continue (static breakpoint / after state edit)
267
+ tracer = SpanBuilder(db_sink(store), run_name=cfg.graph_ref,
268
+ session=body.get("_session"),
269
+ tags=(body.get("_tags") or []) + ["resume"])
270
+ configurable = {"thread_id": thread_id}
271
+ if body.get("checkpoint_id"):
272
+ configurable["checkpoint_id"] = str(body["checkpoint_id"])
273
+ config = {"callbacks": [tracer], "configurable": configurable}
274
+ return _stream_execution(graph_input, config, tracer)
275
+
276
+
277
+ @app.post("/api/threads/{thread_id}/state")
278
+ async def api_thread_update_state(request: Request, thread_id: str):
279
+ """Human-in-the-loop: edit state at the current (or a given) checkpoint —
280
+ LangGraph's update_state. Follow with …/resume to continue on the edit."""
281
+ if graph is None or getattr(graph, "checkpointer", None) is None:
282
+ return JSONResponse({"error": "no local graph with a checkpointer"}, 400)
283
+ body = await request.json()
284
+ values = body.get("values")
285
+ if not isinstance(values, dict):
286
+ return JSONResponse({"error": "requires values (object of state keys)"}, 400)
287
+ configurable = {"thread_id": thread_id}
288
+ if body.get("checkpoint_id"):
289
+ configurable["checkpoint_id"] = str(body["checkpoint_id"])
290
+ try:
291
+ new_cfg = graph.update_state({"configurable": configurable}, values,
292
+ as_node=body.get("as_node"))
293
+ return JSONResponse({"ok": True, "checkpoint_id":
294
+ (new_cfg.get("configurable") or {}).get("checkpoint_id")})
295
+ except Exception as e:
296
+ return JSONResponse({"error": str(e)}, 400)
297
+
298
+
215
299
  @app.post("/api/ingest")
216
300
  async def api_ingest(request: Request):
217
301
  ev = await request.json()
@@ -340,6 +424,36 @@ def api_thread_history(thread_id: str, limit: int = 80):
340
424
  return JSONResponse({"thread_id": thread_id, "steps": steps})
341
425
 
342
426
 
427
+ @app.get("/api/memory/namespaces")
428
+ def api_memory_namespaces():
429
+ """LangGraph long-term memory (Store) browser — namespaces."""
430
+ st = getattr(graph, "store", None) if graph is not None else None
431
+ if st is None:
432
+ return JSONResponse({"error": "no local graph with a store"}, 404)
433
+ try:
434
+ return JSONResponse({"namespaces": [list(ns) for ns in st.list_namespaces()]})
435
+ except Exception as e:
436
+ return JSONResponse({"error": str(e)}, 500)
437
+
438
+
439
+ @app.get("/api/memory/items")
440
+ def api_memory_items(namespace: str, query: str = None, limit: int = 50):
441
+ """Items in one namespace (dot-separated); optional semantic/text query."""
442
+ st = getattr(graph, "store", None) if graph is not None else None
443
+ if st is None:
444
+ return JSONResponse({"error": "no local graph with a store"}, 404)
445
+ try:
446
+ ns = tuple(namespace.split(".")) if namespace else ()
447
+ items = st.search(ns, query=query, limit=max(1, min(limit, 200)))
448
+ return JSONResponse({"namespace": namespace, "items": [
449
+ {"key": i.key, "value": _trunc(i.value, 3000),
450
+ "created_at": str(getattr(i, "created_at", "") or "") or None,
451
+ "updated_at": str(getattr(i, "updated_at", "") or "") or None,
452
+ "score": getattr(i, "score", None)} for i in items]})
453
+ except Exception as e:
454
+ return JSONResponse({"error": str(e)}, 500)
455
+
456
+
343
457
  @app.get("/api/datasets")
344
458
  def api_datasets():
345
459
  return JSONResponse([{k: d[k] for k in ("id", "name", "n_items", "created_ms")}
@@ -130,6 +130,58 @@ td.r,th.r{text-align:right}
130
130
  .empty{padding:56px 20px;text-align:center;color:var(--muted)}
131
131
  .empty .t{font-weight:600;color:var(--text2);margin-bottom:4px}
132
132
 
133
+ /* ---------- human-in-the-loop ---------- */
134
+ async function consumeRunStream(res){
135
+ const rd=res.body.getReader(),dec=new TextDecoder(); let buf='';
136
+ while(true){ const {value,done}=await rd.read(); if(done)break; buf+=dec.decode(value,{stream:true});
137
+ let ps=buf.split('\n\n'); buf=ps.pop();
138
+ 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):{};
139
+ if(ev==='node'&&cy){ cy.$id(d.node).addClass('running'); }
140
+ else if(ev==='progress'){ toast('progress: '+JSON.stringify(d.data).slice(0,120)); }
141
+ else if(ev==='interrupted'){ toast('Paused again — awaiting human'); }
142
+ else if(ev==='done'){ toast('Resumed run complete'); }
143
+ else if(ev==='error'){ toast('Resume error: '+(d.message||'failed')); } } }
144
+ if(VIEW==='runs') loadRuns();
145
+ }
146
+ function parseMaybeJSON(v){ if(v==='')return undefined;
147
+ try{ return JSON.parse(v); }catch(e){ return v; } }
148
+ window.resumeThread=async(tid)=>{
149
+ const raw=document.getElementById('hitl-val').value.trim();
150
+ const body={}; const v=parseMaybeJSON(raw); if(v!==undefined) body.value=v;
151
+ closeAll(); switchView('graph'); toast('Resuming thread '+tid.slice(0,8)+'…');
152
+ const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume',
153
+ {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
154
+ if(!res.ok){ toast((await res.json()).error||'resume failed'); return; }
155
+ await consumeRunStream(res);
156
+ };
157
+ window.resumeGoto=async(tid)=>{
158
+ const g=document.getElementById('hitl-goto').value; if(!g) return toast('Pick a node first');
159
+ closeAll(); switchView('graph'); toast('Redirecting thread to '+g+'…');
160
+ const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume',
161
+ {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({goto:g})});
162
+ if(!res.ok){ toast((await res.json()).error||'resume failed'); return; }
163
+ await consumeRunStream(res);
164
+ };
165
+ window.rerunFrom=async(tid,cid)=>{
166
+ closeAll(); switchView('graph'); toast('Forking from checkpoint '+String(cid).slice(-8)+'…');
167
+ const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume',
168
+ {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({checkpoint_id:cid})});
169
+ if(!res.ok){ toast((await res.json()).error||'fork failed'); return; }
170
+ await consumeRunStream(res);
171
+ };
172
+ window.editState=async(tid,cid,valuesJson)=>{
173
+ const cur=window.prompt('Edit state values (JSON object) — applied via update_state:',valuesJson||'{}');
174
+ if(cur==null) return;
175
+ let values; try{ values=JSON.parse(cur); }catch(e){ return toast('Invalid JSON'); }
176
+ const body={values}; if(cid) body.checkpoint_id=cid;
177
+ const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/state',
178
+ {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
179
+ const d=await res.json();
180
+ if(!res.ok) return toast(d.error||'update_state failed');
181
+ toast('State updated — new checkpoint '+String(d.checkpoint_id||'').slice(-8)+'; Resume to continue');
182
+ showHistory(tid);
183
+ };
184
+
133
185
  /* ---------- time-travel (checkpoint history) ---------- */
134
186
  window.showHistory=async(tid)=>{
135
187
  let d=null; try{ const r=await fetch('/api/threads/'+encodeURIComponent(tid)+'/history');
@@ -153,6 +205,10 @@ window.showHistory=async(tid)=>{
153
205
  <div class="tr-detail" id="${did}">
154
206
  ${s.writes!=null?kv('writes',s.writes):''}
155
207
  ${kv('state at this checkpoint',s.values)}
208
+ <div style="display:flex;gap:8px;margin:4px 0 10px">
209
+ <button class="btn ghost" onclick='rerunFrom("${esc(tid)}","${esc(s.checkpoint_id||'')}")'>⑂ Re-run from here</button>
210
+ <button class="btn ghost" onclick='editState("${esc(tid)}","${esc(s.checkpoint_id||'')}",${JSON.stringify(JSON.stringify(s.values??{}))})'>✎ Edit state</button>
211
+ </div>
156
212
  </div></div>`;
157
213
  }).join('')||'<div class="empty">No checkpoints recorded for this thread</div>';
158
214
  $('#scrim').classList.add('on'); $('#drawer').classList.add('on');
@@ -236,6 +292,7 @@ window.pickDiff=async(id)=>{
236
292
  .chip.tool{background:var(--chip-tool);color:var(--chip-tool-t)}
237
293
  .chip.retriever{background:var(--chip-ret);color:var(--chip-ret-t)}
238
294
  .chip.interrupt{background:var(--warn-weak);color:var(--warn)}
295
+ .chip.event{background:var(--surface2);color:var(--text2);border:1px dashed var(--border2)}
239
296
  .tr-name{font-weight:600;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
240
297
  .tr-name.err{color:var(--err)}
241
298
  .tr-gantt{height:6px;background:var(--surface2);border-radius:3px;position:relative;overflow:hidden}
@@ -296,6 +353,8 @@ textarea:focus{border-color:var(--accent)}
296
353
  .star.on{color:var(--warn)}
297
354
  .tagchip{display:inline-block;background:var(--accent-weak);color:var(--accent-text);font-size:10.5px;font-weight:600;
298
355
  padding:1.5px 7px;border-radius:5px;margin:1px 4px 1px 0;cursor:pointer}
356
+ .pause-chip{background:var(--surface2);color:var(--text2);border:1px dashed var(--border2)}
357
+ .pause-chip.on{background:var(--warn-weak);color:var(--warn);border-style:solid;border-color:var(--warn)}
299
358
  .scorechip{display:inline-block;background:var(--ok-weak);color:var(--ok);font-size:10.5px;font-weight:600;
300
359
  padding:1.5px 7px;border-radius:5px;margin:1px 4px 1px 0}
301
360
  .pager{display:flex;align-items:center;gap:12px;justify-content:flex-end;margin-top:10px;color:var(--muted);font-size:12.5px}
@@ -347,6 +406,8 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
347
406
  .tr-bar.tool{background:var(--chip-tool-t)}
348
407
  .tr-bar.retriever{background:var(--chip-ret-t)}
349
408
  .tr-bar.interrupt{background:var(--warn)}
409
+ .tr-bar.event{background:var(--border2)}
410
+ .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
411
  .charts .card:nth-child(2) .cbar{background:var(--chip-llm-t)}
351
412
  #cy{background:radial-gradient(circle,var(--border) 1px,transparent 1.5px) 0 0/24px 24px}
352
413
  .legend{font-family:var(--mono);font-size:10.5px;letter-spacing:.06em;text-transform:uppercase}
@@ -390,9 +451,10 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
390
451
  <button data-v="graph" class="on">${ICON.graph} Graph</button>
391
452
  <button data-v="runs">${ICON.list} Runs</button>
392
453
  <button data-v="sessions">${ICON.layers} Sessions</button>
454
+ <button data-v="memory" id="nav-mem" style="display:none">${ICON.mem} Memory</button>
393
455
  <button data-v="stats">${ICON.chart} Stats</button>
394
456
  </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>
457
+ <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.10</span></div>
396
458
  </aside>
397
459
 
398
460
  <main>
@@ -444,6 +506,16 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
444
506
  <div class="card"><div id="sess-wrap"><div class="empty">Loading…</div></div></div>
445
507
  </div>
446
508
  </div>
509
+ <div id="view-memory" style="display:none">
510
+ <div class="page">
511
+ <div class="page-head"><h1>Memory</h1><span class="sub">LangGraph long-term store</span></div>
512
+ <div class="rtools">
513
+ <select id="mem-ns"></select>
514
+ <input type="search" id="mem-q" placeholder="Search this namespace…">
515
+ </div>
516
+ <div class="card"><div id="mem-wrap"><div class="empty">Loading…</div></div></div>
517
+ </div>
518
+ </div>
447
519
  <div id="view-stats" style="display:none">
448
520
  <div class="page">
449
521
  <div class="page-head"><h1>Stats</h1><span class="sub">all recorded runs</span></div>
@@ -458,6 +530,7 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
458
530
  <button data-v="graph" class="on">${ICON.graph}<span>Graph</span></button>
459
531
  <button data-v="runs">${ICON.list}<span>Runs</span></button>
460
532
  <button data-v="sessions">${ICON.layers}<span>Sessions</span></button>
533
+ <button data-v="memory" class="nav-mem" style="display:none">${ICON.mem}<span>Memory</span></button>
461
534
  <button data-v="stats">${ICON.chart}<span>Stats</span></button>
462
535
  </div>
463
536
 
@@ -481,6 +554,8 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
481
554
  <input id="run-session" placeholder="session (optional)" style="flex:1;min-width:130px">
482
555
  <input id="run-tags" placeholder="tags, comma-separated (optional)" style="flex:1;min-width:160px">
483
556
  </div>
557
+ <div class="hint" style="margin:8px 0 4px">Pause before (static breakpoints — resume from the run drawer):</div>
558
+ <div id="pause-chips" style="display:flex;flex-wrap:wrap;gap:6px"></div>
484
559
  <div class="actions">
485
560
  <button class="btn ghost" onclick="closeAll()">Cancel</button>
486
561
  <button class="btn primary" id="do-run">${ICON.play} Run</button>
@@ -497,6 +572,7 @@ const ICON = {
497
572
  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
573
  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
574
  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>',
575
+ 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
576
  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
577
  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
578
  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,20 +749,27 @@ function switchView(v){ VIEW=v;
673
749
  if(location.hash!=='#'+v) history.replaceState(null,'','#'+v);
674
750
  $$('.nav button,.bottom-nav button').forEach(b=>b.classList.toggle('on',b.dataset.v===v));
675
751
  $('#crumb').textContent=v[0].toUpperCase()+v.slice(1);
676
- for(const k of ['graph','runs','sessions','stats'])
752
+ for(const k of ['graph','runs','sessions','memory','stats'])
677
753
  $('#view-'+k).style.display=v===k?'':'none';
678
- if(v==='runs')loadRuns(); if(v==='sessions')loadSessions(); if(v==='stats'){loadStats();loadDatasets();}
754
+ if(v==='runs')loadRuns(); if(v==='sessions')loadSessions(); if(v==='memory')loadMemory();
755
+ if(v==='stats'){loadStats();loadDatasets();}
679
756
  if(cy&&v==='graph')setTimeout(()=>cy.resize(),40);
680
757
  }
681
758
  $$('.nav button,.bottom-nav button').forEach(b=>b.onclick=()=>switchView(b.dataset.v));
682
759
 
683
760
  /* ---------- new run ---------- */
684
761
  $('#newrun').onclick=async()=>{ let tpl={}; try{tpl=(await(await fetch('/api/schema')).json()).template||{};}catch(e){}
685
- $('#input-json').value=JSON.stringify(tpl,null,2); $('#scrim').classList.add('on'); $('#modal').classList.add('on'); };
762
+ $('#input-json').value=JSON.stringify(tpl,null,2);
763
+ const pc=document.getElementById('pause-chips');
764
+ if(pc&&cy) pc.innerHTML=cy.nodes().filter(n=>!n.data('terminal')).map(n=>
765
+ `<span class="tagchip pause-chip" data-n="${esc(n.id())}" onclick="this.classList.toggle('on')">${esc(n.id())}</span>`).join('');
766
+ $('#scrim').classList.add('on'); $('#modal').classList.add('on'); };
686
767
  $('#do-run').onclick=async()=>{ let input; try{input=JSON.parse($('#input-json').value||'{}');}catch(e){return toast('Invalid JSON input');}
687
768
  const sess=$('#run-session').value.trim();
688
769
  const tags=$('#run-tags').value.split(',').map(s=>s.trim()).filter(Boolean);
689
770
  if(sess)input._session=sess; if(tags.length)input._tags=tags;
771
+ const paused=[...document.querySelectorAll('.pause-chip.on')].map(e=>e.dataset.n);
772
+ if(paused.length) input._interrupt_before=paused;
690
773
  closeAll(); switchView('graph'); await runNow(input); };
691
774
 
692
775
  async function runNow(input){
@@ -700,6 +783,7 @@ async function runNow(input){
700
783
  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
784
  if(ev==='node'){ cy.$id(prev).addClass('done').removeClass('running'); cy.$id(d.node).addClass('running');
702
785
  cy.edges(`[source="${prev}"][target="${d.node}"]`).addClass('flow'); prev=d.node; }
786
+ else if(ev==='progress'){ toast('progress: '+JSON.stringify(d.data).slice(0,120)); }
703
787
  else if(ev==='interrupt'){ toast('Run paused at an interrupt — awaiting resume'); }
704
788
  else if(ev==='interrupted'){ cy.$id(prev).addClass('running'); toast('Run interrupted — see Runs for the payload'); }
705
789
  else if(ev==='done'){ cy.$id(prev).addClass('done').removeClass('running'); cy.$id('__end__').addClass('done'); toast('Run complete — saved to Runs'); }
@@ -804,6 +888,19 @@ async function openRun(id){
804
888
  <span class="chip node">${esc(s.name)}</span>
805
889
  ${Object.keys(s.output).map(k=>`<span class="tagchip" style="cursor:default">${esc(k)}</span>`).join('')}
806
890
  <span style="color:var(--muted);font-size:11px;margin-left:auto" class="mono">${s.dur_ms!=null?fmtms(s.dur_ms):''}</span></div>`).join('');
891
+ if(r.status==='interrupted'&&r.thread_id){
892
+ const ivs=r.spans.filter(s=>s.type==='interrupt'); const iv=ivs.find(s=>s.output!=null)||ivs.pop();
893
+ html += `<div class="kv" style="margin-top:14px"><div class="k" style="color:var(--warn)">interrupt — awaiting human</div>
894
+ ${iv&&iv.output!=null?`<pre>${esc(JSON.stringify(iv.output,null,2))}</pre>`:'<pre>paused at a breakpoint</pre>'}
895
+ <div class="scoreadd" style="margin-top:8px">
896
+ <input id="hitl-val" placeholder='resume value — JSON or text (blank = just continue)' style="flex:1;min-width:200px">
897
+ <button class="btn primary" onclick="resumeThread('${esc(r.thread_id)}')">Resume</button>
898
+ </div>
899
+ <div class="scoreadd">
900
+ <select id="hitl-goto"><option value="">goto node (optional)…</option></select>
901
+ <button class="btn ghost" onclick="resumeGoto('${esc(r.thread_id)}')">Send to node</button>
902
+ </div></div>`;
903
+ }
807
904
  html += `<div class="kv" style="margin-top:14px"><div class="k">scores</div></div>
808
905
  ${(r.scores||[]).map(sc=>`<div class="scorerow"><span class="scorechip">${esc(sc.name)} ${sc.value}</span>
809
906
  ${sc.comment?`<span style="color:var(--muted);font-size:12px">${esc(sc.comment)}</span>`:''}
@@ -820,6 +917,9 @@ async function openRun(id){
820
917
  $('#d-body').innerHTML=html;
821
918
  openIds.forEach(i=>{const e=document.getElementById(i); if(e){e.style.display='block';
822
919
  const row=e.previousElementSibling; row&&row.querySelector('.caret')?.classList.add('open');}});
920
+ const gsel=document.getElementById('hitl-goto');
921
+ if(gsel&&cy) gsel.innerHTML='<option value="">goto node (optional)…</option>'+
922
+ cy.nodes().filter(n=>!n.data('terminal')).map(n=>`<option>${esc(n.id())}</option>`).join('');
823
923
  $('#scrim').classList.add('on'); $('#drawer').classList.add('on');
824
924
  clearTimeout(window.__liveT);
825
925
  if(r.status==='running')
@@ -843,6 +943,7 @@ function renderSpans(list,byParent,total,depth){
843
943
  const kids=byParent[s.id]||[];
844
944
  const left=((s.offset_ms||0)/total*100).toFixed(2), width=Math.max(1.2,(s.dur_ms||0)/total*100).toFixed(2);
845
945
  const meta=[s.dur_ms!=null?s.dur_ms+'ms':null,
946
+ s.ttft_ms!=null?'ttft '+s.ttft_ms+'ms':null,
846
947
  s.type==='llm'&&(s.prompt_tokens!=null||s.completion_tokens!=null)?fmtk((s.prompt_tokens||0)+(s.completion_tokens||0))+'t':null,
847
948
  s.cost_usd!=null?'$'+s.cost_usd.toFixed(4):null].filter(Boolean).join(' · ');
848
949
  const did='sp'+s.id;
@@ -850,12 +951,12 @@ function renderSpans(list,byParent,total,depth){
850
951
  <div class="tr-row" onclick="tog('${did}',this)">
851
952
  <div class="tr-main"><span class="indent" style="width:${depth*18}px"></span>
852
953
  <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>
954
+ <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
955
  <div class="tr-gantt"><div class="tr-bar ${s.type}" style="left:${left}%;width:${width}%"></div></div>
855
956
  <div class="tr-meta">${meta}</div>
856
957
  </div>
857
958
  <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>`:''}
959
+ ${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
960
  ${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
961
  ${s.input!=null?kv('input',s.input):''}
861
962
  ${s.output!=null?kv('output',s.output):''}
@@ -887,6 +988,58 @@ window.showSpanSource=async(sid)=>{
887
988
  :'<pre>no source available for this node (external run, or inspect could not trace it)</pre>';
888
989
  };
889
990
 
991
+ /* ---------- human-in-the-loop ---------- */
992
+ async function consumeRunStream(res){
993
+ const rd=res.body.getReader(),dec=new TextDecoder(); let buf='';
994
+ while(true){ const {value,done}=await rd.read(); if(done)break; buf+=dec.decode(value,{stream:true});
995
+ let ps=buf.split('\n\n'); buf=ps.pop();
996
+ 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):{};
997
+ if(ev==='node'&&cy){ cy.$id(d.node).addClass('running'); }
998
+ else if(ev==='progress'){ toast('progress: '+JSON.stringify(d.data).slice(0,120)); }
999
+ else if(ev==='interrupted'){ toast('Paused again — awaiting human'); }
1000
+ else if(ev==='done'){ toast('Resumed run complete'); }
1001
+ else if(ev==='error'){ toast('Resume error: '+(d.message||'failed')); } } }
1002
+ if(VIEW==='runs') loadRuns();
1003
+ }
1004
+ function parseMaybeJSON(v){ if(v==='')return undefined;
1005
+ try{ return JSON.parse(v); }catch(e){ return v; } }
1006
+ window.resumeThread=async(tid)=>{
1007
+ const raw=document.getElementById('hitl-val').value.trim();
1008
+ const body={}; const v=parseMaybeJSON(raw); if(v!==undefined) body.value=v;
1009
+ closeAll(); switchView('graph'); toast('Resuming thread '+tid.slice(0,8)+'…');
1010
+ const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume',
1011
+ {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
1012
+ if(!res.ok){ toast((await res.json()).error||'resume failed'); return; }
1013
+ await consumeRunStream(res);
1014
+ };
1015
+ window.resumeGoto=async(tid)=>{
1016
+ const g=document.getElementById('hitl-goto').value; if(!g) return toast('Pick a node first');
1017
+ closeAll(); switchView('graph'); toast('Redirecting thread to '+g+'…');
1018
+ const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume',
1019
+ {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({goto:g})});
1020
+ if(!res.ok){ toast((await res.json()).error||'resume failed'); return; }
1021
+ await consumeRunStream(res);
1022
+ };
1023
+ window.rerunFrom=async(tid,cid)=>{
1024
+ closeAll(); switchView('graph'); toast('Forking from checkpoint '+String(cid).slice(-8)+'…');
1025
+ const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume',
1026
+ {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({checkpoint_id:cid})});
1027
+ if(!res.ok){ toast((await res.json()).error||'fork failed'); return; }
1028
+ await consumeRunStream(res);
1029
+ };
1030
+ window.editState=async(tid,cid,valuesJson)=>{
1031
+ const cur=window.prompt('Edit state values (JSON object) — applied via update_state:',valuesJson||'{}');
1032
+ if(cur==null) return;
1033
+ let values; try{ values=JSON.parse(cur); }catch(e){ return toast('Invalid JSON'); }
1034
+ const body={values}; if(cid) body.checkpoint_id=cid;
1035
+ const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/state',
1036
+ {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
1037
+ const d=await res.json();
1038
+ if(!res.ok) return toast(d.error||'update_state failed');
1039
+ toast('State updated — new checkpoint '+String(d.checkpoint_id||'').slice(-8)+'; Resume to continue');
1040
+ showHistory(tid);
1041
+ };
1042
+
890
1043
  /* ---------- time-travel (checkpoint history) ---------- */
891
1044
  window.showHistory=async(tid)=>{
892
1045
  let d=null; try{ const r=await fetch('/api/threads/'+encodeURIComponent(tid)+'/history');
@@ -910,6 +1063,10 @@ window.showHistory=async(tid)=>{
910
1063
  <div class="tr-detail" id="${did}">
911
1064
  ${s.writes!=null?kv('writes',s.writes):''}
912
1065
  ${kv('state at this checkpoint',s.values)}
1066
+ <div style="display:flex;gap:8px;margin:4px 0 10px">
1067
+ <button class="btn ghost" onclick='rerunFrom("${esc(tid)}","${esc(s.checkpoint_id||'')}")'>⑂ Re-run from here</button>
1068
+ <button class="btn ghost" onclick='editState("${esc(tid)}","${esc(s.checkpoint_id||'')}",${JSON.stringify(JSON.stringify(s.values??{}))})'>✎ Edit state</button>
1069
+ </div>
913
1070
  </div></div>`;
914
1071
  }).join('')||'<div class="empty">No checkpoints recorded for this thread</div>';
915
1072
  $('#scrim').classList.add('on'); $('#drawer').classList.add('on');
@@ -1009,6 +1166,37 @@ function fmtk(n){ return n<1000?String(n):(n/1000).toFixed(1)+'k'; }
1009
1166
  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
1167
  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
1168
 
1169
+ let MEM_NS=[];
1170
+ async function probeMemory(){
1171
+ try{ const r=await fetch('/api/memory/namespaces'); if(!r.ok) return;
1172
+ MEM_NS=(await r.json()).namespaces||[];
1173
+ document.getElementById('nav-mem').style.display='';
1174
+ const m=document.querySelector('.bottom-nav .nav-mem'); if(m)m.style.display='';
1175
+ }catch(e){}
1176
+ }
1177
+ async function loadMemory(){
1178
+ const sel=document.getElementById('mem-ns');
1179
+ try{ const r=await fetch('/api/memory/namespaces'); MEM_NS=(await r.json()).namespaces||[]; }catch(e){}
1180
+ const cur=sel.value;
1181
+ sel.innerHTML=MEM_NS.map(ns=>`<option value="${esc(ns.join('.'))}">${esc(ns.join(' / '))}</option>`).join('');
1182
+ if(cur) sel.value=cur;
1183
+ const ns=sel.value;
1184
+ const w=document.getElementById('mem-wrap');
1185
+ 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; }
1186
+ const q=document.getElementById('mem-q').value.trim();
1187
+ const p=new URLSearchParams({namespace:ns}); if(q)p.set('query',q);
1188
+ let d={items:[]}; try{ d=await(await fetch('/api/memory/items?'+p)).json(); }catch(e){}
1189
+ w.innerHTML=(d.items||[]).length?`<div style="overflow:auto"><table>
1190
+ <thead><tr><th>Key</th><th>Value</th><th class="r">Updated</th></tr></thead>
1191
+ <tbody>${d.items.map(i=>`<tr>
1192
+ <td class="mono" style="font-weight:600;white-space:nowrap">${esc(i.key)}</td>
1193
+ <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>
1194
+ <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>`
1195
+ :'<div class="empty">Nothing in this namespace'+(q?' matching that query':'')+'.</div>';
1196
+ }
1197
+ document.addEventListener('change',e=>{ if(e.target&&e.target.id==='mem-ns') loadMemory(); });
1198
+ let memQT; document.addEventListener('input',e=>{ if(e.target&&e.target.id==='mem-q'){ clearTimeout(memQT); memQT=setTimeout(loadMemory,300); } });
1199
+
1012
1200
  async function loadDatasets(){
1013
1201
  const w=document.getElementById('ds-wrap'); if(!w) return;
1014
1202
  let ds=[]; try{ ds=await(await fetch('/api/datasets')).json(); }catch(e){}
@@ -1027,7 +1215,7 @@ window.runDataset=async(id,name)=>{
1027
1215
  toast('Eval started: '+d.items+' items → session '+d.session);
1028
1216
  };
1029
1217
  window.delDataset=async(id)=>{ await fetch('/api/datasets/'+id,{method:'DELETE'}); loadDatasets(); };
1030
- loadGraph(); liveTopology();
1218
+ loadGraph(); liveTopology(); probeMemory();
1031
1219
  /* deep links: #runs #sessions #stats #run=<id> */
1032
1220
  (function(){ const h=location.hash.slice(1);
1033
1221
  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:
@@ -130,6 +137,7 @@ class SpanBuilder(BaseCallbackHandler):
130
137
  self._open: dict[str, dict] = {} # langchain run_id -> pending span
131
138
  self._span_of: dict[str, str] = {} # langchain run_id -> our span id (for parent links)
132
139
  self._seq = 0
140
+ self._interrupted = False
133
141
 
134
142
  # -- infra --
135
143
  def _emit(self, ev: dict) -> None:
@@ -142,7 +150,7 @@ class SpanBuilder(BaseCallbackHandler):
142
150
  return int((t - (self._t0 or t)) * 1000)
143
151
 
144
152
  def _finish_span(self, info: dict, *, output=None, status="ok", error=None,
145
- model=None, pt=None, ct=None):
153
+ model=None, pt=None, ct=None, usage_detail=None):
146
154
  now = time.time()
147
155
  sid = info["span_id"]
148
156
  self._emit({"kind": "span", "id": sid, "run_id": self.run_id,
@@ -153,7 +161,10 @@ class SpanBuilder(BaseCallbackHandler):
153
161
  "dur_ms": int((now - info["t"]) * 1000),
154
162
  "input": info.get("input"), "output": output, "model": model,
155
163
  "prompt_tokens": pt, "completion_tokens": ct,
156
- "cost_usd": cost_of(model, pt, ct), "error": error})
164
+ "cost_usd": cost_of(model, pt, ct), "error": error,
165
+ "retries": info.get("retries"),
166
+ "ttft_ms": int(info["ttft"] * 1000) if info.get("ttft") is not None else None,
167
+ "usage_detail": usage_detail})
157
168
  self._seq += 1
158
169
 
159
170
  # -- chains / nodes --
@@ -196,8 +207,11 @@ class SpanBuilder(BaseCallbackHandler):
196
207
  # LangGraph human-in-the-loop: a paused graph surfaces __interrupt__
197
208
  # in its final output — that's a pause awaiting Command(resume=...),
198
209
  # not a completion.
199
- interrupted = isinstance(outputs, dict) and "__interrupt__" in outputs
200
- if interrupted:
210
+ interrupted = self._interrupted or (
211
+ isinstance(outputs, dict) and "__interrupt__" in outputs)
212
+ if interrupted and not self._interrupted:
213
+ # marker not yet emitted by the GraphInterrupt path
214
+ self._interrupted = True
201
215
  self._finish_span(
202
216
  {"span_id": uuid.uuid4().hex[:12], "type": "interrupt",
203
217
  "name": "interrupt", "t": time.time(), "parent": None,
@@ -209,6 +223,32 @@ class SpanBuilder(BaseCallbackHandler):
209
223
 
210
224
  def on_chain_error(self, error, *, run_id=None, **kw):
211
225
  info = self._open.pop(run_id, None)
226
+ # LangGraph signals a human-in-the-loop pause by raising GraphInterrupt
227
+ # through the node — that's a pause, not a failure.
228
+ if type(error).__name__ in ("GraphInterrupt", "NodeInterrupt"):
229
+ payload = []
230
+ try:
231
+ for i in (error.args[0] if error.args else []):
232
+ payload.append(getattr(i, "value", str(i)))
233
+ except Exception:
234
+ pass
235
+ if info:
236
+ self._finish_span(info, status="interrupted",
237
+ output=_trunc(payload) if payload else None)
238
+ if not self._interrupted: # one marker span per pause
239
+ self._interrupted = True
240
+ now = time.time()
241
+ self._emit({"kind": "span", "id": uuid.uuid4().hex[:12],
242
+ "run_id": self.run_id, "parent_id": None,
243
+ "seq": self._seq, "type": "interrupt", "name": "interrupt",
244
+ "status": "ok", "started_ms": int(now * 1000),
245
+ "ended_ms": int(now * 1000), "offset_ms": self._rel(now),
246
+ "dur_ms": 0, "input": None,
247
+ "output": _trunc(payload) if payload else None,
248
+ "model": None, "prompt_tokens": None,
249
+ "completion_tokens": None, "cost_usd": None, "error": None})
250
+ self._seq += 1
251
+ return
212
252
  err = _err_text(error)
213
253
  if info:
214
254
  self._finish_span(info, status="error", error=err)
@@ -235,15 +275,41 @@ class SpanBuilder(BaseCallbackHandler):
235
275
  info = self._open.pop(run_id, None)
236
276
  if not info:
237
277
  return
238
- pt, ct = _usage(response)
278
+ pt, ct, detail = _usage(response)
239
279
  self._finish_span(info, output=_gen_text(response)[:4000],
240
- model=info["name"], pt=pt, ct=ct)
280
+ model=info["name"], pt=pt, ct=ct, usage_detail=detail)
241
281
 
242
282
  def on_llm_error(self, error, *, run_id=None, **kw):
243
283
  info = self._open.pop(run_id, None)
244
284
  if info:
245
285
  self._finish_span(info, status="error", error=_err_text(error), model=info["name"])
246
286
 
287
+ def on_llm_new_token(self, token, *, run_id=None, **kw):
288
+ # streaming: first token stamps time-to-first-token
289
+ info = self._open.get(run_id)
290
+ if info is not None and "ttft" not in info:
291
+ info["ttft"] = time.time() - info["t"]
292
+
293
+ # -- retries (tenacity, e.g. rate limits) --
294
+ def on_retry(self, retry_state, *, run_id=None, **kw):
295
+ info = self._open.get(run_id)
296
+ if info is not None:
297
+ info["retries"] = getattr(retry_state, "attempt_number", None) or \
298
+ (info.get("retries") or 0) + 1
299
+
300
+ # -- custom events (langchain dispatch_custom_event) --
301
+ def on_custom_event(self, name, data, *, run_id=None, **kw):
302
+ now = time.time()
303
+ self._emit({"kind": "span", "id": uuid.uuid4().hex[:12], "run_id": self.run_id,
304
+ "parent_id": self._span_of.get(run_id),
305
+ "seq": self._seq, "type": "event", "name": str(name), "status": "ok",
306
+ "started_ms": int(now * 1000), "ended_ms": int(now * 1000),
307
+ "offset_ms": self._rel(now), "dur_ms": 0,
308
+ "input": None, "output": _trunc(data), "model": None,
309
+ "prompt_tokens": None, "completion_tokens": None,
310
+ "cost_usd": None, "error": None})
311
+ self._seq += 1
312
+
247
313
  # -- retrievers (LangChain RAG) --
248
314
  def on_retriever_start(self, serialized, query, *, run_id=None, parent_run_id=None, **kw):
249
315
  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