pico-cli-sdk 0.1.1__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.
- pico_cli_sdk-0.1.1.dist-info/METADATA +28 -0
- pico_cli_sdk-0.1.1.dist-info/RECORD +12 -0
- pico_cli_sdk-0.1.1.dist-info/WHEEL +4 -0
- pico_sdk/__init__.py +49 -0
- pico_sdk/__main__.py +6 -0
- pico_sdk/cli.py +157 -0
- pico_sdk/config.py +57 -0
- pico_sdk/extensions.py +108 -0
- pico_sdk/providers.py +92 -0
- pico_sdk/py.typed +1 -0
- pico_sdk/session.py +316 -0
- pico_sdk/skills.py +102 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pico-cli-sdk
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Headless library API and extension/plugin binding.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Arya-Ojha/PicoCLI_Learn
|
|
6
|
+
Project-URL: Repository, https://github.com/Arya-Ojha/PicoCLI_Learn
|
|
7
|
+
Project-URL: Issues, https://github.com/Arya-Ojha/PicoCLI_Learn/issues
|
|
8
|
+
Author-email: Arya-Ojha <aryaojha195@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: ai-agent,cli,coding-assistant,llm
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Requires-Dist: pico-cli-ai
|
|
20
|
+
Requires-Dist: pico-cli-core
|
|
21
|
+
Requires-Dist: pydantic>=2
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# pico-cli-sdk
|
|
25
|
+
|
|
26
|
+
Headless library API for `pico-cli`: `AgentSession`, curated observe-only hooks, `SKILL.md` skills, provider setup, and the `picocli-chat` command-line interface.
|
|
27
|
+
|
|
28
|
+
See the [pico-cli README](https://github.com/Arya-Ojha/PicoCLI_Learn#readme) for the full picture.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
pico_sdk/__init__.py,sha256=VyHjArq8d2BC9yP0Dc8Wm1qLXVMLnIdRI3ZC8vUHhYc,1253
|
|
2
|
+
pico_sdk/__main__.py,sha256=RhLRauBRlaCTsl6hIs0Q6cvhMMu3hRzmAR7su3NPfF8,137
|
|
3
|
+
pico_sdk/cli.py,sha256=G21KpSBC_u1IGmr0mtr39uwFiqnQ49ICwNgsXTFKQXg,5781
|
|
4
|
+
pico_sdk/config.py,sha256=Ecu9mdIbhNrIXeyIJOgk8B4eocvt7dealgXgzHgjFGY,2257
|
|
5
|
+
pico_sdk/extensions.py,sha256=BTQDjfrr_D2cswr8kIa85XuJdS1f8B7e9Ab-qMhsY4A,3657
|
|
6
|
+
pico_sdk/providers.py,sha256=RjXyxVG61Q8x86Eo-sDsEDf4ZeL0Tte1EgNdSDiTfGw,3169
|
|
7
|
+
pico_sdk/py.typed,sha256=s9UQ7wQnXKjmmOWzy7Ds45Se-SUvDNyDnp7jR0CaIgk,2
|
|
8
|
+
pico_sdk/session.py,sha256=P4vZysV7_6WXxEaOEq7Wlq_fila2vcu3moyyooADQeU,12253
|
|
9
|
+
pico_sdk/skills.py,sha256=mSEG1niW2LJvos1OLgvpNYp55hiOvI_MxbnJNPTF-qo,3489
|
|
10
|
+
pico_cli_sdk-0.1.1.dist-info/METADATA,sha256=5UJ0waJJPZLlhQVnCTFPoHE267tgT2kdFjYqdgj6vw4,1183
|
|
11
|
+
pico_cli_sdk-0.1.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
12
|
+
pico_cli_sdk-0.1.1.dist-info/RECORD,,
|
pico_sdk/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""pico_sdk: the headless library API and curated hooks/skills."""
|
|
2
|
+
|
|
3
|
+
from pico_core.fsm import AgentState, LoopEvent, RunResult
|
|
4
|
+
from pico_core.session import (
|
|
5
|
+
AssistantPayload,
|
|
6
|
+
CompactionSummaryPayload,
|
|
7
|
+
Node,
|
|
8
|
+
Session,
|
|
9
|
+
ToolRequestPayload,
|
|
10
|
+
ToolResultPayload,
|
|
11
|
+
UserPayload,
|
|
12
|
+
)
|
|
13
|
+
from pico_core.subagents import ChildSpec, SpawnTool
|
|
14
|
+
from pico_core.trace import TraceRow, assemble_trace_rows
|
|
15
|
+
|
|
16
|
+
from .config import Settings, load_settings
|
|
17
|
+
from .extensions import ALLOWED_HOOKS, ExtensionManager
|
|
18
|
+
from .session import DEFAULT_SYSTEM_PROMPT, AgentSession
|
|
19
|
+
from .skills import Skill, discover_skills, merge_skills, parse_skill_file, render_skills_prompt
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"AgentSession",
|
|
23
|
+
"ExtensionManager",
|
|
24
|
+
"ALLOWED_HOOKS",
|
|
25
|
+
"Settings",
|
|
26
|
+
"load_settings",
|
|
27
|
+
"Skill",
|
|
28
|
+
"discover_skills",
|
|
29
|
+
"merge_skills",
|
|
30
|
+
"parse_skill_file",
|
|
31
|
+
"render_skills_prompt",
|
|
32
|
+
"DEFAULT_SYSTEM_PROMPT",
|
|
33
|
+
"ChildSpec",
|
|
34
|
+
"SpawnTool",
|
|
35
|
+
"TraceRow",
|
|
36
|
+
"assemble_trace_rows",
|
|
37
|
+
"AgentState",
|
|
38
|
+
"LoopEvent",
|
|
39
|
+
"RunResult",
|
|
40
|
+
"AssistantPayload",
|
|
41
|
+
"CompactionSummaryPayload",
|
|
42
|
+
"Node",
|
|
43
|
+
"Session",
|
|
44
|
+
"ToolRequestPayload",
|
|
45
|
+
"ToolResultPayload",
|
|
46
|
+
"UserPayload",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
pico_sdk/__main__.py
ADDED
pico_sdk/cli.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""The ``pico`` command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from pico_core.fsm import LoopEvent
|
|
10
|
+
|
|
11
|
+
from .config import Settings, load_settings
|
|
12
|
+
from .providers import (
|
|
13
|
+
FREE_MODEL_ALIAS,
|
|
14
|
+
create_provider,
|
|
15
|
+
missing_required,
|
|
16
|
+
resolve_free_model,
|
|
17
|
+
)
|
|
18
|
+
from .session import AgentSession
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def format_event(event: LoopEvent) -> str | None:
|
|
22
|
+
"""Return the text to print for an event, or ``None`` to print nothing."""
|
|
23
|
+
if event.kind == "text":
|
|
24
|
+
return event.text
|
|
25
|
+
if event.kind == "tool_request" and event.tool_request is not None:
|
|
26
|
+
if event.tool_request.tool_call.name == "bash":
|
|
27
|
+
return "$ " + event.tool_request.tool_call.arguments.get("command", "") + "\n"
|
|
28
|
+
if event.kind == "tool_result" and event.tool_result is not None:
|
|
29
|
+
if event.tool_result.name == "bash":
|
|
30
|
+
return event.tool_result.content.rstrip() + "\n"
|
|
31
|
+
if event.tool_result.is_error:
|
|
32
|
+
# Surface failures (denied/unknown/error) for non-bash tools;
|
|
33
|
+
# successful read/write/edit/grep/fetch/websearch/todo results
|
|
34
|
+
# stay quiet — the model's summary covers them.
|
|
35
|
+
return f"error [{event.tool_result.name}]: {event.tool_result.content.rstrip()}\n"
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def apply_cli_overrides(args: argparse.Namespace, settings: Settings) -> bool:
|
|
40
|
+
"""Apply CLI flags onto ``settings`` (permission gating + skills).
|
|
41
|
+
|
|
42
|
+
``--allow-tools`` (comma-separated) wins over ``--no-bash`` per ADR-0003
|
|
43
|
+
precedence; ``--skills-dir`` overrides the configured skills directory;
|
|
44
|
+
``--no-skills`` disables skill loading entirely (returns the flag).
|
|
45
|
+
"""
|
|
46
|
+
if getattr(args, "allow_tools", None):
|
|
47
|
+
settings.allowed_tools = [
|
|
48
|
+
name.strip() for name in args.allow_tools.split(",") if name.strip()
|
|
49
|
+
]
|
|
50
|
+
if getattr(args, "skills_dir", None):
|
|
51
|
+
settings.skills_dir = args.skills_dir
|
|
52
|
+
return not getattr(args, "no_skills", False)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
56
|
+
parser = argparse.ArgumentParser(prog="picocli-chat", description="A headless coding agent.")
|
|
57
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
58
|
+
|
|
59
|
+
run = sub.add_parser("run", help="Run the agent on a prompt.")
|
|
60
|
+
run.add_argument("prompt", nargs="+", help="The prompt to run.")
|
|
61
|
+
run.add_argument(
|
|
62
|
+
"--no-bash",
|
|
63
|
+
action="store_true",
|
|
64
|
+
help="Disable unsandboxed bash (on by default; ignored when "
|
|
65
|
+
"allowed_tools is set without 'bash').",
|
|
66
|
+
)
|
|
67
|
+
run.add_argument("--model", default=None, help="Override the configured model.")
|
|
68
|
+
run.add_argument("--cwd", default=None, help="Working directory (default: current).")
|
|
69
|
+
run.add_argument("--session", default=None, help="Resume an existing session by id.")
|
|
70
|
+
run.add_argument(
|
|
71
|
+
"--allow-tools",
|
|
72
|
+
default=None,
|
|
73
|
+
help="Comma-separated tool allowlist (e.g. 'read,grep,bash'); "
|
|
74
|
+
"overrides settings.allowed_tools and --no-bash precedence.",
|
|
75
|
+
)
|
|
76
|
+
run.add_argument(
|
|
77
|
+
"--skills-dir", default=None, help="Override the configured skills directory."
|
|
78
|
+
)
|
|
79
|
+
run.add_argument(
|
|
80
|
+
"--no-skills", action="store_true", help="Disable SKILL.md loading."
|
|
81
|
+
)
|
|
82
|
+
run.add_argument(
|
|
83
|
+
"--provider", default=None, help="Provider id (e.g. 'openai', 'ollama')."
|
|
84
|
+
)
|
|
85
|
+
return parser
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def run_command(args: argparse.Namespace) -> int:
|
|
89
|
+
settings = load_settings()
|
|
90
|
+
if args.provider:
|
|
91
|
+
settings.provider = args.provider
|
|
92
|
+
model = args.model or settings.model
|
|
93
|
+
load_skills = apply_cli_overrides(args, settings)
|
|
94
|
+
missing = missing_required(settings.provider, settings)
|
|
95
|
+
|
|
96
|
+
if missing:
|
|
97
|
+
sys.stderr.write(
|
|
98
|
+
f"error: provider '{settings.provider}' is missing required "
|
|
99
|
+
f"config: {', '.join(missing)}.\n"
|
|
100
|
+
f"Run `picocli` and use /provider to configure it, or set "
|
|
101
|
+
f"the corresponding environment variable.\n"
|
|
102
|
+
)
|
|
103
|
+
return 1
|
|
104
|
+
|
|
105
|
+
provider = create_provider(settings)
|
|
106
|
+
if model == FREE_MODEL_ALIAS and settings.provider == "openrouter":
|
|
107
|
+
resolved = await resolve_free_model(provider)
|
|
108
|
+
if resolved:
|
|
109
|
+
model = resolved
|
|
110
|
+
sys.stdout.write(f"using free model: {model}\n")
|
|
111
|
+
if args.session:
|
|
112
|
+
session = AgentSession.load(
|
|
113
|
+
args.session,
|
|
114
|
+
provider=provider,
|
|
115
|
+
model=model,
|
|
116
|
+
settings=settings,
|
|
117
|
+
working_dir=args.cwd,
|
|
118
|
+
allow_bash=not args.no_bash,
|
|
119
|
+
load_skills=load_skills,
|
|
120
|
+
)
|
|
121
|
+
else:
|
|
122
|
+
session = AgentSession(
|
|
123
|
+
provider=provider,
|
|
124
|
+
model=model,
|
|
125
|
+
settings=settings,
|
|
126
|
+
working_dir=args.cwd,
|
|
127
|
+
allow_bash=not args.no_bash,
|
|
128
|
+
load_skills=load_skills,
|
|
129
|
+
)
|
|
130
|
+
prompt = " ".join(args.prompt)
|
|
131
|
+
if prompt.startswith("/compact"):
|
|
132
|
+
instructions = prompt[len("/compact") :].strip()
|
|
133
|
+
await session.compact(instructions)
|
|
134
|
+
sys.stdout.write("compacted context\n")
|
|
135
|
+
session.save()
|
|
136
|
+
return 0
|
|
137
|
+
async for event in session.stream(prompt):
|
|
138
|
+
rendered = format_event(event)
|
|
139
|
+
if rendered:
|
|
140
|
+
sys.stdout.write(rendered)
|
|
141
|
+
sys.stdout.flush()
|
|
142
|
+
sys.stdout.write("\n")
|
|
143
|
+
session.save()
|
|
144
|
+
return 0
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def main(argv: list[str] | None = None) -> int:
|
|
148
|
+
parser = build_parser()
|
|
149
|
+
args = parser.parse_args(argv)
|
|
150
|
+
if args.command == "run":
|
|
151
|
+
return asyncio.run(run_command(args))
|
|
152
|
+
parser.error(f"unknown command: {args.command}")
|
|
153
|
+
return 1
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
raise SystemExit(main())
|
pico_sdk/config.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Settings loaded from ``~/.pico/settings.json`` with sensible defaults."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Settings(BaseModel):
|
|
12
|
+
"""User-configurable settings for the agent."""
|
|
13
|
+
|
|
14
|
+
model: str = "openrouter/free" # alias: best available free model
|
|
15
|
+
context_window: int = 128_000
|
|
16
|
+
reserve_tokens: int = 16_384
|
|
17
|
+
session_dir: str = "~/.pico/sessions"
|
|
18
|
+
api_key_env: str = "OPENROUTER_API_KEY"
|
|
19
|
+
# Active provider id (see ``pico_ai.providers`` registry).
|
|
20
|
+
provider: str = "openrouter"
|
|
21
|
+
# Per-provider stored config, e.g. {"openai": {"api_key": "sk-…"}}.
|
|
22
|
+
# Secrets live here in plaintext — prefer env vars for shared machines.
|
|
23
|
+
providers: dict[str, dict[str, str]] = Field(default_factory=dict)
|
|
24
|
+
# Curated-extension settings (ADR-0003: hardcoded core).
|
|
25
|
+
skills_dir: str = "~/.pico/skills"
|
|
26
|
+
# Permission gating: which core tools the model may invoke.
|
|
27
|
+
# ``None`` (default) allows every core tool; ``[]`` allows none.
|
|
28
|
+
# When set, this loop-level gate wins over the per-tool ``allow_bash``
|
|
29
|
+
# flag. Unknown names are ignored with a startup warning.
|
|
30
|
+
allowed_tools: list[str] | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def default_settings_path() -> Path:
|
|
34
|
+
"""Return the default settings file path."""
|
|
35
|
+
return Path.home() / ".pico" / "settings.json"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_settings(path: Path | None = None) -> Settings:
|
|
39
|
+
"""Load settings from ``path`` (default ``~/.pico/settings.json``).
|
|
40
|
+
|
|
41
|
+
Missing keys fall back to defaults; a missing file yields all defaults.
|
|
42
|
+
"""
|
|
43
|
+
path = Path(path) if path is not None else default_settings_path()
|
|
44
|
+
if not path.exists():
|
|
45
|
+
return Settings()
|
|
46
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
47
|
+
return Settings.model_validate(data)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def save_settings(settings: Settings, path: Path | None = None) -> Path:
|
|
51
|
+
"""Persist ``settings`` to ``path`` (default ``~/.pico/settings.json``)."""
|
|
52
|
+
path = Path(path) if path is not None else default_settings_path()
|
|
53
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
path.write_text(
|
|
55
|
+
json.dumps(settings.model_dump(), indent=2) + "\n", encoding="utf-8"
|
|
56
|
+
)
|
|
57
|
+
return path
|
pico_sdk/extensions.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Curated extension hooks (Claude Code-style).
|
|
2
|
+
|
|
3
|
+
The core (tools, loop, provider, session) is hardcoded and non-replaceable
|
|
4
|
+
(see ADR-0003). Extensions are guests: they observe lifecycle events through
|
|
5
|
+
a small fixed hook vocabulary. Hooks cannot mutate arguments/results or veto
|
|
6
|
+
execution.
|
|
7
|
+
|
|
8
|
+
Fixed vocabulary:
|
|
9
|
+
- ``session_start`` — fires once per ``AgentSession.stream`` run.
|
|
10
|
+
- ``pre_tool_use`` — fires before a tool runs (observe-only).
|
|
11
|
+
- ``post_tool_use`` — fires after a tool succeeds.
|
|
12
|
+
- ``post_tool_failure`` — fires after a tool returns an error result or raises.
|
|
13
|
+
|
|
14
|
+
Legacy aliases (``on_session_start``, ``tool.before.*``, ``tool.after.*``)
|
|
15
|
+
are accepted and mapped onto the fixed vocabulary.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import inspect
|
|
21
|
+
from collections import defaultdict
|
|
22
|
+
from typing import Any, Callable
|
|
23
|
+
|
|
24
|
+
from pico_core.session import Session, ToolResultPayload
|
|
25
|
+
|
|
26
|
+
Hook = Callable[..., Any]
|
|
27
|
+
|
|
28
|
+
#: The only hook names ``on()`` accepts (after alias normalisation).
|
|
29
|
+
ALLOWED_HOOKS = frozenset(
|
|
30
|
+
{
|
|
31
|
+
"session_start",
|
|
32
|
+
"pre_tool_use",
|
|
33
|
+
"post_tool_use",
|
|
34
|
+
"post_tool_failure",
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_LEGACY_ALIASES = {
|
|
39
|
+
"on_session_start": "session_start",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _normalize_event(event: str) -> str:
|
|
44
|
+
"""Map legacy hook names onto the fixed vocabulary."""
|
|
45
|
+
if event in _LEGACY_ALIASES:
|
|
46
|
+
return _LEGACY_ALIASES[event]
|
|
47
|
+
if event.startswith("tool.before."):
|
|
48
|
+
return "pre_tool_use"
|
|
49
|
+
if event.startswith("tool.after."):
|
|
50
|
+
return "post_tool_use"
|
|
51
|
+
return event
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ExtensionManager:
|
|
55
|
+
"""Fixed-vocabulary, observe-only hook surface.
|
|
56
|
+
|
|
57
|
+
Implements the loop's ``HookSink``. There is no provider registry and no
|
|
58
|
+
plugin-directory loader: the provider is built by
|
|
59
|
+
``pico_sdk.providers.create_provider`` and tools are wired directly in
|
|
60
|
+
``AgentSession._register_core_tools``.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self) -> None:
|
|
64
|
+
self._hooks: dict[str, list[Hook]] = defaultdict(list)
|
|
65
|
+
|
|
66
|
+
# -- hooks --------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
def on(self, event: str, callback: Hook) -> Callable[[], None]:
|
|
69
|
+
"""Subscribe ``callback`` to a fixed hook. Returns an unsubscribe fn."""
|
|
70
|
+
name = _normalize_event(event)
|
|
71
|
+
if name not in ALLOWED_HOOKS:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"unknown hook: {event!r}; allowed: {sorted(ALLOWED_HOOKS)}"
|
|
74
|
+
)
|
|
75
|
+
self._hooks[name].append(callback)
|
|
76
|
+
|
|
77
|
+
def _off() -> None:
|
|
78
|
+
try:
|
|
79
|
+
self._hooks[name].remove(callback)
|
|
80
|
+
except ValueError:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
return _off
|
|
84
|
+
|
|
85
|
+
async def _fire(self, event: str, **kwargs: Any) -> None:
|
|
86
|
+
for callback in list(self._hooks.get(event, [])):
|
|
87
|
+
result = callback(**kwargs)
|
|
88
|
+
if inspect.isawaitable(result):
|
|
89
|
+
await result
|
|
90
|
+
|
|
91
|
+
# HookSink implementation ----------------------------------------------
|
|
92
|
+
|
|
93
|
+
async def on_session_start(self, session: Session) -> None:
|
|
94
|
+
await self._fire("session_start", session=session)
|
|
95
|
+
|
|
96
|
+
async def tool_before(self, name: str, arguments: dict) -> None:
|
|
97
|
+
await self._fire("pre_tool_use", name=name, arguments=arguments)
|
|
98
|
+
|
|
99
|
+
async def tool_after(
|
|
100
|
+
self, name: str, arguments: dict, result: ToolResultPayload
|
|
101
|
+
) -> None:
|
|
102
|
+
await self._fire(
|
|
103
|
+
"post_tool_use", name=name, arguments=arguments, result=result
|
|
104
|
+
)
|
|
105
|
+
if result.is_error:
|
|
106
|
+
await self._fire(
|
|
107
|
+
"post_tool_failure", name=name, arguments=arguments, result=result
|
|
108
|
+
)
|
pico_sdk/providers.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Provider construction from settings and per-provider stored config."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import warnings
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pico_ai.providers import create_provider as build_adapter
|
|
10
|
+
from pico_ai.providers import get_spec, provider_ids
|
|
11
|
+
|
|
12
|
+
from .config import Settings, load_settings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def effective_config(provider_id: str, settings: Settings) -> dict[str, str]:
|
|
16
|
+
"""Resolve the working config for ``provider_id``.
|
|
17
|
+
|
|
18
|
+
Precedence: field default < environment variable < stored settings value.
|
|
19
|
+
Clearing a stored value falls back to env/default.
|
|
20
|
+
"""
|
|
21
|
+
spec = get_spec(provider_id)
|
|
22
|
+
stored = settings.providers.get(provider_id) or {}
|
|
23
|
+
resolved: dict[str, str] = {}
|
|
24
|
+
for f in spec.fields:
|
|
25
|
+
value = stored.get(f.key, "")
|
|
26
|
+
if not value and f.env_var:
|
|
27
|
+
value = os.environ.get(f.env_var, "")
|
|
28
|
+
if not value:
|
|
29
|
+
value = f.default
|
|
30
|
+
resolved[f.key] = value
|
|
31
|
+
return resolved
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def missing_required(provider_id: str, settings: Settings) -> list[str]:
|
|
35
|
+
"""Return labels of required fields with no effective value."""
|
|
36
|
+
spec = get_spec(provider_id)
|
|
37
|
+
config = effective_config(provider_id, settings)
|
|
38
|
+
return [f.label for f in spec.required_fields if not config.get(f.key)]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def create_provider(settings: Settings | None = None) -> Any:
|
|
42
|
+
"""Build the active provider from settings (id + stored config)."""
|
|
43
|
+
settings = settings or load_settings()
|
|
44
|
+
provider_id = settings.provider or "openrouter"
|
|
45
|
+
if provider_id not in provider_ids():
|
|
46
|
+
warnings.warn(
|
|
47
|
+
f"unknown provider {provider_id!r}; falling back to 'openrouter'",
|
|
48
|
+
stacklevel=2,
|
|
49
|
+
)
|
|
50
|
+
provider_id = "openrouter"
|
|
51
|
+
return build_adapter(provider_id, effective_config(provider_id, settings))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def describe_providers(settings: Settings) -> list[dict]:
|
|
55
|
+
"""Return picker rows: id, display name, description, configured, active."""
|
|
56
|
+
rows = []
|
|
57
|
+
for pid in provider_ids():
|
|
58
|
+
spec = get_spec(pid)
|
|
59
|
+
rows.append(
|
|
60
|
+
{
|
|
61
|
+
"id": pid,
|
|
62
|
+
"display_name": spec.display_name,
|
|
63
|
+
"description": spec.description,
|
|
64
|
+
"configured": not missing_required(pid, settings),
|
|
65
|
+
"active": pid == (settings.provider or "openrouter"),
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
return rows
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
FREE_MODEL_ALIAS = "openrouter/free"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def resolve_free_model(provider: Any) -> str | None:
|
|
75
|
+
"""Resolve the ``openrouter/free`` alias to a concrete free model id.
|
|
76
|
+
|
|
77
|
+
Picks the first free model (alphabetically) that supports tool calling;
|
|
78
|
+
falls back to any free model. Returns None if nothing free is available
|
|
79
|
+
or the lookup fails — callers should keep the alias as-is in that case.
|
|
80
|
+
"""
|
|
81
|
+
try:
|
|
82
|
+
models = await provider.list_models()
|
|
83
|
+
except Exception:
|
|
84
|
+
return None
|
|
85
|
+
free = sorted(
|
|
86
|
+
(m for m in models if m.get("is_free", False)),
|
|
87
|
+
key=lambda m: str(m.get("name", "")).lower(),
|
|
88
|
+
)
|
|
89
|
+
with_tools = [m for m in free if m.get("supports_tools", True)]
|
|
90
|
+
if with_tools:
|
|
91
|
+
return with_tools[0]["id"]
|
|
92
|
+
return free[0]["id"] if free else None
|
pico_sdk/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
��
|
pico_sdk/session.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""The headless library API: ``AgentSession`` (hardcoded core, ADR-0003)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pico_core.fsm import AgentLoop, LoopEvent, RunResult
|
|
11
|
+
from pico_core.session import Session
|
|
12
|
+
from pico_core.subagents import DEFAULT_CHILD_TOOLS, MAX_DEPTH, ChildSpec, SpawnTool
|
|
13
|
+
from pico_core.todos import TodoList, TodoTool
|
|
14
|
+
from pico_core.tools import BashTool, EditTool, FetchTool, GrepTool, ReadTool, ToolOutcome, ToolRegistry, WebSearchTool, WriteTool
|
|
15
|
+
|
|
16
|
+
from .config import Settings, load_settings
|
|
17
|
+
from .extensions import ExtensionManager
|
|
18
|
+
from .skills import AGENTS_SKILLS_DIR, Skill, discover_skills, merge_skills, render_skills_prompt
|
|
19
|
+
|
|
20
|
+
DEFAULT_SYSTEM_PROMPT = (
|
|
21
|
+
"You are pico, a coding agent. You can read, write, and edit files, search "
|
|
22
|
+
"file contents with grep, fetch URLs, search the web, and run "
|
|
23
|
+
"bash commands. Work autonomously to complete the user's task, then report "
|
|
24
|
+
"what you did. For multi-step tasks, track progress with the todo tool "
|
|
25
|
+
"(add one todo per step, mark each in_progress while you work on it and "
|
|
26
|
+
"completed when done). The run only ends once every todo is completed — "
|
|
27
|
+
"do not stop early with unfinished todos."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AgentSession:
|
|
32
|
+
"""A headless agent session: hardcoded provider + tools + session tree.
|
|
33
|
+
|
|
34
|
+
The core is non-replaceable (ADR-0003). Curated extensions only: fixed
|
|
35
|
+
lifecycle hooks via :meth:`on` and model-invoked ``SKILL.md`` skills.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
#: Core tool names in registration order (permission-gating vocabulary).
|
|
39
|
+
CORE_TOOLS = (
|
|
40
|
+
"read",
|
|
41
|
+
"write",
|
|
42
|
+
"edit",
|
|
43
|
+
"grep",
|
|
44
|
+
"fetch",
|
|
45
|
+
"websearch",
|
|
46
|
+
"bash",
|
|
47
|
+
"todo",
|
|
48
|
+
"task",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
*,
|
|
54
|
+
provider: Any,
|
|
55
|
+
model: str | None = None,
|
|
56
|
+
settings: Settings | None = None,
|
|
57
|
+
working_dir: str | Path | None = None,
|
|
58
|
+
allow_bash: bool = True,
|
|
59
|
+
session_id: str | None = None,
|
|
60
|
+
session: Session | None = None,
|
|
61
|
+
system_prompt: str = DEFAULT_SYSTEM_PROMPT,
|
|
62
|
+
load_skills: bool = True,
|
|
63
|
+
) -> None:
|
|
64
|
+
self.settings = settings or load_settings()
|
|
65
|
+
self.model = model or self.settings.model
|
|
66
|
+
self.working_dir = Path(working_dir) if working_dir else Path.cwd()
|
|
67
|
+
self.allow_bash = allow_bash
|
|
68
|
+
self.extensions = ExtensionManager()
|
|
69
|
+
self.provider_id: str = (
|
|
70
|
+
getattr(provider, "provider_id", None)
|
|
71
|
+
or self.settings.provider
|
|
72
|
+
or "openrouter"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if self.settings.allowed_tools is not None:
|
|
76
|
+
unknown = set(self.settings.allowed_tools) - set(self.CORE_TOOLS)
|
|
77
|
+
if unknown:
|
|
78
|
+
warnings.warn(
|
|
79
|
+
f"unknown tools in allowed_tools (ignored): "
|
|
80
|
+
f"{sorted(unknown)}; valid: {list(self.CORE_TOOLS)}",
|
|
81
|
+
stacklevel=2,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
self.system_prompt = system_prompt
|
|
85
|
+
self.skills: list[Skill] = []
|
|
86
|
+
if load_skills:
|
|
87
|
+
# Global skills (shared ~/.agents/skills first, then the
|
|
88
|
+
# configured skills_dir) plus project-local override
|
|
89
|
+
# (<cwd>/.pico/skills wins on name conflicts); all loaded.
|
|
90
|
+
self.skills = merge_skills(
|
|
91
|
+
[
|
|
92
|
+
discover_skills([AGENTS_SKILLS_DIR]),
|
|
93
|
+
discover_skills([self.settings.skills_dir]),
|
|
94
|
+
discover_skills([self.working_dir / ".pico" / "skills"]),
|
|
95
|
+
]
|
|
96
|
+
)
|
|
97
|
+
skills_section = render_skills_prompt(self.skills)
|
|
98
|
+
if skills_section:
|
|
99
|
+
self.system_prompt = f"{system_prompt}\n\n{skills_section}"
|
|
100
|
+
|
|
101
|
+
self.session = session or (Session(id=session_id) if session_id else Session())
|
|
102
|
+
self.tools = ToolRegistry()
|
|
103
|
+
self.todos = TodoList()
|
|
104
|
+
self._register_core_tools(allow_bash)
|
|
105
|
+
|
|
106
|
+
self.loop = AgentLoop(
|
|
107
|
+
provider=provider,
|
|
108
|
+
session=self.session,
|
|
109
|
+
tools=self.tools,
|
|
110
|
+
system_prompt=self.system_prompt,
|
|
111
|
+
model=self.model,
|
|
112
|
+
context_window=self.settings.context_window,
|
|
113
|
+
reserve_tokens=self.settings.reserve_tokens,
|
|
114
|
+
hooks=self.extensions,
|
|
115
|
+
allowed_tools=self.settings.allowed_tools,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def provider(self) -> Any:
|
|
120
|
+
return self.loop.provider
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def provider_name(self) -> str:
|
|
124
|
+
"""Return a human-readable provider name."""
|
|
125
|
+
display = getattr(self.loop.provider, "display_name", None)
|
|
126
|
+
if display:
|
|
127
|
+
return str(display)
|
|
128
|
+
provider_class = type(self.loop.provider).__name__
|
|
129
|
+
# Convert CamelCase to readable name
|
|
130
|
+
if "OpenRouter" in provider_class:
|
|
131
|
+
return "OpenRouter"
|
|
132
|
+
return provider_class.replace("Provider", "")
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def context_window(self) -> int:
|
|
136
|
+
"""Return the context window size."""
|
|
137
|
+
return self.loop.context_window
|
|
138
|
+
|
|
139
|
+
def estimate_tokens(self) -> int:
|
|
140
|
+
"""Return the current estimated token count."""
|
|
141
|
+
return self.loop.estimate_tokens()
|
|
142
|
+
|
|
143
|
+
# -- core tools (hardcoded, non-replaceable) ------------------------------
|
|
144
|
+
|
|
145
|
+
def _register_core_tools(self, allow_bash: bool) -> None:
|
|
146
|
+
# Precedence: the loop-level ``allowed_tools`` gate (when set) rejects
|
|
147
|
+
# before any tool runs, so it wins over this per-tool flag. When
|
|
148
|
+
# ``allowed_tools`` is None, ``allow_bash`` decides for bash.
|
|
149
|
+
for tool in (
|
|
150
|
+
ReadTool(self.working_dir),
|
|
151
|
+
WriteTool(self.working_dir),
|
|
152
|
+
EditTool(self.working_dir),
|
|
153
|
+
GrepTool(self.working_dir),
|
|
154
|
+
FetchTool(),
|
|
155
|
+
WebSearchTool(),
|
|
156
|
+
BashTool(self.working_dir, enabled=allow_bash),
|
|
157
|
+
TodoTool(self.todos),
|
|
158
|
+
SpawnTool(
|
|
159
|
+
factory=self._make_child_loop,
|
|
160
|
+
depth=0,
|
|
161
|
+
session_dir=Path(self.settings.session_dir).expanduser(),
|
|
162
|
+
),
|
|
163
|
+
):
|
|
164
|
+
self.tools.register(tool)
|
|
165
|
+
|
|
166
|
+
def _make_child_loop(self, spec: ChildSpec, depth: int) -> AgentLoop:
|
|
167
|
+
"""Build an isolated child loop for a delegated task (ADR-0005).
|
|
168
|
+
|
|
169
|
+
The child gets a fresh session, fresh todos, and a restricted tool
|
|
170
|
+
allowlist (intersected with the parent's own gate so a parent can
|
|
171
|
+
never escalate). ``task`` is only registered when explicitly granted
|
|
172
|
+
and the nesting depth allows it.
|
|
173
|
+
"""
|
|
174
|
+
allowed = (
|
|
175
|
+
list(spec.allowed_tools)
|
|
176
|
+
if spec.allowed_tools is not None
|
|
177
|
+
else list(DEFAULT_CHILD_TOOLS)
|
|
178
|
+
)
|
|
179
|
+
if self.settings.allowed_tools is not None:
|
|
180
|
+
allowed = [t for t in allowed if t in self.settings.allowed_tools]
|
|
181
|
+
if depth > MAX_DEPTH:
|
|
182
|
+
allowed = [t for t in allowed if t != "task"]
|
|
183
|
+
todos = TodoList()
|
|
184
|
+
registry = ToolRegistry()
|
|
185
|
+
for tool in (
|
|
186
|
+
ReadTool(self.working_dir),
|
|
187
|
+
WriteTool(self.working_dir),
|
|
188
|
+
EditTool(self.working_dir),
|
|
189
|
+
GrepTool(self.working_dir),
|
|
190
|
+
FetchTool(),
|
|
191
|
+
WebSearchTool(),
|
|
192
|
+
BashTool(self.working_dir, enabled=self.allow_bash),
|
|
193
|
+
TodoTool(todos),
|
|
194
|
+
):
|
|
195
|
+
registry.register(tool)
|
|
196
|
+
if "task" in allowed:
|
|
197
|
+
registry.register(
|
|
198
|
+
SpawnTool(
|
|
199
|
+
factory=self._make_child_loop,
|
|
200
|
+
depth=depth,
|
|
201
|
+
session_dir=Path(self.settings.session_dir).expanduser(),
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
return AgentLoop(
|
|
205
|
+
provider=self.loop.provider,
|
|
206
|
+
session=Session(),
|
|
207
|
+
tools=registry,
|
|
208
|
+
system_prompt=self.system_prompt,
|
|
209
|
+
model=spec.model or self.model,
|
|
210
|
+
context_window=self.settings.context_window,
|
|
211
|
+
reserve_tokens=self.settings.reserve_tokens,
|
|
212
|
+
hooks=self.extensions,
|
|
213
|
+
allowed_tools=allowed,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
async def spawn(self, prompt: str, **kwargs: Any) -> ToolOutcome:
|
|
217
|
+
"""Delegate ``prompt`` to a sub-agent and return its summary outcome.
|
|
218
|
+
|
|
219
|
+
Accepts the same keyword arguments as the ``task`` tool
|
|
220
|
+
(``description``, ``allowed_tools``, ``model``, ``max_turns``,
|
|
221
|
+
``timeout_s``).
|
|
222
|
+
"""
|
|
223
|
+
tool = self.tools.get("task")
|
|
224
|
+
assert tool is not None, "task tool is hardcoded in the core"
|
|
225
|
+
return await tool.run({"prompt": prompt, **kwargs})
|
|
226
|
+
|
|
227
|
+
# -- running ------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
async def run(self, prompt: str) -> RunResult:
|
|
230
|
+
return await self.loop.run(prompt)
|
|
231
|
+
|
|
232
|
+
def stream(self, prompt: str) -> AsyncIterator[LoopEvent]:
|
|
233
|
+
return self.loop.stream(prompt)
|
|
234
|
+
|
|
235
|
+
def fork(self, node_id: str) -> None:
|
|
236
|
+
self.session.fork(node_id)
|
|
237
|
+
|
|
238
|
+
async def compact(self, instructions: str = "") -> None:
|
|
239
|
+
await self.loop.compact(instructions)
|
|
240
|
+
|
|
241
|
+
# -- curated extensions -------------------------------------------------
|
|
242
|
+
# Hooks are observe-only; see ``pico_sdk.extensions.ALLOWED_HOOKS``.
|
|
243
|
+
|
|
244
|
+
def on(self, event: str, callback: Any) -> Any:
|
|
245
|
+
return self.extensions.on(event, callback)
|
|
246
|
+
|
|
247
|
+
# -- providers ----------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
def set_provider(
|
|
250
|
+
self, provider_id: str, values: dict[str, str] | None = None
|
|
251
|
+
) -> Any:
|
|
252
|
+
"""Switch the active provider, storing ``values`` into settings.
|
|
253
|
+
|
|
254
|
+
Only the given ``values`` are stored (merged over existing stored
|
|
255
|
+
config); effective config still falls back to env vars and field
|
|
256
|
+
defaults. The model resets to the stored/default model for the new
|
|
257
|
+
provider — model ids are provider-specific — and is written back to
|
|
258
|
+
settings so the next launch reopens on this provider + model.
|
|
259
|
+
Returns the adapter.
|
|
260
|
+
"""
|
|
261
|
+
from pico_ai.providers import get_spec
|
|
262
|
+
|
|
263
|
+
from .providers import effective_config
|
|
264
|
+
|
|
265
|
+
spec = get_spec(provider_id) # KeyError on unknown id
|
|
266
|
+
stored = dict(self.settings.providers.get(provider_id, {}))
|
|
267
|
+
if values:
|
|
268
|
+
stored.update(values)
|
|
269
|
+
self.settings.providers[provider_id] = stored
|
|
270
|
+
self.settings.provider = provider_id
|
|
271
|
+
self.provider_id = provider_id
|
|
272
|
+
config = effective_config(provider_id, self.settings)
|
|
273
|
+
self.loop.provider = spec.create(config)
|
|
274
|
+
self.model = config.get("model") or spec.default_model
|
|
275
|
+
self.loop.model = self.model
|
|
276
|
+
self.settings.model = self.model
|
|
277
|
+
return self.loop.provider
|
|
278
|
+
|
|
279
|
+
# -- persistence --------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
def session_path(self) -> Path:
|
|
282
|
+
directory = Path(self.settings.session_dir).expanduser()
|
|
283
|
+
return directory / f"{self.session.id}.jsonl"
|
|
284
|
+
|
|
285
|
+
def save(self) -> Path:
|
|
286
|
+
path = self.session_path()
|
|
287
|
+
self.session.save(path)
|
|
288
|
+
return path
|
|
289
|
+
|
|
290
|
+
@classmethod
|
|
291
|
+
def load(
|
|
292
|
+
cls,
|
|
293
|
+
session_id: str,
|
|
294
|
+
*,
|
|
295
|
+
provider: Any,
|
|
296
|
+
model: str | None = None,
|
|
297
|
+
settings: Settings | None = None,
|
|
298
|
+
working_dir: str | Path | None = None,
|
|
299
|
+
allow_bash: bool = True,
|
|
300
|
+
system_prompt: str = DEFAULT_SYSTEM_PROMPT,
|
|
301
|
+
load_skills: bool = True,
|
|
302
|
+
) -> "AgentSession":
|
|
303
|
+
"""Resume an existing session persisted under ``session_dir``."""
|
|
304
|
+
settings = settings or load_settings()
|
|
305
|
+
directory = Path(settings.session_dir).expanduser()
|
|
306
|
+
session = Session.load(directory / f"{session_id}.jsonl")
|
|
307
|
+
return cls(
|
|
308
|
+
provider=provider,
|
|
309
|
+
model=model,
|
|
310
|
+
settings=settings,
|
|
311
|
+
working_dir=working_dir,
|
|
312
|
+
allow_bash=allow_bash,
|
|
313
|
+
session=session,
|
|
314
|
+
system_prompt=system_prompt,
|
|
315
|
+
load_skills=load_skills,
|
|
316
|
+
)
|
pico_sdk/skills.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Curated skills: model-invoked ``SKILL.md`` markdown files (Claude Code-style).
|
|
2
|
+
|
|
3
|
+
A skill is a directory containing a ``SKILL.md`` file with optional YAML
|
|
4
|
+
frontmatter (``name`` / ``description``) followed by markdown instructions::
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
name: commit-helper
|
|
8
|
+
description: Use when the user wants to commit code.
|
|
9
|
+
---
|
|
10
|
+
# Commit helper
|
|
11
|
+
Run ``git status`` first ...
|
|
12
|
+
|
|
13
|
+
Skills are discovered from ``~/.pico/skills/*/SKILL.md`` plus
|
|
14
|
+
``~/.agents/skills/*/SKILL.md`` (e.g. shared/opencode skills) plus an
|
|
15
|
+
optional project-local directory, and their descriptions are inlined
|
|
16
|
+
into the system prompt so the model knows when to apply them. Skills
|
|
17
|
+
carry knowledge only — they execute no code.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Skill:
|
|
28
|
+
name: str
|
|
29
|
+
description: str
|
|
30
|
+
content: str
|
|
31
|
+
path: Path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_skill_file(path: Path) -> Skill:
|
|
35
|
+
"""Parse a single ``SKILL.md`` file (frontmatter optional)."""
|
|
36
|
+
text = path.read_text(encoding="utf-8")
|
|
37
|
+
name = path.parent.name
|
|
38
|
+
description = ""
|
|
39
|
+
content = text
|
|
40
|
+
if text.startswith("---"):
|
|
41
|
+
end = text.find("---", 3)
|
|
42
|
+
if end != -1:
|
|
43
|
+
front = text[3:end].strip()
|
|
44
|
+
content = text[end + 3 :].strip()
|
|
45
|
+
for line in front.splitlines():
|
|
46
|
+
if ":" not in line:
|
|
47
|
+
continue
|
|
48
|
+
key, _, value = line.partition(":")
|
|
49
|
+
key = key.strip().lower()
|
|
50
|
+
value = value.strip().strip("\"'")
|
|
51
|
+
if key == "name" and value:
|
|
52
|
+
name = value
|
|
53
|
+
elif key == "description" and value:
|
|
54
|
+
description = value
|
|
55
|
+
return Skill(name=name, description=description, content=content, path=path)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def discover_skills(directories: list[Path | str]) -> list[Skill]:
|
|
59
|
+
"""Collect skills from ``<dir>/*/SKILL.md`` across ``directories``."""
|
|
60
|
+
skills: list[Skill] = []
|
|
61
|
+
for directory in directories:
|
|
62
|
+
root = Path(directory).expanduser()
|
|
63
|
+
if not root.is_dir():
|
|
64
|
+
continue
|
|
65
|
+
for candidate in sorted(root.glob("*/SKILL.md")):
|
|
66
|
+
try:
|
|
67
|
+
skills.append(parse_skill_file(candidate))
|
|
68
|
+
except OSError:
|
|
69
|
+
continue
|
|
70
|
+
return skills
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
#: Second global skills location (shared/opencode-style skills, e.g. Matt
|
|
74
|
+
#: Pocock's). Always searched alongside the configured ``skills_dir``;
|
|
75
|
+
#: ``skills_dir`` wins on name conflicts, project-local wins over both.
|
|
76
|
+
AGENTS_SKILLS_DIR = "~/.agents/skills"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def merge_skills(layers: list[list[Skill]]) -> list[Skill]:
|
|
80
|
+
"""Merge skill layers; later layers override earlier ones by name.
|
|
81
|
+
|
|
82
|
+
Returns all skills sorted by name (no cap — every discovered skill is
|
|
83
|
+
inlined into the system prompt).
|
|
84
|
+
"""
|
|
85
|
+
merged: dict[str, Skill] = {}
|
|
86
|
+
for layer in layers:
|
|
87
|
+
for skill in layer:
|
|
88
|
+
merged[skill.name] = skill
|
|
89
|
+
return sorted(merged.values(), key=lambda s: s.name)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def render_skills_prompt(skills: list[Skill]) -> str:
|
|
93
|
+
"""Render the system-prompt section describing available skills."""
|
|
94
|
+
if not skills:
|
|
95
|
+
return ""
|
|
96
|
+
lines = ["Available skills (apply when relevant):"]
|
|
97
|
+
for skill in skills:
|
|
98
|
+
trigger = skill.description or f"skill '{skill.name}'"
|
|
99
|
+
lines.append(f"- {skill.name}: {trigger}")
|
|
100
|
+
if skill.content:
|
|
101
|
+
lines.append(f" {skill.content[:500]}".rstrip())
|
|
102
|
+
return "\n".join(lines)
|