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/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,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
|