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/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()