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/server.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""MCP server for DevsContext.
|
|
2
|
+
|
|
3
|
+
This module provides the Model Context Protocol (MCP) server implementation
|
|
4
|
+
that exposes DevsContext functionality as MCP tools.
|
|
5
|
+
|
|
6
|
+
The server runs over stdio transport and provides the following tools:
|
|
7
|
+
- get_task_context: Get synthesized context for a task
|
|
8
|
+
- search_context: Search across all configured sources
|
|
9
|
+
- get_standards: Get coding standards documentation
|
|
10
|
+
|
|
11
|
+
Example:
|
|
12
|
+
Run as MCP server:
|
|
13
|
+
devscontext serve
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import time
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from mcp.server import Server
|
|
23
|
+
from mcp.server.stdio import stdio_server
|
|
24
|
+
from mcp.types import TextContent, Tool
|
|
25
|
+
|
|
26
|
+
from devscontext.config import load_devscontext_config
|
|
27
|
+
from devscontext.core import DevsContextCore
|
|
28
|
+
from devscontext.logging import get_logger
|
|
29
|
+
|
|
30
|
+
logger = get_logger(__name__)
|
|
31
|
+
|
|
32
|
+
# Initialize the MCP server
|
|
33
|
+
server = Server("devscontext")
|
|
34
|
+
|
|
35
|
+
# Global core instance (initialized on startup)
|
|
36
|
+
_core: DevsContextCore | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_core() -> DevsContextCore:
|
|
40
|
+
"""Get or create the global DevsContextCore instance.
|
|
41
|
+
|
|
42
|
+
Lazily initializes the core on first access using
|
|
43
|
+
the configuration loaded from the config file.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The singleton DevsContextCore instance.
|
|
47
|
+
"""
|
|
48
|
+
global _core
|
|
49
|
+
if _core is None:
|
|
50
|
+
config = load_devscontext_config()
|
|
51
|
+
_core = DevsContextCore(config)
|
|
52
|
+
logger.info("DevsContextCore initialized")
|
|
53
|
+
return _core
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@server.list_tools() # type: ignore[no-untyped-call, untyped-decorator]
|
|
57
|
+
async def list_tools() -> list[Tool]:
|
|
58
|
+
"""List available MCP tools.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
List of Tool definitions for get_task_context, search_context,
|
|
62
|
+
and get_standards.
|
|
63
|
+
"""
|
|
64
|
+
return [
|
|
65
|
+
Tool(
|
|
66
|
+
name="get_task_context",
|
|
67
|
+
description=(
|
|
68
|
+
"Use this when starting work on a Jira ticket. "
|
|
69
|
+
"Fetches and synthesizes everything you need: ticket requirements, "
|
|
70
|
+
"acceptance criteria, discussion comments, related meeting transcripts, "
|
|
71
|
+
"architecture docs, ADRs, and applicable coding standards. "
|
|
72
|
+
"Call this FIRST when the user says 'work on PROJ-123' or 'start TICKET-456'."
|
|
73
|
+
),
|
|
74
|
+
inputSchema={
|
|
75
|
+
"type": "object",
|
|
76
|
+
"properties": {
|
|
77
|
+
"task_id": {
|
|
78
|
+
"type": "string",
|
|
79
|
+
"description": "Jira ticket ID (e.g., 'PROJ-123', 'TICKET-456')",
|
|
80
|
+
},
|
|
81
|
+
"refresh": {
|
|
82
|
+
"type": "boolean",
|
|
83
|
+
"description": "Force refresh, bypassing cache",
|
|
84
|
+
"default": False,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
"required": ["task_id"],
|
|
88
|
+
},
|
|
89
|
+
),
|
|
90
|
+
Tool(
|
|
91
|
+
name="search_context",
|
|
92
|
+
description=(
|
|
93
|
+
"Use this for freeform questions about the codebase, architecture, "
|
|
94
|
+
"or past decisions. Searches across Jira tickets, meeting transcripts, "
|
|
95
|
+
"and documentation. Use when the user asks questions like "
|
|
96
|
+
"'how do we handle errors?', 'what was decided about webhooks?', "
|
|
97
|
+
"or 'why did we choose SQS?'. Input is a natural language question."
|
|
98
|
+
),
|
|
99
|
+
inputSchema={
|
|
100
|
+
"type": "object",
|
|
101
|
+
"properties": {
|
|
102
|
+
"query": {
|
|
103
|
+
"type": "string",
|
|
104
|
+
"description": "Natural language question or search terms",
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
"required": ["query"],
|
|
108
|
+
},
|
|
109
|
+
),
|
|
110
|
+
Tool(
|
|
111
|
+
name="get_standards",
|
|
112
|
+
description=(
|
|
113
|
+
"Use this when checking coding conventions before or during implementation. "
|
|
114
|
+
"Returns coding standards, style guides, and best practices from local docs. "
|
|
115
|
+
"Filter by area: 'testing', 'error-handling', 'typescript', 'api', etc. "
|
|
116
|
+
"Use when the user asks 'what are our testing conventions?' or "
|
|
117
|
+
"'how should I handle errors?' or before writing significant code."
|
|
118
|
+
),
|
|
119
|
+
inputSchema={
|
|
120
|
+
"type": "object",
|
|
121
|
+
"properties": {
|
|
122
|
+
"area": {
|
|
123
|
+
"type": "string",
|
|
124
|
+
"description": (
|
|
125
|
+
"Filter by area: 'testing', 'typescript', 'error-handling', etc. "
|
|
126
|
+
"Omit to get all standards."
|
|
127
|
+
),
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
),
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@server.call_tool() # type: ignore[untyped-decorator]
|
|
136
|
+
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
|
|
137
|
+
"""Handle MCP tool calls.
|
|
138
|
+
|
|
139
|
+
Routes tool calls to the appropriate core method and formats
|
|
140
|
+
the response for the MCP client.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
name: The name of the tool to call.
|
|
144
|
+
arguments: The tool arguments as a dictionary.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
List containing a single TextContent with the tool result.
|
|
148
|
+
"""
|
|
149
|
+
start_time = time.monotonic()
|
|
150
|
+
core = get_core()
|
|
151
|
+
|
|
152
|
+
logger.info(
|
|
153
|
+
"Tool call received",
|
|
154
|
+
extra={"tool": name, "arguments": arguments},
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
if name == "get_task_context":
|
|
159
|
+
return await _handle_get_task_context(core, arguments, start_time)
|
|
160
|
+
|
|
161
|
+
elif name == "search_context":
|
|
162
|
+
return await _handle_search_context(core, arguments, start_time)
|
|
163
|
+
|
|
164
|
+
elif name == "get_standards":
|
|
165
|
+
return await _handle_get_standards(core, arguments, start_time)
|
|
166
|
+
|
|
167
|
+
else:
|
|
168
|
+
logger.warning("Unknown tool called", extra={"tool": name})
|
|
169
|
+
return [TextContent(type="text", text=f"Error: Unknown tool '{name}'")]
|
|
170
|
+
|
|
171
|
+
except Exception as e:
|
|
172
|
+
duration_ms = int((time.monotonic() - start_time) * 1000)
|
|
173
|
+
logger.exception(
|
|
174
|
+
"Tool call failed",
|
|
175
|
+
extra={"tool": name, "error": str(e), "duration_ms": duration_ms},
|
|
176
|
+
)
|
|
177
|
+
return [
|
|
178
|
+
TextContent(
|
|
179
|
+
type="text",
|
|
180
|
+
text=f"Error: An unexpected error occurred while processing '{name}': {e}",
|
|
181
|
+
)
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
async def _handle_get_task_context(
|
|
186
|
+
core: DevsContextCore,
|
|
187
|
+
arguments: dict[str, Any],
|
|
188
|
+
start_time: float,
|
|
189
|
+
) -> list[TextContent]:
|
|
190
|
+
"""Handle the get_task_context tool call.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
core: The DevsContextCore instance.
|
|
194
|
+
arguments: Tool arguments.
|
|
195
|
+
start_time: When the request started for duration logging.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
List containing TextContent with the synthesized context.
|
|
199
|
+
"""
|
|
200
|
+
task_id = arguments.get("task_id", "")
|
|
201
|
+
refresh = arguments.get("refresh", False)
|
|
202
|
+
|
|
203
|
+
if not task_id:
|
|
204
|
+
logger.warning("get_task_context called without task_id")
|
|
205
|
+
return [TextContent(type="text", text="Error: task_id is required")]
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
result = await core.get_task_context(
|
|
209
|
+
task_id=task_id,
|
|
210
|
+
use_cache=not refresh,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
duration_ms = int((time.monotonic() - start_time) * 1000)
|
|
214
|
+
logger.info(
|
|
215
|
+
"get_task_context completed",
|
|
216
|
+
extra={
|
|
217
|
+
"task_id": task_id,
|
|
218
|
+
"source_count": len(result.sources_used),
|
|
219
|
+
"cached": result.cached,
|
|
220
|
+
"duration_ms": duration_ms,
|
|
221
|
+
},
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# Return the synthesized markdown directly
|
|
225
|
+
return [TextContent(type="text", text=result.synthesized)]
|
|
226
|
+
|
|
227
|
+
except Exception as e:
|
|
228
|
+
duration_ms = int((time.monotonic() - start_time) * 1000)
|
|
229
|
+
logger.exception(
|
|
230
|
+
"get_task_context failed",
|
|
231
|
+
extra={"task_id": task_id, "error": str(e), "duration_ms": duration_ms},
|
|
232
|
+
)
|
|
233
|
+
return [
|
|
234
|
+
TextContent(
|
|
235
|
+
type="text",
|
|
236
|
+
text=f"Error fetching context for {task_id}: {e}\n\n"
|
|
237
|
+
f"Please check that your Jira and Fireflies credentials are configured correctly.",
|
|
238
|
+
)
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
async def _handle_search_context(
|
|
243
|
+
core: DevsContextCore,
|
|
244
|
+
arguments: dict[str, Any],
|
|
245
|
+
start_time: float,
|
|
246
|
+
) -> list[TextContent]:
|
|
247
|
+
"""Handle the search_context tool call.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
core: The DevsContextCore instance.
|
|
251
|
+
arguments: Tool arguments.
|
|
252
|
+
start_time: When the request started for duration logging.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
List containing TextContent with search results.
|
|
256
|
+
"""
|
|
257
|
+
query = arguments.get("query", "")
|
|
258
|
+
|
|
259
|
+
if not query:
|
|
260
|
+
logger.warning("search_context called without query")
|
|
261
|
+
return [TextContent(type="text", text="Error: query is required")]
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
result = await core.search_context(query=query)
|
|
265
|
+
|
|
266
|
+
duration_ms = int((time.monotonic() - start_time) * 1000)
|
|
267
|
+
logger.info(
|
|
268
|
+
"search_context completed",
|
|
269
|
+
extra={
|
|
270
|
+
"query": query,
|
|
271
|
+
"result_count": result["result_count"],
|
|
272
|
+
"duration_ms": duration_ms,
|
|
273
|
+
},
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
sources = result.get("sources", [])
|
|
277
|
+
sources_str = ", ".join(sources) if isinstance(sources, list) else str(sources)
|
|
278
|
+
|
|
279
|
+
response_text = f"""# Search Results for "{query}"
|
|
280
|
+
|
|
281
|
+
**Sources searched:** {sources_str}
|
|
282
|
+
**Results found:** {result["result_count"]}
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
{result["results"]}
|
|
287
|
+
"""
|
|
288
|
+
return [TextContent(type="text", text=response_text)]
|
|
289
|
+
|
|
290
|
+
except Exception as e:
|
|
291
|
+
duration_ms = int((time.monotonic() - start_time) * 1000)
|
|
292
|
+
logger.exception(
|
|
293
|
+
"search_context failed",
|
|
294
|
+
extra={"query": query, "error": str(e), "duration_ms": duration_ms},
|
|
295
|
+
)
|
|
296
|
+
return [
|
|
297
|
+
TextContent(
|
|
298
|
+
type="text",
|
|
299
|
+
text=f"Error searching for '{query}': {e}",
|
|
300
|
+
)
|
|
301
|
+
]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
async def _handle_get_standards(
|
|
305
|
+
core: DevsContextCore,
|
|
306
|
+
arguments: dict[str, Any],
|
|
307
|
+
start_time: float,
|
|
308
|
+
) -> list[TextContent]:
|
|
309
|
+
"""Handle the get_standards tool call.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
core: The DevsContextCore instance.
|
|
313
|
+
arguments: Tool arguments.
|
|
314
|
+
start_time: When the request started for duration logging.
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
List containing TextContent with standards content.
|
|
318
|
+
"""
|
|
319
|
+
area = arguments.get("area")
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
result = await core.get_standards(area=area)
|
|
323
|
+
|
|
324
|
+
duration_ms = int((time.monotonic() - start_time) * 1000)
|
|
325
|
+
logger.info(
|
|
326
|
+
"get_standards completed",
|
|
327
|
+
extra={"area": area, "duration_ms": duration_ms},
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
area_text = f" ({area})" if area else ""
|
|
331
|
+
response_text = f"""# Coding Standards{area_text}
|
|
332
|
+
|
|
333
|
+
{result["content"]}
|
|
334
|
+
"""
|
|
335
|
+
return [TextContent(type="text", text=response_text)]
|
|
336
|
+
|
|
337
|
+
except Exception as e:
|
|
338
|
+
duration_ms = int((time.monotonic() - start_time) * 1000)
|
|
339
|
+
logger.exception(
|
|
340
|
+
"get_standards failed",
|
|
341
|
+
extra={"area": area, "error": str(e), "duration_ms": duration_ms},
|
|
342
|
+
)
|
|
343
|
+
return [
|
|
344
|
+
TextContent(
|
|
345
|
+
type="text",
|
|
346
|
+
text=f"Error fetching standards: {e}",
|
|
347
|
+
)
|
|
348
|
+
]
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
async def run_server() -> None:
|
|
352
|
+
"""Run the MCP server over stdio transport.
|
|
353
|
+
|
|
354
|
+
Sets up the stdio transport and runs the server until interrupted.
|
|
355
|
+
"""
|
|
356
|
+
logger.info("Starting MCP server")
|
|
357
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
358
|
+
await server.run(
|
|
359
|
+
read_stream,
|
|
360
|
+
write_stream,
|
|
361
|
+
server.create_initialization_options(),
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def main() -> None:
|
|
366
|
+
"""Entry point for the MCP server.
|
|
367
|
+
|
|
368
|
+
Configures logging and runs the async server.
|
|
369
|
+
"""
|
|
370
|
+
asyncio.run(run_server())
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
if __name__ == "__main__":
|
|
374
|
+
main()
|
devscontext/storage.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""SQLite storage for pre-built context.
|
|
2
|
+
|
|
3
|
+
This module provides persistent storage for pre-built context that has been
|
|
4
|
+
processed by the background agent. The MCP server can then retrieve this
|
|
5
|
+
context instantly instead of fetching on-demand.
|
|
6
|
+
|
|
7
|
+
Uses aiosqlite for async SQLite access.
|
|
8
|
+
|
|
9
|
+
Example:
|
|
10
|
+
storage = PrebuiltContextStorage(".devscontext/cache.db")
|
|
11
|
+
await storage.initialize()
|
|
12
|
+
|
|
13
|
+
# Store pre-built context
|
|
14
|
+
await storage.store(context)
|
|
15
|
+
|
|
16
|
+
# Retrieve context
|
|
17
|
+
context = await storage.get("PROJ-123")
|
|
18
|
+
if context and not context.is_expired():
|
|
19
|
+
# Use the pre-built context
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
await storage.close()
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
from datetime import UTC, datetime
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
import aiosqlite
|
|
33
|
+
|
|
34
|
+
from devscontext.logging import get_logger
|
|
35
|
+
from devscontext.models import PrebuiltContext
|
|
36
|
+
|
|
37
|
+
logger = get_logger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PrebuiltContextStorage:
|
|
41
|
+
"""SQLite storage for pre-built context.
|
|
42
|
+
|
|
43
|
+
Provides async storage and retrieval of pre-built context that was
|
|
44
|
+
created by the background preprocessing agent.
|
|
45
|
+
|
|
46
|
+
The storage uses a single SQLite table with the following schema:
|
|
47
|
+
- task_id: TEXT PRIMARY KEY
|
|
48
|
+
- synthesized: TEXT (markdown content)
|
|
49
|
+
- sources_used: TEXT (JSON array)
|
|
50
|
+
- context_quality_score: REAL (0-1)
|
|
51
|
+
- gaps: TEXT (JSON array)
|
|
52
|
+
- built_at: TEXT (ISO timestamp)
|
|
53
|
+
- expires_at: TEXT (ISO timestamp)
|
|
54
|
+
- source_data_hash: TEXT (for staleness detection)
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, db_path: str = ".devscontext/cache.db") -> None:
|
|
58
|
+
"""Initialize storage with database path.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
db_path: Path to SQLite database file.
|
|
62
|
+
"""
|
|
63
|
+
self._db_path = Path(db_path)
|
|
64
|
+
self._conn: aiosqlite.Connection | None = None
|
|
65
|
+
|
|
66
|
+
async def initialize(self) -> None:
|
|
67
|
+
"""Create database and table if needed.
|
|
68
|
+
|
|
69
|
+
Creates the parent directory if it doesn't exist.
|
|
70
|
+
"""
|
|
71
|
+
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
self._conn = await aiosqlite.connect(self._db_path)
|
|
73
|
+
|
|
74
|
+
await self._conn.execute("""
|
|
75
|
+
CREATE TABLE IF NOT EXISTS prebuilt_context (
|
|
76
|
+
task_id TEXT PRIMARY KEY,
|
|
77
|
+
synthesized TEXT NOT NULL,
|
|
78
|
+
sources_used TEXT NOT NULL,
|
|
79
|
+
context_quality_score REAL NOT NULL,
|
|
80
|
+
gaps TEXT NOT NULL,
|
|
81
|
+
built_at TEXT NOT NULL,
|
|
82
|
+
expires_at TEXT NOT NULL,
|
|
83
|
+
source_data_hash TEXT NOT NULL
|
|
84
|
+
)
|
|
85
|
+
""")
|
|
86
|
+
await self._conn.commit()
|
|
87
|
+
|
|
88
|
+
logger.info(
|
|
89
|
+
"Storage initialized",
|
|
90
|
+
extra={"db_path": str(self._db_path)},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
async def store(self, context: PrebuiltContext) -> None:
|
|
94
|
+
"""Store pre-built context, replacing if exists.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
context: PrebuiltContext to store.
|
|
98
|
+
"""
|
|
99
|
+
if self._conn is None:
|
|
100
|
+
raise RuntimeError("Storage not initialized. Call initialize() first.")
|
|
101
|
+
|
|
102
|
+
await self._conn.execute(
|
|
103
|
+
"""
|
|
104
|
+
INSERT OR REPLACE INTO prebuilt_context
|
|
105
|
+
(task_id, synthesized, sources_used, context_quality_score,
|
|
106
|
+
gaps, built_at, expires_at, source_data_hash)
|
|
107
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
108
|
+
""",
|
|
109
|
+
(
|
|
110
|
+
context.task_id,
|
|
111
|
+
context.synthesized,
|
|
112
|
+
json.dumps(context.sources_used),
|
|
113
|
+
context.context_quality_score,
|
|
114
|
+
json.dumps(context.gaps),
|
|
115
|
+
context.built_at.isoformat(),
|
|
116
|
+
context.expires_at.isoformat(),
|
|
117
|
+
context.source_data_hash,
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
await self._conn.commit()
|
|
121
|
+
|
|
122
|
+
logger.info(
|
|
123
|
+
"Stored pre-built context",
|
|
124
|
+
extra={
|
|
125
|
+
"task_id": context.task_id,
|
|
126
|
+
"quality_score": context.context_quality_score,
|
|
127
|
+
"gaps_count": len(context.gaps),
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
async def get(self, task_id: str) -> PrebuiltContext | None:
|
|
132
|
+
"""Get pre-built context if exists.
|
|
133
|
+
|
|
134
|
+
Note: This returns the context even if expired. Use is_expired()
|
|
135
|
+
to check if it should be refreshed.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
task_id: Task identifier to retrieve.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
PrebuiltContext if found, None otherwise.
|
|
142
|
+
"""
|
|
143
|
+
if self._conn is None:
|
|
144
|
+
raise RuntimeError("Storage not initialized. Call initialize() first.")
|
|
145
|
+
|
|
146
|
+
cursor = await self._conn.execute(
|
|
147
|
+
"""
|
|
148
|
+
SELECT task_id, synthesized, sources_used, context_quality_score,
|
|
149
|
+
gaps, built_at, expires_at, source_data_hash
|
|
150
|
+
FROM prebuilt_context
|
|
151
|
+
WHERE task_id = ?
|
|
152
|
+
""",
|
|
153
|
+
(task_id,),
|
|
154
|
+
)
|
|
155
|
+
row = await cursor.fetchone()
|
|
156
|
+
|
|
157
|
+
if row is None:
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
return PrebuiltContext(
|
|
161
|
+
task_id=row[0],
|
|
162
|
+
synthesized=row[1],
|
|
163
|
+
sources_used=json.loads(row[2]),
|
|
164
|
+
context_quality_score=row[3],
|
|
165
|
+
gaps=json.loads(row[4]),
|
|
166
|
+
built_at=datetime.fromisoformat(row[5]),
|
|
167
|
+
expires_at=datetime.fromisoformat(row[6]),
|
|
168
|
+
source_data_hash=row[7],
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
async def is_stale(self, task_id: str, current_hash: str) -> bool:
|
|
172
|
+
"""Check if stored context is stale.
|
|
173
|
+
|
|
174
|
+
Context is stale if the source data has changed (Jira ticket updated).
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
task_id: Task identifier.
|
|
178
|
+
current_hash: Current hash of source data (ticket.updated).
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
True if stale or not found, False if fresh.
|
|
182
|
+
"""
|
|
183
|
+
if self._conn is None:
|
|
184
|
+
raise RuntimeError("Storage not initialized. Call initialize() first.")
|
|
185
|
+
|
|
186
|
+
cursor = await self._conn.execute(
|
|
187
|
+
"SELECT source_data_hash FROM prebuilt_context WHERE task_id = ?",
|
|
188
|
+
(task_id,),
|
|
189
|
+
)
|
|
190
|
+
row = await cursor.fetchone()
|
|
191
|
+
|
|
192
|
+
if row is None:
|
|
193
|
+
return True # Not found = stale
|
|
194
|
+
|
|
195
|
+
return bool(row[0] != current_hash)
|
|
196
|
+
|
|
197
|
+
async def delete(self, task_id: str) -> bool:
|
|
198
|
+
"""Delete pre-built context for a task.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
task_id: Task identifier to delete.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
True if deleted, False if not found.
|
|
205
|
+
"""
|
|
206
|
+
if self._conn is None:
|
|
207
|
+
raise RuntimeError("Storage not initialized. Call initialize() first.")
|
|
208
|
+
|
|
209
|
+
cursor = await self._conn.execute(
|
|
210
|
+
"DELETE FROM prebuilt_context WHERE task_id = ?",
|
|
211
|
+
(task_id,),
|
|
212
|
+
)
|
|
213
|
+
await self._conn.commit()
|
|
214
|
+
|
|
215
|
+
deleted = cursor.rowcount > 0
|
|
216
|
+
if deleted:
|
|
217
|
+
logger.info("Deleted pre-built context", extra={"task_id": task_id})
|
|
218
|
+
|
|
219
|
+
return deleted
|
|
220
|
+
|
|
221
|
+
async def delete_expired(self) -> int:
|
|
222
|
+
"""Delete all expired entries.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Number of entries deleted.
|
|
226
|
+
"""
|
|
227
|
+
if self._conn is None:
|
|
228
|
+
raise RuntimeError("Storage not initialized. Call initialize() first.")
|
|
229
|
+
|
|
230
|
+
now = datetime.now(UTC).isoformat()
|
|
231
|
+
cursor = await self._conn.execute(
|
|
232
|
+
"DELETE FROM prebuilt_context WHERE expires_at < ?",
|
|
233
|
+
(now,),
|
|
234
|
+
)
|
|
235
|
+
await self._conn.commit()
|
|
236
|
+
|
|
237
|
+
count = cursor.rowcount
|
|
238
|
+
if count > 0:
|
|
239
|
+
logger.info("Deleted expired contexts", extra={"count": count})
|
|
240
|
+
|
|
241
|
+
return count
|
|
242
|
+
|
|
243
|
+
async def list_all(self) -> list[dict[str, Any]]:
|
|
244
|
+
"""List all stored contexts (summary only).
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
List of dicts with task_id, quality_score, built_at, expires_at.
|
|
248
|
+
"""
|
|
249
|
+
if self._conn is None:
|
|
250
|
+
raise RuntimeError("Storage not initialized. Call initialize() first.")
|
|
251
|
+
|
|
252
|
+
cursor = await self._conn.execute(
|
|
253
|
+
"""
|
|
254
|
+
SELECT task_id, context_quality_score, built_at, expires_at, gaps
|
|
255
|
+
FROM prebuilt_context
|
|
256
|
+
ORDER BY built_at DESC
|
|
257
|
+
"""
|
|
258
|
+
)
|
|
259
|
+
rows = await cursor.fetchall()
|
|
260
|
+
|
|
261
|
+
return [
|
|
262
|
+
{
|
|
263
|
+
"task_id": row[0],
|
|
264
|
+
"quality_score": row[1],
|
|
265
|
+
"built_at": row[2],
|
|
266
|
+
"expires_at": row[3],
|
|
267
|
+
"gaps_count": len(json.loads(row[4])),
|
|
268
|
+
}
|
|
269
|
+
for row in rows
|
|
270
|
+
]
|
|
271
|
+
|
|
272
|
+
async def get_stats(self) -> dict[str, Any]:
|
|
273
|
+
"""Get storage statistics for CLI status command.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
Dict with total, active, expired counts and average quality.
|
|
277
|
+
"""
|
|
278
|
+
if self._conn is None:
|
|
279
|
+
raise RuntimeError("Storage not initialized. Call initialize() first.")
|
|
280
|
+
|
|
281
|
+
now = datetime.now(UTC).isoformat()
|
|
282
|
+
|
|
283
|
+
# Total count
|
|
284
|
+
cursor = await self._conn.execute("SELECT COUNT(*) FROM prebuilt_context")
|
|
285
|
+
total_row = await cursor.fetchone()
|
|
286
|
+
total: int = total_row[0] if total_row else 0
|
|
287
|
+
|
|
288
|
+
# Active (not expired) count
|
|
289
|
+
cursor = await self._conn.execute(
|
|
290
|
+
"SELECT COUNT(*) FROM prebuilt_context WHERE expires_at >= ?",
|
|
291
|
+
(now,),
|
|
292
|
+
)
|
|
293
|
+
active_row = await cursor.fetchone()
|
|
294
|
+
active: int = active_row[0] if active_row else 0
|
|
295
|
+
|
|
296
|
+
# Average quality score
|
|
297
|
+
cursor = await self._conn.execute("SELECT AVG(context_quality_score) FROM prebuilt_context")
|
|
298
|
+
avg_quality_row = await cursor.fetchone()
|
|
299
|
+
avg_quality: float = (
|
|
300
|
+
avg_quality_row[0] if avg_quality_row and avg_quality_row[0] is not None else 0.0
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# Last build time
|
|
304
|
+
cursor = await self._conn.execute("SELECT MAX(built_at) FROM prebuilt_context")
|
|
305
|
+
last_build_row = await cursor.fetchone()
|
|
306
|
+
last_build: str | None = last_build_row[0] if last_build_row and last_build_row[0] else None
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
"total": total,
|
|
310
|
+
"active": active,
|
|
311
|
+
"expired": total - active,
|
|
312
|
+
"avg_quality": avg_quality,
|
|
313
|
+
"last_build": last_build,
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async def close(self) -> None:
|
|
317
|
+
"""Close database connection."""
|
|
318
|
+
if self._conn is not None:
|
|
319
|
+
await self._conn.close()
|
|
320
|
+
self._conn = None
|
|
321
|
+
logger.debug("Storage connection closed")
|