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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """sliceagent — a memory-native coding agent (deterministic, bounded, reconstructed context)."""
2
+
3
+ __version__ = "0.1.0"
sliceagent/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Enable `python -m sliceagent`. The console-script (`sliceagent`) regenerates with a stale shebang when the
2
+ venv is moved, so this module path is the reliable way to launch from a source checkout."""
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
sliceagent/access.py ADDED
@@ -0,0 +1,93 @@
1
+ """Resource-access model for safe tool parallelism.
2
+
3
+ Each tool declares what it touches; the scheduler runs non-conflicting tool calls
4
+ concurrently and serializes conflicting ones. Two accesses conflict iff one is
5
+ `AllAccess` (globally exclusive, e.g. a shell), OR one writes AND their paths overlap.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class FileAccess:
14
+ operation: str # "read" | "write" | "readwrite" | "search"
15
+ path: str
16
+ recursive: bool = False
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class AllAccess:
21
+ """An un-representable side effect (shell, network). Globally exclusive."""
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ReadAllAccess:
26
+ """A workspace-WIDE READ with no side effect (a read-only explorer subagent: it may read/list/grep/
27
+ recall anywhere, but cannot write/run). Conflicts with any WRITER (AllAccess or a write FileAccess)
28
+ but NEVER with another ReadAllAccess or a plain read — so N explorers fan out concurrently while any
29
+ writing child still serializes. This is what turns the dormant one-at-a-time swarm into a real swarm."""
30
+
31
+
32
+ Access = FileAccess | AllAccess | ReadAllAccess
33
+ Accesses = list[Access]
34
+
35
+
36
+ # convenience builders
37
+ def none() -> Accesses:
38
+ return []
39
+
40
+
41
+ def all_() -> Accesses:
42
+ return [AllAccess()]
43
+
44
+
45
+ def read_file(path: str) -> Accesses:
46
+ return [FileAccess("read", path)]
47
+
48
+
49
+ def write_file(path: str) -> Accesses:
50
+ return [FileAccess("readwrite", path)]
51
+
52
+
53
+ def search_tree(path: str) -> Accesses:
54
+ return [FileAccess("search", path, recursive=True)]
55
+
56
+
57
+ def _writes(op: str) -> bool:
58
+ return op in ("write", "readwrite")
59
+
60
+
61
+ def _norm(path: str) -> str:
62
+ p = path.replace("\\", "/")
63
+ while "//" in p:
64
+ p = p.replace("//", "/")
65
+ p = p.lower()
66
+ return p[:-1] if len(p) > 1 and p.endswith("/") else p
67
+
68
+
69
+ def _overlap(left: FileAccess, right: FileAccess) -> bool:
70
+ lp, rp = _norm(left.path), _norm(right.path)
71
+ if lp == rp:
72
+ return True
73
+ lpre = lp if lp.endswith("/") else lp + "/"
74
+ rpre = rp if rp.endswith("/") else rp + "/"
75
+ return (left.recursive and rp.startswith(lpre)) or (right.recursive and lp.startswith(rpre))
76
+
77
+
78
+ def _pair_conflict(left: Access, right: Access) -> bool:
79
+ if isinstance(left, AllAccess) or isinstance(right, AllAccess):
80
+ return True
81
+ ra_left, ra_right = isinstance(left, ReadAllAccess), isinstance(right, ReadAllAccess)
82
+ if ra_left or ra_right:
83
+ if ra_left and ra_right:
84
+ return False # two workspace-wide reads never conflict
85
+ other = right if ra_left else left # the other side is a FileAccess (AllAccess handled above)
86
+ return _writes(other.operation) # read-all conflicts ONLY with a write, never with a read
87
+ if not (_writes(left.operation) or _writes(right.operation)):
88
+ return False # read/read, read/search never conflict
89
+ return _overlap(left, right)
90
+
91
+
92
+ def conflict(left: Accesses, right: Accesses) -> bool:
93
+ return any(_pair_conflict(a, b) for a in left for b in right)
sliceagent/agents.py ADDED
@@ -0,0 +1,173 @@
1
+ """Named-agent registry — file-defined subagent KINDS.
2
+
3
+ sliceagent's subagents were two HARDCODED kinds (read-only explorer + writable). The kinds are now a
4
+ pluggable REGISTRY: each agent is a {name, description, tools-allowlist, reasoning, system-prompt}
5
+ definition, discovered from `<root>/agents/*.md` (markdown + frontmatter — sliceagent's own SKILL.md idiom),
6
+ and the model spawns one BY NAME via the generic `spawn_agent` tool. Built-ins (explorer, general) ship
7
+ in-tree; user files add or override by name.
8
+
9
+ Periphery, NOT the moat: a spawned agent still runs the bounded slice loop and returns only a summary.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import glob
14
+ import os
15
+ from dataclasses import dataclass
16
+
17
+ # An EXPLORER's read-only surface — the single source of truth (subagent.py imports this).
18
+ # `grep` (find by CONTENT) + `glob` (find by NAME) are the two discovery tools; both read-only.
19
+ READ_ONLY_TOOLS = ("read_file", "list_files", "grep", "glob", "skill", "recall_history", "code_review")
20
+ _READ_ONLY_SET = frozenset(READ_ONLY_TOOLS) # mutability is decided against this KNOWN-safe set (pessimistic)
21
+
22
+ # Tools NO subagent may use, regardless of its allowlist. A
23
+ # subagent must not stop to ask the END-USER — ambiguity is the parent's job; a child that blocks on input
24
+ # is a stall (and racy/meaningless when several run in parallel). It returns its summary instead.
25
+ SUBAGENT_EXCLUDED_TOOLS = frozenset({"ask_user"})
26
+
27
+ # Mutating tools — an agent whose allowlist includes ANY of these is "writable" (globally serialized vs
28
+ # other writers); an allowlist with none of them is read-only (parallelizes as a swarm).
29
+ WRITE_TOOLS = frozenset({
30
+ "edit_file", "append_to_file", "str_replace", "run_command", "execute_code",
31
+ "world_set", "world_clear", "require", "drop_requirement", "requirement_done", "update_plan",
32
+ "set_mission", "mission_done",
33
+ "terminal_open", "terminal_send", "terminal_read", "terminal_wait", "terminal_close",
34
+ "proc_start", "proc_poll", "proc_tail", "proc_wait", "proc_kill",
35
+ "spawn_subagent", "spawn_explore", "spawn_agent",
36
+ })
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class AgentSpec:
41
+ """One subagent KIND. `tools=None` → inherit the parent's FULL tool surface (a 'general' agent)."""
42
+ name: str
43
+ description: str = ""
44
+ tools: tuple[str, ...] | None = None # allowlist of tool names the child may use (None = all)
45
+ reasoning: str | None = None # "fast" | "full" | None (inherit the parent's)
46
+ system_prompt: str = "" # extra system-prompt layer prepended for the child
47
+ summary_is_deliverable: bool = False # the child's SUMMARY is the product (a trailing failing check is
48
+ # intentional, not a crash) — like a verifier that ends on a FAIL.
49
+
50
+ @property
51
+ def read_only(self) -> bool:
52
+ """A child is read-only iff EVERY tool in its allowlist is a KNOWN read-only tool. Pessimistic by
53
+ design: an unknown / plugin / MCP tool is NOT assumed safe (the old check only excluded the static
54
+ WRITE_TOOLS names, so a side-effecting plugin tool was mis-classified read-only and could be
55
+ scheduled as a parallel non-writer). None (full surface) is writable."""
56
+ return self.tools is not None and set(self.tools).issubset(_READ_ONLY_SET)
57
+
58
+
59
+ BUILTIN_AGENTS: dict[str, AgentSpec] = {
60
+ "explorer": AgentSpec(
61
+ name="explorer",
62
+ description="Read-only investigation — find files, trace usages, understand code; returns a summary. "
63
+ "Fan out several in one turn for breadth.",
64
+ tools=READ_ONLY_TOOLS, reasoning="fast",
65
+ system_prompt="You are a read-only EXPLORER subagent: investigate the task by reading/grepping and "
66
+ "return a concise summary of what you found (files, locations, conclusions). You cannot "
67
+ "modify anything — do not attempt edits or commands.",
68
+ ),
69
+ "general": AgentSpec(
70
+ name="general",
71
+ description="A full sub-agent for ONE self-contained sub-task (can read AND edit/run); returns a summary.",
72
+ tools=None, reasoning=None,
73
+ system_prompt="You are a SUBAGENT handling one self-contained sub-task in the shared workspace. Do the "
74
+ "work, then return a concise summary of what you changed and verified. Do NOT ask the "
75
+ "user; if the task is ambiguous, make the best reasonable choice and note it in the summary.",
76
+ ),
77
+ # An independent ADVERSARIAL verifier. Runs in a FRESH
78
+ # slice and returns only a VERDICT + evidence — so it complements the parent's structural done-gates
79
+ # (OracleHook/SelfCheckHook) with a second, skeptical opinion WITHOUT any context crossing the seal.
80
+ # Read-only EXCEPT running checks: read/grep + run_command/execute_code (to build/test/probe), no edit
81
+ # tools (the allowlist is enforced at runtime in subagent.py). It is "writable" by classification (shell
82
+ # is in WRITE_TOOLS) so it serializes vs other writers — correct for a verifier that runs tests.
83
+ "verification": AgentSpec(
84
+ name="verification",
85
+ description="Independent adversarial VERIFIER — given a change/claim, TRY TO BREAK IT (reproduce, run "
86
+ "build/tests, probe edges) and return VERDICT: PASS/FAIL/PARTIAL with command evidence. "
87
+ "Read-only except running checks. Spawn after a non-trivial change, before reporting done.",
88
+ tools=READ_ONLY_TOOLS + ("run_command", "execute_code"),
89
+ reasoning="full",
90
+ summary_is_deliverable=True, # a FAIL verdict normally ends on a failing check — that's the product,
91
+ # not a crash; don't reclassify it as "did not finish cleanly".
92
+ system_prompt=(
93
+ "You are an independent VERIFICATION subagent. Your job is NOT to confirm the work is done — it is "
94
+ "to TRY TO BREAK IT. You are given a task/claim and the change that was made; verify it "
95
+ "INDEPENDENTLY and decide.\n"
96
+ "Avoid two failure modes: (1) verification AVOIDANCE — reading code and narrating what you WOULD "
97
+ "test, then writing PASS. Reading is NOT verification; RUN it. (2) being seduced by the first 80% "
98
+ "— a passing test suite or the happy path is not proof; your value is the last 20%.\n"
99
+ "DO NOT MODIFY THE PROJECT: no editing/creating/deleting project files, no installing deps, no git "
100
+ "writes. You MAY write EPHEMERAL probe scripts to a temp dir WHERE THE SANDBOX ALLOWS (e.g. $TMPDIR "
101
+ "or /tmp, via run_command/execute_code) and clean up after yourself.\n"
102
+ "Method: REPRODUCE the original issue/scenario; run the cheapest sufficient build/test; then RUN at "
103
+ "least ONE adversarial probe — a boundary/empty/large input, idempotency, the EXACT property the "
104
+ "task names, or a related path that could regress. The implementer is also an LLM, so its tests may "
105
+ "be happy-path — verify end-to-end yourself.\n"
106
+ "Before PASS: you must have RUN at least one adversarial probe and observed its real output. Before "
107
+ "FAIL: check the issue isn't already handled elsewhere or intentional.\n"
108
+ "Format every check as — Check: <what> / Command: <exact> / Output: <actual observed, not "
109
+ "paraphrased> / PASS or FAIL. A check with no command output is a SKIP, not a PASS. END your "
110
+ "summary with EXACTLY one line: 'VERDICT: PASS' or 'VERDICT: FAIL' or 'VERDICT: PARTIAL' (PARTIAL "
111
+ "only for environment limits — missing tool/deps/can't run — never for 'unsure'). Do NOT ask the user."
112
+ ),
113
+ ),
114
+ }
115
+
116
+
117
+ def _parse_agent_md(path: str) -> AgentSpec | None:
118
+ """Parse an agent file: optional `---` frontmatter (name/description/tools/reasoning) + body = system
119
+ prompt. Never raises — a malformed/unreadable file is skipped (returns None)."""
120
+ try:
121
+ with open(path, encoding="utf-8") as f:
122
+ text = f.read()
123
+ except OSError:
124
+ return None
125
+ meta: dict[str, str] = {}
126
+ body = text
127
+ if text.startswith("---"):
128
+ end = text.find("\n---", 3)
129
+ if end == -1:
130
+ # opening fence but no closing one (authoring typo). FAIL CLOSED: don't fall through to the
131
+ # no-frontmatter path, which would leave tools=None (= full writable surface) for a file that
132
+ # was trying to declare a restrictive tool list. Skip it, per the "malformed → skipped" contract.
133
+ return None
134
+ if end != -1:
135
+ for line in text[3:end].splitlines():
136
+ line = line.strip()
137
+ if ":" in line and not line.startswith("#"):
138
+ k, v = line.split(":", 1)
139
+ meta[k.strip().lower()] = v.strip()
140
+ body = text[end + 4:].lstrip("\n")
141
+ name = meta.get("name") or os.path.splitext(os.path.basename(path))[0]
142
+ if not name:
143
+ return None
144
+ tools_raw = meta.get("tools")
145
+ # #58: accept both the scalar list `tools: a, b` AND inline YAML `tools: [a, b]` — strip brackets/quotes
146
+ # before splitting so a bracketed value doesn't become tool names like "[a".
147
+ # A PRESENT-but-blank `tools:` means restrict to ZERO tools (read-only, matching `tools: []`); only an
148
+ # ABSENT key grants the full writable surface (None).
149
+ if "tools" in meta and not str(tools_raw or "").strip():
150
+ tools = ()
151
+ elif tools_raw:
152
+ tools = tuple(t for t in tools_raw.replace(",", " ").replace("[", " ").replace("]", " ")
153
+ .replace("'", " ").replace('"', " ").split() if t)
154
+ else:
155
+ tools = None
156
+ reasoning = (meta.get("reasoning") or "").lower() or None
157
+ return AgentSpec(name=name, description=meta.get("description", ""),
158
+ tools=tools, reasoning=reasoning, system_prompt=body.strip())
159
+
160
+
161
+ def load_agents(roots) -> dict[str, AgentSpec]:
162
+ """Built-in agents overlaid with user-defined `<root>/agents/*.md` (later roots / user files win by
163
+ name). `roots` are dirs that MAY contain an `agents/` subdir."""
164
+ out = dict(BUILTIN_AGENTS)
165
+ for root in roots or []:
166
+ adir = os.path.join(root, "agents")
167
+ if not os.path.isdir(adir):
168
+ continue
169
+ for path in sorted(glob.glob(os.path.join(adir, "*.md"))):
170
+ spec = _parse_agent_md(path)
171
+ if spec:
172
+ out[spec.name] = spec
173
+ return out
@@ -0,0 +1,146 @@
1
+ """Async background-review fork (item 16) — OPT-IN, OFF by default, behind an env flag.
2
+
3
+ After a turn, fork a daemon thread that critiques the just-finished episode and writes lessons
4
+ to the memory store. sliceagent has NO transcript to replay, so
5
+ the fork instead re-runs the EXISTING consolidation code (consolidate.promote_episodes /
6
+ promote_procedures) over the durable episodic JSONL — same write surface as session-end
7
+ consolidate, just incremental and off the critical path.
8
+
9
+ WHY THIS CAN'T DESTABILIZE THE DEFAULT PATH (the hard requirement):
10
+ - OFF by default. Only runs when AGENT_BACKGROUND_REVIEW is truthy.
11
+ - The fork reads ONLY the durable episodic cache (already flushed to disk by hippocampus.py's EpisodeSink)
12
+ and writes ONLY to durable stores (memem remember / SKILL.md / FTS5 index). It NEVER
13
+ touches the Slice, the Session, the loop, the dispatcher, or the prompt cache.
14
+ - It is a daemon thread: it can't block process exit and an exception in it is swallowed.
15
+ - Re-entrancy guard: at most one review in flight; a new turn while one runs is skipped.
16
+ - The main turn has already returned before this is even spawned (cli wires it AFTER
17
+ run_turn), so latency is never on the user's critical path.
18
+
19
+ NO-TRANSCRIPT INVARIANT: upheld structurally — the worker's only inputs are durable records
20
+ and its only outputs are durable stores. Nothing it does can enter a slice tier directly; a
21
+ lesson it writes is recalled later through the SAME relevance-gated memory tier as any other.
22
+
23
+ PUBLIC SIGNATURES (pinned):
24
+ background_review_enabled() -> bool
25
+ class BackgroundReviewer:
26
+ def __init__(self, memory, *, scope: str, on_log=None) -> None
27
+ def review(self, session_id: str) -> None # spawns the daemon (no-op if disabled/busy)
28
+ def join(self, timeout: float | None = None) -> None # tests/shutdown: wait for the worker
29
+ make_background_reviewer(memory, *, scope: str, on_log=None) -> BackgroundReviewer | None
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import os
34
+ import threading
35
+
36
+ _ENV_FLAG = "AGENT_BACKGROUND_REVIEW"
37
+
38
+
39
+ def background_review_enabled() -> bool:
40
+ """True iff AGENT_BACKGROUND_REVIEW is set truthy. OFF by default — the whole feature is
41
+ inert unless explicitly opted in, so the synchronous default path is byte-for-byte unchanged."""
42
+ return str(os.environ.get(_ENV_FLAG, "")).strip().lower() in ("1", "true", "yes", "on")
43
+
44
+
45
+ class BackgroundReviewer:
46
+ """Forks a daemon thread per review that consolidates the latest episode incrementally.
47
+
48
+ Reuses consolidate.promote_episodes / promote_procedures verbatim (the moat's existing
49
+ write logic) — this module adds ONLY the fork + safety scaffolding, no new mining logic.
50
+ """
51
+
52
+ def __init__(self, memory, *, scope: str, on_log=None) -> None:
53
+ self.memory = memory
54
+ self.scope = scope
55
+ self._on_log = on_log
56
+ self._lock = threading.Lock()
57
+ self._busy = False
58
+ self._thread: threading.Thread | None = None
59
+
60
+ def review(self, session_id: str) -> None:
61
+ """Spawn the review daemon for `session_id`. No-op when disabled, when memory isn't
62
+ durable, or when a prior review is still running (re-entrancy guard). Returns
63
+ immediately — the work happens off-thread."""
64
+ if not background_review_enabled():
65
+ return
66
+ if not getattr(self.memory, "is_durable", False):
67
+ return
68
+ with self._lock:
69
+ if self._busy:
70
+ return # at most one review in flight
71
+ self._busy = True
72
+ t = threading.Thread(
73
+ target=self._run, args=(session_id,),
74
+ name="sliceagent-bg-review", daemon=True)
75
+ # publish self._thread only AFTER a successful start: otherwise a start() failure both latches
76
+ # _busy=True forever (no more reviews) AND leaves join() calling .join() on a never-started thread.
77
+ try:
78
+ t.start()
79
+ except Exception: # noqa: BLE001 — a thread-spawn failure must not escape into the foreground caller
80
+ with self._lock:
81
+ self._busy = False
82
+ self._log("background review: thread start failed")
83
+ return
84
+ self._thread = t
85
+
86
+ def join(self, timeout: float | None = None) -> None:
87
+ """Wait for the in-flight review (for deterministic tests / clean shutdown)."""
88
+ t = self._thread
89
+ if t is not None:
90
+ t.join(timeout)
91
+
92
+ def _log(self, msg: str) -> None:
93
+ if self._on_log is not None:
94
+ try:
95
+ self._on_log(msg)
96
+ except Exception:
97
+ pass
98
+
99
+ def _run(self, session_id: str) -> None:
100
+ """Worker body — durable-in, durable-out. Every failure is swallowed: a background
101
+ critique must NEVER affect the foreground session."""
102
+ try:
103
+ from .neocortex import promote_episodes, promote_procedures, render_skill
104
+ records = self.memory.read_episodes(session_id)
105
+ if not records:
106
+ return
107
+ # critique the LATEST turn in the context of the session: promote_* are pure and
108
+ # frequency-weight across the whole session, so passing all records gives the newest
109
+ # corrective/procedural signal its proper recurrence count. Dedup against what's
110
+ # already stored is memem's job (remember is idempotent-ish on identical content).
111
+ lessons = promote_episodes(records)
112
+ for lesson in lessons:
113
+ try:
114
+ self.memory.remember(lesson["content"], title=lesson["title"], scope=self.scope,
115
+ tags=lesson["tags"], paths=lesson.get("files")) # file-context parity
116
+ except Exception:
117
+ pass
118
+ procs = promote_procedures(records)
119
+ if procs:
120
+ from .memory import _skills_dir, write_skill_file # SAME guarded writer as session-end
121
+ from .skill_provenance import AUTO, reset_authoring_origin, set_authoring_origin
122
+ skills_dir = _skills_dir()
123
+ token = set_authoring_origin(AUTO) # mark fork-authored skills curator-prunable
124
+ try:
125
+ for proc in procs:
126
+ # guarded writer: validate frontmatter + strict threat-scan + redact + ATOMIC replace
127
+ # (parity with memory.consolidate; closes the bypass + non-atomic-write bugs)
128
+ write_skill_file(proc["name"], render_skill(proc), skills_dir=skills_dir)
129
+ finally:
130
+ reset_authoring_origin(token)
131
+ self._log(f"background review: {len(lessons)} lesson(s), {len(procs)} skill(s)")
132
+ except Exception:
133
+ pass
134
+ finally:
135
+ with self._lock:
136
+ self._busy = False
137
+
138
+
139
+ def make_background_reviewer(memory, *, scope: str, on_log=None) -> BackgroundReviewer | None:
140
+ """Factory. Returns None when the feature is disabled OR memory isn't durable — so the
141
+ host can skip wiring entirely and the default path adds zero objects."""
142
+ if not background_review_enabled():
143
+ return None
144
+ if not getattr(memory, "is_durable", False):
145
+ return None
146
+ return BackgroundReviewer(memory, scope=scope, on_log=on_log)
sliceagent/binsniff.py ADDED
@@ -0,0 +1,89 @@
1
+ """Binary-file sniffing (build task: binsniff) — keep text-only tools from choking on binaries.
2
+
3
+ A read_file / str_replace / fuzzy edit only makes sense on TEXT. Feeding a .png, a .so, or a
4
+ mojibake blob through those paths corrupts the slice (garbage in OPEN FILES) and wastes tokens.
5
+ This module is the single cheap gate the file tools call BEFORE treating bytes as text.
6
+
7
+ Two independent signals, in cost order:
8
+ 1. Extension — a pure string check, no I/O (`has_binary_extension`). Catches the common case
9
+ (.png/.so/.zip/...) without ever reading the file.
10
+ 2. Content — a NUL byte, or >30% non-printable control chars (excluding \\n \\r \\t) in a
11
+ head sample, marks bytes that decoded to text but clearly are not text.
12
+
13
+ NO-TRANSCRIPT INVARIANT: pure functions over a path string + a head sample; no state, no growing
14
+ context, no I/O of their own. The caller supplies the sample (already bounded to a file head).
15
+
16
+ PUBLIC SIGNATURES (pinned):
17
+ BINARY_EXTENSIONS: frozenset[str]
18
+ has_binary_extension(path: str) -> bool
19
+ looks_binary(path: str, sample: str) -> bool
20
+ """
21
+
22
+ # Extensions whose contents can't be meaningfully treated as text.
23
+ BINARY_EXTENSIONS = frozenset({
24
+ # Images
25
+ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".tiff", ".tif",
26
+ # Videos
27
+ ".mp4", ".mov", ".avi", ".mkv", ".webm", ".wmv", ".flv", ".m4v", ".mpeg", ".mpg",
28
+ # Audio
29
+ ".mp3", ".wav", ".ogg", ".flac", ".aac", ".m4a", ".wma", ".aiff", ".opus",
30
+ # Archives
31
+ ".zip", ".tar", ".gz", ".bz2", ".7z", ".rar", ".xz", ".z", ".tgz", ".iso",
32
+ # Executables/binaries
33
+ ".exe", ".dll", ".so", ".dylib", ".bin", ".o", ".a", ".obj", ".lib",
34
+ ".app", ".msi", ".deb", ".rpm",
35
+ # Documents (exclude .pdf — text-based, agents may want to inspect)
36
+ ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
37
+ ".odt", ".ods", ".odp",
38
+ # Fonts
39
+ ".ttf", ".otf", ".woff", ".woff2", ".eot",
40
+ # Bytecode / VM artifacts
41
+ ".pyc", ".pyo", ".class", ".jar", ".war", ".ear", ".node", ".wasm", ".rlib",
42
+ # Database files
43
+ ".sqlite", ".sqlite3", ".db", ".mdb", ".idx",
44
+ # Design / 3D
45
+ ".psd", ".ai", ".eps", ".sketch", ".fig", ".xd", ".blend", ".3ds", ".max",
46
+ # Flash
47
+ ".swf", ".fla",
48
+ # Lock/profiling data
49
+ ".lockb", ".dat", ".data",
50
+ })
51
+
52
+ # How many leading chars of the sample to inspect, and the non-printable fraction that
53
+ # tips a decoded-but-not-text blob over into "binary".
54
+ _HEAD = 1000
55
+ _NON_PRINTABLE_RATIO = 0.30
56
+
57
+
58
+ def has_binary_extension(path: str) -> bool:
59
+ """True if `path` has a known binary extension. Pure string check, no I/O."""
60
+ dot = path.rfind(".")
61
+ if dot == -1:
62
+ return False
63
+ return path[dot:].lower() in BINARY_EXTENSIONS
64
+
65
+
66
+ def looks_binary(path: str, sample: str) -> bool:
67
+ """True if `path` is binary by extension, or `sample` looks binary by content.
68
+
69
+ Content is binary when it contains a NUL byte, or when more than 30% of the first
70
+ 1000 chars are non-printable control chars (ord < 32, excluding \\n \\r \\t).
71
+
72
+ `sample == ''` means we have no content signal -> non-binary unless the extension says so.
73
+ O(head sample) only; never reads the file itself.
74
+ """
75
+ if has_binary_extension(path):
76
+ return True
77
+ if not sample:
78
+ return False
79
+ head = sample[:_HEAD]
80
+ if "\x00" in head: # a single NUL is a definitive binary marker
81
+ return True
82
+ non_printable = sum(1 for c in head if ord(c) < 32 and c not in "\n\r\t")
83
+ return non_printable / len(head) > _NON_PRINTABLE_RATIO
84
+
85
+
86
+ if __name__ == "__main__": # smoke: a .png ext and a NUL sample are binary; plain text is not
87
+ assert looks_binary("logo.png", "") and looks_binary("x.txt", "a\x00b")
88
+ assert not looks_binary("notes.txt", "plain readable text\nwith newlines\n")
89
+ print("binsniff smoke OK")