hdsp-jupyter-extension 2.0.23__py3-none-any.whl → 2.0.26__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 (60) hide show
  1. agent_server/context_providers/__init__.py +22 -0
  2. agent_server/context_providers/actions.py +45 -0
  3. agent_server/context_providers/base.py +231 -0
  4. agent_server/context_providers/file.py +316 -0
  5. agent_server/context_providers/processor.py +150 -0
  6. agent_server/langchain/agent_factory.py +14 -14
  7. agent_server/langchain/agent_prompts/planner_prompt.py +13 -19
  8. agent_server/langchain/custom_middleware.py +73 -17
  9. agent_server/langchain/models/gpt_oss_chat.py +26 -13
  10. agent_server/langchain/prompts.py +11 -8
  11. agent_server/langchain/tools/jupyter_tools.py +43 -0
  12. agent_server/main.py +2 -1
  13. agent_server/routers/chat.py +61 -10
  14. agent_server/routers/context.py +168 -0
  15. agent_server/routers/langchain_agent.py +806 -203
  16. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.data}/data/share/jupyter/labextensions/hdsp-agent/build_log.json +1 -1
  17. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.data}/data/share/jupyter/labextensions/hdsp-agent/package.json +2 -2
  18. 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.26.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.b5e4416b4e07ec087aad.js +245 -121
  19. hdsp_jupyter_extension-2.0.26.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.b5e4416b4e07ec087aad.js.map +1 -0
  20. jupyter_ext/labextension/static/lib_index_js.2d5ea542350862f7c531.js → hdsp_jupyter_extension-2.0.26.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.67505497667f9c0a763d.js +583 -39
  21. hdsp_jupyter_extension-2.0.26.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.67505497667f9c0a763d.js.map +1 -0
  22. hdsp_jupyter_extension-2.0.23.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.f0127d8744730f2092c1.js → hdsp_jupyter_extension-2.0.26.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.0fe2dcbbd176ee0efceb.js +3 -3
  23. hdsp_jupyter_extension-2.0.23.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.f0127d8744730f2092c1.js.map → hdsp_jupyter_extension-2.0.26.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.0fe2dcbbd176ee0efceb.js.map +1 -1
  24. {hdsp_jupyter_extension-2.0.23.dist-info → hdsp_jupyter_extension-2.0.26.dist-info}/METADATA +1 -1
  25. {hdsp_jupyter_extension-2.0.23.dist-info → hdsp_jupyter_extension-2.0.26.dist-info}/RECORD +56 -50
  26. jupyter_ext/_version.py +1 -1
  27. jupyter_ext/handlers.py +29 -0
  28. jupyter_ext/labextension/build_log.json +1 -1
  29. jupyter_ext/labextension/package.json +2 -2
  30. jupyter_ext/labextension/static/{frontend_styles_index_js.96745acc14125453fba8.js → frontend_styles_index_js.b5e4416b4e07ec087aad.js} +245 -121
  31. jupyter_ext/labextension/static/frontend_styles_index_js.b5e4416b4e07ec087aad.js.map +1 -0
  32. 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
  33. jupyter_ext/labextension/static/lib_index_js.67505497667f9c0a763d.js.map +1 -0
  34. jupyter_ext/labextension/static/{remoteEntry.f0127d8744730f2092c1.js → remoteEntry.0fe2dcbbd176ee0efceb.js} +3 -3
  35. jupyter_ext/labextension/static/{remoteEntry.f0127d8744730f2092c1.js.map → remoteEntry.0fe2dcbbd176ee0efceb.js.map} +1 -1
  36. hdsp_jupyter_extension-2.0.23.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.96745acc14125453fba8.js.map +0 -1
  37. hdsp_jupyter_extension-2.0.23.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.2d5ea542350862f7c531.js.map +0 -1
  38. jupyter_ext/labextension/static/frontend_styles_index_js.96745acc14125453fba8.js.map +0 -1
  39. jupyter_ext/labextension/static/lib_index_js.2d5ea542350862f7c531.js.map +0 -1
  40. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.data}/data/etc/jupyter/jupyter_server_config.d/hdsp_jupyter_extension.json +0 -0
  41. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.data}/data/share/jupyter/labextensions/hdsp-agent/install.json +0 -0
  42. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.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
  43. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.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
  44. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.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
  45. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.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
  46. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.data}/data/share/jupyter/labextensions/hdsp-agent/static/style.js +0 -0
  47. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.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
  48. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.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
  49. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js.24edcc52a1c014a8a5f0.js +0 -0
  50. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.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
  51. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.19ecf6babe00caff6b8a.js +0 -0
  52. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.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
  53. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_styled_dist_emotion-styled_browser_development_esm_js.661fb5836f4978a7c6e1.js +0 -0
  54. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.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
  55. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_index_js.985697e0162d8d088ca2.js +0 -0
  56. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_index_js.985697e0162d8d088ca2.js.map +0 -0
  57. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.1f5038488cdfd8b3a85d.js +0 -0
  58. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.1f5038488cdfd8b3a85d.js.map +0 -0
  59. {hdsp_jupyter_extension-2.0.23.dist-info → hdsp_jupyter_extension-2.0.26.dist-info}/WHEEL +0 -0
  60. {hdsp_jupyter_extension-2.0.23.dist-info → hdsp_jupyter_extension-2.0.26.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