pulse-coding-agent 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.
- pulse/__init__.py +5 -0
- pulse/__main__.py +4 -0
- pulse/agent.py +270 -0
- pulse/agent_manager.py +335 -0
- pulse/audit.py +70 -0
- pulse/auth.py +670 -0
- pulse/ci/github_client.py +66 -0
- pulse/ci/runner.py +28 -0
- pulse/cli.py +1075 -0
- pulse/cli_ui.py +977 -0
- pulse/config.py +167 -0
- pulse/context.py +960 -0
- pulse/conversations/__init__.py +8 -0
- pulse/conversations/manager.py +312 -0
- pulse/core/agent.py +188 -0
- pulse/core/planner.py +105 -0
- pulse/core/protocols.py +37 -0
- pulse/edits.py +65 -0
- pulse/episodic.py +93 -0
- pulse/eval/__init__.py +8 -0
- pulse/eval/trajectory_logger.py +91 -0
- pulse/eval/verifier.py +133 -0
- pulse/execution/__init__.py +5 -0
- pulse/execution/remote_task.py +76 -0
- pulse/git.py +162 -0
- pulse/interactive.py +234 -0
- pulse/mcp/__init__.py +4 -0
- pulse/mcp/client.py +215 -0
- pulse/mcp/local_tools.py +105 -0
- pulse/memory.py +212 -0
- pulse/mutations.py +283 -0
- pulse/orchestration/__init__.py +3 -0
- pulse/orchestration/orchestrator.py +162 -0
- pulse/patch.py +129 -0
- pulse/planner/__init__.py +3 -0
- pulse/planner/dag_planner.py +85 -0
- pulse/planner/execution_loop.py +159 -0
- pulse/production.py +235 -0
- pulse/provider.py +59 -0
- pulse/provider_keys.py +278 -0
- pulse/providers/__init__.py +26 -0
- pulse/providers/anthropic.py +65 -0
- pulse/providers/base.py +251 -0
- pulse/providers/deepseek.py +10 -0
- pulse/providers/failover.py +32 -0
- pulse/providers/gemini.py +66 -0
- pulse/providers/groq.py +10 -0
- pulse/providers/manager.py +262 -0
- pulse/providers/openai.py +40 -0
- pulse/providers/openrouter.py +20 -0
- pulse/py.typed +1 -0
- pulse/reasoning.py +570 -0
- pulse/refactor/__init__.py +3 -0
- pulse/refactor/impact_analyzer.py +44 -0
- pulse/repository.py +209 -0
- pulse/rpc.py +249 -0
- pulse/rule_synthesizer.py +54 -0
- pulse/runtime.py +217 -0
- pulse/safety/__init__.py +3 -0
- pulse/safety/safety_manager.py +97 -0
- pulse/sandbox/SECURITY.md +57 -0
- pulse/sandbox/__init__.py +57 -0
- pulse/sandbox/api.py +594 -0
- pulse/sandbox/audit.py +153 -0
- pulse/sandbox/backend/__init__.py +7 -0
- pulse/sandbox/backend/base.py +72 -0
- pulse/sandbox/backend/docker.py +498 -0
- pulse/sandbox/backend/host.py +140 -0
- pulse/sandbox/backend/remote.py +224 -0
- pulse/sandbox/errors.py +106 -0
- pulse/sandbox/filesystem.py +476 -0
- pulse/sandbox/git_safe.py +50 -0
- pulse/sandbox/lifecycle.py +88 -0
- pulse/sandbox/network.py +205 -0
- pulse/sandbox/path_validator.py +280 -0
- pulse/sandbox/policy.py +209 -0
- pulse/sandbox/process.py +331 -0
- pulse/sandbox/project.py +158 -0
- pulse/sandbox/python_safe.py +62 -0
- pulse/sandbox/remote/__init__.py +1 -0
- pulse/sandbox/remote/client.py +389 -0
- pulse/sandbox/remote/models.py +167 -0
- pulse/sandbox/remote/protocol.py +65 -0
- pulse/sandbox/remote/server.py +984 -0
- pulse/sandbox/remote/worker.py +175 -0
- pulse/sandbox/resources.py +236 -0
- pulse/sandbox/secrets.py +241 -0
- pulse/session_manager.py +365 -0
- pulse/software_engineer.py +189 -0
- pulse/storage.py +140 -0
- pulse/streaming.py +385 -0
- pulse/subprocesses.py +79 -0
- pulse/task_manager.py +2005 -0
- pulse/telemetry/__init__.py +25 -0
- pulse/telemetry/cost_tracker.py +95 -0
- pulse/telemetry/logger.py +110 -0
- pulse/tool_policy.py +197 -0
- pulse/tool_registry.py +163 -0
- pulse/tools.py +372 -0
- pulse/verification.py +118 -0
- pulse_coding_agent-0.1.0.dist-info/METADATA +211 -0
- pulse_coding_agent-0.1.0.dist-info/RECORD +104 -0
- pulse_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- pulse_coding_agent-0.1.0.dist-info/entry_points.txt +4 -0
pulse/repository.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Async incremental repository indexing and lexical semantic retrieval."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import asyncio
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import asdict, dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class Symbol:
|
|
16
|
+
name: str
|
|
17
|
+
kind: str
|
|
18
|
+
line: int
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class IndexedFile:
|
|
23
|
+
path: str
|
|
24
|
+
fingerprint: str
|
|
25
|
+
imports: list[str] = field(default_factory=list)
|
|
26
|
+
symbols: list[Symbol] = field(default_factory=list)
|
|
27
|
+
terms: list[str] = field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class IndexReport:
|
|
32
|
+
files: int
|
|
33
|
+
folders: int
|
|
34
|
+
indexed: int
|
|
35
|
+
unchanged: int
|
|
36
|
+
removed: int
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class SearchResult:
|
|
41
|
+
path: str
|
|
42
|
+
score: float
|
|
43
|
+
symbols: tuple[Symbol, ...]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RepositoryIndex:
|
|
47
|
+
"""Persists metadata under `.agent` and reparses only changed files."""
|
|
48
|
+
|
|
49
|
+
_IGNORED = {".git", ".agent", ".agents", ".venv", "venv", "__pycache__", ".pytest_cache", "node_modules"} # noqa: RUF012
|
|
50
|
+
_IGNORED_NAMES = {".env", "credentials.json"} # noqa: RUF012
|
|
51
|
+
_IGNORED_SUFFIXES = { # noqa: RUF012
|
|
52
|
+
".crt", ".db", ".key", ".log", ".p12", ".pem", ".pfx", ".sqlite", ".sqlite3"
|
|
53
|
+
}
|
|
54
|
+
_MAX_INDEX_FILE_BYTES = 2_097_152
|
|
55
|
+
_MAX_SAVED_INDEX_BYTES = 16_777_216
|
|
56
|
+
|
|
57
|
+
def __init__(self, workspace: Path, index_path: Path | None = None) -> None:
|
|
58
|
+
self.workspace = workspace.resolve()
|
|
59
|
+
self.index_path = index_path or self.workspace / ".agent" / "repository-index.json"
|
|
60
|
+
self._files: dict[str, IndexedFile] = {}
|
|
61
|
+
self._folders: set[str] = set()
|
|
62
|
+
self._loaded = False
|
|
63
|
+
self._lock = asyncio.Lock()
|
|
64
|
+
|
|
65
|
+
async def index(self) -> IndexReport:
|
|
66
|
+
async with self._lock:
|
|
67
|
+
return await asyncio.to_thread(self._index_sync)
|
|
68
|
+
|
|
69
|
+
async def search(self, query: str, *, limit: int = 6) -> list[SearchResult]:
|
|
70
|
+
"""Return filename and lexical-semantic matches without exposing storage."""
|
|
71
|
+
await self.index()
|
|
72
|
+
query_terms = set(self._terms(query))
|
|
73
|
+
query_lower = query.lower()
|
|
74
|
+
results: list[SearchResult] = []
|
|
75
|
+
for item in self._files.values():
|
|
76
|
+
path_lower = item.path.lower()
|
|
77
|
+
score = float(4 if query_lower and query_lower in path_lower else 0)
|
|
78
|
+
score += 2 * len(query_terms.intersection(self._terms(Path(item.path).name)))
|
|
79
|
+
score += len(query_terms.intersection(item.terms))
|
|
80
|
+
score += 2 * len(query_terms.intersection({symbol.name.lower() for symbol in item.symbols}))
|
|
81
|
+
if score:
|
|
82
|
+
results.append(SearchResult(item.path, score, tuple(item.symbols)))
|
|
83
|
+
return sorted(results, key=lambda result: (-result.score, result.path))[:limit]
|
|
84
|
+
|
|
85
|
+
async def symbols(self, file_path: str) -> list[Symbol]:
|
|
86
|
+
await self.index()
|
|
87
|
+
return list(self._files.get(self._normalise_path(file_path), IndexedFile("", "")).symbols)
|
|
88
|
+
|
|
89
|
+
async def details(self, file_path: str) -> IndexedFile | None:
|
|
90
|
+
await self.index()
|
|
91
|
+
return self._files.get(self._normalise_path(file_path))
|
|
92
|
+
|
|
93
|
+
async def files(self) -> list[str]:
|
|
94
|
+
await self.index()
|
|
95
|
+
return sorted(self._files)
|
|
96
|
+
|
|
97
|
+
def _index_sync(self) -> IndexReport:
|
|
98
|
+
self._load()
|
|
99
|
+
current: dict[str, Path] = {}
|
|
100
|
+
folders: set[str] = set()
|
|
101
|
+
for path in self.workspace.rglob("*"):
|
|
102
|
+
relative = path.relative_to(self.workspace)
|
|
103
|
+
if any(part in self._IGNORED for part in relative.parts):
|
|
104
|
+
continue
|
|
105
|
+
if path.name.lower() in self._IGNORED_NAMES or path.suffix.lower() in self._IGNORED_SUFFIXES:
|
|
106
|
+
continue
|
|
107
|
+
if path.is_symlink():
|
|
108
|
+
continue
|
|
109
|
+
if path.is_dir():
|
|
110
|
+
folders.add(relative.as_posix())
|
|
111
|
+
elif path.is_file():
|
|
112
|
+
current[relative.as_posix()] = path
|
|
113
|
+
|
|
114
|
+
indexed = unchanged = 0
|
|
115
|
+
for relative, path in current.items():
|
|
116
|
+
fingerprint = self._fingerprint(path)
|
|
117
|
+
existing = self._files.get(relative)
|
|
118
|
+
if existing and existing.fingerprint == fingerprint:
|
|
119
|
+
unchanged += 1
|
|
120
|
+
continue
|
|
121
|
+
self._files[relative] = self._parse(relative, path, fingerprint)
|
|
122
|
+
indexed += 1
|
|
123
|
+
removed_paths = set(self._files) - set(current)
|
|
124
|
+
for relative in removed_paths:
|
|
125
|
+
del self._files[relative]
|
|
126
|
+
folders_changed = folders != self._folders
|
|
127
|
+
self._folders = folders
|
|
128
|
+
# Avoid rewriting the index on a no-op refresh. This matters because
|
|
129
|
+
# search refreshes the index before serving a result.
|
|
130
|
+
if indexed or removed_paths or folders_changed or not self.index_path.exists():
|
|
131
|
+
self._save()
|
|
132
|
+
return IndexReport(len(self._files), len(folders), indexed, unchanged, len(removed_paths))
|
|
133
|
+
|
|
134
|
+
def _load(self) -> None:
|
|
135
|
+
if self._loaded:
|
|
136
|
+
return
|
|
137
|
+
self._loaded = True
|
|
138
|
+
if not self.index_path.exists():
|
|
139
|
+
return
|
|
140
|
+
if self.index_path.is_symlink() or self.index_path.stat().st_size > self._MAX_SAVED_INDEX_BYTES:
|
|
141
|
+
self._files, self._folders = {}, set()
|
|
142
|
+
return
|
|
143
|
+
try:
|
|
144
|
+
raw = json.loads(self.index_path.read_text(encoding="utf-8"))
|
|
145
|
+
self._folders = set(raw.get("folders", []))
|
|
146
|
+
for value in raw.get("files", []):
|
|
147
|
+
value["symbols"] = [Symbol(**symbol) for symbol in value.get("symbols", [])]
|
|
148
|
+
item = IndexedFile(**value)
|
|
149
|
+
self._files[item.path] = item
|
|
150
|
+
except (json.JSONDecodeError, TypeError, KeyError):
|
|
151
|
+
self._files, self._folders = {}, set()
|
|
152
|
+
|
|
153
|
+
def _save(self) -> None:
|
|
154
|
+
self.index_path.parent.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
payload = {
|
|
156
|
+
"version": 1,
|
|
157
|
+
"folders": sorted(self._folders),
|
|
158
|
+
"files": [asdict(item) for item in sorted(self._files.values(), key=lambda item: item.path)],
|
|
159
|
+
}
|
|
160
|
+
temporary_path = self.index_path.with_suffix(".tmp")
|
|
161
|
+
temporary_path.write_text(json.dumps(payload, separators=(",", ":")), encoding="utf-8")
|
|
162
|
+
temporary_path.replace(self.index_path)
|
|
163
|
+
|
|
164
|
+
def _parse(self, relative: str, path: Path, fingerprint: str) -> IndexedFile:
|
|
165
|
+
if path.is_symlink() or path.stat().st_size > self._MAX_INDEX_FILE_BYTES:
|
|
166
|
+
return IndexedFile(relative, fingerprint, terms=sorted(set(self._terms(relative))))
|
|
167
|
+
try:
|
|
168
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
169
|
+
except OSError:
|
|
170
|
+
text = ""
|
|
171
|
+
imports: list[str] = []
|
|
172
|
+
symbols: list[Symbol] = []
|
|
173
|
+
if path.suffix == ".py":
|
|
174
|
+
try:
|
|
175
|
+
tree = ast.parse(text)
|
|
176
|
+
for node in ast.walk(tree):
|
|
177
|
+
if isinstance(node, ast.Import):
|
|
178
|
+
imports.extend(alias.name for alias in node.names)
|
|
179
|
+
elif isinstance(node, ast.ImportFrom):
|
|
180
|
+
imports.extend(f"{node.module or ''}.{alias.name}".strip(".") for alias in node.names)
|
|
181
|
+
elif isinstance(node, ast.ClassDef):
|
|
182
|
+
symbols.append(Symbol(node.name, "class", node.lineno))
|
|
183
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
184
|
+
symbols.append(Symbol(node.name, "function", node.lineno))
|
|
185
|
+
except SyntaxError:
|
|
186
|
+
pass
|
|
187
|
+
terms = sorted(set(self._terms(relative) + self._terms(text[:50_000]) + [symbol.name.lower() for symbol in symbols]))
|
|
188
|
+
return IndexedFile(relative, fingerprint, imports, symbols, terms)
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _fingerprint(path: Path) -> str:
|
|
192
|
+
stat = path.stat()
|
|
193
|
+
return hashlib.sha256(f"{stat.st_mtime_ns}:{stat.st_size}".encode()).hexdigest()
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def _terms(text: str) -> list[str]:
|
|
197
|
+
words = re.findall(r"[a-zA-Z_][a-zA-Z0-9_]*", text.lower())
|
|
198
|
+
# Preserve identifiers while also matching their snake_case components.
|
|
199
|
+
return words + [part for word in words for part in word.split("_") if part]
|
|
200
|
+
|
|
201
|
+
def _normalise_path(self, file_path: str) -> str:
|
|
202
|
+
"""Convert a CLI path to the portable path stored in the index."""
|
|
203
|
+
candidate = Path(file_path)
|
|
204
|
+
if candidate.is_absolute():
|
|
205
|
+
try:
|
|
206
|
+
candidate = candidate.resolve().relative_to(self.workspace)
|
|
207
|
+
except ValueError:
|
|
208
|
+
return ""
|
|
209
|
+
return candidate.as_posix()
|
pulse/rpc.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""JSON-RPC 2.0 adapter for Pulse, with an optional local WebSocket server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import hmac
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Protocol
|
|
12
|
+
|
|
13
|
+
from pulse import __version__
|
|
14
|
+
from pulse.config import load_agent_config
|
|
15
|
+
from pulse.runtime import build_runtime
|
|
16
|
+
from pulse.telemetry import get_correlation_id, set_correlation_id
|
|
17
|
+
from pulse.tool_registry import ToolInvocation
|
|
18
|
+
|
|
19
|
+
RPC_PROTOCOL_VERSION = "1.0"
|
|
20
|
+
RPC_COMPATIBLE_MAJOR = 1
|
|
21
|
+
RPC_MAX_PROMPT_CHARS = 100_000
|
|
22
|
+
RPC_MAX_CONTEXT_ITEMS = 64
|
|
23
|
+
RPC_MAX_CONTEXT_CHARS = 500_000
|
|
24
|
+
RPC_COMMAND_ALLOWLIST = frozenset({"doctor", "git", "index", "search", "status", "symbols"})
|
|
25
|
+
RPC_METHODS = (
|
|
26
|
+
"pulse.ask",
|
|
27
|
+
"pulse.askStream",
|
|
28
|
+
"pulse.codeAction",
|
|
29
|
+
"pulse.command",
|
|
30
|
+
"pulse.health",
|
|
31
|
+
"pulse.protocolVersion",
|
|
32
|
+
"pulse.stream",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PulseRpcEngine(Protocol):
|
|
37
|
+
async def respond_remote(self, prompt: str, context: list[str]) -> str: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class JsonRpcDispatcher:
|
|
41
|
+
"""Transport-neutral JSON-RPC dispatcher; easy to test without sockets."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, runtime: Any) -> None:
|
|
44
|
+
self.runtime = runtime
|
|
45
|
+
|
|
46
|
+
async def dispatch(self, message: dict[str, Any]) -> dict[str, Any] | None:
|
|
47
|
+
request_id = message.get("id")
|
|
48
|
+
if message.get("jsonrpc") != "2.0" or not isinstance(message.get("method"), str):
|
|
49
|
+
return self._error(request_id, -32600, "Invalid JSON-RPC request.")
|
|
50
|
+
method, params = message["method"], message.get("params", {})
|
|
51
|
+
if not isinstance(params, dict):
|
|
52
|
+
return self._error(request_id, -32602, "Parameters must be an object.")
|
|
53
|
+
requested_protocol = params.get("protocol_version")
|
|
54
|
+
if requested_protocol is not None and not self._is_compatible_protocol(
|
|
55
|
+
requested_protocol
|
|
56
|
+
):
|
|
57
|
+
return self._error(
|
|
58
|
+
request_id,
|
|
59
|
+
-32001,
|
|
60
|
+
"Unsupported Pulse RPC protocol version.",
|
|
61
|
+
)
|
|
62
|
+
correlation_id = set_correlation_id(params.get("correlation_id"))
|
|
63
|
+
try:
|
|
64
|
+
if method == "pulse.health":
|
|
65
|
+
result: Any = {
|
|
66
|
+
"status": "ok",
|
|
67
|
+
"service_version": __version__,
|
|
68
|
+
"protocol_version": RPC_PROTOCOL_VERSION,
|
|
69
|
+
}
|
|
70
|
+
elif method == "pulse.protocolVersion":
|
|
71
|
+
result = {
|
|
72
|
+
"protocol_version": RPC_PROTOCOL_VERSION,
|
|
73
|
+
"compatible_major": RPC_COMPATIBLE_MAJOR,
|
|
74
|
+
"methods": list(RPC_METHODS),
|
|
75
|
+
}
|
|
76
|
+
elif method in {"pulse.ask", "pulse.codeAction"}:
|
|
77
|
+
prompt = str(params.get("prompt", "")).strip()
|
|
78
|
+
if not prompt:
|
|
79
|
+
return self._error(request_id, -32602, "A prompt is required.")
|
|
80
|
+
if len(prompt) > RPC_MAX_PROMPT_CHARS:
|
|
81
|
+
return self._error(request_id, -32602, "The prompt exceeds the size limit.")
|
|
82
|
+
raw_context = params.get("context", [])
|
|
83
|
+
if not isinstance(raw_context, list) or any(
|
|
84
|
+
not isinstance(item, str) for item in raw_context
|
|
85
|
+
):
|
|
86
|
+
return self._error(request_id, -32602, "Context must be an array of strings.")
|
|
87
|
+
if len(raw_context) > RPC_MAX_CONTEXT_ITEMS or sum(
|
|
88
|
+
len(item) for item in raw_context
|
|
89
|
+
) > RPC_MAX_CONTEXT_CHARS:
|
|
90
|
+
return self._error(request_id, -32602, "Context exceeds the size limit.")
|
|
91
|
+
context = list(raw_context)
|
|
92
|
+
result = {"content": await self.runtime.agent.respond_remote(prompt, context)}
|
|
93
|
+
elif method in {"pulse.askStream", "pulse.stream"}:
|
|
94
|
+
prompt = str(params.get("prompt", "")).strip()
|
|
95
|
+
if not prompt:
|
|
96
|
+
return self._error(request_id, -32602, "A prompt is required.")
|
|
97
|
+
if len(prompt) > RPC_MAX_PROMPT_CHARS:
|
|
98
|
+
return self._error(request_id, -32602, "The prompt exceeds the size limit.")
|
|
99
|
+
from pulse.streaming import StreamingExecutionEngine
|
|
100
|
+
engine = StreamingExecutionEngine(
|
|
101
|
+
provider=getattr(self.runtime, "provider", None),
|
|
102
|
+
task_manager=getattr(self.runtime, "task_manager", None),
|
|
103
|
+
tool_registry=getattr(self.runtime, "tools", None),
|
|
104
|
+
)
|
|
105
|
+
events = []
|
|
106
|
+
async for event in engine.execute_stream(prompt):
|
|
107
|
+
events.append(event.to_dict())
|
|
108
|
+
result = {"events": events, "completed": True}
|
|
109
|
+
elif method == "pulse.command":
|
|
110
|
+
name = str(params.get("name", ""))
|
|
111
|
+
if name not in RPC_COMMAND_ALLOWLIST:
|
|
112
|
+
return self._error(
|
|
113
|
+
request_id,
|
|
114
|
+
-32602,
|
|
115
|
+
"This command is unavailable through RPC because interactive approval is required.",
|
|
116
|
+
)
|
|
117
|
+
arguments = params.get("arguments", {})
|
|
118
|
+
if not isinstance(arguments, dict):
|
|
119
|
+
return self._error(request_id, -32602, "Command arguments must be an object.")
|
|
120
|
+
tool_result = await self.runtime.tools.execute(
|
|
121
|
+
ToolInvocation(name=name, arguments=arguments)
|
|
122
|
+
)
|
|
123
|
+
if tool_result is None:
|
|
124
|
+
return self._error(request_id, -32601, "Unknown Pulse command.")
|
|
125
|
+
result = {"content": tool_result.content, "metadata": self._json_metadata(tool_result.metadata)}
|
|
126
|
+
else:
|
|
127
|
+
return self._error(request_id, -32601, f"Method not found: {method}")
|
|
128
|
+
except Exception: # Boundary adapter: return protocol errors, never tracebacks. # noqa: BLE001
|
|
129
|
+
return self._error(request_id, -32000, "Internal Pulse error.")
|
|
130
|
+
return None if request_id is None else {
|
|
131
|
+
"jsonrpc": "2.0",
|
|
132
|
+
"id": request_id,
|
|
133
|
+
"result": result,
|
|
134
|
+
"correlation_id": correlation_id,
|
|
135
|
+
"pulse_protocol_version": RPC_PROTOCOL_VERSION,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def _is_compatible_protocol(value: object) -> bool:
|
|
140
|
+
if not isinstance(value, str):
|
|
141
|
+
return False
|
|
142
|
+
major, separator, minor = value.partition(".")
|
|
143
|
+
return separator == "." and major.isdigit() and minor.isdigit() and int(major) == RPC_COMPATIBLE_MAJOR
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def _error(request_id: object, code: int, message: str) -> dict[str, Any]:
|
|
147
|
+
return {
|
|
148
|
+
"jsonrpc": "2.0",
|
|
149
|
+
"id": request_id,
|
|
150
|
+
"error": {"code": code, "message": message},
|
|
151
|
+
"correlation_id": get_correlation_id(),
|
|
152
|
+
"pulse_protocol_version": RPC_PROTOCOL_VERSION,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _json_metadata(metadata: dict[str, Any]) -> dict[str, str]:
|
|
157
|
+
allowed = {"correlation_id", "error_code", "permission_denied"}
|
|
158
|
+
return {key: str(value) for key, value in metadata.items() if key in allowed}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _valid_rpc_token(token: str | None) -> bool:
|
|
162
|
+
if token is None or len(token) < 32 or len(token) > 512:
|
|
163
|
+
return False
|
|
164
|
+
lowered = token.lower()
|
|
165
|
+
return lowered not in {"changeme", "replace_me", "placeholder"} and len(set(token)) >= 8
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _authorized_rpc_header(header: str | None, token: str) -> bool:
|
|
169
|
+
if not header or not header.startswith("Bearer "):
|
|
170
|
+
return False
|
|
171
|
+
candidate = header.removeprefix("Bearer ")
|
|
172
|
+
return bool(candidate) and hmac.compare_digest(candidate, token)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def serve(
|
|
176
|
+
workspace: str,
|
|
177
|
+
host: str = "127.0.0.1",
|
|
178
|
+
port: int = 8765,
|
|
179
|
+
*,
|
|
180
|
+
auth_token: str | None = None,
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Serve Pulse over loopback WebSocket transport using JSON-RPC messages."""
|
|
183
|
+
if host.strip().lower() not in {"127.0.0.1", "::1", "localhost"}:
|
|
184
|
+
raise ValueError("pulse-rpc is a local-only service and must bind to loopback.")
|
|
185
|
+
token = auth_token or os.environ.get("PULSE_RPC_TOKEN")
|
|
186
|
+
if not _valid_rpc_token(token):
|
|
187
|
+
raise ValueError(
|
|
188
|
+
"PULSE_RPC_TOKEN must be a non-placeholder secret of 32-512 characters."
|
|
189
|
+
)
|
|
190
|
+
assert token is not None
|
|
191
|
+
try:
|
|
192
|
+
from websockets.asyncio.server import serve as websocket_serve
|
|
193
|
+
except ImportError as error:
|
|
194
|
+
raise RuntimeError("WebSocket support requires the 'websockets' package. Run `uv sync`.") from error
|
|
195
|
+
|
|
196
|
+
workspace_path = Path(workspace).resolve()
|
|
197
|
+
runtime = build_runtime(workspace_path, load_agent_config(workspace_path))
|
|
198
|
+
dispatcher = JsonRpcDispatcher(runtime)
|
|
199
|
+
|
|
200
|
+
async def handler(websocket: Any) -> None:
|
|
201
|
+
async for raw in websocket:
|
|
202
|
+
try:
|
|
203
|
+
payload = json.loads(raw)
|
|
204
|
+
if not isinstance(payload, dict):
|
|
205
|
+
response = JsonRpcDispatcher._error(None, -32600, "Invalid JSON-RPC request.")
|
|
206
|
+
else:
|
|
207
|
+
response = await dispatcher.dispatch(payload)
|
|
208
|
+
except (json.JSONDecodeError, RecursionError, TypeError, ValueError):
|
|
209
|
+
response = JsonRpcDispatcher._error(None, -32700, "Parse error.")
|
|
210
|
+
if response is not None:
|
|
211
|
+
await websocket.send(json.dumps(response))
|
|
212
|
+
|
|
213
|
+
async def authenticate(_connection: Any, request: Any) -> Any | None:
|
|
214
|
+
if _authorized_rpc_header(request.headers.get("Authorization"), token):
|
|
215
|
+
return None
|
|
216
|
+
from websockets.datastructures import Headers
|
|
217
|
+
from websockets.http11 import Response
|
|
218
|
+
|
|
219
|
+
return Response(401, "Unauthorized", Headers(), b"Unauthorized")
|
|
220
|
+
|
|
221
|
+
async with websocket_serve(
|
|
222
|
+
handler,
|
|
223
|
+
host,
|
|
224
|
+
port,
|
|
225
|
+
max_size=1_048_576,
|
|
226
|
+
process_request=authenticate,
|
|
227
|
+
):
|
|
228
|
+
print(f"Pulse JSON-RPC server listening on ws://{host}:{port}")
|
|
229
|
+
await asyncio.get_running_loop().create_future()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def main() -> None:
|
|
233
|
+
parser = argparse.ArgumentParser(prog="pulse-rpc", description="Run Pulse's local JSON-RPC WebSocket server.")
|
|
234
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
235
|
+
parser.add_argument("--workspace", default=".")
|
|
236
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
237
|
+
parser.add_argument("--port", type=int, default=8765)
|
|
238
|
+
args = parser.parse_args()
|
|
239
|
+
try:
|
|
240
|
+
asyncio.run(serve(args.workspace, args.host, args.port))
|
|
241
|
+
except KeyboardInterrupt:
|
|
242
|
+
raise SystemExit(130) from None
|
|
243
|
+
except (OSError, RuntimeError, ValueError):
|
|
244
|
+
print("pulse-rpc failed to start. Check host, port, workspace, and token settings.")
|
|
245
|
+
raise SystemExit(1) from None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
if __name__ == "__main__":
|
|
249
|
+
main()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pulse.episodic import EpisodicMemory
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RuleSynthesizer:
|
|
10
|
+
"""Extracts repeated error resolution patterns from episodic memory and auto-generates guidelines into `.agent/rules`."""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
memory: EpisodicMemory,
|
|
15
|
+
rules_dir: Path | None = None,
|
|
16
|
+
frequency_threshold: int = 2,
|
|
17
|
+
) -> None:
|
|
18
|
+
self.memory = memory
|
|
19
|
+
self.rules_dir = rules_dir or Path(".agent/rules")
|
|
20
|
+
self.frequency_threshold = frequency_threshold
|
|
21
|
+
|
|
22
|
+
def synthesize_rules(self) -> list[Path]:
|
|
23
|
+
traces = self.memory.get_all_traces()
|
|
24
|
+
if not traces:
|
|
25
|
+
return []
|
|
26
|
+
|
|
27
|
+
pattern_counter: Counter[str] = Counter()
|
|
28
|
+
resolutions_by_error: dict[str, str] = {}
|
|
29
|
+
|
|
30
|
+
for trace in traces:
|
|
31
|
+
if not trace.error.strip() or not trace.resolution.strip():
|
|
32
|
+
continue
|
|
33
|
+
error_key = trace.error.strip().splitlines()[0][:60]
|
|
34
|
+
pattern_counter[error_key] += 1
|
|
35
|
+
resolutions_by_error[error_key] = trace.resolution.strip()
|
|
36
|
+
|
|
37
|
+
self.rules_dir.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
generated_rules: list[Path] = []
|
|
39
|
+
|
|
40
|
+
rule_idx = 1
|
|
41
|
+
for error_key, count in pattern_counter.items():
|
|
42
|
+
if count >= self.frequency_threshold:
|
|
43
|
+
rule_file = self.rules_dir / f"rule_{rule_idx:03d}.md"
|
|
44
|
+
content = (
|
|
45
|
+
f"# Auto-Synthesized Rule {rule_idx:03d}\n\n"
|
|
46
|
+
f"**Trigger Error Pattern:**\n```\n{error_key}\n```\n\n"
|
|
47
|
+
f"**Frequency Observed:** {count} times\n\n"
|
|
48
|
+
f"**Recommended Resolution Guideline:**\n{resolutions_by_error[error_key]}\n"
|
|
49
|
+
)
|
|
50
|
+
rule_file.write_text(content, encoding="utf-8")
|
|
51
|
+
generated_rules.append(rule_file)
|
|
52
|
+
rule_idx += 1
|
|
53
|
+
|
|
54
|
+
return generated_rules
|