bareagent-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. bareagent/__init__.py +10 -0
  2. bareagent/concurrency/__init__.py +6 -0
  3. bareagent/concurrency/background.py +97 -0
  4. bareagent/concurrency/notification.py +61 -0
  5. bareagent/concurrency/scheduler.py +136 -0
  6. bareagent/config.toml +299 -0
  7. bareagent/core/__init__.py +1 -0
  8. bareagent/core/config_paths.py +49 -0
  9. bareagent/core/context.py +127 -0
  10. bareagent/core/fileutil.py +103 -0
  11. bareagent/core/goal.py +214 -0
  12. bareagent/core/handlers/__init__.py +1 -0
  13. bareagent/core/handlers/bash.py +79 -0
  14. bareagent/core/handlers/file_edit.py +47 -0
  15. bareagent/core/handlers/file_read.py +270 -0
  16. bareagent/core/handlers/file_write.py +34 -0
  17. bareagent/core/handlers/glob_search.py +30 -0
  18. bareagent/core/handlers/goal.py +60 -0
  19. bareagent/core/handlers/grep_search.py +52 -0
  20. bareagent/core/handlers/memory.py +71 -0
  21. bareagent/core/handlers/plan.py +106 -0
  22. bareagent/core/handlers/search_utils.py +77 -0
  23. bareagent/core/handlers/skill.py +87 -0
  24. bareagent/core/handlers/subagent_send.py +70 -0
  25. bareagent/core/handlers/web_fetch.py +126 -0
  26. bareagent/core/handlers/web_search.py +165 -0
  27. bareagent/core/handlers/workflow.py +190 -0
  28. bareagent/core/loop.py +535 -0
  29. bareagent/core/retry.py +131 -0
  30. bareagent/core/sandbox.py +27 -0
  31. bareagent/core/schema.py +21 -0
  32. bareagent/core/tools.py +779 -0
  33. bareagent/core/workflow.py +517 -0
  34. bareagent/core/workflow_registry.py +219 -0
  35. bareagent/debug/__init__.py +0 -0
  36. bareagent/debug/interaction_log.py +263 -0
  37. bareagent/debug/viewer.html +1750 -0
  38. bareagent/debug/web_viewer.py +157 -0
  39. bareagent/hooks/__init__.py +32 -0
  40. bareagent/hooks/config.py +118 -0
  41. bareagent/hooks/engine.py +197 -0
  42. bareagent/hooks/errors.py +14 -0
  43. bareagent/hooks/events.py +22 -0
  44. bareagent/lsp/__init__.py +63 -0
  45. bareagent/lsp/config.py +134 -0
  46. bareagent/lsp/coord.py +118 -0
  47. bareagent/lsp/diagnostics.py +240 -0
  48. bareagent/lsp/errors.py +24 -0
  49. bareagent/lsp/manager.py +866 -0
  50. bareagent/lsp/tools.py +629 -0
  51. bareagent/lsp/workspace_edit.py +305 -0
  52. bareagent/main.py +4205 -0
  53. bareagent/mcp/__init__.py +69 -0
  54. bareagent/mcp/_sse.py +69 -0
  55. bareagent/mcp/client.py +341 -0
  56. bareagent/mcp/config.py +169 -0
  57. bareagent/mcp/errors.py +32 -0
  58. bareagent/mcp/manager.py +318 -0
  59. bareagent/mcp/protocol.py +187 -0
  60. bareagent/mcp/registry.py +557 -0
  61. bareagent/mcp/transport/__init__.py +15 -0
  62. bareagent/mcp/transport/base.py +149 -0
  63. bareagent/mcp/transport/http_legacy.py +192 -0
  64. bareagent/mcp/transport/http_streamable.py +217 -0
  65. bareagent/mcp/transport/stdio.py +202 -0
  66. bareagent/memory/__init__.py +1 -0
  67. bareagent/memory/compact.py +203 -0
  68. bareagent/memory/conversation_io.py +226 -0
  69. bareagent/memory/embedding.py +194 -0
  70. bareagent/memory/persistent.py +515 -0
  71. bareagent/memory/token_counter.py +67 -0
  72. bareagent/memory/token_tracker.py +262 -0
  73. bareagent/memory/transcript.py +100 -0
  74. bareagent/permission/__init__.py +1 -0
  75. bareagent/permission/guard.py +329 -0
  76. bareagent/permission/rules.py +19 -0
  77. bareagent/planning/__init__.py +19 -0
  78. bareagent/planning/agent_types.py +169 -0
  79. bareagent/planning/skill_gen.py +141 -0
  80. bareagent/planning/skill_store.py +173 -0
  81. bareagent/planning/skills.py +146 -0
  82. bareagent/planning/subagent.py +355 -0
  83. bareagent/planning/subagent_registry.py +77 -0
  84. bareagent/planning/tasks.py +348 -0
  85. bareagent/planning/todo.py +153 -0
  86. bareagent/planning/worktree.py +122 -0
  87. bareagent/provider/__init__.py +1 -0
  88. bareagent/provider/anthropic.py +348 -0
  89. bareagent/provider/base.py +136 -0
  90. bareagent/provider/factory.py +130 -0
  91. bareagent/provider/openai.py +881 -0
  92. bareagent/provider/presets.py +72 -0
  93. bareagent/provider/setup.py +356 -0
  94. bareagent/skills/.gitkeep +1 -0
  95. bareagent/skills/code-review/SKILL.md +68 -0
  96. bareagent/skills/git/SKILL.md +68 -0
  97. bareagent/skills/test/SKILL.md +70 -0
  98. bareagent/team/__init__.py +17 -0
  99. bareagent/team/autonomous.py +193 -0
  100. bareagent/team/mailbox.py +239 -0
  101. bareagent/team/manager.py +155 -0
  102. bareagent/team/protocols.py +129 -0
  103. bareagent/tracing/__init__.py +12 -0
  104. bareagent/tracing/_api.py +92 -0
  105. bareagent/tracing/_proxy.py +60 -0
  106. bareagent/tracing/composite.py +115 -0
  107. bareagent/tracing/json_file.py +115 -0
  108. bareagent/tracing/langfuse.py +139 -0
  109. bareagent/tracing/otel.py +107 -0
  110. bareagent/tracing/setup.py +85 -0
  111. bareagent/ui/__init__.py +24 -0
  112. bareagent/ui/console.py +167 -0
  113. bareagent/ui/prompt.py +78 -0
  114. bareagent/ui/protocol.py +24 -0
  115. bareagent/ui/stream.py +66 -0
  116. bareagent/ui/theme.py +240 -0
  117. bareagent_cli-0.1.0.dist-info/METADATA +331 -0
  118. bareagent_cli-0.1.0.dist-info/RECORD +121 -0
  119. bareagent_cli-0.1.0.dist-info/WHEEL +4 -0
  120. bareagent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  121. bareagent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def parse_permission_rules(config: dict[str, Any]) -> tuple[list[str], list[str]]:
