mdinject 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. mdinject/__init__.py +7 -0
  2. mdinject/__main__.py +270 -0
  3. mdinject/adapters/__init__.py +15 -0
  4. mdinject/adapters/content_format.py +356 -0
  5. mdinject/adapters/export.py +518 -0
  6. mdinject/adapters/license.py +929 -0
  7. mdinject/adapters/prompt_storage.py +478 -0
  8. mdinject/adapters/sqlite_wrapper.py +115 -0
  9. mdinject/appserver/__init__.py +5 -0
  10. mdinject/appserver/server.py +820 -0
  11. mdinject/appserver/token_store.py +24 -0
  12. mdinject/assets/examples/templates/api-docs.md +56 -0
  13. mdinject/assets/examples/templates/code-review.md +49 -0
  14. mdinject/assets/examples/templates/debug-assist.md +47 -0
  15. mdinject/assets/examples/templates/general-question.md +25 -0
  16. mdinject/assets/prompt_editor.html +92 -0
  17. mdinject/assets/terminal.html +107 -0
  18. mdinject/assets/vendor/all.min.css +9 -0
  19. mdinject/assets/vendor/all.min.js +6 -0
  20. mdinject/assets/vendor/easymde.min.css +7 -0
  21. mdinject/assets/vendor/easymde.min.js +7 -0
  22. mdinject/assets/vendor/markdown-wysiwyg.css +10 -0
  23. mdinject/assets/vendor/markdown-wysiwyg.js +6 -0
  24. mdinject/assets/vendor/milkdown.bundle.css +1 -0
  25. mdinject/assets/vendor/milkdown.bundle.js +993 -0
  26. mdinject/assets/vendor/qwebchannel.js +157 -0
  27. mdinject/assets/vendor/webfonts/fa-regular-400.woff2 +0 -0
  28. mdinject/assets/vendor/webfonts/fa-solid-900.woff2 +0 -0
  29. mdinject/assets/vendor/xterm-addon-fit.js +2 -0
  30. mdinject/assets/vendor/xterm.css +209 -0
  31. mdinject/assets/vendor/xterm.js +2 -0
  32. mdinject/config/__init__.py +344 -0
  33. mdinject/config/examples/cli_profiles.toml +113 -0
  34. mdinject/config/format_config.py +159 -0
  35. mdinject/config/loader.py +328 -0
  36. mdinject/config/profiles.py +200 -0
  37. mdinject/exporters.py +82 -0
  38. mdinject/logging/__init__.py +261 -0
  39. mdinject/mcp/API.md +935 -0
  40. mdinject/mcp/__init__.py +9 -0
  41. mdinject/mcp/_app.py +30 -0
  42. mdinject/mcp/config/__init__.py +7 -0
  43. mdinject/mcp/config/mdinject_mcp.py +99 -0
  44. mdinject/mcp/health.py +70 -0
  45. mdinject/mcp/models.py +316 -0
  46. mdinject/mcp/server.py +169 -0
  47. mdinject/mcp/server_core.py +257 -0
  48. mdinject/mcp/tools/__init__.py +29 -0
  49. mdinject/mcp/tools/collaboration_tools.py +289 -0
  50. mdinject/mcp/tools/export_tools.py +170 -0
  51. mdinject/mcp/tools/format_tools.py +397 -0
  52. mdinject/mcp/tools/license_tools.py +161 -0
  53. mdinject/mcp/tools/prompt_tools.py +555 -0
  54. mdinject/mcp/tools/terminal_tools.py +241 -0
  55. mdinject/mcp/tools/widget_tools.py +271 -0
  56. mdinject/profiles.py +204 -0
  57. mdinject/pty_process.py +424 -0
  58. mdinject/services/__init__.py +37 -0
  59. mdinject/services/export_service.py +296 -0
  60. mdinject/services/format_conversion_service.py +655 -0
  61. mdinject/services/license_service.py +449 -0
  62. mdinject/services/orchestrator.py +547 -0
  63. mdinject/services/prompt_pane_service.py +306 -0
  64. mdinject/services/prompt_storage_service.py +264 -0
  65. mdinject/services/terminal_pane_service.py +431 -0
  66. mdinject/storage/__init__.py +308 -0
  67. mdinject/storage/async_sqlite_store.py +309 -0
  68. mdinject/storage/scope.py +145 -0
  69. mdinject/storage.py +166 -0
  70. mdinject/template_loader.py +261 -0
  71. mdinject/templates.py +186 -0
  72. mdinject/tools/__init__.py +1 -0
  73. mdinject/tools/macos_app.py +147 -0
  74. mdinject-0.2.0.dist-info/METADATA +440 -0
  75. mdinject-0.2.0.dist-info/RECORD +78 -0
  76. mdinject-0.2.0.dist-info/WHEEL +4 -0
  77. mdinject-0.2.0.dist-info/entry_points.txt +5 -0
  78. mdinject-0.2.0.dist-info/licenses/LICENSE +28 -0
