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/memory.py ADDED
@@ -0,0 +1,212 @@
1
+ """SQLite-backed long-term project memory, independent of Pulse interfaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import re
8
+ import sqlite3
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any, Protocol
12
+
13
+ from pulse.sandbox.secrets import SecretScrubber
14
+ from pulse.storage import migrate_database
15
+
16
+ MEMORY_SCHEMA_VERSION = 1
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class MemoryEntry:
21
+ id: int
22
+ category: str
23
+ content: str
24
+ tags: tuple[str, ...]
25
+ created_at: str
26
+
27
+
28
+ class RequestWithMessage(Protocol):
29
+ message: str
30
+
31
+
32
+ class LongTermMemory:
33
+ """Small async façade over a workspace-local SQLite memory database."""
34
+
35
+ def __init__(
36
+ self,
37
+ workspace: Path,
38
+ database_path: Path | None = None,
39
+ secrets: list[str] | None = None,
40
+ ) -> None:
41
+ self.workspace = workspace.resolve()
42
+ self.database_path = database_path or self.workspace / ".agent" / "pulse-memory.sqlite3"
43
+ self._lock = asyncio.Lock()
44
+ self._scrubber = SecretScrubber(secrets)
45
+ migrate_database(self.database_path, MEMORY_SCHEMA_VERSION, self._migrate_schema)
46
+
47
+ @staticmethod
48
+ def _migrate_schema(connection: sqlite3.Connection, _current: int) -> None:
49
+ connection.execute(
50
+ "CREATE TABLE IF NOT EXISTS memories (id INTEGER PRIMARY KEY, category TEXT NOT NULL, content TEXT NOT NULL, tags TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)"
51
+ )
52
+ connection.execute(
53
+ "CREATE TABLE IF NOT EXISTS preferences (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)"
54
+ )
55
+
56
+ async def store_project_context(self, content: str, *, tags: tuple[str, ...] = ()) -> MemoryEntry:
57
+ return await self._store("project", content, tags)
58
+
59
+ async def remember_agent_information(self, content: str, *, tags: tuple[str, ...] = ()) -> MemoryEntry:
60
+ return await self._store("agent", content, tags)
61
+
62
+ async def remember_task(self, request: str, response: str) -> MemoryEntry:
63
+ tags = tuple(sorted(set(self._terms(request))))[:12]
64
+ return await self._store("task", f"Request: {request}\nResponse: {response[:4_000]}", tags)
65
+
66
+ async def record_workflow(
67
+ self,
68
+ request: str,
69
+ tool_sequence: tuple[str, ...] | list[str],
70
+ *,
71
+ success: bool,
72
+ error: str | None = None,
73
+ summary: str | None = None,
74
+ ) -> MemoryEntry:
75
+ payload = {
76
+ "request": request.strip(),
77
+ "tool_sequence": [str(tool) for tool in tool_sequence],
78
+ "success": bool(success),
79
+ "error": str(error or ""),
80
+ "summary": str(summary or ""),
81
+ }
82
+ terms = set(self._terms(request))
83
+ terms.update(self._terms(" ".join(payload["tool_sequence"])))
84
+ tags = tuple(sorted(terms))[:12]
85
+ return await self._store("workflow", json.dumps(payload, sort_keys=True), tags)
86
+
87
+ async def workflow_recommendations(self, query: str, *, limit: int = 4) -> list[dict[str, Any]]:
88
+ async with self._lock:
89
+ return await asyncio.to_thread(self._workflow_recommendations, query, limit)
90
+
91
+ async def set_preference(self, key: str, value: str) -> None:
92
+ if not key.strip() or not value.strip():
93
+ raise ValueError("Preference key and value are required.")
94
+ async with self._lock:
95
+ await asyncio.to_thread(self._set_preference, key.strip(), value.strip())
96
+
97
+ async def preferences(self) -> dict[str, str]:
98
+ async with self._lock:
99
+ return await asyncio.to_thread(self._preferences)
100
+
101
+ async def retrieve(self, query: str, *, limit: int = 6) -> list[MemoryEntry]:
102
+ async with self._lock:
103
+ return await asyncio.to_thread(self._retrieve, query, limit)
104
+
105
+ async def context_for(self, query: str, *, limit: int = 4) -> list[str]:
106
+ """Format durable preferences and relevant memories as approved context."""
107
+ preferences, entries = await asyncio.gather(self.preferences(), self.retrieve(query, limit=limit))
108
+ context = [f"User preference — {key}: {value}" for key, value in preferences.items()]
109
+ context.extend(f"Remembered {entry.category}: {entry.content}" for entry in entries)
110
+ return context
111
+
112
+ async def _store(self, category: str, content: str, tags: tuple[str, ...]) -> MemoryEntry:
113
+ clean_content = self._scrubber.redact(content).strip()
114
+ clean_tags = tuple(self._scrubber.redact(tag) for tag in tags)
115
+ if not clean_content:
116
+ raise ValueError("Memory content is required.")
117
+ async with self._lock:
118
+ return await asyncio.to_thread(
119
+ self._store_sync, category, clean_content, clean_tags
120
+ )
121
+
122
+ def _connection(self) -> sqlite3.Connection:
123
+ self.database_path.parent.mkdir(parents=True, exist_ok=True)
124
+ connection = sqlite3.connect(self.database_path)
125
+ connection.execute("PRAGMA journal_mode=WAL")
126
+ return connection
127
+
128
+ def _store_sync(self, category: str, content: str, tags: tuple[str, ...]) -> MemoryEntry:
129
+ with self._connection() as connection:
130
+ cursor = connection.execute(
131
+ "INSERT INTO memories(category, content, tags) VALUES (?, ?, ?)", (category, content, json.dumps(tags)),
132
+ )
133
+ row = connection.execute("SELECT id, category, content, tags, created_at FROM memories WHERE id = ?", (cursor.lastrowid,)).fetchone()
134
+ return self._entry(row)
135
+
136
+ def _set_preference(self, key: str, value: str) -> None:
137
+ key = self._scrubber.redact(key)
138
+ value = self._scrubber.redact(value)
139
+ with self._connection() as connection:
140
+ connection.execute(
141
+ "INSERT INTO preferences(key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
142
+ (key, value),
143
+ )
144
+
145
+ def _preferences(self) -> dict[str, str]:
146
+ with self._connection() as connection:
147
+ rows = connection.execute("SELECT key, value FROM preferences ORDER BY key").fetchall()
148
+ return {str(key): str(value) for key, value in rows}
149
+
150
+ def _retrieve(self, query: str, limit: int) -> list[MemoryEntry]:
151
+ terms = set(self._terms(query))
152
+ with self._connection() as connection:
153
+ rows = connection.execute("SELECT id, category, content, tags, created_at FROM memories ORDER BY id DESC").fetchall()
154
+ entries = [self._entry(row) for row in rows]
155
+ if not terms:
156
+ return entries[:limit]
157
+ ranked = [
158
+ (len(terms.intersection(self._terms(entry.content + " " + " ".join(entry.tags)))), entry)
159
+ for entry in entries
160
+ ]
161
+ return [entry for score, entry in sorted(ranked, key=lambda item: (-item[0], -item[1].id)) if score][:limit]
162
+
163
+ def _workflow_recommendations(self, query: str, limit: int) -> list[dict[str, Any]]:
164
+ terms = set(self._terms(query))
165
+ with self._connection() as connection:
166
+ rows = connection.execute(
167
+ "SELECT id, category, content, tags, created_at FROM memories WHERE category = 'workflow' ORDER BY id DESC"
168
+ ).fetchall()
169
+ workflow_entries = []
170
+ for row in rows:
171
+ entry = self._entry(row)
172
+ try:
173
+ payload = json.loads(entry.content)
174
+ except json.JSONDecodeError:
175
+ continue
176
+ if not isinstance(payload, dict):
177
+ continue
178
+ workflow_entries.append({
179
+ "id": entry.id,
180
+ "request": str(payload.get("request", "")),
181
+ "tool_sequence": list(payload.get("tool_sequence", [])),
182
+ "success": bool(payload.get("success")),
183
+ "error": str(payload.get("error", "")),
184
+ "summary": str(payload.get("summary", "")),
185
+ "created_at": entry.created_at,
186
+ })
187
+ if not terms:
188
+ return workflow_entries[:limit]
189
+ ranked = []
190
+ for workflow in workflow_entries:
191
+ score = len(terms.intersection(self._terms(workflow["request"] + " " + " ".join(workflow["tool_sequence"]))))
192
+ ranked.append((score, workflow))
193
+ sorted_workflows = [workflow for score, workflow in sorted(ranked, key=lambda item: (-item[0], -item[1]["id"])) if score]
194
+ return sorted_workflows[:limit]
195
+
196
+ @staticmethod
197
+ def _entry(row: tuple[Any, ...]) -> MemoryEntry:
198
+ return MemoryEntry(int(row[0]), str(row[1]), str(row[2]), tuple(json.loads(row[3])), str(row[4]))
199
+
200
+ @staticmethod
201
+ def _terms(value: str) -> list[str]:
202
+ return re.findall(r"[a-zA-Z_][a-zA-Z0-9_]*", value.lower())
203
+
204
+
205
+ class MemoryContextSource:
206
+ """Adapter for the core Agent context protocol without importing it."""
207
+
208
+ def __init__(self, memory: LongTermMemory) -> None:
209
+ self.memory = memory
210
+
211
+ async def context_for(self, request: RequestWithMessage) -> list[str]:
212
+ return await self.memory.context_for(request.message)
pulse/mutations.py ADDED
@@ -0,0 +1,283 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import uuid
8
+ from collections.abc import Iterator
9
+ from dataclasses import asdict, dataclass, field
10
+ from datetime import UTC, datetime
11
+ from difflib import unified_diff
12
+ from pathlib import Path
13
+ from typing import Self
14
+
15
+ from pulse.subprocesses import isolated_process_kwargs, isolated_subprocess_environment
16
+ from pulse.telemetry import get_correlation_id
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class FileSnapshot:
21
+ path: str
22
+ content: bytes
23
+ sha256: str
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class MutationEvent:
28
+ """An immutable, rollback-ready record of one workspace file mutation."""
29
+
30
+ schema_version: int = field(default=1, init=False)
31
+ transaction_id: str
32
+ correlation_id: str
33
+ timestamp: str
34
+ action: str
35
+ file_path: str
36
+ before_content: str | None
37
+ after_content: str | None
38
+ before_sha256: str | None
39
+ after_sha256: str | None
40
+ unified_diff: str
41
+ command: str | None
42
+ generated_by_command: bool
43
+ git_before: dict[str, object]
44
+ git_after: dict[str, object]
45
+
46
+
47
+ class MutationTransaction:
48
+ def __init__(self, tracker: MutationTracker, *, command: str | None = None) -> None:
49
+ self.tracker = tracker
50
+ self.id = str(uuid.uuid4())
51
+ self.command = command
52
+ self._before = tracker._snapshot_workspace()
53
+ self._git_before = tracker.git_state()
54
+ self._closed = False
55
+
56
+ def finalize(self) -> list[MutationEvent]:
57
+ if self._closed:
58
+ return []
59
+ self._closed = True
60
+ after = self.tracker._snapshot_workspace()
61
+ git_after = self.tracker.git_state()
62
+ events = self.tracker._events_for_change(
63
+ self.id, self._before, after, self._git_before, git_after, self.command
64
+ )
65
+ self.tracker._append(events)
66
+ return events
67
+
68
+ def __enter__(self) -> Self:
69
+ return self
70
+
71
+ def __exit__(self, exc_type, exc, traceback) -> bool:
72
+ self.finalize()
73
+ return False
74
+
75
+
76
+ class MutationTracker:
77
+ """Capture file-system and Git changes made by Pulse operations.
78
+
79
+ Content snapshots are intentional: they provide the data required for a future
80
+ rollback implementation. Consumers should protect `.agent/logs` because it may
81
+ contain the previous or replacement contents of edited files.
82
+ """
83
+
84
+ _IGNORED_PARTS = { # noqa: RUF012
85
+ ".git",
86
+ ".agent",
87
+ ".agents",
88
+ ".pulse",
89
+ ".venv",
90
+ "venv",
91
+ "__pycache__",
92
+ ".pytest_cache",
93
+ }
94
+ _IGNORED_NAMES = {".env", "credentials.json"} # noqa: RUF012
95
+ _IGNORED_SUFFIXES = { # noqa: RUF012
96
+ ".crt", ".db", ".key", ".log", ".p12", ".pem", ".pfx", ".sqlite", ".sqlite3"
97
+ }
98
+
99
+ def __init__(self, workspace: Path, log_path: Path | None = None) -> None:
100
+ self.workspace = workspace.resolve()
101
+ self.log_path = log_path or self.workspace / ".agent" / "logs" / "mutations.jsonl"
102
+
103
+ def transaction(self, *, command: str | None = None) -> MutationTransaction:
104
+ return MutationTransaction(self, command=command)
105
+
106
+ def run_command(self, command: str, *, timeout: float | None = None) -> subprocess.CompletedProcess[str]:
107
+ """Run a workspace command and record every file it creates or changes."""
108
+ with self.transaction(command=command):
109
+ return subprocess.run(
110
+ command,
111
+ cwd=self.workspace,
112
+ shell=True,
113
+ text=True,
114
+ capture_output=True,
115
+ timeout=timeout,
116
+ check=False,
117
+ env=isolated_subprocess_environment(),
118
+ **isolated_process_kwargs(),
119
+ )
120
+
121
+ def git_state(self) -> dict[str, object]:
122
+ return {
123
+ "available": self._git("rev-parse", "--is-inside-work-tree") == "true",
124
+ "commit": self._git("rev-parse", "HEAD"),
125
+ "status": self._git("status", "--porcelain=v1"),
126
+ "changed_files": self._git_lines("status", "--porcelain=v1"),
127
+ "diff": self._git("diff", "--binary"),
128
+ }
129
+
130
+ def history(self) -> Iterator[dict[str, object]]:
131
+ if not self.log_path.exists():
132
+ return iter(())
133
+
134
+ def entries() -> Iterator[dict[str, object]]:
135
+ for line in self.log_path.read_text(encoding="utf-8").splitlines():
136
+ try:
137
+ value = json.loads(line)
138
+ except json.JSONDecodeError:
139
+ continue
140
+ if isinstance(value, dict):
141
+ yield value
142
+
143
+ return entries()
144
+
145
+ def latest_transaction(self) -> list[dict[str, object]]:
146
+ events = list(self.history())
147
+ if not events:
148
+ return []
149
+ last_id = str(events[-1].get("transaction_id", ""))
150
+ return [event for event in events if event.get("transaction_id") == last_id]
151
+
152
+ def last_approved_edit(self) -> list[dict[str, object]]:
153
+ """Return the latest approved-edit transaction, excluding rollback logs."""
154
+ events = [event for event in self.history() if event.get("command") == "pulse approved edit"]
155
+ if not events:
156
+ return []
157
+ transaction_id = events[-1].get("transaction_id")
158
+ return [event for event in events if event.get("transaction_id") == transaction_id]
159
+
160
+ def _snapshot_workspace(self) -> dict[str, FileSnapshot]:
161
+ snapshots: dict[str, FileSnapshot] = {}
162
+ for path in self.workspace.rglob("*"):
163
+ relative = path.relative_to(self.workspace)
164
+ if (
165
+ any(part in self._IGNORED_PARTS for part in relative.parts)
166
+ or path.name.lower() in self._IGNORED_NAMES
167
+ or path.suffix.lower() in self._IGNORED_SUFFIXES
168
+ or path.is_symlink()
169
+ or not path.is_file()
170
+ ):
171
+ continue
172
+ content = path.read_bytes()
173
+ display_path = relative.as_posix()
174
+ snapshots[display_path] = FileSnapshot(display_path, content, self._hash(content))
175
+ return snapshots
176
+
177
+ def _events_for_change(
178
+ self,
179
+ transaction_id: str,
180
+ before: dict[str, FileSnapshot],
181
+ after: dict[str, FileSnapshot],
182
+ git_before: dict[str, object],
183
+ git_after: dict[str, object],
184
+ command: str | None,
185
+ ) -> list[MutationEvent]:
186
+ events: list[MutationEvent] = []
187
+ paths = sorted(set(before) | set(after))
188
+ for path in paths:
189
+ previous, current = before.get(path), after.get(path)
190
+ if previous and current and previous.sha256 == current.sha256:
191
+ continue
192
+ action = "create" if previous is None else "delete" if current is None else "modify"
193
+ events.append(self._event(transaction_id, action, path, previous, current, command, git_before, git_after))
194
+
195
+ # Detect a rename when a deleted file's exact content appears at a new path.
196
+ deleted = [event for event in events if event.action == "delete"]
197
+ created = [event for event in events if event.action == "create"]
198
+ replacements: dict[str, MutationEvent] = {}
199
+ removed: set[str] = set()
200
+ for old in deleted:
201
+ for new in created:
202
+ if old.before_sha256 == new.after_sha256:
203
+ replacements[old.file_path] = MutationEvent(
204
+ transaction_id=transaction_id,
205
+ correlation_id=old.correlation_id,
206
+ timestamp=old.timestamp,
207
+ action="rename",
208
+ file_path=f"{old.file_path} -> {new.file_path}",
209
+ before_content=old.before_content,
210
+ after_content=new.after_content,
211
+ before_sha256=old.before_sha256,
212
+ after_sha256=new.after_sha256,
213
+ unified_diff="",
214
+ command=command,
215
+ generated_by_command=command is not None,
216
+ git_before=git_before,
217
+ git_after=git_after,
218
+ )
219
+ removed.add(new.file_path)
220
+ break
221
+ return [event for event in events if event.file_path not in replacements and event.file_path not in removed] + list(replacements.values())
222
+
223
+ def _event(self, transaction_id: str, action: str, path: str, before: FileSnapshot | None, after: FileSnapshot | None, command: str | None, git_before: dict[str, object], git_after: dict[str, object]) -> MutationEvent:
224
+ before_text = self._decode(before.content) if before else None
225
+ after_text = self._decode(after.content) if after else None
226
+ return MutationEvent(
227
+ transaction_id=transaction_id,
228
+ correlation_id=get_correlation_id(),
229
+ timestamp=datetime.now(UTC).isoformat(),
230
+ action=action,
231
+ file_path=path,
232
+ before_content=before_text,
233
+ after_content=after_text,
234
+ before_sha256=before.sha256 if before else None,
235
+ after_sha256=after.sha256 if after else None,
236
+ unified_diff=self._diff(path, before_text, after_text),
237
+ command=command,
238
+ generated_by_command=command is not None,
239
+ git_before=git_before,
240
+ git_after=git_after,
241
+ )
242
+
243
+ def _append(self, events: list[MutationEvent]) -> None:
244
+ if not events:
245
+ return
246
+ self.log_path.parent.mkdir(parents=True, exist_ok=True)
247
+ with self.log_path.open("a", encoding="utf-8") as handle:
248
+ for event in events:
249
+ handle.write(json.dumps(asdict(event), separators=(",", ":")) + "\n")
250
+
251
+ def _git(self, *args: str) -> str | None:
252
+ result = subprocess.run(
253
+ ["git", *args],
254
+ cwd=self.workspace,
255
+ text=True,
256
+ capture_output=True,
257
+ check=False,
258
+ env=isolated_subprocess_environment(
259
+ {
260
+ "GIT_CONFIG_NOSYSTEM": "1",
261
+ "GIT_CONFIG_GLOBAL": str(Path(os.devnull)),
262
+ "GIT_ATTR_NOSYSTEM": "1",
263
+ }
264
+ ),
265
+ **isolated_process_kwargs(),
266
+ )
267
+ return result.stdout.strip() if result.returncode == 0 else None
268
+
269
+ def _git_lines(self, *args: str) -> list[str]:
270
+ value = self._git(*args)
271
+ return value.splitlines() if value else []
272
+
273
+ @staticmethod
274
+ def _hash(content: bytes) -> str:
275
+ return hashlib.sha256(content).hexdigest()
276
+
277
+ @staticmethod
278
+ def _decode(content: bytes) -> str:
279
+ return content.decode("utf-8", errors="replace")
280
+
281
+ @staticmethod
282
+ def _diff(path: str, before: str | None, after: str | None) -> str:
283
+ return "".join(unified_diff((before or "").splitlines(keepends=True), (after or "").splitlines(keepends=True), fromfile=f"a/{path}", tofile=f"b/{path}"))
@@ -0,0 +1,3 @@
1
+ from pulse.orchestration.orchestrator import AgentOrchestrator
2
+
3
+ __all__ = ["AgentOrchestrator"]
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ from pulse.context import ContextManager
7
+ from pulse.core.agent import Agent, AgentRequest, AgentResponse
8
+ from pulse.core.protocols import LLMProvider
9
+ from pulse.repository import RepositoryIndex
10
+ from pulse.safety.safety_manager import SafetyManager
11
+ from pulse.tool_registry import ToolInvocation, ToolRegistry
12
+
13
+
14
+ class AgentOrchestrator:
15
+ """Intercepts user prompts, queries Repository Intelligence, and manages tool/LLM execution flow.
16
+
17
+ - Intercepts user prompt.
18
+ - Runs Repository Intelligence.
19
+ - Executes tool directly if intent is deterministic (e.g. read file, search symbol, registered tool).
20
+ - Forwards to LLM only if intent is complex.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ agent: Agent | None = None,
26
+ provider: LLMProvider | None = None,
27
+ repository: RepositoryIndex | None = None,
28
+ tool_registry: ToolRegistry | None = None,
29
+ safety_manager: SafetyManager | None = None,
30
+ system_prompt: str = "You are a pulse project assistant.",
31
+ context_manager: ContextManager | None = None,
32
+ ) -> None:
33
+ self.repository = repository
34
+ self.tool_registry = tool_registry or ToolRegistry()
35
+ self.safety_manager = safety_manager or SafetyManager()
36
+ self.context_manager = context_manager
37
+
38
+ if agent:
39
+ self.agent = agent
40
+ elif provider:
41
+ self.agent = Agent(
42
+ provider=provider,
43
+ system_prompt=system_prompt,
44
+ tool_registry=self.tool_registry,
45
+ )
46
+ else:
47
+ self.agent = None
48
+
49
+ async def handle_request(self, request: AgentRequest | str) -> AgentResponse:
50
+ if isinstance(request, str):
51
+ request = AgentRequest(message=request)
52
+
53
+ prompt = request.message.strip()
54
+
55
+ # 1. Run Repository Intelligence
56
+ repo_results = []
57
+ if self.repository:
58
+ repo_results = await self.repository.search(prompt)
59
+
60
+ # 2. Execute tool directly if intent is deterministic
61
+ deterministic_response = await self._try_deterministic_execution(prompt, repo_results, request)
62
+ if deterministic_response is not None:
63
+ return deterministic_response
64
+
65
+ # 3. Forward to LLM only if intent is complex
66
+ if not self.agent:
67
+ return AgentResponse(
68
+ content="No LLM agent or provider configured for handling complex prompts.",
69
+ conversation_id=request.conversation_id,
70
+ request_id=str(request.metadata.get("request_id", "local")),
71
+ )
72
+
73
+ context = list(request.context)
74
+
75
+ # Prepend ContextManager output (ranked, compressed, token-budgeted)
76
+ # before raw repository results so the LLM sees the most relevant
77
+ # context first.
78
+ if self.context_manager:
79
+ managed_context = await self.context_manager.as_strings(prompt)
80
+ context = [*managed_context, *context]
81
+
82
+ if repo_results:
83
+ repo_context = "Repository Intelligence Context:\n" + "\n".join(
84
+ f"- {item.path} (score: {item.score})" for item in repo_results
85
+ )
86
+ context.append(repo_context)
87
+
88
+ enriched_request = AgentRequest(
89
+ message=request.message,
90
+ conversation_id=request.conversation_id,
91
+ context=context,
92
+ metadata=request.metadata,
93
+ )
94
+
95
+ return await self.agent.respond(enriched_request)
96
+
97
+ async def _try_deterministic_execution(
98
+ self, prompt: str, repo_results: list[Any], request: AgentRequest
99
+ ) -> AgentResponse | None:
100
+ prompt_lower = prompt.lower()
101
+ request_id = str(request.metadata.get("request_id", "deterministic"))
102
+
103
+ # Check explicit tool registry match
104
+ invocation = ToolInvocation(message=prompt, metadata=request.metadata)
105
+ matched_tool = self.tool_registry.match(invocation)
106
+ if matched_tool:
107
+ authorized = await self.safety_manager.authorize(
108
+ action=matched_tool.name,
109
+ target=prompt,
110
+ detail=f"Deterministic tool execution: {matched_tool.name}",
111
+ )
112
+ if not authorized:
113
+ return AgentResponse(
114
+ content=f"Execution blocked by SafetyManager for tool: {matched_tool.name}",
115
+ conversation_id=request.conversation_id,
116
+ request_id=request_id,
117
+ tool_name=matched_tool.name,
118
+ )
119
+
120
+ tool_result = await self.tool_registry.execute(invocation)
121
+ content = tool_result.content if tool_result else "Tool executed."
122
+ return AgentResponse(
123
+ content=content,
124
+ conversation_id=request.conversation_id,
125
+ request_id=request_id,
126
+ tool_name=matched_tool.name,
127
+ )
128
+
129
+ # Deterministic symbol search intent
130
+ if self.repository and any(kw in prompt_lower for kw in ["search symbol", "find symbol", "lookup symbol"]):
131
+ symbol_query = re.sub(r"(?i)^(search|find|lookup)\s+symbol\s*", "", prompt).strip()
132
+ if symbol_query:
133
+ matches = await self.repository.search(symbol_query)
134
+ found_symbols = []
135
+ for m in matches:
136
+ for s in m.symbols:
137
+ if symbol_query.lower() in s.name.lower():
138
+ found_symbols.append(f"- `{s.name}` ({s.kind}) in `{m.path}` at line {s.line}")
139
+ content = (
140
+ "Symbol search results:\n" + "\n".join(found_symbols)
141
+ if found_symbols
142
+ else f"No symbols matching '{symbol_query}' found."
143
+ )
144
+ return AgentResponse(
145
+ content=content,
146
+ conversation_id=request.conversation_id,
147
+ request_id=request_id,
148
+ tool_name="repository_symbol_search",
149
+ )
150
+
151
+ # Deterministic file read / listing intent
152
+ if self.repository and ("list files" in prompt_lower or "show files" in prompt_lower):
153
+ files = await self.repository.files()
154
+ content = "Repository files:\n" + "\n".join(f"- {f}" for f in files)
155
+ return AgentResponse(
156
+ content=content,
157
+ conversation_id=request.conversation_id,
158
+ request_id=request_id,
159
+ tool_name="repository_list_files",
160
+ )
161
+
162
+ return None