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/clock.py ADDED
@@ -0,0 +1,32 @@
1
+ """Clock abstraction.
2
+
3
+ Wraps wall-clock time behind a tiny interface so timed logic (cron) is DETERMINISTICALLY testable:
4
+ SystemClock in production, FakeClock in tests. Anything that needs "now" takes a clock instead of
5
+ calling time.time() directly.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import time
10
+
11
+
12
+ class SystemClock:
13
+ """Real wall-clock time (epoch seconds)."""
14
+
15
+ def now(self) -> float:
16
+ return time.time()
17
+
18
+
19
+ class FakeClock:
20
+ """Controllable clock for tests — `now()` returns a fixed value you `advance()` explicitly."""
21
+
22
+ def __init__(self, t: float = 0.0):
23
+ self._t = float(t)
24
+
25
+ def now(self) -> float:
26
+ return self._t
27
+
28
+ def advance(self, seconds: float) -> None:
29
+ self._t += float(seconds)
30
+
31
+
32
+ SYSTEM_CLOCK = SystemClock()
@@ -0,0 +1,329 @@
1
+ """Guarded `grep` tool — ripgrep pagination + a consecutive-search guard.
2
+
3
+ The single discovery-on-demand seam (W7 deleted `code_index.snippets`; the model now
4
+ greps for content instead). Two mechanisms:
5
+
6
+ - Grep pagination: run ripgrep, then slice the result lines by offset/limit and
7
+ append an explicit "[truncated; use offset=N to see more]" notice when more
8
+ remain.
9
+ - Consecutive-search guard (the `{last_key, consecutive}` tracker): the 4th
10
+ identical-in-a-row call returns a BLOCKED message instead of re-running
11
+ the same search forever. The key INCLUDES offset, so paging through truncated results
12
+ (a *different* offset each call) never trips the guard.
13
+
14
+ Moat note: GREP_GUARD is keyed by host identity (not a transcript). Every call sources
15
+ live from the workspace via ripgrep; no growing message history is assumed.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import shutil
20
+ import subprocess
21
+ import threading
22
+
23
+ from .access import FileAccess
24
+ from .registry import ToolEntry, ToolText
25
+
26
+ # {host_id: {"last_key": tuple | None, "consecutive": int}} — module-level, per-host.
27
+ # A per-task tracker shape. Not a transcript: a tiny durable counter.
28
+ GREP_GUARD: dict = {}
29
+ _GREP_LOCK = threading.Lock() # parallel explorers share GREP_GUARD; serialize the check-then-update
30
+
31
+ _BLOCK_AFTER = 4 # 4th identical-in-a-row call is blocked (count>=4)
32
+ _DEFAULT_LIMIT = 50
33
+ _RG_MAX_FILESIZE = "300K"
34
+ _RG_MAX_COLUMNS = "400"
35
+
36
+
37
+ _GREP_SCHEMA = {
38
+ "type": "function",
39
+ "function": {
40
+ "name": "grep",
41
+ "description": (
42
+ "Search file CONTENTS for a regex pattern (ripgrep) under the workspace. `output_mode` shapes the "
43
+ "result: 'content' (default — line-numbered file:line:text matches), 'files_with_matches' (just the "
44
+ "matching file paths, newest-modified first), or 'count' (per-file match counts) — use the latter "
45
+ "two to locate code cheaply before reading. Results paginate via offset/limit. Prefer this over "
46
+ "reading whole files or bash grep."
47
+ ),
48
+ "parameters": {
49
+ "type": "object",
50
+ "properties": {
51
+ "pattern": {"type": "string", "description": "Regex pattern to search for."},
52
+ "path": {"type": "string", "description": "Subdirectory to search under (default: workspace root)."},
53
+ "glob": {"type": "string", "description": "Optional file glob filter, e.g. '*.py' or '*.{ts,tsx}'."},
54
+ "type": {"type": "string", "description": "Optional ripgrep file type, e.g. 'py', 'js', 'rust'."},
55
+ "output_mode": {"type": "string", "enum": ["content", "files_with_matches", "count"],
56
+ "description": "content (default) | files_with_matches | count."},
57
+ "context": {"type": "integer", "description": "Lines of context around each match (content mode; like rg -C)."},
58
+ "offset": {"type": "integer", "description": "Number of leading result lines to skip (default 0)."},
59
+ "limit": {"type": "integer", "description": "Max result lines to return (default 50)."},
60
+ },
61
+ "required": ["pattern"],
62
+ },
63
+ },
64
+ }
65
+
66
+
67
+ def _norm_int(value, default: int) -> int:
68
+ """Coerce a model-supplied arg to a non-negative int, falling back to default."""
69
+ try:
70
+ n = int(value)
71
+ except (TypeError, ValueError):
72
+ return default
73
+ return n if n >= 0 else default
74
+
75
+
76
+ def _bump_guard(host_id, key) -> int:
77
+ """Update the consecutive-identical counter for this host; return the run length. Locked: parallel
78
+ explorers share GREP_GUARD, so the check-then-clear and the counter update must be atomic (else a
79
+ concurrent clear() blows away another thread's run length)."""
80
+ with _GREP_LOCK:
81
+ if host_id not in GREP_GUARD and len(GREP_GUARD) > 256:
82
+ GREP_GUARD.clear() # bound the per-host map (no host-teardown hook + id() reuse) — resetting the
83
+ # consecutive counter is harmless (worst case: one missed back-to-back warning)
84
+ data = GREP_GUARD.setdefault(host_id, {"last_key": None, "consecutive": 0})
85
+ if data["last_key"] == key:
86
+ data["consecutive"] += 1
87
+ else:
88
+ data["last_key"] = key
89
+ data["consecutive"] = 1
90
+ return data["consecutive"]
91
+
92
+
93
+ def make_grep_tool(host) -> ToolEntry:
94
+ """Build the guarded grep ToolEntry bound to a host (LocalToolHost-like: root()/_resolve)."""
95
+
96
+ def handler(args: dict) -> str:
97
+ pattern = args.get("pattern") or ""
98
+ if not pattern:
99
+ return "grep: no pattern given — pass a 'pattern' to search for."
100
+ path = args.get("path") or "."
101
+ glob = args.get("glob") or ""
102
+ ftype = (args.get("type") or "").strip()
103
+ mode = (args.get("output_mode") or "content").strip().lower()
104
+ if mode not in ("content", "files_with_matches", "count"):
105
+ mode = "content"
106
+ context = _norm_int(args.get("context"), 0)
107
+ offset = _norm_int(args.get("offset"), 0)
108
+ limit = _norm_int(args.get("limit"), _DEFAULT_LIMIT)
109
+ if limit <= 0:
110
+ limit = _DEFAULT_LIMIT
111
+
112
+ # Consecutive-identical guard. Key includes offset so paging never trips it.
113
+ key = (pattern, path, glob, ftype, mode, context, limit, offset)
114
+ count = _bump_guard((id(host), threading.get_ident()), key) # per-THREAD: parallel explorers share the host but must not cross-contaminate the consecutive-grep counter
115
+ if count >= _BLOCK_AFTER:
116
+ return (
117
+ f"BLOCKED: you have run this exact grep {count} times in a row and the results "
118
+ "have NOT changed. STOP re-searching — use what you already have, or change the "
119
+ "pattern/path/glob/type/output_mode/offset."
120
+ )
121
+
122
+ # Confine the search target under the workspace root (rejects escapes).
123
+ try:
124
+ target = host._resolve(path)
125
+ except (PermissionError, ValueError) as e:
126
+ return ToolText(f"Error: {e}", ok=False) # ok=False so a repeated boundary-escape is seen by the failure guardrail
127
+
128
+ rg = shutil.which("rg")
129
+ if not rg:
130
+ # Quiet, non-failing: degrade gracefully when ripgrep is absent.
131
+ return "grep: ripgrep (rg) is not available in this environment; no results."
132
+
133
+ cmd = [rg]
134
+ if mode == "files_with_matches":
135
+ cmd += ["-l", "--sortr", "modified"] # just the files, newest-changed first (cheap relevance)
136
+ elif mode == "count":
137
+ cmd += ["-c"]
138
+ else:
139
+ cmd += ["-n"]
140
+ if context > 0:
141
+ cmd += ["-C", str(context)]
142
+ cmd += ["--max-filesize", _RG_MAX_FILESIZE, "--max-columns", _RG_MAX_COLUMNS]
143
+ if glob:
144
+ cmd += ["--glob", glob]
145
+ if ftype:
146
+ cmd += ["--type", ftype]
147
+ cmd += ["--", pattern, target]
148
+
149
+ try:
150
+ proc = subprocess.run(
151
+ cmd, capture_output=True, text=True, cwd=host.root(), timeout=30
152
+ )
153
+ except (OSError, subprocess.SubprocessError) as e:
154
+ return f"grep: search failed ({e}); no results."
155
+
156
+ # rg exit codes: 0 = matches, 1 = no matches (not an error), 2 = real error.
157
+ if proc.returncode == 1:
158
+ return "grep: no matches found."
159
+ if proc.returncode not in (0, 1):
160
+ err = (proc.stderr or "").strip()
161
+ return f"grep: no matches found.{(' (' + err + ')') if err else ''}"
162
+
163
+ lines = [ln for ln in proc.stdout.splitlines() if ln]
164
+ if not lines:
165
+ return "grep: no matches found."
166
+
167
+ total = len(lines)
168
+ window = lines[offset:offset + limit]
169
+ if not window:
170
+ return f"grep: no results at offset={offset} ({total} total matches)."
171
+
172
+ body = "\n".join(window)
173
+ if offset + limit < total:
174
+ next_offset = offset + limit
175
+ body += f"\n\n[truncated; use offset={next_offset} to see more]"
176
+ return body
177
+
178
+ return ToolEntry(
179
+ name="grep",
180
+ schema=_GREP_SCHEMA,
181
+ handler=handler,
182
+ accesses=lambda args: [FileAccess("search", args.get("path") or ".", recursive=True)],
183
+ source="builtin",
184
+ )
185
+
186
+
187
+ # ── glob: find files by NAME (the discovery companion to grep's find-by-CONTENT) ──────────────────────
188
+ _GLOB_DEFAULT_LIMIT = 100
189
+ _GLOB_IGNORE_DIRS = {".git", ".venv", "venv", "node_modules", "__pycache__", ".ruff_cache", ".pytest_cache",
190
+ ".next", ".turbo", ".parcel-cache", ".nuxt", ".svelte-kit", ".output", "dist", "build"}
191
+
192
+ _GLOB_SCHEMA = {
193
+ "type": "function",
194
+ "function": {
195
+ "name": "glob",
196
+ "description": (
197
+ "Find files AND directories by NAME pattern under the workspace (for CONTENTS use grep). Matching "
198
+ "folders (e.g. a 'hunter/' project dir) are returned too, listed first. Supports glob wildcards incl. "
199
+ "brace sets, e.g. '*.py', 'src/**/*.{ts,tsx}', '*hunter*'. Paths most-recently-modified first, capped. "
200
+ "Use to locate a file or project folder before opening it."
201
+ ),
202
+ "parameters": {
203
+ "type": "object",
204
+ "properties": {
205
+ "pattern": {"type": "string", "description": "File-name glob, e.g. '*.py' or '**/*.{ts,tsx}'."},
206
+ "path": {"type": "string", "description": "Subdirectory to search under (default: workspace root)."},
207
+ "limit": {"type": "integer", "description": "Max paths to return (default 100)."},
208
+ },
209
+ "required": ["pattern"],
210
+ },
211
+ },
212
+ }
213
+
214
+
215
+ def _expand_braces(pat: str) -> list:
216
+ """Expand ONE level of brace alternation ('*.{ts,tsx}' -> ['*.ts','*.tsx']); recurse for nested."""
217
+ import re as _re
218
+ m = _re.search(r"\{([^{}]*)\}", pat)
219
+ if not m:
220
+ return [pat]
221
+ pre, post = pat[:m.start()], pat[m.end():]
222
+ out: list = []
223
+ for opt in m.group(1).split(","):
224
+ out.extend(_expand_braces(pre + opt + post))
225
+ return out
226
+
227
+
228
+ def _glob_walk(root: str, pattern: str, cap: int) -> list:
229
+ """ripgrep-free fallback: os.walk + brace-expanded fnmatch, newest-modified first."""
230
+ import fnmatch
231
+ import os as _os
232
+ pats = _expand_braces(pattern)
233
+ hits: list = []
234
+ for dp, dns, fns in _os.walk(root):
235
+ dns[:] = [d for d in dns if d not in _GLOB_IGNORE_DIRS]
236
+ for fn in fns:
237
+ full = _os.path.join(dp, fn)
238
+ rel = _os.path.relpath(full, root)
239
+ if any(fnmatch.fnmatch(rel, p) or fnmatch.fnmatch(fn, p) for p in pats):
240
+ hits.append(full)
241
+ if len(hits) >= cap * 4: # gather generously, then mtime-sort + cap
242
+ break
243
+ hits.sort(key=lambda f: -(_os.path.getmtime(f) if _os.path.exists(f) else 0.0))
244
+ return hits
245
+
246
+
247
+ def _dir_matches(root: str, pats: list, cap: int, maxdepth: int = 12) -> list:
248
+ """Find DIRECTORIES whose name (or relpath) matches the pattern. `rg --files` lists FILES only, so a
249
+ project FOLDER named like the pattern (e.g. a 'hunter/' dir) is otherwise invisible to glob. BREADTH-
250
+ first so a shallow match (Desktop/hunter) is found before descending huge deep trees (Library/…), and
251
+ bounded by depth + a node budget so it can't run away on a big home directory."""
252
+ import fnmatch
253
+ import os as _os
254
+ from collections import deque
255
+ hits: list = []
256
+ q: deque = deque([(root, 0)])
257
+ budget = 40000
258
+ while q and budget > 0:
259
+ base, depth = q.popleft()
260
+ budget -= 1
261
+ try:
262
+ entries = list(_os.scandir(base))
263
+ except OSError:
264
+ continue
265
+ for e in entries:
266
+ try:
267
+ if not e.is_dir(follow_symlinks=False): # no symlink loops; dirs only
268
+ continue
269
+ except OSError:
270
+ continue
271
+ if e.name in _GLOB_IGNORE_DIRS:
272
+ continue
273
+ rel = _os.path.relpath(e.path, root)
274
+ if any(fnmatch.fnmatch(rel, p) or fnmatch.fnmatch(e.name, p) for p in pats):
275
+ hits.append(e.path + "/")
276
+ if len(hits) >= cap:
277
+ return hits
278
+ if depth + 1 < maxdepth:
279
+ q.append((e.path, depth + 1))
280
+ return hits
281
+
282
+
283
+ def make_glob_tool(host) -> ToolEntry:
284
+ """Build the file-name `glob` ToolEntry bound to a host (uses ripgrep --files, falls back to os.walk)."""
285
+
286
+ def handler(args: dict) -> str:
287
+ pattern = (args.get("pattern") or "").strip()
288
+ if not pattern:
289
+ return "glob: no pattern given — pass a 'pattern' like '*.py'."
290
+ path = args.get("path") or "."
291
+ limit = _norm_int(args.get("limit"), _GLOB_DEFAULT_LIMIT) or _GLOB_DEFAULT_LIMIT
292
+ try:
293
+ target = host._resolve(path)
294
+ except (PermissionError, ValueError) as e:
295
+ return ToolText(f"Error: {e}", ok=False)
296
+
297
+ rg = shutil.which("rg")
298
+ files: list = []
299
+ if rg:
300
+ cmd = [rg, "--files", "--sortr", "modified", "-g", pattern, target]
301
+ try:
302
+ proc = subprocess.run(cmd, capture_output=True, text=True, cwd=host.root(), timeout=30)
303
+ if proc.returncode in (0, 1): # 0 = files, 1 = none (not an error)
304
+ files = [ln for ln in proc.stdout.splitlines() if ln]
305
+ except (OSError, subprocess.SubprocessError):
306
+ files = []
307
+ else:
308
+ files = _glob_walk(target, pattern, limit) # graceful degrade when rg is absent
309
+
310
+ # rg --files lists FILES only — so a DIRECTORY named like the pattern (a 'hunter/' project folder)
311
+ # would be invisible. Always add matching directories so "glob *hunter*" finds folders too; show
312
+ # them FIRST (a name search is usually after the folder, not files buried beneath it).
313
+ dirs = _dir_matches(target, _expand_braces(pattern), limit)
314
+ results = dirs + files
315
+ if not results:
316
+ return f"glob: nothing matches {pattern!r} (no files or directories)."
317
+ total = len(results)
318
+ body = "\n".join(results[:limit])
319
+ if total > limit:
320
+ body += f"\n\n[{total - limit} more not shown; narrow the pattern or path]"
321
+ return body
322
+
323
+ return ToolEntry(
324
+ name="glob",
325
+ schema=_GLOB_SCHEMA,
326
+ handler=handler,
327
+ accesses=lambda args: [FileAccess("search", args.get("path") or ".", recursive=True)],
328
+ source="builtin",
329
+ )