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/pfc.py ADDED
@@ -0,0 +1,449 @@
1
+ """PFC — the Active Memory Slice's own carried state (the working-memory brain region).
2
+
3
+ NORTH STAR — the slice is a CACHE, not a log. Every model call is a pure function
4
+ f(selector, store): the durable stores (disk, code graph, episode cache) are the only
5
+ authority; the slice is a small typed SELECTOR over them, reconstructed ONCE PER TURN as the
6
+ SEED (see seed.py). Within the turn, working memory ACCUMULATES as native assistant/tool
7
+ messages — no per-step rebuild, no within-turn eviction; the bound is the TURN-BOUNDARY seal
8
+ (the next turn starts from a fresh seed + recall). The single invariant "cache not log" IMPLIES
9
+ the moat (a cache keeps no history), task-agnosticism (a cache doesn't know what it caches), and
10
+ LLM-agnosticism (the cache contract sits below the model).
11
+
12
+ IN BRAIN TERMS (a naming aid — see pagetable.py for the fuller legend): the Slice's own carried
13
+ state (findings, conversation ring, plan, mission — see seal() below) is PREFRONTAL CORTEX /
14
+ working memory: bounded, actively maintained, free, lost on reset. This module owns exactly
15
+ that region: the `Slice` dataclass, its lifecycle (reset/seal), and the functions that MUTATE
16
+ it in place (touch_file, add_skill, record_user, consolidate_checkpoint, slice_sink). The
17
+ reconstruction seam that READS durable stores to build a turn's SEED lives in seed.py; the
18
+ stable SYSTEM prompt text lives in prompt.py.
19
+
20
+ PROVENANCE (Invariant 1): a finding is tagged by where it came from, and model prose is never an
21
+ established FACT. Each finding carries a `source` (observed > tool-note > claim): a tool result is
22
+ "observed"; the `note` arg on a non-failing call is "tool-note"; the model's free reasoning and any
23
+ "done"-style claim are "claim". ALL are folded forward so a reasoning model reuses them instead of
24
+ re-deriving (the costly Markov trap) — but rendered as the model's OWN notes to VERIFY against OPEN
25
+ FILES, never as "do not re-derive". Pure intent/narration ("Let me…") is dropped. This keeps the
26
+ "already done" ratchet dead (a claim is not a fact) WITHOUT starving carry-forward — dropping prose
27
+ entirely ~2x'd steps on normal tasks.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import re
32
+ from dataclasses import dataclass, field
33
+
34
+ from .events import AssistantText, Event, ToolResult
35
+ from .regions import (
36
+ CONVO_MSG_CHARS,
37
+ MAX_CONVERSATION,
38
+ MAX_FINDINGS,
39
+ MAX_MISSION_CHARS,
40
+ MAX_PLAN_CHARS,
41
+ MAX_PLAN_ITEMS,
42
+ MAX_REQUIREMENTS,
43
+ MAX_REQ_CHARS,
44
+ record_action,
45
+ record_note,
46
+ )
47
+ from .swap import READ_BUDGET, READ_BUDGET_MAX, _DEFAULT_SWAP
48
+ from .text_utils import one_line
49
+
50
+ # literal paths the model touches via execute_code helpers — so code-as-action reads/edits
51
+ # still populate the OPEN FILES working set (they run in the sandbox, bypassing the ToolHost)
52
+ _CODE_PATH_RE = re.compile(
53
+ r"\b(?:read_file|write_file|append_file|str_replace)\(\s*['\"]([^'\"]+)['\"]"
54
+ )
55
+ # the subset that MUTATES a file (vs read_file) — so code-as-action edits join the protected change set
56
+ _CODE_EDIT_PATH_RE = re.compile(
57
+ r"\b(?:write_file|append_file|str_replace)\(\s*['\"]([^'\"]+)['\"]"
58
+ )
59
+ # code_ops (the anti-loop tally's view THROUGH execute_code) lives in regions.py with action_sig/
60
+ # record_action. paths_in_code/edited_paths_in_code stay here (slice_sink is their only caller).
61
+
62
+
63
+ def paths_in_code(code: str) -> list[str]:
64
+ return _CODE_PATH_RE.findall(code or "")
65
+
66
+
67
+ def edited_paths_in_code(code: str) -> list[str]:
68
+ return _CODE_EDIT_PATH_RE.findall(code or "")
69
+
70
+
71
+ @dataclass
72
+ class Slice:
73
+ goal: str = ""
74
+ # DURABLE TASK SPEC — the original defining request for this topic (the first user message), kept
75
+ # WHOLE and resident for the life of the topic. Standing requirements live here (an exact function
76
+ # name/signature, output format, "use British spelling", "don't use lib X", an invariant) — they are
77
+ # STANDING REQUIREMENTS — the live contract that must hold when the task is DONE: a model-CURATED set
78
+ # of constraints (an exact name/signature, an output format, a stated rule, an added requirement), NOT
79
+ # the frozen first message. The model maintains it in-band via require / requirement_done /
80
+ # drop_requirement (folded by slice_sink, the world_set seam — zero extra LLM call). CARRIED across
81
+ # turns (the seal never touches it; continue_topic moves `goal`, not this), wiped only by reset/new_topic.
82
+ # EMPTY by default → a greeting/question has NO contract and the region self-suppresses, so a trivial
83
+ # first message can never become a binding spec. bound-is-relevance: only what must hold at the end.
84
+ requirements: list[dict] = field(default_factory=list) # [{"text": str, "done": bool}], insertion order
85
+ # PLAN (TodoWrite) — the model's ORDERED execution steps with live status. Distinct from requirements
86
+ # (acceptance criteria): this is the step sequence + progress. Replace-all via the update_plan tool
87
+ # (folded by slice_sink). Carried by seal() (continuity), wiped by reset(). Bounded (MAX_PLAN_ITEMS).
88
+ plan: list[dict] = field(default_factory=list) # [{"step": str, "status": pending|in_progress|done}]
89
+ # MISSION — the NORTH-STAR objective / "why" framing the agent sets to stay oriented
90
+ # over a long multi-step task, ABOVE the literal `goal`. ONE string (inherently bounded); set via
91
+ # set_mission, cleared via mission_done. Self-suppresses when empty (no bloat). Carried by seal()
92
+ # across the task's turns; wiped by reset() (a brand-new task) — same lifecycle as requirements/plan.
93
+ mission: str = ""
94
+ action_log: dict[str, dict] = field(default_factory=dict)
95
+ active_files: list[str] = field(default_factory=list)
96
+ last_error: str = ""
97
+ active_skills: list[dict] = field(default_factory=list) # [{name, body}] loaded SKILLs
98
+ edit_anchor: dict[str, str] = field(default_factory=dict) # path -> last edit-target text (huge-file focus)
99
+ findings: list[str] = field(default_factory=list) # distilled conclusions carried across turns
100
+ # I1 PROVENANCE — source tag per finding (text -> "observed" | "tool-note" | "claim"). Parallel
101
+ # to `findings` (kept a plain list[str] so it stays JSON-serializable for taskstate/memory and
102
+ # readable by discovery_query). Bounded with the findings ring; pruned to live keys only.
103
+ finding_source: dict = field(default_factory=dict)
104
+ # AGENT WORLD MODEL — a durable, agent-MAINTAINED key→value scratchpad for NON-code task state the
105
+ # model must carry across many steps: an explored maze map, a text-adventure's rooms+inventory, a
106
+ # system inventory (processes/ports/services), a running plan. Written via the world_set tool (folded
107
+ # in by slice_sink, the same note→findings seam); READ from the rendered WORLD MODEL region, built into
108
+ # each turn's SEED (no world_get needed) — within the SAME turn a just-set value lives only in the
109
+ # model's own world_set call above until the next seed re-renders it. Unbounded (bound = the seal); SURVIVES
110
+ # the seal (distilled task state); cleared only by reset (a new task). This generalizes the slice
111
+ # beyond source files — where its multi-step memory wins on non-code tasks (maze/zork) actually lives.
112
+ world: dict = field(default_factory=dict)
113
+ edited_files: set = field(default_factory=set) # the change set — protected from eviction
114
+ since_edit: int = 0 # tool calls since the last successful edit — drives the EDIT convergence check
115
+ turn_actions: int = 0 # tool calls THIS user turn — finding-INDEPENDENT (unlike since_edit, which resets
116
+ # on every new finding); drives the explore-nudge so a read-heavy Q&A that records notes still converges
117
+ reviewed: list[str] = field(default_factory=list) # history lookbacks done — the recall_history ratchet
118
+ # I3 — OPEN USER REPORT. The user's most-recent FAILURE REPORT ("it can't play", "cd: no such
119
+ # file"), captured verbatim as a BLOCKER the model must verify against the real artifact before
120
+ # claiming done. A snapshot agent loses the dialectic — the user pushing back on a "done" claim —
121
+ # so the report is a durable tier. ONE string (inherently bounded); survives continue_topic (a new
122
+ # directive does NOT mean the user retracted the report); cleared only by a real topic reset or a
123
+ # NEWER report. NOT a transcript: a single most-recent line, capped.
124
+ open_report: str = ""
125
+ # CONTINUITY (short-range): a bounded ring of the last few user<->assistant exchanges so the slice
126
+ # carries the immediate conversational thread (a snapshot agent otherwise loses "what we just said").
127
+ # Older turns are NOT here — they live in the durable episodic cache, paged in ON DEMAND via
128
+ # recall_history (the decompression path). `turns` counts user turns this topic (for the "+N older"
129
+ # pointer). Bounded => growth stays decoupled from conversation length (the moat).
130
+ conversation: list[dict] = field(default_factory=list) # [{user, assistant}], last MAX_CONVERSATION
131
+ turns: int = 0
132
+ # GHOST INDEX: bounded recovery POINTERS (references, not content) to things recently paged OUT of
133
+ # the slice — an evicted read, a dropped skill. Turns "omission is unrecoverable" into a one-call
134
+ # fetch. [{kind, ref}], bounded by MAX_GHOSTS; an item leaves the moment it's back in the slice.
135
+ ghosts: list[dict] = field(default_factory=list)
136
+ # CO-RESIDENCY: read-only files that are DEPENDENCIES (contracts/callers) of the change set,
137
+ # recomputed each turn from the code graph (make_build_slice). Protected from eviction so the
138
+ # files an edit must stay consistent with don't page out from under it. Bounded (DEP_CEILING);
139
+ # a plain set of relpaths (serializable, like edited_files). Empty without a dep graph.
140
+ protected_deps: set = field(default_factory=set)
141
+ # CHANGE-SET CLOSURE state (symbol-aware): pre_defs snapshots each file's def-names BEFORE it is
142
+ # edited, so prefetch can compute what an edit REMOVED (pre - current) and flag dependents whose
143
+ # CURRENT tokens still reference a removed name — a precise dangling-call-site signal. INTERNAL
144
+ # host state (def-name sets from the durable code graph), never rendered into the slice and never a
145
+ # conversation transcript; scoped to the files touched this session (one small set per such file).
146
+ pre_defs: dict = field(default_factory=dict)
147
+ stale_deps: set = field(default_factory=set)
148
+ # KERNEL-INTERNAL self-tuning state — NOT rendered into the slice the model sees, NOT serialized
149
+ # (taskstate ignores it), transient. Pure mechanism, like Linux vmstat + mm/workingset refault
150
+ # tracking. `io` = per-session page hit/miss/refault/evict counters (makes the moat MEASURED, not
151
+ # asserted; the only legit input to any future budget auto-sizing). `hot` = files the kernel granted
152
+ # ITSELF a brief reclaim-protection on a refault (path -> TTL steps), bounded by HOT_CEILING — the
153
+ # automatic self-tuning loop (no model involvement), the validated automatic-beats-active-asker path.
154
+ io: dict = field(default_factory=lambda: {"hit": 0, "miss": 0, "refault": 0, "evict": 0})
155
+ hot: dict = field(default_factory=dict)
156
+ # ADAPTIVE working-set budget (the "bounded = Markov current-state, not a fixed ceiling" reframe). The
157
+ # resident exploratory-read budget is no longer the constant READ_BUDGET: it starts at that FLOOR and the
158
+ # kernel GROWS it on refault thrash (SwapManager._grow) up to read_ceiling. Transient session state (like
159
+ # io/hot) — NOT serialized, reset per task. Growth is task/refault-driven + window-bounded, never history-
160
+ # proportional, so the moat holds. read_ceiling is the per-slice disaster ceiling (genuine breadth goes to
161
+ # the swarm, not to inflating this one slice).
162
+ read_budget: int = READ_BUDGET
163
+ read_ceiling: int = READ_BUDGET_MAX
164
+ # EXPLORER mode: a read-only delegated explorer's whole job IS thorough read-only investigation, so
165
+ # the read-only convergence nudge ("you've explored N times, ANSWER now") must NOT fire on it — that
166
+ # nudge is for the TOP-LEVEL agent over-exploring instead of answering the user. max_steps bounds the
167
+ # explorer. Transient, set by run_subagent for read-only children. (Sibling of the EXPLORER_READ_BUDGET fix.)
168
+ explore_mode: bool = False
169
+
170
+ def reset(self, goal: str) -> None:
171
+ self.goal = goal
172
+ self.requirements = [] # a brand-new task starts with an EMPTY contract (model curates it in-band)
173
+ self.plan = [] # a brand-new task starts with an empty plan (kept by seal() within a task)
174
+ self.mission = "" # north-star objective — wiped on a brand-new task (kept by seal())
175
+ self.action_log = {}
176
+ self.active_files = []
177
+ self.last_error = ""
178
+ self.active_skills = []
179
+ self.edit_anchor = {}
180
+ self.findings = []
181
+ self.finding_source = {}
182
+ self.world = {} # agent world model → wiped on a brand-new task (kept by seal())
183
+ self.edited_files = set()
184
+ self.since_edit = 0
185
+ self.turn_actions = 0
186
+ self.reviewed = []
187
+ self.open_report = ""
188
+ self.conversation = []
189
+ self.turns = 0
190
+ self.ghosts = []
191
+ self.protected_deps = set()
192
+ self.pre_defs = {}
193
+ self.stale_deps = set()
194
+ self.io = {"hit": 0, "miss": 0, "refault": 0, "evict": 0}
195
+ self.hot = {}
196
+ self.read_budget = READ_BUDGET # back to the lean floor each task; grows on refault within the task
197
+ self.read_ceiling = READ_BUDGET_MAX
198
+ self.explore_mode = False
199
+
200
+ def seal(self) -> None:
201
+ """SEAL the loop at a TURN boundary — "bounded = seal the within-loop info" (the moat is the seal
202
+ BETWEEN loops, never a cut WITHIN one). The finished loop's COMPLETE info was archived to the
203
+ durable cache on TurnEnd; here the NEXT loop starts FRESH so per-turn cost stays flat across a
204
+ long multi-turn session instead of growing like a transcript.
205
+
206
+ CARRY (distilled, durable continuity): findings + their sources, the in-progress edited change-set
207
+ (+ its edit anchors), conversation ring, goal, the OPEN USER REPORT blocker, deliberate pins, and
208
+ the demoted anti-loop tally — all kept by simply not touching them.
209
+ SEAL (archived + recall-on-demand via recall_history): the RAW within-loop trajectory — recent
210
+ steps, the intra-turn step cache, exploratory (non-edited) reads, and the prior loop's transient
211
+ kernel state. None of it is lost: it's in the durable cache, one recall away if the next loop needs
212
+ it. Distinct from reset() (a brand-new task wipes everything); seal() preserves the distilled carry."""
213
+ self.ghosts = [] # recovery pointers for the prior loop's evictions → moot now
214
+ self.hot = {} # prior loop's kernel soft-pins → reset
215
+ self.turn_actions = 0 # fresh action epoch for the new loop
216
+ self.read_budget = READ_BUDGET # back to the lean floor; re-grows on refault within the new loop
217
+ # BOUND THE CARRY AT THE SEAL (not within the loop): the next loop starts from the most-recent
218
+ # MAX_FINDINGS distilled facts; older ones are in the durable episodic cache (archived at TurnEnd,
219
+ # recallable). This is the loop-boundary bound the moat prescribes — without it findings carried
220
+ # unbounded across a long session (transcript-style growth). pre_defs is mostly transient pre-edit
221
+ # state (re-derived by prefetch next loop) — but prefetch only snapshots NON-edited files, so the
222
+ # pre-edit baseline for an EDITED file would be lost here and the change-set-closure (stale_deps)
223
+ # could never detect a removed symbol's dangling callers next turn. So KEEP pre_defs for the carried
224
+ # change-set (bounded by it), drop the exploratory rest. (world is intentionally durable: kept).
225
+ if len(self.findings) > MAX_FINDINGS:
226
+ self.findings = self.findings[-MAX_FINDINGS:]
227
+ live = set(self.findings)
228
+ self.finding_source = {k: v for k, v in self.finding_source.items() if k in live}
229
+ self.pre_defs = {p: d for p, d in self.pre_defs.items() if p in self.edited_files}
230
+ # CARRY the in-progress change-set resident; SEAL exploratory reads (re-readable / recallable).
231
+ self.active_files = [p for p in self.active_files if p in self.edited_files]
232
+ # Keep edited_files ⊆ active_files coherent: drop any phantom edit that is no longer resident, so a
233
+ # restored/desynced state can't feed ghost files into prefetch's change-set closure next turn.
234
+ self.edited_files = type(self.edited_files)(p for p in self.edited_files if p in self.active_files)
235
+ self.edit_anchor = {p: a for p, a in self.edit_anchor.items() if p in self.edited_files}
236
+ self.protected_deps = set() # re-derived from the carried change-set by prefetch on next build
237
+ self.stale_deps = set()
238
+
239
+
240
+ def touch_file(s: Slice, path: str, edited: bool = False) -> None:
241
+ """Shim → SwapManager.load (swap.py owns the file load→evict→ghost lifecycle). Signature unchanged."""
242
+ _DEFAULT_SWAP.load(s, path, edited=edited)
243
+
244
+
245
+ def add_skill(s: Slice, name: str, body: str) -> None:
246
+ """Shim → SwapManager.load_skill (swap.py owns skill load/evict + ghosts). Signature unchanged."""
247
+ _DEFAULT_SWAP.load_skill(s, name, body)
248
+
249
+
250
+ def _active(state):
251
+ """Resolve the current Slice from a Slice or a Session (host-side topic manager)."""
252
+ return state.active() if hasattr(state, "active") else state
253
+
254
+
255
+ def record_user(s: Slice, message: str) -> None:
256
+ """Append the user's message to the short-range CONVERSATION ring and count the turn. The host
257
+ calls this once per user message; slice_sink fills the assistant side as the turn produces text.
258
+ Bounded ring — older exchanges live in the durable cache, paged in on demand (not kept here)."""
259
+ s.turns += 1
260
+ s.turn_actions = 0 # new user turn → reset the per-turn exploration budget (drives the explore-nudge)
261
+ s.conversation.append({"user": one_line(message, CONVO_MSG_CHARS), "assistant": ""})
262
+ s.conversation = s.conversation[-MAX_CONVERSATION:]
263
+
264
+
265
+ def consolidate_checkpoint(s: "Slice", *, compact: bool = True) -> str:
266
+ """F1 — the CHECKPOINT: a deterministic, BOUNDED re-projection of the carried task state into ONE dense
267
+ 'state of play' snapshot (intent · decisions · change-set · open/next, plus a findings digest in full
268
+ mode). Pure (no LLM) — built from the durable tiers seal() already carries, so it adds LEGIBILITY +
269
+ a single resume/rebuild artifact, never new state. `compact=True` is the steady-state slice tier (no
270
+ findings re-list — those have their own tier); `compact=False` is the FULL artifact for the overflow
271
+ REBUILD (where the detailed tiers are gone, so the snapshot must stand alone). Self-suppresses when
272
+ there is nothing to report (a fresh greeting → no bytes)."""
273
+ from .finding_types import RULED_OUT, classify_finding # typed decisions read sharper in the snapshot
274
+ lines: list[str] = []
275
+ goal = (s.goal or "").strip()
276
+ if goal:
277
+ lines.append(f"intent: {one_line(goal, 240)}")
278
+ open_reqs = [r.get("text", "") for r in s.requirements if isinstance(r, dict) and not r.get("done")]
279
+ if open_reqs:
280
+ lines.append("requirements: " + " · ".join(one_line(t, 80) for t in open_reqs[:5]))
281
+ decisions = [f for f in s.findings if classify_finding(f) in ("decision", RULED_OUT)]
282
+ if decisions:
283
+ lines.append("decisions:")
284
+ lines += [f" - {one_line(d, 160)}" for d in decisions[-4:]]
285
+ if s.edited_files:
286
+ ch = sorted(s.edited_files)
287
+ lines.append("change-set: " + ", ".join(ch[:8]) + (f" (+{len(ch) - 8})" if len(ch) > 8 else ""))
288
+ if not compact: # FULL artifact: include the non-decision findings digest
289
+ facts = [f for f in s.findings if f not in decisions]
290
+ if facts:
291
+ lines.append("findings:")
292
+ lines += [f" - {one_line(f, 160)}" for f in facts[-8:]]
293
+ if s.open_report:
294
+ lines.append(f"open: {one_line(s.open_report, 200)}")
295
+ return "\n".join(lines)
296
+
297
+
298
+ def _history_mark(args: dict) -> str:
299
+ """A short label for a recall_history lookback, so the slice records WHAT was already reviewed."""
300
+ if not isinstance(args, dict):
301
+ return ""
302
+ full = "·full" if args.get("full") else ""
303
+ if args.get("turns"):
304
+ nums = []
305
+ for t in (args["turns"] if isinstance(args["turns"], (list, tuple)) else []):
306
+ try:
307
+ nums.append(int(t))
308
+ except (TypeError, ValueError):
309
+ pass # a malformed turn id is just a bad display label — never abort the slice fold
310
+ return f"turns={sorted(nums)}" + full
311
+ if args.get("last"):
312
+ try:
313
+ return f"last={int(args['last'])}" + full
314
+ except (TypeError, ValueError):
315
+ return "index"
316
+ return "index"
317
+
318
+
319
+ def slice_sink(state):
320
+ """Event sink that folds tool results back into the tiers (keeps the loop decoupled). `state`
321
+ is a Slice or a Session — events fold into the CURRENT active slice (so a topic switch redirects
322
+ subsequent folding)."""
323
+ def sink(event: Event) -> None:
324
+ s = _active(state)
325
+ # I1 PROVENANCE (root-cause revision) — fold the model's reasoning forward as an UNVERIFIED
326
+ # CLAIM, never as an established fact. Dropping assistant text ENTIRELY (the first I1 cut)
327
+ # starved the anti-re-derivation tier and ~2x'd steps on normal tasks; the defect was narration
328
+ # becoming FACT, not carry-forward itself. record_note drops pure narration (_NARRATION_RE),
329
+ # downgrades "done"-style claims, and bounds+dedups the ring — so this restores reasoning-reuse
330
+ # WITHOUT reviving the "already done" ratchet: a claim renders as "verify against OPEN FILES",
331
+ # and OPEN FILES stays the only ground truth. (The episode cache keeps assistant text losslessly.)
332
+ if isinstance(event, AssistantText):
333
+ record_note(s, event.content, source="claim")
334
+ if s.conversation and (event.content or "").strip():
335
+ # fill the assistant side of the in-progress exchange — the LAST AssistantText of the
336
+ # turn wins, so this ends up holding the final reply shown to the user (continuity).
337
+ full = event.content
338
+ s.conversation[-1]["assistant"] = one_line(full, CONVO_MSG_CHARS)
339
+ # RECALL BRIDGE (core cross-turn continuity): flag when the reply was CUT to the gist, so
340
+ # the NEXT turn's RECENT CONVERSATION advertises the recall_history call to page the FULL
341
+ # reply back. Without this the model reads an 800-char gist as the complete reply and
342
+ # confabulates anything past it ("explain item 2" of a long report it can no longer see)
343
+ # instead of recalling — the failure the whole cache-not-log design exists to prevent.
344
+ s.conversation[-1]["truncated"] = len(one_line(full, CONVO_MSG_CHARS + 1)) > CONVO_MSG_CHARS
345
+ return
346
+ if isinstance(event, ToolResult):
347
+ # the model's distilled conclusion rides on the tool call (the note arg) — fold it into
348
+ # the FINDINGS tier so a reasoning model reuses it instead of re-deriving next turn. A note
349
+ # on a NON-FAILING call is backed by a real tool result (source "tool-note"); a note on a
350
+ # FAILING call has no observation behind it → "claim" (rendered as unverified). record_note
351
+ # further downgrades any "done"-style note to "claim" unless an observation backs it.
352
+ new_finding = record_note(s, event.args.get("note", ""),
353
+ source="tool-note" if not event.failing else "claim")
354
+ # WORLD MODEL — fold world_set/world_clear into the durable scratchpad (the note→findings seam,
355
+ # but structured key→value). The tool handler only confirms; the STATE lives here so it renders
356
+ # into each turn's seed, survives the seal, and clears on reset.
357
+ if event.name == "world_set" and not event.failing:
358
+ _k = str(event.args.get("key", "")).strip()
359
+ if _k:
360
+ s.world[_k] = str(event.args.get("value", ""))
361
+ elif event.name == "world_clear" and not event.failing:
362
+ _k = str(event.args.get("key", "")).strip()
363
+ if _k:
364
+ s.world.pop(_k, None)
365
+ else:
366
+ s.world.clear()
367
+ # STANDING REQUIREMENTS — fold require/requirement_done/drop_requirement into the carried
368
+ # contract (same seam; the handler only confirms). Text-matched + idempotent so a re-emit is a
369
+ # no-op (byte-stable prefix); append-only + status-flip-in-place so a change never reorders
370
+ # existing lines; no match on done/drop = no-op (never silently corrupt the contract).
371
+ elif event.name in ("require", "requirement_done", "drop_requirement") and not event.failing:
372
+ _t = " ".join(str(event.args.get("text", "")).split())[:MAX_REQ_CHARS]
373
+ if _t:
374
+ _hit = next((r for r in s.requirements if isinstance(r, dict) and r.get("text", "").lower() == _t.lower()), None)
375
+ if event.name == "require":
376
+ if _hit is None:
377
+ s.requirements.append({"text": _t, "done": False})
378
+ del s.requirements[:-MAX_REQUIREMENTS] # bound: keep the most recent N
379
+ elif event.name == "requirement_done":
380
+ if _hit:
381
+ _hit["done"] = True
382
+ elif _hit: # drop_requirement
383
+ s.requirements.remove(_hit)
384
+ # PLAN (TodoWrite) — fold update_plan: the model sends the FULL ordered list each call, so this
385
+ # REPLACES s.plan (validated + bounded). Distinct from requirements (criteria); this is the step
386
+ # sequence + live progress. Replace-all keeps it simple and always consistent with the model's view.
387
+ elif event.name == "update_plan" and not event.failing:
388
+ _new = []
389
+ for _it in (event.args.get("steps") or [])[:MAX_PLAN_ITEMS]:
390
+ if not isinstance(_it, dict):
391
+ continue
392
+ _step = " ".join(str(_it.get("step", "")).split())[:MAX_PLAN_CHARS]
393
+ _st = str(_it.get("status", "pending")).strip().lower()
394
+ if _st not in ("pending", "in_progress", "done"):
395
+ _st = "pending"
396
+ if _step:
397
+ _new.append({"step": _step, "status": _st})
398
+ s.plan = _new
399
+ # MISSION (north star) — fold set_mission/mission_done (the handler only confirms; STATE here).
400
+ elif event.name == "set_mission" and not event.failing:
401
+ s.mission = " ".join(str(event.args.get("text", "")).split())[:MAX_MISSION_CHARS]
402
+ elif event.name == "mission_done" and not event.failing:
403
+ s.mission = ""
404
+ # FAN-IN: a subagent/explorer reports its result as the tool OUTPUT (not the note arg). Fold that
405
+ # distilled summary into the carried FINDINGS tier (observed) so it survives the turn-boundary seal —
406
+ # the parent reconciles summaries, never the children's transcripts (the swarm's no-bloat guarantee).
407
+ if event.name in ("spawn_subagent", "spawn_explore", "spawn_agent") and not event.failing and event.output:
408
+ new_finding = record_note(s, event.output, source="observed") or new_finding
409
+ did_edit = False
410
+ if event.name == "skill" and not event.failing:
411
+ # a loaded skill's body must enter the ACTIVE SKILL tier or it vanishes
412
+ # next turn (no transcript). The skill tool returns the body as its output.
413
+ add_skill(s, event.args.get("name", ""), event.output)
414
+ if event.name == "recall_history" and not event.failing:
415
+ # RATCHET (via SwapManager): record the lookback so render_reviewed shows it next rebuild.
416
+ _DEFAULT_SWAP.note_review(s, _history_mark(event.args))
417
+ # list_files/grep/glob "path" is a DIRECTORY scope, not a working-set FILE — don't pin it (else
418
+ # build_artifacts read_text(dir) → IsADirectoryError → a bogus OPEN FILES entry every turn).
419
+ if event.args.get("path") and event.name not in ("list_files", "grep", "glob"):
420
+ did_edit = event.name in ("edit_file", "append_to_file", "str_replace") and not event.failing
421
+ # WS1 — gate membership on SUCCESS. A read/edit that FAILED (e.g. _resolve raised
422
+ # "path escapes workspace") must NOT be pinned into the working set, or OPEN FILES
423
+ # re-renders the unreachable/missing path every rebuild and poisons the slice
424
+ # (the read-blindness loop). Successful reads and edits still join the set.
425
+ if not event.failing:
426
+ touch_file(s, event.args["path"], edited=did_edit)
427
+ # remember the agent's edit target so a HUGE file's region follows it (and a
428
+ # failed str_replace re-aims the region to where the agent meant to edit)
429
+ if event.name == "str_replace" and event.args.get("old_string"):
430
+ anchor = next((ln.strip() for ln in event.args["old_string"].splitlines() if ln.strip()), "")
431
+ if anchor:
432
+ s.edit_anchor[event.args["path"]] = anchor[:80]
433
+ elif event.name == "execute_code":
434
+ code = event.args.get("code", "")
435
+ mutated = set(edited_paths_in_code(code))
436
+ did_edit = bool(mutated) and not event.failing
437
+ # WS1 parity with the file-tool branch above: gate working-set/change-set membership on
438
+ # SUCCESS. A FAILED execute_code (e.g. NameError before write_file ran) must NOT pin the
439
+ # files it MENTIONS — that poisons edited_files with phantom writes (false "done") and
440
+ # active_files with phantom reads (read-blindness), and survives the seal.
441
+ if not event.failing:
442
+ for p in paths_in_code(code):
443
+ touch_file(s, p, edited=(p in mutated)) # code-as-action edits join the change set
444
+ record_action(s, event.name, event.args, event.output, failing=event.failing)
445
+ # convergence tracking: a real edit OR a genuinely-new finding resets the spin counter —
446
+ # actively LEARNING (recording new facts) is progress, not spinning (review #5). Only a call
447
+ # that neither edits nor learns advances the convergence/no-progress counter.
448
+ s.since_edit = 0 if (did_edit or new_finding) else s.since_edit + 1
449
+ return sink
sliceagent/plugins.py ADDED
@@ -0,0 +1,127 @@
1
+ """Plugins — packaging that feeds the existing seams (③.4).
2
+
3
+ A plugin is a directory with a `plugin.toml` manifest and
4
+ an `__init__.py` exposing `register(ctx)`. Through `ctx` it contributes to the SAME seams
5
+ everything else uses — the tool registry, the skill manager, MCP servers, and hooks — so a
6
+ plugin gets NO privileged surface and reuses all the sandbox/policy/scheduler machinery.
7
+
8
+ A broken plugin logs and is skipped; it never crashes the host.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import importlib.util
13
+ import os
14
+ try:
15
+ import tomllib
16
+ except ModuleNotFoundError: # Python < 3.11
17
+ import tomli as tomllib
18
+
19
+ from .access import AllAccess
20
+ from .registry import ToolEntry
21
+
22
+
23
+ def _default_dirs() -> list[str]:
24
+ return [
25
+ os.path.join(os.getcwd(), ".sliceagent", "plugins"),
26
+ os.path.join(os.path.expanduser("~"), ".sliceagent", "plugins"),
27
+ ]
28
+
29
+
30
+ class PluginContext:
31
+ """The single facade a plugin's `register(ctx)` uses. Everything it contributes flows into
32
+ existing seams; aggregated MCP servers + hooks are returned to the host to connect/compose."""
33
+
34
+ def __init__(self, name: str, registry, skills, *, root: str, config):
35
+ self.name = name
36
+ self.registry = registry # shared ToolRegistry
37
+ self.skills = skills # SkillManager
38
+ self.root = root # workspace root
39
+ self.config = config # Config
40
+ self.mcp_servers: dict = {} # collected; host connects them
41
+ self.hooks: list = [] # collected Hooks instances; host composes them
42
+ self.counts = {"tools": 0, "skills": 0, "mcp": 0, "hooks": 0}
43
+
44
+ def register_tool(self, name: str, description: str, handler, *, parameters: dict | None = None,
45
+ accesses=None, check=None, override: bool = False) -> None:
46
+ schema = {"type": "function", "function": {
47
+ "name": name, "description": description,
48
+ "parameters": parameters or {"type": "object", "properties": {}}}}
49
+ self.registry.register(ToolEntry(
50
+ name=name, schema=schema, handler=handler,
51
+ accesses=accesses or (lambda _a: [AllAccess()]), check=check,
52
+ source=f"plugin:{self.name}"), override=override)
53
+ self.counts["tools"] += 1
54
+
55
+ def register_skill(self, name: str, body: str, description: str = "") -> None:
56
+ self.skills.add(name, body, description=description)
57
+ self.counts["skills"] += 1
58
+
59
+ def register_mcp_server(self, name: str, config: dict) -> None:
60
+ self.mcp_servers[name] = config
61
+ self.counts["mcp"] += 1
62
+
63
+ def register_hook(self, hook) -> None: # a Hooks instance
64
+ self.hooks.append(hook)
65
+ self.counts["hooks"] += 1
66
+
67
+ def log(self, msg: str) -> None:
68
+ print(f" · plugin:{self.name}: {msg}")
69
+
70
+
71
+ def _load_one(pdir: str, registry, skills, *, root, config, on_log) -> PluginContext:
72
+ try:
73
+ with open(os.path.join(pdir, "plugin.toml"), "rb") as f:
74
+ meta = tomllib.load(f)
75
+ except (OSError, tomllib.TOMLDecodeError):
76
+ meta = {}
77
+ name = (meta.get("name") or os.path.basename(pdir)).strip()
78
+ ctx = PluginContext(name, registry, skills, root=root, config=config)
79
+ try:
80
+ spec = importlib.util.spec_from_file_location(
81
+ f"sliceagent_plugin_{name}", os.path.join(pdir, "__init__.py"))
82
+ mod = importlib.util.module_from_spec(spec)
83
+ spec.loader.exec_module(mod)
84
+ register = getattr(mod, "register", None)
85
+ if register is None:
86
+ on_log(f"plugin:{name} has no register(ctx), skipped")
87
+ return ctx
88
+ register(ctx)
89
+ c = ctx.counts
90
+ on_log(f"plugin:{name} loaded (tools={c['tools']} skills={c['skills']} "
91
+ f"mcp={c['mcp']} hooks={c['hooks']})")
92
+ except Exception as e: # a broken plugin must never crash the host
93
+ on_log(f"plugin:{name} failed: {e}")
94
+ return ctx
95
+
96
+
97
+ def load_plugins(registry, skills, dirs: list[str] | None = None, *, root: str, config,
98
+ on_log=lambda m: None) -> tuple[dict, list]:
99
+ """Discover + load plugins from (provided dirs + defaults). Each contributes to the shared
100
+ registry/skills; returns aggregated (mcp_servers, hooks) for the host to connect/compose."""
101
+ search = list(dict.fromkeys((dirs or []) + _default_dirs()))
102
+ mcp_servers: dict = {}
103
+ hooks: list = []
104
+ def _ls(r):
105
+ try:
106
+ return sorted(os.listdir(r))
107
+ except OSError: # an unreadable plugin search dir must not crash the whole host
108
+ return []
109
+ found = [os.path.join(r, e) for r in search if os.path.isdir(r) for e in _ls(r)
110
+ if os.path.isfile(os.path.join(r, e, "plugin.toml"))
111
+ and os.path.isfile(os.path.join(r, e, "__init__.py"))]
112
+ # SECURITY (#6/#7): a plugin's __init__.py executes with FULL host privileges, and a plugin hook can
113
+ # allow/deny any tool and rewrite messages/results. Loading is therefore OPT-IN: require an explicit
114
+ # AGENT_ALLOW_PLUGINS=1 before running ANY plugin code — otherwise a plugin dropped in ~/.sliceagent/
115
+ # plugins (a default search dir) would auto-execute silently. Default off = safe.
116
+ allowed = (os.environ.get("AGENT_ALLOW_PLUGINS") or "").strip().lower() in ("1", "true", "yes", "on")
117
+ if not allowed:
118
+ if found:
119
+ on_log(f"{len(found)} plugin(s) present but NOT loaded — set AGENT_ALLOW_PLUGINS=1 to run them "
120
+ "(plugin code executes with HOST privileges and can register auth hooks)")
121
+ return mcp_servers, hooks
122
+ for pdir in found:
123
+ on_log(f"⚠ loading TRUSTED plugin {os.path.basename(pdir)} — runs with host privileges")
124
+ ctx = _load_one(pdir, registry, skills, root=root, config=config, on_log=on_log)
125
+ mcp_servers.update(ctx.mcp_servers)
126
+ hooks.extend(ctx.hooks)
127
+ return mcp_servers, hooks