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
@@ -0,0 +1,92 @@
1
+ """Conflict-aware tool scheduler.
2
+
3
+ Runs a batch of tool calls with maximum safe concurrency: non-conflicting calls
4
+ overlap, conflicting calls serialize, and results are returned in PROVIDER ORDER
5
+ (deterministic for the model) regardless of completion order.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
11
+ from typing import Callable
12
+
13
+ from .access import Accesses, conflict
14
+
15
+ Task = tuple[Accesses, Callable[[], str]]
16
+
17
+
18
+ def run_scheduled(tasks: list[Task], max_workers: int = 8, timeout: float | None = None) -> list[str]:
19
+ """Run a batch with max safe concurrency, results in PROVIDER ORDER. `timeout` (seconds, opt-in) is a
20
+ per-task WALL-CLOCK deadline: a tool that overruns yields a timeout result so the turn proceeds instead
21
+ of hanging on a stuck tool (the orphaned thread is abandoned, not joined — Python can't force-kill a
22
+ thread, so this is a last-resort net above each tool's own subprocess/SIGALRM timeout). timeout=None
23
+ preserves the original behaviour exactly (wait for every task)."""
24
+ n = len(tasks)
25
+ if n == 0:
26
+ return []
27
+ if n == 1 and timeout is None:
28
+ try:
29
+ return [tasks[0][1]()]
30
+ except Exception as e:
31
+ return [f"Error: {e}"]
32
+
33
+ results: list[str | None] = [None] * n
34
+ accesses = [t[0] for t in tasks]
35
+ pending = list(range(n))
36
+ running: dict = {} # future -> index
37
+ started: dict = {} # future -> monotonic start time (deadline tracking)
38
+
39
+ pool = ThreadPoolExecutor(max_workers=min(max_workers, n))
40
+ abandoned_acc: list[Accesses] = [] # accesses of timed-out, still-LIVE threads — keep blocking conflicts
41
+ try:
42
+ while pending or running:
43
+ running_acc = [accesses[i] for i in running.values()] + abandoned_acc
44
+ selected_acc: list[Accesses] = []
45
+ for idx in list(pending):
46
+ a = accesses[idx]
47
+ # never run two conflicting tasks at once (vs running, an abandoned overrun, or already-selected)
48
+ if any(conflict(a, ra) for ra in running_acc) or any(conflict(a, sa) for sa in selected_acc):
49
+ continue
50
+ fut = pool.submit(tasks[idx][1])
51
+ running[fut] = idx
52
+ started[fut] = time.monotonic()
53
+ selected_acc.append(a)
54
+ pending.remove(idx)
55
+ if not running:
56
+ # nothing running and nothing startable → the remaining pending all conflict with an
57
+ # ABANDONED (never-finishing) thread. Resolve them so the loop can't spin forever.
58
+ for idx in pending:
59
+ results[idx] = ("Error: not run — conflicts with a tool that timed out and is still "
60
+ "running in the background")
61
+ pending.clear()
62
+ break
63
+ if running:
64
+ wait_t = None
65
+ if timeout is not None:
66
+ # wake no later than the SOONEST deadline = the OLDEST running task (MAX elapsed).
67
+ # (min elapsed = newest task = furthest-out deadline → would reap the oldest too late.)
68
+ elapsed_max = max(time.monotonic() - started[f] for f in running)
69
+ wait_t = max(0.05, timeout - elapsed_max)
70
+ done, _ = wait(list(running.keys()), timeout=wait_t, return_when=FIRST_COMPLETED)
71
+ for fut in done:
72
+ idx = running.pop(fut)
73
+ started.pop(fut, None)
74
+ try:
75
+ results[idx] = fut.result()
76
+ except Exception as e:
77
+ results[idx] = f"Error: {e}"
78
+ if timeout is not None: # reap overruns: abandon the wait, let the turn continue
79
+ now = time.monotonic()
80
+ for fut in [f for f in running if now - started[f] >= timeout]:
81
+ idx = running.pop(fut)
82
+ started.pop(fut, None)
83
+ abandoned_acc.append(accesses[idx]) # still-live thread → keep it in the conflict set
84
+ results[idx] = (f"Error: tool timed out after {timeout:g}s "
85
+ "(abandoned; it may still be running in the background)")
86
+ # safety: the loop only exits when pending AND running are empty, so every slot is set — but
87
+ # never let a stray None escape to a caller that expects str.
88
+ return [r if r is not None else "Error: tool produced no result" for r in results]
89
+ finally:
90
+ # never block process teardown on an abandoned/stuck worker (all non-timed-out tasks have
91
+ # already completed by the time we exit the loop, so wait=False is lossless in the normal path).
92
+ pool.shutdown(wait=False)
@@ -0,0 +1,289 @@
1
+ """Cross-session episode search — a durable SQLite FTS5 index over episodic records.
2
+
3
+ Three shapes (discovery / scroll / read), ZERO LLM — every shape returns actual
4
+ stored rows. sliceagent has no transcript, so we
5
+ index per-EPISODE records (one row per turn) appended by hippocampus.py's EpisodeSink. The index is an
6
+ ADDITIVE sidecar over the already-durable JSONL cache — the JSONL stays the source of
7
+ truth, this is a queryable mirror. Recall (hippocampus.py's recall_history) is single-session today; this lets
8
+ it search ACROSS sessions without ever feeding a growing transcript.
9
+
10
+ NO-TRANSCRIPT INVARIANT: this never enters the slice. It is a durable store the model
11
+ queries on demand (exactly like recall_history), bounded by `limit`/snippet length.
12
+
13
+ GRACEFUL DEGRADE: if sqlite3 or FTS5 is unavailable, `EpisodeIndex` construction returns
14
+ a no-op (index_episode does nothing, search returns []), so the JSONL path is untouched.
15
+
16
+ PUBLIC SIGNATURES (pinned — other agents code against these verbatim):
17
+ fts5_available() -> bool
18
+ class EpisodeIndex:
19
+ def __init__(self, db_path: str) -> None
20
+ is_active: bool # False when FTS5 unavailable / open failed
21
+ def index_episode(self, *, session_id: str, task_id: str, turn: int,
22
+ ts: str, title: str, note: str, text: str) -> None
23
+ def search(self, query: str, *, limit: int = 5,
24
+ exclude_session: str | None = None) -> list[dict]
25
+ def close(self) -> None
26
+ episode_searchable_text(record: dict) -> str
27
+ default_index_path() -> str
28
+
29
+ `search` returns a list of dicts:
30
+ {"session_id","task_id","turn","ts","title","note","snippet","score"}
31
+ ordered by FTS5 rank (best first). `snippet` is the FTS5-highlighted excerpt.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import os
36
+ import re
37
+ import threading
38
+
39
+ _FTS_TABLE = "episodes"
40
+ # Recency tie-break weight for search() — how much a hit's age can re-order it WITHIN the relevance band
41
+ # the floor keeps (bounded by the top lexical score, so it never overrides a clearly-stronger match). At
42
+ # 0.25 a recent hit scoring within ~25% of the top lexical can edge ahead — enough that "the latest review"
43
+ # wins over a stale turn that merely shares more keywords, without recency dominating relevance.
44
+ _RECENCY_W = 0.25
45
+
46
+
47
+ def _fts_match_query(q: str) -> str:
48
+ """Turn a free-text query into a SAFE FTS5 MATCH expression. Extract word tokens only and quote each
49
+ (so query punctuation/operators - " * : ( ) AND OR NEAR can never trigger a syntax error that silently
50
+ returns nothing), then OR-join them. OR (not AND) is the recall-correct default: a query carries terms
51
+ the target turn won't all contain — meta/ordinal words ("second", "finding"), the user's own framing,
52
+ stray operators — and AND-joining means ONE absent token zeroes the whole result (the 'can't locate my
53
+ second finding' bug: 'second' appeared in no review turn, so the AND failed). With OR, any term surfaces
54
+ the turn and BM25 rank + the relative floor in search() keep it precise. Empty → '' (no search)."""
55
+ toks = re.findall(r"\w+", q or "", flags=re.UNICODE)
56
+ return " OR ".join(f'"{t}"' for t in toks)
57
+
58
+
59
+ def fts5_available() -> bool:
60
+ """True iff this Python's sqlite3 can create an FTS5 virtual table."""
61
+ try:
62
+ import sqlite3
63
+ except Exception:
64
+ return False
65
+ try:
66
+ con = sqlite3.connect(":memory:")
67
+ try:
68
+ con.execute("CREATE VIRTUAL TABLE _probe USING fts5(x)")
69
+ return True
70
+ finally:
71
+ con.close()
72
+ except Exception:
73
+ return False
74
+
75
+
76
+ def default_index_path() -> str:
77
+ """The index lives under the same vault as the episodic JSONL (memory._vault_root).
78
+
79
+ Lazy import keeps this module free of a hard memory.py dependency for tests.
80
+ """
81
+ try:
82
+ from .memory import _vault_root
83
+ root = _vault_root()
84
+ except Exception:
85
+ root = os.path.join(os.path.expanduser("~"), ".sliceagent", "vault")
86
+ return os.path.join(root, "episodic", "index.db")
87
+
88
+
89
+ def episode_searchable_text(record: dict) -> str:
90
+ """Flatten an episode `record` into one searchable blob (title + note + actions +
91
+ observations + files). Deterministic, bounded per-field so one huge observation can't
92
+ dominate the index. Mirrors what history.render_trace surfaces, so a search matches
93
+ what the model would actually read back."""
94
+ parts: list[str] = []
95
+ title = record.get("title") or ""
96
+ if title:
97
+ parts.append(title)
98
+ note = record.get("note") or ""
99
+ if note:
100
+ parts.append(note)
101
+ for st in record.get("steps", []) or []:
102
+ for a in st.get("action", []) or []:
103
+ name = a.get("name") or ""
104
+ args = a.get("args")
105
+ args = args if isinstance(args, dict) else {} # tolerate a non-dict in an OLD persisted episode
106
+ hint = ""
107
+ for k in ("path", "command", "query", "goal"):
108
+ if args.get(k):
109
+ hint = str(args[k])[:120]
110
+ break
111
+ parts.append(f"{name} {hint}".strip())
112
+ for o in st.get("observation", []) or []:
113
+ if isinstance(o, str) and o:
114
+ parts.append(o[:500]) # bounded — head of each observation
115
+ meta = record.get("meta", {}) or {}
116
+ for f in meta.get("files", []) or []:
117
+ parts.append(str(f))
118
+ return "\n".join(p for p in parts if p)
119
+
120
+
121
+ class EpisodeIndex:
122
+ """SQLite FTS5 mirror of episodic records, queryable across sessions.
123
+
124
+ Best-effort throughout: every method swallows its own errors (an index hiccup must
125
+ never break a session — same discipline as memory.append_episode). When FTS5 is
126
+ unavailable or the DB can't open, `is_active` is False and all writes/reads no-op.
127
+ """
128
+
129
+ def __init__(self, db_path: str) -> None:
130
+ self.db_path = db_path
131
+ self.is_active = False
132
+ self._con = None
133
+ # the connection is shared (check_same_thread=False) across parallel explorer subagents — sqlite
134
+ # connections are NOT safe for concurrent cursors, so serialize every statement+fetch/commit.
135
+ self._lock = threading.Lock()
136
+ if not fts5_available():
137
+ return
138
+ try:
139
+ import sqlite3
140
+ os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True)
141
+ # check_same_thread=False so the OPT-IN background-review fork (item 16) can
142
+ # index from its worker thread without a second connection.
143
+ con = sqlite3.connect(db_path, check_same_thread=False)
144
+ con.execute(
145
+ f"CREATE VIRTUAL TABLE IF NOT EXISTS {_FTS_TABLE} USING fts5("
146
+ "session_id UNINDEXED, task_id UNINDEXED, turn UNINDEXED, "
147
+ "ts UNINDEXED, title, note, body, "
148
+ "tokenize='porter unicode61')"
149
+ )
150
+ con.commit()
151
+ self._con = con
152
+ self.is_active = True
153
+ except Exception:
154
+ self._con = None
155
+ self.is_active = False
156
+
157
+ def index_episode(self, *, session_id: str, task_id: str, turn: int,
158
+ ts: str, title: str, note: str, text: str) -> None:
159
+ """Insert one episode row. Idempotent per (session_id, turn): a re-index deletes
160
+ the prior row first, so a replayed/duplicate append can't double-count."""
161
+ if not self.is_active or self._con is None:
162
+ return
163
+ try:
164
+ with self._lock: # serialize the write txn vs concurrent reads on the shared connection
165
+ # turn is stored as TEXT (FTS5 UNINDEXED preserves the exact type), so the DELETE
166
+ # must bind the SAME str — int 2 != stored '2' and the row would never be removed.
167
+ self._con.execute(
168
+ f"DELETE FROM {_FTS_TABLE} WHERE session_id = ? AND turn = ?",
169
+ (session_id, str(turn)),
170
+ )
171
+ self._con.execute(
172
+ f"INSERT INTO {_FTS_TABLE} "
173
+ "(session_id, task_id, turn, ts, title, note, body) "
174
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
175
+ (session_id, task_id, str(turn), ts, title or "", note or "", text or ""),
176
+ )
177
+ self._con.commit()
178
+ except Exception:
179
+ pass
180
+
181
+ def search(self, query: str, *, limit: int = 5,
182
+ exclude_session: str | None = None,
183
+ only_session: str | None = None) -> list[dict]:
184
+ """FTS5 discovery over indexed sessions. Returns bounded hit dicts ordered by rank.
185
+ `exclude_session` drops the current session lineage (cross-session recall). `only_session`
186
+ restricts to ONE session (within-session content recall of the long tail — turns past the
187
+ manifest/index window). Opposite scopings; pass at most one. Never raises."""
188
+ if not self.is_active or self._con is None:
189
+ return []
190
+ match = _fts_match_query(query)
191
+ if not match:
192
+ return []
193
+ try:
194
+ lim = max(1, min(int(limit), 20))
195
+ except (TypeError, ValueError):
196
+ lim = 5
197
+ sel = (f"SELECT session_id, task_id, turn, ts, title, note, "
198
+ f"snippet({_FTS_TABLE}, 6, '«', '»', ' … ', 12) AS snip, "
199
+ f"rank AS score "
200
+ f"FROM {_FTS_TABLE} WHERE {_FTS_TABLE} MATCH ? ")
201
+ try:
202
+ with self._lock: # serialize cursor use on the shared connection (parallel explorers)
203
+ if only_session:
204
+ # session_id is an UNINDEXED FTS5 column → push the filter into SQL. A globally-ranked
205
+ # over-fetch + Python post-filter STARVES to [] when other sessions out-rank the target
206
+ # (the long-tail recall this serves is exactly that case). No +10 needed: nothing dropped.
207
+ rows = self._con.execute(
208
+ sel + "AND session_id = ? ORDER BY rank LIMIT ?",
209
+ (match, only_session, lim),
210
+ ).fetchall()
211
+ else:
212
+ rows = self._con.execute(
213
+ sel + "ORDER BY rank LIMIT ?",
214
+ (match, lim + 10), # over-fetch so exclude_session can't starve the result
215
+ ).fetchall()
216
+ except Exception:
217
+ return []
218
+ out: list[dict] = []
219
+ for r in rows:
220
+ session_id = r[0]
221
+ if exclude_session and session_id == exclude_session:
222
+ continue
223
+ if only_session and session_id != only_session:
224
+ continue
225
+ try:
226
+ turn = int(r[2])
227
+ except (TypeError, ValueError):
228
+ turn = r[2]
229
+ out.append({
230
+ "session_id": session_id,
231
+ "task_id": r[1],
232
+ "turn": turn,
233
+ "ts": r[3],
234
+ "title": r[4],
235
+ "note": r[5],
236
+ "snippet": r[6],
237
+ # #41: FTS5 `rank` is negative (more-negative = better). Negate so callers reading `score`
238
+ # get an intuitive higher-is-better number; result ORDER already follows `rank` directly.
239
+ "score": -float(r[7]) if r[7] is not None else 0.0,
240
+ })
241
+ # collect ALL candidates (no early break) — the floor + recency re-rank below need them.
242
+ # RELATIVE FLOOR (counterweight to OR-breadth): keep only hits scoring within 15% of the top hit,
243
+ # always keeping #1. OR-join maximizes recall; this trims the long tail of turns that matched on a
244
+ # single weak/common term, so a precise query still returns a precise set (and a vague one degrades
245
+ # to "the few most relevant", not "30 loosely-related turns"). Degenerate scores (≤0) → keep all.
246
+ if out:
247
+ top = out[0]["score"]
248
+ if top > 0:
249
+ cut = top * 0.15
250
+ out = [out[0]] + [h for h in out[1:] if h["score"] >= cut]
251
+ # RECENCY RE-RANK: among the comparably-relevant survivors the floor kept, prefer the more RECENT
252
+ # turn — a fresh discussion of a topic should win over a stale one ("the findings" = the latest
253
+ # review, not an older mention that shares more keywords). The bonus is bounded by the TOP lexical
254
+ # score, so recency only re-orders WITHIN the relevance band; it can never pull in a turn the floor
255
+ # rejected (relevance stays the gate, recency is the tie-break — this is the recall channel, so the
256
+ # moat's "relevant push" stays primary). Age order = ts then turn.
257
+ if len(out) > 1:
258
+ tops = out[0]["score"] or 1.0
259
+ def _age_key(h):
260
+ t = h.get("turn") or 0
261
+ try:
262
+ t = int(t)
263
+ except (TypeError, ValueError):
264
+ t = 0 # a str/None turn must not make sort compare str vs int (TypeError)
265
+ return ((h.get("ts") or ""), t)
266
+ by_age = sorted(out, key=_age_key) # oldest→newest
267
+ n = len(by_age)
268
+ for k, h in enumerate(by_age):
269
+ h["_blend"] = h["score"] + _RECENCY_W * tops * (k / (n - 1))
270
+ out.sort(key=lambda h: h["_blend"], reverse=True)
271
+ for h in out:
272
+ h.pop("_blend", None)
273
+ return out[:lim]
274
+
275
+ def close(self) -> None:
276
+ with self._lock: # serialize close vs an in-flight index_episode/search on the shared connection
277
+ if self._con is not None:
278
+ try:
279
+ self._con.close()
280
+ except Exception:
281
+ pass
282
+ self._con = None
283
+ self.is_active = False
284
+
285
+
286
+ def make_episode_index(db_path: str | None = None) -> EpisodeIndex:
287
+ """Factory. `db_path` defaults to default_index_path(). Always returns an EpisodeIndex
288
+ (its `is_active` flag tells callers whether FTS5 actually came up)."""
289
+ return EpisodeIndex(db_path or default_index_path())