sliceagent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. sliceagent/__init__.py +3 -0
  2. sliceagent/__main__.py +6 -0
  3. sliceagent/access.py +93 -0
  4. sliceagent/agents.py +173 -0
  5. sliceagent/background_review.py +146 -0
  6. sliceagent/binsniff.py +89 -0
  7. sliceagent/cli.py +890 -0
  8. sliceagent/clock.py +32 -0
  9. sliceagent/code_grep.py +329 -0
  10. sliceagent/code_index.py +417 -0
  11. sliceagent/config.py +240 -0
  12. sliceagent/context_overflow.py +227 -0
  13. sliceagent/envspec.py +129 -0
  14. sliceagent/errors.py +167 -0
  15. sliceagent/events.py +96 -0
  16. sliceagent/finding_types.py +70 -0
  17. sliceagent/flags.py +63 -0
  18. sliceagent/fuzzy.py +135 -0
  19. sliceagent/guardrails.py +438 -0
  20. sliceagent/guidance.py +69 -0
  21. sliceagent/hippocampus.py +581 -0
  22. sliceagent/hooks.py +334 -0
  23. sliceagent/interfaces.py +144 -0
  24. sliceagent/llm.py +695 -0
  25. sliceagent/loop.py +548 -0
  26. sliceagent/mcp_client.py +255 -0
  27. sliceagent/mcp_security.py +77 -0
  28. sliceagent/memory.py +428 -0
  29. sliceagent/metrics.py +103 -0
  30. sliceagent/model_catalog.py +124 -0
  31. sliceagent/monitor.py +615 -0
  32. sliceagent/neocortex.py +436 -0
  33. sliceagent/onboarding.py +323 -0
  34. sliceagent/oracle.py +36 -0
  35. sliceagent/pagetable.py +255 -0
  36. sliceagent/pfc.py +449 -0
  37. sliceagent/plugins.py +127 -0
  38. sliceagent/policy.py +234 -0
  39. sliceagent/procman.py +187 -0
  40. sliceagent/prompt.py +239 -0
  41. sliceagent/records.py +108 -0
  42. sliceagent/recovery.py +119 -0
  43. sliceagent/regions.py +678 -0
  44. sliceagent/registry.py +128 -0
  45. sliceagent/retriever.py +19 -0
  46. sliceagent/safety.py +332 -0
  47. sliceagent/sandbox.py +143 -0
  48. sliceagent/scheduler.py +92 -0
  49. sliceagent/search_index.py +289 -0
  50. sliceagent/seed.py +465 -0
  51. sliceagent/sensory_cortex.py +500 -0
  52. sliceagent/session.py +222 -0
  53. sliceagent/skill_provenance.py +71 -0
  54. sliceagent/skill_usage.py +123 -0
  55. sliceagent/skills.py +209 -0
  56. sliceagent/subagent.py +332 -0
  57. sliceagent/subdir_hints.py +222 -0
  58. sliceagent/swap.py +182 -0
  59. sliceagent/taskstate.py +57 -0
  60. sliceagent/telemetry.py +59 -0
  61. sliceagent/terminal.py +240 -0
  62. sliceagent/text_utils.py +56 -0
  63. sliceagent/tool_summary.py +93 -0
  64. sliceagent/tools.py +1194 -0
  65. sliceagent/tui.py +1377 -0
  66. sliceagent/web.py +354 -0
  67. sliceagent-0.1.0.dist-info/METADATA +262 -0
  68. sliceagent-0.1.0.dist-info/RECORD +71 -0
  69. sliceagent-0.1.0.dist-info/WHEEL +4 -0
  70. sliceagent-0.1.0.dist-info/entry_points.txt +2 -0
  71. sliceagent-0.1.0.dist-info/licenses/LICENSE +21 -0
