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.
Files changed (104) hide show
  1. pulse/__init__.py +5 -0
  2. pulse/__main__.py +4 -0
  3. pulse/agent.py +270 -0
  4. pulse/agent_manager.py +335 -0
  5. pulse/audit.py +70 -0
  6. pulse/auth.py +670 -0
  7. pulse/ci/github_client.py +66 -0
  8. pulse/ci/runner.py +28 -0
  9. pulse/cli.py +1075 -0
  10. pulse/cli_ui.py +977 -0
  11. pulse/config.py +167 -0
  12. pulse/context.py +960 -0
  13. pulse/conversations/__init__.py +8 -0
  14. pulse/conversations/manager.py +312 -0
  15. pulse/core/agent.py +188 -0
  16. pulse/core/planner.py +105 -0
  17. pulse/core/protocols.py +37 -0
  18. pulse/edits.py +65 -0
  19. pulse/episodic.py +93 -0
  20. pulse/eval/__init__.py +8 -0
  21. pulse/eval/trajectory_logger.py +91 -0
  22. pulse/eval/verifier.py +133 -0
  23. pulse/execution/__init__.py +5 -0
  24. pulse/execution/remote_task.py +76 -0
  25. pulse/git.py +162 -0
  26. pulse/interactive.py +234 -0
  27. pulse/mcp/__init__.py +4 -0
  28. pulse/mcp/client.py +215 -0
  29. pulse/mcp/local_tools.py +105 -0
  30. pulse/memory.py +212 -0
  31. pulse/mutations.py +283 -0
  32. pulse/orchestration/__init__.py +3 -0
  33. pulse/orchestration/orchestrator.py +162 -0
  34. pulse/patch.py +129 -0
  35. pulse/planner/__init__.py +3 -0
  36. pulse/planner/dag_planner.py +85 -0
  37. pulse/planner/execution_loop.py +159 -0
  38. pulse/production.py +235 -0
  39. pulse/provider.py +59 -0
  40. pulse/provider_keys.py +278 -0
  41. pulse/providers/__init__.py +26 -0
  42. pulse/providers/anthropic.py +65 -0
  43. pulse/providers/base.py +251 -0
  44. pulse/providers/deepseek.py +10 -0
  45. pulse/providers/failover.py +32 -0
  46. pulse/providers/gemini.py +66 -0
  47. pulse/providers/groq.py +10 -0
  48. pulse/providers/manager.py +262 -0
  49. pulse/providers/openai.py +40 -0
  50. pulse/providers/openrouter.py +20 -0
  51. pulse/py.typed +1 -0
  52. pulse/reasoning.py +570 -0
  53. pulse/refactor/__init__.py +3 -0
  54. pulse/refactor/impact_analyzer.py +44 -0
  55. pulse/repository.py +209 -0
  56. pulse/rpc.py +249 -0
  57. pulse/rule_synthesizer.py +54 -0
  58. pulse/runtime.py +217 -0
  59. pulse/safety/__init__.py +3 -0
  60. pulse/safety/safety_manager.py +97 -0
  61. pulse/sandbox/SECURITY.md +57 -0
  62. pulse/sandbox/__init__.py +57 -0
  63. pulse/sandbox/api.py +594 -0
  64. pulse/sandbox/audit.py +153 -0
  65. pulse/sandbox/backend/__init__.py +7 -0
  66. pulse/sandbox/backend/base.py +72 -0
  67. pulse/sandbox/backend/docker.py +498 -0
  68. pulse/sandbox/backend/host.py +140 -0
  69. pulse/sandbox/backend/remote.py +224 -0
  70. pulse/sandbox/errors.py +106 -0
  71. pulse/sandbox/filesystem.py +476 -0
  72. pulse/sandbox/git_safe.py +50 -0
  73. pulse/sandbox/lifecycle.py +88 -0
  74. pulse/sandbox/network.py +205 -0
  75. pulse/sandbox/path_validator.py +280 -0
  76. pulse/sandbox/policy.py +209 -0
  77. pulse/sandbox/process.py +331 -0
  78. pulse/sandbox/project.py +158 -0
  79. pulse/sandbox/python_safe.py +62 -0
  80. pulse/sandbox/remote/__init__.py +1 -0
  81. pulse/sandbox/remote/client.py +389 -0
  82. pulse/sandbox/remote/models.py +167 -0
  83. pulse/sandbox/remote/protocol.py +65 -0
  84. pulse/sandbox/remote/server.py +984 -0
  85. pulse/sandbox/remote/worker.py +175 -0
  86. pulse/sandbox/resources.py +236 -0
  87. pulse/sandbox/secrets.py +241 -0
  88. pulse/session_manager.py +365 -0
  89. pulse/software_engineer.py +189 -0
  90. pulse/storage.py +140 -0
  91. pulse/streaming.py +385 -0
  92. pulse/subprocesses.py +79 -0
  93. pulse/task_manager.py +2005 -0
  94. pulse/telemetry/__init__.py +25 -0
  95. pulse/telemetry/cost_tracker.py +95 -0
  96. pulse/telemetry/logger.py +110 -0
  97. pulse/tool_policy.py +197 -0
  98. pulse/tool_registry.py +163 -0
  99. pulse/tools.py +372 -0
  100. pulse/verification.py +118 -0
  101. pulse_coding_agent-0.1.0.dist-info/METADATA +211 -0
  102. pulse_coding_agent-0.1.0.dist-info/RECORD +104 -0
  103. pulse_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  104. 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