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/session.py ADDED
@@ -0,0 +1,222 @@
1
+ """Session — host-side topic manager (MEMORY-SPEC step 3, mechanical core).
2
+
3
+ Holds one bounded Slice per topic; switching PARKS the current and activates another. Within a
4
+ session, parked topic-slices stay in memory (lossless — switching back returns the same Slice);
5
+ a durable checkpoint is ALSO written on park when the memory is durable, so a topic can be resumed
6
+ in a future session (distilled from the vault, files re-read live). A topic not in the live set is
7
+ resumed via memory.load_task.
8
+
9
+ This is the host layer — it never touches the loop/slice core. The remaining half of step 3 (the
10
+ model-facing new_topic/switch_topic TOOLS and the OTHER OPEN THREADS render tier) layers on top of
11
+ this. NullMemory works fine: the in-memory topic dict gives full within-session switching; only
12
+ cross-session resume needs a durable vault.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import re
19
+ import uuid
20
+
21
+ from .interfaces import TaskRef
22
+ from .pfc import Slice
23
+ from .regions import capture_user_report
24
+ from .text_utils import one_line
25
+ from .taskstate import slice_to_task_state, task_state_to_slice
26
+
27
+
28
+ def _mint_task_id() -> str:
29
+ return "t-" + uuid.uuid4().hex[:8]
30
+
31
+
32
+ class Session:
33
+ def __init__(self, memory, session_id: str | None = None):
34
+ self.memory = memory
35
+ self.session_id = session_id or ("s-" + uuid.uuid4().hex[:12])
36
+ self.tasks: dict[str, Slice] = {} # task_id -> live bounded slice (in-session)
37
+ self.active_id: str | None = None
38
+
39
+ def active(self) -> Slice:
40
+ return self.tasks[self.active_id]
41
+
42
+ def _park(self, status: str = "parked") -> None:
43
+ """Durably checkpoint the active topic (for cross-session resume); a no-op under NullMemory.
44
+ The live Slice stays in self.tasks regardless, so within-session switching is lossless."""
45
+ if self.active_id is None:
46
+ return
47
+ if getattr(self.memory, "is_durable", False):
48
+ self.memory.checkpoint_task(slice_to_task_state(
49
+ self.tasks[self.active_id], self.active_id,
50
+ session_id=self.session_id, status=status))
51
+
52
+ def new_topic(self, goal: str) -> str:
53
+ """Park the current topic and start a fresh one. Returns the new task_id."""
54
+ self._park()
55
+ tid = _mint_task_id()
56
+ s = Slice()
57
+ s.reset(goal)
58
+ self.tasks[tid] = s
59
+ self.active_id = tid
60
+ return tid
61
+
62
+ def switch_topic(self, task_id: str) -> Slice:
63
+ """Park the current topic and activate another — from the live set if present, else resumed
64
+ from the durable vault (distilled). Raises KeyError if neither has it."""
65
+ self._park()
66
+ if task_id not in self.tasks:
67
+ ts = self.memory.load_task(task_id)
68
+ if ts is None:
69
+ raise KeyError(f"unknown topic {task_id}")
70
+ self.tasks[task_id] = task_state_to_slice(ts) # cross-session: distilled + since_edit=0
71
+ self.active_id = task_id
72
+ return self.tasks[task_id]
73
+
74
+ def open_threads(self, *, include_active: bool = False) -> list[TaskRef]:
75
+ """The OTHER OPEN THREADS source: live topics (parked by default; the active one optional)."""
76
+ out: list[TaskRef] = []
77
+ for tid, s in self.tasks.items():
78
+ if not include_active and tid == self.active_id:
79
+ continue
80
+ out.append(TaskRef(task_id=tid, title=one_line(s.goal, 60),
81
+ status="active" if tid == self.active_id else "parked"))
82
+ return out
83
+
84
+ def continue_topic(self, message: str, *, resume: bool = False) -> Slice:
85
+ """Continue the active topic with a NEW directive: set the goal, start a fresh action epoch
86
+ but KEEP the durable context — findings and the working set — so the follow-up builds on
87
+ what's already done.
88
+
89
+ I3 WS2 — DEMOTE the anti-loop epoch, don't wipe it. Clearing action_log every directive let a
90
+ completed sub-step re-run with no REPEATED warning (turn 14 ran the same command twice). Instead
91
+ we mark prior entries non-failing (a NEW directive shouldn't carry a stale failure) but KEEP
92
+ their counts, so a genuinely-repeated command still trips REPEATED-with-no-progress. since_edit
93
+ is still cleared (a fresh convergence epoch).
94
+
95
+ I3 OPEN USER REPORT — if this follow-up looks like a FAILURE REPORT, capture it as a blocker;
96
+ it is NOT cleared by continue_topic (a new directive does not mean the user retracted the
97
+ report — only verifying the fix, or a real topic change, clears it)."""
98
+ s = self.active()
99
+ # SEAL the prior loop at this turn boundary: the finished loop was archived on TurnEnd; start the
100
+ # next loop fresh — CARRY the distilled context (findings + edited change-set + conversation), SEAL
101
+ # the raw trajectory (recent/step-cache/exploratory reads → recall-on-demand). This is what keeps
102
+ # per-turn cost flat across a long session (the moat) while within-loop info stays complete.
103
+ s.seal()
104
+ if not resume:
105
+ s.goal = message # a RESUME cue ("go back to the auth task") must NOT replace the topic's defining goal
106
+ s.last_error = ""
107
+ # demote (don't clear): keep counts, drop the failing flag — see WS2 above
108
+ for sig, a in s.action_log.items():
109
+ a["failing"] = False
110
+ s.since_edit = 0
111
+ s.reviewed = [] # new directive → the history ratchet resets (re-allow fresh lookbacks)
112
+ capture_user_report(s, message) # a failure report rides forward as an OPEN USER REPORT blocker
113
+ return s
114
+
115
+
116
+ def route_topic(llm, message: str, session: "Session") -> tuple[str, str]:
117
+ """Classify a new user message against the session: ('continue'|'new'|'resume', task_id). ONE
118
+ cheap LLM call, biased to 'continue', safe defaults on any parse failure. No topic is mutated
119
+ here — the host applies the result — so there are no junk topics. (Provider-agnostic: uses the
120
+ LLMClient contract.)"""
121
+ if session.active_id is None:
122
+ return ("new", "")
123
+ threads = session.open_threads(include_active=False)
124
+ parked = "\n".join(f"- {t.task_id}: {t.title}" for t in threads) or "(none)"
125
+ sys_msg = (
126
+ "You route a user's new message in a coding session into ONE action. Reply with ONLY a JSON "
127
+ 'object: {"action":"continue|new|resume","task_id":"<a parked id, or empty>"}. '
128
+ "continue = it continues or refines the ACTIVE task. new = a different, unrelated task. "
129
+ "resume = it asks to return to one of the PARKED topics (give that topic's id). "
130
+ "Bias to 'continue' when unsure.")
131
+ usr = (f"ACTIVE TASK: {session.active().goal or '(none)'}\nPARKED TOPICS:\n{parked}\n"
132
+ f"NEW MESSAGE: {message}")
133
+ try:
134
+ resp = llm.complete([{"role": "system", "content": sys_msg},
135
+ {"role": "user", "content": usr}], [])
136
+ m = re.search(r"\{.*\}", resp.content or "", re.S)
137
+ d = json.loads(m.group(0)) if m else {}
138
+ action = d.get("action", "continue")
139
+ if action == "resume":
140
+ tid = d.get("task_id", "")
141
+ return ("resume", tid) if any(t.task_id == tid for t in threads) else ("continue", "")
142
+ if action in ("new", "continue"):
143
+ return (action, "")
144
+ except Exception:
145
+ pass
146
+ return ("continue", "")
147
+
148
+
149
+ _RESUME_CUES = ("go back", "going back", "back to", "return to", "returning to", "resume",
150
+ "switch back", "switch to", "revisit", "pick up where", "pick back up")
151
+
152
+
153
+ def route_topic_lexical(message: str, session: "Session") -> tuple[str, str]:
154
+ """Routing WITHOUT an LLM round-trip (the moat-aligned default): the dominant 'continue' case pays
155
+ nothing, and the host never *guesses* 'new' — a genuinely new task is the agent's call via its own
156
+ new_topic tool (make_topic_tools), which is recoverable and parks the old topic. The host only acts
157
+ on an UNAMBIGUOUS resume signal: an explicit parked task_id in the message, or a resume cue
158
+ ('go back to…') plus a title-keyword match. Everything else → continue. Same signature/return contract
159
+ as route_topic, so it's a drop-in. See memory route-topic-hidden-llm-call for why this exists."""
160
+ if session.active_id is None:
161
+ return ("new", "")
162
+ threads = session.open_threads(include_active=False)
163
+ if threads:
164
+ msg_l = message.lower()
165
+ for t in threads: # explicit parked id mentioned → resume it
166
+ if t.task_id.lower() in msg_l:
167
+ return ("resume", t.task_id)
168
+ if any(cue in msg_l for cue in _RESUME_CUES): # resume cue → best title-keyword overlap
169
+ msg_words = set(re.findall(r"[a-z0-9]+", msg_l))
170
+ best, score = "", 0
171
+ for t in threads:
172
+ kw = {w for w in re.findall(r"[a-z0-9]+", (t.title or "").lower()) if len(w) > 3}
173
+ overlap = len(kw & msg_words)
174
+ if overlap > score:
175
+ best, score = t.task_id, overlap
176
+ if best and score >= 1:
177
+ return ("resume", best)
178
+ return ("continue", "")
179
+
180
+
181
+ def route(llm, message: str, session: "Session") -> tuple[str, str]:
182
+ """The configured topic router. DEFAULT = lexical (zero LLM round-trips — moat-aligned: a follow-up
183
+ no longer pays a per-message provider call before the turn even starts). Set AGENT_ROUTER=llm to
184
+ restore the classifier (tighter automatic 'new'-task detection, at one round-trip per follow-up).
185
+ Measured (evals/route_accuracy.py): lexical == llm on continue+resume (15/15); they differ only on
186
+ 'new', which lexical defers to the agent's new_topic tool. Single call site for both UI paths."""
187
+ if os.environ.get("AGENT_ROUTER", "lexical").strip().lower() == "llm":
188
+ return route_topic(llm, message, session)
189
+ return route_topic_lexical(message, session)
190
+
191
+
192
+ def make_topic_tools(session: "Session"):
193
+ """Model-facing tools so the agent can route topics itself. Default behaviour is CONTINUE (no
194
+ call); a switch/new is an explicit, recoverable action. Returns ToolEntry list for the registry."""
195
+ from .registry import ToolEntry
196
+
197
+ def _new(args: dict) -> str:
198
+ tid = session.new_topic(args["goal"])
199
+ return f"Started new topic [{tid}]: {one_line(args['goal'], 80)}. Previous topic parked (resumable)."
200
+
201
+ def _switch(args: dict) -> str:
202
+ try:
203
+ s = session.switch_topic(args["task_id"])
204
+ except KeyError:
205
+ return (f"Error: no open topic {args.get('task_id')!r}. Pick a task_id from "
206
+ "OTHER OPEN THREADS.")
207
+ return f"Switched to topic [{args['task_id']}]: {one_line(s.goal, 80)} (its state is restored)."
208
+
209
+ new_schema = {"type": "function", "function": {
210
+ "name": "new_topic",
211
+ "description": ("Start a NEW, unrelated task as its own topic — parks the current one (you can "
212
+ "return to it later via switch_topic). Use ONLY when the request is a different "
213
+ "task, not a continuation of the current one."),
214
+ "parameters": {"type": "object", "properties": {"goal": {"type": "string"}}, "required": ["goal"]}}}
215
+ switch_schema = {"type": "function", "function": {
216
+ "name": "switch_topic",
217
+ "description": ("Resume a PARKED topic listed in OTHER OPEN THREADS — restores its state. Use "
218
+ "only to return to earlier work, not to start something new."),
219
+ "parameters": {"type": "object", "properties": {"task_id": {"type": "string"}},
220
+ "required": ["task_id"]}}}
221
+ return [ToolEntry(name="new_topic", schema=new_schema, handler=_new, source="builtin"),
222
+ ToolEntry(name="switch_topic", schema=switch_schema, handler=_switch, source="builtin")]
@@ -0,0 +1,71 @@
1
+ """Skill write-origin provenance (item 13) — distinguishes consolidation-AUTHORED skills
2
+ from user-authored ones, so a future curator prunes ONLY auto skills, never the user's.
3
+
4
+ A ContextVar carries the write origin, paired with the frontmatter provenance field from
5
+ skill_usage.py. sliceagent has no foreground tool that writes skills today —
6
+ consolidate.render_skill is the ONLY writer — so we mark provenance two ways, belt-and-braces:
7
+
8
+ 1. A `provenance:` frontmatter field on auto-written SKILL.md (durable, survives a sidecar
9
+ wipe; readable by the loader and any future curator without a side channel).
10
+ 2. A ContextVar (`set_authoring_origin`) the writer can set, so a future foreground
11
+ skill-write tool inherits the right default without each call passing a flag.
12
+
13
+ NO-TRANSCRIPT INVARIANT: provenance is a property of the durable SKILL.md store, never of any
14
+ message history. The ContextVar is process-local control state, not context.
15
+
16
+ PUBLIC SIGNATURES (pinned):
17
+ AUTO = "consolidation"
18
+ USER = "user"
19
+ set_authoring_origin(origin: str) -> Token
20
+ reset_authoring_origin(token: Token) -> None
21
+ current_authoring_origin() -> str # default USER (a bare write is the user's)
22
+ frontmatter_line(origin: str) -> str # "provenance: <origin>"
23
+ provenance_of(meta: dict) -> str # read it back from parsed frontmatter
24
+ is_auto(meta: dict) -> bool # only auto skills are curator-prunable
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import contextvars
29
+
30
+ AUTO = "consolidation"
31
+ USER = "user"
32
+
33
+ _origin: contextvars.ContextVar[str] = contextvars.ContextVar(
34
+ "skill_authoring_origin", default=USER
35
+ )
36
+
37
+
38
+ def set_authoring_origin(origin: str) -> contextvars.Token:
39
+ """Bind the active skill-authoring origin to the current context. Returns a Token the
40
+ caller MUST pass to reset_authoring_origin in a finally block."""
41
+ return _origin.set(origin or USER)
42
+
43
+
44
+ def reset_authoring_origin(token: contextvars.Token) -> None:
45
+ _origin.reset(token)
46
+
47
+
48
+ def current_authoring_origin() -> str:
49
+ """The active authoring origin. Default USER — a bare skill write belongs to the user
50
+ and must never be auto-pruned. neocortex.py sets AUTO around its render_skill writes."""
51
+ return _origin.get()
52
+
53
+
54
+ def frontmatter_line(origin: str) -> str:
55
+ """The frontmatter line to embed in an auto-written SKILL.md."""
56
+ return f"provenance: {origin or USER}"
57
+
58
+
59
+ def provenance_of(meta: dict) -> str:
60
+ """Read provenance back from parsed frontmatter (skills.parse_frontmatter output).
61
+ Missing field → USER (legacy/user-authored skills predate the field; never prune them)."""
62
+ if not isinstance(meta, dict):
63
+ return USER
64
+ v = (meta.get("provenance") or "").strip().lower()
65
+ return v or USER
66
+
67
+
68
+ def is_auto(meta: dict) -> bool:
69
+ """True iff this skill was authored by consolidation (curator-prunable). User skills,
70
+ and any skill missing the field, return False — safe by default."""
71
+ return provenance_of(meta) == AUTO
@@ -0,0 +1,123 @@
1
+ """Skill usage sidecar (item 13) — per-skill last-used + use-count telemetry in ONE JSON
2
+ file, so neocortex.py can frequency-weight skills the way it already weights pitfalls and
3
+ procedures, and a future curator can prune stale AUTO skills.
4
+
5
+ Design:
6
+ - sidecar (`.usage.json`) keyed by skill name, NOT frontmatter — keeps telemetry out of
7
+ user-authored SKILL.md content (frontmatter carries only provenance, which is durable
8
+ intent; usage is mutable observability).
9
+ - atomic write (tempfile + os.replace).
10
+ - best-effort everywhere: a broken sidecar never breaks a skill load.
11
+ Deliberately omitted: lifecycle states, hub/bundled manifests, archive/restore, file locking
12
+ (sliceagent has no concurrent-process skill writes today — add fcntl only if that changes).
13
+
14
+ NO-TRANSCRIPT INVARIANT: the sidecar is a durable store; it feeds consolidate's frequency
15
+ weight, never the slice.
16
+
17
+ PUBLIC SIGNATURES (pinned):
18
+ usage_path(skills_dir: str) -> str
19
+ load_usage(skills_dir: str) -> dict[str, dict]
20
+ bump_use(skills_dir: str, name: str) -> None # loader calls this on skill load
21
+ record(skills_dir: str, name: str) -> dict # one skill's record (defaults backfilled)
22
+ use_count(skills_dir: str, name: str) -> int # consolidate frequency weight
23
+ last_used_at(skills_dir: str, name: str) -> str | None
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import tempfile
30
+ import threading
31
+ from datetime import datetime, timezone
32
+
33
+ _USAGE_FILE = ".usage.json"
34
+ _BUMP_LOCK = threading.Lock() # the skill tool declares no access → concurrent skill() calls run in parallel
35
+ # threads; serialize load→increment→save so an increment isn't lost.
36
+
37
+
38
+ def usage_path(skills_dir: str) -> str:
39
+ return os.path.join(skills_dir, _USAGE_FILE)
40
+
41
+
42
+ def _now_iso() -> str:
43
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
44
+
45
+
46
+ def _empty_record() -> dict:
47
+ return {"use_count": 0, "last_used_at": None}
48
+
49
+
50
+ def load_usage(skills_dir: str) -> dict:
51
+ """Read the whole sidecar map. Returns {} on missing/corrupt (never raises)."""
52
+ path = usage_path(skills_dir)
53
+ if not os.path.exists(path):
54
+ return {}
55
+ try:
56
+ with open(path, encoding="utf-8") as f:
57
+ data = json.load(f)
58
+ except (OSError, ValueError):
59
+ return {}
60
+ if not isinstance(data, dict):
61
+ return {}
62
+ return {str(k): v for k, v in data.items() if isinstance(v, dict)}
63
+
64
+
65
+ def _save_usage(skills_dir: str, data: dict) -> None:
66
+ """Atomic write. Best-effort — errors are swallowed (telemetry must never break a load)."""
67
+ path = usage_path(skills_dir)
68
+ try:
69
+ os.makedirs(skills_dir, exist_ok=True)
70
+ fd, tmp = tempfile.mkstemp(dir=skills_dir, prefix=".usage_", suffix=".tmp")
71
+ try:
72
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
73
+ json.dump(data, f, indent=2, sort_keys=True, ensure_ascii=False)
74
+ os.replace(tmp, path)
75
+ except BaseException:
76
+ try:
77
+ os.unlink(tmp)
78
+ except OSError:
79
+ pass
80
+ raise
81
+ except Exception:
82
+ pass
83
+
84
+
85
+ def bump_use(skills_dir: str, name: str) -> None:
86
+ """Increment use_count and stamp last_used_at for `name`. Called by the SkillManager
87
+ when a skill body is loaded into the slice. Best-effort."""
88
+ if not name:
89
+ return
90
+ try:
91
+ with _BUMP_LOCK: # atomic read-modify-write so parallel skill() threads don't lose an increment
92
+ data = load_usage(skills_dir)
93
+ rec = data.get(name)
94
+ if not isinstance(rec, dict):
95
+ rec = _empty_record()
96
+ rec["use_count"] = int(rec.get("use_count") or 0) + 1
97
+ rec["last_used_at"] = _now_iso()
98
+ data[name] = rec
99
+ _save_usage(skills_dir, data)
100
+ except Exception:
101
+ pass
102
+
103
+
104
+ def record(skills_dir: str, name: str) -> dict:
105
+ """Return `name`'s record with defaults backfilled (never None)."""
106
+ rec = dict(_empty_record())
107
+ raw = load_usage(skills_dir).get(name)
108
+ if isinstance(raw, dict):
109
+ rec.update({k: raw.get(k, rec[k]) for k in rec})
110
+ for k, v in raw.items():
111
+ rec.setdefault(k, v)
112
+ return rec
113
+
114
+
115
+ def use_count(skills_dir: str, name: str) -> int:
116
+ try:
117
+ return int(record(skills_dir, name).get("use_count") or 0)
118
+ except (TypeError, ValueError):
119
+ return 0
120
+
121
+
122
+ def last_used_at(skills_dir: str, name: str) -> str | None:
123
+ return record(skills_dir, name).get("last_used_at")
sliceagent/skills.py ADDED
@@ -0,0 +1,209 @@
1
+ """Skills — reusable procedure prompt-packs.
2
+
3
+ A skill is a SKILL.md: frontmatter (name + description required) + a markdown body of
4
+ instructions. Progressive disclosure: the `skill` tool's *description* carries the cheap
5
+ catalog (name + one-line description); calling `skill(name=...)` returns the full body,
6
+ which slice_sink folds into the slice's ACTIVE SKILL tier (slice.add_skill) where it
7
+ PERSISTS across turns — the sliceagent-specific adaptation (no transcript to hold a one-shot
8
+ injection). Skills are instructions the model then follows with its existing tools; they
9
+ are NOT code.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import re
15
+ import shlex
16
+ from dataclasses import dataclass
17
+
18
+ from .registry import ToolEntry, ToolText
19
+ from .text_utils import one_line
20
+
21
+ _MAX_SCAN_DEPTH = 8 # bound skill-root walk depth — defensive vs deep trees
22
+
23
+
24
+ def expand_skill_args(body: str, argstr: str) -> str:
25
+ """Substitute a skill's parameter placeholders: `$ARGUMENTS` → the full
26
+ arg string, `$1`/`$2`/… → positional tokens (shell-split). A skill with no placeholders is returned
27
+ unchanged, so existing skills are unaffected."""
28
+ if "$ARGUMENTS" not in body and not re.search(r"\$\d", body):
29
+ return body
30
+ argstr = argstr or ""
31
+ try:
32
+ parts = shlex.split(argstr)
33
+ except ValueError: # unbalanced quotes → fall back to whitespace split
34
+ parts = argstr.split()
35
+ # ONE regex pass over the original body. The old sequential `replace(f"${i}", tok)` loop was buggy:
36
+ # • $10 corrupted (replacing $1 first also matched the "$1" inside "$10")
37
+ # • re-expansion (a token containing "$N" got substituted again by a later iteration)
38
+ # • unfilled placeholders LEAKED as literal "$N"
39
+ # \d+ matches the whole index ($10 before $1), and re.sub never re-scans substituted text.
40
+ def _sub(m: "re.Match") -> str:
41
+ tok = m.group(0)
42
+ if tok == "$ARGUMENTS":
43
+ return argstr
44
+ idx = int(tok[1:]) # $N → 1-based positional
45
+ return parts[idx - 1] if 1 <= idx <= len(parts) else "" # unfilled → blank (no leak)
46
+
47
+ return re.sub(r"\$ARGUMENTS|\$\d+", _sub, body)
48
+
49
+
50
+ @dataclass
51
+ class Skill:
52
+ name: str
53
+ description: str
54
+ body: str
55
+ path: str
56
+ provenance: str = "user" # "user" | "consolidation" (item 13); from `provenance:` frontmatter
57
+ root: str = "" # the discovery root this skill came from (where its .usage.json lives)
58
+ when_to_use: str = "" # from `when-to-use:` frontmatter — shown in the catalog to improve routing
59
+
60
+
61
+ def parse_frontmatter(text: str) -> tuple[dict, str]:
62
+ """Minimal '---' fenced frontmatter → (meta, body). No YAML dependency: only simple
63
+ `key: value` lines are read (enough for name/description/when-to-use)."""
64
+ if not text.startswith("---"):
65
+ return {}, text
66
+ rest = text[3:]
67
+ end = rest.find("\n---")
68
+ if end == -1:
69
+ return {}, text
70
+ fm, body = rest[:end], rest[end + 4:].lstrip("\n")
71
+ meta: dict = {}
72
+ for line in fm.splitlines():
73
+ line = line.strip()
74
+ if not line or line.startswith("#") or ":" not in line:
75
+ continue
76
+ k, _, v = line.partition(":")
77
+ meta[k.strip().lower()] = v.strip().strip('"').strip("'")
78
+ return meta, body
79
+
80
+
81
+ def _default_roots() -> list[str]:
82
+ # project skills win over user skills (discovery order = first-wins)
83
+ return [
84
+ os.path.join(os.getcwd(), ".sliceagent", "skills"),
85
+ os.path.join(os.path.expanduser("~"), ".sliceagent", "skills"),
86
+ ]
87
+
88
+
89
+ class SkillManager:
90
+ def __init__(self, roots: list[str] | None = None):
91
+ self.roots = roots if roots is not None else _default_roots()
92
+ self._skills: dict[str, Skill] = {}
93
+ self.discover()
94
+
95
+ def discover(self) -> "SkillManager":
96
+ self._skills = {}
97
+ for root in self.roots:
98
+ if not os.path.isdir(root):
99
+ continue
100
+ for dp, _dirs, files in os.walk(root): # followlinks=False → no symlink cycles
101
+ if dp[len(root):].count(os.sep) > _MAX_SCAN_DEPTH:
102
+ _dirs[:] = [] # bound depth
103
+ continue
104
+ for fn in files:
105
+ if fn == "SKILL.md" or (fn.endswith(".md") and dp == root):
106
+ self._load(os.path.join(dp, fn), root)
107
+ return self
108
+
109
+ def _load(self, path: str, root: str = "") -> None:
110
+ try:
111
+ with open(path, encoding="utf-8", errors="replace") as f:
112
+ text = f.read()
113
+ except OSError:
114
+ return
115
+ meta, body = parse_frontmatter(text)
116
+ name = (meta.get("name") or "").strip().lower()
117
+ if not name and os.path.basename(path) != "SKILL.md":
118
+ name = os.path.splitext(os.path.basename(path))[0].lower()
119
+ when = (meta.get("when-to-use") or meta.get("when_to_use") or "").strip()
120
+ desc = (meta.get("description") or when or "").strip()
121
+ if not name or not body.strip():
122
+ return
123
+ prov = (meta.get("provenance") or "user").strip().lower() or "user"
124
+ self._skills.setdefault(name, Skill(name, desc, body.strip(), path, provenance=prov,
125
+ root=root, when_to_use=when)) # first-wins
126
+
127
+ def add(self, name: str, body: str, description: str = "") -> None:
128
+ """Register an in-memory skill (e.g. contributed by a plugin). First-wins, so a
129
+ disk skill of the same name takes precedence."""
130
+ name = (name or "").strip().lower()
131
+ if name and body and body.strip():
132
+ self._skills.setdefault(name, Skill(name, description.strip(), body.strip(), "<plugin>"))
133
+
134
+ def names(self) -> list[str]:
135
+ return sorted(self._skills)
136
+
137
+ def catalog(self) -> list[tuple[str, str]]:
138
+ return [(n, self._skills[n].description) for n in self.names()]
139
+
140
+ def load(self, name: str) -> str | None:
141
+ s = self._skills.get((name or "").lower())
142
+ if s is None:
143
+ return None
144
+ self._bump_usage(s) # item 13: last-used sidecar feeds consolidate's frequency weight
145
+ return s.body
146
+
147
+ def _bump_usage(self, s: "Skill") -> None:
148
+ """Bump the .usage.json sidecar in the skill's discovery root. Best-effort: a sidecar
149
+ hiccup, or a plugin/in-memory skill with no root, never breaks the skill load."""
150
+ if not s.root or not os.path.isdir(s.root):
151
+ return
152
+ try:
153
+ from .skill_usage import bump_use
154
+ bump_use(s.root, s.name)
155
+ except Exception:
156
+ pass
157
+
158
+
159
+ def make_skill_tool(manager: SkillManager) -> ToolEntry | None:
160
+ """A ToolEntry for the `skill` tool — or None when no skills are discovered. The tool
161
+ description IS the catalog (progressive disclosure); the handler returns the body, which
162
+ slice_sink folds into the ACTIVE SKILL tier."""
163
+ cat = manager.catalog()
164
+ if not cat:
165
+ return None
166
+ names = [n for n, _ in cat]
167
+
168
+ def _line(n: str, d: str) -> str: # show when-to-use to improve routing
169
+ s = manager._skills.get(n)
170
+ w = (s.when_to_use if s else "") or ""
171
+ base = f"- {n}: {one_line(d, 140)}"
172
+ return base + (f" (when: {one_line(w, 80)})" if w and w != d else "")
173
+
174
+ listing = "\n".join(_line(n, d) for n, d in cat)
175
+ desc = (
176
+ "Load a SKILL: a reusable procedure whose detailed instructions are added to your "
177
+ "working context and PERSIST for the rest of the task. Call it BEFORE starting work "
178
+ "when a task matches one of these skills. Pass `arguments` to fill a parameterized skill "
179
+ "($ARGUMENTS / $1 $2 in its body). Available skills:\n" + listing
180
+ )
181
+ schema = {
182
+ "type": "function",
183
+ "function": {
184
+ "name": "skill", "description": desc,
185
+ "parameters": {"type": "object",
186
+ "properties": {
187
+ "name": {"type": "string", "enum": names},
188
+ "arguments": {"type": "string", "description": "Optional. Substituted "
189
+ "into the skill's $ARGUMENTS / $1 $2 placeholders."}},
190
+ "required": ["name"]},
191
+ },
192
+ }
193
+
194
+ def handler(args: dict) -> str:
195
+ body = manager.load(args.get("name", ""))
196
+ if body is None:
197
+ # ok=False ⟺ genuine failure → event.failing is True → slice_sink does NOT fold this error
198
+ # string into the ACTIVE SKILLS tier as a persistent fake skill (it would survive every rebuild).
199
+ return ToolText(f"Error: no skill named {args.get('name')!r}. Available: {', '.join(names)}", ok=False)
200
+ # expand $ARGUMENTS / $N placeholders (no-op for skills without them); slice_sink folds the
201
+ # result into the ACTIVE SKILL tier (persists across turns).
202
+ return expand_skill_args(body, args.get("arguments") or "")
203
+
204
+ return ToolEntry(name="skill", schema=schema, handler=handler,
205
+ accesses=lambda _a: [], source="skill")
206
+
207
+
208
+ def make_skill_manager(roots: list[str] | None = None) -> SkillManager:
209
+ return SkillManager(roots)