tsugite-cli 0.3.3__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 (101) hide show
  1. tsugite/__init__.py +6 -0
  2. tsugite/agent_composition.py +163 -0
  3. tsugite/agent_inheritance.py +479 -0
  4. tsugite/agent_preparation.py +236 -0
  5. tsugite/agent_runner/__init__.py +45 -0
  6. tsugite/agent_runner/helpers.py +106 -0
  7. tsugite/agent_runner/history_integration.py +248 -0
  8. tsugite/agent_runner/metrics.py +100 -0
  9. tsugite/agent_runner/runner.py +1879 -0
  10. tsugite/agent_runner/validation.py +70 -0
  11. tsugite/agent_utils.py +167 -0
  12. tsugite/attachments/__init__.py +65 -0
  13. tsugite/attachments/auto_context.py +199 -0
  14. tsugite/attachments/base.py +34 -0
  15. tsugite/attachments/file.py +51 -0
  16. tsugite/attachments/inline.py +31 -0
  17. tsugite/attachments/storage.py +178 -0
  18. tsugite/attachments/url.py +59 -0
  19. tsugite/attachments/youtube.py +101 -0
  20. tsugite/benchmark/__init__.py +62 -0
  21. tsugite/benchmark/config.py +183 -0
  22. tsugite/benchmark/core.py +292 -0
  23. tsugite/benchmark/discovery.py +377 -0
  24. tsugite/benchmark/evaluators.py +671 -0
  25. tsugite/benchmark/execution.py +657 -0
  26. tsugite/benchmark/metrics.py +204 -0
  27. tsugite/benchmark/reports.py +420 -0
  28. tsugite/benchmark/utils.py +288 -0
  29. tsugite/builtin_agents/chat-assistant.md +53 -0
  30. tsugite/builtin_agents/default.md +140 -0
  31. tsugite/builtin_agents.py +5 -0
  32. tsugite/cache.py +195 -0
  33. tsugite/cli/__init__.py +1042 -0
  34. tsugite/cli/agents.py +148 -0
  35. tsugite/cli/attachments.py +193 -0
  36. tsugite/cli/benchmark.py +663 -0
  37. tsugite/cli/cache.py +113 -0
  38. tsugite/cli/config.py +272 -0
  39. tsugite/cli/helpers.py +534 -0
  40. tsugite/cli/history.py +193 -0
  41. tsugite/cli/init.py +387 -0
  42. tsugite/cli/mcp.py +193 -0
  43. tsugite/cli/tools.py +419 -0
  44. tsugite/config.py +204 -0
  45. tsugite/console.py +48 -0
  46. tsugite/constants.py +21 -0
  47. tsugite/core/__init__.py +19 -0
  48. tsugite/core/agent.py +774 -0
  49. tsugite/core/executor.py +300 -0
  50. tsugite/core/memory.py +67 -0
  51. tsugite/core/tools.py +271 -0
  52. tsugite/docker_cli.py +270 -0
  53. tsugite/events/__init__.py +55 -0
  54. tsugite/events/base.py +46 -0
  55. tsugite/events/bus.py +62 -0
  56. tsugite/events/events.py +224 -0
  57. tsugite/exceptions.py +40 -0
  58. tsugite/history/__init__.py +29 -0
  59. tsugite/history/index.py +210 -0
  60. tsugite/history/models.py +106 -0
  61. tsugite/history/storage.py +157 -0
  62. tsugite/mcp_client.py +219 -0
  63. tsugite/mcp_config.py +174 -0
  64. tsugite/md_agents.py +751 -0
  65. tsugite/models.py +257 -0
  66. tsugite/renderer.py +151 -0
  67. tsugite/shell_tool_config.py +265 -0
  68. tsugite/templates/assistant.md +14 -0
  69. tsugite/tools/__init__.py +265 -0
  70. tsugite/tools/agents.py +312 -0
  71. tsugite/tools/edit_strategies.py +393 -0
  72. tsugite/tools/fs.py +329 -0
  73. tsugite/tools/http.py +239 -0
  74. tsugite/tools/interactive.py +430 -0
  75. tsugite/tools/shell.py +129 -0
  76. tsugite/tools/shell_tools.py +214 -0
  77. tsugite/tools/tasks.py +339 -0
  78. tsugite/tsugite.py +7 -0
  79. tsugite/ui/__init__.py +46 -0
  80. tsugite/ui/base.py +638 -0
  81. tsugite/ui/chat.py +265 -0
  82. tsugite/ui/chat.tcss +92 -0
  83. tsugite/ui/chat_history.py +286 -0
  84. tsugite/ui/helpers.py +102 -0
  85. tsugite/ui/jsonl.py +125 -0
  86. tsugite/ui/live_template.py +529 -0
  87. tsugite/ui/plain.py +419 -0
  88. tsugite/ui/textual_chat.py +642 -0
  89. tsugite/ui/textual_handler.py +225 -0
  90. tsugite/ui/widgets/__init__.py +6 -0
  91. tsugite/ui/widgets/base_scroll_log.py +27 -0
  92. tsugite/ui/widgets/message_list.py +121 -0
  93. tsugite/ui/widgets/thought_log.py +80 -0
  94. tsugite/ui_context.py +90 -0
  95. tsugite/utils.py +367 -0
  96. tsugite/xdg.py +104 -0
  97. tsugite_cli-0.3.3.dist-info/METADATA +325 -0
  98. tsugite_cli-0.3.3.dist-info/RECORD +101 -0
  99. tsugite_cli-0.3.3.dist-info/WHEEL +4 -0
  100. tsugite_cli-0.3.3.dist-info/entry_points.txt +5 -0
  101. tsugite_cli-0.3.3.dist-info/licenses/LICENSE +235 -0
