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.
Files changed (99) hide show
  1. spark/__init__.py +14 -0
  2. spark/__main__.py +4 -0
  3. spark/agents.py +184 -0
  4. spark/asyncutil.py +55 -0
  5. spark/cli.py +820 -0
  6. spark/config.py +813 -0
  7. spark/core/__init__.py +3 -0
  8. spark/core/checkpoints.py +429 -0
  9. spark/core/compact.py +497 -0
  10. spark/core/context.py +335 -0
  11. spark/core/context_profile.py +139 -0
  12. spark/core/graph.py +381 -0
  13. spark/core/loop.py +1135 -0
  14. spark/core/tokens.py +40 -0
  15. spark/desktop.py +127 -0
  16. spark/edit/__init__.py +48 -0
  17. spark/edit/ast_check.py +109 -0
  18. spark/edit/core.py +248 -0
  19. spark/edit/tool.py +148 -0
  20. spark/errors.py +10 -0
  21. spark/evals/__init__.py +21 -0
  22. spark/evals/__main__.py +64 -0
  23. spark/evals/data/swe_lite_real_subset.json +74 -0
  24. spark/evals/download_real.py +59 -0
  25. spark/evals/harness.py +413 -0
  26. spark/evals/scenarios.py +246 -0
  27. spark/git_memory.py +293 -0
  28. spark/hooks.py +147 -0
  29. spark/mcp_expose.py +108 -0
  30. spark/mcp_registry.py +429 -0
  31. spark/memory/__init__.py +0 -0
  32. spark/memory/extractor.py +265 -0
  33. spark/memory/llm.py +108 -0
  34. spark/memory/service.py +443 -0
  35. spark/memory/store.py +851 -0
  36. spark/model_catalog.py +161 -0
  37. spark/models.py +74 -0
  38. spark/onboard.py +320 -0
  39. spark/otel.py +93 -0
  40. spark/platform_compat.py +23 -0
  41. spark/policy.py +261 -0
  42. spark/prompts/system.md +33 -0
  43. spark/providers/__init__.py +19 -0
  44. spark/providers/base.py +14 -0
  45. spark/providers/factory.py +43 -0
  46. spark/providers/mock.py +55 -0
  47. spark/providers/openai_compat.py +453 -0
  48. spark/providers/probe.py +188 -0
  49. spark/providers/routing.py +347 -0
  50. spark/pty/__init__.py +307 -0
  51. spark/repo_map.py +288 -0
  52. spark/sandbox.py +273 -0
  53. spark/sanitize.py +104 -0
  54. spark/skills.py +103 -0
  55. spark/slash.py +159 -0
  56. spark/store.py +771 -0
  57. spark/structured.py +168 -0
  58. spark/tools/__init__.py +3 -0
  59. spark/tools/_common.py +16 -0
  60. spark/tools/bg.py +310 -0
  61. spark/tools/diagnostics.py +149 -0
  62. spark/tools/fs.py +249 -0
  63. spark/tools/gitops.py +379 -0
  64. spark/tools/mcp_bridge.py +427 -0
  65. spark/tools/notebook.py +123 -0
  66. spark/tools/registry.py +537 -0
  67. spark/tools/search.py +269 -0
  68. spark/tools/shell.py +297 -0
  69. spark/tools/untrusted.py +44 -0
  70. spark/tools/web.py +246 -0
  71. spark/tui/__init__.py +3 -0
  72. spark/tui/app.py +584 -0
  73. spark/tui/welcome.py +126 -0
  74. spark/web/__init__.py +3 -0
  75. spark/web/_handlers.py +862 -0
  76. spark/web/_httpbase.py +26 -0
  77. spark/web/_routing.py +12 -0
  78. spark/web/api_spec.py +162 -0
  79. spark/web/dist/assets/SettingsModal-C3YLub9B.js +4 -0
  80. spark/web/dist/assets/highlight-CBGBLn5w.js +5 -0
  81. spark/web/dist/assets/index-DG4oXX01.js +42 -0
  82. spark/web/dist/assets/index-SPHoMi93.css +1 -0
  83. spark/web/dist/assets/markdown-CnxUTuzz.js +29 -0
  84. spark/web/dist/assets/react-DyzINgAZ.js +9 -0
  85. spark/web/dist/favicon.svg +17 -0
  86. spark/web/dist/index.html +29 -0
  87. spark/web/index.html +253 -0
  88. spark/web/pty_api.py +162 -0
  89. spark/web/routes.py +543 -0
  90. spark/web/server.py +134 -0
  91. spark/web/sse.py +263 -0
  92. spark/web/state.py +363 -0
  93. spark/web/utils.py +175 -0
  94. spark_agent-0.9.6.dist-info/METADATA +199 -0
  95. spark_agent-0.9.6.dist-info/RECORD +99 -0
  96. spark_agent-0.9.6.dist-info/WHEEL +5 -0
  97. spark_agent-0.9.6.dist-info/entry_points.txt +2 -0
  98. spark_agent-0.9.6.dist-info/licenses/LICENSE +21 -0
  99. 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
@@ -0,0 +1,4 @@
1
+ from spark.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
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