spark-agent 0.9.6__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.
- spark/__init__.py +14 -0
- spark/__main__.py +4 -0
- spark/agents.py +184 -0
- spark/asyncutil.py +55 -0
- spark/cli.py +820 -0
- spark/config.py +813 -0
- spark/core/__init__.py +3 -0
- spark/core/checkpoints.py +429 -0
- spark/core/compact.py +497 -0
- spark/core/context.py +335 -0
- spark/core/context_profile.py +139 -0
- spark/core/graph.py +381 -0
- spark/core/loop.py +1135 -0
- spark/core/tokens.py +40 -0
- spark/desktop.py +127 -0
- spark/edit/__init__.py +48 -0
- spark/edit/ast_check.py +109 -0
- spark/edit/core.py +248 -0
- spark/edit/tool.py +148 -0
- spark/errors.py +10 -0
- spark/evals/__init__.py +21 -0
- spark/evals/__main__.py +64 -0
- spark/evals/data/swe_lite_real_subset.json +74 -0
- spark/evals/download_real.py +59 -0
- spark/evals/harness.py +413 -0
- spark/evals/scenarios.py +246 -0
- spark/git_memory.py +293 -0
- spark/hooks.py +147 -0
- spark/mcp_expose.py +108 -0
- spark/mcp_registry.py +429 -0
- spark/memory/__init__.py +0 -0
- spark/memory/extractor.py +265 -0
- spark/memory/llm.py +108 -0
- spark/memory/service.py +443 -0
- spark/memory/store.py +851 -0
- spark/model_catalog.py +161 -0
- spark/models.py +74 -0
- spark/onboard.py +320 -0
- spark/otel.py +93 -0
- spark/platform_compat.py +23 -0
- spark/policy.py +261 -0
- spark/prompts/system.md +33 -0
- spark/providers/__init__.py +19 -0
- spark/providers/base.py +14 -0
- spark/providers/factory.py +43 -0
- spark/providers/mock.py +55 -0
- spark/providers/openai_compat.py +453 -0
- spark/providers/probe.py +188 -0
- spark/providers/routing.py +347 -0
- spark/pty/__init__.py +307 -0
- spark/repo_map.py +288 -0
- spark/sandbox.py +273 -0
- spark/sanitize.py +104 -0
- spark/skills.py +103 -0
- spark/slash.py +159 -0
- spark/store.py +771 -0
- spark/structured.py +168 -0
- spark/tools/__init__.py +3 -0
- spark/tools/_common.py +16 -0
- spark/tools/bg.py +310 -0
- spark/tools/diagnostics.py +149 -0
- spark/tools/fs.py +249 -0
- spark/tools/gitops.py +379 -0
- spark/tools/mcp_bridge.py +427 -0
- spark/tools/notebook.py +123 -0
- spark/tools/registry.py +537 -0
- spark/tools/search.py +269 -0
- spark/tools/shell.py +297 -0
- spark/tools/untrusted.py +44 -0
- spark/tools/web.py +246 -0
- spark/tui/__init__.py +3 -0
- spark/tui/app.py +584 -0
- spark/tui/welcome.py +126 -0
- spark/web/__init__.py +3 -0
- spark/web/_handlers.py +862 -0
- spark/web/_httpbase.py +26 -0
- spark/web/_routing.py +12 -0
- spark/web/api_spec.py +162 -0
- spark/web/dist/assets/SettingsModal-C3YLub9B.js +4 -0
- spark/web/dist/assets/highlight-CBGBLn5w.js +5 -0
- spark/web/dist/assets/index-DG4oXX01.js +42 -0
- spark/web/dist/assets/index-SPHoMi93.css +1 -0
- spark/web/dist/assets/markdown-CnxUTuzz.js +29 -0
- spark/web/dist/assets/react-DyzINgAZ.js +9 -0
- spark/web/dist/favicon.svg +17 -0
- spark/web/dist/index.html +29 -0
- spark/web/index.html +253 -0
- spark/web/pty_api.py +162 -0
- spark/web/routes.py +543 -0
- spark/web/server.py +134 -0
- spark/web/sse.py +263 -0
- spark/web/state.py +363 -0
- spark/web/utils.py +175 -0
- spark_agent-0.9.6.dist-info/METADATA +199 -0
- spark_agent-0.9.6.dist-info/RECORD +99 -0
- spark_agent-0.9.6.dist-info/WHEEL +5 -0
- spark_agent-0.9.6.dist-info/entry_points.txt +2 -0
- spark_agent-0.9.6.dist-info/licenses/LICENSE +21 -0
- spark_agent-0.9.6.dist-info/top_level.txt +1 -0
spark/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Spark — local-first AI coding agent."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
# Skip LiteLLM's model-cost-map fetch from raw.githubusercontent.com, which is
|
|
6
|
+
# unreachable from mainland China and burns ~9s of connection retries on every
|
|
7
|
+
# process start. The bundled local map ships in the wheel; users can opt back
|
|
8
|
+
# into remote refreshes by setting the variable to False explicitly.
|
|
9
|
+
# Must run before the first litellm import anywhere in the package (litellm
|
|
10
|
+
# reads this variable at import time), so it lives at the package root —
|
|
11
|
+
# every entry point (cli/web/desktop) imports spark first.
|
|
12
|
+
os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
|
13
|
+
|
|
14
|
+
__version__ = "0.9.6"
|
spark/__main__.py
ADDED
spark/agents.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Declarative sub-agents ("file as subagent", Claude-Code convention).
|
|
2
|
+
|
|
3
|
+
Scans ``<worktree>/.claude/agents/*.md`` (project level) and
|
|
4
|
+
``~/.claude/agents/*.md`` (user level). Each Markdown file is one sub-agent:
|
|
5
|
+
|
|
6
|
+
* frontmatter::
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
name: reader # required, the spawn handle
|
|
10
|
+
description: ... # one-line summary shown to the main agent
|
|
11
|
+
when_to_use: ... # optional hint for when to delegate
|
|
12
|
+
tools: read_file, grep # optional comma/space list; default = all tools
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
* the body (everything after the closing ``---``) is the sub-agent's own
|
|
16
|
+
system prompt — its role, rules and output format.
|
|
17
|
+
|
|
18
|
+
Parsing rules mirror :mod:`spark.skills` (no YAML dependency: only simple
|
|
19
|
+
``key: value`` frontmatter lines are read). Unlike skills, a declared agent
|
|
20
|
+
file must be semantically valid — missing frontmatter, missing ``name`` or an
|
|
21
|
+
empty body raise :class:`AgentFileError`; discovery is tolerant (one bad file
|
|
22
|
+
never kills the main loop) while spawning resolves strictly.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
# Reuse the exact frontmatter precedent from skills.py: single source for the
|
|
31
|
+
# ``---`` delimiter grammar and key/value splitting.
|
|
32
|
+
from spark.skills import _parse_frontmatter
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
PROJECT_AGENTS_DIR = Path(".claude") / "agents"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AgentFileError(ValueError):
|
|
40
|
+
"""A declared-agent file failed validation (frontmatter/name/body)."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class DeclaredAgent:
|
|
45
|
+
name: str
|
|
46
|
+
description: str
|
|
47
|
+
body: str
|
|
48
|
+
path: Path
|
|
49
|
+
when_to_use: str = ""
|
|
50
|
+
tools: list[str] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parse_tools(raw: str) -> list[str]:
|
|
54
|
+
return [t.strip() for t in raw.replace(",", " ").split() if t.strip()]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_agent_file(path: Path) -> DeclaredAgent:
|
|
58
|
+
"""Strictly parse one ``*.md`` file into a :class:`DeclaredAgent`.
|
|
59
|
+
|
|
60
|
+
Raises :class:`AgentFileError` when the file has no frontmatter block,
|
|
61
|
+
no usable ``name``, or an empty body.
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
65
|
+
except OSError as exc:
|
|
66
|
+
raise AgentFileError(f"{path}: unreadable: {exc}") from exc
|
|
67
|
+
parsed = _parse_frontmatter(text)
|
|
68
|
+
if parsed is None:
|
|
69
|
+
raise AgentFileError(f"{path}: missing YAML frontmatter (--- ... ---)")
|
|
70
|
+
meta, body = parsed
|
|
71
|
+
name = (meta.get("name") or "").strip() or path.stem.strip()
|
|
72
|
+
if not name:
|
|
73
|
+
raise AgentFileError(f"{path}: frontmatter is missing 'name'")
|
|
74
|
+
stripped_body = body.strip()
|
|
75
|
+
if not stripped_body:
|
|
76
|
+
raise AgentFileError(f"{path}: empty body — a sub-agent needs instructions")
|
|
77
|
+
return DeclaredAgent(
|
|
78
|
+
name=name,
|
|
79
|
+
description=(meta.get("description") or "").strip(),
|
|
80
|
+
when_to_use=(meta.get("when_to_use") or "").strip(),
|
|
81
|
+
tools=_parse_tools(meta.get("tools", "")),
|
|
82
|
+
body=stripped_body,
|
|
83
|
+
path=path,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def default_global_dir() -> Path:
|
|
88
|
+
"""User-level agents dir (~/.claude/agents), overridable in tests."""
|
|
89
|
+
return Path.home() / ".claude" / "agents"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def discover_declared_agents(
|
|
93
|
+
workdir: Path,
|
|
94
|
+
*,
|
|
95
|
+
global_dir: Path | None = None,
|
|
96
|
+
strict: bool = False,
|
|
97
|
+
) -> list[DeclaredAgent]:
|
|
98
|
+
"""Discover declared sub-agents under ``.claude/agents/``.
|
|
99
|
+
|
|
100
|
+
Project-level files win over user-level files on name collision. By
|
|
101
|
+
default invalid files are skipped with a warning (tolerant discovery so a
|
|
102
|
+
broken demo file never breaks the main loop); pass ``strict=True`` to let
|
|
103
|
+
:class:`AgentFileError` propagate.
|
|
104
|
+
"""
|
|
105
|
+
global_dir = global_dir or default_global_dir()
|
|
106
|
+
dirs: list[tuple[Path, bool]] = [
|
|
107
|
+
(workdir / PROJECT_AGENTS_DIR, True),
|
|
108
|
+
(global_dir, False),
|
|
109
|
+
]
|
|
110
|
+
agents: list[DeclaredAgent] = []
|
|
111
|
+
seen: set[str] = set()
|
|
112
|
+
for d, inside in dirs:
|
|
113
|
+
if not d.is_dir():
|
|
114
|
+
continue
|
|
115
|
+
for md in sorted(d.glob("*.md")):
|
|
116
|
+
if md.name.startswith("."):
|
|
117
|
+
continue
|
|
118
|
+
try:
|
|
119
|
+
agent = parse_agent_file(md)
|
|
120
|
+
except AgentFileError as exc:
|
|
121
|
+
if strict:
|
|
122
|
+
raise
|
|
123
|
+
logger.warning("skipping invalid agent file: %s", exc)
|
|
124
|
+
continue
|
|
125
|
+
if agent.name in seen:
|
|
126
|
+
continue # project level already won this name
|
|
127
|
+
seen.add(agent.name)
|
|
128
|
+
agents.append(agent)
|
|
129
|
+
return agents
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_declared_agent(
|
|
133
|
+
workdir: Path,
|
|
134
|
+
name: str,
|
|
135
|
+
*,
|
|
136
|
+
global_dir: Path | None = None,
|
|
137
|
+
) -> DeclaredAgent | None:
|
|
138
|
+
"""Resolve one declared agent by name; its file is parsed strictly.
|
|
139
|
+
|
|
140
|
+
Looks up ``<name>.md`` directly (project level first, then user level) so
|
|
141
|
+
a file that *exists* but fails validation raises :class:`AgentFileError` —
|
|
142
|
+
the task tool surfaces it as "invalid agent file ..." instead of silently
|
|
143
|
+
degrading to "unknown". Returns ``None`` only when no such file exists.
|
|
144
|
+
"""
|
|
145
|
+
name = name.strip()
|
|
146
|
+
if name.endswith(".md"):
|
|
147
|
+
name = name[:-3]
|
|
148
|
+
name = name.strip()
|
|
149
|
+
if not name:
|
|
150
|
+
return None
|
|
151
|
+
global_dir = global_dir or default_global_dir()
|
|
152
|
+
for path in (workdir / PROJECT_AGENTS_DIR / f"{name}.md", global_dir / f"{name}.md"):
|
|
153
|
+
if path.is_file():
|
|
154
|
+
return parse_agent_file(path)
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def inject_subagents(system_prompt: str, agents: list[DeclaredAgent]) -> str:
|
|
159
|
+
"""Append an ``<available_subagents>`` XML block (mirrors inject_skills)."""
|
|
160
|
+
if not agents:
|
|
161
|
+
return system_prompt
|
|
162
|
+
newline = "" if system_prompt.endswith("\n") else "\n"
|
|
163
|
+
block = [
|
|
164
|
+
f"{newline}<available_subagents>",
|
|
165
|
+
" To delegate a self-contained task to a specialist, call the `task` "
|
|
166
|
+
'tool with {"agent": "<name>", "prompt": "..."}. Use "tasks" with '
|
|
167
|
+
'items {"agent": "<name>", "prompt": "..."} to run several in parallel.',
|
|
168
|
+
]
|
|
169
|
+
for a in agents:
|
|
170
|
+
attr = f' when_to_use="{a.when_to_use}"' if a.when_to_use else ""
|
|
171
|
+
block.append(
|
|
172
|
+
f' <subagent name="{a.name}" description="{a.description}"{attr}/>'
|
|
173
|
+
)
|
|
174
|
+
block.append("</available_subagents>\n")
|
|
175
|
+
return system_prompt + "\n".join(block)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def subagent_system_prompt(agent: DeclaredAgent) -> str:
|
|
179
|
+
"""Wrap a declared agent's body so it becomes its own system-prompt slot."""
|
|
180
|
+
return (
|
|
181
|
+
f"<declared_subagent name=\"{agent.name}\" path=\"{agent.path}\">\n"
|
|
182
|
+
f"{agent.body}\n"
|
|
183
|
+
f"</declared_subagent>"
|
|
184
|
+
)
|
spark/asyncutil.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""在同步上下文安全运行协程(含"事件循环已在运行"的降级路径)。
|
|
2
|
+
|
|
3
|
+
统一入口:web/state、memory/service、core/compact 过去各自内联同一套
|
|
4
|
+
workaround(thread + 新 loop),这里收敛为唯一实现。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextvars
|
|
11
|
+
import threading
|
|
12
|
+
from typing import Any, Coroutine
|
|
13
|
+
|
|
14
|
+
DEFAULT_TIMEOUT = 120.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run_coroutine_sync(
|
|
18
|
+
coro: Coroutine[Any, Any, Any], timeout: float | None = DEFAULT_TIMEOUT
|
|
19
|
+
) -> Any:
|
|
20
|
+
"""运行 *coro* 至完成并返回结果。
|
|
21
|
+
|
|
22
|
+
当前线程无运行中的事件循环 → 直接 ``asyncio.run``;已有运行中的循环
|
|
23
|
+
(从 async 代码经同步垫片回调)→ 另起线程 + 独立 loop,保留
|
|
24
|
+
``contextvars`` 上下文,避免 ``asyncio.run() cannot be called from a
|
|
25
|
+
running event loop``。超时抛 :class:`TimeoutError`,协程内异常原样重抛。
|
|
26
|
+
"""
|
|
27
|
+
try:
|
|
28
|
+
asyncio.get_running_loop()
|
|
29
|
+
except RuntimeError:
|
|
30
|
+
return asyncio.run(coro)
|
|
31
|
+
|
|
32
|
+
result: list[Any] = []
|
|
33
|
+
error: list[BaseException] = []
|
|
34
|
+
|
|
35
|
+
def _runner() -> None:
|
|
36
|
+
loop = asyncio.new_event_loop()
|
|
37
|
+
try:
|
|
38
|
+
ctx = contextvars.copy_context()
|
|
39
|
+
result.append(ctx.run(loop.run_until_complete, coro))
|
|
40
|
+
except BaseException as exc: # noqa: BLE001 — 在调用线程原样重抛
|
|
41
|
+
error.append(exc)
|
|
42
|
+
finally:
|
|
43
|
+
try:
|
|
44
|
+
loop.run_until_complete(loop.shutdown_asyncgens())
|
|
45
|
+
finally:
|
|
46
|
+
loop.close()
|
|
47
|
+
|
|
48
|
+
thread = threading.Thread(target=_runner, daemon=True, name="spark-coro-sync")
|
|
49
|
+
thread.start()
|
|
50
|
+
thread.join(timeout=timeout)
|
|
51
|
+
if thread.is_alive():
|
|
52
|
+
raise TimeoutError(f"coroutine did not finish within {timeout}s")
|
|
53
|
+
if error:
|
|
54
|
+
raise error[0]
|
|
55
|
+
return result[0] if result else None
|