7
+ """Parse allow and deny permission rules from config data."""
8
+ permission_config = config.get("permission", {})
9
+ allow = _coerce_rule_list(permission_config.get("allow", []))
10
+ deny = _coerce_rule_list(permission_config.get("deny", []))
11
+ return allow, deny
12
+
13
+
14
+ def _coerce_rule_list(value: Any) -> list[str]:
15
+ if value is None:
16
+ return []
17
+ if isinstance(value, str):
18
+ return [value]
19
+ return [str(rule) for rule in value]
@@ -0,0 +1,19 @@
1
+ """Planning modules for BareAgent."""
2
+
3
+ from bareagent.planning.agent_types import BUILTIN_AGENT_TYPES, DEFAULT_AGENT_TYPE, AgentType
4
+ from bareagent.planning.skills import SkillLoader, SkillMeta
5
+ from bareagent.planning.subagent import run_subagent
6
+ from bareagent.planning.tasks import Task, TaskManager
7
+ from bareagent.planning.todo import TodoManager
8
+
9
+ __all__ = [
10
+ "AgentType",
11
+ "BUILTIN_AGENT_TYPES",
12
+ "DEFAULT_AGENT_TYPE",
13
+ "SkillLoader",
14
+ "SkillMeta",
15
+ "Task",
16
+ "TaskManager",
17
+ "TodoManager",
18
+ "run_subagent",
19
+ ]
@@ -0,0 +1,169 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from bareagent.permission.guard import PermissionMode
8
+
9
+ _log = logging.getLogger(__name__)
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class AgentType:
14
+ """Definition for a built-in child-agent profile."""
15
+
16
+ name: str
17
+ description: str
18
+ system_prompt: str = ""
19
+ tools: list[str] | None = None
20
+ disallowed_tools: list[str] | None = None
21
+ max_turns: int = 200
22
+ allow_nesting: bool = True
23
+ permission_mode: PermissionMode | None = None
24
+ # When False, ``filter_tools`` strips every ``mcp__*`` tool. Used by
25
+ # read-only agent types (explore / plan / code-review) to keep MCP side
26
+ # effects out of subagent contexts. Defaults to True for backwards
27
+ # compatibility with custom user-defined agent types.
28
+ mcp_tools_enabled: bool = True
29
+ # When False, ``filter_tools`` strips every ``lsp_*`` tool. The four
30
+ # Tier-1 LSP tools are read-only (outline / definition / references /
31
+ # diagnostics), so read-only agent types keep them enabled by default —
32
+ # this flag exists for tighter sandboxes that want a strict allow-list.
33
+ lsp_tools_enabled: bool = True
34
+ # The ``memory`` tool is a single tool with a ``command`` enum, so it
35
+ # cannot be downgraded to read-only by name-filtering like mcp/lsp. When
36
+ # False, the subagent layer wraps the handler to reject the write commands
37
+ # (create/str_replace/insert/delete/rename) while still allowing ``view``.
38
+ # Read-only agent types default to False so they can recall but not mutate
39
+ # shared memory.
40
+ memory_writable: bool = True
41
+
42
+
43
+ # Tools that only make sense in the main REPL loop and must never reach a
44
+ # sub-agent, regardless of agent type. ``exit_plan_mode`` flips the *parent's*
45
+ # permission mode through an interactive approval prompt -- a sub-agent has no
46
+ # human to ask and no business mutating the parent's mode. ``workflow`` fans out
47
+ # its own subagents; letting a sub-agent call it would allow unbounded nesting
48
+ # (out of scope for the MVP). ``subagent_send`` resumes a foreground subagent
49
+ # from the REPL-scoped registry; only the main loop spawns resumable contexts,
50
+ # so a sub-agent has nothing to continue. ``skill_create`` stays out of
51
+ # sub-agents by never joining the global tool set; ``exit_plan_mode`` /
52
+ # ``workflow`` / ``subagent_send`` do live in the main loop's set, so they are
53
+ # stripped here as a centralized, agent-type-independent guarantee
54
+ # (filter_handlers then drops the orphaned handler).
55
+ MAIN_LOOP_ONLY_TOOLS = frozenset({"exit_plan_mode", "workflow", "subagent_send"})
56
+
57
+
58
+ _READ_ONLY_DEFAULTS: dict[str, Any] = {
59
+ # ``semantic_rename`` is a write tool that does not carry the ``lsp_`` name
60
+ # prefix (so ``lsp_tools_enabled=True`` does not filter it). It must be
61
+ # denied explicitly here, otherwise read-only agents would keep a tool that
62
+ # mutates files across the workspace.
63
+ "disallowed_tools": [
64
+ "write_file",
65
+ "edit_file",
66
+ "bash",
67
+ "subagent",
68
+ "semantic_rename",
69
+ ],
70
+ "max_turns": 50,
71
+ "allow_nesting": False,
72
+ "permission_mode": PermissionMode.PLAN,
73
+ "mcp_tools_enabled": False,
74
+ "lsp_tools_enabled": True,
75
+ "memory_writable": False,
76
+ }
77
+
78
+ BUILTIN_AGENT_TYPES: dict[str, AgentType] = {
79
+ "general-purpose": AgentType(
80
+ name="general-purpose",
81
+ description="General child agent with the full inherited toolset.",
82
+ ),
83
+ "explore": AgentType(
84
+ name="explore",
85
+ description="Read-only agent for code search and repository understanding.",
86
+ system_prompt=(
87
+ "You are a read-only exploration agent. Search and inspect the repository, "
88
+ "but do not modify files or perform side effects."
89
+ ),
90
+ **_READ_ONLY_DEFAULTS,
91
+ ),
92
+ "plan": AgentType(
93
+ name="plan",
94
+ description="Planning agent for implementation design without repository mutation.",
95
+ system_prompt=(
96
+ "You are a planning agent. Analyze the codebase and produce an implementation "
97
+ "plan, but do not modify files or perform side effects."
98
+ ),
99
+ **_READ_ONLY_DEFAULTS,
100
+ ),
101
+ "code-review": AgentType(
102
+ name="code-review",
103
+ description="Read-only review agent for bugs, regressions, and code quality issues.",
104
+ system_prompt=(
105
+ "You are a code review agent. Inspect code for defects, regressions, security "
106
+ "issues, and maintainability risks. Do not modify files."
107
+ ),
108
+ **_READ_ONLY_DEFAULTS,
109
+ ),
110
+ }
111
+
112
+ DEFAULT_AGENT_TYPE = "general-purpose"
113
+
114
+
115
+ def resolve_agent_type(
116
+ name: str | None,
117
+ *,
118
+ default_name: str = DEFAULT_AGENT_TYPE,
119
+ ) -> AgentType:
120
+ """Resolve a child-agent type, falling back to the configured default."""
121
+
122
+ resolved_default = BUILTIN_AGENT_TYPES.get(
123
+ default_name, BUILTIN_AGENT_TYPES[DEFAULT_AGENT_TYPE]
124
+ )
125
+ if name is None:
126
+ return resolved_default
127
+ if name not in BUILTIN_AGENT_TYPES:
128
+ _log.warning("Unknown agent type %r, falling back to %r", name, resolved_default.name)
129
+ return resolved_default
130
+ return BUILTIN_AGENT_TYPES[name]
131
+
132
+
133
+ def filter_tools(
134
+ all_tools: list[dict[str, Any]],
135
+ agent_type: AgentType,
136
+ ) -> list[dict[str, Any]]:
137
+ """Apply whitelist, blacklist, and nesting controls to a tool schema list."""
138
+
139
+ allowed = set(agent_type.tools) if agent_type.tools is not None else None
140
+ denied = set(agent_type.disallowed_tools) if agent_type.disallowed_tools is not None else None
141
+ strip_nesting = not agent_type.allow_nesting
142
+
143
+ def _keep(tool: dict[str, Any]) -> bool:
144
+ name = str(tool.get("name"))
145
+ if name in MAIN_LOOP_ONLY_TOOLS:
146
+ return False
147
+ if allowed is not None and name not in allowed:
148
+ return False
149
+ if denied is not None and name in denied:
150
+ return False
151
+ if strip_nesting and name == "subagent":
152
+ return False
153
+ if not agent_type.mcp_tools_enabled and name.startswith("mcp__"):
154
+ return False
155
+ if not agent_type.lsp_tools_enabled and name.startswith("lsp_"):
156
+ return False
157
+ return True
158
+
159
+ return [tool for tool in all_tools if _keep(tool)]
160
+
161
+
162
+ def filter_handlers(
163
+ all_handlers: dict[str, Any],
164
+ filtered_tools: list[dict[str, Any]],
165
+ ) -> dict[str, Any]:
166
+ """Keep only handlers that still have a matching tool schema."""
167
+
168
+ allowed_names = {str(tool.get("name")) for tool in filtered_tools}
169
+ return {name: handler for name, handler in all_handlers.items() if name in allowed_names}
@@ -0,0 +1,141 @@
1
+ """Experiential skill generation: decide when to draft a reusable skill.
2
+
3
+ Pure logic with no LLM / loop / SDK dependencies so it is unit-testable in
4
+ isolation (mirrors the ``src/core/retry.py`` pure-module pattern). The agent
5
+ loop feeds per-turn tool-call counts into :class:`SkillGenerator`, which
6
+ accumulates them across user turns and reports when both thresholds are crossed.
7
+ The actual reflection LLM call + draft persistence live in the REPL / store
8
+ layers; this module only owns the *trigger decision* and the draft instruction
9
+ text.
10
+
11
+ Design (see task 06-01-experiential-skill-gen):
12
+ - A "task worth saving" spans multiple user turns AND involves real tool work.
13
+ So the trigger is a double AND: cumulative ``tool_calls >= min_tool_calls``
14
+ and cumulative ``user_replies >= min_user_replies``.
15
+ - Counters accumulate from session start / last draft and reset on each trigger,
16
+ so one multi-turn workflow is packed into a single skill rather than firing
17
+ every turn.
18
+ - ``enabled=False`` short-circuits everything: no counting, never drafts.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class SkillGenConfig:
28
+ """Thresholds + master switch for experiential skill generation.
29
+
30
+ Mirrors the user-facing ``[skills]`` config (``main.py`` builds this from
31
+ ``SkillsConfig`` the same way ``_build_retry_policy`` adapts ``RetryConfig``
32
+ to ``RetryPolicy``).
33
+ """
34
+
35
+ enabled: bool = True
36
+ min_tool_calls: int = 5
37
+ min_user_replies: int = 3
38
+
39
+
40
+ def should_draft_skill(
41
+ tool_calls: int,
42
+ user_replies: int,
43
+ config: SkillGenConfig,
44
+ ) -> bool:
45
+ """Return True when the accumulated activity warrants drafting a skill.
46
+
47
+ Double AND on the two thresholds; always False when disabled. Pure function
48
+ so the decision is directly unit-testable without constructing a generator.
49
+ """
50
+ if not config.enabled:
51
+ return False
52
+ return tool_calls >= config.min_tool_calls and user_replies >= config.min_user_replies
53
+
54
+
55
+ # Instruction injected as a user turn for the isolated reflection call. It lets
56
+ # the model DECLINE (respond "no skill") so a low-value, one-off task does not
57
+ # get forced into a skill — a second quality gate on top of the user-promote
58
+ # step. The model is told to call ``skill_create`` exactly once when it does
59
+ # decide the workflow is worth preserving.
60
+ DRAFT_INSTRUCTION = (
61
+ "You just finished a multi-step task spanning several turns. If — and only "
62
+ "if — there is a genuinely reusable, non-trivial workflow worth preserving "
63
+ "for next time, capture it as a skill by calling the `skill_create` tool "
64
+ "exactly once.\n\n"
65
+ "Distill the procedure so a future agent with no memory of this session "
66
+ "could follow it:\n"
67
+ '- name: short kebab-case identifier (e.g. "add-config-section").\n'
68
+ '- description: one line starting with "Use this when".\n'
69
+ "- body: markdown with sections like Steps / Pitfalls / Verification, "
70
+ "including dead-ends you hit and how you got past them.\n\n"
71
+ "If the work was too trivial or one-off to generalize, do NOT call the "
72
+ 'tool — reply with the single line "no skill" instead.'
73
+ )
74
+
75
+
76
+ def render_reflection_prompt(candidates: list[tuple[str, str]]) -> str:
77
+ """Build the reflection user message, optionally offering skills to refine.
78
+
79
+ Self-evolution (task 06-01-skill-self-evolution): when generated skills
80
+ already exist they are listed as refinement targets — the model is told to
81
+ reuse a skill's EXACT name (after reading it with ``load_skill``) so the new
82
+ draft supersedes it on promotion, rather than piling up a near-duplicate.
83
+
84
+ With no candidates this returns the bare :data:`DRAFT_INSTRUCTION`
85
+ (byte-identical to the pre-evolution create-only behavior).
86
+ """
87
+ if not candidates:
88
+ return DRAFT_INSTRUCTION
89
+ listing = "\n".join(f"- {name}: {desc}" for name, desc in candidates)
90
+ prefix = (
91
+ "Existing generated skills you may REFINE instead of duplicating. If "
92
+ "this session improves one of them, read it first with `load_skill`, "
93
+ "then call `skill_create` with that skill's EXACT name so your improved "
94
+ "version supersedes it. Otherwise use a fresh name. Never reuse a "
95
+ "built-in (repo) skill name.\n"
96
+ f"{listing}\n\n"
97
+ )
98
+ return prefix + DRAFT_INSTRUCTION
99
+
100
+
101
+ class SkillGenerator:
102
+ """Accumulates per-turn activity and decides when to draft a skill.
103
+
104
+ Lives for the whole REPL session and is passed into the *main* ``agent_loop``
105
+ only (sub-agents never receive it, so background / nested agents never
106
+ trigger generation — same isolation stance as ``hook_engine``). The loop
107
+ calls :meth:`note_turn` once per completed user turn; the REPL then checks
108
+ :meth:`should_draft` and runs the reflection.
109
+ """
110
+
111
+ __slots__ = ("config", "_tool_calls", "_user_replies")
112
+
113
+ def __init__(self, config: SkillGenConfig) -> None:
114
+ self.config = config
115
+ self._tool_calls = 0
116
+ self._user_replies = 0
117
+
118
+ @property
119
+ def enabled(self) -> bool:
120
+ return self.config.enabled
121
+
122
+ @property
123
+ def counters(self) -> tuple[int, int]:
124
+ """Current ``(tool_calls, user_replies)`` accumulators (for tests / logs)."""
125
+ return (self._tool_calls, self._user_replies)
126
+
127
+ def note_turn(self, turn_tool_calls: int) -> None:
128
+ """Record one completed user turn that ran ``turn_tool_calls`` tools."""
129
+ if not self.config.enabled:
130
+ return
131
+ self._tool_calls += max(0, int(turn_tool_calls))
132
+ self._user_replies += 1
133
+
134
+ def should_draft(self) -> bool:
135
+ """Whether the accumulated activity has crossed both thresholds."""
136
+ return should_draft_skill(self._tool_calls, self._user_replies, self.config)
137
+
138
+ def reset(self) -> None:
139
+ """Zero the accumulators (on trigger, or on session boundary)."""
140
+ self._tool_calls = 0
141
+ self._user_replies = 0
@@ -0,0 +1,173 @@
1
+ """Storage for experientially generated skills (drafts + promotion).
2
+
3
+ Generated skills live OUTSIDE the repo's checked-in ``skills/`` (which is the
4
+ hand-written canon) to avoid polluting version control. They go under the
5
+ user-global BareAgent home, project-isolated by workspace slug — mirroring the
6
+ persistent-memory directory convention (``derive_memory_slug``). Drafts land in
7
+ a ``.pending/`` subdirectory and only become loadable once the user promotes
8
+ them with ``/skill keep``.
9
+
10
+ Layout (``root`` = generated skills root for this project)::
11
+
12
+ <root>/<skill>/SKILL.md # live (loadable) generated skills
13
+ <root>/.pending/<skill>/SKILL.md # drafts awaiting /skill keep
14
+
15
+ Pure filesystem logic with no LLM/loop dependency — unit-testable in isolation.
16
+ The agent-facing ``skill_create`` tool and the ``/skill`` REPL command are thin
17
+ layers over this store.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ import shutil
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+
27
+ from bareagent.core.fileutil import atomic_write_text
28
+ from bareagent.memory.persistent import derive_memory_slug
29
+
30
+ PENDING_DIRNAME = ".pending"
31
+ _SKILL_FILE = "SKILL.md"
32
+
33
+
34
+ class SkillStoreError(ValueError):
35
+ """Raised for caller-facing storage failures (bad name, missing draft)."""
36
+
37
+
38
+ def derive_skill_slug(name: str) -> str:
39
+ """Slugify a skill name into a safe single path segment.
40
+
41
+ Lowercases, collapses any non ``[a-z0-9]`` run to a single hyphen, and
42
+ strips leading/trailing hyphens. This also neutralizes path traversal
43
+ (``../`` etc.) since separators become hyphens. Empty result is rejected by
44
+ the callers as an invalid name.
45
+ """
46
+ slug = re.sub(r"[^a-z0-9]+", "-", name.strip().lower()).strip("-")
47
+ return slug
48
+
49
+
50
+ def default_generated_skills_root(workspace: Path) -> Path:
51
+ """Per-project generated-skills directory under the user-global home."""
52
+ return Path.home() / ".bareagent" / "projects" / derive_memory_slug(workspace) / "skills"
53
+
54
+
55
+ def resolve_generated_skills_root(workspace: Path, configured_dir: str) -> Path:
56
+ """Resolve the generated-skills root from config.
57
+
58
+ Empty ``configured_dir`` falls back to :func:`default_generated_skills_root`.
59
+ A relative override is taken relative to the workspace; an absolute one is
60
+ used as-is. Mirrors ``resolve_memory_root``.
61
+ """
62
+ configured = (configured_dir or "").strip()
63
+ if not configured:
64
+ return default_generated_skills_root(workspace)
65
+ candidate = Path(configured).expanduser()
66
+ if not candidate.is_absolute():
67
+ candidate = workspace / candidate
68
+ return candidate
69
+
70
+
71
+ def _render_skill_md(name: str, description: str, body: str) -> str:
72
+ """Render SKILL.md in the format ``SkillLoader`` expects.
73
+
74
+ The description must be the first non-empty, non-``#`` line so
75
+ ``SkillLoader._extract_description`` picks it up.
76
+ """
77
+ desc = description.strip() or "No description provided."
78
+ sections = [f"# {name}", "", desc]
79
+ body = body.strip()
80
+ if body:
81
+ sections += ["", body]
82
+ return "\n".join(sections).rstrip() + "\n"
83
+
84
+
85
+ @dataclass(slots=True)
86
+ class SkillStore:
87
+ """Create / promote / discard / list generated skills under ``root``."""
88
+
89
+ root: Path
90
+
91
+ @property
92
+ def pending_root(self) -> Path:
93
+ return self.root / PENDING_DIRNAME
94
+
95
+ def create_draft(self, name: str, description: str, body: str) -> str:
96
+ """Write a draft SKILL.md under ``.pending/<slug>/`` and return a note."""
97
+ slug = derive_skill_slug(name)
98
+ if not slug:
99
+ raise SkillStoreError(f"invalid skill name: {name!r}")
100
+ target = self.pending_root / slug / _SKILL_FILE
101
+ atomic_write_text(target, _render_skill_md(slug, description, body))
102
+ return f"Drafted skill '{slug}' to pending (use /skill keep {slug} to keep it)."
103
+
104
+ def promote(self, name: str) -> str:
105
+ """Move a draft from ``.pending/`` to the live root (replacing any live)."""
106
+ slug = derive_skill_slug(name)
107
+ src = self.pending_root / slug
108
+ if not (src / _SKILL_FILE).exists():
109
+ raise SkillStoreError(f"no pending draft named {slug!r}")
110
+ dest = self.root / slug
111
+ if dest.exists():
112
+ shutil.rmtree(dest, ignore_errors=True)
113
+ dest.parent.mkdir(parents=True, exist_ok=True)
114
+ shutil.move(str(src), str(dest))
115
+ return f"Promoted skill '{slug}' — it is now loadable."
116
+
117
+ def discard(self, name: str) -> str:
118
+ """Delete a pending draft."""
119
+ slug = derive_skill_slug(name)
120
+ src = self.pending_root / slug
121
+ if not (src / _SKILL_FILE).exists():
122
+ raise SkillStoreError(f"no pending draft named {slug!r}")
123
+ shutil.rmtree(src, ignore_errors=True)
124
+ return f"Discarded pending skill '{slug}'."
125
+
126
+ def list_live(self) -> list[str]:
127
+ """Names of promoted (loadable) generated skills, sorted."""
128
+ return self._list_skill_dirs(self.root)
129
+
130
+ def list_pending(self) -> list[str]:
131
+ """Names of pending drafts, sorted."""
132
+ return self._list_skill_dirs(self.pending_root)
133
+
134
+ def prune_pending(self, max_pending: int) -> list[str]:
135
+ """Drop oldest pending drafts beyond ``max_pending``; return removed names.
136
+
137
+ Count-based soft cap (no time TTL): keep the ``max_pending`` newest
138
+ drafts by SKILL.md mtime, remove the rest. ``max_pending <= 0`` disables
139
+ pruning.
140
+ """
141
+ if max_pending <= 0:
142
+ return []
143
+ entries = self._pending_entries_by_mtime()
144
+ if len(entries) <= max_pending:
145
+ return []
146
+ removed: list[str] = []
147
+ for _mtime, slug, path in entries[: len(entries) - max_pending]:
148
+ shutil.rmtree(path, ignore_errors=True)
149
+ removed.append(slug)
150
+ return removed
151
+
152
+ def _pending_entries_by_mtime(self) -> list[tuple[float, str, Path]]:
153
+ """Pending ``(mtime, slug, dir)`` tuples, oldest first (ties by name)."""
154
+ entries: list[tuple[float, str, Path]] = []
155
+ try:
156
+ paths = sorted(self.pending_root.glob(f"*/{_SKILL_FILE}"))
157
+ except OSError:
158
+ return []
159
+ for skill_file in paths:
160
+ try:
161
+ mtime = skill_file.stat().st_mtime
162
+ except OSError:
163
+ mtime = 0.0
164
+ entries.append((mtime, skill_file.parent.name, skill_file.parent))
165
+ entries.sort(key=lambda item: (item[0], item[1]))
166
+ return entries
167
+
168
+ @staticmethod
169
+ def _list_skill_dirs(base: Path) -> list[str]:
170
+ try:
171
+ return sorted(p.parent.name for p in base.glob(f"*/{_SKILL_FILE}"))
172
+ except OSError:
173
+ return []
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from importlib.resources import files
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from bareagent.core.schema import tool_schema as _schema
10
+
11
+ LOAD_SKILL_TOOL_SCHEMAS: list[dict[str, Any]] = [
12
+ _schema(
13
+ "load_skill",
14
+ "Load the full content of a named SKILL.md file on demand.",
15
+ {
16
+ "skill_name": {
17
+ "type": "string",
18
+ "description": "The skill directory name to load.",
19
+ }
20
+ },
21
+ ["skill_name"],
22
+ )
23
+ ]
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class SkillMeta:
28
+ skill_name: str
29
+ description: str
30
+ path: Path
31
+
32
+
33
+ def resolve_skills_dir() -> Path:
34
+ """Locate the bundled canon skills directory.
35
+
36
+ Honors the ``BAREAGENT_SKILLS_DIR`` override first (if it exists); otherwise
37
+ resolves the ``skills/`` tree bundled inside the package
38
+ (``src/bareagent/skills``) via importlib.resources, which works for both
39
+ editable and wheel installs. BareAgent is never a zipapp, so the resource is
40
+ a real on-disk directory that supports ``.glob()``.
41
+ """
42
+ env_path = os.getenv("BAREAGENT_SKILLS_DIR")
43
+ if env_path:
44
+ candidate = Path(env_path).expanduser()
45
+ if candidate.exists():
46
+ return candidate.resolve()
47
+
48
+ return Path(str(files("bareagent").joinpath("skills"))).resolve()
49
+
50
+
51
+ class SkillLoader:
52
+ def __init__(self, skills_dir: Path, generated_root: Path | None = None) -> None:
53
+ # ``skills_dir`` is the repo's checked-in canon. ``generated_root`` is the
54
+ # optional experiential-skill layer (live promoted skills only — its
55
+ # ``.pending/`` drafts are NOT scanned because the glob is one level
56
+ # deep). Canon wins on name conflicts.
57
+ self.skills_dir = skills_dir
58
+ self.generated_root = generated_root
59
+ self._cache: dict[str, SkillMeta] = {}
60
+
61
+ def scan(self) -> list[SkillMeta]:
62
+ skills: list[SkillMeta] = []
63
+ cache: dict[str, SkillMeta] = {}
64
+
65
+ # Order matters: scan canon first so a generated skill never shadows a
66
+ # repo skill of the same name.
67
+ roots = [self.skills_dir]
68
+ if self.generated_root is not None:
69
+ roots.append(self.generated_root)
70
+
71
+ for root in roots:
72
+ try:
73
+ entries = sorted(root.glob("*/SKILL.md"))
74
+ except OSError:
75
+ continue
76
+ for skill_file in entries:
77
+ skill_name = skill_file.parent.name
78
+ if skill_name in cache:
79
+ continue
80
+ description = self._extract_description(skill_file)
81
+ meta = SkillMeta(
82
+ skill_name=skill_name,
83
+ description=description,
84
+ path=skill_file,
85
+ )
86
+ skills.append(meta)
87
+ cache[skill_name] = meta
88
+
89
+ self._cache = cache
90
+ return skills
91
+
92
+ def load(self, skill_name: str) -> str:
93
+ meta = self._lookup(skill_name)
94
+ return meta.path.read_text(encoding="utf-8").strip()
95
+
96
+ def canon_skill_names(self) -> set[str]:
97
+ """Names of the repo's checked-in canon skills (``skills_dir`` only).
98
+
99
+ Used by the self-evolution reflection to forbid generated skills from
100
+ colliding with a canon name — such a generated skill would be shadowed
101
+ (canon wins in :meth:`scan`) and never load, i.e. a dead skill.
102
+ """
103
+ try:
104
+ return {skill_file.parent.name for skill_file in self.skills_dir.glob("*/SKILL.md")}
105
+ except OSError:
106
+ return set()
107
+
108
+ def get_skill_list_prompt(self) -> str:
109
+ skills = self.scan()
110
+ if not skills:
111
+ return "No skills are available."
112
+
113
+ lines = [
114
+ "Available skills (load the full SKILL.md only when you need the details):"
115
+ ]
116
+ for skill in skills:
117
+ lines.append(f"- {skill.skill_name}: {skill.description}")
118
+ return "\n".join(lines)
119
+
120
+ def _lookup(self, skill_name: str) -> SkillMeta:
121
+ normalized = skill_name.strip()
122
+ if not normalized:
123
+ raise ValueError("skill_name must not be empty")
124
+
125
+ meta = self._cache.get(normalized)
126
+ if meta is None:
127
+ self.scan()
128
+ meta = self._cache.get(normalized)
129
+ if meta is None:
130
+ raise ValueError(f"Unknown skill: {skill_name}")
131
+ return meta
132
+
133
+ def _extract_description(self, skill_file: Path) -> str:
134
+ with skill_file.open(encoding="utf-8") as fh:
135
+ for raw_line in fh:
136
+ line = raw_line.strip()
137
+ if not line:
138
+ continue
139
+ if line.startswith("#"):
140
+ continue
141
+ return line
142
+ return "No description provided."
143
+
144
+
145
+ def make_skill_handlers(skill_loader: SkillLoader) -> dict[str, Any]:
146
+ return {"load_skill": skill_loader.load}