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.
- sliceagent/__init__.py +3 -0
- sliceagent/__main__.py +6 -0
- sliceagent/access.py +93 -0
- sliceagent/agents.py +173 -0
- sliceagent/background_review.py +146 -0
- sliceagent/binsniff.py +89 -0
- sliceagent/cli.py +890 -0
- sliceagent/clock.py +32 -0
- sliceagent/code_grep.py +329 -0
- sliceagent/code_index.py +417 -0
- sliceagent/config.py +240 -0
- sliceagent/context_overflow.py +227 -0
- sliceagent/envspec.py +129 -0
- sliceagent/errors.py +167 -0
- sliceagent/events.py +96 -0
- sliceagent/finding_types.py +70 -0
- sliceagent/flags.py +63 -0
- sliceagent/fuzzy.py +135 -0
- sliceagent/guardrails.py +438 -0
- sliceagent/guidance.py +69 -0
- sliceagent/hippocampus.py +581 -0
- sliceagent/hooks.py +334 -0
- sliceagent/interfaces.py +144 -0
- sliceagent/llm.py +695 -0
- sliceagent/loop.py +548 -0
- sliceagent/mcp_client.py +255 -0
- sliceagent/mcp_security.py +77 -0
- sliceagent/memory.py +428 -0
- sliceagent/metrics.py +103 -0
- sliceagent/model_catalog.py +124 -0
- sliceagent/monitor.py +615 -0
- sliceagent/neocortex.py +436 -0
- sliceagent/onboarding.py +323 -0
- sliceagent/oracle.py +36 -0
- sliceagent/pagetable.py +255 -0
- sliceagent/pfc.py +449 -0
- sliceagent/plugins.py +127 -0
- sliceagent/policy.py +234 -0
- sliceagent/procman.py +187 -0
- sliceagent/prompt.py +239 -0
- sliceagent/records.py +108 -0
- sliceagent/recovery.py +119 -0
- sliceagent/regions.py +678 -0
- sliceagent/registry.py +128 -0
- sliceagent/retriever.py +19 -0
- sliceagent/safety.py +332 -0
- sliceagent/sandbox.py +143 -0
- sliceagent/scheduler.py +92 -0
- sliceagent/search_index.py +289 -0
- sliceagent/seed.py +465 -0
- sliceagent/sensory_cortex.py +500 -0
- sliceagent/session.py +222 -0
- sliceagent/skill_provenance.py +71 -0
- sliceagent/skill_usage.py +123 -0
- sliceagent/skills.py +209 -0
- sliceagent/subagent.py +332 -0
- sliceagent/subdir_hints.py +222 -0
- sliceagent/swap.py +182 -0
- sliceagent/taskstate.py +57 -0
- sliceagent/telemetry.py +59 -0
- sliceagent/terminal.py +240 -0
- sliceagent/text_utils.py +56 -0
- sliceagent/tool_summary.py +93 -0
- sliceagent/tools.py +1194 -0
- sliceagent/tui.py +1377 -0
- sliceagent/web.py +354 -0
- sliceagent-0.1.0.dist-info/METADATA +262 -0
- sliceagent-0.1.0.dist-info/RECORD +71 -0
- sliceagent-0.1.0.dist-info/WHEEL +4 -0
- sliceagent-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|