turnloop 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.
- turnloop/__init__.py +3 -0
- turnloop/__main__.py +4 -0
- turnloop/agent/__init__.py +0 -0
- turnloop/agent/factory.py +175 -0
- turnloop/agent/headless.py +150 -0
- turnloop/agent/loop.py +396 -0
- turnloop/agent/subagent.py +182 -0
- turnloop/agent/system_prompt.py +263 -0
- turnloop/cli.py +202 -0
- turnloop/commands/__init__.py +0 -0
- turnloop/commands/dispatch.py +428 -0
- turnloop/commands/loader.py +140 -0
- turnloop/config.py +425 -0
- turnloop/context/__init__.py +0 -0
- turnloop/context/budget.py +74 -0
- turnloop/context/compaction.py +441 -0
- turnloop/context/memory.py +120 -0
- turnloop/core/__init__.py +0 -0
- turnloop/core/events.py +145 -0
- turnloop/core/ids.py +29 -0
- turnloop/core/messages.py +217 -0
- turnloop/core/tokens.py +104 -0
- turnloop/diagnostics.py +233 -0
- turnloop/errors.py +64 -0
- turnloop/experiments/__init__.py +0 -0
- turnloop/experiments/configs/bakeoff.yaml +50 -0
- turnloop/experiments/configs/ceiling-check.yaml +31 -0
- turnloop/experiments/configs/context-glm.yaml +59 -0
- turnloop/experiments/configs/context.yaml +65 -0
- turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
- turnloop/experiments/configs/groq-free.yaml +28 -0
- turnloop/experiments/configs/hard-calibration.yaml +26 -0
- turnloop/experiments/configs/hard-glm.yaml +41 -0
- turnloop/experiments/configs/loop.yaml +34 -0
- turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
- turnloop/experiments/configs/reliability.yaml +44 -0
- turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
- turnloop/experiments/configs/smoke.yaml +22 -0
- turnloop/experiments/graders.py +209 -0
- turnloop/experiments/report.py +371 -0
- turnloop/experiments/runner.py +495 -0
- turnloop/experiments/suite/default.yaml +185 -0
- turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
- turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
- turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
- turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
- turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
- turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
- turnloop/experiments/suite/fixtures/empty/.keep +1 -0
- turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
- turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
- turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
- turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
- turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
- turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
- turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
- turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
- turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
- turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
- turnloop/experiments/suite/fixtures/rename/report.py +5 -0
- turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
- turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
- turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
- turnloop/experiments/suite/fixtures/util/util.py +9 -0
- turnloop/hooks/__init__.py +0 -0
- turnloop/hooks/runner.py +221 -0
- turnloop/mcp/__init__.py +0 -0
- turnloop/mcp/adapter.py +109 -0
- turnloop/mcp/client.py +299 -0
- turnloop/permissions/__init__.py +0 -0
- turnloop/permissions/engine.py +211 -0
- turnloop/permissions/rules.py +325 -0
- turnloop/providers/__init__.py +0 -0
- turnloop/providers/anthropic.py +318 -0
- turnloop/providers/base.py +329 -0
- turnloop/providers/gemini.py +217 -0
- turnloop/providers/mock.py +226 -0
- turnloop/providers/openai_compat.py +357 -0
- turnloop/providers/pricing.py +148 -0
- turnloop/providers/registry.py +75 -0
- turnloop/providers/sse.py +114 -0
- turnloop/sessions/__init__.py +0 -0
- turnloop/sessions/models.py +139 -0
- turnloop/sessions/store.py +271 -0
- turnloop/tools/__init__.py +0 -0
- turnloop/tools/ask.py +133 -0
- turnloop/tools/base.py +286 -0
- turnloop/tools/bash.py +423 -0
- turnloop/tools/builtin.py +63 -0
- turnloop/tools/edit.py +238 -0
- turnloop/tools/glob.py +235 -0
- turnloop/tools/grep.py +314 -0
- turnloop/tools/read.py +151 -0
- turnloop/tools/runner.py +284 -0
- turnloop/tools/shell.py +222 -0
- turnloop/tools/skill.py +89 -0
- turnloop/tools/task.py +161 -0
- turnloop/tools/textio.py +87 -0
- turnloop/tools/todo.py +140 -0
- turnloop/tools/webfetch.py +235 -0
- turnloop/tools/write.py +97 -0
- turnloop/tui/__init__.py +0 -0
- turnloop/tui/app.py +330 -0
- turnloop/tui/app.tcss +84 -0
- turnloop/tui/bridge.py +142 -0
- turnloop/tui/widgets/__init__.py +0 -0
- turnloop/tui/widgets/permission.py +131 -0
- turnloop/tui/widgets/status.py +110 -0
- turnloop/tui/widgets/transcript.py +146 -0
- turnloop-0.1.0.dist-info/METADATA +916 -0
- turnloop-0.1.0.dist-info/RECORD +132 -0
- turnloop-0.1.0.dist-info/WHEEL +4 -0
- turnloop-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""Slash commands.
|
|
2
|
+
|
|
3
|
+
Two kinds. Builtins act on the session (`/cost`, `/compact`, `/plan`) and never
|
|
4
|
+
reach the model. User commands are prompt templates: their body is expanded and
|
|
5
|
+
submitted as the next user turn.
|
|
6
|
+
|
|
7
|
+
Template expansion is the part with teeth. `` !`cmd` `` runs a shell command and
|
|
8
|
+
inlines its output — which means a markdown file in the repository can execute
|
|
9
|
+
code. It therefore goes through the same permission engine as a Bash tool call,
|
|
10
|
+
with no exception. A prompt template is not a trusted input just because it lives
|
|
11
|
+
on disk.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from turnloop.config import PermissionMode, Settings
|
|
21
|
+
|
|
22
|
+
BUILTINS = {
|
|
23
|
+
"help": "list commands",
|
|
24
|
+
"clear": "clear the view (history is kept)",
|
|
25
|
+
"compact": "summarize the conversation now",
|
|
26
|
+
"cost": "show token and cost accounting",
|
|
27
|
+
"context": "show what is filling the context window",
|
|
28
|
+
"model": "show or change the model",
|
|
29
|
+
"provider": "switch provider",
|
|
30
|
+
"permissions": "show permission rules and mode",
|
|
31
|
+
"plan": "switch to plan mode (read-only)",
|
|
32
|
+
"auto": "switch to auto mode (no prompts)",
|
|
33
|
+
"default": "switch back to default permissions",
|
|
34
|
+
"memory": "show discovered memory files",
|
|
35
|
+
"tools": "list available tools",
|
|
36
|
+
"mcp": "show MCP server status",
|
|
37
|
+
"hooks": "show configured hooks",
|
|
38
|
+
"sessions": "list recorded sessions",
|
|
39
|
+
"export": "write the transcript to a markdown file",
|
|
40
|
+
"doctor": "run diagnostics",
|
|
41
|
+
"quit": "exit",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
ARG_RE = re.compile(r"\$(\d|ARGUMENTS)")
|
|
45
|
+
BANG_RE = re.compile(r"!`([^`]+)`")
|
|
46
|
+
FILE_RE = re.compile(r"@([^\s]+)")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class CommandOutcome:
|
|
51
|
+
message: str = ""
|
|
52
|
+
prompt: str | None = None # submit this to the model
|
|
53
|
+
echo: bool = False
|
|
54
|
+
clear: bool = False
|
|
55
|
+
quit: bool = False
|
|
56
|
+
mode: PermissionMode | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def dispatch_command(text: str, agent, settings: Settings, cwd: Path) -> CommandOutcome:
|
|
60
|
+
raw = text.lstrip("/").strip()
|
|
61
|
+
name, _, argument_text = raw.partition(" ")
|
|
62
|
+
name = name.lower()
|
|
63
|
+
|
|
64
|
+
from turnloop.commands.loader import load_commands
|
|
65
|
+
|
|
66
|
+
user_commands = load_commands(settings.project_root)
|
|
67
|
+
if name in user_commands:
|
|
68
|
+
return await _expand_user_command(user_commands[name], argument_text, agent, settings, cwd)
|
|
69
|
+
|
|
70
|
+
handler = _BUILTIN_HANDLERS.get(name)
|
|
71
|
+
if handler is None:
|
|
72
|
+
known = ", ".join(sorted(set(BUILTINS) | set(user_commands)))
|
|
73
|
+
return CommandOutcome(message=f"unknown command /{name}. Available: {known}")
|
|
74
|
+
return await handler(argument_text, agent, settings, cwd)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# --------------------------------------------------------------------------
|
|
78
|
+
# user commands
|
|
79
|
+
# --------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def _expand_user_command(command, argument_text: str, agent, settings: Settings,
|
|
83
|
+
cwd: Path) -> CommandOutcome:
|
|
84
|
+
args = argument_text.split()
|
|
85
|
+
body = command.body
|
|
86
|
+
|
|
87
|
+
def substitute(match: re.Match[str]) -> str:
|
|
88
|
+
token = match.group(1)
|
|
89
|
+
if token == "ARGUMENTS":
|
|
90
|
+
return argument_text
|
|
91
|
+
index = int(token)
|
|
92
|
+
return args[index - 1] if 0 < index <= len(args) else ""
|
|
93
|
+
|
|
94
|
+
body = ARG_RE.sub(substitute, body)
|
|
95
|
+
body = FILE_RE.sub(lambda m: _inline_file(m.group(1), cwd), body)
|
|
96
|
+
body, refusals = await _inline_commands(body, agent, settings, cwd)
|
|
97
|
+
|
|
98
|
+
message = f"/{command.name}"
|
|
99
|
+
if refusals:
|
|
100
|
+
message += " — " + "; ".join(refusals)
|
|
101
|
+
return CommandOutcome(message=message, prompt=body)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _inline_file(reference: str, cwd: Path) -> str:
|
|
105
|
+
path = (cwd / reference).expanduser()
|
|
106
|
+
if not path.is_file():
|
|
107
|
+
return f"(missing file: {reference})"
|
|
108
|
+
try:
|
|
109
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
110
|
+
except OSError as exc:
|
|
111
|
+
return f"(unreadable: {reference}: {exc})"
|
|
112
|
+
return f"\n--- {reference} ---\n{content}\n--- end {reference} ---\n"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def _inline_commands(body: str, agent, settings: Settings, cwd: Path) -> tuple[str, list[str]]:
|
|
116
|
+
"""Expand `` !`cmd` `` through the permission engine, not around it."""
|
|
117
|
+
refusals: list[str] = []
|
|
118
|
+
matches = list(BANG_RE.finditer(body))
|
|
119
|
+
if not matches:
|
|
120
|
+
return body, refusals
|
|
121
|
+
|
|
122
|
+
from turnloop.permissions.engine import Verdict
|
|
123
|
+
from turnloop.tools.bash import BashArgs, BashTool
|
|
124
|
+
|
|
125
|
+
tool = BashTool()
|
|
126
|
+
for match in reversed(matches):
|
|
127
|
+
command = match.group(1)
|
|
128
|
+
args = BashArgs(command=command, description="slash command expansion")
|
|
129
|
+
decision = agent.permissions.check(tool, args)
|
|
130
|
+
if decision.verdict is Verdict.DENY:
|
|
131
|
+
replacement = f"(command not run: {decision.reason})"
|
|
132
|
+
refusals.append(f"{command}: denied")
|
|
133
|
+
elif decision.verdict is Verdict.ASK:
|
|
134
|
+
# A template cannot open a modal mid-expansion, and silently running it
|
|
135
|
+
# would let a checked-in markdown file execute whatever it liked.
|
|
136
|
+
replacement = (
|
|
137
|
+
f"(command not run: {command!r} needs approval; add a rule to allow it)"
|
|
138
|
+
)
|
|
139
|
+
refusals.append(f"{command}: needs approval")
|
|
140
|
+
else:
|
|
141
|
+
from turnloop.tools.base import ToolContext
|
|
142
|
+
|
|
143
|
+
ctx = ToolContext(
|
|
144
|
+
cwd=cwd,
|
|
145
|
+
settings=settings,
|
|
146
|
+
session=agent.session,
|
|
147
|
+
permissions=agent.permissions,
|
|
148
|
+
emit=agent.loop.ui.send,
|
|
149
|
+
ask=agent.loop.ui.ask,
|
|
150
|
+
)
|
|
151
|
+
output = await tool.run(args, ctx)
|
|
152
|
+
replacement = output.content
|
|
153
|
+
body = body[: match.start()] + replacement + body[match.end() :]
|
|
154
|
+
return body, refusals
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# --------------------------------------------------------------------------
|
|
158
|
+
# builtins
|
|
159
|
+
# --------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def _help(_args, agent, settings, cwd) -> CommandOutcome:
|
|
163
|
+
from turnloop.commands.loader import load_commands
|
|
164
|
+
|
|
165
|
+
lines = ["Commands:"]
|
|
166
|
+
lines += [f" /{name:<12} {desc}" for name, desc in sorted(BUILTINS.items())]
|
|
167
|
+
if user := load_commands(settings.project_root):
|
|
168
|
+
lines.append("Project commands:")
|
|
169
|
+
lines += [
|
|
170
|
+
f" /{c.name:<12} {c.description or c.path.name}" for c in sorted(user.values(), key=lambda c: c.name)
|
|
171
|
+
]
|
|
172
|
+
lines += [
|
|
173
|
+
"",
|
|
174
|
+
"Keys: ctrl+c interrupt · ctrl+p cycle permission mode · ctrl+l clear · ctrl+d quit",
|
|
175
|
+
]
|
|
176
|
+
return CommandOutcome(message="\n".join(lines))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def _clear(_args, agent, settings, cwd) -> CommandOutcome:
|
|
180
|
+
return CommandOutcome(clear=True)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
async def _quit(_args, agent, settings, cwd) -> CommandOutcome:
|
|
184
|
+
return CommandOutcome(quit=True)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def _compact(args, agent, settings, cwd) -> CommandOutcome:
|
|
188
|
+
from turnloop.context.budget import Budget, output_reserve
|
|
189
|
+
|
|
190
|
+
budget = Budget.build(
|
|
191
|
+
agent.provider.caps.max_context,
|
|
192
|
+
output_reserve(
|
|
193
|
+
settings.compaction.reserve_output_tokens, agent.provider.caps.max_output
|
|
194
|
+
),
|
|
195
|
+
agent.loop.system,
|
|
196
|
+
agent.registry.specs(settings.verbosity_for()),
|
|
197
|
+
)
|
|
198
|
+
compactor = agent.loop.compactor
|
|
199
|
+
if args:
|
|
200
|
+
compactor.extra_instructions = args
|
|
201
|
+
result = await compactor._full_compact(agent.session, budget.available)
|
|
202
|
+
if not result.happened:
|
|
203
|
+
return CommandOutcome(message="nothing to compact yet")
|
|
204
|
+
return CommandOutcome(
|
|
205
|
+
message=(
|
|
206
|
+
f"compacted {result.tokens_before:,} → {result.tokens_after:,} tokens "
|
|
207
|
+
f"({result.reclaimed:,} reclaimed)"
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
async def _cost(_args, agent, settings, cwd) -> CommandOutcome:
|
|
213
|
+
cost = agent.session.cost
|
|
214
|
+
lines = [
|
|
215
|
+
f"requests {cost.requests}",
|
|
216
|
+
f"input tokens {cost.usage.input_tokens:,}",
|
|
217
|
+
f"output tokens {cost.usage.output_tokens:,}",
|
|
218
|
+
]
|
|
219
|
+
if cost.usage.cache_read_tokens:
|
|
220
|
+
lines.append(f"cache reads {cost.usage.cache_read_tokens:,}")
|
|
221
|
+
if cost.cost_usd:
|
|
222
|
+
lines.append(f"token cost ${cost.cost_usd:.4f}")
|
|
223
|
+
for name, amount in sorted(cost.by_provider.items()):
|
|
224
|
+
lines.append(f" via {name:<10} ${amount:.4f}")
|
|
225
|
+
if (gpu := agent.provider.gpu_seconds()) is not None:
|
|
226
|
+
hourly = agent.provider.caps.cost_per_hour
|
|
227
|
+
lines.append(f"gpu wall clock {gpu / 60:.1f} min ≈ ${gpu / 3600 * hourly:.2f}")
|
|
228
|
+
lines.append(" stop it with: modal app stop glm-5-2-serve")
|
|
229
|
+
return CommandOutcome(message="\n".join(lines))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
async def _context(_args, agent, settings, cwd) -> CommandOutcome:
|
|
233
|
+
"""Show what is actually filling the window — usually a surprise."""
|
|
234
|
+
from turnloop.core.tokens import message_tokens, rough_tokens, tool_spec_tokens
|
|
235
|
+
|
|
236
|
+
system = rough_tokens("\n\n".join(agent.loop.system))
|
|
237
|
+
tools = tool_spec_tokens(agent.registry.specs(settings.verbosity_for()))
|
|
238
|
+
history = sum(message_tokens(m) for m in agent.session.messages)
|
|
239
|
+
from turnloop.context.budget import output_reserve
|
|
240
|
+
|
|
241
|
+
reserve = output_reserve(
|
|
242
|
+
settings.compaction.reserve_output_tokens, agent.provider.caps.max_output
|
|
243
|
+
)
|
|
244
|
+
total = agent.provider.caps.max_context
|
|
245
|
+
|
|
246
|
+
biggest = sorted(
|
|
247
|
+
((message_tokens(m), i, m) for i, m in enumerate(agent.session.messages)),
|
|
248
|
+
reverse=True,
|
|
249
|
+
)[:5]
|
|
250
|
+
|
|
251
|
+
lines = [
|
|
252
|
+
f"window {total:,}",
|
|
253
|
+
f" system {system:,}",
|
|
254
|
+
f" tools {tools:,} ({len(agent.registry)} tools, {settings.verbosity_for()})",
|
|
255
|
+
f" history {history:,} ({len(agent.session.messages)} messages)",
|
|
256
|
+
f" reserved out {reserve:,}",
|
|
257
|
+
f" free {total - system - tools - history - reserve:,}",
|
|
258
|
+
f"compactions {agent.session.compactions}",
|
|
259
|
+
"largest messages:",
|
|
260
|
+
]
|
|
261
|
+
for tokens, index, message in biggest:
|
|
262
|
+
kind = message.role
|
|
263
|
+
if message.tool_results:
|
|
264
|
+
kind += " (tool result)"
|
|
265
|
+
lines.append(f" #{index:<4} {kind:<20} {tokens:,} tokens")
|
|
266
|
+
return CommandOutcome(message="\n".join(lines))
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
async def _permissions(_args, agent, settings, cwd) -> CommandOutcome:
|
|
270
|
+
snap = agent.permissions.snapshot()
|
|
271
|
+
lines = [f"mode {snap['mode']}"]
|
|
272
|
+
for key in ("allow", "deny", "ask", "session_grants"):
|
|
273
|
+
if snap[key]:
|
|
274
|
+
lines.append(f"{key:<15} {', '.join(snap[key])}")
|
|
275
|
+
return CommandOutcome(message="\n".join(lines))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
async def _plan(_args, agent, settings, cwd) -> CommandOutcome:
|
|
279
|
+
return CommandOutcome(message="plan mode: tools that modify anything are blocked",
|
|
280
|
+
mode="plan")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
async def _auto(_args, agent, settings, cwd) -> CommandOutcome:
|
|
284
|
+
return CommandOutcome(message="auto mode: no prompts (deny rules still apply)", mode="auto")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
async def _default_mode(_args, agent, settings, cwd) -> CommandOutcome:
|
|
288
|
+
return CommandOutcome(message="default mode: writes ask for confirmation", mode="default")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
async def _model(args, agent, settings, cwd) -> CommandOutcome:
|
|
292
|
+
if not args:
|
|
293
|
+
caps = agent.provider.caps
|
|
294
|
+
return CommandOutcome(
|
|
295
|
+
message=(
|
|
296
|
+
f"{agent.provider.name}:{agent.provider.model}\n"
|
|
297
|
+
f"context {caps.max_context:,} · output {caps.max_output:,} · "
|
|
298
|
+
f"caching {'yes' if caps.supports_prompt_caching else 'no'} · "
|
|
299
|
+
f"reasoning field {'yes' if caps.supports_reasoning_field else 'no'}\n"
|
|
300
|
+
"changing the model mid-session needs a restart: "
|
|
301
|
+
"turnloop --provider <name> --model <id>"
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
return CommandOutcome(
|
|
305
|
+
message=f"restart with: turnloop --provider {agent.provider.name} --model {args}"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
async def _provider(args, agent, settings, cwd) -> CommandOutcome:
|
|
310
|
+
if not args:
|
|
311
|
+
return CommandOutcome(
|
|
312
|
+
message="providers: " + ", ".join(sorted(settings.providers))
|
|
313
|
+
)
|
|
314
|
+
return CommandOutcome(message=f"restart with: turnloop --provider {args}")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
async def _memory(_args, agent, settings, cwd) -> CommandOutcome:
|
|
318
|
+
from turnloop.context.memory import discover_memory
|
|
319
|
+
|
|
320
|
+
files = discover_memory(cwd, settings.project_root)
|
|
321
|
+
if not files:
|
|
322
|
+
return CommandOutcome(
|
|
323
|
+
message="no memory files. Create TURNLOOP.md (or CLAUDE.md) for project instructions."
|
|
324
|
+
)
|
|
325
|
+
return CommandOutcome(
|
|
326
|
+
message="\n".join(f"{m.path} ({m.scope}, ~{m.tokens:,} tokens)" for m in files)
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
async def _tools(_args, agent, settings, cwd) -> CommandOutcome:
|
|
331
|
+
lines = []
|
|
332
|
+
for tool in sorted(agent.registry, key=lambda t: t.name):
|
|
333
|
+
flags = []
|
|
334
|
+
if tool.read_only:
|
|
335
|
+
flags.append("read-only")
|
|
336
|
+
if tool.parallel_safe:
|
|
337
|
+
flags.append("parallel")
|
|
338
|
+
lines.append(f" {tool.name:<18} {', '.join(flags)}")
|
|
339
|
+
return CommandOutcome(message=f"{len(agent.registry)} tools:\n" + "\n".join(lines))
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
async def _mcp(_args, agent, settings, cwd) -> CommandOutcome:
|
|
343
|
+
if not settings.mcp_servers:
|
|
344
|
+
return CommandOutcome(message="no MCP servers configured")
|
|
345
|
+
lines = []
|
|
346
|
+
for name, cfg in sorted(settings.mcp_servers.items()):
|
|
347
|
+
tools = [t for t in agent.registry.names() if t.startswith(f"mcp__{name}__")]
|
|
348
|
+
state = "disabled" if not cfg.enabled else (f"{len(tools)} tools" if tools else "no tools")
|
|
349
|
+
lines.append(f" {name:<16} {cfg.transport:<6} {state}")
|
|
350
|
+
return CommandOutcome(message="\n".join(lines))
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
async def _hooks(_args, agent, settings, cwd) -> CommandOutcome:
|
|
354
|
+
if not settings.hooks:
|
|
355
|
+
return CommandOutcome(message="no hooks configured")
|
|
356
|
+
lines = []
|
|
357
|
+
for event, matchers in sorted(settings.hooks.items()):
|
|
358
|
+
for matcher in matchers:
|
|
359
|
+
for hook in matcher.hooks:
|
|
360
|
+
lines.append(f" {event:<18} {matcher.matcher:<12} {hook.command}")
|
|
361
|
+
return CommandOutcome(message="\n".join(lines))
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
async def _sessions(_args, agent, settings, cwd) -> CommandOutcome:
|
|
365
|
+
from turnloop.sessions.store import SessionStore
|
|
366
|
+
|
|
367
|
+
rows = SessionStore.list_sessions(settings.project_root)[:15]
|
|
368
|
+
if not rows:
|
|
369
|
+
return CommandOutcome(message="no recorded sessions")
|
|
370
|
+
return CommandOutcome(
|
|
371
|
+
message="\n".join(
|
|
372
|
+
f" {r.session_id} {r.started_at:%Y-%m-%d %H:%M} {r.messages:>4} msgs {r.summary[:50]}"
|
|
373
|
+
for r in rows
|
|
374
|
+
)
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
async def _export(args, agent, settings, cwd) -> CommandOutcome:
|
|
379
|
+
target = Path(args.strip()) if args.strip() else cwd / f"{agent.session.session_id}.md"
|
|
380
|
+
lines = [f"# turnloop session {agent.session.session_id}", ""]
|
|
381
|
+
for message in agent.session.messages:
|
|
382
|
+
if message.role == "user" and message.tool_results:
|
|
383
|
+
for result in message.tool_results:
|
|
384
|
+
status = "error" if result.is_error else "ok"
|
|
385
|
+
lines += [f"**tool result ({status})**", "", "```", result.content[:4000], "```", ""]
|
|
386
|
+
continue
|
|
387
|
+
who = "User" if message.role == "user" else "Assistant"
|
|
388
|
+
lines += [f"## {who}", "", message.text, ""]
|
|
389
|
+
for call in message.tool_uses:
|
|
390
|
+
lines += [f"**{call.name}** `{call.args}`", ""]
|
|
391
|
+
target.write_text("\n".join(lines), encoding="utf-8")
|
|
392
|
+
return CommandOutcome(message=f"exported to {target}")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
async def _doctor(_args, agent, settings, cwd) -> CommandOutcome:
|
|
396
|
+
import io
|
|
397
|
+
from contextlib import redirect_stdout
|
|
398
|
+
|
|
399
|
+
from turnloop.diagnostics import run_doctor
|
|
400
|
+
|
|
401
|
+
buffer = io.StringIO()
|
|
402
|
+
with redirect_stdout(buffer):
|
|
403
|
+
run_doctor(settings, cwd)
|
|
404
|
+
return CommandOutcome(message=buffer.getvalue())
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
_BUILTIN_HANDLERS = {
|
|
408
|
+
"help": _help,
|
|
409
|
+
"clear": _clear,
|
|
410
|
+
"quit": _quit,
|
|
411
|
+
"exit": _quit,
|
|
412
|
+
"compact": _compact,
|
|
413
|
+
"cost": _cost,
|
|
414
|
+
"context": _context,
|
|
415
|
+
"permissions": _permissions,
|
|
416
|
+
"plan": _plan,
|
|
417
|
+
"auto": _auto,
|
|
418
|
+
"default": _default_mode,
|
|
419
|
+
"model": _model,
|
|
420
|
+
"provider": _provider,
|
|
421
|
+
"memory": _memory,
|
|
422
|
+
"tools": _tools,
|
|
423
|
+
"mcp": _mcp,
|
|
424
|
+
"hooks": _hooks,
|
|
425
|
+
"sessions": _sessions,
|
|
426
|
+
"export": _export,
|
|
427
|
+
"doctor": _doctor,
|
|
428
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Discovery of user-authored slash commands and skills.
|
|
2
|
+
|
|
3
|
+
Both are markdown files with YAML frontmatter; they differ in how they are
|
|
4
|
+
invoked and, crucially, in what they cost.
|
|
5
|
+
|
|
6
|
+
* A **command** is expanded when the user types `/name`. Its body never sits in
|
|
7
|
+
the system prompt.
|
|
8
|
+
* A **skill** advertises only its `name` and `description` in the system prompt —
|
|
9
|
+
a line or two — and the model loads the full body through the Skill tool when it
|
|
10
|
+
decides the skill applies.
|
|
11
|
+
|
|
12
|
+
That distinction is the whole design. Ten skills with 2,000-token bodies would be
|
|
13
|
+
20,000 tokens of permanent overhead; as name-and-description pairs they cost about
|
|
14
|
+
300, and the body arrives only when it is relevant.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True)
|
|
26
|
+
class Frontmatter:
|
|
27
|
+
data: dict
|
|
28
|
+
body: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_frontmatter(text: str) -> Frontmatter:
|
|
32
|
+
"""Split a leading `---` YAML block off a markdown file."""
|
|
33
|
+
if not text.startswith("---"):
|
|
34
|
+
return Frontmatter({}, text)
|
|
35
|
+
lines = text.splitlines()
|
|
36
|
+
end = next((i for i in range(1, len(lines)) if lines[i].strip() in ("---", "...")), None)
|
|
37
|
+
if end is None:
|
|
38
|
+
return Frontmatter({}, text)
|
|
39
|
+
try:
|
|
40
|
+
data = yaml.safe_load("\n".join(lines[1:end])) or {}
|
|
41
|
+
except yaml.YAMLError:
|
|
42
|
+
data = {}
|
|
43
|
+
if not isinstance(data, dict):
|
|
44
|
+
data = {}
|
|
45
|
+
return Frontmatter(data, "\n".join(lines[end + 1 :]).lstrip("\n"))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(slots=True)
|
|
49
|
+
class UserCommand:
|
|
50
|
+
name: str
|
|
51
|
+
description: str
|
|
52
|
+
body: str
|
|
53
|
+
path: Path
|
|
54
|
+
argument_hint: str = ""
|
|
55
|
+
allowed_tools: list[str] = field(default_factory=list)
|
|
56
|
+
model: str | None = None
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def invocation(self) -> str:
|
|
60
|
+
return f"/{self.name}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(slots=True)
|
|
64
|
+
class Skill:
|
|
65
|
+
name: str
|
|
66
|
+
description: str
|
|
67
|
+
body: str
|
|
68
|
+
path: Path
|
|
69
|
+
|
|
70
|
+
def advertise(self) -> str:
|
|
71
|
+
return f"- {self.name}: {self.description}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _search_dirs(project_root: Path, kind: str) -> list[tuple[Path, str]]:
|
|
75
|
+
return [
|
|
76
|
+
(Path.home() / ".turnloop" / kind, "user"),
|
|
77
|
+
(project_root / ".turnloop" / kind, "project"),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_commands(project_root: Path) -> dict[str, UserCommand]:
|
|
82
|
+
"""Load `.turnloop/commands/**.md`. Project definitions win over user ones.
|
|
83
|
+
|
|
84
|
+
Nested directories namespace the command: `commands/git/sync.md` -> `/git:sync`.
|
|
85
|
+
"""
|
|
86
|
+
commands: dict[str, UserCommand] = {}
|
|
87
|
+
for directory, _scope in _search_dirs(project_root, "commands"):
|
|
88
|
+
if not directory.is_dir():
|
|
89
|
+
continue
|
|
90
|
+
for path in sorted(directory.rglob("*.md")):
|
|
91
|
+
rel = path.relative_to(directory).with_suffix("")
|
|
92
|
+
name = ":".join(rel.parts)
|
|
93
|
+
fm = parse_frontmatter(_read(path))
|
|
94
|
+
commands[name] = UserCommand(
|
|
95
|
+
name=name,
|
|
96
|
+
description=str(fm.data.get("description", "")).strip(),
|
|
97
|
+
body=fm.body,
|
|
98
|
+
path=path,
|
|
99
|
+
argument_hint=str(fm.data.get("argument-hint", "")).strip(),
|
|
100
|
+
allowed_tools=_as_list(fm.data.get("allowed-tools")),
|
|
101
|
+
model=fm.data.get("model"),
|
|
102
|
+
)
|
|
103
|
+
return commands
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def load_skills(project_root: Path) -> dict[str, Skill]:
|
|
107
|
+
"""Load `.turnloop/skills/<name>/SKILL.md`."""
|
|
108
|
+
skills: dict[str, Skill] = {}
|
|
109
|
+
for directory, _scope in _search_dirs(project_root, "skills"):
|
|
110
|
+
if not directory.is_dir():
|
|
111
|
+
continue
|
|
112
|
+
for skill_file in sorted(directory.glob("*/SKILL.md")):
|
|
113
|
+
fm = parse_frontmatter(_read(skill_file))
|
|
114
|
+
name = str(fm.data.get("name") or skill_file.parent.name).strip()
|
|
115
|
+
description = str(fm.data.get("description", "")).strip()
|
|
116
|
+
if not description:
|
|
117
|
+
# Without a description the model has no basis for choosing it, so
|
|
118
|
+
# advertising it would be pure context cost.
|
|
119
|
+
continue
|
|
120
|
+
skills[name] = Skill(
|
|
121
|
+
name=name, description=description, body=fm.body, path=skill_file
|
|
122
|
+
)
|
|
123
|
+
return skills
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _read(path: Path) -> str:
|
|
127
|
+
try:
|
|
128
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
129
|
+
except OSError:
|
|
130
|
+
return ""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _as_list(value) -> list[str]:
|
|
134
|
+
if value is None:
|
|
135
|
+
return []
|
|
136
|
+
if isinstance(value, str):
|
|
137
|
+
return [v.strip() for v in value.split(",") if v.strip()]
|
|
138
|
+
if isinstance(value, list):
|
|
139
|
+
return [str(v).strip() for v in value]
|
|
140
|
+
return []
|