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/context.py
ADDED
|
@@ -0,0 +1,960 @@
|
|
|
1
|
+
"""Production-grade Context Manager for Pulse.
|
|
2
|
+
|
|
3
|
+
Architecture
|
|
4
|
+
============
|
|
5
|
+
``ContextManager`` gathers context from up to six built-in *source adapters*
|
|
6
|
+
and any number of user-registered adapters (RAG extension point). Each source
|
|
7
|
+
returns :class:`ContextItem` objects that are ranked by relevance to the
|
|
8
|
+
current user request, then trimmed to a configurable token budget.
|
|
9
|
+
|
|
10
|
+
Two compression strategies are available:
|
|
11
|
+
|
|
12
|
+
* :class:`ContextCompressor` — heuristic head+tail truncation. No extra LLM
|
|
13
|
+
call; low latency; suitable when a model provider is not available.
|
|
14
|
+
* :class:`SummarizationCompressor` — LLM-backed summarisation. Produces much
|
|
15
|
+
denser summaries for large code/file items. Falls back silently to the
|
|
16
|
+
heuristic strategy when the provider is unavailable or raises.
|
|
17
|
+
|
|
18
|
+
Built-in sources (all optional via constructor injection):
|
|
19
|
+
- :class:`ConversationHistorySource` — recent conversation turns
|
|
20
|
+
- :class:`RepositoryIntelligenceSource` — ranked file/symbol hits
|
|
21
|
+
- :class:`MemorySource` — long-term memory & preferences
|
|
22
|
+
- :class:`GitStatusSource` — branch + working-tree state
|
|
23
|
+
- :class:`ActiveFileSource` — content of the IDE active file
|
|
24
|
+
- :class:`UserIntentSource` — intent signals parsed from prompt
|
|
25
|
+
|
|
26
|
+
Extension
|
|
27
|
+
=========
|
|
28
|
+
Any object that satisfies the :class:`ContextSource` structural protocol can
|
|
29
|
+
be registered at runtime::
|
|
30
|
+
|
|
31
|
+
cm = ContextManager(...)
|
|
32
|
+
await cm.register_source(MyRagSource())
|
|
33
|
+
|
|
34
|
+
The new source participates in every subsequent ``build()`` call.
|
|
35
|
+
|
|
36
|
+
Token Budget
|
|
37
|
+
============
|
|
38
|
+
Default budget is 6 000 tokens (≈ 24 000 characters). Override via the
|
|
39
|
+
``max_tokens`` constructor parameter or the ``PULSE_CONTEXT_MAX_TOKENS``
|
|
40
|
+
environment variable.
|
|
41
|
+
|
|
42
|
+
Cache
|
|
43
|
+
=====
|
|
44
|
+
Built contexts are cached in-process for 30 s (configurable via ``cache_ttl``).
|
|
45
|
+
Call ``await cm.invalidate_cache(request)`` to force a refresh, e.g. between
|
|
46
|
+
autonomous loop turns where the repository or Git state may have changed.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import asyncio
|
|
52
|
+
import hashlib
|
|
53
|
+
import os
|
|
54
|
+
import re
|
|
55
|
+
import time
|
|
56
|
+
from dataclasses import dataclass, field
|
|
57
|
+
from pathlib import Path
|
|
58
|
+
from typing import Any, Protocol
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Data types
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(slots=True)
|
|
66
|
+
class ContextItem:
|
|
67
|
+
"""A single piece of context produced by a source adapter.
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
source: Human-readable source label (e.g. ``"memory"``, ``"git"``).
|
|
71
|
+
content: Raw text content to be delivered to the LLM.
|
|
72
|
+
relevance_score: Float in [0, 1] assigned by :class:`ContextRanker`.
|
|
73
|
+
token_estimate: Approximate token count (1 token ≈ 4 characters).
|
|
74
|
+
metadata: Arbitrary source-specific key/value pairs.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
source: str
|
|
78
|
+
content: str
|
|
79
|
+
relevance_score: float = 0.0
|
|
80
|
+
token_estimate: int = 0
|
|
81
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
82
|
+
|
|
83
|
+
def __post_init__(self) -> None:
|
|
84
|
+
if not self.token_estimate:
|
|
85
|
+
self.token_estimate = _estimate_tokens(self.content)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(slots=True)
|
|
89
|
+
class BuiltContext:
|
|
90
|
+
"""Output of :meth:`ContextManager.build`.
|
|
91
|
+
|
|
92
|
+
Attributes:
|
|
93
|
+
items: Ranked, budget-fitted :class:`ContextItem` list (highest
|
|
94
|
+
relevance first).
|
|
95
|
+
total_tokens: Sum of ``item.token_estimate`` for included items.
|
|
96
|
+
was_compressed: ``True`` if the compressor had to truncate any item.
|
|
97
|
+
compression_strategy: ``"heuristic"`` or ``"llm"`` — which compressor ran.
|
|
98
|
+
build_time_ms: Wall-clock milliseconds spent building this context.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
items: list[ContextItem]
|
|
102
|
+
total_tokens: int
|
|
103
|
+
was_compressed: bool
|
|
104
|
+
build_time_ms: float
|
|
105
|
+
compression_strategy: str = "heuristic"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(slots=True)
|
|
109
|
+
class ContextStats:
|
|
110
|
+
"""Diagnostic snapshot of a :class:`ContextManager` instance.
|
|
111
|
+
|
|
112
|
+
Attributes:
|
|
113
|
+
builtin_source_count: Number of built-in source adapters registered.
|
|
114
|
+
extra_source_count: Number of user-registered (RAG) source adapters.
|
|
115
|
+
cache_enabled: Whether the TTL cache is active.
|
|
116
|
+
max_tokens: Configured token budget.
|
|
117
|
+
compression_strategy: Active compression strategy name.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
builtin_source_count: int
|
|
121
|
+
extra_source_count: int
|
|
122
|
+
cache_enabled: bool
|
|
123
|
+
max_tokens: int
|
|
124
|
+
compression_strategy: str
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# Source protocol — RAG extension hook
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ContextSource(Protocol):
|
|
133
|
+
"""Structural protocol that every context source must satisfy.
|
|
134
|
+
|
|
135
|
+
Any object with an ``async gather(request)`` method can be registered
|
|
136
|
+
with :meth:`ContextManager.register_source` and will participate in
|
|
137
|
+
every ``build()`` call.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
async def gather(self, request: str) -> list[ContextItem]:
|
|
141
|
+
"""Gather context items relevant to *request*.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
request: The raw user prompt / question.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
A list of :class:`ContextItem` objects. The list may be empty.
|
|
148
|
+
"""
|
|
149
|
+
...
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# Built-in source adapters
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class ConversationHistorySource:
|
|
158
|
+
"""Injects recent conversation turns as context.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
store: Any object with an async ``read(conversation_id)`` method that
|
|
162
|
+
returns objects with ``role`` and ``content`` attributes.
|
|
163
|
+
conversation_id: Identifier of the conversation to read.
|
|
164
|
+
max_turns: Maximum number of recent turns to include.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
def __init__(self, store: Any, conversation_id: str = "default", max_turns: int = 6) -> None:
|
|
168
|
+
self._store = store
|
|
169
|
+
self._conversation_id = conversation_id
|
|
170
|
+
self._max_turns = max_turns
|
|
171
|
+
|
|
172
|
+
async def gather(self, request: str) -> list[ContextItem]:
|
|
173
|
+
try:
|
|
174
|
+
messages = await self._store.read(self._conversation_id)
|
|
175
|
+
# Intentionally broad to isolate execution boundaries and prevent crashes.
|
|
176
|
+
except Exception: # noqa: BLE001
|
|
177
|
+
return []
|
|
178
|
+
|
|
179
|
+
recent = messages[-self._max_turns * 2:] # user + assistant pairs
|
|
180
|
+
if not recent:
|
|
181
|
+
return []
|
|
182
|
+
|
|
183
|
+
lines = [f"{msg.role.capitalize()}: {msg.content}" for msg in recent]
|
|
184
|
+
content = "Conversation history:\n" + "\n".join(lines)
|
|
185
|
+
return [
|
|
186
|
+
ContextItem(
|
|
187
|
+
source="history",
|
|
188
|
+
content=content,
|
|
189
|
+
metadata={"turns": len(recent)},
|
|
190
|
+
)
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class RepositoryIntelligenceSource:
|
|
195
|
+
"""Retrieves ranked file/symbol hits from :class:`~pulse.repository.RepositoryIndex`.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
repository: A ``RepositoryIndex`` instance (or duck-typed equivalent).
|
|
199
|
+
limit: Maximum search results to include.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
def __init__(self, repository: Any, limit: int = 5) -> None:
|
|
203
|
+
self._repository = repository
|
|
204
|
+
self._limit = limit
|
|
205
|
+
|
|
206
|
+
async def gather(self, request: str) -> list[ContextItem]:
|
|
207
|
+
try:
|
|
208
|
+
results = await self._repository.search(request, limit=self._limit)
|
|
209
|
+
# Intentionally broad to isolate execution boundaries and prevent crashes.
|
|
210
|
+
except Exception: # noqa: BLE001
|
|
211
|
+
return []
|
|
212
|
+
|
|
213
|
+
items: list[ContextItem] = []
|
|
214
|
+
for result in results:
|
|
215
|
+
symbols = ", ".join(f"{s.name}({s.kind})" for s in result.symbols[:8])
|
|
216
|
+
content = f"Repository file: {result.path}"
|
|
217
|
+
if symbols:
|
|
218
|
+
content += f"\n Symbols: {symbols}"
|
|
219
|
+
items.append(
|
|
220
|
+
ContextItem(
|
|
221
|
+
source="repository",
|
|
222
|
+
content=content,
|
|
223
|
+
relevance_score=min(result.score / 10.0, 1.0),
|
|
224
|
+
metadata={"path": result.path, "score": result.score},
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
return items
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class MemorySource:
|
|
231
|
+
"""Injects long-term memories and user preferences.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
memory: A :class:`~pulse.memory.LongTermMemory` instance.
|
|
235
|
+
limit: Maximum memory entries to retrieve.
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
def __init__(self, memory: Any, limit: int = 4) -> None:
|
|
239
|
+
self._memory = memory
|
|
240
|
+
self._limit = limit
|
|
241
|
+
|
|
242
|
+
async def gather(self, request: str) -> list[ContextItem]:
|
|
243
|
+
try:
|
|
244
|
+
strings = await self._memory.context_for(request, limit=self._limit)
|
|
245
|
+
# Intentionally broad to isolate execution boundaries and prevent crashes.
|
|
246
|
+
except Exception: # noqa: BLE001
|
|
247
|
+
return []
|
|
248
|
+
|
|
249
|
+
if not strings:
|
|
250
|
+
return []
|
|
251
|
+
|
|
252
|
+
content = "Long-term memory:\n" + "\n".join(f"- {s}" for s in strings)
|
|
253
|
+
return [
|
|
254
|
+
ContextItem(
|
|
255
|
+
source="memory",
|
|
256
|
+
content=content,
|
|
257
|
+
metadata={"entries": len(strings)},
|
|
258
|
+
)
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class GitStatusSource:
|
|
263
|
+
"""Captures current Git branch and working-tree state.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
git: A :class:`~pulse.git.GitIntelligence` instance.
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
def __init__(self, git: Any) -> None:
|
|
270
|
+
self._git = git
|
|
271
|
+
|
|
272
|
+
async def gather(self, request: str) -> list[ContextItem]:
|
|
273
|
+
try:
|
|
274
|
+
status = await self._git.status()
|
|
275
|
+
# Intentionally broad to isolate execution boundaries and prevent crashes.
|
|
276
|
+
except Exception: # noqa: BLE001
|
|
277
|
+
return []
|
|
278
|
+
|
|
279
|
+
if not status.is_repository:
|
|
280
|
+
return []
|
|
281
|
+
|
|
282
|
+
lines = [f"Git branch: {status.branch or 'unknown'}"]
|
|
283
|
+
if status.head:
|
|
284
|
+
lines.append(f"HEAD: {status.head}")
|
|
285
|
+
if status.changes:
|
|
286
|
+
changed = [f" {c.index_status}{c.worktree_status} {c.path}" for c in status.changes[:10]]
|
|
287
|
+
lines.append("Changed files:")
|
|
288
|
+
lines.extend(changed)
|
|
289
|
+
if len(status.changes) > 10:
|
|
290
|
+
lines.append(f" … and {len(status.changes) - 10} more")
|
|
291
|
+
|
|
292
|
+
content = "\n".join(lines)
|
|
293
|
+
return [
|
|
294
|
+
ContextItem(
|
|
295
|
+
source="git",
|
|
296
|
+
content=content,
|
|
297
|
+
metadata={
|
|
298
|
+
"branch": status.branch,
|
|
299
|
+
"head": status.head,
|
|
300
|
+
"changes": len(status.changes),
|
|
301
|
+
},
|
|
302
|
+
)
|
|
303
|
+
]
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class ActiveFileSource:
|
|
307
|
+
"""Injects the content (or a prefix) of the IDE's currently active file.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
workspace: Root workspace path used to resolve relative paths.
|
|
311
|
+
max_lines: Maximum lines to include from the file before truncation.
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
def __init__(self, workspace: Path, max_lines: int = 120) -> None:
|
|
315
|
+
self._workspace = workspace
|
|
316
|
+
self._max_lines = max_lines
|
|
317
|
+
|
|
318
|
+
async def gather(self, request: str, *, active_file: str | None = None) -> list[ContextItem]:
|
|
319
|
+
if not active_file:
|
|
320
|
+
return []
|
|
321
|
+
path = Path(active_file)
|
|
322
|
+
if not path.is_absolute():
|
|
323
|
+
path = self._workspace / path
|
|
324
|
+
try:
|
|
325
|
+
text = await asyncio.to_thread(path.read_text, encoding="utf-8", errors="replace")
|
|
326
|
+
except OSError:
|
|
327
|
+
return []
|
|
328
|
+
|
|
329
|
+
lines = text.splitlines()
|
|
330
|
+
truncated = len(lines) > self._max_lines
|
|
331
|
+
snippet = "\n".join(lines[: self._max_lines])
|
|
332
|
+
suffix = f"\n… ({len(lines) - self._max_lines} more lines)" if truncated else ""
|
|
333
|
+
content = f"Active file: {path.name}\n```\n{snippet}{suffix}\n```"
|
|
334
|
+
return [
|
|
335
|
+
ContextItem(
|
|
336
|
+
source="active_file",
|
|
337
|
+
content=content,
|
|
338
|
+
metadata={"path": str(path), "lines": len(lines), "truncated": truncated},
|
|
339
|
+
)
|
|
340
|
+
]
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class UserIntentSource:
|
|
344
|
+
"""Emits a concise intent summary derived from the user's prompt.
|
|
345
|
+
|
|
346
|
+
Extracts key terms, verbs, and file/symbol references so the LLM has a
|
|
347
|
+
clean, deduplicated statement of intent even when the raw prompt is long.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
max_keywords: Maximum extracted keywords to surface.
|
|
351
|
+
"""
|
|
352
|
+
|
|
353
|
+
# Action verbs that signal code-editing or investigative intent.
|
|
354
|
+
_ACTION_VERBS = frozenset({
|
|
355
|
+
"add", "build", "change", "check", "create", "debug", "delete",
|
|
356
|
+
"explain", "find", "fix", "implement", "list", "modify", "refactor",
|
|
357
|
+
"remove", "rename", "search", "show", "test", "update", "write",
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
def __init__(self, max_keywords: int = 10) -> None:
|
|
361
|
+
self._max_keywords = max_keywords
|
|
362
|
+
|
|
363
|
+
async def gather(self, request: str) -> list[ContextItem]:
|
|
364
|
+
if not request.strip():
|
|
365
|
+
return []
|
|
366
|
+
|
|
367
|
+
words = re.findall(r"[a-zA-Z_][a-zA-Z0-9_./-]*", request.lower())
|
|
368
|
+
actions = [w for w in words if w in self._ACTION_VERBS]
|
|
369
|
+
# File/symbol references: tokens containing "." or "/" or ending in .py etc.
|
|
370
|
+
refs = [w for w in words if ("." in w or "/" in w) and len(w) > 2]
|
|
371
|
+
# General keywords: longer words, deduped, excluding stop words.
|
|
372
|
+
stop = {"the", "a", "an", "in", "on", "at", "to", "for", "of", "and",
|
|
373
|
+
"or", "is", "it", "be", "as", "by", "that", "this", "with"}
|
|
374
|
+
keywords = list(dict.fromkeys(
|
|
375
|
+
w for w in words if len(w) > 3 and w not in stop
|
|
376
|
+
))[: self._max_keywords]
|
|
377
|
+
|
|
378
|
+
summary_parts: list[str] = []
|
|
379
|
+
if actions:
|
|
380
|
+
summary_parts.append(f"Intent actions: {', '.join(dict.fromkeys(actions))}")
|
|
381
|
+
if refs:
|
|
382
|
+
summary_parts.append(f"File/symbol refs: {', '.join(refs[:6])}")
|
|
383
|
+
if keywords:
|
|
384
|
+
summary_parts.append(f"Key terms: {', '.join(keywords)}")
|
|
385
|
+
|
|
386
|
+
if not summary_parts:
|
|
387
|
+
return []
|
|
388
|
+
|
|
389
|
+
content = "User intent analysis:\n" + "\n".join(summary_parts)
|
|
390
|
+
return [
|
|
391
|
+
ContextItem(
|
|
392
|
+
source="intent",
|
|
393
|
+
content=content,
|
|
394
|
+
metadata={"actions": actions, "refs": refs, "keywords": keywords},
|
|
395
|
+
)
|
|
396
|
+
]
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# ---------------------------------------------------------------------------
|
|
400
|
+
# Ranker
|
|
401
|
+
# ---------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
class ContextRanker:
|
|
405
|
+
"""Scores each :class:`ContextItem` by relevance to the user request.
|
|
406
|
+
|
|
407
|
+
Uses a TF-IDF-style keyword overlap between the request and item content.
|
|
408
|
+
Repository items receive a boost from their pre-computed ``score`` field.
|
|
409
|
+
Memory and history items receive a small recency boost so they surface
|
|
410
|
+
even when keyword overlap is low.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
boost_repository: Extra multiplier applied to repository item scores.
|
|
414
|
+
boost_memory: Additive boost applied to memory/history items.
|
|
415
|
+
"""
|
|
416
|
+
|
|
417
|
+
def __init__(self, boost_repository: float = 1.5, boost_memory: float = 0.05) -> None:
|
|
418
|
+
self._boost_repository = boost_repository
|
|
419
|
+
self._boost_memory = boost_memory
|
|
420
|
+
|
|
421
|
+
def rank(self, items: list[ContextItem], request: str) -> list[ContextItem]:
|
|
422
|
+
"""Return *items* sorted highest-relevance first with updated scores.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
items: Raw items from all sources.
|
|
426
|
+
request: The user's prompt.
|
|
427
|
+
|
|
428
|
+
Returns:
|
|
429
|
+
A new list of :class:`ContextItem` with ``relevance_score`` filled.
|
|
430
|
+
"""
|
|
431
|
+
if not request.strip():
|
|
432
|
+
return items
|
|
433
|
+
|
|
434
|
+
query_terms = self._terms(request)
|
|
435
|
+
if not query_terms:
|
|
436
|
+
return items
|
|
437
|
+
|
|
438
|
+
scored: list[ContextItem] = []
|
|
439
|
+
for item in items:
|
|
440
|
+
item_terms = self._terms(item.content)
|
|
441
|
+
overlap = len(query_terms.intersection(item_terms))
|
|
442
|
+
score = overlap / max(len(query_terms), 1)
|
|
443
|
+
|
|
444
|
+
# Apply source-specific boosts.
|
|
445
|
+
if item.source == "repository":
|
|
446
|
+
score = score * self._boost_repository + item.relevance_score * 0.3
|
|
447
|
+
elif item.source in {"memory", "history"}:
|
|
448
|
+
score = score + self._boost_memory
|
|
449
|
+
elif item.source == "intent":
|
|
450
|
+
# Intent is always surfaced near the top.
|
|
451
|
+
score = max(score, 0.5)
|
|
452
|
+
|
|
453
|
+
score = min(score, 1.0)
|
|
454
|
+
scored.append(
|
|
455
|
+
ContextItem(
|
|
456
|
+
source=item.source,
|
|
457
|
+
content=item.content,
|
|
458
|
+
relevance_score=round(score, 4),
|
|
459
|
+
token_estimate=item.token_estimate,
|
|
460
|
+
metadata=item.metadata,
|
|
461
|
+
)
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
return sorted(scored, key=lambda i: (-i.relevance_score, i.source))
|
|
465
|
+
|
|
466
|
+
@staticmethod
|
|
467
|
+
def _terms(text: str) -> set[str]:
|
|
468
|
+
words = re.findall(r"[a-zA-Z_][a-zA-Z0-9_]*", text.lower())
|
|
469
|
+
return {w for w in words if len(w) > 2}
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
# ---------------------------------------------------------------------------
|
|
473
|
+
# Compressors
|
|
474
|
+
# ---------------------------------------------------------------------------
|
|
475
|
+
|
|
476
|
+
_COMPRESSION_KEEP_HEAD = 30 # lines to keep from the start of an item
|
|
477
|
+
_COMPRESSION_KEEP_TAIL = 10 # lines to keep from the end of an item
|
|
478
|
+
|
|
479
|
+
# Prompt template used by SummarizationCompressor.
|
|
480
|
+
_SUMMARIZATION_PROMPT = (
|
|
481
|
+
"You are a precise technical summarizer for a software engineering assistant.\n"
|
|
482
|
+
"Summarize the following context item into at most {max_tokens} tokens, "
|
|
483
|
+
"preserving all file names, symbol names, function signatures, and "
|
|
484
|
+
"important design decisions. Output only the summary, no preamble.\n\n"
|
|
485
|
+
"Context item (source: {source}):\n{content}"
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
class ContextCompressor:
|
|
490
|
+
"""Heuristic compressor that truncates over-budget items.
|
|
491
|
+
|
|
492
|
+
No external LLM call is made. The compressor preserves the *head* and
|
|
493
|
+
*tail* of each item's content on the assumption that file headers and
|
|
494
|
+
trailing summaries are the most information-dense parts.
|
|
495
|
+
|
|
496
|
+
Swap this out for :class:`SummarizationCompressor` to get LLM-backed
|
|
497
|
+
summarisation at the cost of an extra API call.
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
max_tokens: Token budget for the total context.
|
|
501
|
+
head_lines: Lines to keep from the start of each truncated item.
|
|
502
|
+
tail_lines: Lines to keep from the end of each truncated item.
|
|
503
|
+
"""
|
|
504
|
+
|
|
505
|
+
name: str = "heuristic"
|
|
506
|
+
|
|
507
|
+
def __init__(
|
|
508
|
+
self,
|
|
509
|
+
max_tokens: int = 6000,
|
|
510
|
+
head_lines: int = _COMPRESSION_KEEP_HEAD,
|
|
511
|
+
tail_lines: int = _COMPRESSION_KEEP_TAIL,
|
|
512
|
+
) -> None:
|
|
513
|
+
self._max_tokens = max_tokens
|
|
514
|
+
self._head_lines = head_lines
|
|
515
|
+
self._tail_lines = tail_lines
|
|
516
|
+
|
|
517
|
+
async def compress(self, items: list[ContextItem]) -> tuple[list[ContextItem], bool]:
|
|
518
|
+
"""Fit *items* into the token budget.
|
|
519
|
+
|
|
520
|
+
First, items with zero relevance are dropped. Then the least-relevant
|
|
521
|
+
items are removed until the budget is satisfied. Finally, if the budget
|
|
522
|
+
is still exceeded, individual items are truncated using the head/tail
|
|
523
|
+
strategy.
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
items: Ranked list of :class:`ContextItem` objects.
|
|
527
|
+
|
|
528
|
+
Returns:
|
|
529
|
+
A ``(fitted_items, was_compressed)`` tuple.
|
|
530
|
+
"""
|
|
531
|
+
was_compressed = False
|
|
532
|
+
|
|
533
|
+
# Drop zero-relevance items first (intent always scores >= 0.5 so safe).
|
|
534
|
+
fitted = [i for i in items if i.relevance_score > 0.0 or i.source == "intent"]
|
|
535
|
+
if not fitted:
|
|
536
|
+
fitted = list(items) # nothing to drop; keep all
|
|
537
|
+
|
|
538
|
+
# Remove lowest-ranked items until budget is satisfied.
|
|
539
|
+
while fitted and sum(i.token_estimate for i in fitted) > self._max_tokens:
|
|
540
|
+
fitted.pop() # items are highest-first; pop removes lowest
|
|
541
|
+
was_compressed = True
|
|
542
|
+
|
|
543
|
+
# If still over budget (a single item is very large), truncate items.
|
|
544
|
+
for index, item in enumerate(fitted):
|
|
545
|
+
if sum(i.token_estimate for i in fitted) <= self._max_tokens:
|
|
546
|
+
break
|
|
547
|
+
truncated_content = self._truncate(item.content)
|
|
548
|
+
if truncated_content != item.content:
|
|
549
|
+
was_compressed = True
|
|
550
|
+
fitted[index] = ContextItem(
|
|
551
|
+
source=item.source,
|
|
552
|
+
content=truncated_content,
|
|
553
|
+
relevance_score=item.relevance_score,
|
|
554
|
+
token_estimate=_estimate_tokens(truncated_content),
|
|
555
|
+
metadata={**item.metadata, "compressed": True},
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
return fitted, was_compressed
|
|
559
|
+
|
|
560
|
+
def _truncate(self, text: str) -> str:
|
|
561
|
+
lines = text.splitlines()
|
|
562
|
+
if len(lines) <= self._head_lines + self._tail_lines:
|
|
563
|
+
return text
|
|
564
|
+
omitted = len(lines) - self._head_lines - self._tail_lines
|
|
565
|
+
head = lines[: self._head_lines]
|
|
566
|
+
tail = lines[-self._tail_lines:]
|
|
567
|
+
return "\n".join(head) + f"\n… [{omitted} lines omitted] …\n" + "\n".join(tail)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
class SummarizationCompressor:
|
|
571
|
+
"""LLM-backed compressor that summarises over-budget context items.
|
|
572
|
+
|
|
573
|
+
When the ranked items exceed *max_tokens*, this compressor calls the
|
|
574
|
+
provided LLM provider to produce a dense, accurate summary of each
|
|
575
|
+
over-budget item. It falls back silently to the heuristic strategy
|
|
576
|
+
if the provider is unavailable or raises.
|
|
577
|
+
|
|
578
|
+
This compressor satisfies the same interface as :class:`ContextCompressor`
|
|
579
|
+
and can be swapped in via the ``compressor`` parameter of
|
|
580
|
+
:class:`ContextManager`.
|
|
581
|
+
|
|
582
|
+
Args:
|
|
583
|
+
provider: Any object with a ``chat(messages, temperature)`` method
|
|
584
|
+
(satisfies :class:`~pulse.core.protocols.LLMProvider`).
|
|
585
|
+
max_tokens: Token budget for the total context.
|
|
586
|
+
summary_tokens_per_item: Maximum tokens allowed per summarised item.
|
|
587
|
+
fallback: Heuristic compressor used when the provider is unavailable.
|
|
588
|
+
|
|
589
|
+
Example::
|
|
590
|
+
|
|
591
|
+
from pulse.context import ContextManager, SummarizationCompressor
|
|
592
|
+
from pulse.provider import OpenAIProvider
|
|
593
|
+
|
|
594
|
+
provider = OpenAIProvider(config, workspace / ".env")
|
|
595
|
+
compressor = SummarizationCompressor(provider=provider, max_tokens=6000)
|
|
596
|
+
cm = ContextManager(..., compressor=compressor)
|
|
597
|
+
"""
|
|
598
|
+
|
|
599
|
+
name: str = "llm"
|
|
600
|
+
|
|
601
|
+
def __init__(
|
|
602
|
+
self,
|
|
603
|
+
provider: Any,
|
|
604
|
+
max_tokens: int = 6000,
|
|
605
|
+
summary_tokens_per_item: int = 300,
|
|
606
|
+
fallback: ContextCompressor | None = None,
|
|
607
|
+
) -> None:
|
|
608
|
+
self._provider = provider
|
|
609
|
+
self._max_tokens = max_tokens
|
|
610
|
+
self._summary_tokens = summary_tokens_per_item
|
|
611
|
+
self._fallback = fallback or ContextCompressor(max_tokens=max_tokens)
|
|
612
|
+
|
|
613
|
+
async def compress(self, items: list[ContextItem]) -> tuple[list[ContextItem], bool]:
|
|
614
|
+
"""Summarise over-budget items using the LLM, then apply heuristic fallback.
|
|
615
|
+
|
|
616
|
+
Items that already fit in the budget are returned unchanged. Only
|
|
617
|
+
items that cause a budget overflow are candidates for summarisation.
|
|
618
|
+
If the provider raises, the heuristic strategy is used for that item.
|
|
619
|
+
|
|
620
|
+
Args:
|
|
621
|
+
items: Ranked list of :class:`ContextItem` objects.
|
|
622
|
+
|
|
623
|
+
Returns:
|
|
624
|
+
A ``(fitted_items, was_compressed)`` tuple.
|
|
625
|
+
"""
|
|
626
|
+
was_compressed = False
|
|
627
|
+
total = sum(i.token_estimate for i in items)
|
|
628
|
+
|
|
629
|
+
if total <= self._max_tokens:
|
|
630
|
+
return list(items), False
|
|
631
|
+
|
|
632
|
+
# Summarise all items except intent/git (structural, already tiny).
|
|
633
|
+
summarised: list[ContextItem] = []
|
|
634
|
+
for item in items:
|
|
635
|
+
if item.source in {"intent", "git"} or item.token_estimate <= self._summary_tokens:
|
|
636
|
+
summarised.append(item)
|
|
637
|
+
continue
|
|
638
|
+
try:
|
|
639
|
+
summary_text = await self._summarize(item)
|
|
640
|
+
was_compressed = True
|
|
641
|
+
summarised.append(
|
|
642
|
+
ContextItem(
|
|
643
|
+
source=item.source,
|
|
644
|
+
content=summary_text,
|
|
645
|
+
relevance_score=item.relevance_score,
|
|
646
|
+
token_estimate=_estimate_tokens(summary_text),
|
|
647
|
+
metadata={**item.metadata, "compressed": True, "compression": "llm_summary"},
|
|
648
|
+
)
|
|
649
|
+
)
|
|
650
|
+
# Intentionally broad to isolate execution boundaries and prevent crashes.
|
|
651
|
+
except Exception: # noqa: BLE001
|
|
652
|
+
# Provider unavailable — fall back to the heuristic for this item.
|
|
653
|
+
summarised.append(item)
|
|
654
|
+
|
|
655
|
+
# After summarisation, apply heuristic to handle any remaining overflow.
|
|
656
|
+
fitted, heuristic_compressed = await self._fallback.compress(summarised)
|
|
657
|
+
return fitted, was_compressed or heuristic_compressed
|
|
658
|
+
|
|
659
|
+
async def _summarize(self, item: ContextItem) -> str:
|
|
660
|
+
"""Call the LLM provider to summarise a single context item."""
|
|
661
|
+
prompt = _SUMMARIZATION_PROMPT.format(
|
|
662
|
+
max_tokens=self._summary_tokens,
|
|
663
|
+
source=item.source,
|
|
664
|
+
content=item.content[: self._summary_tokens * 8], # rough char limit
|
|
665
|
+
)
|
|
666
|
+
messages = [{"role": "user", "content": prompt}]
|
|
667
|
+
# Use chat() (sync) if the provider doesn't expose async, but prefer
|
|
668
|
+
# the async path via asyncio.to_thread to stay non-blocking.
|
|
669
|
+
if hasattr(self._provider, "_chat"):
|
|
670
|
+
return await self._provider._chat(messages, temperature=0.1)
|
|
671
|
+
return await asyncio.to_thread(self._provider.chat, messages, 0.1)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
# ---------------------------------------------------------------------------
|
|
675
|
+
# Cache
|
|
676
|
+
# ---------------------------------------------------------------------------
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
@dataclass(slots=True)
|
|
680
|
+
class _CacheEntry:
|
|
681
|
+
result: BuiltContext
|
|
682
|
+
expires_at: float
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
class _ContextCache:
|
|
686
|
+
"""Simple in-process TTL cache for built contexts."""
|
|
687
|
+
|
|
688
|
+
def __init__(self, ttl_seconds: float = 30.0) -> None:
|
|
689
|
+
self._ttl = ttl_seconds
|
|
690
|
+
self._store: dict[str, _CacheEntry] = {}
|
|
691
|
+
self._lock = asyncio.Lock()
|
|
692
|
+
|
|
693
|
+
async def get(self, key: str) -> BuiltContext | None:
|
|
694
|
+
async with self._lock:
|
|
695
|
+
entry = self._store.get(key)
|
|
696
|
+
if entry and time.monotonic() < entry.expires_at:
|
|
697
|
+
return entry.result
|
|
698
|
+
if entry:
|
|
699
|
+
del self._store[key]
|
|
700
|
+
return None
|
|
701
|
+
|
|
702
|
+
async def set(self, key: str, value: BuiltContext) -> None:
|
|
703
|
+
async with self._lock:
|
|
704
|
+
self._store[key] = _CacheEntry(
|
|
705
|
+
result=value,
|
|
706
|
+
expires_at=time.monotonic() + self._ttl,
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
async def invalidate(self, key: str) -> None:
|
|
710
|
+
async with self._lock:
|
|
711
|
+
self._store.pop(key, None)
|
|
712
|
+
|
|
713
|
+
async def clear(self) -> None:
|
|
714
|
+
async with self._lock:
|
|
715
|
+
self._store.clear()
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
# ---------------------------------------------------------------------------
|
|
719
|
+
# ContextManager
|
|
720
|
+
# ---------------------------------------------------------------------------
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
class ContextManager:
|
|
724
|
+
"""Async, provider-agnostic Context Manager for Pulse.
|
|
725
|
+
|
|
726
|
+
Assembles ranked, compressed context from multiple sources and delivers it
|
|
727
|
+
as a ``list[str]`` ready to be passed to any LLM provider.
|
|
728
|
+
|
|
729
|
+
Args:
|
|
730
|
+
memory: Optional :class:`~pulse.memory.LongTermMemory` instance.
|
|
731
|
+
repository: Optional :class:`~pulse.repository.RepositoryIndex` instance.
|
|
732
|
+
git: Optional :class:`~pulse.git.GitIntelligence` instance.
|
|
733
|
+
workspace: Workspace root path (used by :class:`ActiveFileSource`).
|
|
734
|
+
conversation_store: Optional conversation store for history injection.
|
|
735
|
+
conversation_id: Conversation to read history from.
|
|
736
|
+
max_tokens: Token budget for the assembled context. Defaults to the
|
|
737
|
+
``PULSE_CONTEXT_MAX_TOKENS`` env var if set, otherwise 6 000.
|
|
738
|
+
cache_ttl: TTL in seconds for the in-process cache (0 disables caching).
|
|
739
|
+
ranker: Custom :class:`ContextRanker` (defaults to built-in).
|
|
740
|
+
compressor: Custom compressor — either :class:`ContextCompressor`
|
|
741
|
+
(default, heuristic) or :class:`SummarizationCompressor` (LLM-backed).
|
|
742
|
+
|
|
743
|
+
Example::
|
|
744
|
+
|
|
745
|
+
from pulse.context import ContextManager, SummarizationCompressor
|
|
746
|
+
from pulse.memory import LongTermMemory
|
|
747
|
+
from pulse.repository import RepositoryIndex
|
|
748
|
+
from pulse.git import GitIntelligence
|
|
749
|
+
|
|
750
|
+
cm = ContextManager(
|
|
751
|
+
memory=LongTermMemory(workspace),
|
|
752
|
+
repository=RepositoryIndex(workspace),
|
|
753
|
+
git=GitIntelligence(workspace),
|
|
754
|
+
workspace=workspace,
|
|
755
|
+
# Use LLM-backed summarisation for large projects:
|
|
756
|
+
compressor=SummarizationCompressor(provider=provider),
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
strings = await cm.as_strings("How does the planner work?")
|
|
760
|
+
# Pass `strings` as context to any LLM call.
|
|
761
|
+
"""
|
|
762
|
+
|
|
763
|
+
def __init__(
|
|
764
|
+
self,
|
|
765
|
+
*,
|
|
766
|
+
memory: Any | None = None,
|
|
767
|
+
repository: Any | None = None,
|
|
768
|
+
git: Any | None = None,
|
|
769
|
+
workspace: Path | None = None,
|
|
770
|
+
conversation_store: Any | None = None,
|
|
771
|
+
conversation_id: str = "default",
|
|
772
|
+
max_tokens: int | None = None,
|
|
773
|
+
cache_ttl: float = 30.0,
|
|
774
|
+
ranker: ContextRanker | None = None,
|
|
775
|
+
compressor: ContextCompressor | SummarizationCompressor | None = None,
|
|
776
|
+
) -> None:
|
|
777
|
+
self._max_tokens = max_tokens or int(os.environ.get("PULSE_CONTEXT_MAX_TOKENS", "6000"))
|
|
778
|
+
self._ranker = ranker or ContextRanker()
|
|
779
|
+
self._compressor: ContextCompressor | SummarizationCompressor = (
|
|
780
|
+
compressor or ContextCompressor(max_tokens=self._max_tokens)
|
|
781
|
+
)
|
|
782
|
+
self._cache = _ContextCache(ttl_seconds=cache_ttl) if cache_ttl > 0 else None
|
|
783
|
+
|
|
784
|
+
# Built-in sources (registered in priority order).
|
|
785
|
+
self._builtin_sources: list[Any] = []
|
|
786
|
+
if conversation_store:
|
|
787
|
+
self._builtin_sources.append(
|
|
788
|
+
ConversationHistorySource(conversation_store, conversation_id)
|
|
789
|
+
)
|
|
790
|
+
if memory:
|
|
791
|
+
self._builtin_sources.append(MemorySource(memory))
|
|
792
|
+
if repository:
|
|
793
|
+
self._builtin_sources.append(RepositoryIntelligenceSource(repository))
|
|
794
|
+
if git:
|
|
795
|
+
self._builtin_sources.append(GitStatusSource(git))
|
|
796
|
+
if workspace:
|
|
797
|
+
self._builtin_sources.append(ActiveFileSource(workspace))
|
|
798
|
+
self._builtin_sources.append(UserIntentSource())
|
|
799
|
+
|
|
800
|
+
# Extra (user-registered) sources — RAG extension point.
|
|
801
|
+
self._extra_sources: list[Any] = []
|
|
802
|
+
|
|
803
|
+
# ------------------------------------------------------------------
|
|
804
|
+
# Public API
|
|
805
|
+
# ------------------------------------------------------------------
|
|
806
|
+
|
|
807
|
+
async def register_source(self, source: ContextSource) -> None:
|
|
808
|
+
"""Register an additional :class:`ContextSource` at runtime.
|
|
809
|
+
|
|
810
|
+
The source participates in every subsequent :meth:`build` call.
|
|
811
|
+
Useful for plugging in RAG retrieval, vector databases, or any other
|
|
812
|
+
external context provider.
|
|
813
|
+
|
|
814
|
+
Args:
|
|
815
|
+
source: Any object satisfying the :class:`ContextSource` protocol.
|
|
816
|
+
"""
|
|
817
|
+
self._extra_sources.append(source)
|
|
818
|
+
|
|
819
|
+
async def build(
|
|
820
|
+
self,
|
|
821
|
+
request: str,
|
|
822
|
+
*,
|
|
823
|
+
active_file: str | None = None,
|
|
824
|
+
) -> BuiltContext:
|
|
825
|
+
"""Build ranked, compressed context for *request*.
|
|
826
|
+
|
|
827
|
+
Results are cached for ``cache_ttl`` seconds. Pass ``active_file`` to
|
|
828
|
+
inject the currently open IDE file.
|
|
829
|
+
|
|
830
|
+
Args:
|
|
831
|
+
request: The raw user prompt / question.
|
|
832
|
+
active_file: Optional path to the IDE's active file.
|
|
833
|
+
|
|
834
|
+
Returns:
|
|
835
|
+
A :class:`BuiltContext` ready for LLM consumption.
|
|
836
|
+
"""
|
|
837
|
+
cache_key = _cache_key(request, active_file)
|
|
838
|
+
if self._cache:
|
|
839
|
+
cached = await self._cache.get(cache_key)
|
|
840
|
+
if cached is not None:
|
|
841
|
+
return cached
|
|
842
|
+
|
|
843
|
+
start = time.monotonic()
|
|
844
|
+
items = await self._gather_all(request, active_file=active_file)
|
|
845
|
+
ranked = self._ranker.rank(items, request)
|
|
846
|
+
fitted, was_compressed = await self._compressor.compress(ranked)
|
|
847
|
+
total_tokens = sum(i.token_estimate for i in fitted)
|
|
848
|
+
result = BuiltContext(
|
|
849
|
+
items=fitted,
|
|
850
|
+
total_tokens=total_tokens,
|
|
851
|
+
was_compressed=was_compressed,
|
|
852
|
+
build_time_ms=round((time.monotonic() - start) * 1000, 2),
|
|
853
|
+
compression_strategy=self._compressor.name,
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
if self._cache:
|
|
857
|
+
await self._cache.set(cache_key, result)
|
|
858
|
+
|
|
859
|
+
return result
|
|
860
|
+
|
|
861
|
+
async def as_strings(
|
|
862
|
+
self,
|
|
863
|
+
request: str,
|
|
864
|
+
*,
|
|
865
|
+
active_file: str | None = None,
|
|
866
|
+
) -> list[str]:
|
|
867
|
+
"""Return assembled context as a plain ``list[str]``.
|
|
868
|
+
|
|
869
|
+
This is the primary integration point for :class:`AgentOrchestrator`,
|
|
870
|
+
:class:`AgentManager`, :class:`AutonomousLoop`, and
|
|
871
|
+
:class:`ProjectAgent`.
|
|
872
|
+
|
|
873
|
+
Args:
|
|
874
|
+
request: The raw user prompt / question.
|
|
875
|
+
active_file: Optional path to the IDE's active file.
|
|
876
|
+
|
|
877
|
+
Returns:
|
|
878
|
+
Ordered list of context strings (highest relevance first).
|
|
879
|
+
"""
|
|
880
|
+
built = await self.build(request, active_file=active_file)
|
|
881
|
+
return [item.content for item in built.items]
|
|
882
|
+
|
|
883
|
+
async def invalidate_cache(self, request: str, *, active_file: str | None = None) -> None:
|
|
884
|
+
"""Invalidate the cached context for the given request.
|
|
885
|
+
|
|
886
|
+
Call this between autonomous loop turns or after repository/Git state
|
|
887
|
+
changes to force a fresh context build on the next :meth:`build` call.
|
|
888
|
+
|
|
889
|
+
Args:
|
|
890
|
+
request: The prompt whose cache entry should be evicted.
|
|
891
|
+
active_file: Active file used in the original ``build()`` call.
|
|
892
|
+
"""
|
|
893
|
+
if self._cache:
|
|
894
|
+
await self._cache.invalidate(_cache_key(request, active_file))
|
|
895
|
+
|
|
896
|
+
async def clear_cache(self) -> None:
|
|
897
|
+
"""Evict all cached context entries.
|
|
898
|
+
|
|
899
|
+
Use when you know the workspace state has changed broadly (e.g. after
|
|
900
|
+
a large Git rebase or file system restructure).
|
|
901
|
+
"""
|
|
902
|
+
if self._cache:
|
|
903
|
+
await self._cache.clear()
|
|
904
|
+
|
|
905
|
+
def stats(self) -> ContextStats:
|
|
906
|
+
"""Return a diagnostic snapshot of this :class:`ContextManager`.
|
|
907
|
+
|
|
908
|
+
Returns:
|
|
909
|
+
A :class:`ContextStats` dataclass with source counts, cache status,
|
|
910
|
+
token budget, and active compression strategy.
|
|
911
|
+
"""
|
|
912
|
+
return ContextStats(
|
|
913
|
+
builtin_source_count=len(self._builtin_sources),
|
|
914
|
+
extra_source_count=len(self._extra_sources),
|
|
915
|
+
cache_enabled=self._cache is not None,
|
|
916
|
+
max_tokens=self._max_tokens,
|
|
917
|
+
compression_strategy=self._compressor.name,
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
# ------------------------------------------------------------------
|
|
921
|
+
# Internal helpers
|
|
922
|
+
# ------------------------------------------------------------------
|
|
923
|
+
|
|
924
|
+
async def _gather_all(
|
|
925
|
+
self, request: str, *, active_file: str | None = None
|
|
926
|
+
) -> list[ContextItem]:
|
|
927
|
+
"""Gather items from all sources concurrently."""
|
|
928
|
+
all_sources = [*self._builtin_sources, *self._extra_sources]
|
|
929
|
+
|
|
930
|
+
async def _gather_one(source: Any) -> list[ContextItem]:
|
|
931
|
+
try:
|
|
932
|
+
# ActiveFileSource.gather() accepts an optional active_file kwarg.
|
|
933
|
+
if isinstance(source, ActiveFileSource):
|
|
934
|
+
return await source.gather(request, active_file=active_file)
|
|
935
|
+
return await source.gather(request)
|
|
936
|
+
# Intentionally broad to isolate execution boundaries and prevent crashes.
|
|
937
|
+
except Exception: # noqa: BLE001
|
|
938
|
+
# Never let a single failing source break the whole build.
|
|
939
|
+
return []
|
|
940
|
+
|
|
941
|
+
results: list[list[ContextItem]] = await asyncio.gather(
|
|
942
|
+
*(_gather_one(src) for src in all_sources)
|
|
943
|
+
)
|
|
944
|
+
# Flatten while preserving source order.
|
|
945
|
+
return [item for batch in results for item in batch]
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
# ---------------------------------------------------------------------------
|
|
949
|
+
# Helpers
|
|
950
|
+
# ---------------------------------------------------------------------------
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _estimate_tokens(text: str) -> int:
|
|
954
|
+
"""Estimate token count using the rule-of-thumb: 1 token ≈ 4 characters."""
|
|
955
|
+
return max(1, len(text) // 4)
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _cache_key(request: str, active_file: str | None) -> str:
|
|
959
|
+
raw = f"{request}|{active_file or ''}"
|
|
960
|
+
return hashlib.sha256(raw.encode()).hexdigest()
|