@@ -0,0 +1,288 @@
1
+ """Common utilities for benchmark framework."""
2
+
3
+ import json
4
+ import re
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from .config import TEST_CATEGORIES
8
+
9
+
10
+ def extract_inline_field(content: str, label: str) -> Optional[str]:
11
+ """Extract single-line field values like Prompt or Expected Output.
12
+
13
+ Args:
14
+ content: Markdown content to search
15
+ label: Field label to extract (e.g., "Prompt", "Expected Output")
16
+
17
+ Returns:
18
+ Extracted field value or None if not found
19
+ """
20
+ pattern_label = re.escape(label)
21
+
22
+ # Try quoted format first
23
+ quoted = re.search(rf"\*\*{pattern_label}:\*\*\s*\"([^\"]+)\"", content)
24
+ if quoted:
25
+ return quoted.group(1).strip()
26
+
27
+ # Try block format
28
+ block = re.search(rf"\*\*{pattern_label}:\*\*\s*(.+?)(?=\n\*\*|$)", content, re.DOTALL)
29
+ if block:
30
+ return block.group(1).strip()
31
+
32
+ return None
33
+
34
+
35
+ def extract_block(content: str, label: str) -> Optional[str]:
36
+ """Extract multi-line blocks introduced by a bold label.
37
+
38
+ Args:
39
+ content: Markdown content to search
40
+ label: Block label (e.g., "Expected Behaviors")
41
+
42
+ Returns:
43
+ Block content or None if not found
44
+ """
45
+ pattern_label = re.escape(label)
46
+ match = re.search(rf"\*\*{pattern_label}:\*\*\n(.*?)(?=\n\*\*|\Z)", content, re.DOTALL)
47
+ if match:
48
+ return match.group(1).strip()
49
+ return None
50
+
51
+
52
+ def parse_bullet_list(block: Optional[str]) -> List[str]:
53
+ """Parse a bullet list from a markdown block.
54
+
55
+ Args:
56
+ block: Block containing bullet list
57
+
58
+ Returns:
59
+ List of bullet items (without leading "- ")
60
+ """
61
+ if not block:
62
+ return []
63
+ items = []
64
+ for line in block.splitlines():
65
+ line = line.strip()
66
+ if line.startswith("- "):
67
+ items.append(line[2:].strip())
68
+ return items
69
+
70
+
71
+ def coerce_value(value: str) -> Any:
72
+ """Convert string value to appropriate Python type.
73
+
74
+ Args:
75
+ value: String value to convert
76
+
77
+ Returns:
78
+ Converted value (bool, None, number, or string)
79
+ """
80
+ raw = value.strip()
81
+ lowered = raw.lower()
82
+
83
+ # Check for boolean
84
+ if lowered in {"true", "false"}:
85
+ return lowered == "true"
86
+
87
+ # Check for null
88
+ if lowered == "null":
89
+ return None
90
+
91
+ # Try JSON parsing first
92
+ try:
93
+ return json.loads(raw)
94
+ except (json.JSONDecodeError, TypeError):
95
+ pass
96
+
97
+ # Try numeric conversion
98
+ try:
99
+ if "." in raw:
100
+ return float(raw)
101
+ return int(raw)
102
+ except ValueError:
103
+ # Return as string, removing quotes
104
+ return raw.strip("'\"")
105
+
106
+
107
+ def parse_key_value_block(block: Optional[str]) -> Dict[str, Any]:
108
+ """Parse a key-value block from markdown.
109
+
110
+ Supports both simple key-value pairs and nested YAML structures:
111
+ - simple_key: value
112
+ - nested_key:
113
+ sub_key: value
114
+ another: value
115
+
116
+ Args:
117
+ block: Block containing key-value pairs (bullet list format)
118
+
119
+ Returns:
120
+ Dictionary of parsed key-value pairs
121
+ """
122
+ if not block:
123
+ return {}
124
+
125
+ result: Dict[str, Any] = {}
126
+ lines = block.splitlines()
127
+ i = 0
128
+
129
+ while i < len(lines):
130
+ line = lines[i]
131
+ stripped = line.strip()
132
+
133
+ # Skip non-bullet lines
134
+ if not stripped.startswith("- ") or ":" not in stripped:
135
+ i += 1
136
+ continue
137
+
138
+ # Extract key from bullet item
139
+ key_part = stripped[2:] # Remove "- "
140
+ key, raw_value = key_part.split(":", 1)
141
+ key = key.strip()
142
+ raw_value = raw_value.strip()
143
+
144
+ # Check if this is a nested structure (value is empty or whitespace)
145
+ if not raw_value:
146
+ # Collect indented lines that follow
147
+ nested_lines = []
148
+ indent_level = None
149
+ i += 1
150
+
151
+ while i < len(lines):
152
+ next_line = lines[i]
153
+
154
+ # Check if line is indented (part of nested structure)
155
+ if next_line and not next_line[0].isspace():
156
+ # Not indented, end of nested structure
157
+ break
158
+
159
+ if next_line.strip(): # Non-empty indented line
160
+ # Determine indent level from first indented line
161
+ if indent_level is None:
162
+ indent_level = len(next_line) - len(next_line.lstrip())
163
+
164
+ # Remove the base indent level to get relative indentation
165
+ if len(next_line) - len(next_line.lstrip()) >= indent_level:
166
+ nested_lines.append(next_line[indent_level:])
167
+ else:
168
+ nested_lines.append(next_line.lstrip())
169
+
170
+ i += 1
171
+
172
+ # Parse nested structure as YAML
173
+ if nested_lines:
174
+ import yaml
175
+
176
+ nested_yaml = "\n".join(nested_lines)
177
+ try:
178
+ result[key] = yaml.safe_load(nested_yaml)
179
+ except yaml.YAMLError:
180
+ # If YAML parsing fails, store as string
181
+ result[key] = nested_yaml
182
+ else:
183
+ result[key] = ""
184
+ else:
185
+ # Simple key-value pair on same line
186
+ result[key] = coerce_value(raw_value)
187
+ i += 1
188
+
189
+ return result
190
+
191
+
192
+ def get_test_category(test_id: str) -> str:
193
+ """Extract category from test ID.
194
+
195
+ Args:
196
+ test_id: Test identifier
197
+
198
+ Returns:
199
+ Category name (e.g., "basic", "tools", "unknown")
200
+ """
201
+ return TEST_CATEGORIES.get_category(test_id)
202
+
203
+
204
+ def extract_prompt_from_markdown(markdown_content: str) -> str:
205
+ """Derive a prompt from markdown content when none is provided explicitly.
206
+
207
+ Args:
208
+ markdown_content: Markdown content
209
+
210
+ Returns:
211
+ Extracted prompt or default message
212
+ """
213
+ for line in markdown_content.splitlines():
214
+ stripped = line.strip()
215
+ # Skip empty lines and headers
216
+ if not stripped or stripped.startswith("#"):
217
+ continue
218
+ return stripped
219
+ return "Describe the required task in detail."
220
+
221
+
222
+ def normalize_code(code: str) -> str:
223
+ """Normalize code for comparison by removing comments and extra whitespace.
224
+
225
+ Args:
226
+ code: Source code
227
+
228
+ Returns:
229
+ Normalized code
230
+ """
231
+ lines = []
232
+ for line in code.split("\n"):
233
+ # Remove comments (simplified - handles # and //)
234
+ line = re.sub(r"#.*$", "", line)
235
+ line = re.sub(r"//.*$", "", line)
236
+ line = line.strip()
237
+ if line:
238
+ lines.append(line)
239
+
240
+ return "\n".join(lines)
241
+
242
+
243
+ def json_similarity(obj1: Any, obj2: Any) -> float:
244
+ """Calculate similarity between JSON objects recursively.
245
+
246
+ Args:
247
+ obj1: First object
248
+ obj2: Second object
249
+
250
+ Returns:
251
+ Similarity score from 0.0 to 1.0
252
+ """
253
+ # Type mismatch
254
+ if type(obj1) is not type(obj2):
255
+ return 0.0
256
+
257
+ # Dictionary comparison
258
+ if isinstance(obj1, dict):
259
+ if not obj1 and not obj2:
260
+ return 1.0
261
+
262
+ all_keys = set(obj1.keys()) | set(obj2.keys())
263
+ if not all_keys:
264
+ return 1.0
265
+
266
+ key_scores = []
267
+ for key in all_keys:
268
+ if key in obj1 and key in obj2:
269
+ key_scores.append(json_similarity(obj1[key], obj2[key]))
270
+ else:
271
+ key_scores.append(0.0)
272
+
273
+ return sum(key_scores) / len(key_scores)
274
+
275
+ # List comparison
276
+ elif isinstance(obj1, list):
277
+ if len(obj1) != len(obj2):
278
+ return 0.5 if obj1 == obj2 else 0.0
279
+
280
+ if not obj1:
281
+ return 1.0
282
+
283
+ scores = [json_similarity(a, b) for a, b in zip(obj1, obj2)]
284
+ return sum(scores) / len(scores)
285
+
286
+ # Primitive comparison
287
+ else:
288
+ return 1.0 if obj1 == obj2 else 0.0
@@ -0,0 +1,53 @@
1
+ ---
2
+ name: chat-assistant
3
+ description: A conversational assistant that can respond naturally or use tools when needed
4
+ extends: none
5
+ text_mode: true
6
+ max_turns: 10
7
+ tools:
8
+ - read_file
9
+ - write_file
10
+ - list_files
11
+ - web_search
12
+ - fetch_text
13
+ - run
14
+ ---
15
+
16
+ You are a helpful conversational assistant with access to tools.
17
+
18
+ ## How to respond:
19
+
20
+ **For simple conversational questions:** Respond directly with just your Thought:
21
+ ```
22
+ Thought: Your answer here
23
+ ```
24
+
25
+ **When you need to use tools or get information:** Write a code block:
26
+ ```
27
+ Thought: I'll use [tool] to [action]
28
+ ```python
29
+ result = list_files(path=".")
30
+ final_answer(result)
31
+ ```
32
+ ```
33
+
34
+ ## Available tools you can use:
35
+
36
+ - `list_files(path=".", pattern="*")` - List files in a directory
37
+ - `read_file(path="file.txt")` - Read file contents
38
+ - `write_file(path="file.txt", content="...")` - Write to a file
39
+ - `web_search(query="...", max_results=5)` - Search the web and get a list of results
40
+ - Returns: `[{"title": "...", "url": "...", "snippet": "..."}]`
41
+ - **Important:** Format results nicely for the user! Extract relevant info from snippets and present clearly.
42
+ - Example for weather: Read the snippets and summarize the current conditions/forecast
43
+ - `fetch_text(url="...")` - Fetch full content from a webpage as text
44
+ - Use this when search snippets aren't enough and you need the full page
45
+ - `run(command="...")` - Run shell commands
46
+
47
+ **Important:** When the user asks about files, directories, or anything requiring system information, ALWAYS use the appropriate tool with a code block!
48
+
49
+ **Note:** When continuing a conversation, previous messages are automatically included in your context. You don't need to reference them explicitly - they're part of the conversation history.
50
+
51
+ ## Current Request
52
+
53
+ {{ user_prompt }}
@@ -0,0 +1,140 @@
1
+ ---
2
+ name: default
3
+ description: Default base agent with sensible defaults
4
+ extends: none
5
+ max_turns: 10
6
+ tools:
7
+ - spawn_agent
8
+ - read_file
9
+ - list_files
10
+ - task_*
11
+ - write_file
12
+ - edit_file
13
+ prefetch:
14
+ - tool: list_agents
15
+ args: {}
16
+ assign: available_agents
17
+ instructions: |
18
+ You are a helpful AI assistant running in the Tsugite agent framework.
19
+
20
+ Follow these guidelines:
21
+ - Be concise and direct in your responses
22
+ - Use available tools when they help accomplish the task
23
+ - Use task tracking tools (task_add, task_update, task_complete) to organize your work
24
+ - Complete all required tasks (optional tasks marked with ✨ are nice-to-have)
25
+ - Break down complex tasks into clear steps
26
+ - Ask clarifying questions when the task is ambiguous
27
+ {% if text_mode %}
28
+ - For simple responses: respond directly with ONLY "Thought: [answer]" - no additional explanation
29
+ - When using tools: write Python code blocks and call final_answer(result)
30
+ {% else %}
31
+ - Write Python code to accomplish tasks
32
+ - Call final_answer(result) when you've completed the task
33
+ {% endif %}
34
+
35
+ **IMPORTANT - Seeing Tool Results:**
36
+ - Tool results are NOT automatically visible to you in the next turn
37
+ - You MUST print() results if you want to see and use them later
38
+ - Example:
39
+ ```python
40
+ content = read_file("file.txt")
41
+ print(content) # Now you can see it in your next reasoning turn
42
+ ```
43
+ - Or use final_answer() to see and return the result immediately
44
+ ---
45
+ # Context
46
+
47
+ {% if is_interactive %}
48
+ **Interactive Mode**: You are currently in an interactive session with the user, you can ask questions to clarify the task.
49
+ {% else %}
50
+ **Non-Interactive Mode**: You are in a headless/non-interactive session. You cannot ask the user questions.
51
+ {% endif %}
52
+
53
+ {% if subagent_instructions is defined and subagent_instructions %}
54
+ {{ subagent_instructions }}
55
+ {% endif %}
56
+
57
+ **Note:** When continuing a conversation, previous messages are automatically included in your context as part of the conversation history. You don't need to reference them explicitly.
58
+
59
+ {% if step_number is defined %}
60
+
61
+ ## Multi-Step Execution
62
+
63
+ You are in step {{ step_number }} of {{ total_steps }} ({{ step_name }}).
64
+
65
+ **IMPORTANT Step Completion**:
66
+
67
+ - Complete ONLY the task assigned in this step
68
+ {% if text_mode %}- After completing the task, call final_answer(result) with your result
69
+ {% else %}- After completing the task, write a Python code block with final_answer(result)
70
+ - Example: ```python
71
+ final_answer("step result")
72
+
73
+ ```
74
+ {% endif %}- Do NOT generate additional conversational text after calling final_answer()
75
+ - The framework will automatically present the next step - you do not need to ask or wait
76
+ - Each step is independent - focus on this step's goal only
77
+
78
+ {% endif %}
79
+
80
+ {% if available_agents %}
81
+ ## Available Specialized Agents
82
+
83
+ You can delegate to these specialized agents when they match the task:
84
+
85
+ {{ available_agents }}
86
+
87
+ To delegate a task, use: `spawn_agent(agent_path, prompt)`
88
+
89
+ Only delegate when:
90
+ 1. A specialized agent clearly matches the task requirements
91
+ 2. The task would benefit from specialized knowledge or tools
92
+ 3. You can provide a clear, specific prompt for the agent
93
+
94
+ **CRITICAL: When a subagent fully completes the task, return its result immediately:**
95
+ ```python
96
+ result = spawn_agent("agents/code_review.md", "Review app.py for security issues")
97
+ final_answer(result) # STOP HERE - task is done, return the result
98
+ ```
99
+
100
+ **Example 2: Only process results further if the subagent output needs additional work**
101
+
102
+ ```python
103
+ # Spawn agent and store result
104
+ review = spawn_agent("agents/code_review.md", "Review app.py")
105
+ print(review) # IMPORTANT: Print so you can see it in your next turn!
106
+ # DON'T call final_answer() here - let the agent continue thinking
107
+ ```
108
+
109
+ Then in the next turn, you can:
110
+
111
+ - Analyze the review results (you'll see them because you printed)
112
+ - Spawn additional agents
113
+ - Combine data from multiple sources
114
+ - Finally call `final_answer()` when truly done
115
+
116
+ **Key principles:**
117
+
118
+ - **If the subagent result fully answers the user's request → call final_answer(result) immediately**
119
+ - Only process results further if you genuinely need to combine/transform them
120
+ - Don't waste turns analyzing results that already answer the question
121
+ - Tool/agent results are NOT automatically visible unless printed or passed to final_answer()
122
+
123
+ {% endif %}
124
+ {% if 'web_search' in tools %}
125
+
126
+ ## Web Search Guidelines
127
+
128
+ When searching the web:
129
+
130
+ - Use `web_search(query="...", max_results=5)` to get search results
131
+ - Returns: `[{"title": "...", "url": "...", "snippet": "..."}]`
132
+ - **Important:** Format results nicely for the user! Extract and summarize relevant information from snippets
133
+ - Use `fetch_text(url="...")` to get full page content when snippets aren't enough
134
+ {% endif %}
135
+
136
+ {{ task_summary }}
137
+
138
+ # Task
139
+
140
+ {{ user_prompt }}
@@ -0,0 +1,5 @@
1
+ """Package-provided agent utilities.
2
+
3
+ Package-provided agents are stored as .md files in the builtin_agents/ directory
4
+ and are discovered automatically through the agent resolution system.
5
+ """
tsugite/cache.py ADDED
@@ -0,0 +1,195 @@
1
+ """Cache management for attachment content."""
2
+
3
+ import hashlib
4
+ import json
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Dict, Optional
8
+
9
+ from tsugite.xdg import get_xdg_cache_path
10
+
11
+
12
+ def get_cache_key(source: str) -> str:
13
+ """Generate cache key from source URL or path.
14
+
15
+ Args:
16
+ source: URL or file path to cache
17
+
18
+ Returns:
19
+ 16-character hex string (first 16 chars of SHA256)
20
+ """
21
+ return hashlib.sha256(source.encode()).hexdigest()[:16]
22
+
23
+
24
+ def get_cache_file_path(source: str) -> Path:
25
+ """Get cache file path for a source.
26
+
27
+ Args:
28
+ source: URL or file path
29
+
30
+ Returns:
31
+ Path to cache file
32
+ """
33
+ cache_dir = get_xdg_cache_path("attachments")
34
+ cache_key = get_cache_key(source)
35
+ return cache_dir / f"{cache_key}.txt"
36
+
37
+
38
+ def get_cached_content(source: str) -> Optional[str]:
39
+ """Get cached content if exists.
40
+
41
+ Args:
42
+ source: URL or file path
43
+
44
+ Returns:
45
+ Cached content if exists, None otherwise
46
+ """
47
+ cache_file = get_cache_file_path(source)
48
+ if cache_file.exists():
49
+ return cache_file.read_text(encoding="utf-8")
50
+ return None
51
+
52
+
53
+ def save_to_cache(source: str, content: str) -> None:
54
+ """Save content to cache.
55
+
56
+ Args:
57
+ source: URL or file path
58
+ content: Content to cache
59
+
60
+ Raises:
61
+ RuntimeError: If cache save fails
62
+ """
63
+ cache_file = get_cache_file_path(source)
64
+
65
+ # Ensure cache directory exists
66
+ cache_file.parent.mkdir(parents=True, exist_ok=True)
67
+
68
+ try:
69
+ cache_file.write_text(content, encoding="utf-8")
70
+
71
+ # Update metadata
72
+ _update_cache_metadata(source, cache_file)
73
+ except IOError as e:
74
+ raise RuntimeError(f"Failed to save cache for {source}: {e}") from e
75
+
76
+
77
+ def _update_cache_metadata(source: str, cache_file: Path) -> None:
78
+ """Update cache metadata file.
79
+
80
+ Args:
81
+ source: Original source URL/path
82
+ cache_file: Path to cache file
83
+ """
84
+ metadata_file = get_xdg_cache_path("attachments") / "metadata.json"
85
+
86
+ # Load existing metadata
87
+ if metadata_file.exists():
88
+ try:
89
+ with open(metadata_file, "r", encoding="utf-8") as f:
90
+ metadata = json.load(f)
91
+ except (json.JSONDecodeError, IOError):
92
+ metadata = {}
93
+ else:
94
+ metadata = {}
95
+
96
+ # Update entry
97
+ cache_key = get_cache_key(source)
98
+ metadata[cache_key] = {
99
+ "source": source,
100
+ "cached_at": datetime.now(timezone.utc).isoformat(),
101
+ "size": cache_file.stat().st_size,
102
+ }
103
+
104
+ # Save metadata
105
+ metadata_file.parent.mkdir(parents=True, exist_ok=True)
106
+ try:
107
+ with open(metadata_file, "w", encoding="utf-8") as f:
108
+ json.dump(metadata, f, indent=2, ensure_ascii=False)
109
+ except IOError:
110
+ # Metadata update failure is not critical
111
+ pass
112
+
113
+
114
+ def clear_cache(source: Optional[str] = None) -> int:
115
+ """Clear cache for source, or entire cache if source is None.
116
+
117
+ Args:
118
+ source: URL or file path to clear, or None to clear all
119
+
120
+ Returns:
121
+ Number of cache files deleted
122
+ """
123
+ cache_dir = get_xdg_cache_path("attachments")
124
+
125
+ if not cache_dir.exists():
126
+ return 0
127
+
128
+ count = 0
129
+
130
+ if source:
131
+ # Clear specific cache entry
132
+ cache_file = get_cache_file_path(source)
133
+ if cache_file.exists():
134
+ cache_file.unlink()
135
+ count = 1
136
+
137
+ # Update metadata
138
+ metadata_file = cache_dir / "metadata.json"
139
+ if metadata_file.exists():
140
+ try:
141
+ with open(metadata_file, "r", encoding="utf-8") as f:
142
+ metadata = json.load(f)
143
+
144
+ cache_key = get_cache_key(source)
145
+ if cache_key in metadata:
146
+ del metadata[cache_key]
147
+
148
+ with open(metadata_file, "w", encoding="utf-8") as f:
149
+ json.dump(metadata, f, indent=2, ensure_ascii=False)
150
+ except (json.JSONDecodeError, IOError):
151
+ pass
152
+ else:
153
+ # Clear all cache files
154
+ for cache_file in cache_dir.glob("*.txt"):
155
+ cache_file.unlink()
156
+ count += 1
157
+
158
+ # Clear metadata
159
+ metadata_file = cache_dir / "metadata.json"
160
+ if metadata_file.exists():
161
+ metadata_file.unlink()
162
+
163
+ return count
164
+
165
+
166
+ def list_cache() -> Dict[str, Dict[str, any]]:
167
+ """List all cached entries with metadata.
168
+
169
+ Returns:
170
+ Dictionary mapping cache keys to metadata
171
+ """
172
+ metadata_file = get_xdg_cache_path("attachments") / "metadata.json"
173
+
174
+ if not metadata_file.exists():
175
+ return {}
176
+
177
+ try:
178
+ with open(metadata_file, "r", encoding="utf-8") as f:
179
+ return json.load(f)
180
+ except (json.JSONDecodeError, IOError):
181
+ return {}
182
+
183
+
184
+ def get_cache_info(source: str) -> Optional[Dict[str, any]]:
185
+ """Get cache metadata for a specific source.
186
+
187
+ Args:
188
+ source: URL or file path
189
+
190
+ Returns:
191
+ Metadata dict if cached, None otherwise
192
+ """
193
+ metadata = list_cache()
194
+ cache_key = get_cache_key(source)
195
+ return metadata.get(cache_key)