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.
- sliceagent/__init__.py +3 -0
- sliceagent/__main__.py +6 -0
- sliceagent/access.py +93 -0
- sliceagent/agents.py +173 -0
- sliceagent/background_review.py +146 -0
- sliceagent/binsniff.py +89 -0
- sliceagent/cli.py +890 -0
- sliceagent/clock.py +32 -0
- sliceagent/code_grep.py +329 -0
- sliceagent/code_index.py +417 -0
- sliceagent/config.py +240 -0
- sliceagent/context_overflow.py +227 -0
- sliceagent/envspec.py +129 -0
- sliceagent/errors.py +167 -0
- sliceagent/events.py +96 -0
- sliceagent/finding_types.py +70 -0
- sliceagent/flags.py +63 -0
- sliceagent/fuzzy.py +135 -0
- sliceagent/guardrails.py +438 -0
- sliceagent/guidance.py +69 -0
- sliceagent/hippocampus.py +581 -0
- sliceagent/hooks.py +334 -0
- sliceagent/interfaces.py +144 -0
- sliceagent/llm.py +695 -0
- sliceagent/loop.py +548 -0
- sliceagent/mcp_client.py +255 -0
- sliceagent/mcp_security.py +77 -0
- sliceagent/memory.py +428 -0
- sliceagent/metrics.py +103 -0
- sliceagent/model_catalog.py +124 -0
- sliceagent/monitor.py +615 -0
- sliceagent/neocortex.py +436 -0
- sliceagent/onboarding.py +323 -0
- sliceagent/oracle.py +36 -0
- sliceagent/pagetable.py +255 -0
- sliceagent/pfc.py +449 -0
- sliceagent/plugins.py +127 -0
- sliceagent/policy.py +234 -0
- sliceagent/procman.py +187 -0
- sliceagent/prompt.py +239 -0
- sliceagent/records.py +108 -0
- sliceagent/recovery.py +119 -0
- sliceagent/regions.py +678 -0
- sliceagent/registry.py +128 -0
- sliceagent/retriever.py +19 -0
- sliceagent/safety.py +332 -0
- sliceagent/sandbox.py +143 -0
- sliceagent/scheduler.py +92 -0
- sliceagent/search_index.py +289 -0
- sliceagent/seed.py +465 -0
- sliceagent/sensory_cortex.py +500 -0
- sliceagent/session.py +222 -0
- sliceagent/skill_provenance.py +71 -0
- sliceagent/skill_usage.py +123 -0
- sliceagent/skills.py +209 -0
- sliceagent/subagent.py +332 -0
- sliceagent/subdir_hints.py +222 -0
- sliceagent/swap.py +182 -0
- sliceagent/taskstate.py +57 -0
- sliceagent/telemetry.py +59 -0
- sliceagent/terminal.py +240 -0
- sliceagent/text_utils.py +56 -0
- sliceagent/tool_summary.py +93 -0
- sliceagent/tools.py +1194 -0
- sliceagent/tui.py +1377 -0
- sliceagent/web.py +354 -0
- sliceagent-0.1.0.dist-info/METADATA +262 -0
- sliceagent-0.1.0.dist-info/RECORD +71 -0
- sliceagent-0.1.0.dist-info/WHEEL +4 -0
- sliceagent-0.1.0.dist-info/entry_points.txt +2 -0
- 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=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[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()
|