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/swap.py ADDED
@@ -0,0 +1,182 @@
1
+ """SwapManager — single owner of the working-set PAGE lifecycle (file/dep/skill/ghost/reviewed); every page enters/
2
+ leaves the slice THROUGH here. The memory plane of a DEMAND-PAGED SNAPSHOT MACHINE: the slice is a CACHE, not a log,
3
+ so eviction is always safe (a re-fault re-reads from the durable store). DUCK-TYPED: imports nothing from pfc.py
4
+ (reverse import circular — pfc.py imports the bounds below); only prefetch() reaches
5
+ self.retriever. SELF-TUNING (automatic, no model): a re-read of a file still in the recency ring (a REFAULT) proves
6
+ the budget was momentarily too tight, so the kernel grants ITSELF a brief reclaim-protection (Linux mm/workingset
7
+ refault detection, scaled down) AND widens its OWN read budget one notch (s.read_budget, bounded by s.read_ceiling)
8
+ — so the working set grows to TASK need, not to a fixed ceiling. hit/miss/refault/evict are counted (s.io) so the
9
+ moat is MEASURED, not asserted."""
10
+ from __future__ import annotations
11
+
12
+ READ_BUDGET = 4 # FLOOR for the exploratory-read residue — the lean DEFAULT, NOT a hard cap. The kernel GROWS the
13
+ # live budget (s.read_budget) on refault thrash up to READ_BUDGET_MAX. "Bounded" = no PASSIVE/
14
+ # history-proportional growth (Markov current-state), never a fixed size ceiling. SINGLE owner
15
+ # of this and every other bound below (pfc.py/seed.py import directly, no re-export chain).
16
+ READ_BUDGET_MAX = 16 # per-slice DISASTER CEILING for refault-driven growth. A single COHERENT task rarely needs more
17
+ # resident reads than this; genuine BREADTH ("review the repo") is delegated to the subagent SWARM
18
+ # (each child a fresh lean slice), NOT served by inflating one slice toward the context window
19
+ # (that invites context-rot / lost-in-the-middle — see auto-memory: kernel-architecture).
20
+ # bound ≠ size: the change set + dependency closure are RELEVANCE sets, kept resident IN FULL (evict no
21
+ # longer truncates them — see evict/prefetch). These two constants are now PHYSICAL fan-in backstops
22
+ # (guard against a pathological 100s-of-callers symbol), generous and well above any coherent change —
23
+ # NOT relevance caps. swap_tools.py reads them for the occupancy display; the real overflow handler is
24
+ # the physical-context tighten ladder, not these.
25
+ EDIT_CEILING = 32 # physical backstop on the change set (a coherent change is far smaller); not a relevance cap
26
+ DEP_CEILING = 64 # per-symbol caller fan-out backstop — keep ALL direct callers that break on the change
27
+ # (caller-first ranked in code_index.deps); generous physical guard, not a relevance cap.
28
+ MAX_GHOSTS = 6 # GHOST INDEX ring — pointers to recently paged-out files/skills (also the refault recency window)
29
+ MAX_ACTIVE_SKILLS = 2 # keep only the most-recently-loaded skills active
30
+ MAX_SKILL_CHARS = 12000 # a loaded skill body before it enters the slice; bounded by COUNT (MAX_ACTIVE_SKILLS=2),
31
+ # so this is generous — a half-read skill (helpers/examples/patterns in the tail lost) misleads more than it costs
32
+ MAX_REVIEWED = 8 # bounded ring of history lookbacks done (the recall_history ratchet)
33
+ HOT_TTL = 3 # steps a REFAULT-promoted file stays kernel-protected (self-tuning; not the model)
34
+ HOT_CEILING = 4 # bound the kernel-granted soft-pin set — never an accumulating tier (decoupled from
35
+ # DEP_CEILING so raising the dep ceiling doesn't widen the refault soft-pin set)
36
+
37
+
38
+ class SwapManager:
39
+ """Owns the working set: file load/evict, dep prefetch, skill load/evict, reviewed ratchet, ghosts."""
40
+ def __init__(self, retriever=None):
41
+ self.retriever = retriever
42
+
43
+ def load(self, s, path: str, edited: bool = False) -> None: # add/refresh a file, then evict to bounds
44
+ if not path:
45
+ return
46
+ io = getattr(s, "io", None)
47
+ if path in s.active_files: # already resident — a cache HIT
48
+ if io is not None:
49
+ io["hit"] += 1
50
+ elif self._is_ghost(s, "file", path): # re-read of a recently-evicted page — a REFAULT
51
+ if io is not None:
52
+ io["refault"] += 1
53
+ self._promote(s, path) # budget was too tight → kernel grants a brief soft-pin
54
+ self._grow(s) # …and widens its OWN read budget one notch (bidirectional ladder)
55
+ elif io is not None: # first sight — a cold MISS
56
+ io["miss"] += 1
57
+ s.active_files = [p for p in s.active_files if p != path]
58
+ s.active_files.append(path)
59
+ self._ghost_drop(s, "file", path) # it's back in the working set → no longer a ghost
60
+ if edited:
61
+ s.edited_files.add(path)
62
+ self.evict(s)
63
+
64
+ def evict(self, s) -> None:
65
+ """Keep the change set (edited) + HOT + the WHOLE dependency closure (relevance, not a count) +
66
+ most-recent read_budget exploratory reads; page the rest OUT — non-lossy, since every evicted
67
+ file leaves a GHOST recovery pointer and re-reads on demand (a REFAULT, which also widens the
68
+ budget). Edited/hot/deps never evict for a plain read (re-observation reach must cover them).
69
+ NOTE: whether OPEN FILES should evict at all within a loop is an OPEN design decision (see the
70
+ bound-is-relevance discussion); this is the re-faultable middle ground pending that call."""
71
+ edited_set = {p for p in s.active_files if p in s.edited_files}
72
+ hot_set = {p for p in s.active_files if p in getattr(s, "hot", {})} - edited_set
73
+ protect = edited_set | hot_set
74
+ deps_set = {p for p in s.active_files if p in s.protected_deps and p not in protect}
75
+ read_budget = getattr(s, "read_budget", READ_BUDGET) # LIVE adaptive budget (grows on refault); floor = READ_BUDGET
76
+ reads = [p for p in s.active_files
77
+ if p not in s.edited_files and p not in deps_set and p not in protect][-read_budget:]
78
+ keep = protect | deps_set | set(reads)
79
+ io = getattr(s, "io", None)
80
+ for p in s.active_files:
81
+ if p not in keep:
82
+ s.edit_anchor.pop(p, None)
83
+ s.edited_files.discard(p)
84
+ if io is not None:
85
+ io["evict"] += 1
86
+ self._ghost_add(s, "file", p) # paged out of OPEN FILES → leave a recovery pointer
87
+ s.active_files = [p for p in s.active_files if p in keep]
88
+
89
+ def prefetch(self, s) -> None:
90
+ """Refresh change-set protected DEPS from the code graph (recomputed once per turn at seed build).
91
+ Also AGE the kernel-granted soft-pins one notch (runs once per build = once per TURN) so refault
92
+ protection is temporary, never an accumulating tier. No graph → deps no-op; the hot decay still runs."""
93
+ if getattr(s, "hot", None):
94
+ s.hot = {p: t - 1 for p, t in s.hot.items() if t - 1 > 0}
95
+ r = self.retriever
96
+ # Snapshot pre-edit def-names of files READ but not yet edited, so we can later see what an edit
97
+ # REMOVED/MOVED (pre-edit defs - current defs). Cheap: reads the already-computed code graph
98
+ # (SENSORY CORTEX — a derived view, not a persisted store).
99
+ if hasattr(r, "def_names") and hasattr(s, "pre_defs"):
100
+ for p in s.active_files:
101
+ if p not in s.edited_files and p not in s.pre_defs:
102
+ s.pre_defs[p] = r.def_names(p)
103
+ if hasattr(r, "deps") and s.edited_files:
104
+ edited = list(s.edited_files) # the WHOLE change set (relevance, not a count) — materialize ONCE
105
+ deps: set = set()
106
+ for e in edited:
107
+ deps.update(r.deps(e, limit=DEP_CEILING)) # all direct callers that break on the change
108
+ s.protected_deps = deps - s.edited_files
109
+ # CHANGE-SET CLOSURE (symbol-aware): names the edits removed/moved, then the dependents whose
110
+ # CURRENT tokens still reference one — precise dangling call-sites. SILENT on feature-adds
111
+ # (nothing removed) so it never inflates non-refactor tasks; render_closure further filters to
112
+ # the UNOPENED ones (self-extinguishing once the model opens the site to fix/confirm).
113
+ if hasattr(r, "def_names") and hasattr(r, "ref_tokens") and hasattr(s, "stale_deps"):
114
+ removed: set = set()
115
+ for e in edited:
116
+ removed |= (s.pre_defs.get(e, set()) - r.def_names(e))
117
+ s.stale_deps = ({d for d in s.protected_deps if r.ref_tokens(d) & removed}
118
+ if removed else set())
119
+ elif not s.edited_files:
120
+ s.protected_deps = set()
121
+ if hasattr(s, "stale_deps"):
122
+ s.stale_deps = set()
123
+
124
+ def load_skill(self, s, name: str, body: str) -> None: # fold a SKILL into the active tier; evict overflow
125
+ if not name or not body:
126
+ return
127
+ s.active_skills = [sk for sk in s.active_skills if sk["name"] != name]
128
+ s.active_skills.append({"name": name, "body": body[:MAX_SKILL_CHARS]})
129
+ self._ghost_drop(s, "skill", name) # freshly loaded → not a ghost
130
+ if len(s.active_skills) > MAX_ACTIVE_SKILLS:
131
+ self.evict_skill(s)
132
+
133
+ def evict_skill(self, s) -> None: # drop skills beyond MAX_ACTIVE_SKILLS (oldest first), each → a ghost
134
+ for sk in s.active_skills[:-MAX_ACTIVE_SKILLS]:
135
+ self._ghost_add(s, "skill", sk["name"]) # evicted skill → recovery pointer
136
+ s.active_skills = s.active_skills[-MAX_ACTIVE_SKILLS:]
137
+
138
+ def note_review(self, s, mark: str) -> None: # RATCHET: record a history lookback (bounded) so it isn't re-done
139
+ if mark and mark not in s.reviewed:
140
+ s.reviewed.append(mark)
141
+ del s.reviewed[:-MAX_REVIEWED]
142
+
143
+ def _is_ghost(self, s, kind: str, ref: str) -> bool: # is this ref currently in the (recency-bounded) ghost ring?
144
+ return any(g["kind"] == kind and g["ref"] == ref for g in s.ghosts)
145
+
146
+ def _promote(self, s, path: str) -> None:
147
+ """Kernel grants ITSELF a brief reclaim-protection for a thrashing (refaulted) page — refault-driven,
148
+ NO model involvement (the validated automatic-beats-active-asker path). Bounded by HOT_CEILING; decays
149
+ in prefetch (HOT_TTL steps), so it can never become an accumulating tier."""
150
+ hot = getattr(s, "hot", None)
151
+ if hot is None:
152
+ return
153
+ hot[path] = HOT_TTL
154
+ while len(hot) > HOT_CEILING: # drop the oldest soft-pin (insertion-ordered) past the ceiling
155
+ hot.pop(next(iter(hot)))
156
+
157
+ def _grow(self, s) -> None:
158
+ """A REFAULT proves the resident read budget was momentarily too tight for THIS task (thrash).
159
+ The kernel widens its OWN live budget one notch — the BIDIRECTIONAL counterpart to the overflow
160
+ tighten ladder (which shrinks it). Bounded by the per-slice disaster ceiling (s.read_ceiling);
161
+ kernel-driven from MEASURED thrash (s.io refault), NO model involvement. Monotone within a
162
+ session (a task that needed N reads keeps needing ~N); never history-proportional, so the moat
163
+ holds — growth is TASK/refault-driven and window-bounded, and the kernel can always say no at
164
+ the ceiling. Same signal + same place as _promote: extend self-tuning from a per-file soft-pin
165
+ to the budget itself (Linux mm/workingset, scaled up)."""
166
+ cur = getattr(s, "read_budget", READ_BUDGET)
167
+ ceiling = getattr(s, "read_ceiling", READ_BUDGET_MAX)
168
+ if cur < ceiling:
169
+ s.read_budget = cur + 1
170
+
171
+ def _ghost_add(self, s, kind: str, ref: str) -> None: # paged-out item → bounded recovery POINTER (~0 tokens)
172
+ if not ref:
173
+ return
174
+ s.ghosts = [g for g in s.ghosts if not (g["kind"] == kind and g["ref"] == ref)]
175
+ s.ghosts.append({"kind": kind, "ref": ref})
176
+ s.ghosts = s.ghosts[-MAX_GHOSTS:]
177
+
178
+ def _ghost_drop(self, s, kind: str, ref: str) -> None: # back IN the slice → no longer a ghost
179
+ s.ghosts = [g for g in s.ghosts if not (g["kind"] == kind and g["ref"] == ref)]
180
+
181
+
182
+ _DEFAULT_SWAP = SwapManager() # retriever-free ops; touch_file/add_skill in pfc.py delegate here
@@ -0,0 +1,57 @@
1
+ """Pure Slice <-> TaskState mappers (MEMORY-SPEC step 2).
2
+
3
+ No I/O, no memem — keeps pfc.py (the moat) byte-identical. TaskState stores REFS (paths +
4
+ anchors), never file contents; resume re-reads files live (ground truth = disk). Transient tiers
5
+ (recent, action_log, active_skills) are intentionally NOT serialized — they're per-turn residue
6
+ re-derived from ground truth. NOTE on resume: active_skills is dropped (skill bodies live on disk
7
+ and re-load via the `skill` tool); since_edit is zeroed (a fresh action epoch — otherwise the
8
+ restored counter could fire render_convergence's STOP nudge on turn 1). The `conversation` ring +
9
+ `turns` counter are also intentionally NOT carried CROSS-session (they survive within a session via
10
+ seal()): the durable distilled state (goal, findings, world, requirements, edited set) is what a
11
+ resume rebuilds on, and short-range chat is re-grounded from the goal — carrying it would persist
12
+ verbatim user text into the vault for marginal continuity gain.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from .interfaces import TaskState
17
+ from .pfc import Slice
18
+ from .text_utils import one_line
19
+
20
+
21
+ def slice_to_task_state(s: Slice, task_id: str, *, session_id: str = "", title: str = "",
22
+ status: str = "active", tags: str = "", resolution: str = "",
23
+ links: list[str] | None = None) -> TaskState:
24
+ return TaskState(
25
+ task_id=task_id, session_id=session_id,
26
+ title=title or one_line(s.goal, 60), status=status, goal=s.goal,
27
+ findings=list(s.findings),
28
+ finding_source={k: v for k, v in s.finding_source.items() if k in set(s.findings)}, # provenance, bounded to live findings
29
+ requirements=[dict(r) for r in s.requirements], # carry the standing contract across resume
30
+ plan=[dict(p) for p in s.plan], # carry the PLAN (TodoWrite) across resume
31
+ mission=s.mission, # carry the MISSION north-star across resume
32
+ open_report=getattr(s, "open_report", ""), # carry the OPEN USER REPORT blocker (was silently lost)
33
+ world=dict(s.world), # carry the agent WORLD MODEL (was silently lost)
34
+ active_files=list(s.active_files),
35
+ edited_files=sorted(s.edited_files), # byte-stable across checkpoints
36
+ edit_anchor={p: a for p, a in s.edit_anchor.items() if p in s.active_files}, # consistency
37
+ last_error=s.last_error, since_edit=s.since_edit, # serialized faithfully
38
+ links=list(links or []), tags=tags, resolution=resolution,
39
+ )
40
+
41
+
42
+ def task_state_to_slice(ts: TaskState, s: Slice | None = None) -> Slice:
43
+ s = s or Slice()
44
+ s.reset(ts.goal) # zeroes transient tiers (recent/action_log/active_skills/...)
45
+ s.findings = list(ts.findings)
46
+ s.finding_source = dict(getattr(ts, "finding_source", {})) # restore provenance (getattr: back-compat with old checkpoints)
47
+ s.requirements = [dict(r) for r in getattr(ts, "requirements", [])] # restore the standing contract
48
+ s.plan = [dict(p) for p in getattr(ts, "plan", [])] # restore the PLAN (TodoWrite)
49
+ s.mission = getattr(ts, "mission", "") # restore the MISSION north-star
50
+ s.open_report = getattr(ts, "open_report", "") # restore the OPEN USER REPORT blocker
51
+ s.world = dict(getattr(ts, "world", {})) # restore the WORLD MODEL
52
+ s.active_files = list(ts.active_files)
53
+ s.edited_files = set(ts.edited_files)
54
+ s.edit_anchor = dict(ts.edit_anchor)
55
+ s.last_error = ts.last_error
56
+ s.since_edit = 0 # resume = fresh action epoch (don't fire render_convergence)
57
+ return s
@@ -0,0 +1,59 @@
1
+ """Reconstruction-quality telemetry — measures whether the slice KEEPS what the model needs, so the
2
+ slice's central bet becomes a NUMBER instead of an anecdote.
3
+
4
+ A pure OBSERVER sink: it consumes the loop's events and accumulates counters. It emits no events and
5
+ mutates no slice — completely off the moat. Deterministic, bounded, zero LLM. Wire it into a
6
+ dispatcher alongside slice_sink (the eval harness does this) and read `.summary()` after the run.
7
+
8
+ Signals:
9
+ - re_reads : a read_file on a path ALREADY read within the last `window` steps. Within a turn the prior
10
+ read is still in the accumulated transcript, so a re-read signals the model isn't using its resident
11
+ context; across turns it's the reconstruction-MISS signal (the seed/seal didn't carry what it needed).
12
+ - recalls : recall_history calls — recovery from the cold cache (the model knew it forgot).
13
+ - reads : total successful read_file calls (the denominator for a re-read RATE).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from .events import Event, StepEnd, ToolResult
18
+
19
+ RE_READ_WINDOW = 6 # a path re-read within this many steps counts as a likely reconstruction miss
20
+ _MAX_TRACKED = 256 # bound the per-path last-seen map (never a transcript)
21
+
22
+
23
+ class Telemetry:
24
+ """Callable event sink that counts reconstruction-cost signals. Read `.summary()` after a run."""
25
+
26
+ def __init__(self, window: int = RE_READ_WINDOW):
27
+ self.window = window
28
+ self.step = 0
29
+ self.reads = 0
30
+ self.re_reads = 0
31
+ self.recalls = 0
32
+ self._last_read: dict[str, int] = {} # path -> step it was last read (bounded)
33
+
34
+ def __call__(self, e: Event) -> None:
35
+ if isinstance(e, StepEnd):
36
+ self.step += 1
37
+ elif isinstance(e, ToolResult) and not e.failing:
38
+ if e.name == "read_file":
39
+ self.reads += 1
40
+ path = (e.args or {}).get("path")
41
+ if path:
42
+ last = self._last_read.get(path)
43
+ if last is not None and (self.step - last) <= self.window:
44
+ self.re_reads += 1 # read again soon after — slice didn't carry it
45
+ self._last_read[path] = self.step
46
+ if len(self._last_read) > _MAX_TRACKED: # bound: drop the oldest entry
47
+ del self._last_read[min(self._last_read, key=self._last_read.get)]
48
+ elif e.name == "recall_history":
49
+ self.recalls += 1
50
+
51
+ def summary(self) -> dict:
52
+ rate = round(self.re_reads / self.reads, 3) if self.reads else 0.0
53
+ return {"reads": self.reads, "re_reads": self.re_reads,
54
+ "re_read_rate": rate, "recalls": self.recalls}
55
+
56
+
57
+ def make_telemetry_sink() -> Telemetry:
58
+ """A Telemetry instance IS the sink (it's callable) AND carries the counters to read afterward."""
59
+ return Telemetry()
sliceagent/terminal.py ADDED
@@ -0,0 +1,240 @@
1
+ """terminal — persistent interactive PTY sessions (the other half of the live-process gap).
2
+
3
+ ``Sandbox.run`` is one-shot and has no stdin, so a whole class of work is impossible: driving a
4
+ REPL or text game through successive prompts, navigating a TUI, sending keys into vim, or just
5
+ holding shell + env state (``cd``, ``export``, venv activation) across many tool calls. A
6
+ ``PtySession`` allocates a real pseudo-terminal (stdlib ``pty``), launches a shell (or any program)
7
+ attached to it, and keeps it alive in a registry keyed by name so the agent can ``send`` keys,
8
+ ``read`` the live output, ``wait`` for an expected pattern (the "expect" primitive that makes
9
+ interaction reliable), then ``close``.
10
+
11
+ stdlib only — ``pty`` + ``select`` + ``fcntl`` (``pexpect`` isn't a dependency). The master fd is
12
+ non-blocking; reads drain whatever the program has emitted (the output STREAM — not a rendered
13
+ screen-buffer, so full-curses TUIs show raw escape codes, but REPLs / games / line tools read
14
+ cleanly). Children run in their own process group so ``close`` takes down the whole tree.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import codecs
19
+ import fcntl
20
+ import os
21
+ import pty
22
+ import re
23
+ import select
24
+ import signal
25
+ import subprocess
26
+ import time
27
+
28
+ from .sandbox import _scrub_env
29
+
30
+ _READ_CHUNK = 65536
31
+ _BUF_CAP = 200_000 # keep the tail of a chatty session bounded
32
+
33
+
34
+ class _Session:
35
+ __slots__ = ("name", "cmd", "master", "popen", "buf", "_decoder")
36
+
37
+ def __init__(self, name, cmd, master, popen):
38
+ self.name = name
39
+ self.cmd = cmd
40
+ self.master = master
41
+ self.popen = popen
42
+ self.buf = "" # output accumulated since the last read/wait returned it
43
+ # STATEFUL utf-8 decoder: a multibyte char split across two os.read() boundaries must not decode to
44
+ # U+FFFD on both halves — the decoder holds the partial sequence until its continuation bytes arrive.
45
+ self._decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
46
+
47
+
48
+ class SessionManager:
49
+ """Registry of live interactive PTY sessions. Single-threaded (the agent loop is)."""
50
+
51
+ def __init__(self, *, scrub_secrets: bool = True):
52
+ self.scrub_secrets = scrub_secrets
53
+ self._s: dict[str, _Session] = {}
54
+
55
+ # ── lifecycle ──────────────────────────────────────────────────────────
56
+ def open(self, name: str, *, cwd: str, command: str | None = None) -> str:
57
+ if name in self._s:
58
+ raise ValueError(f"session {name!r} is already open (close it first, or use another name)")
59
+ master, slave = pty.openpty()
60
+ env = _scrub_env() if self.scrub_secrets else dict(os.environ)
61
+ env["PYTHONUNBUFFERED"] = "1"
62
+ env.setdefault("TERM", "xterm")
63
+ try:
64
+ try:
65
+ popen = self._spawn_pty(command, cwd, env, slave)
66
+ finally:
67
+ os.close(slave) # parent keeps only the master end
68
+ except BaseException:
69
+ os.close(master) # #19: Popen failed — don't leak the master fd too
70
+ raise
71
+ try: # a fcntl failure AFTER spawn must tear down both the master fd AND the orphaned child (#19)
72
+ flags = fcntl.fcntl(master, fcntl.F_GETFL)
73
+ fcntl.fcntl(master, fcntl.F_SETFL, flags | os.O_NONBLOCK)
74
+ except BaseException:
75
+ try:
76
+ os.killpg(os.getpgid(popen.pid), signal.SIGKILL)
77
+ except Exception: # noqa: BLE001
78
+ pass
79
+ os.close(master)
80
+ raise
81
+ self._s[name] = _Session(name, command or "(shell)", master, popen)
82
+ return name
83
+
84
+ def _spawn_pty(self, command, cwd, env, slave):
85
+ """Launch the PTY-attached process. OVERRIDABLE SEAM: a container variant relaunches the same
86
+ command through `docker exec -it` so the session lives INSIDE the task container (host path here)."""
87
+ if command: # run the given program directly on the PTY
88
+ return subprocess.Popen(command, shell=True, cwd=cwd, env=env,
89
+ stdin=slave, stdout=slave, stderr=slave,
90
+ start_new_session=True, close_fds=True)
91
+ shell = os.environ.get("SHELL") or "/bin/bash" # interactive shell (holds cd/env across turns)
92
+ return subprocess.Popen([shell], cwd=cwd, env=env,
93
+ stdin=slave, stdout=slave, stderr=slave,
94
+ start_new_session=True, close_fds=True)
95
+
96
+ def send(self, name: str, keys: str, *, enter: bool = True, timeout: float = 30.0) -> str:
97
+ sess = self._get(name)
98
+ data = (keys + ("\n" if enter else "")).encode("utf-8", errors="replace")
99
+ # The master fd is O_NONBLOCK: one os.write may write only PART of a large payload (the rest would be
100
+ # silently dropped), and a FULL buffer raises BlockingIOError (EAGAIN) — back-pressure, not a dead
101
+ # session. Loop until every byte is written, waiting for writability on EAGAIN — but under an OVERALL
102
+ # deadline, so a child that has stopped reading stdin can't wedge the (possibly inline) loop thread
103
+ # forever. Reserve the "may have exited" error for a genuine OSError.
104
+ view = memoryview(data)
105
+ deadline = time.time() + timeout
106
+ try:
107
+ while view:
108
+ try:
109
+ view = view[os.write(sess.master, view):]
110
+ except BlockingIOError:
111
+ remaining = deadline - time.time()
112
+ if remaining <= 0:
113
+ raise ValueError(f"session {name!r} not writable within {timeout:g}s; the program "
114
+ "isn't reading its input (stdin) or its output buffer is full") from None
115
+ select.select([], [sess.master], [], min(remaining, 5)) # wait for the PTY to drain, then retry
116
+ except OSError as e:
117
+ raise ValueError(f"session {name!r} is not writable ({e}); it may have exited") from None
118
+ # peek (NON-consuming) so the response is still there for a following read/wait — otherwise
119
+ # send would eat the very output the model is about to wait for.
120
+ return self.peek(name, timeout=0.4)
121
+
122
+ def read(self, name: str, *, timeout: float = 1.0) -> str:
123
+ sess = self._get(name)
124
+ self._drain(sess, hard=timeout)
125
+ out, sess.buf = sess.buf, ""
126
+ if not out:
127
+ return f"(no output; {self._status(sess)})"
128
+ return out
129
+
130
+ def peek(self, name: str, *, timeout: float = 0.6) -> str:
131
+ """Drain output and return it WITHOUT consuming — so a later read/wait still sees it.
132
+ Used right after open() so the program's first prompt isn't eaten by the banner read."""
133
+ sess = self._get(name)
134
+ self._drain(sess, hard=timeout)
135
+ return sess.buf or f"(no output yet; {self._status(sess)})"
136
+
137
+ def wait(self, name: str, pattern: str, *, timeout: float = 10.0) -> str:
138
+ """Drain until `pattern` (regex) appears or timeout — the reliable interaction primitive."""
139
+ sess = self._get(name)
140
+ # #20: bound the (model-supplied) pattern — cap its length to limit catastrophic-backtracking
141
+ # surface, and fail clearly on a bad regex instead of crashing the tool. (Python's re has no
142
+ # match timeout; the haystack is this subprocess's own output, so length-capping is the mitigation.)
143
+ if len(pattern) > 500:
144
+ raise ValueError("wait pattern too long (max 500 chars)")
145
+ try:
146
+ rx = re.compile(pattern)
147
+ except re.error as e:
148
+ raise ValueError(f"invalid wait pattern: {e}")
149
+ end = time.time() + timeout
150
+ # inspect already-buffered output at least once BEFORE the timeout loop — so timeout<=0 still polls
151
+ # (matches a buffered hit instead of false-negativing it and then clearing the buffer below).
152
+ self._drain(sess, hard=min(0.4, timeout) if timeout > 0 else 0.0)
153
+ m = rx.search(sess.buf)
154
+ while m is None and time.time() < end:
155
+ self._drain(sess, hard=0.4)
156
+ m = rx.search(sess.buf)
157
+ if m:
158
+ break
159
+ if sess.popen.poll() is not None: # process died — one last drain, then stop
160
+ self._drain(sess, hard=0.2)
161
+ m = rx.search(sess.buf)
162
+ break
163
+ if m: # expect semantics: consume up to the match, KEEP the rest
164
+ cut = m.end()
165
+ out, sess.buf = sess.buf[:cut], sess.buf[cut:]
166
+ return f"[{name}: matched; {self._status(sess)}]\n{out}"
167
+ out, sess.buf = sess.buf, "" # timeout: return + clear everything seen
168
+ return f"[{name}: NO match for {pattern!r} before timeout; {self._status(sess)}]\n{out or '(no output)'}"
169
+
170
+ def close(self, name: str) -> str:
171
+ sess = self._get(name)
172
+ if sess.popen.poll() is None:
173
+ try:
174
+ os.killpg(os.getpgid(sess.popen.pid), signal.SIGTERM)
175
+ except OSError:
176
+ try:
177
+ sess.popen.terminate()
178
+ except OSError:
179
+ pass
180
+ try:
181
+ sess.popen.wait(timeout=3)
182
+ except subprocess.TimeoutExpired:
183
+ try:
184
+ os.killpg(os.getpgid(sess.popen.pid), signal.SIGKILL)
185
+ except OSError:
186
+ pass
187
+ try:
188
+ sess.popen.wait(timeout=2) # reap the SIGKILLed child so it isn't left a zombie at fd close
189
+ except (subprocess.TimeoutExpired, OSError):
190
+ pass
191
+ try:
192
+ os.close(sess.master)
193
+ except OSError:
194
+ pass
195
+ self._s.pop(name, None)
196
+ return f"closed {name}"
197
+
198
+ def list(self) -> str:
199
+ if not self._s:
200
+ return "(no terminal sessions)"
201
+ return "\n".join(f"{n}: {self._status(s)} — {s.cmd}" for n, s in self._s.items())
202
+
203
+ def cleanup(self) -> None:
204
+ for n in list(self._s):
205
+ try:
206
+ self.close(n)
207
+ except Exception: # noqa: BLE001 — best-effort teardown
208
+ pass
209
+
210
+ # ── internals ──────────────────────────────────────────────────────────
211
+ def _get(self, name: str) -> _Session:
212
+ s = self._s.get(name)
213
+ if s is None:
214
+ raise ValueError(
215
+ f"unknown session {name!r}. Open: {', '.join(self._s) or '(none)'}")
216
+ return s
217
+
218
+ def _drain(self, sess: _Session, *, hard: float) -> None:
219
+ """Read everything currently available, stopping on a short idle gap or `hard` cap."""
220
+ end = time.time() + hard
221
+ while time.time() < end:
222
+ r, _, _ = select.select([sess.master], [], [], min(0.15, hard))
223
+ if not r:
224
+ break # no more output right now
225
+ try:
226
+ chunk = os.read(sess.master, _READ_CHUNK)
227
+ except (BlockingIOError, InterruptedError):
228
+ continue
229
+ except OSError:
230
+ break # master closed / child gone
231
+ if not chunk:
232
+ break # EOF
233
+ sess.buf += sess._decoder.decode(chunk) # incremental: holds a partial multibyte tail across reads
234
+ if len(sess.buf) > _BUF_CAP:
235
+ sess.buf = "…[earlier output elided]…\n" + sess.buf[-_BUF_CAP:]
236
+
237
+ @staticmethod
238
+ def _status(sess: _Session) -> str:
239
+ rc = sess.popen.poll()
240
+ return "alive" if rc is None else f"exited {rc}"
@@ -0,0 +1,56 @@
1
+ """Tiny shared text helpers — ONE home for the whitespace/timestamp normalizers that were copy-pasted
2
+ across regions.py, skills.py, pagetable.py and hippocampus.py. Leaf module (no intra-package imports) so
3
+ any module can use it without an import cycle. Behavior is byte-identical to the expressions it replaces.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from datetime import datetime, timezone
9
+
10
+ _WS = re.compile(r"\s+")
11
+
12
+
13
+ def now_iso() -> str:
14
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
15
+
16
+
17
+ def normalize_ws(s) -> str:
18
+ """Collapse all runs of whitespace to a single space and strip. `None`/non-str -> ''."""
19
+ return _WS.sub(" ", str(s or "")).strip()
20
+
21
+
22
+ def one_line(s, n: int = 80) -> str:
23
+ """`normalize_ws` truncated to `n` chars — a one-line, bounded rendering of arbitrary text."""
24
+ return normalize_ws(s)[:n]
25
+
26
+
27
+ def format_ts(ts) -> str:
28
+ """ISO timestamp -> compact 'MM-DD HH:MM' (e.g. '06-16 12:30'); '' for empty/None."""
29
+ return (ts or "")[5:16].replace("T", " ")
30
+
31
+
32
+ # Pure social/greeting messages that never imply work. The host answers these on a CHEAP path — no slice,
33
+ # no tool schemas, a tiny prompt — so a "hi" or "thanks" doesn't pay the full per-turn token cost.
34
+ _CHITCHAT = frozenset({
35
+ "hi", "hii", "hiya", "hello", "hey", "heya", "hey there", "hi there", "hello there", "yo", "sup",
36
+ "howdy", "good morning", "good afternoon", "good evening", "morning", "evening", "gm",
37
+ "how are you", "how are you?", "how's it going", "hows it going", "what's up", "whats up", "how's things",
38
+ "thanks", "thank you", "thanks!", "thank you!", "thx", "ty", "tysm", "cheers", "much appreciated",
39
+ "appreciate it", "thanks a lot", "thank you so much",
40
+ "ok", "okay", "k", "kk", "cool", "nice", "great", "awesome", "perfect", "got it", "sounds good",
41
+ "great thanks", "ok thanks", "okay thanks", "nice thanks", "perfect thanks",
42
+ "bye", "goodbye", "see you", "see ya", "later", "good night", "gn", "night",
43
+ "lol", "haha", "nice one", "well done", "good job", "good bot", "gg",
44
+ })
45
+
46
+ # Minimal system prompt for the chitchat fast-path — keep it tiny; the whole point is to spend few tokens.
47
+ CHITCHAT_PROMPT = ("You are sliceagent, a coding agent. The user sent a brief greeting or social message. "
48
+ "Reply in ONE short, warm line. Do not call tools, summarize, or start any work.")
49
+
50
+
51
+ def is_chitchat(text) -> bool:
52
+ """True ONLY for a pure greeting/social message (high precision — never fires on a real request). The
53
+ whole message must match a known social phrase after trimming punctuation; a length cap guards against
54
+ anything substantive ('hi, can you fix X' is not chitchat)."""
55
+ t = str(text or "").strip().lower().rstrip("!.?,~ ")
56
+ return 0 < len(t) <= 40 and t in _CHITCHAT