sliceagent/monitor.py ADDED
@@ -0,0 +1,615 @@
1
+ """Active-memory-slice monitor — a web view of EXACTLY what the LLM sees each turn.
2
+
3
+ The slice core's only output is the event stream (events.py). This module adds one more sink:
4
+ it captures every SliceBuilt (the full [system, user] the model receives) plus what the model
5
+ did with it (assistant text, tool calls, tokens, stop reason), and serves it to a tiny stdlib
6
+ HTTP page. No new dependencies, no provider coupling, no touch to the loop — a sink failure is
7
+ already contained by make_dispatcher, so the monitor can never break a run.
8
+
9
+ Wire it in any host:
10
+ from sliceagent.monitor import start_monitor
11
+ monitor, sink, url = start_monitor(context_fn=lambda: {"goal": s.goal, "topic": s.active_id})
12
+ dispatch = make_dispatcher(..., sink) # add the sink alongside the others
13
+ print(url) # open in a browser
14
+
15
+ The store is task- and llm-agnostic: it shows whatever the slice is, for whatever model.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import threading
22
+ import time
23
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
24
+ from urllib.parse import parse_qs, urlparse
25
+
26
+ from .events import (
27
+ AssistantText,
28
+ Event,
29
+ SliceBuilt,
30
+ StepEnd,
31
+ ToolResult,
32
+ TurnEnd,
33
+ TurnInterrupted,
34
+ )
35
+
36
+ _MAX_OUTPUT = 6000 # cap a single tool output in the snapshot (the page stays snappy)
37
+ _MAX_ARGS = 4000 # cap a single tool-args blob
38
+
39
+ # Invariant 0 — bound the observer. The slice is bounded; its monitor must be too.
40
+ _RING_CAP = 40 # serve only the last N steps; counters stay accurate from full tallies (MON1)
41
+
42
+
43
+ def _clip(text: str, n: int) -> str:
44
+ return text if len(text) <= n else text[:n] + f"\n…[+{len(text) - n} chars]"
45
+
46
+
47
+ class SliceMonitor:
48
+ """Thread-safe capture of the per-step slice timeline. The loop (one thread) writes via the
49
+ sink; the HTTP server (another thread) reads via snapshot — both under one lock.
50
+
51
+ BOUNDED (Invariant 0 / MON1): the in-memory step ring is capped at `cap` (last N steps); cumulative
52
+ turn/step/token COUNTERS are kept from full tallies so the snapshot stays truthful while memory and
53
+ serialized size stay O(N). `i` is a monotonic step id (not a list index) so it remains stable across
54
+ trims and the UI can still select a step uniquely."""
55
+
56
+ def __init__(self, context_fn=None, cap: int = _RING_CAP):
57
+ self._ctx_fn = context_fn
58
+ self._cap = max(1, cap)
59
+ self._lock = threading.Lock()
60
+ self._steps: list[dict] = []
61
+ self._turn = 0
62
+ self._step_in_turn = 0
63
+ self._steps_total = 0 # full tally — survives ring trimming (MON1 counter)
64
+ self._tokens = 0 # full token tally — survives ring trimming (MON1 counter)
65
+ self._open = False # is a turn currently in progress?
66
+ self._cur: dict | None = None
67
+ self._version = 0 # bumps on ANY mutation, so the page re-renders live detail
68
+
69
+ def _ctx(self) -> dict:
70
+ if self._ctx_fn is None:
71
+ return {}
72
+ try:
73
+ return self._ctx_fn() or {}
74
+ except Exception:
75
+ return {}
76
+
77
+ def sink(self, e: Event) -> None:
78
+ with self._lock:
79
+ if isinstance(e, SliceBuilt):
80
+ if not self._open: # no TurnBegin event → first slice opens a turn
81
+ self._turn += 1
82
+ self._open = True
83
+ self._step_in_turn = 0
84
+ self._step_in_turn += 1
85
+ self._steps_total += 1 # monotonic full tally (survives ring trim)
86
+ msgs = e.messages or []
87
+ system = next((m.get("content", "") for m in msgs if m.get("role") == "system"), "")
88
+ user = next((m.get("content", "") for m in reversed(msgs) if m.get("role") == "user"),
89
+ e.rendered)
90
+ if isinstance(user, list): # multimodal turn (text+image parts) → use the rendered text
91
+ user = e.rendered # (mirrors loop.py) so detail render doesn't choke on a list
92
+ ctx = self._ctx()
93
+ self._cur = {
94
+ "i": self._steps_total - 1, "turn": self._turn, "step": self._step_in_turn,
95
+ "goal": ctx.get("goal", ""), "topic": ctx.get("topic", ""),
96
+ "system": system, "user": user, "assistant": "", "tools": [],
97
+ "usage": {}, "stop_reason": "", "interrupted": "",
98
+ }
99
+ self._steps.append(self._cur)
100
+ # MON1: trim past the high-water mark — keep only the last `cap` steps. The live step
101
+ # is always the last element, so trimming the front never drops `self._cur`.
102
+ if len(self._steps) > self._cap:
103
+ del self._steps[:len(self._steps) - self._cap]
104
+ elif isinstance(e, AssistantText):
105
+ if self._cur is not None:
106
+ self._cur["assistant"] += e.content
107
+ elif isinstance(e, ToolResult):
108
+ if self._cur is not None:
109
+ self._cur["tools"].append({
110
+ "name": e.name, "args": _clip(json.dumps(e.args, ensure_ascii=False), _MAX_ARGS),
111
+ "output": _clip(e.output, _MAX_OUTPUT), "failing": e.failing})
112
+ elif isinstance(e, StepEnd):
113
+ if self._cur is not None:
114
+ cu = self._cur.setdefault("usage", {}) # ACCUMULATE across steps (don't overwrite, else the
115
+ for _k, _v in (e.usage or {}).items(): # turn snapshot shows only the LAST step's usage)
116
+ if isinstance(_v, (int, float)):
117
+ cu[_k] = cu.get(_k, 0) + _v
118
+ self._cur["stop_reason"] = e.stop_reason
119
+ u = e.usage or {}
120
+ self._tokens += u.get("prompt_tokens", 0) + u.get("completion_tokens", 0)
121
+ elif isinstance(e, TurnEnd):
122
+ self._open = False
123
+ self._cur = None
124
+ elif isinstance(e, TurnInterrupted):
125
+ if self._cur is not None:
126
+ self._cur["interrupted"] = e.reason
127
+ self._open = False
128
+ self._cur = None
129
+ else:
130
+ return # event we don't track → no version bump
131
+ self._version += 1
132
+
133
+ def snapshot(self) -> dict:
134
+ with self._lock:
135
+ # MON1: tokens/turns/steps_total come from the FULL tallies, NOT the trimmed ring — so the
136
+ # counters stay accurate even though only the last `cap` steps are served.
137
+ # deep-copy nested MUTABLES (tools list, usage dict): json.dumps runs outside the lock,
138
+ # and the loop thread mutates the live step's tools list in place — a shallow dict(s) would
139
+ # share it and risk "list changed size during iteration" mid-poll.
140
+ steps = [{**s, "tools": [dict(t) for t in s["tools"]], "usage": dict(s["usage"])}
141
+ for s in self._steps]
142
+ return {"version": self._version, "turns": self._turn, "steps_total": self._steps_total,
143
+ "tokens": self._tokens, "steps": steps}
144
+
145
+
146
+ class _Handler(BaseHTTPRequestHandler):
147
+ def log_message(self, *args): # silence the default stderr access log
148
+ pass
149
+
150
+ def _send(self, body: bytes, ctype: str) -> None:
151
+ self.send_response(200)
152
+ self.send_header("Content-Type", ctype)
153
+ self.send_header("Content-Length", str(len(body)))
154
+ self.send_header("Cache-Control", "no-store")
155
+ self.end_headers()
156
+ try:
157
+ self.wfile.write(body)
158
+ except (BrokenPipeError, ConnectionResetError):
159
+ pass
160
+
161
+ def do_GET(self):
162
+ path = self.path.split("?", 1)[0]
163
+ if path == "/" or path == "/index.html":
164
+ self._send(PAGE.encode("utf-8"), "text/html; charset=utf-8")
165
+ elif path == "/api/state":
166
+ body = json.dumps(self.server.monitor.snapshot(), ensure_ascii=False).encode("utf-8")
167
+ self._send(body, "application/json; charset=utf-8")
168
+ else:
169
+ self.send_response(404)
170
+ self.end_headers()
171
+
172
+
173
+ def serve(monitor: SliceMonitor, host: str = "127.0.0.1", port: int = 7654):
174
+ """Start the HTTP server on the first free port at/after `port`. Returns (server, url)."""
175
+ last = None
176
+ for p in range(port, port + 10):
177
+ try:
178
+ srv = ThreadingHTTPServer((host, p), _Handler)
179
+ srv.monitor = monitor
180
+ srv.daemon_threads = True
181
+ t = threading.Thread(target=srv.serve_forever, daemon=True)
182
+ t.start()
183
+ return srv, f"http://{host}:{p}"
184
+ except OSError as exc: # port busy → try the next
185
+ last = exc
186
+ continue
187
+ raise RuntimeError(f"no free port in {port}..{port + 9}: {last}")
188
+
189
+
190
+ def start_monitor(context_fn=None, host: str = "127.0.0.1", port: int = 7654):
191
+ """Convenience: build a monitor, start its server, return (monitor, sink, url)."""
192
+ monitor = SliceMonitor(context_fn=context_fn)
193
+ _srv, url = serve(monitor, host, port)
194
+ return monitor, monitor.sink, url
195
+
196
+
197
+ # --- persistent monitor: a STANDING server decoupled from agent sessions --------------------
198
+ # A session writes its snapshot to <dir>/<session>.json each event; the standing server reads the
199
+ # dir and serves it, going idle when nothing's written recently. So the monitor survives sessions
200
+ # (it's a separate process) and shows whichever session is active — or idles — instead of dying.
201
+ IDLE_SECONDS = 12
202
+
203
+ # Invariant 0 — bound the observer on disk too.
204
+ _DEBOUNCE_MS = 250 # MON2: write at most this often on the hot path; settle points flush now
205
+ _SESSION_TTL_SECONDS = 24 * 3600 # MON3: prune session files older than this by mtime
206
+ _MAX_SESSION_FILES = 20 # MON3: and/or keep only the most-recent M (never the active one)
207
+
208
+
209
+ def _monitor_dir(d: str | None = None) -> str:
210
+ d = d or os.environ.get("SLICEAGENT_MONITOR_DIR") or \
211
+ os.path.join(os.path.expanduser("~"), ".sliceagent", "monitor")
212
+ os.makedirs(d, exist_ok=True)
213
+ return d
214
+
215
+
216
+ class _SnapshotWriter:
217
+ """MON2: decouple json.dump+os.replace from the loop hot path.
218
+
219
+ A single background daemon thread owns ALL disk I/O. The sink (loop thread) only publishes the
220
+ latest snapshot into a single-slot coalescing ref and signals an Event — it never blocks on disk.
221
+ The writer coalesces: if 5 events arrive while one write is in flight, only the LAST snapshot is
222
+ written (no per-event O(N) writes). Debounced to at most one write per `_DEBOUNCE_MS`, except a
223
+ `flush` (StepEnd/TurnEnd) forces an immediate write so settle points land promptly. Atomic
224
+ (tmp + os.replace) and fully failure-contained — a monitor write can never break the loop."""
225
+
226
+ def __init__(self, path: str, debounce_ms: int = _DEBOUNCE_MS):
227
+ self._path = path
228
+ self._debounce = debounce_ms / 1000.0
229
+ self._lock = threading.Lock()
230
+ self._pending: dict | None = None # single-slot: only the freshest snapshot survives
231
+ self._flush = False # the pending snapshot must be written immediately
232
+ self._busy = False # a write is currently in flight on the writer thread
233
+ self._wake = threading.Event()
234
+ self._stop = False
235
+ self._thread = threading.Thread(target=self._run, daemon=True)
236
+ self._thread.start()
237
+
238
+ def submit(self, snap: dict, *, flush: bool = False) -> None:
239
+ with self._lock:
240
+ self._pending = snap # coalesce — drop any older un-written snapshot
241
+ if flush:
242
+ self._flush = True
243
+ self._wake.set()
244
+
245
+ def _run(self) -> None:
246
+ while True:
247
+ self._wake.wait()
248
+ if self._stop and self._pending is None:
249
+ return
250
+ with self._lock:
251
+ snap, flush = self._pending, self._flush
252
+ self._pending, self._flush = None, False
253
+ self._busy = snap is not None
254
+ self._wake.clear()
255
+ if snap is not None:
256
+ self._write(snap)
257
+ with self._lock:
258
+ self._busy = False
259
+ if self._stop and self._pending is None:
260
+ return # flushed the last pending after close() → exit now; the wake edge was consumed,
261
+ # so looping back to wake.wait() would park the thread forever (flush-then-stop).
262
+ # debounce: pause so a burst of hot-path events coalesces into the NEXT single write.
263
+ # A flush skips the pause so settle points (StepEnd/TurnEnd) land without added latency.
264
+ if not flush and not self._stop:
265
+ time.sleep(self._debounce)
266
+
267
+ def _write(self, snap: dict) -> None:
268
+ try:
269
+ tmp = self._path + ".tmp"
270
+ with open(tmp, "w", encoding="utf-8") as f:
271
+ json.dump(snap, f, ensure_ascii=False)
272
+ os.replace(tmp, self._path)
273
+ except Exception:
274
+ pass
275
+
276
+ def drain(self, timeout: float = 2.0) -> bool:
277
+ """Block until the single pending slot has been written (best-effort, for tests/shutdown).
278
+ Returns True if drained within `timeout`. Never used on the loop hot path."""
279
+ deadline = time.time() + timeout
280
+ while time.time() < deadline:
281
+ with self._lock:
282
+ if self._pending is None and not self._busy:
283
+ return True
284
+ time.sleep(0.005)
285
+ with self._lock:
286
+ return self._pending is None and not self._busy
287
+
288
+ def close(self) -> None:
289
+ with self._lock:
290
+ self._stop = True
291
+ self._wake.set()
292
+
293
+
294
+ def make_file_monitor_sink(session_id: str, context_fn=None, dir: str | None = None):
295
+ """A monitor sink that PERSISTS the snapshot to <dir>/<session>.json, so a standing server
296
+ (python -m sliceagent.monitor) can show it — decoupled from this process.
297
+
298
+ MON2: disk I/O runs on a background daemon thread, never on the loop hot path; per-event writes
299
+ coalesce and debounce, with an immediate flush on StepEnd/TurnEnd. MON1 keeps the snapshot O(N).
300
+ Writes are atomic and failure-contained (a monitor write must never break the loop)."""
301
+ monitor = SliceMonitor(context_fn=context_fn)
302
+ path = os.path.join(_monitor_dir(dir), f"{session_id}.json")
303
+ inner = monitor.sink
304
+ writer = _SnapshotWriter(path)
305
+ _settle = (StepEnd, TurnEnd, TurnInterrupted) # the points worth a guaranteed prompt write
306
+
307
+ def sink(event: Event) -> None:
308
+ inner(event)
309
+ try:
310
+ snap = monitor.snapshot()
311
+ snap["session"] = session_id
312
+ writer.submit(snap, flush=isinstance(event, _settle))
313
+ except Exception:
314
+ pass
315
+ sink.writer = writer # expose for explicit close()/inspection; never required for correctness
316
+ return sink
317
+
318
+
319
+ def _session_files(d: str):
320
+ out = []
321
+ try:
322
+ for fn in os.listdir(d):
323
+ if fn.endswith(".json"):
324
+ try:
325
+ out.append((fn[:-5], os.path.getmtime(os.path.join(d, fn))))
326
+ except OSError:
327
+ continue # a file vanished mid-enumeration → skip it, don't truncate the session list
328
+ except OSError:
329
+ pass
330
+ return sorted(out, key=lambda x: x[1], reverse=True) # most-recently-active first
331
+
332
+
333
+ def _prune_sessions(d: str, ttl: float = _SESSION_TTL_SECONDS, keep: int = _MAX_SESSION_FILES):
334
+ """MON3: drop stale session files (older than `ttl` by mtime) and/or all but the most-recent
335
+ `keep`, then return the surviving list (most-recently-active first). NEVER deletes the freshest
336
+ file — even if it's stale and over the cap — so the active/most-recent session is always served.
337
+ Failure-contained: a delete error just leaves the file in place."""
338
+ files = _session_files(d)
339
+ if not files:
340
+ return files
341
+ now = time.time()
342
+ survivors = []
343
+ for idx, (sid, mtime) in enumerate(files):
344
+ too_old = (now - mtime) > ttl
345
+ over_cap = idx >= keep
346
+ if idx > 0 and (too_old or over_cap): # idx 0 is the freshest/active — always keep it
347
+ try:
348
+ os.remove(os.path.join(d, f"{sid}.json"))
349
+ continue
350
+ except OSError:
351
+ pass # couldn't delete → keep serving it
352
+ survivors.append((sid, mtime))
353
+ return survivors
354
+
355
+
356
+ class _PersistentHandler(_Handler):
357
+ """Serves the page + /api/state read from the monitor dir (freshest or ?session=), with an idle
358
+ flag and a session list — so one standing server reflects whatever session is running, or idles."""
359
+
360
+ def do_GET(self):
361
+ u = urlparse(self.path)
362
+ if u.path in ("/", "/index.html"):
363
+ self._send(PAGE.encode("utf-8"), "text/html; charset=utf-8")
364
+ elif u.path == "/api/state":
365
+ sel = (parse_qs(u.query).get("session") or [None])[0]
366
+ self._send(json.dumps(self._state(sel), ensure_ascii=False).encode("utf-8"),
367
+ "application/json; charset=utf-8")
368
+ else:
369
+ self.send_response(404)
370
+ self.end_headers()
371
+
372
+ def _state(self, sel):
373
+ d = self.server.monitor_dir
374
+ files = _prune_sessions(d) # MON3: drop stale/over-cap files here (never the freshest/active)
375
+ base = {"version": -1, "turns": 0, "steps_total": 0, "tokens": 0, "steps": []}
376
+ if not files:
377
+ return {**base, "idle": True, "session": None, "sessions": []}
378
+ sessions = [s for s, _ in files]
379
+ chosen = sel if sel in sessions else sessions[0] # default = most-recently-active
380
+ path = os.path.join(d, f"{chosen}.json")
381
+ try:
382
+ with open(path, encoding="utf-8") as fh:
383
+ snap = json.load(fh)
384
+ if not isinstance(snap, dict): # a valid but non-dict JSON (list/number) would crash snap[...]=
385
+ snap = dict(base)
386
+ age = time.time() - os.path.getmtime(path)
387
+ except Exception:
388
+ snap, age = dict(base), IDLE_SECONDS + 1
389
+ snap["idle"] = age > IDLE_SECONDS
390
+ snap["age"] = round(age, 1)
391
+ snap["session"] = chosen
392
+ snap["sessions"] = sessions
393
+ return snap
394
+
395
+
396
+ def main():
397
+ """`python -m sliceagent.monitor` — the standing, persistent monitor server. Stays up across
398
+ agent sessions; idle when none is running; populated when a session runs with AGENT_MONITOR=1."""
399
+ d = _monitor_dir()
400
+ _prune_sessions(d) # MON3: clear stale/over-cap session files on startup (keeps the freshest)
401
+ host = "127.0.0.1"
402
+ port = int(os.environ.get("AGENT_MONITOR_PORT", "7654"))
403
+ last = None
404
+ for p in range(port, port + 10):
405
+ try:
406
+ srv = ThreadingHTTPServer((host, p), _PersistentHandler)
407
+ srv.monitor_dir = d
408
+ srv.daemon_threads = True
409
+ print(f"sliceagent monitor (persistent) → http://{host}:{p} · watching {d}", flush=True)
410
+ print("idle until a session runs with AGENT_MONITOR=1; Ctrl-C to stop.", flush=True)
411
+ srv.serve_forever()
412
+ return
413
+ except OSError as exc:
414
+ last = exc
415
+ raise RuntimeError(f"no free port in {port}..{port + 9}: {last}")
416
+
417
+
418
+ PAGE = r"""<!doctype html>
419
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
420
+ <title>sliceagent · active memory slice monitor</title>
421
+ <style>
422
+ :root{
423
+ --bg:#0d1117; --panel:#161b22; --panel2:#10151c; --border:#30363d; --fg:#e6edf3;
424
+ --muted:#8b949e; --accent:#58a6ff; --green:#3fb950; --amber:#d29922; --red:#f85149;
425
+ --purple:#bc8cff; --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
426
+ }
427
+ *{box-sizing:border-box}
428
+ body{margin:0;background:var(--bg);color:var(--fg);font:13px/1.5 var(--mono)}
429
+ header{display:flex;align-items:center;gap:16px;padding:10px 16px;background:var(--panel);
430
+ border-bottom:1px solid var(--border);position:sticky;top:0;z-index:10}
431
+ header h1{font-size:14px;margin:0;font-weight:600;letter-spacing:.2px}
432
+ header .dot{width:8px;height:8px;border-radius:50%;background:var(--green);
433
+ box-shadow:0 0 6px var(--green);display:inline-block;margin-right:6px}
434
+ header .dot.stale{background:var(--muted);box-shadow:none}
435
+ header .dot.idle{background:var(--amber);box-shadow:0 0 6px var(--amber)}
436
+ .stats{display:flex;gap:14px;color:var(--muted);font-size:12px;margin-left:auto;align-items:center}
437
+ .stats b{color:var(--fg);font-weight:600}
438
+ label.follow{display:flex;align-items:center;gap:5px;cursor:pointer;color:var(--muted)}
439
+ main{display:flex;height:calc(100vh - 45px)}
440
+ nav{width:280px;min-width:280px;overflow:auto;border-right:1px solid var(--border);background:var(--panel2)}
441
+ .turn{border-bottom:1px solid var(--border)}
442
+ .turn-h{padding:7px 12px;color:var(--muted);font-size:11px;text-transform:uppercase;
443
+ letter-spacing:.6px;position:sticky;top:0;background:var(--panel2)}
444
+ .turn-h .g{color:var(--fg);text-transform:none;letter-spacing:0;font-size:12px;display:block;margin-top:2px}
445
+ .step{padding:7px 12px 7px 22px;cursor:pointer;border-left:3px solid transparent;display:flex;
446
+ gap:8px;align-items:baseline}
447
+ .step:hover{background:#1b222c}
448
+ .step.sel{background:#1f6feb22;border-left-color:var(--accent)}
449
+ .step .n{color:var(--accent);font-weight:600}
450
+ .step .meta{color:var(--muted);font-size:11px;margin-left:auto}
451
+ .step .pill{font-size:10px;padding:0 5px;border-radius:8px;border:1px solid var(--border);color:var(--muted)}
452
+ .step .pill.tool{color:var(--purple);border-color:#3a3050}
453
+ .step .pill.end{color:var(--green);border-color:#1f3a26}
454
+ .step .pill.err{color:var(--red);border-color:#3a1f1f}
455
+ section.detail{flex:1;overflow:auto;padding:16px 20px}
456
+ .empty{color:var(--muted);text-align:center;margin-top:80px}
457
+ .dmeta{color:var(--muted);margin-bottom:12px;font-size:12px}
458
+ .dmeta b{color:var(--fg)}
459
+ .block{margin-bottom:16px;border:1px solid var(--border);border-radius:8px;overflow:hidden;background:var(--panel)}
460
+ .block>.bh{padding:7px 12px;background:#11161d;border-bottom:1px solid var(--border);cursor:pointer;
461
+ display:flex;align-items:center;gap:8px;font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted)}
462
+ .block>.bh .c{margin-left:auto;color:var(--muted);font-size:11px;text-transform:none}
463
+ .block>.body{padding:0}
464
+ .block.collapsed>.body{display:none}
465
+ .tier{border-top:1px dashed var(--border)}
466
+ .tier:first-child{border-top:none}
467
+ .tier-h{padding:6px 12px;color:var(--accent);font-weight:600;font-size:12px;background:#0f141b}
468
+ .tier-h.err{color:var(--red)} .tier-h.thr{color:var(--amber)} .tier-h.est{color:var(--green)}
469
+ .tier-h.mem{color:var(--purple)} .tier-h.now{color:var(--fg)}
470
+ pre{margin:0;padding:8px 12px;white-space:pre-wrap;word-break:break-word;font:12px/1.5 var(--mono);color:#cdd9e5}
471
+ .sys pre{color:#9fb0c3}
472
+ .tool{border-top:1px solid var(--border)}
473
+ .tool-h{padding:6px 12px;display:flex;gap:8px;align-items:center;background:#0f141b}
474
+ .tool-h .tn{color:var(--purple);font-weight:600}
475
+ .tool-h .fail{color:var(--red);font-size:11px}
476
+ .asst pre{color:#e6edf3}
477
+ .hint{color:var(--muted);font-size:11px;padding:6px 12px}
478
+ </style></head>
479
+ <body>
480
+ <header>
481
+ <h1><span class="dot" id="dot"></span>sliceagent · active memory slice</h1>
482
+ <span id="status" style="font-size:11px;color:var(--muted)">connecting…</span>
483
+ <div class="stats">
484
+ <select id="sess" title="session" style="background:var(--panel2);color:var(--fg);border:1px solid var(--border);border-radius:5px;padding:2px 6px;font:11px var(--mono)"></select>
485
+ <span>turns <b id="s-turns">0</b></span>
486
+ <span>steps <b id="s-steps">0</b></span>
487
+ <span>tokens <b id="s-tok">0</b></span>
488
+ <label class="follow"><input type="checkbox" id="follow" checked> follow latest</label>
489
+ </div>
490
+ </header>
491
+ <main>
492
+ <nav id="nav"></nav>
493
+ <section class="detail" id="detail"><div class="empty">waiting for the first turn…<br>run a task in the agent.</div></section>
494
+ </main>
495
+ <script>
496
+ let STATE={steps:[],version:-1}, SEL=null, LASTVER=-1, PICK="";
497
+ document.addEventListener("change",e=>{ if(e.target.id==="sess"){ PICK=e.target.value; LASTVER=-1; SEL=null; } });
498
+ const $=id=>document.getElementById(id);
499
+ const esc=s=>(s||"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
500
+
501
+ function tierClass(h){
502
+ h=h.toUpperCase();
503
+ if(h.includes("CURRENT ERROR"))return"err";
504
+ if(h.includes("OTHER OPEN THREADS"))return"thr";
505
+ if(h.includes("YOUR NOTES")||h.includes("ESTABLISHED")||h.includes("ACTIVE SKILL"))return"est";
506
+ if(h.includes("MEMORY"))return"mem";
507
+ if(h.startsWith("# NOW"))return"now";
508
+ return"";
509
+ }
510
+ // split the user slice into tier cards by leading "# HEADER" lines
511
+ function renderSlice(text){
512
+ const lines=(text||"").split("\n"); let tiers=[],cur=null;
513
+ for(const ln of lines){
514
+ if(/^#\s+\S/.test(ln)){ cur={h:ln,body:[]}; tiers.push(cur); }
515
+ else{ if(!cur){cur={h:"",body:[]};tiers.push(cur);} cur.body.push(ln); }
516
+ }
517
+ return tiers.map(t=>{
518
+ const head=t.h?`<div class="tier-h ${tierClass(t.h)}">${esc(t.h)}</div>`:"";
519
+ const body=t.body.join("\n").replace(/^\n+|\n+$/g,"");
520
+ return `<div class="tier">${head}${body?`<pre>${esc(body)}</pre>`:""}</div>`;
521
+ }).join("");
522
+ }
523
+ function block(title,inner,extra,collapsed){
524
+ const id="b"+Math.random().toString(36).slice(2,8);
525
+ return `<div class="block ${collapsed?'collapsed':''}" id="${id}">
526
+ <div class="bh" onclick="document.getElementById('${id}').classList.toggle('collapsed')">
527
+ <span>${title}</span><span class="c">${extra||""}</span></div>
528
+ <div class="body">${inner}</div></div>`;
529
+ }
530
+ function renderDetail(s){
531
+ if(!s){$("detail").innerHTML='<div class="empty">select a step</div>';return;}
532
+ const u=s.usage||{}, tok=(u.prompt_tokens||0)+(u.completion_tokens||0);
533
+ const stop=s.interrupted?`interrupted: ${s.interrupted}`:(s.stop_reason||"…");
534
+ let h=`<div class="dmeta">turn <b>${s.turn}</b> · step <b>${s.step}</b> ·
535
+ topic <b>${esc(s.topic||"—")}</b> · goal <b>${esc(s.goal||"—")}</b><br>
536
+ stop <b>${esc(stop)}</b> · tokens <b>${tok}</b>
537
+ (prompt ${u.prompt_tokens||0} / completion ${u.completion_tokens||0})</div>`;
538
+ h+=block("⟶ ACTIVE MEMORY SLICE (user message — what the model reads)",
539
+ renderSlice(s.user), s.user.length+" chars", false);
540
+ h+=block("SYSTEM PROMPT (instructions + task)",
541
+ `<div class="sys"><pre>${esc(s.system)}</pre></div>`, s.system.length+" chars", true);
542
+ let did="";
543
+ if(s.assistant) did+=`<div class="asst"><div class="hint">assistant text</div><pre>${esc(s.assistant)}</pre></div>`;
544
+ for(const t of (s.tools||[])){
545
+ did+=`<div class="tool"><div class="tool-h"><span class="tn">${esc(t.name)}</span>
546
+ <span class="hint">${esc(t.args)}</span>${t.failing?'<span class="fail">● failed</span>':''}</div>
547
+ <pre>${esc(t.output)}</pre></div>`;
548
+ }
549
+ if(!did) did='<div class="hint">no model output captured for this step yet…</div>';
550
+ h+=block("⟵ WHAT THE MODEL DID (this step)", did, (s.tools||[]).length+" tool call(s)", false);
551
+ $("detail").innerHTML=h;
552
+ }
553
+ function renderNav(){
554
+ const byTurn={};
555
+ for(const s of STATE.steps){ (byTurn[s.turn]=byTurn[s.turn]||[]).push(s); }
556
+ let h="";
557
+ for(const tn of Object.keys(byTurn).sort((a,b)=>a-b)){
558
+ const steps=byTurn[tn], g=steps[0].goal||"";
559
+ h+=`<div class="turn"><div class="turn-h">turn ${tn}<span class="g">${esc(g.slice(0,70))}</span></div>`;
560
+ for(const s of steps){
561
+ const u=s.usage||{}, tok=(u.prompt_tokens||0)+(u.completion_tokens||0);
562
+ let pill="", cls="";
563
+ if(s.interrupted){pill=s.interrupted;cls="err";}
564
+ else if(s.stop_reason==="tool_use"){pill=(s.tools||[]).length+"⚒";cls="tool";}
565
+ else if(s.stop_reason==="end_turn"){pill="done";cls="end";}
566
+ else if(s.stop_reason){pill=s.stop_reason;cls="";}
567
+ h+=`<div class="step ${SEL===s.i?'sel':''}" data-i="${s.i}">
568
+ <span class="n">${s.step}</span>
569
+ <span class="pill ${cls}">${esc(pill||"…")}</span>
570
+ <span class="meta">${tok||""}</span></div>`;
571
+ }
572
+ h+="</div>";
573
+ }
574
+ $("nav").innerHTML=h;
575
+ for(const el of document.querySelectorAll(".step")){
576
+ el.onclick=()=>{ SEL=+el.dataset.i; renderNav(); renderDetail(stepById(SEL)); };
577
+ }
578
+ }
579
+ // `i` is a MONOTONIC step id (stable across the capped ring), NOT an array index — look it up.
580
+ function stepById(id){ return (STATE.steps||[]).find(s=>s.i===id) || null; }
581
+ function syncSessions(d){
582
+ const sel=$("sess"); if(!sel) return;
583
+ const list=d.sessions||[];
584
+ sel.style.display = list.length>1 ? "" : "none";
585
+ const cur=list.join("|");
586
+ if(sel.dataset.list!==cur){ sel.dataset.list=cur; sel.innerHTML=list.map(s=>`<option value="${s}">${s}</option>`).join(""); }
587
+ if(d.session) sel.value=d.session;
588
+ }
589
+ async function poll(){
590
+ try{
591
+ const url="/api/state"+(PICK?("?session="+encodeURIComponent(PICK)):"");
592
+ const d=await(await fetch(url)).json();
593
+ const dot=$("dot"); dot.classList.remove("stale","idle");
594
+ if(d.session===null){ dot.classList.add("idle"); $("status").textContent="idle — no sessions yet (run with AGENT_MONITOR=1)"; }
595
+ else if(d.idle){ dot.classList.add("idle"); $("status").textContent=`idle · ${d.session} (last activity ${d.age}s ago)`; }
596
+ else { $("status").textContent=`live · ${d.session}`; }
597
+ syncSessions(d);
598
+ $("s-turns").textContent=d.turns; $("s-steps").textContent=d.steps_total;
599
+ $("s-tok").textContent=(d.tokens||0).toLocaleString();
600
+ if(d.version!==LASTVER){
601
+ LASTVER=d.version; STATE=d;
602
+ if($("follow").checked && d.steps.length) SEL=d.steps[d.steps.length-1].i;
603
+ renderNav();
604
+ renderDetail(SEL!=null?stepById(SEL):null);
605
+ }
606
+ }catch(e){ $("dot").classList.add("stale"); $("status").textContent="disconnected"; }
607
+ }
608
+ setInterval(poll,1000); poll();
609
+ </script>
610
+ </body></html>
611
+ """
612
+
613
+
614
+ if __name__ == "__main__":
615
+ main()