hdsp-jupyter-extension 2.0.23__py3-none-any.whl → 2.0.25__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.
- agent_server/context_providers/__init__.py +22 -0
- agent_server/context_providers/actions.py +45 -0
- agent_server/context_providers/base.py +231 -0
- agent_server/context_providers/file.py +316 -0
- agent_server/context_providers/processor.py +150 -0
- agent_server/main.py +2 -1
- agent_server/routers/chat.py +61 -10
- agent_server/routers/context.py +168 -0
- agent_server/routers/langchain_agent.py +609 -182
- {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/build_log.json +1 -1
- {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/package.json +2 -2
- hdsp_jupyter_extension-2.0.23.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.96745acc14125453fba8.js → hdsp_jupyter_extension-2.0.25.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.b5e4416b4e07ec087aad.js +245 -121
- hdsp_jupyter_extension-2.0.25.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.b5e4416b4e07ec087aad.js.map +1 -0
- jupyter_ext/labextension/static/lib_index_js.2d5ea542350862f7c531.js → hdsp_jupyter_extension-2.0.25.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.67505497667f9c0a763d.js +583 -39
- hdsp_jupyter_extension-2.0.25.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.67505497667f9c0a763d.js.map +1 -0
- hdsp_jupyter_extension-2.0.23.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.f0127d8744730f2092c1.js → hdsp_jupyter_extension-2.0.25.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.ffc2b4bc8e6cb300e1e1.js +3 -3
- jupyter_ext/labextension/static/remoteEntry.f0127d8744730f2092c1.js.map → hdsp_jupyter_extension-2.0.25.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.ffc2b4bc8e6cb300e1e1.js.map +1 -1
- {hdsp_jupyter_extension-2.0.23.dist-info → hdsp_jupyter_extension-2.0.25.dist-info}/METADATA +1 -1
- {hdsp_jupyter_extension-2.0.23.dist-info → hdsp_jupyter_extension-2.0.25.dist-info}/RECORD +50 -44
- jupyter_ext/_version.py +1 -1
- jupyter_ext/handlers.py +29 -0
- jupyter_ext/labextension/build_log.json +1 -1
- jupyter_ext/labextension/package.json +2 -2
- jupyter_ext/labextension/static/{frontend_styles_index_js.96745acc14125453fba8.js → frontend_styles_index_js.b5e4416b4e07ec087aad.js} +245 -121
- jupyter_ext/labextension/static/frontend_styles_index_js.b5e4416b4e07ec087aad.js.map +1 -0
- hdsp_jupyter_extension-2.0.23.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.2d5ea542350862f7c531.js → jupyter_ext/labextension/static/lib_index_js.67505497667f9c0a763d.js +583 -39
- jupyter_ext/labextension/static/lib_index_js.67505497667f9c0a763d.js.map +1 -0
- jupyter_ext/labextension/static/{remoteEntry.f0127d8744730f2092c1.js → remoteEntry.ffc2b4bc8e6cb300e1e1.js} +3 -3
- hdsp_jupyter_extension-2.0.23.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.f0127d8744730f2092c1.js.map → jupyter_ext/labextension/static/remoteEntry.ffc2b4bc8e6cb300e1e1.js.map +1 -1
- hdsp_jupyter_extension-2.0.23.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.96745acc14125453fba8.js.map +0 -1
- hdsp_jupyter_extension-2.0.23.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.2d5ea542350862f7c531.js.map +0 -1
- jupyter_ext/labextension/static/frontend_styles_index_js.96745acc14125453fba8.js.map +0 -1
- jupyter_ext/labextension/static/lib_index_js.2d5ea542350862f7c531.js.map +0 -1
- {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/etc/jupyter/jupyter_server_config.d/hdsp_jupyter_extension.json +0 -0
- {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/install.json +0 -0
- {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/static/node_modules_emotion_use-insertion-effect-with-fallbacks_dist_emotion-use-insertion-effect-wi-3ba6b80.c095373419d05e6f141a.js +0 -0
- {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/static/node_modules_emotion_use-insertion-effect-with-fallbacks_dist_emotion-use-insertion-effect-wi-3ba6b80.c095373419d05e6f141a.js.map +0 -0
- {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/static/node_modules_emotion_use-insertion-effect-with-fallbacks_dist_emotion-use-insertion-effect-wi-3ba6b81.61e75fb98ecff46cf836.js +0 -0
- {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/static/node_modules_emotion_use-insertion-effect-with-fallbacks_dist_emotion-use-insertion-effect-wi-3ba6b81.61e75fb98ecff46cf836.js.map +0 -0
- {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/static/style.js +0 -0
- {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_babel_runtime_helpers_esm_extends_js-node_modules_emotion_serialize_dist-051195.e2553aab0c3963b83dd7.js +0 -0
- {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_babel_runtime_helpers_esm_extends_js-node_modules_emotion_serialize_dist-051195.e2553aab0c3963b83dd7.js.map +0 -0
- {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js.24edcc52a1c014a8a5f0.js +0 -0
- {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js.24edcc52a1c014a8a5f0.js.map +0 -0
- {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.19ecf6babe00caff6b8a.js +0 -0
- {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.19ecf6babe00caff6b8a.js.map +0 -0
- {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_styled_dist_emotion-styled_browser_development_esm_js.661fb5836f4978a7c6e1.js +0 -0
- {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_styled_dist_emotion-styled_browser_development_esm_js.661fb5836f4978a7c6e1.js.map +0 -0
- {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_index_js.985697e0162d8d088ca2.js +0 -0
- {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_index_js.985697e0162d8d088ca2.js.map +0 -0
- {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.1f5038488cdfd8b3a85d.js +0 -0
- {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.1f5038488cdfd8b3a85d.js.map +0 -0
- {hdsp_jupyter_extension-2.0.23.dist-info → hdsp_jupyter_extension-2.0.25.dist-info}/WHEEL +0 -0
- {hdsp_jupyter_extension-2.0.23.dist-info → hdsp_jupyter_extension-2.0.25.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Context Providers Module
|
|
3
|
+
|
|
4
|
+
Provides context injection for LLM prompts (e.g., @file, @notebook, @selection).
|
|
5
|
+
Also provides action commands (e.g., @reset).
|
|
6
|
+
Inspired by jupyter-ai's context provider architecture.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .actions import ResetContextProvider
|
|
10
|
+
from .base import BaseContextProvider, ContextCommand, find_commands
|
|
11
|
+
from .file import FileContextProvider
|
|
12
|
+
from .processor import ContextProcessor, process_context_commands
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ContextCommand",
|
|
16
|
+
"BaseContextProvider",
|
|
17
|
+
"FileContextProvider",
|
|
18
|
+
"ResetContextProvider",
|
|
19
|
+
"ContextProcessor",
|
|
20
|
+
"find_commands",
|
|
21
|
+
"process_context_commands",
|
|
22
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Action Context Providers
|
|
3
|
+
|
|
4
|
+
These providers handle action commands like @reset that perform actions
|
|
5
|
+
rather than providing context to the LLM.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .base import BaseContextProvider, ContextCommand, ListOptionsEntry
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ResetContextProvider(BaseContextProvider):
|
|
12
|
+
"""
|
|
13
|
+
Action provider for resetting the session.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
@reset - Clear conversation history and recreate agent
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
id = "@reset"
|
|
20
|
+
description = "Clear session and reset agent"
|
|
21
|
+
requires_arg = False
|
|
22
|
+
only_start = False
|
|
23
|
+
|
|
24
|
+
def __init__(self, base_dir: str = "."):
|
|
25
|
+
super().__init__(base_dir)
|
|
26
|
+
|
|
27
|
+
def get_arg_options(self, arg_prefix: str) -> list[ListOptionsEntry]:
|
|
28
|
+
"""No arguments for @reset."""
|
|
29
|
+
return []
|
|
30
|
+
|
|
31
|
+
def make_context(self, command: ContextCommand) -> str:
|
|
32
|
+
"""@reset doesn't provide context - it's an action command."""
|
|
33
|
+
return ""
|
|
34
|
+
|
|
35
|
+
def replace_command(self, command: ContextCommand) -> str:
|
|
36
|
+
"""Replace @reset with empty string (it's an action, not context)."""
|
|
37
|
+
return ""
|
|
38
|
+
|
|
39
|
+
def is_action_command(self) -> bool:
|
|
40
|
+
"""Indicate this is an action command."""
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Add more action commands here as needed
|
|
45
|
+
# Example: @clear, @help, @history, etc.
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base Context Provider Module
|
|
3
|
+
|
|
4
|
+
Defines the base classes and utilities for context providers.
|
|
5
|
+
Based on jupyter-ai's context provider architecture.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ContextCommand(BaseModel):
|
|
16
|
+
"""
|
|
17
|
+
Represents a context command in user input (e.g., @file:path/to/file.py).
|
|
18
|
+
|
|
19
|
+
Properties:
|
|
20
|
+
cmd: Full command string (e.g., "@file:path/to/file.py")
|
|
21
|
+
id: Command identifier (e.g., "@file")
|
|
22
|
+
arg: Optional argument after the colon (e.g., "path/to/file.py")
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
cmd: str
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def id(self) -> str:
|
|
29
|
+
"""Extract command ID (e.g., '@file' from '@file:path')"""
|
|
30
|
+
return self.cmd.partition(":")[0]
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def arg(self) -> Optional[str]:
|
|
34
|
+
"""
|
|
35
|
+
Extract argument after colon.
|
|
36
|
+
Handles quoted paths and escaped spaces.
|
|
37
|
+
"""
|
|
38
|
+
if ":" not in self.cmd:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
arg = self.cmd.partition(":")[2]
|
|
42
|
+
if not arg:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
# Remove surrounding quotes if present
|
|
46
|
+
arg = arg.strip("'\"")
|
|
47
|
+
# Handle escaped spaces
|
|
48
|
+
arg = arg.replace("\\ ", " ")
|
|
49
|
+
|
|
50
|
+
return arg
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ListOptionsEntry(BaseModel):
|
|
54
|
+
"""Entry for autocomplete options list."""
|
|
55
|
+
|
|
56
|
+
id: str # e.g., "@file"
|
|
57
|
+
label: str # e.g., "@file:" or "@file:path/to/file.py"
|
|
58
|
+
description: str # e.g., "Include file contents" or "Python file"
|
|
59
|
+
only_start: bool = False # Whether command only works at start of prompt
|
|
60
|
+
is_complete: bool = True # Whether this option completes the command
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ListOptionsResponse(BaseModel):
|
|
64
|
+
"""Response for autocomplete options."""
|
|
65
|
+
|
|
66
|
+
options: list[ListOptionsEntry] = []
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ContextProviderException(Exception):
|
|
70
|
+
"""Exception raised by context providers."""
|
|
71
|
+
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class BaseContextProvider(ABC):
|
|
76
|
+
"""
|
|
77
|
+
Base class for context providers.
|
|
78
|
+
|
|
79
|
+
Context providers handle @commands in user input, providing context
|
|
80
|
+
to the LLM from external sources (files, notebooks, etc.).
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
# Unique identifier for this provider (e.g., "@file")
|
|
84
|
+
id: str = ""
|
|
85
|
+
|
|
86
|
+
# Human-readable description
|
|
87
|
+
description: str = ""
|
|
88
|
+
|
|
89
|
+
# Whether this provider requires an argument after the colon
|
|
90
|
+
requires_arg: bool = True
|
|
91
|
+
|
|
92
|
+
# Whether this command only works at the start of the prompt
|
|
93
|
+
only_start: bool = False
|
|
94
|
+
|
|
95
|
+
def __init__(self, base_dir: str = "."):
|
|
96
|
+
"""
|
|
97
|
+
Initialize the context provider.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
base_dir: Base directory for resolving relative paths
|
|
101
|
+
"""
|
|
102
|
+
self.base_dir = base_dir
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def command_id(self) -> str:
|
|
106
|
+
"""Get the command ID (e.g., '@file')."""
|
|
107
|
+
return self.id
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def pattern(self) -> str:
|
|
111
|
+
"""
|
|
112
|
+
Regex pattern to match this context command.
|
|
113
|
+
|
|
114
|
+
Handles:
|
|
115
|
+
- @file:path/to/file
|
|
116
|
+
- @file:'path with spaces'
|
|
117
|
+
- @file:"path with spaces"
|
|
118
|
+
- @file:path\\ with\\ escaped\\ spaces
|
|
119
|
+
"""
|
|
120
|
+
if self.requires_arg:
|
|
121
|
+
# Pattern for commands with arguments
|
|
122
|
+
# Matches: @file:path, @file:'quoted path', @file:"quoted path", @file:escaped\\ path
|
|
123
|
+
return (
|
|
124
|
+
rf"(?<![^\s.]){re.escape(self.command_id)}:"
|
|
125
|
+
rf"(?:'[^']+'|\"[^\"]+\"|[^\s\\]+(?:\\ [^\s\\]*)*)"
|
|
126
|
+
)
|
|
127
|
+
else:
|
|
128
|
+
# Pattern for commands without arguments
|
|
129
|
+
return rf"(?<![^\s.]){re.escape(self.command_id)}(?![^\s.])"
|
|
130
|
+
|
|
131
|
+
@abstractmethod
|
|
132
|
+
def get_arg_options(self, arg_prefix: str) -> list[ListOptionsEntry]:
|
|
133
|
+
"""
|
|
134
|
+
Get autocomplete options for the command argument.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
arg_prefix: Current argument prefix (e.g., "src/" for "@file:src/")
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
List of autocomplete options
|
|
141
|
+
"""
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
@abstractmethod
|
|
145
|
+
def make_context(self, command: ContextCommand) -> str:
|
|
146
|
+
"""
|
|
147
|
+
Generate context content for this command.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
command: The parsed context command
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Context string to inject into the prompt
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
ContextProviderException: If context cannot be generated
|
|
157
|
+
"""
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
def replace_command(self, command: ContextCommand) -> str:
|
|
161
|
+
"""
|
|
162
|
+
Get the replacement text for this command in the cleaned prompt.
|
|
163
|
+
|
|
164
|
+
By default, replaces @file:path with 'path'.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
command: The parsed context command
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Replacement text
|
|
171
|
+
"""
|
|
172
|
+
if command.arg:
|
|
173
|
+
return f"'{command.arg}'"
|
|
174
|
+
return ""
|
|
175
|
+
|
|
176
|
+
def get_provider_option(self) -> ListOptionsEntry:
|
|
177
|
+
"""Get the autocomplete option for this provider itself."""
|
|
178
|
+
return ListOptionsEntry(
|
|
179
|
+
id=self.command_id,
|
|
180
|
+
label=f"{self.command_id}:" if self.requires_arg else self.command_id,
|
|
181
|
+
description=self.description,
|
|
182
|
+
only_start=self.only_start,
|
|
183
|
+
is_complete=not self.requires_arg,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _is_inside_code_block(match: re.Match, text: str) -> bool:
|
|
188
|
+
"""
|
|
189
|
+
Check if a regex match is inside a code block (backticks).
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
match: Regex match object
|
|
193
|
+
text: Full text being searched
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
True if match is inside backticks
|
|
197
|
+
"""
|
|
198
|
+
start = match.start()
|
|
199
|
+
before = text[:start]
|
|
200
|
+
|
|
201
|
+
# Count backticks before the match
|
|
202
|
+
# If odd number, we're inside a code block
|
|
203
|
+
single_backticks = before.count("`") - before.count("```") * 3
|
|
204
|
+
triple_backticks = before.count("```")
|
|
205
|
+
|
|
206
|
+
# Inside code block if odd number of backticks/triple backticks
|
|
207
|
+
return single_backticks % 2 == 1 or triple_backticks % 2 == 1
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def find_commands(provider: BaseContextProvider, text: str) -> list[ContextCommand]:
|
|
211
|
+
"""
|
|
212
|
+
Find all context commands for a provider in the given text.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
provider: The context provider to match
|
|
216
|
+
text: Text to search
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
List of found context commands
|
|
220
|
+
"""
|
|
221
|
+
matches = list(re.finditer(provider.pattern, text))
|
|
222
|
+
results = []
|
|
223
|
+
|
|
224
|
+
for match in matches:
|
|
225
|
+
# Skip if inside code block
|
|
226
|
+
if _is_inside_code_block(match, text):
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
results.append(ContextCommand(cmd=match.group()))
|
|
230
|
+
|
|
231
|
+
return results
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File Context Provider
|
|
3
|
+
|
|
4
|
+
Provides @file: context injection for including file contents in prompts.
|
|
5
|
+
Based on jupyter-ai's FileContextProvider.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import glob
|
|
9
|
+
import os
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import nbformat
|
|
13
|
+
|
|
14
|
+
from .base import (
|
|
15
|
+
BaseContextProvider,
|
|
16
|
+
ContextCommand,
|
|
17
|
+
ContextProviderException,
|
|
18
|
+
ListOptionsEntry,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Supported file extensions
|
|
22
|
+
SUPPORTED_EXTS = {
|
|
23
|
+
".py",
|
|
24
|
+
".md",
|
|
25
|
+
".txt",
|
|
26
|
+
".json",
|
|
27
|
+
".yaml",
|
|
28
|
+
".yml",
|
|
29
|
+
".toml",
|
|
30
|
+
".ini",
|
|
31
|
+
".cfg",
|
|
32
|
+
".sh",
|
|
33
|
+
".bash",
|
|
34
|
+
".ipynb",
|
|
35
|
+
".js",
|
|
36
|
+
".ts",
|
|
37
|
+
".jsx",
|
|
38
|
+
".tsx",
|
|
39
|
+
".html",
|
|
40
|
+
".css",
|
|
41
|
+
".scss",
|
|
42
|
+
".sql",
|
|
43
|
+
".r",
|
|
44
|
+
".R",
|
|
45
|
+
".Rmd",
|
|
46
|
+
".jl",
|
|
47
|
+
".csv",
|
|
48
|
+
".xml",
|
|
49
|
+
".env",
|
|
50
|
+
".gitignore",
|
|
51
|
+
".dockerignore",
|
|
52
|
+
"Dockerfile",
|
|
53
|
+
"Makefile",
|
|
54
|
+
".tex",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Template for file context
|
|
58
|
+
FILE_CONTEXT_TEMPLATE = """The following is the content of the file `{filepath}`:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
{content}
|
|
62
|
+
```"""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class FileContextProvider(BaseContextProvider):
|
|
66
|
+
"""
|
|
67
|
+
Context provider for including file contents in prompts.
|
|
68
|
+
|
|
69
|
+
Usage:
|
|
70
|
+
@file:path/to/file.py
|
|
71
|
+
@file:'path with spaces/file.py'
|
|
72
|
+
@file:"another/path.py"
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
id = "@file"
|
|
76
|
+
description = "Include file contents in the prompt"
|
|
77
|
+
requires_arg = True
|
|
78
|
+
only_start = False
|
|
79
|
+
|
|
80
|
+
def __init__(self, base_dir: str = "."):
|
|
81
|
+
super().__init__(base_dir)
|
|
82
|
+
|
|
83
|
+
def get_arg_options(self, arg_prefix: str) -> list[ListOptionsEntry]:
|
|
84
|
+
"""
|
|
85
|
+
Get autocomplete options for file paths.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
arg_prefix: Current path prefix (e.g., "src/" or "data/train")
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
List of matching file/directory options
|
|
92
|
+
"""
|
|
93
|
+
options = []
|
|
94
|
+
|
|
95
|
+
# Resolve the path prefix
|
|
96
|
+
if os.path.isabs(arg_prefix):
|
|
97
|
+
path_prefix = arg_prefix
|
|
98
|
+
else:
|
|
99
|
+
path_prefix = os.path.join(self.base_dir, arg_prefix)
|
|
100
|
+
|
|
101
|
+
# Normalize path (handle .. and .)
|
|
102
|
+
path_prefix = os.path.normpath(path_prefix)
|
|
103
|
+
|
|
104
|
+
# If the original prefix ends with / and it's a directory, list contents
|
|
105
|
+
if arg_prefix.endswith("/") and os.path.isdir(path_prefix):
|
|
106
|
+
glob_pattern = os.path.join(path_prefix, "*")
|
|
107
|
+
else:
|
|
108
|
+
# Otherwise, use the prefix to match
|
|
109
|
+
glob_pattern = path_prefix + "*"
|
|
110
|
+
|
|
111
|
+
# Use glob to find matching paths
|
|
112
|
+
try:
|
|
113
|
+
path_matches = glob.glob(glob_pattern)
|
|
114
|
+
except Exception:
|
|
115
|
+
path_matches = []
|
|
116
|
+
|
|
117
|
+
# Sort: directories first, then files
|
|
118
|
+
path_matches = sorted(
|
|
119
|
+
path_matches,
|
|
120
|
+
key=lambda p: (not os.path.isdir(p), os.path.basename(p).lower()),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Limit results
|
|
124
|
+
for path in path_matches[:20]:
|
|
125
|
+
is_dir = os.path.isdir(path)
|
|
126
|
+
|
|
127
|
+
# For files, check if extension is supported
|
|
128
|
+
if not is_dir:
|
|
129
|
+
ext = os.path.splitext(path)[1]
|
|
130
|
+
basename = os.path.basename(path)
|
|
131
|
+
# Check extension or special filenames
|
|
132
|
+
if ext not in SUPPORTED_EXTS and basename not in SUPPORTED_EXTS:
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
# Make relative path for display
|
|
136
|
+
try:
|
|
137
|
+
if os.path.isabs(arg_prefix):
|
|
138
|
+
display_path = path
|
|
139
|
+
else:
|
|
140
|
+
display_path = os.path.relpath(path, self.base_dir)
|
|
141
|
+
except ValueError:
|
|
142
|
+
display_path = path
|
|
143
|
+
|
|
144
|
+
# Add trailing slash for directories
|
|
145
|
+
label_path = display_path + "/" if is_dir else display_path
|
|
146
|
+
|
|
147
|
+
# Handle paths with spaces
|
|
148
|
+
if " " in label_path:
|
|
149
|
+
label_path = f"'{label_path}'"
|
|
150
|
+
|
|
151
|
+
options.append(
|
|
152
|
+
ListOptionsEntry(
|
|
153
|
+
id=self.command_id,
|
|
154
|
+
label=f"{self.command_id}:{label_path}",
|
|
155
|
+
description="Directory"
|
|
156
|
+
if is_dir
|
|
157
|
+
else self._get_file_description(path),
|
|
158
|
+
only_start=self.only_start,
|
|
159
|
+
is_complete=not is_dir, # Complete only for files
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
return options
|
|
164
|
+
|
|
165
|
+
def _get_file_description(self, filepath: str) -> str:
|
|
166
|
+
"""Get a description for a file based on its extension."""
|
|
167
|
+
ext = os.path.splitext(filepath)[1].lower()
|
|
168
|
+
basename = os.path.basename(filepath)
|
|
169
|
+
|
|
170
|
+
descriptions = {
|
|
171
|
+
".py": "Python file",
|
|
172
|
+
".ipynb": "Jupyter notebook",
|
|
173
|
+
".md": "Markdown file",
|
|
174
|
+
".txt": "Text file",
|
|
175
|
+
".json": "JSON file",
|
|
176
|
+
".yaml": "YAML file",
|
|
177
|
+
".yml": "YAML file",
|
|
178
|
+
".toml": "TOML file",
|
|
179
|
+
".js": "JavaScript file",
|
|
180
|
+
".ts": "TypeScript file",
|
|
181
|
+
".tsx": "TypeScript React file",
|
|
182
|
+
".jsx": "JavaScript React file",
|
|
183
|
+
".html": "HTML file",
|
|
184
|
+
".css": "CSS file",
|
|
185
|
+
".sql": "SQL file",
|
|
186
|
+
".sh": "Shell script",
|
|
187
|
+
".csv": "CSV file",
|
|
188
|
+
".r": "R file",
|
|
189
|
+
".R": "R file",
|
|
190
|
+
".jl": "Julia file",
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
# Check special filenames
|
|
194
|
+
if basename == "Dockerfile":
|
|
195
|
+
return "Dockerfile"
|
|
196
|
+
if basename == "Makefile":
|
|
197
|
+
return "Makefile"
|
|
198
|
+
|
|
199
|
+
return descriptions.get(ext, "File")
|
|
200
|
+
|
|
201
|
+
def make_context(self, command: ContextCommand) -> str:
|
|
202
|
+
"""
|
|
203
|
+
Read file contents and format for LLM.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
command: The @file command
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
Formatted file context
|
|
210
|
+
|
|
211
|
+
Raises:
|
|
212
|
+
ContextProviderException: If file cannot be read
|
|
213
|
+
"""
|
|
214
|
+
filepath = command.arg
|
|
215
|
+
if not filepath:
|
|
216
|
+
raise ContextProviderException("No file path specified")
|
|
217
|
+
|
|
218
|
+
# Resolve path
|
|
219
|
+
if not os.path.isabs(filepath):
|
|
220
|
+
filepath = os.path.join(self.base_dir, filepath)
|
|
221
|
+
|
|
222
|
+
filepath = os.path.normpath(filepath)
|
|
223
|
+
|
|
224
|
+
# Validation
|
|
225
|
+
if not os.path.exists(filepath):
|
|
226
|
+
raise ContextProviderException(f"File not found: {filepath}")
|
|
227
|
+
|
|
228
|
+
if os.path.isdir(filepath):
|
|
229
|
+
raise ContextProviderException(f"Cannot read directory: {filepath}")
|
|
230
|
+
|
|
231
|
+
# Check extension
|
|
232
|
+
ext = os.path.splitext(filepath)[1]
|
|
233
|
+
basename = os.path.basename(filepath)
|
|
234
|
+
if ext not in SUPPORTED_EXTS and basename not in SUPPORTED_EXTS:
|
|
235
|
+
raise ContextProviderException(
|
|
236
|
+
f"Unsupported file type: {ext}. "
|
|
237
|
+
f"Supported types: {', '.join(sorted(SUPPORTED_EXTS))}"
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# Read file
|
|
241
|
+
try:
|
|
242
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
243
|
+
content = f.read()
|
|
244
|
+
except UnicodeDecodeError:
|
|
245
|
+
raise ContextProviderException(
|
|
246
|
+
f"Cannot read file (encoding error): {filepath}"
|
|
247
|
+
)
|
|
248
|
+
except PermissionError:
|
|
249
|
+
raise ContextProviderException(f"Permission denied: {filepath}")
|
|
250
|
+
except Exception as e:
|
|
251
|
+
raise ContextProviderException(f"Error reading file: {e}")
|
|
252
|
+
|
|
253
|
+
# Process content (special handling for notebooks)
|
|
254
|
+
content = self._process_file(content, filepath)
|
|
255
|
+
|
|
256
|
+
# Truncate if too long (100K chars max)
|
|
257
|
+
max_chars = 100_000
|
|
258
|
+
if len(content) > max_chars:
|
|
259
|
+
content = (
|
|
260
|
+
content[:max_chars]
|
|
261
|
+
+ f"\n\n... [truncated, {len(content) - max_chars} more characters]"
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# Format with template
|
|
265
|
+
return FILE_CONTEXT_TEMPLATE.format(
|
|
266
|
+
filepath=filepath,
|
|
267
|
+
content=content,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def _process_file(self, content: str, filepath: str) -> str:
|
|
271
|
+
"""
|
|
272
|
+
Process file content, with special handling for certain file types.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
content: Raw file content
|
|
276
|
+
filepath: Path to the file
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
Processed content
|
|
280
|
+
"""
|
|
281
|
+
if filepath.endswith(".ipynb"):
|
|
282
|
+
# Extract cell contents from Jupyter notebook
|
|
283
|
+
try:
|
|
284
|
+
nb = nbformat.reads(content, as_version=4)
|
|
285
|
+
cells = []
|
|
286
|
+
for i, cell in enumerate(nb.cells):
|
|
287
|
+
cell_type = cell.cell_type
|
|
288
|
+
source = cell.source
|
|
289
|
+
cells.append(f"# Cell {i + 1} ({cell_type}):\n{source}")
|
|
290
|
+
return "\n\n".join(cells)
|
|
291
|
+
except Exception:
|
|
292
|
+
# If parsing fails, return raw content
|
|
293
|
+
return content
|
|
294
|
+
|
|
295
|
+
return content
|
|
296
|
+
|
|
297
|
+
def replace_command(self, command: ContextCommand) -> str:
|
|
298
|
+
"""
|
|
299
|
+
Get replacement text for the command in the cleaned prompt.
|
|
300
|
+
|
|
301
|
+
Replaces @file:path with 'path'.
|
|
302
|
+
"""
|
|
303
|
+
filepath = command.arg or ""
|
|
304
|
+
return f"'{filepath}'"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# Singleton instance with default base directory
|
|
308
|
+
_file_provider: Optional[FileContextProvider] = None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def get_file_provider(base_dir: str = ".") -> FileContextProvider:
|
|
312
|
+
"""Get or create the file context provider singleton."""
|
|
313
|
+
global _file_provider
|
|
314
|
+
if _file_provider is None or _file_provider.base_dir != base_dir:
|
|
315
|
+
_file_provider = FileContextProvider(base_dir=base_dir)
|
|
316
|
+
return _file_provider
|