devscontext 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.
- devscontext/__init__.py +3 -0
- devscontext/adapters/__init__.py +23 -0
- devscontext/adapters/base.py +105 -0
- devscontext/adapters/fireflies.py +585 -0
- devscontext/adapters/gmail.py +580 -0
- devscontext/adapters/jira.py +639 -0
- devscontext/adapters/local_docs.py +984 -0
- devscontext/adapters/slack.py +804 -0
- devscontext/agents/__init__.py +28 -0
- devscontext/agents/preprocessor.py +775 -0
- devscontext/agents/watcher.py +265 -0
- devscontext/cache.py +151 -0
- devscontext/cli.py +727 -0
- devscontext/config.py +264 -0
- devscontext/constants.py +107 -0
- devscontext/core.py +582 -0
- devscontext/exceptions.py +148 -0
- devscontext/logging.py +181 -0
- devscontext/models.py +504 -0
- devscontext/plugins/__init__.py +49 -0
- devscontext/plugins/base.py +321 -0
- devscontext/plugins/registry.py +544 -0
- devscontext/py.typed +0 -0
- devscontext/rag/__init__.py +113 -0
- devscontext/rag/embeddings.py +296 -0
- devscontext/rag/index.py +323 -0
- devscontext/server.py +374 -0
- devscontext/storage.py +321 -0
- devscontext/synthesis.py +1057 -0
- devscontext/utils.py +297 -0
- devscontext-0.1.0.dist-info/METADATA +253 -0
- devscontext-0.1.0.dist-info/RECORD +35 -0
- devscontext-0.1.0.dist-info/WHEEL +4 -0
- devscontext-0.1.0.dist-info/entry_points.txt +2 -0
- devscontext-0.1.0.dist-info/licenses/LICENSE +21 -0
devscontext/core.py
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
"""Core orchestration logic for DevsContext.
|
|
2
|
+
|
|
3
|
+
This module contains the DevsContextCore class which coordinates
|
|
4
|
+
fetching context from multiple adapters, synthesizing the results
|
|
5
|
+
with an LLM, and caching for performance.
|
|
6
|
+
|
|
7
|
+
Uses the plugin registry for adapter and synthesis plugin management,
|
|
8
|
+
supporting the primary/secondary source fetch strategy.
|
|
9
|
+
|
|
10
|
+
Example:
|
|
11
|
+
config = DevsContextConfig(
|
|
12
|
+
sources=SourcesConfig(
|
|
13
|
+
jira=JiraConfig(enabled=True, base_url="https://...", primary=True),
|
|
14
|
+
docs=DocsConfig(enabled=True, primary=False),
|
|
15
|
+
),
|
|
16
|
+
synthesis=SynthesisConfig(provider="anthropic"),
|
|
17
|
+
)
|
|
18
|
+
core = DevsContextCore(config)
|
|
19
|
+
result = await core.get_task_context("PROJ-123")
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import time
|
|
26
|
+
from datetime import UTC, datetime
|
|
27
|
+
from typing import TYPE_CHECKING
|
|
28
|
+
|
|
29
|
+
from devscontext.cache import SimpleCache
|
|
30
|
+
from devscontext.logging import get_logger
|
|
31
|
+
from devscontext.models import DocsContext, JiraContext, JiraTicket, MeetingContext, TaskContext
|
|
32
|
+
from devscontext.plugins.base import SourceContext
|
|
33
|
+
from devscontext.plugins.registry import PluginRegistry
|
|
34
|
+
from devscontext.storage import PrebuiltContextStorage
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from devscontext.models import DevsContextConfig, PrebuiltContext
|
|
38
|
+
|
|
39
|
+
logger = get_logger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DevsContextCore:
|
|
43
|
+
"""Core orchestration for fetching and synthesizing engineering context.
|
|
44
|
+
|
|
45
|
+
This class coordinates:
|
|
46
|
+
- Fetching context from adapters using the plugin registry
|
|
47
|
+
- Primary adapters are fetched first (e.g., Jira)
|
|
48
|
+
- Secondary adapters are fetched in parallel using primary context
|
|
49
|
+
- Synthesizing raw context into structured markdown via synthesis plugins
|
|
50
|
+
- Caching results to avoid redundant API calls
|
|
51
|
+
|
|
52
|
+
Uses PluginRegistry for managing adapters and synthesis plugins.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, config: DevsContextConfig) -> None:
|
|
56
|
+
"""Initialize the core with configuration.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
config: DevsContextConfig containing sources, synthesis, and cache settings.
|
|
60
|
+
"""
|
|
61
|
+
self._config = config
|
|
62
|
+
|
|
63
|
+
# Initialize cache if enabled
|
|
64
|
+
self._cache: SimpleCache | None = None
|
|
65
|
+
if config.cache.enabled:
|
|
66
|
+
self._cache = SimpleCache(
|
|
67
|
+
ttl=config.cache.ttl_seconds,
|
|
68
|
+
max_size=config.cache.max_size,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Initialize pre-built context storage if configured
|
|
72
|
+
self._storage: PrebuiltContextStorage | None = None
|
|
73
|
+
if config.storage.path:
|
|
74
|
+
self._storage = PrebuiltContextStorage(config.storage.path)
|
|
75
|
+
self._storage_initialized = False
|
|
76
|
+
else:
|
|
77
|
+
self._storage_initialized = True # No storage to initialize
|
|
78
|
+
|
|
79
|
+
# Initialize plugin registry
|
|
80
|
+
self._registry = PluginRegistry()
|
|
81
|
+
self._registry.register_builtin_plugins()
|
|
82
|
+
self._registry.discover_plugins()
|
|
83
|
+
self._registry.load_from_config(config)
|
|
84
|
+
|
|
85
|
+
synthesis = self._registry.get_synthesis()
|
|
86
|
+
logger.info(
|
|
87
|
+
"DevsContextCore initialized",
|
|
88
|
+
extra={
|
|
89
|
+
"cache_enabled": config.cache.enabled,
|
|
90
|
+
"adapters_loaded": list(self._registry.get_active_adapters().keys()),
|
|
91
|
+
"synthesis_plugin": synthesis.name if synthesis else None,
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
async def get_task_context(
|
|
96
|
+
self,
|
|
97
|
+
task_id: str,
|
|
98
|
+
*,
|
|
99
|
+
use_cache: bool = True,
|
|
100
|
+
) -> TaskContext:
|
|
101
|
+
"""Get aggregated and synthesized context for a task.
|
|
102
|
+
|
|
103
|
+
Fetches context from all configured adapters in parallel,
|
|
104
|
+
then uses the LLM to synthesize into structured markdown.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
task_id: The task identifier (e.g., Jira ticket ID).
|
|
108
|
+
use_cache: Whether to use cached results.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
TaskContext with synthesized markdown and metadata.
|
|
112
|
+
"""
|
|
113
|
+
start_time = time.monotonic()
|
|
114
|
+
cache_key = f"context:{task_id}"
|
|
115
|
+
|
|
116
|
+
# Check pre-built context storage first (instant return with rich context)
|
|
117
|
+
if use_cache and self._storage is not None:
|
|
118
|
+
prebuilt = await self._get_prebuilt_context(task_id)
|
|
119
|
+
if prebuilt is not None and not prebuilt.is_expired():
|
|
120
|
+
logger.info(
|
|
121
|
+
"Pre-built context hit",
|
|
122
|
+
extra={
|
|
123
|
+
"task_id": task_id,
|
|
124
|
+
"quality_score": prebuilt.context_quality_score,
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
return TaskContext(
|
|
128
|
+
task_id=task_id,
|
|
129
|
+
synthesized=prebuilt.synthesized,
|
|
130
|
+
sources_used=prebuilt.sources_used,
|
|
131
|
+
fetch_duration_ms=0,
|
|
132
|
+
synthesized_at=prebuilt.built_at,
|
|
133
|
+
cached=True,
|
|
134
|
+
prebuilt=True,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Check in-memory cache
|
|
138
|
+
if use_cache and self._cache is not None:
|
|
139
|
+
cached = self._cache.get(cache_key)
|
|
140
|
+
if cached is not None:
|
|
141
|
+
logger.info("Cache hit for task context", extra={"task_id": task_id})
|
|
142
|
+
# Return a copy with cached=True
|
|
143
|
+
if isinstance(cached, TaskContext):
|
|
144
|
+
return TaskContext(
|
|
145
|
+
task_id=cached.task_id,
|
|
146
|
+
synthesized=cached.synthesized,
|
|
147
|
+
sources_used=cached.sources_used,
|
|
148
|
+
fetch_duration_ms=cached.fetch_duration_ms,
|
|
149
|
+
synthesized_at=cached.synthesized_at,
|
|
150
|
+
cached=True,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Fetch context from adapters using new plugin interface
|
|
154
|
+
source_contexts = await self._fetch_all_context(task_id)
|
|
155
|
+
|
|
156
|
+
# Build sources list from source contexts
|
|
157
|
+
sources_used: list[str] = []
|
|
158
|
+
for name, ctx in source_contexts.items():
|
|
159
|
+
if ctx.is_empty():
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
if name == "jira" and isinstance(ctx.data, JiraContext):
|
|
163
|
+
sources_used.append(f"jira:{ctx.data.ticket.ticket_id}")
|
|
164
|
+
elif name == "fireflies" and isinstance(ctx.data, MeetingContext):
|
|
165
|
+
sources_used.extend(
|
|
166
|
+
f"fireflies:{m.meeting_date.strftime('%Y-%m-%d')}" for m in ctx.data.meetings
|
|
167
|
+
)
|
|
168
|
+
elif name == "local_docs" and isinstance(ctx.data, DocsContext):
|
|
169
|
+
sources_used.extend(f"docs:{s.file_path}" for s in ctx.data.sections)
|
|
170
|
+
|
|
171
|
+
# Synthesize using plugin interface
|
|
172
|
+
synthesis = self._registry.get_synthesis()
|
|
173
|
+
if synthesis is None:
|
|
174
|
+
synthesized = f"## Task: {task_id}\n\nNo synthesis plugin configured."
|
|
175
|
+
else:
|
|
176
|
+
synthesized = await synthesis.synthesize(
|
|
177
|
+
task_id=task_id,
|
|
178
|
+
source_contexts=source_contexts,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
duration_ms = int((time.monotonic() - start_time) * 1000)
|
|
182
|
+
|
|
183
|
+
logger.info(
|
|
184
|
+
"Task context fetched and synthesized",
|
|
185
|
+
extra={
|
|
186
|
+
"task_id": task_id,
|
|
187
|
+
"source_count": len(sources_used),
|
|
188
|
+
"duration_ms": duration_ms,
|
|
189
|
+
},
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
result = TaskContext(
|
|
193
|
+
task_id=task_id,
|
|
194
|
+
synthesized=synthesized,
|
|
195
|
+
sources_used=sources_used,
|
|
196
|
+
fetch_duration_ms=duration_ms,
|
|
197
|
+
synthesized_at=datetime.now(UTC),
|
|
198
|
+
cached=False,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Store in cache
|
|
202
|
+
if use_cache and self._cache is not None:
|
|
203
|
+
self._cache.set(cache_key, result)
|
|
204
|
+
|
|
205
|
+
return result
|
|
206
|
+
|
|
207
|
+
async def _fetch_all_context(
|
|
208
|
+
self,
|
|
209
|
+
task_id: str,
|
|
210
|
+
) -> dict[str, SourceContext]:
|
|
211
|
+
"""Fetch context from all adapters using the plugin interface.
|
|
212
|
+
|
|
213
|
+
Uses two-phase fetch strategy:
|
|
214
|
+
1. Fetch from primary adapters first (typically Jira)
|
|
215
|
+
2. Fetch from secondary adapters in parallel, passing primary context
|
|
216
|
+
|
|
217
|
+
This allows secondary adapters (docs, meetings) to use ticket data
|
|
218
|
+
for better context matching (components, labels, keywords).
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
task_id: The task identifier.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
Dict mapping adapter names to their SourceContext.
|
|
225
|
+
"""
|
|
226
|
+
source_contexts: dict[str, SourceContext] = {}
|
|
227
|
+
|
|
228
|
+
# Phase 1: Fetch from primary adapters (typically Jira)
|
|
229
|
+
# Primary adapters are fetched first, their context is shared with secondary
|
|
230
|
+
ticket: JiraTicket | None = None
|
|
231
|
+
primary_adapters = self._registry.get_primary_adapters()
|
|
232
|
+
|
|
233
|
+
for name, adapter in primary_adapters.items():
|
|
234
|
+
try:
|
|
235
|
+
ctx = await adapter.fetch_task_context(task_id)
|
|
236
|
+
source_contexts[name] = ctx
|
|
237
|
+
# Extract Jira ticket if available for secondary adapters
|
|
238
|
+
if isinstance(ctx.data, JiraContext):
|
|
239
|
+
ticket = ctx.data.ticket
|
|
240
|
+
except Exception as e:
|
|
241
|
+
logger.warning(
|
|
242
|
+
f"Primary adapter {name} fetch failed",
|
|
243
|
+
extra={"error": str(e), "task_id": task_id},
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Phase 2: Fetch from secondary adapters in parallel
|
|
247
|
+
# Pass ticket data for context-aware matching
|
|
248
|
+
secondary_adapters = self._registry.get_secondary_adapters()
|
|
249
|
+
|
|
250
|
+
if secondary_adapters:
|
|
251
|
+
coros = []
|
|
252
|
+
adapter_names = []
|
|
253
|
+
|
|
254
|
+
for name, adapter in secondary_adapters.items():
|
|
255
|
+
coros.append(adapter.fetch_task_context(task_id, ticket))
|
|
256
|
+
adapter_names.append(name)
|
|
257
|
+
|
|
258
|
+
results = await asyncio.gather(*coros, return_exceptions=True)
|
|
259
|
+
|
|
260
|
+
for name, result in zip(adapter_names, results, strict=True):
|
|
261
|
+
if isinstance(result, BaseException):
|
|
262
|
+
logger.warning(
|
|
263
|
+
f"Secondary adapter {name} fetch failed",
|
|
264
|
+
extra={"error": str(result), "task_id": task_id},
|
|
265
|
+
)
|
|
266
|
+
elif isinstance(result, SourceContext):
|
|
267
|
+
source_contexts[name] = result
|
|
268
|
+
|
|
269
|
+
return source_contexts
|
|
270
|
+
|
|
271
|
+
async def health_check(self) -> dict[str, bool]:
|
|
272
|
+
"""Check health of all configured adapters.
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
Dictionary mapping adapter names to health status.
|
|
276
|
+
"""
|
|
277
|
+
results = await self._registry.health_check_all()
|
|
278
|
+
logger.info("Health check completed", extra={"adapters": results})
|
|
279
|
+
return results
|
|
280
|
+
|
|
281
|
+
def invalidate_cache(self, task_id: str | None = None) -> None:
|
|
282
|
+
"""Invalidate cached context.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
task_id: Specific task to invalidate, or None to clear all.
|
|
286
|
+
"""
|
|
287
|
+
if self._cache is None:
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
if task_id:
|
|
291
|
+
self._cache.invalidate(f"context:{task_id}")
|
|
292
|
+
logger.debug("Cache invalidated", extra={"task_id": task_id})
|
|
293
|
+
else:
|
|
294
|
+
self._cache.clear()
|
|
295
|
+
logger.debug("Cache cleared")
|
|
296
|
+
|
|
297
|
+
async def search_context(self, query: str) -> dict[str, str | list[str] | int]:
|
|
298
|
+
"""Search across all sources by keyword.
|
|
299
|
+
|
|
300
|
+
Searches Jira, meetings, and local docs in parallel for freeform queries
|
|
301
|
+
like "how do we handle retries?" or "what was decided about payments?".
|
|
302
|
+
|
|
303
|
+
No LLM synthesis - returns formatted search results directly.
|
|
304
|
+
No caching - queries are too varied.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
query: The search query.
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
Dictionary with formatted results and metadata.
|
|
311
|
+
"""
|
|
312
|
+
start_time = time.monotonic()
|
|
313
|
+
logger.info("Search context", extra={"query": query})
|
|
314
|
+
|
|
315
|
+
# Search all sources in parallel
|
|
316
|
+
jira_coro = self._search_jira(query)
|
|
317
|
+
meetings_coro = self._search_meetings(query)
|
|
318
|
+
docs_coro = self._search_docs(query)
|
|
319
|
+
|
|
320
|
+
results = await asyncio.gather(jira_coro, meetings_coro, docs_coro, return_exceptions=True)
|
|
321
|
+
|
|
322
|
+
jira_results, meeting_results, docs_results = results
|
|
323
|
+
|
|
324
|
+
# Format results into markdown
|
|
325
|
+
sections: list[str] = [f'## Search Results for "{query}"']
|
|
326
|
+
sources_used: list[str] = []
|
|
327
|
+
total_results = 0
|
|
328
|
+
|
|
329
|
+
# Format Jira results
|
|
330
|
+
if isinstance(jira_results, list) and jira_results:
|
|
331
|
+
sources_used.append("jira")
|
|
332
|
+
total_results += len(jira_results)
|
|
333
|
+
sections.append(self._format_jira_search_results(jira_results))
|
|
334
|
+
elif isinstance(jira_results, BaseException):
|
|
335
|
+
logger.warning("Jira search failed", extra={"error": str(jira_results)})
|
|
336
|
+
|
|
337
|
+
# Format meeting results
|
|
338
|
+
if isinstance(meeting_results, MeetingContext) and meeting_results.meetings:
|
|
339
|
+
sources_used.append("fireflies")
|
|
340
|
+
total_results += len(meeting_results.meetings)
|
|
341
|
+
sections.append(self._format_meeting_search_results(meeting_results))
|
|
342
|
+
elif isinstance(meeting_results, BaseException):
|
|
343
|
+
logger.warning("Meeting search failed", extra={"error": str(meeting_results)})
|
|
344
|
+
|
|
345
|
+
# Format docs results
|
|
346
|
+
if isinstance(docs_results, DocsContext) and docs_results.sections:
|
|
347
|
+
sources_used.append("docs")
|
|
348
|
+
total_results += len(docs_results.sections)
|
|
349
|
+
sections.append(self._format_docs_search_results(docs_results))
|
|
350
|
+
elif isinstance(docs_results, BaseException):
|
|
351
|
+
logger.warning("Docs search failed", extra={"error": str(docs_results)})
|
|
352
|
+
|
|
353
|
+
# Handle no results
|
|
354
|
+
if total_results == 0:
|
|
355
|
+
sections.append("\nNo results found.")
|
|
356
|
+
|
|
357
|
+
duration_ms = int((time.monotonic() - start_time) * 1000)
|
|
358
|
+
|
|
359
|
+
logger.info(
|
|
360
|
+
"Search completed",
|
|
361
|
+
extra={
|
|
362
|
+
"query": query,
|
|
363
|
+
"result_count": total_results,
|
|
364
|
+
"sources": sources_used,
|
|
365
|
+
"duration_ms": duration_ms,
|
|
366
|
+
},
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
"query": query,
|
|
371
|
+
"results": "\n\n".join(sections),
|
|
372
|
+
"sources": sources_used if sources_used else ["none"],
|
|
373
|
+
"result_count": total_results,
|
|
374
|
+
"duration_ms": duration_ms,
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async def _search_jira(self, query: str) -> list[JiraTicket]:
|
|
378
|
+
"""Search Jira for matching issues."""
|
|
379
|
+
jira = self._registry.get_adapter("jira")
|
|
380
|
+
if jira is None:
|
|
381
|
+
return []
|
|
382
|
+
return await jira.search_issues(query, max_results=5) # type: ignore[attr-defined,no-any-return]
|
|
383
|
+
|
|
384
|
+
async def _search_meetings(self, query: str) -> MeetingContext:
|
|
385
|
+
"""Search meeting transcripts for query."""
|
|
386
|
+
fireflies = self._registry.get_adapter("fireflies")
|
|
387
|
+
if fireflies is None:
|
|
388
|
+
return MeetingContext(meetings=[])
|
|
389
|
+
return await fireflies.get_meeting_context(query) # type: ignore[attr-defined,no-any-return]
|
|
390
|
+
|
|
391
|
+
async def _search_docs(self, query: str) -> DocsContext:
|
|
392
|
+
"""Search local documentation for query."""
|
|
393
|
+
docs = self._registry.get_adapter("local_docs")
|
|
394
|
+
if docs is None:
|
|
395
|
+
return DocsContext(sections=[])
|
|
396
|
+
return await docs.search_docs(query, max_results=5) # type: ignore[attr-defined,no-any-return]
|
|
397
|
+
|
|
398
|
+
def _format_jira_search_results(self, tickets: list[JiraTicket]) -> str:
|
|
399
|
+
"""Format Jira search results as markdown."""
|
|
400
|
+
parts = ["### Jira Tickets"]
|
|
401
|
+
for ticket in tickets:
|
|
402
|
+
status_badge = f"[{ticket.status}]"
|
|
403
|
+
assignee = f" — {ticket.assignee}" if ticket.assignee else ""
|
|
404
|
+
parts.append(f"- **{ticket.ticket_id}**: {ticket.title} {status_badge}{assignee}")
|
|
405
|
+
return "\n".join(parts)
|
|
406
|
+
|
|
407
|
+
def _format_meeting_search_results(self, context: MeetingContext) -> str:
|
|
408
|
+
"""Format meeting search results as markdown."""
|
|
409
|
+
parts = ["### Meeting Discussions"]
|
|
410
|
+
for meeting in context.meetings:
|
|
411
|
+
date_str = meeting.meeting_date.strftime("%Y-%m-%d")
|
|
412
|
+
parts.append(f"\n**{meeting.meeting_title}** ({date_str})")
|
|
413
|
+
# Truncate excerpt for search results
|
|
414
|
+
excerpt = meeting.excerpt
|
|
415
|
+
if len(excerpt) > 300:
|
|
416
|
+
excerpt = excerpt[:300] + "..."
|
|
417
|
+
parts.append(excerpt)
|
|
418
|
+
return "\n".join(parts)
|
|
419
|
+
|
|
420
|
+
def _format_docs_search_results(self, context: DocsContext) -> str:
|
|
421
|
+
"""Format docs search results as markdown."""
|
|
422
|
+
parts = ["### Documentation"]
|
|
423
|
+
for section in context.sections:
|
|
424
|
+
title = section.section_title or section.file_path
|
|
425
|
+
doc_type = f"[{section.doc_type}]"
|
|
426
|
+
parts.append(f"\n**{title}** {doc_type}")
|
|
427
|
+
parts.append(f"*Source: {section.file_path}*")
|
|
428
|
+
# Truncate content for search results
|
|
429
|
+
content = section.content
|
|
430
|
+
if len(content) > 200:
|
|
431
|
+
content = content[:200] + "..."
|
|
432
|
+
parts.append(content)
|
|
433
|
+
return "\n".join(parts)
|
|
434
|
+
|
|
435
|
+
async def get_standards(
|
|
436
|
+
self, area: str | None = None
|
|
437
|
+
) -> dict[str, str | None | int | list[str]]:
|
|
438
|
+
"""Get coding standards from local documentation.
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
area: Optional area to filter (e.g., 'typescript', 'testing').
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
Dictionary containing standards content and metadata.
|
|
445
|
+
"""
|
|
446
|
+
start_time = time.monotonic()
|
|
447
|
+
logger.info("Get standards", extra={"area": area})
|
|
448
|
+
|
|
449
|
+
docs = self._registry.get_adapter("local_docs")
|
|
450
|
+
available_areas: list[str] = []
|
|
451
|
+
|
|
452
|
+
if docs is None:
|
|
453
|
+
content = self._get_standards_not_configured_message()
|
|
454
|
+
section_count = 0
|
|
455
|
+
else:
|
|
456
|
+
docs_context = await docs.get_standards(area) # type: ignore[attr-defined]
|
|
457
|
+
available_areas = await docs.list_standards_areas() # type: ignore[attr-defined]
|
|
458
|
+
|
|
459
|
+
if docs_context.sections:
|
|
460
|
+
# Format sections into markdown with header
|
|
461
|
+
title = f"# Coding Standards: {area}" if area else "# Coding Standards"
|
|
462
|
+
parts: list[str] = [title, ""]
|
|
463
|
+
|
|
464
|
+
for section in docs_context.sections:
|
|
465
|
+
# Add source file info
|
|
466
|
+
source_info = f"*Source: {section.file_path}*"
|
|
467
|
+
if section.section_title:
|
|
468
|
+
parts.append(f"## {section.section_title}")
|
|
469
|
+
parts.append(source_info)
|
|
470
|
+
parts.append("")
|
|
471
|
+
parts.append(section.content)
|
|
472
|
+
else:
|
|
473
|
+
parts.append(source_info)
|
|
474
|
+
parts.append("")
|
|
475
|
+
parts.append(section.content)
|
|
476
|
+
parts.append("") # Blank line between sections
|
|
477
|
+
|
|
478
|
+
content = "\n".join(parts)
|
|
479
|
+
section_count = len(docs_context.sections)
|
|
480
|
+
elif area and available_areas:
|
|
481
|
+
# Area specified but no matches - show available areas
|
|
482
|
+
content = self._get_no_matching_standards_message(area, available_areas)
|
|
483
|
+
section_count = 0
|
|
484
|
+
else:
|
|
485
|
+
content = self._get_standards_not_configured_message()
|
|
486
|
+
section_count = 0
|
|
487
|
+
|
|
488
|
+
duration_ms = int((time.monotonic() - start_time) * 1000)
|
|
489
|
+
logger.info(
|
|
490
|
+
"Get standards completed",
|
|
491
|
+
extra={"area": area, "duration_ms": duration_ms, "section_count": section_count},
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
return {
|
|
495
|
+
"area": area,
|
|
496
|
+
"content": content,
|
|
497
|
+
"section_count": section_count,
|
|
498
|
+
"available_areas": available_areas,
|
|
499
|
+
"duration_ms": duration_ms,
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
def _get_standards_not_configured_message(self) -> str:
|
|
503
|
+
"""Generate message when no standards are configured."""
|
|
504
|
+
return """# Coding Standards
|
|
505
|
+
|
|
506
|
+
No standards documents found.
|
|
507
|
+
|
|
508
|
+
## How to Configure
|
|
509
|
+
|
|
510
|
+
1. Create markdown files in your docs directory
|
|
511
|
+
2. Configure `sources.docs.paths` in `.devscontext.yaml`
|
|
512
|
+
3. Place files in a `standards/` subdirectory
|
|
513
|
+
|
|
514
|
+
### Example Structure
|
|
515
|
+
|
|
516
|
+
```
|
|
517
|
+
docs/
|
|
518
|
+
standards/
|
|
519
|
+
typescript.md
|
|
520
|
+
testing.md
|
|
521
|
+
api-design.md
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
### Example .devscontext.yaml
|
|
525
|
+
|
|
526
|
+
```yaml
|
|
527
|
+
sources:
|
|
528
|
+
docs:
|
|
529
|
+
enabled: true
|
|
530
|
+
paths:
|
|
531
|
+
- ./docs
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
Files in the `standards/` directory will be automatically recognized as coding standards.
|
|
535
|
+
"""
|
|
536
|
+
|
|
537
|
+
def _get_no_matching_standards_message(self, area: str, available_areas: list[str]) -> str:
|
|
538
|
+
"""Generate message when no standards match the requested area."""
|
|
539
|
+
areas_list = "\n".join(f"- `{a}`" for a in available_areas)
|
|
540
|
+
return f"""# Coding Standards: {area}
|
|
541
|
+
|
|
542
|
+
No standards found for "{area}".
|
|
543
|
+
|
|
544
|
+
## Available Areas
|
|
545
|
+
|
|
546
|
+
{areas_list}
|
|
547
|
+
|
|
548
|
+
Try one of the areas above, or omit the area parameter to see all standards.
|
|
549
|
+
"""
|
|
550
|
+
|
|
551
|
+
async def _get_prebuilt_context(self, task_id: str) -> PrebuiltContext | None:
|
|
552
|
+
"""Get pre-built context from storage, initializing if needed.
|
|
553
|
+
|
|
554
|
+
Args:
|
|
555
|
+
task_id: Task identifier.
|
|
556
|
+
|
|
557
|
+
Returns:
|
|
558
|
+
PrebuiltContext if found, None otherwise.
|
|
559
|
+
"""
|
|
560
|
+
if self._storage is None:
|
|
561
|
+
return None
|
|
562
|
+
|
|
563
|
+
try:
|
|
564
|
+
# Initialize storage on first access
|
|
565
|
+
if not self._storage_initialized:
|
|
566
|
+
await self._storage.initialize()
|
|
567
|
+
self._storage_initialized = True
|
|
568
|
+
|
|
569
|
+
return await self._storage.get(task_id)
|
|
570
|
+
|
|
571
|
+
except Exception as e:
|
|
572
|
+
logger.warning(
|
|
573
|
+
"Failed to get pre-built context",
|
|
574
|
+
extra={"task_id": task_id, "error": str(e)},
|
|
575
|
+
)
|
|
576
|
+
return None
|
|
577
|
+
|
|
578
|
+
async def close(self) -> None:
|
|
579
|
+
"""Close all adapter connections and clean up resources."""
|
|
580
|
+
await self._registry.close_all()
|
|
581
|
+
if self._storage is not None:
|
|
582
|
+
await self._storage.close()
|