mdinject/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """MdInject - Markdown prompt injection for CLI tools.
2
+
3
+ A SwiftUI desktop application for managing and injecting markdown prompts
4
+ into terminal-based CLI tools like Claude Code, Codex, Vibe, and Qwen.
5
+ """
6
+
7
+ __version__ = "0.1.0"
mdinject/__main__.py ADDED
@@ -0,0 +1,270 @@
1
+ """MdInject command-line interface with Oneiric integration.
2
+
3
+ This module provides the CLI entry point for mdinject with full Oneiric
4
+ integration including configuration management, structured logging,
5
+ and adapter lifecycle management.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import sys
12
+ from typing import Any
13
+
14
+ from oneiric.core.lifecycle import LifecycleManager
15
+ from oneiric.core.resolution import Candidate, Resolver
16
+
17
+ from . import __version__
18
+ from .adapters import PromptStorageAdapter
19
+ from .config import (
20
+ MdInjectConfig,
21
+ get_config_paths,
22
+ load_app_config,
23
+ )
24
+ from .logging import clear_context, get_logger, scoped_context, setup_logging
25
+
26
+
27
+ class MdInjectApplication:
28
+ """Main application class with Oneiric integration.
29
+
30
+ This class manages the application lifecycle including configuration,
31
+ logging, adapter registration, and graceful shutdown.
32
+ """
33
+
34
+ def __init__(self) -> None:
35
+ """Initialize the application."""
36
+ self._config: MdInjectConfig | None = None
37
+ self._resolver: Resolver | None = None
38
+ self._lifecycle: LifecycleManager | None = None
39
+ # The bound structlog logger doesn't have a precise type that ty
40
+ # can resolve, so type it loosely and narrow at use sites.
41
+ self._logger: Any = None
42
+ self._storage: PromptStorageAdapter | None = None
43
+
44
+ async def startup(self) -> None:
45
+ """Initialize the application.
46
+
47
+ This method:
48
+ 1. Loads configuration
49
+ 2. Sets up structured logging
50
+ 3. Initializes Oneiric resolver
51
+ 4. Registers mdinject adapters
52
+ 5. Creates lifecycle manager
53
+ 6. Activates storage adapter
54
+ 7. Performs health checks
55
+ """
56
+ # Load configuration
57
+ self._config = load_app_config()
58
+ paths = get_config_paths()
59
+
60
+ # Setup structured logging
61
+ setup_logging(
62
+ level="DEBUG" if self._config.debug else "INFO",
63
+ development=self._config.debug,
64
+ log_file=paths["cache"] / "mdinject.log",
65
+ service_name="mdinject",
66
+ )
67
+
68
+ self._logger = get_logger("mdinject.app").bind(
69
+ version=__version__,
70
+ environment=self._config.environment,
71
+ )
72
+
73
+ with scoped_context(phase="startup"):
74
+ self._logger.info("application_startup", config_path=str(paths["config"]))
75
+
76
+ # Initialize Oneiric resolver
77
+ self._resolver = Resolver()
78
+ await self._register_adapters()
79
+
80
+ # Create lifecycle manager
81
+ self._lifecycle = LifecycleManager(resolver=self._resolver)
82
+
83
+ # Activate storage adapter
84
+ await self._activate_storage()
85
+
86
+ # Perform health checks
87
+ await self._health_check()
88
+
89
+ async def _register_adapters(self) -> None:
90
+ """Register mdinject adapters with the resolver.
91
+
92
+ This method registers all mdinject-specific adapters including
93
+ the prompt storage adapter.
94
+ """
95
+ assert self._logger is not None
96
+ assert self._resolver is not None
97
+
98
+ self._logger.info("registering_adapters")
99
+
100
+ # Register prompt storage adapter
101
+ # The adapter class itself contains metadata via @property
102
+ storage_adapter = PromptStorageAdapter()
103
+ self._resolver.register(
104
+ Candidate(
105
+ domain="adapter",
106
+ key="prompt_store",
107
+ provider=storage_adapter.metadata.provider,
108
+ factory=PromptStorageAdapter,
109
+ priority=storage_adapter.metadata.priority,
110
+ metadata=storage_adapter.metadata.model_dump(),
111
+ )
112
+ )
113
+
114
+ self._logger.debug(
115
+ "adapters_registered",
116
+ storage_provider=storage_adapter.metadata.provider,
117
+ )
118
+
119
+ async def _activate_storage(self) -> None:
120
+ """Activate the prompt storage adapter.
121
+
122
+ Raises:
123
+ RuntimeError: If storage adapter fails to activate or health check fails
124
+ """
125
+ assert self._config is not None
126
+ assert self._lifecycle is not None
127
+ assert self._logger is not None
128
+
129
+ self._logger.info(
130
+ "activating_storage",
131
+ provider=self._config.storage.adapter_provider,
132
+ )
133
+
134
+ try:
135
+ # Create storage adapter instance directly
136
+ # (bypassing resolver activation for direct control)
137
+ self._storage = PromptStorageAdapter()
138
+ await self._storage.init()
139
+
140
+ # Verify storage is healthy
141
+ is_healthy = await self._storage.health()
142
+ if not is_healthy:
143
+ raise RuntimeError("Storage adapter failed health check")
144
+
145
+ self._logger.info(
146
+ "storage_activated",
147
+ database_path=self._config.storage.database_path,
148
+ )
149
+
150
+ except Exception as e:
151
+ self._logger.error("storage_activation_failed", error=str(e))
152
+ raise RuntimeError(f"Failed to activate storage: {e}") from e
153
+
154
+ async def _health_check(self) -> None:
155
+ """Perform health checks on all active components.
156
+
157
+ Raises:
158
+ RuntimeError: If any critical component fails health check
159
+ """
160
+ assert self._logger is not None
161
+ assert self._storage is not None
162
+
163
+ self._logger.info("health_check_start")
164
+
165
+ # Check storage health
166
+ storage_healthy = await self._storage.health()
167
+ self._logger.info(
168
+ "health_check_result",
169
+ storage="healthy" if storage_healthy else "unhealthy",
170
+ )
171
+
172
+ if not storage_healthy:
173
+ raise RuntimeError("Storage health check failed")
174
+
175
+ async def run(self) -> None:
176
+ """Run the main application logic.
177
+
178
+ For the CLI interface, this simply lists the loaded prompts.
179
+ In a GUI application, this would start the Qt event loop.
180
+ """
181
+ assert self._logger is not None
182
+ assert self._storage is not None
183
+ assert self._config is not None
184
+
185
+ with scoped_context(phase="run"):
186
+ # List prompts from storage
187
+ prompts = await self._storage.list_prompts()
188
+
189
+ self._logger.info(
190
+ "prompts_loaded",
191
+ count=len(prompts),
192
+ )
193
+
194
+ # Print to console
195
+ print(f"MdInject {__version__} initialized.")
196
+ print(f"Environment: {self._config.environment}")
197
+ print(f"{len(prompts)} prompt(s) loaded.")
198
+ print(f"Database: {self._config.storage.database_path}")
199
+
200
+ # List prompt titles if any exist
201
+ if prompts:
202
+ print("\nPrompts:")
203
+ for prompt in prompts:
204
+ print(f" - {prompt.title}")
205
+
206
+ async def shutdown(self) -> None:
207
+ """Gracefully shutdown the application.
208
+
209
+ This method:
210
+ 1. Cleans up storage adapter
211
+ 2. Clears logging context
212
+ """
213
+ if self._logger:
214
+ self._logger.info("application_shutdown")
215
+
216
+ # Cleanup storage
217
+ if self._storage:
218
+ try:
219
+ await self._storage.cleanup()
220
+ except (RuntimeError, OSError) as e:
221
+ if self._logger:
222
+ self._logger.warning("storage_cleanup_error", error=str(e))
223
+ else:
224
+ print(f"Warning: Storage cleanup error: {e}")
225
+
226
+ # Clear logging context
227
+ clear_context()
228
+
229
+
230
+ async def _async_main() -> int:
231
+ """Async main entry point.
232
+
233
+ Returns:
234
+ Exit code (0 for success, non-zero for errors)
235
+ """
236
+ app = MdInjectApplication()
237
+
238
+ try:
239
+ await app.startup()
240
+ await app.run()
241
+ await app.shutdown()
242
+ return 0
243
+ except KeyboardInterrupt:
244
+ if app._logger:
245
+ app._logger.info("keyboard_interrupt")
246
+ print("\nInterrupted by user")
247
+ return 130 # Standard exit code for SIGINT
248
+ except (RuntimeError, OSError, ValueError) as e:
249
+ if app._logger:
250
+ app._logger.error("application_error", error=str(e))
251
+ print(f"Error: {e}")
252
+ return 1
253
+
254
+
255
+ def main() -> None:
256
+ """Synchronous main entry point.
257
+
258
+ This function is called by the mdinject console script.
259
+ It runs the async main function in an event loop.
260
+ """
261
+ try:
262
+ exit_code = asyncio.run(_async_main())
263
+ sys.exit(exit_code)
264
+ except (RuntimeError, OSError, ValueError) as e:
265
+ print(f"Fatal error: {e}")
266
+ sys.exit(1)
267
+
268
+
269
+ if __name__ == "__main__":
270
+ main()
@@ -0,0 +1,15 @@
1
+ """Oneiric adapters for mdinject.
2
+
3
+ This package contains all Oneiric-compatible adapters for mdinject,
4
+ following the adapter pattern with factory registration and validation.
5
+ """
6
+
7
+ from .prompt_storage import PromptStorageAdapter, PromptStorageSettings
8
+ from .sqlite_wrapper import SQLiteDatabaseAdapter, SQLiteDatabaseSettings
9
+
10
+ __all__ = [
11
+ "PromptStorageAdapter",
12
+ "PromptStorageSettings",
13
+ "SQLiteDatabaseAdapter",
14
+ "SQLiteDatabaseSettings",
15
+ ]
@@ -0,0 +1,356 @@
1
+ """Content format detection and conversion adapters.
2
+
3
+ This module provides format detection, conversion, and related data
4
+ structures for transforming various content formats to clean markdown.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass, field
11
+ from enum import Enum
12
+ from typing import Any
13
+
14
+
15
+ class ContentFormat(Enum):
16
+ """Supported content formats for detection and conversion."""
17
+
18
+ MARKDOWN = "markdown"
19
+ HTML = "html"
20
+ PLAIN_TEXT = "plain_text"
21
+ CODE = "code"
22
+ UNKNOWN = "unknown"
23
+
24
+ def __str__(self) -> str:
25
+ return self.value
26
+
27
+
28
+ @dataclass
29
+ class FormatInfo:
30
+ """Result of format detection.
31
+
32
+ Attributes:
33
+ format: The detected content format
34
+ confidence: Detection confidence from 0.0 to 1.0
35
+ language: Detected programming language (for CODE format)
36
+ metadata: Additional format-specific metadata
37
+ """
38
+
39
+ format: ContentFormat
40
+ confidence: float
41
+ language: str | None = None
42
+ metadata: dict[str, Any] = field(default_factory=dict)
43
+
44
+ def __post_init__(self) -> None:
45
+ """Validate confidence is in valid range."""
46
+ if not 0.0 <= self.confidence <= 1.0:
47
+ raise ValueError(
48
+ f"Confidence must be between 0.0 and 1.0, got {self.confidence}"
49
+ )
50
+
51
+
52
+ # HTML detection patterns
53
+ HTML_PATTERNS = [
54
+ re.compile(r"<\s*!DOCTYPE\s+html", re.IGNORECASE),
55
+ re.compile(r"<\s*html[^>]*>", re.IGNORECASE),
56
+ re.compile(r"<\s*head[^>]*>", re.IGNORECASE),
57
+ re.compile(r"<\s*body[^>]*>", re.IGNORECASE),
58
+ re.compile(r"<\s*div[^>]*>", re.IGNORECASE),
59
+ re.compile(r"<\s*p[\s>]", re.IGNORECASE),
60
+ re.compile(r"<\s*a\s+href", re.IGNORECASE),
61
+ re.compile(r"<\s*img[^>]*src", re.IGNORECASE),
62
+ re.compile(r"<\s*ul[^>]*>|<\s*ol[^>]*>", re.IGNORECASE),
63
+ re.compile(r"<\s*li[^>]*>", re.IGNORECASE),
64
+ re.compile(r"<\s*h[1-6][^>]*>", re.IGNORECASE),
65
+ re.compile(r"<\s*table[^>]*>", re.IGNORECASE),
66
+ re.compile(r"&[a-z]+;|&#\d+;", re.IGNORECASE), # HTML entities
67
+ ]
68
+
69
+ # Markdown detection patterns
70
+ MARKDOWN_PATTERNS = [
71
+ re.compile(r"^#{1,6}\s+\S", re.MULTILINE), # ATX headings
72
+ re.compile(r"^\S[^\n]*\n[-=]{3,}\s*$", re.MULTILINE), # Setext headings
73
+ re.compile(r"\*\*[^*]+\*\*"), # Bold
74
+ re.compile(r"\*[^*]+\*"), # Italic
75
+ re.compile(r"_[^_]+_"), # Italic underscore
76
+ re.compile(r"`[^`]+`"), # Inline code
77
+ re.compile(r"```\w*[\s\S]*?```"), # Fenced code blocks
78
+ re.compile(r"^\s*[-*+]\s+\S", re.MULTILINE), # Unordered lists
79
+ re.compile(r"^\s*\d+\.\s+\S", re.MULTILINE), # Ordered lists
80
+ re.compile(r"\[[^\]]+\]\([^)]+\)"), # Links
81
+ re.compile(r"!\[[^\]]*\]\([^)]+\)"), # Images
82
+ re.compile(r"^\s*>\s+\S", re.MULTILINE), # Blockquotes
83
+ re.compile(r"^\s*---+\s*$", re.MULTILINE), # Horizontal rules
84
+ ]
85
+
86
+ # Programming language detection patterns
87
+ LANGUAGE_PATTERNS = {
88
+ "python": [
89
+ re.compile(r"^\s*def\s+\w+\s*\(", re.MULTILINE),
90
+ re.compile(r"^\s*class\s+\w+[:\(]", re.MULTILINE),
91
+ re.compile(r"^\s*import\s+\w+", re.MULTILINE),
92
+ re.compile(r"^\s*from\s+\w+\s+import", re.MULTILINE),
93
+ re.compile(r":\s*#.+$", re.MULTILINE), # Python comments
94
+ re.compile(r"if\s+__name__\s*==\s*['\"]__main__['\"]"),
95
+ ],
96
+ "javascript": [
97
+ re.compile(r"^\s*function\s+\w+\s*\(", re.MULTILINE),
98
+ re.compile(r"^\s*const\s+\w+\s*=", re.MULTILINE),
99
+ re.compile(r"^\s*let\s+\w+\s*=", re.MULTILINE),
100
+ re.compile(r"^\s*var\s+\w+\s*=", re.MULTILINE),
101
+ re.compile(r"=>\s*[{(]"),
102
+ re.compile(r"^\s*async\s+function", re.MULTILINE),
103
+ re.compile(r"console\.(log|error|warn)\s*\("),
104
+ ],
105
+ "typescript": [
106
+ re.compile(r"^\s*interface\s+\w+", re.MULTILINE),
107
+ re.compile(r"^\s*type\s+\w+\s*=", re.MULTILINE),
108
+ re.compile(r":\s*(string|number|boolean|any)\b"),
109
+ re.compile(r"<[A-Z]\w*>"),
110
+ re.compile(r"^\s*export\s+(interface|type|class|function)", re.MULTILINE),
111
+ ],
112
+ "rust": [
113
+ re.compile(r"^\s*fn\s+\w+", re.MULTILINE),
114
+ re.compile(r"^\s*let\s+mut?\s+\w+", re.MULTILINE),
115
+ re.compile(r"^\s*impl\s+\w+", re.MULTILINE),
116
+ re.compile(r"^\s*pub\s+(fn|struct|enum)", re.MULTILINE),
117
+ re.compile(r"^\s*use\s+\w+::", re.MULTILINE),
118
+ re.compile(r"->\s*\w+"),
119
+ ],
120
+ "go": [
121
+ re.compile(r"^\s*func\s+\w+", re.MULTILINE),
122
+ re.compile(r"^\s*func\s*\(\w+\s+\*?\w+\)", re.MULTILINE),
123
+ re.compile(r"^\s*package\s+\w+", re.MULTILINE),
124
+ re.compile(r"^\s*import\s*\(", re.MULTILINE),
125
+ re.compile(r":=\s*\w+"),
126
+ ],
127
+ "java": [
128
+ re.compile(r"^\s*public\s+class\s+\w+", re.MULTILINE),
129
+ re.compile(r"^\s*private\s+\w+\s+\w+", re.MULTILINE),
130
+ re.compile(r"^\s*public\s+static\s+void\s+main", re.MULTILINE),
131
+ re.compile(r"System\.(out|err)\.print"),
132
+ ],
133
+ "bash": [
134
+ re.compile(r"^#!/bin/(ba)?sh", re.MULTILINE),
135
+ re.compile(r"^\s*if\s+\[\[", re.MULTILINE),
136
+ re.compile(r"^\s*for\s+\w+\s+in", re.MULTILINE),
137
+ re.compile(r"\$\{\w+\}"),
138
+ re.compile(r"^\s*echo\s+", re.MULTILINE),
139
+ ],
140
+ "sql": [
141
+ re.compile(r"^\s*SELECT\s+", re.IGNORECASE | re.MULTILINE),
142
+ re.compile(r"^\s*INSERT\s+INTO", re.IGNORECASE | re.MULTILINE),
143
+ re.compile(r"^\s*UPDATE\s+\w+\s+SET", re.IGNORECASE | re.MULTILINE),
144
+ re.compile(r"^\s*CREATE\s+TABLE", re.IGNORECASE | re.MULTILINE),
145
+ re.compile(r"^\s*FROM\s+\w+", re.IGNORECASE | re.MULTILINE),
146
+ ],
147
+ "yaml": [
148
+ re.compile(r"^\s*\w+:\s*$", re.MULTILINE),
149
+ re.compile(r"^\s*-\s+\w+:", re.MULTILINE),
150
+ re.compile(r"^\s*---\s*$", re.MULTILINE),
151
+ ],
152
+ "json": [
153
+ re.compile(r'^\s*\{[\s\S]*"\w+"\s*:\s*', re.MULTILINE),
154
+ re.compile(r"^\s*\[[\s\S]*\]", re.MULTILINE),
155
+ ],
156
+ "toml": [
157
+ re.compile(r"^\s*\[\w+\]", re.MULTILINE),
158
+ re.compile(r"^\s*\[\[.+\]\]", re.MULTILINE),
159
+ ],
160
+ }
161
+
162
+
163
+ def detect_format(content: str) -> FormatInfo:
164
+ """Detect the format of content.
165
+
166
+ This function analyzes content to determine its likely format
167
+ (HTML, Markdown, Code, or plain text) using pattern matching.
168
+
169
+ Args:
170
+ content: The content to analyze
171
+
172
+ Returns:
173
+ FormatInfo with detected format, confidence, and metadata
174
+
175
+ Example:
176
+ ```python
177
+ info = detect_format("<html><body>Hello</body></html>")
178
+ assert info.format == ContentFormat.HTML
179
+ assert info.confidence > 0.8
180
+ ```
181
+ """
182
+ if not content or not content.strip():
183
+ return FormatInfo(format=ContentFormat.PLAIN_TEXT, confidence=1.0)
184
+
185
+ stripped = content.strip()
186
+
187
+ # Check for HTML first (most distinctive patterns)
188
+ html_score = _score_html(stripped)
189
+ if html_score >= 0.7:
190
+ return FormatInfo(
191
+ format=ContentFormat.HTML,
192
+ confidence=html_score,
193
+ metadata={"html_score": html_score},
194
+ )
195
+
196
+ # Check for code with language detection
197
+ code_result = _detect_code(stripped)
198
+ if code_result["is_code"]:
199
+ return FormatInfo(
200
+ format=ContentFormat.CODE,
201
+ confidence=code_result["confidence"],
202
+ language=code_result["language"],
203
+ metadata={"language_scores": code_result["scores"]},
204
+ )
205
+
206
+ # Check for markdown
207
+ md_score = _score_markdown(stripped)
208
+ if md_score >= 0.5:
209
+ return FormatInfo(
210
+ format=ContentFormat.MARKDOWN,
211
+ confidence=md_score,
212
+ metadata={"markdown_score": md_score},
213
+ )
214
+
215
+ # Default to plain text
216
+ # If we have some markdown patterns but low confidence, still might be markdown
217
+ if md_score > 0.2:
218
+ return FormatInfo(
219
+ format=ContentFormat.MARKDOWN,
220
+ confidence=md_score,
221
+ metadata={"markdown_score": md_score, "low_confidence": True},
222
+ )
223
+
224
+ return FormatInfo(format=ContentFormat.PLAIN_TEXT, confidence=0.8)
225
+
226
+
227
+ def _score_html(content: str) -> float:
228
+ """Score content for HTML likelihood.
229
+
230
+ Args:
231
+ content: Content to analyze
232
+
233
+ Returns:
234
+ Score from 0.0 to 1.0 indicating HTML likelihood
235
+ """
236
+ matches = 0
237
+ for pattern in HTML_PATTERNS:
238
+ if pattern.search(content):
239
+ matches += 1
240
+
241
+ # Normalize score based on number of patterns matched
242
+ # Need at least 2-3 patterns for high confidence
243
+ if matches == 0:
244
+ return 0.0
245
+ if matches == 1:
246
+ return 0.3
247
+ if matches == 2:
248
+ return 0.6
249
+ if matches == 3:
250
+ return 0.8
251
+
252
+ return min(1.0, 0.8 + (matches - 3) * 0.05)
253
+
254
+
255
+ def _score_markdown(content: str) -> float:
256
+ """Score content for Markdown likelihood.
257
+
258
+ Args:
259
+ content: Content to analyze
260
+
261
+ Returns:
262
+ Score from 0.0 to 1.0 indicating Markdown likelihood
263
+ """
264
+ matches = 0
265
+ for pattern in MARKDOWN_PATTERNS:
266
+ if pattern.search(content):
267
+ matches += 1
268
+
269
+ # Normalize score
270
+ if matches == 0:
271
+ return 0.0
272
+ if matches == 1:
273
+ return 0.3
274
+ if matches == 2:
275
+ return 0.5
276
+ if matches == 3:
277
+ return 0.7
278
+
279
+ return min(1.0, 0.7 + (matches - 3) * 0.05)
280
+
281
+
282
+ def _detect_code(content: str) -> dict[str, Any]:
283
+ """Detect if content is code and identify the language.
284
+
285
+ Args:
286
+ content: Content to analyze
287
+
288
+ Returns:
289
+ Dict with 'is_code', 'confidence', 'language', and 'scores'
290
+ """
291
+ # Skip short content
292
+ if len(content) < 20:
293
+ return {"is_code": False, "confidence": 0.0, "language": None, "scores": {}}
294
+
295
+ # Score each language
296
+ scores: dict[str, int] = {}
297
+ for lang, patterns in LANGUAGE_PATTERNS.items():
298
+ lang_score = 0
299
+ for pattern in patterns:
300
+ if pattern.search(content):
301
+ lang_score += 1
302
+ if lang_score > 0:
303
+ scores[lang] = lang_score
304
+
305
+ # Find best match
306
+ if not scores:
307
+ return {"is_code": False, "confidence": 0.0, "language": None, "scores": {}}
308
+
309
+ best_lang = max(scores, key=lambda k: scores[k])
310
+ best_score = scores[best_lang]
311
+
312
+ # Need at least 2 pattern matches for confidence
313
+ if best_score < 2:
314
+ return {"is_code": False, "confidence": 0.0, "language": None, "scores": scores}
315
+
316
+ # Calculate confidence based on number of matches
317
+ num_patterns = len(LANGUAGE_PATTERNS.get(best_lang, []))
318
+ confidence = min(0.95, best_score / max(num_patterns, 1) * 0.8 + 0.3)
319
+
320
+ return {
321
+ "is_code": True,
322
+ "confidence": confidence,
323
+ "language": best_lang,
324
+ "scores": scores,
325
+ }
326
+
327
+
328
+ @dataclass
329
+ class ProcessedPrompt:
330
+ """Result of full prompt processing pipeline.
331
+
332
+ Attributes:
333
+ markdown: The final processed markdown content
334
+ original_format: The detected format of the original content
335
+ detected_language: Programming language if content was code
336
+ confidence: Detection confidence
337
+ was_converted: Whether format conversion was applied
338
+ was_sanitized: Whether sanitization was applied
339
+ warnings: List of warning messages
340
+ changes: List of changes made to the content
341
+ secrets_found: List of detected secrets
342
+ blocked: Whether content was blocked from storage
343
+ block_reason: Reason for blocking if blocked=True
344
+ """
345
+
346
+ markdown: str
347
+ original_format: ContentFormat
348
+ detected_language: str | None = None
349
+ confidence: float = 1.0
350
+ was_converted: bool = False
351
+ was_sanitized: bool = False
352
+ warnings: list[str] = field(default_factory=list)
353
+ changes: list[str] = field(default_factory=list)
354
+ secrets_found: list[Any] = field(default_factory=list) # List[SecretMatch]
355
+ blocked: bool = False
356
+ block_reason: str | None = None