refactor - plugin names and builtin plugins

frdel committed Mar 10, 2026 at 22:20 UTC 6515626242fb0d397421984421168278489ad88d
133 files changed +1327 -743
helpers/email_client.py
+587 -587
@@ -1,587 +1,587 @@
1 -import asyncio
2 -import email
3 -import os
4 -import re
5 -import uuid
6 -from dataclasses import dataclass
7 -from email.header import decode_header
8 -from email.message import Message as EmailMessage
9 -from fnmatch import fnmatch
10 -from typing import Any, Dict, List, Optional, Tuple
11 -
12 -import html2text
13 -from bs4 import BeautifulSoup
14 -from imapclient import IMAPClient
15 -
16 -from helpers import files
17 -from helpers.errors import RepairableException, format_error
18 -from helpers.print_style import PrintStyle
19 -
20 -
21 -@dataclass
22 -class Message:
23 - """Email message representation with sender, subject, body, and attachments."""
24 - sender: str
25 - subject: str
26 - body: str
27 - attachments: List[str]
28 -
29 -
30 -class EmailClient:
31 - """
32 - Async email client for reading messages from IMAP and Exchange servers.
33 -
34 - """
35 -
36 - def __init__(
37 - self,
38 - account_type: str = "imap",
39 - server: str = "",
40 - port: int = 993,
41 - username: str = "",
42 - password: str = "",
43 - options: Optional[Dict[str, Any]] = None,
44 - ):
45 - """
46 - Initialize email client with connection parameters.
47 -
48 - Args:
49 - account_type: Type of account - "imap" or "exchange"
50 - server: Mail server address (e.g., "imap.gmail.com")
51 - port: Server port (default 993 for IMAP SSL)
52 - username: Email account username
53 - password: Email account password
54 - options: Optional configuration dict with keys:
55 - - ssl: Use SSL/TLS (default: True)
56 - - timeout: Connection timeout in seconds (default: 30)
57 - """
58 - self.account_type = account_type.lower()
59 - self.server = server
60 - self.port = port
61 - self.username = username
62 - self.password = password
63 - self.options = options or {}
64 -
65 - # Default options
66 - self.ssl = self.options.get("ssl", True)
67 - self.timeout = self.options.get("timeout", 30)
68 -
69 - self.client: Optional[IMAPClient] = None
70 - self.exchange_account = None
71 -
72 - async def connect(self) -> None:
73 - """Establish connection to email server."""
74 - try:
75 - if self.account_type == "imap":
76 - await self._connect_imap()
77 - elif self.account_type == "exchange":
78 - await self._connect_exchange()
79 - else:
80 - raise RepairableException(
81 - f"Unsupported account type: {self.account_type}. "
82 - "Supported types: 'imap', 'exchange'"
83 - )
84 - except Exception as e:
85 - err = format_error(e)
86 - PrintStyle.error(f"Failed to connect to email server: {err}")
87 - raise RepairableException(f"Email connection failed: {err}") from e
88 -
89 - async def _connect_imap(self) -> None:
90 - """Establish IMAP connection."""
91 - loop = asyncio.get_event_loop()
92 -
93 - def _sync_connect():
94 - client = IMAPClient(self.server, port=self.port, ssl=self.ssl, timeout=self.timeout)
95 - # Increase line length limit to handle large emails (default is 10000)
96 - # This fixes "line too long" errors for emails with large headers or embedded content
97 - client._imap._maxline = 100000
98 - client.login(self.username, self.password)
99 - return client
100 -
101 - self.client = await loop.run_in_executor(None, _sync_connect)
102 - PrintStyle.standard(f"Connected to IMAP server: {self.server}")
103 -
104 - async def _connect_exchange(self) -> None:
105 - """Establish Exchange connection."""
106 - try:
107 - from exchangelib import Account, Configuration, Credentials, DELEGATE
108 -
109 - loop = asyncio.get_event_loop()
110 -
111 - def _sync_connect():
112 - creds = Credentials(username=self.username, password=self.password)
113 - config = Configuration(server=self.server, credentials=creds)
114 - return Account(
115 - primary_smtp_address=self.username,
116 - config=config,
117 - autodiscover=False,
118 - access_type=DELEGATE
119 - )
120 -
121 - self.exchange_account = await loop.run_in_executor(None, _sync_connect)
122 - PrintStyle.standard(f"Connected to Exchange server: {self.server}")
123 - except ImportError as e:
124 - raise RepairableException(
125 - "exchangelib not installed. Install with: pip install exchangelib>=5.4.3"
126 - ) from e
127 -
128 - async def disconnect(self) -> None:
129 - """Clean up connection."""
130 - try:
131 - if self.client:
132 - loop = asyncio.get_event_loop()
133 - await loop.run_in_executor(None, self.client.logout)
134 - self.client = None
135 - PrintStyle.standard("Disconnected from IMAP server")
136 - elif self.exchange_account:
137 - self.exchange_account = None
138 - PrintStyle.standard("Disconnected from Exchange server")
139 - except Exception as e:
140 - PrintStyle.error(f"Error during disconnect: {format_error(e)}")
141 -
142 - async def read_messages(
143 - self,
144 - download_folder: str,
145 - filter: Optional[Dict[str, Any]] = None,
146 - ) -> List[Message]:
147 - """
148 - Read messages based on filter criteria.
149 -
150 - Args:
151 - download_folder: Folder to save attachments (relative to /a0/)
152 - filter: Filter criteria dict with keys:
153 - - unread: Boolean to filter unread messages (default: True)
154 - - sender: Sender pattern with wildcards (e.g., "*@company.com")
155 - - subject: Subject pattern with wildcards (e.g., "*invoice*")
156 - - since_date: Optional datetime for date filtering
157 -
158 - Returns:
159 - List of Message objects with attachments saved to download_folder
160 - """
161 - filter = filter or {}
162 -
163 - if self.account_type == "imap":
164 - return await self._fetch_imap_messages(download_folder, filter)
165 - elif self.account_type == "exchange":
166 - return await self._fetch_exchange_messages(download_folder, filter)
167 - else:
168 - raise RepairableException(f"Unsupported account type: {self.account_type}")
169 -
170 - async def _fetch_imap_messages(
171 - self,
172 - download_folder: str,
173 - filter: Dict[str, Any],
174 - ) -> List[Message]:
175 - """Fetch messages from IMAP server."""
176 - if not self.client:
177 - raise RepairableException("IMAP client not connected. Call connect() first.")
178 -
179 - loop = asyncio.get_event_loop()
180 - messages: List[Message] = []
181 -
182 - def _sync_fetch():
183 - # Select inbox
184 - self.client.select_folder("INBOX")
185 -
186 - # Build search criteria
187 - search_criteria = []
188 - if filter.get("unread", True):
189 - search_criteria.append("UNSEEN")
190 -
191 - if filter.get("since_date"):
192 - since_date = filter["since_date"]
193 - search_criteria.append(["SINCE", since_date])
194 -
195 - # Search for messages
196 - if not search_criteria:
197 - search_criteria = ["ALL"]
198 -
199 - message_ids = self.client.search(search_criteria)
200 - return message_ids
201 -
202 - message_ids = await loop.run_in_executor(None, _sync_fetch)
203 -
204 - if not message_ids:
205 - PrintStyle.hint("No messages found matching criteria")
206 - return messages
207 -
208 - PrintStyle.standard(f"Found {len(message_ids)} messages")
209 -
210 - # Fetch and process messages
211 - for msg_id in message_ids:
212 - try:
213 - msg = await self._fetch_and_parse_imap_message(msg_id, download_folder, filter)
214 - if msg:
215 - messages.append(msg)
216 - except Exception as e:
217 - PrintStyle.error(f"Error processing message {msg_id}: {format_error(e)}")
218 - continue
219 -
220 - return messages
221 -
222 - async def _fetch_and_parse_imap_message(
223 - self,
224 - msg_id: int,
225 - download_folder: str,
226 - filter: Dict[str, Any],
227 - ) -> Optional[Message]:
228 - """Fetch and parse a single IMAP message with retry logic for large messages."""
229 - loop = asyncio.get_event_loop()
230 -
231 - def _sync_fetch():
232 - try:
233 - # Try standard RFC822 fetch first
234 - return self.client.fetch([msg_id], ["RFC822"])[msg_id]
235 - except Exception as e:
236 - error_msg = str(e).lower()
237 - # If "line too long" error, try fetching in parts
238 - if "line too long" in error_msg or "fetch_failed" in error_msg:
239 - PrintStyle.warning(f"Message {msg_id} too large for standard fetch, trying alternative method")
240 - # Fetch headers and body separately to avoid line length issues
241 - try:
242 - envelope = self.client.fetch([msg_id], ["BODY.PEEK[]"])[msg_id]
243 - return envelope
244 - except Exception as e2:
245 - PrintStyle.error(f"Alternative fetch also failed for message {msg_id}: {format_error(e2)}")
246 - raise
247 - raise
248 -
249 - try:
250 - raw_msg = await loop.run_in_executor(None, _sync_fetch)
251 -
252 - # Extract email data from response
253 - if b"RFC822" in raw_msg:
254 - email_data = raw_msg[b"RFC822"]
255 - elif b"BODY[]" in raw_msg:
256 - email_data = raw_msg[b"BODY[]"]
257 - else:
258 - PrintStyle.error(f"Unexpected response format for message {msg_id}")
259 - return None
260 -
261 - email_msg = email.message_from_bytes(email_data)
262 -
263 - # Apply sender filter
264 - sender = self._decode_header(email_msg.get("From", ""))
265 - if filter.get("sender") and not fnmatch(sender, filter["sender"]):
266 - return None
267 -
268 - # Apply subject filter
269 - subject = self._decode_header(email_msg.get("Subject", ""))
270 - if filter.get("subject") and not fnmatch(subject, filter["subject"]):
271 - return None
272 -
273 - # Parse message
274 - return await self._parse_message(email_msg, download_folder)
275 -
276 - except Exception as e:
277 - PrintStyle.error(f"Failed to fetch/parse message {msg_id}: {format_error(e)}")
278 - return None
279 -
280 - async def _fetch_exchange_messages(
281 - self,
282 - download_folder: str,
283 - filter: Dict[str, Any],
284 - ) -> List[Message]:
285 - """Fetch messages from Exchange server."""
286 - if not self.exchange_account:
287 - raise RepairableException("Exchange account not connected. Call connect() first.")
288 -
289 - from exchangelib import Q
290 -
291 - loop = asyncio.get_event_loop()
292 - messages: List[Message] = []
293 -
294 - def _sync_fetch():
295 - # Build query
296 - query = None
297 - if filter.get("unread", True):
298 - query = Q(is_read=False)
299 -
300 - if filter.get("sender"):
301 - sender_pattern = filter["sender"].replace("*", "")
302 - sender_q = Q(sender__contains=sender_pattern)
303 - query = query & sender_q if query else sender_q
304 -
305 - if filter.get("subject"):
306 - subject_pattern = filter["subject"].replace("*", "")
307 - subject_q = Q(subject__contains=subject_pattern)
308 - query = query & subject_q if query else subject_q
309 -
310 - # Fetch messages from inbox
311 - inbox = self.exchange_account.inbox
312 - items = inbox.filter(query) if query else inbox.all()
313 - return list(items)
314 -
315 - exchange_messages = await loop.run_in_executor(None, _sync_fetch)
316 -
317 - PrintStyle.standard(f"Found {len(exchange_messages)} Exchange messages")
318 -
319 - # Process messages
320 - for ex_msg in exchange_messages:
321 - try:
322 - msg = await self._parse_exchange_message(ex_msg, download_folder)
323 - if msg:
324 - messages.append(msg)
325 - except Exception as e:
326 - PrintStyle.error(f"Error processing Exchange message: {format_error(e)}")
327 - continue
328 -
329 - return messages
330 -
331 - async def _parse_exchange_message(
332 - self,
333 - ex_msg,
334 - download_folder: str,
335 - ) -> Message:
336 - """Parse an Exchange message."""
337 - loop = asyncio.get_event_loop()
338 -
339 - def _get_body():
340 - return str(ex_msg.text_body or ex_msg.body or "")
341 -
342 - body = await loop.run_in_executor(None, _get_body)
343 -
344 - # Process HTML if present
345 - if ex_msg.body and str(ex_msg.body).strip().startswith("<"):
346 - body = self._html_to_text(str(ex_msg.body))
347 -
348 - # Save attachments
349 - attachment_paths = []
350 - if ex_msg.attachments:
351 - for attachment in ex_msg.attachments:
352 - if hasattr(attachment, "content"):
353 - path = await self._save_attachment_bytes(
354 - attachment.name,
355 - attachment.content,
356 - download_folder
357 - )
358 - attachment_paths.append(path)
359 -
360 - return Message(
361 - sender=str(ex_msg.sender.email_address) if ex_msg.sender else "",
362 - subject=str(ex_msg.subject or ""),
363 - body=body,
364 - attachments=attachment_paths
365 - )
366 -
367 - async def _parse_message(
368 - self,
369 - email_msg: EmailMessage,
370 - download_folder: str,
371 - ) -> Message:
372 - """
373 - Parse email message and extract content with inline attachments.
374 -
375 - Processes multipart messages, converts HTML to text, and maintains
376 - positional context for inline attachments.
377 - """
378 - sender = self._decode_header(email_msg.get("From", ""))
379 - subject = self._decode_header(email_msg.get("Subject", ""))
380 -
381 - # Extract body and attachments
382 - body = ""
383 - attachment_paths: List[str] = []
384 - cid_map: Dict[str, str] = {} # Map Content-ID to file paths
385 - body_parts: List[str] = [] # Track parts in order
386 -
387 - if email_msg.is_multipart():
388 - # Process parts in order to maintain attachment positions
389 - for part in email_msg.walk():
390 - content_type = part.get_content_type()
391 - content_disposition = str(part.get("Content-Disposition", ""))
392 -
393 - # Skip multipart containers
394 - if part.get_content_maintype() == "multipart":
395 - continue
396 -
397 - # Handle attachments
398 - if "attachment" in content_disposition or part.get("Content-ID"):
399 - filename = part.get_filename()
400 - if filename:
401 - filename = self._decode_header(filename)
402 - content = part.get_payload(decode=True)
403 - if content:
404 - path = await self._save_attachment_bytes(
405 - filename, content, download_folder
406 - )
407 - attachment_paths.append(path)
408 -
409 - # Map Content-ID for inline images
410 - cid = part.get("Content-ID")
411 - if cid:
412 - cid = cid.strip("<>")
413 - cid_map[cid] = path
414 -
415 - # Add positional marker for non-cid attachments
416 - # (cid attachments are positioned via HTML references)
417 - if not cid and body_parts:
418 - body_parts.append(f"\n[file://{path}]\n")
419 -
420 - # Handle body text
421 - elif content_type == "text/plain":
422 - if not body: # Use first text/plain as primary body
423 - charset = part.get_content_charset() or "utf-8"
424 - body = part.get_payload(decode=True).decode(charset, errors="ignore")
425 - body_parts.append(body)
426 -
427 - elif content_type == "text/html":
428 - if not body: # Use first text/html as primary body if no text/plain
429 - charset = part.get_content_charset() or "utf-8"
430 - html_content = part.get_payload(decode=True).decode(charset, errors="ignore")
431 - body = self._html_to_text(html_content, cid_map)
432 - body_parts.append(body)
433 -
434 - # Combine body parts if we built them up
435 - if len(body_parts) > 1:
436 - body = "".join(body_parts)
437 - else:
438 - # Single part message
439 - content_type = email_msg.get_content_type()
440 - charset = email_msg.get_content_charset() or "utf-8"
441 - content = email_msg.get_payload(decode=True)
442 - if content:
443 - if content_type == "text/html":
444 - body = self._html_to_text(content.decode(charset, errors="ignore"), cid_map)
445 - else:
446 - body = content.decode(charset, errors="ignore")
447 -
448 - return Message(
449 - sender=sender,
450 - subject=subject,
451 - body=body,
452 - attachments=attachment_paths
453 - )
454 -
455 - def _html_to_text(self, html_content: str, cid_map: Optional[Dict[str, str]] = None) -> str:
456 - """
457 - Convert HTML to plain text with inline attachment references.
458 -
459 - Replaces inline images with [file:///a0/...] markers to maintain
460 - positional context.
461 - """
462 - cid_map = cid_map or {}
463 -
464 - # Replace cid: references with file paths before conversion
465 - if cid_map:
466 - soup = BeautifulSoup(html_content, "html.parser")
467 - for img in soup.find_all("img"):
468 - src = img.get("src", "")
469 - if src.startswith("cid:"):
470 - cid = src[4:] # Remove "cid:" prefix
471 - if cid in cid_map:
472 - # Replace with file path marker
473 - file_marker = f"[file://{cid_map[cid]}]"
474 - img.replace_with(soup.new_string(file_marker))
475 - html_content = str(soup)
476 -
477 - # Convert HTML to text
478 - h = html2text.HTML2Text()
479 - h.ignore_links = False
480 - h.ignore_images = False
481 - h.ignore_emphasis = False
482 - h.body_width = 0 # Don't wrap lines
483 -
484 - text = h.handle(html_content)
485 -
486 - # Clean up extra whitespace
487 - text = re.sub(r"\n{3,}", "\n\n", text) # Max 2 consecutive newlines
488 - text = text.strip()
489 -
490 - return text
491 -
492 - async def _save_attachment_bytes(
493 - self,
494 - filename: str,
495 - content: bytes,
496 - download_folder: str,
497 - ) -> str:
498 - """
499 - Save attachment to disk and return absolute path.
500 -
501 - Uses Agent Zero's file helpers for path management.
502 - """
503 - # Sanitize filename
504 - filename = files.safe_file_name(filename)
505 -
506 - # Generate unique filename if needed
507 - unique_id = uuid.uuid4().hex[:8]
508 - name, ext = os.path.splitext(filename)
509 - unique_filename = f"{name}_{unique_id}{ext}"
510 -
511 - # Build relative path and save
512 - relative_path = os.path.join(download_folder, unique_filename)
513 - files.write_file_bin(relative_path, content)
514 -
515 - # Return absolute path
516 - abs_path = files.get_abs_path(relative_path)
517 - return abs_path
518 -
519 - def _decode_header(self, header: str) -> str:
520 - """Decode email header handling various encodings."""
521 - if not header:
522 - return ""
523 -
524 - decoded_parts = []
525 - for part, encoding in decode_header(header):
526 - if isinstance(part, bytes):
527 - decoded_parts.append(part.decode(encoding or "utf-8", errors="ignore"))
528 - else:
529 - decoded_parts.append(str(part))
530 -
531 - return " ".join(decoded_parts)
532 -
533 -
534 -async def read_messages(
535 - account_type: str = "imap",
536 - server: str = "",
537 - port: int = 993,
538 - username: str = "",
539 - password: str = "",
540 - download_folder: str = "usr/email",
541 - options: Optional[Dict[str, Any]] = None,
542 - filter: Optional[Dict[str, Any]] = None,
543 -) -> List[Message]:
544 - """
545 - Convenience wrapper for reading email messages.
546 -
547 - Automatically handles connection and disconnection.
548 -
549 - Args:
550 - account_type: "imap" or "exchange"
551 - server: Mail server address
552 - port: Server port (default 993 for IMAP SSL)
553 - username: Email username
554 - password: Email password
555 - download_folder: Folder to save attachments (relative to /a0/)
556 - options: Optional configuration dict
557 - filter: Filter criteria dict
558 -
559 - Returns:
560 - List of Message objects
561 -
562 - Example:
563 - from helpers.email_client import read_messages
564 - messages = await read_messages(
565 - server="imap.gmail.com",
566 - port=993,
567 - username=secrets.get("EMAIL_USER"),
568 - password=secrets.get("EMAIL_PASSWORD"),
569 - download_folder="tmp/email/inbox",
570 - filter={"unread": True, "sender": "*@company.com"}
571 - )
572 - """
573 - client = EmailClient(
574 - account_type=account_type,
575 - server=server,
576 - port=port,
577 - username=username,
578 - password=password,
579 - options=options,
580 - )
581 -
582 - try:
583 - await client.connect()
584 - messages = await client.read_messages(download_folder, filter)
585 - return messages
586 - finally:
587 - await client.disconnect()
1 +# import asyncio
2 +# import email
3 +# import os
4 +# import re
5 +# import uuid
6 +# from dataclasses import dataclass
7 +# from email.header import decode_header
8 +# from email.message import Message as EmailMessage
9 +# from fnmatch import fnmatch
10 +# from typing import Any, Dict, List, Optional, Tuple
11 +
12 +# import html2text
13 +# from bs4 import BeautifulSoup
14 +# from imapclient import IMAPClient
15 +
16 +# from helpers import files
17 +# from helpers.errors import RepairableException, format_error
18 +# from helpers.print_style import PrintStyle
19 +
20 +
21 +# @dataclass
22 +# class Message:
23 +# """Email message representation with sender, subject, body, and attachments."""
24 +# sender: str
25 +# subject: str
26 +# body: str
27 +# attachments: List[str]
28 +
29 +
30 +# class EmailClient:
31 +# """
32 +# Async email client for reading messages from IMAP and Exchange servers.
33 +
34 +# """
35 +
36 +# def __init__(
37 +# self,
38 +# account_type: str = "imap",
39 +# server: str = "",
40 +# port: int = 993,
41 +# username: str = "",
42 +# password: str = "",
43 +# options: Optional[Dict[str, Any]] = None,
44 +# ):
45 +# """
46 +# Initialize email client with connection parameters.
47 +
48 +# Args:
49 +# account_type: Type of account - "imap" or "exchange"
50 +# server: Mail server address (e.g., "imap.gmail.com")
51 +# port: Server port (default 993 for IMAP SSL)
52 +# username: Email account username
53 +# password: Email account password
54 +# options: Optional configuration dict with keys:
55 +# - ssl: Use SSL/TLS (default: True)
56 +# - timeout: Connection timeout in seconds (default: 30)
57 +# """
58 +# self.account_type = account_type.lower()
59 +# self.server = server
60 +# self.port = port
61 +# self.username = username
62 +# self.password = password
63 +# self.options = options or {}
64 +
65 +# # Default options
66 +# self.ssl = self.options.get("ssl", True)
67 +# self.timeout = self.options.get("timeout", 30)
68 +
69 +# self.client: Optional[IMAPClient] = None
70 +# self.exchange_account = None
71 +
72 +# async def connect(self) -> None:
73 +# """Establish connection to email server."""
74 +# try:
75 +# if self.account_type == "imap":
76 +# await self._connect_imap()
77 +# elif self.account_type == "exchange":
78 +# await self._connect_exchange()
79 +# else:
80 +# raise RepairableException(
81 +# f"Unsupported account type: {self.account_type}. "
82 +# "Supported types: 'imap', 'exchange'"
83 +# )
84 +# except Exception as e:
85 +# err = format_error(e)
86 +# PrintStyle.error(f"Failed to connect to email server: {err}")
87 +# raise RepairableException(f"Email connection failed: {err}") from e
88 +
89 +# async def _connect_imap(self) -> None:
90 +# """Establish IMAP connection."""
91 +# loop = asyncio.get_event_loop()
92 +
93 +# def _sync_connect():
94 +# client = IMAPClient(self.server, port=self.port, ssl=self.ssl, timeout=self.timeout)
95 +# # Increase line length limit to handle large emails (default is 10000)
96 +# # This fixes "line too long" errors for emails with large headers or embedded content
97 +# client._imap._maxline = 100000
98 +# client.login(self.username, self.password)
99 +# return client
100 +
101 +# self.client = await loop.run_in_executor(None, _sync_connect)
102 +# PrintStyle.standard(f"Connected to IMAP server: {self.server}")
103 +
104 +# async def _connect_exchange(self) -> None:
105 +# """Establish Exchange connection."""
106 +# try:
107 +# from exchangelib import Account, Configuration, Credentials, DELEGATE
108 +
109 +# loop = asyncio.get_event_loop()
110 +
111 +# def _sync_connect():
112 +# creds = Credentials(username=self.username, password=self.password)
113 +# config = Configuration(server=self.server, credentials=creds)
114 +# return Account(
115 +# primary_smtp_address=self.username,
116 +# config=config,
117 +# autodiscover=False,
118 +# access_type=DELEGATE
119 +# )
120 +
121 +# self.exchange_account = await loop.run_in_executor(None, _sync_connect)
122 +# PrintStyle.standard(f"Connected to Exchange server: {self.server}")
123 +# except ImportError as e:
124 +# raise RepairableException(
125 +# "exchangelib not installed. Install with: pip install exchangelib>=5.4.3"
126 +# ) from e
127 +
128 +# async def disconnect(self) -> None:
129 +# """Clean up connection."""
130 +# try:
131 +# if self.client:
132 +# loop = asyncio.get_event_loop()
133 +# await loop.run_in_executor(None, self.client.logout)
134 +# self.client = None
135 +# PrintStyle.standard("Disconnected from IMAP server")
136 +# elif self.exchange_account:
137 +# self.exchange_account = None
138 +# PrintStyle.standard("Disconnected from Exchange server")
139 +# except Exception as e:
140 +# PrintStyle.error(f"Error during disconnect: {format_error(e)}")
141 +
142 +# async def read_messages(
143 +# self,
144 +# download_folder: str,
145 +# filter: Optional[Dict[str, Any]] = None,
146 +# ) -> List[Message]:
147 +# """
148 +# Read messages based on filter criteria.
149 +
150 +# Args:
151 +# download_folder: Folder to save attachments (relative to /a0/)
152 +# filter: Filter criteria dict with keys:
153 +# - unread: Boolean to filter unread messages (default: True)
154 +# - sender: Sender pattern with wildcards (e.g., "*@company.com")
155 +# - subject: Subject pattern with wildcards (e.g., "*invoice*")
156 +# - since_date: Optional datetime for date filtering
157 +
158 +# Returns:
159 +# List of Message objects with attachments saved to download_folder
160 +# """
161 +# filter = filter or {}
162 +
163 +# if self.account_type == "imap":
164 +# return await self._fetch_imap_messages(download_folder, filter)
165 +# elif self.account_type == "exchange":
166 +# return await self._fetch_exchange_messages(download_folder, filter)
167 +# else:
168 +# raise RepairableException(f"Unsupported account type: {self.account_type}")
169 +
170 +# async def _fetch_imap_messages(
171 +# self,
172 +# download_folder: str,
173 +# filter: Dict[str, Any],
174 +# ) -> List[Message]:
175 +# """Fetch messages from IMAP server."""
176 +# if not self.client:
177 +# raise RepairableException("IMAP client not connected. Call connect() first.")
178 +
179 +# loop = asyncio.get_event_loop()
180 +# messages: List[Message] = []
181 +
182 +# def _sync_fetch():
183 +# # Select inbox
184 +# self.client.select_folder("INBOX")
185 +
186 +# # Build search criteria
187 +# search_criteria = []
188 +# if filter.get("unread", True):
189 +# search_criteria.append("UNSEEN")
190 +
191 +# if filter.get("since_date"):
192 +# since_date = filter["since_date"]
193 +# search_criteria.append(["SINCE", since_date])
194 +
195 +# # Search for messages
196 +# if not search_criteria:
197 +# search_criteria = ["ALL"]
198 +
199 +# message_ids = self.client.search(search_criteria)
200 +# return message_ids
201 +
202 +# message_ids = await loop.run_in_executor(None, _sync_fetch)
203 +
204 +# if not message_ids:
205 +# PrintStyle.hint("No messages found matching criteria")
206 +# return messages
207 +
208 +# PrintStyle.standard(f"Found {len(message_ids)} messages")
209 +
210 +# # Fetch and process messages
211 +# for msg_id in message_ids:
212 +# try:
213 +# msg = await self._fetch_and_parse_imap_message(msg_id, download_folder, filter)
214 +# if msg:
215 +# messages.append(msg)
216 +# except Exception as e:
217 +# PrintStyle.error(f"Error processing message {msg_id}: {format_error(e)}")
218 +# continue
219 +
220 +# return messages
221 +
222 +# async def _fetch_and_parse_imap_message(
223 +# self,
224 +# msg_id: int,
225 +# download_folder: str,
226 +# filter: Dict[str, Any],
227 +# ) -> Optional[Message]:
228 +# """Fetch and parse a single IMAP message with retry logic for large messages."""
229 +# loop = asyncio.get_event_loop()
230 +
231 +# def _sync_fetch():
232 +# try:
233 +# # Try standard RFC822 fetch first
234 +# return self.client.fetch([msg_id], ["RFC822"])[msg_id]
235 +# except Exception as e:
236 +# error_msg = str(e).lower()
237 +# # If "line too long" error, try fetching in parts
238 +# if "line too long" in error_msg or "fetch_failed" in error_msg:
239 +# PrintStyle.warning(f"Message {msg_id} too large for standard fetch, trying alternative method")
240 +# # Fetch headers and body separately to avoid line length issues
241 +# try:
242 +# envelope = self.client.fetch([msg_id], ["BODY.PEEK[]"])[msg_id]
243 +# return envelope
244 +# except Exception as e2:
245 +# PrintStyle.error(f"Alternative fetch also failed for message {msg_id}: {format_error(e2)}")
246 +# raise
247 +# raise
248 +
249 +# try:
250 +# raw_msg = await loop.run_in_executor(None, _sync_fetch)
251 +
252 +# # Extract email data from response
253 +# if b"RFC822" in raw_msg:
254 +# email_data = raw_msg[b"RFC822"]
255 +# elif b"BODY[]" in raw_msg:
256 +# email_data = raw_msg[b"BODY[]"]
257 +# else:
258 +# PrintStyle.error(f"Unexpected response format for message {msg_id}")
259 +# return None
260 +
261 +# email_msg = email.message_from_bytes(email_data)
262 +
263 +# # Apply sender filter
264 +# sender = self._decode_header(email_msg.get("From", ""))
265 +# if filter.get("sender") and not fnmatch(sender, filter["sender"]):
266 +# return None
267 +
268 +# # Apply subject filter
269 +# subject = self._decode_header(email_msg.get("Subject", ""))
270 +# if filter.get("subject") and not fnmatch(subject, filter["subject"]):
271 +# return None
272 +
273 +# # Parse message
274 +# return await self._parse_message(email_msg, download_folder)
275 +
276 +# except Exception as e:
277 +# PrintStyle.error(f"Failed to fetch/parse message {msg_id}: {format_error(e)}")
278 +# return None
279 +
280 +# async def _fetch_exchange_messages(
281 +# self,
282 +# download_folder: str,
283 +# filter: Dict[str, Any],
284 +# ) -> List[Message]:
285 +# """Fetch messages from Exchange server."""
286 +# if not self.exchange_account:
287 +# raise RepairableException("Exchange account not connected. Call connect() first.")
288 +
289 +# from exchangelib import Q
290 +
291 +# loop = asyncio.get_event_loop()
292 +# messages: List[Message] = []
293 +
294 +# def _sync_fetch():
295 +# # Build query
296 +# query = None
297 +# if filter.get("unread", True):
298 +# query = Q(is_read=False)
299 +
300 +# if filter.get("sender"):
301 +# sender_pattern = filter["sender"].replace("*", "")
302 +# sender_q = Q(sender__contains=sender_pattern)
303 +# query = query & sender_q if query else sender_q
304 +
305 +# if filter.get("subject"):
306 +# subject_pattern = filter["subject"].replace("*", "")
307 +# subject_q = Q(subject__contains=subject_pattern)
308 +# query = query & subject_q if query else subject_q
309 +
310 +# # Fetch messages from inbox
311 +# inbox = self.exchange_account.inbox
312 +# items = inbox.filter(query) if query else inbox.all()
313 +# return list(items)
314 +
315 +# exchange_messages = await loop.run_in_executor(None, _sync_fetch)
316 +
317 +# PrintStyle.standard(f"Found {len(exchange_messages)} Exchange messages")
318 +
319 +# # Process messages
320 +# for ex_msg in exchange_messages:
321 +# try:
322 +# msg = await self._parse_exchange_message(ex_msg, download_folder)
323 +# if msg:
324 +# messages.append(msg)
325 +# except Exception as e:
326 +# PrintStyle.error(f"Error processing Exchange message: {format_error(e)}")
327 +# continue
328 +
329 +# return messages
330 +
331 +# async def _parse_exchange_message(
332 +# self,
333 +# ex_msg,
334 +# download_folder: str,
335 +# ) -> Message:
336 +# """Parse an Exchange message."""
337 +# loop = asyncio.get_event_loop()
338 +
339 +# def _get_body():
340 +# return str(ex_msg.text_body or ex_msg.body or "")
341 +
342 +# body = await loop.run_in_executor(None, _get_body)
343 +
344 +# # Process HTML if present
345 +# if ex_msg.body and str(ex_msg.body).strip().startswith("<"):
346 +# body = self._html_to_text(str(ex_msg.body))
347 +
348 +# # Save attachments
349 +# attachment_paths = []
350 +# if ex_msg.attachments:
351 +# for attachment in ex_msg.attachments:
352 +# if hasattr(attachment, "content"):
353 +# path = await self._save_attachment_bytes(
354 +# attachment.name,
355 +# attachment.content,
356 +# download_folder
357 +# )
358 +# attachment_paths.append(path)
359 +
360 +# return Message(
361 +# sender=str(ex_msg.sender.email_address) if ex_msg.sender else "",
362 +# subject=str(ex_msg.subject or ""),
363 +# body=body,
364 +# attachments=attachment_paths
365 +# )
366 +
367 +# async def _parse_message(
368 +# self,
369 +# email_msg: EmailMessage,
370 +# download_folder: str,
371 +# ) -> Message:
372 +# """
373 +# Parse email message and extract content with inline attachments.
374 +
375 +# Processes multipart messages, converts HTML to text, and maintains
376 +# positional context for inline attachments.
377 +# """
378 +# sender = self._decode_header(email_msg.get("From", ""))
379 +# subject = self._decode_header(email_msg.get("Subject", ""))
380 +
381 +# # Extract body and attachments
382 +# body = ""
383 +# attachment_paths: List[str] = []
384 +# cid_map: Dict[str, str] = {} # Map Content-ID to file paths
385 +# body_parts: List[str] = [] # Track parts in order
386 +
387 +# if email_msg.is_multipart():
388 +# # Process parts in order to maintain attachment positions
389 +# for part in email_msg.walk():
390 +# content_type = part.get_content_type()
391 +# content_disposition = str(part.get("Content-Disposition", ""))
392 +
393 +# # Skip multipart containers
394 +# if part.get_content_maintype() == "multipart":
395 +# continue
396 +
397 +# # Handle attachments
398 +# if "attachment" in content_disposition or part.get("Content-ID"):
399 +# filename = part.get_filename()
400 +# if filename:
401 +# filename = self._decode_header(filename)
402 +# content = part.get_payload(decode=True)
403 +# if content:
404 +# path = await self._save_attachment_bytes(
405 +# filename, content, download_folder
406 +# )
407 +# attachment_paths.append(path)
408 +
409 +# # Map Content-ID for inline images
410 +# cid = part.get("Content-ID")
411 +# if cid:
412 +# cid = cid.strip("<>")
413 +# cid_map[cid] = path
414 +
415 +# # Add positional marker for non-cid attachments
416 +# # (cid attachments are positioned via HTML references)
417 +# if not cid and body_parts:
418 +# body_parts.append(f"\n[file://{path}]\n")
419 +
420 +# # Handle body text
421 +# elif content_type == "text/plain":
422 +# if not body: # Use first text/plain as primary body
423 +# charset = part.get_content_charset() or "utf-8"
424 +# body = part.get_payload(decode=True).decode(charset, errors="ignore")
425 +# body_parts.append(body)
426 +
427 +# elif content_type == "text/html":
428 +# if not body: # Use first text/html as primary body if no text/plain
429 +# charset = part.get_content_charset() or "utf-8"
430 +# html_content = part.get_payload(decode=True).decode(charset, errors="ignore")
431 +# body = self._html_to_text(html_content, cid_map)
432 +# body_parts.append(body)
433 +
434 +# # Combine body parts if we built them up
435 +# if len(body_parts) > 1:
436 +# body = "".join(body_parts)
437 +# else:
438 +# # Single part message
439 +# content_type = email_msg.get_content_type()
440 +# charset = email_msg.get_content_charset() or "utf-8"
441 +# content = email_msg.get_payload(decode=True)
442 +# if content:
443 +# if content_type == "text/html":
444 +# body = self._html_to_text(content.decode(charset, errors="ignore"), cid_map)
445 +# else:
446 +# body = content.decode(charset, errors="ignore")
447 +
448 +# return Message(
449 +# sender=sender,
450 +# subject=subject,
451 +# body=body,
452 +# attachments=attachment_paths
453 +# )
454 +
455 +# def _html_to_text(self, html_content: str, cid_map: Optional[Dict[str, str]] = None) -> str:
456 +# """
457 +# Convert HTML to plain text with inline attachment references.
458 +
459 +# Replaces inline images with [file:///a0/...] markers to maintain
460 +# positional context.
461 +# """
462 +# cid_map = cid_map or {}
463 +
464 +# # Replace cid: references with file paths before conversion
465 +# if cid_map:
466 +# soup = BeautifulSoup(html_content, "html.parser")
467 +# for img in soup.find_all("img"):
468 +# src = img.get("src", "")
469 +# if src.startswith("cid:"):
470 +# cid = src[4:] # Remove "cid:" prefix
471 +# if cid in cid_map:
472 +# # Replace with file path marker
473 +# file_marker = f"[file://{cid_map[cid]}]"
474 +# img.replace_with(soup.new_string(file_marker))
475 +# html_content = str(soup)
476 +
477 +# # Convert HTML to text
478 +# h = html2text.HTML2Text()
479 +# h.ignore_links = False
480 +# h.ignore_images = False
481 +# h.ignore_emphasis = False
482 +# h.body_width = 0 # Don't wrap lines
483 +
484 +# text = h.handle(html_content)
485 +
486 +# # Clean up extra whitespace
487 +# text = re.sub(r"\n{3,}", "\n\n", text) # Max 2 consecutive newlines
488 +# text = text.strip()
489 +
490 +# return text
491 +
492 +# async def _save_attachment_bytes(
493 +# self,
494 +# filename: str,
495 +# content: bytes,
496 +# download_folder: str,
497 +# ) -> str:
498 +# """
499 +# Save attachment to disk and return absolute path.
500 +
501 +# Uses Agent Zero's file helpers for path management.
502 +# """
503 +# # Sanitize filename
504 +# filename = files.safe_file_name(filename)
505 +
506 +# # Generate unique filename if needed
507 +# unique_id = uuid.uuid4().hex[:8]
508 +# name, ext = os.path.splitext(filename)
509 +# unique_filename = f"{name}_{unique_id}{ext}"
510 +
511 +# # Build relative path and save
512 +# relative_path = os.path.join(download_folder, unique_filename)
513 +# files.write_file_bin(relative_path, content)
514 +
515 +# # Return absolute path
516 +# abs_path = files.get_abs_path(relative_path)
517 +# return abs_path
518 +
519 +# def _decode_header(self, header: str) -> str:
520 +# """Decode email header handling various encodings."""
521 +# if not header:
522 +# return ""
523 +
524 +# decoded_parts = []
525 +# for part, encoding in decode_header(header):
526 +# if isinstance(part, bytes):
527 +# decoded_parts.append(part.decode(encoding or "utf-8", errors="ignore"))
528 +# else:
529 +# decoded_parts.append(str(part))
530 +
531 +# return " ".join(decoded_parts)
532 +
533 +
534 +# async def read_messages(
535 +# account_type: str = "imap",
536 +# server: str = "",
537 +# port: int = 993,
538 +# username: str = "",
539 +# password: str = "",
540 +# download_folder: str = "usr/email",
541 +# options: Optional[Dict[str, Any]] = None,
542 +# filter: Optional[Dict[str, Any]] = None,
543 +# ) -> List[Message]:
544 +# """
545 +# Convenience wrapper for reading email messages.
546 +
547 +# Automatically handles connection and disconnection.
548 +
549 +# Args:
550 +# account_type: "imap" or "exchange"
551 +# server: Mail server address
552 +# port: Server port (default 993 for IMAP SSL)
553 +# username: Email username
554 +# password: Email password
555 +# download_folder: Folder to save attachments (relative to /a0/)
556 +# options: Optional configuration dict
557 +# filter: Filter criteria dict
558 +
559 +# Returns:
560 +# List of Message objects
561 +
562 +# Example:
563 +# from helpers.email_client import read_messages
564 +# messages = await read_messages(
565 +# server="imap.gmail.com",
566 +# port=993,
567 +# username=secrets.get("EMAIL_USER"),
568 +# password=secrets.get("EMAIL_PASSWORD"),
569 +# download_folder="tmp/email/inbox",
570 +# filter={"unread": True, "sender": "*@company.com"}
571 +# )
572 +# """
573 +# client = EmailClient(
574 +# account_type=account_type,
575 +# server=server,
576 +# port=port,
577 +# username=username,
578 +# password=password,
579 +# options=options,
580 +# )
581 +
582 +# try:
583 +# await client.connect()
584 +# messages = await client.read_messages(download_folder, filter)
585 +# return messages
586 +# finally:
587 +# await client.disconnect()
helpers/files.py
+1
@@ -18,6 +18,7 @@ AGENTS_DIR = "agents"
18 PLUGINS_DIR = "plugins"
19 PROJECTS_DIR = "projects"
20 USER_DIR = "usr"
21 +TEMP_DIR = "tmp"
22
23
24 class VariablesPlugin(ABC):
helpers/plugins.py
+2 -1
@@ -48,6 +48,7 @@ _last_frontend_reload_notification_at = 0.0
48
49
50 class PluginMetadata(BaseModel):
51 + name: str = ""
52 title: str = ""
53 description: str = ""
54 version: str = ""
@@ -567,7 +568,7 @@ def send_frontend_reload_notification(plugin_names: list[str] | None = None):
568 """If the plugin changed has webui extensions, notify frontend to reload the page"""
569 global _last_frontend_reload_notification_at
570
570 - display_time = 3
571 + display_time = 5
572 now = time.monotonic()
573 if now - _last_frontend_reload_notification_at < display_time:
574 return
plugins/_chat_branching/api/branch_chat.py renamed
plugins/_chat_branching/extensions/webui/set_messages_after_loop/inject-branch-buttons.js renamed
+1 -1
@@ -19,7 +19,7 @@ export default async function injectBranchButtons(context) {
19 const ctxid = globalThis.getContext?.();
20 if (!ctxid) throw new Error("No active chat");
21
22 - const res = await callJsonApi("/plugins/chat_branching/branch_chat", {
22 + const res = await callJsonApi("/plugins/_chat_branching/branch_chat", {
23 context: ctxid,
24 log_no: logNo,
25 });
plugins/_chat_branching/extensions/webui/set_messages_after_loop/plugins.py new
+606
@@ -0,0 +1,606 @@
1 +from __future__ import annotations
2 +
3 +import asyncio
4 +import re, json, glob
5 +import time
6 +from pathlib import Path
7 +from typing import (
8 + Any,
9 + Dict,
10 + Iterator,
11 + List,
12 + Literal,
13 + Optional,
14 + TYPE_CHECKING,
15 + TypedDict,
16 +)
17 +
18 +from helpers import files, notification, print_style, yaml as yaml_helper, cache
19 +from pydantic import BaseModel, Field
20 +
21 +from helpers.defer import DeferredTask
22 +
23 +if TYPE_CHECKING:
24 + from agent import Agent
25 +
26 +# Extracts target selector from <meta name="plugin-target" content="...">
27 +_META_TARGET_RE = re.compile(
28 + r'<meta\s+name=["\']plugin-target["\']\s+content=["\']([^"\']+)["\']',
29 + re.IGNORECASE,
30 +)
31 +
32 +type ToggleState = Literal["enabled", "disabled", "advanced"]
33 +
34 +
35 +class PluginAssetFile(TypedDict):
36 + path: str
37 + project_name: str
38 + agent_profile: str
39 +
40 +
41 +META_FILE_NAME = "plugin.yaml"
42 +CONFIG_FILE_NAME = "config.json"
43 +CONFIG_DEFAULT_FILE_NAME = "default_config.yaml"
44 +DISABLED_FILE_NAME = ".toggle-0"
45 +ENABLED_FILE_NAME = ".toggle-1"
46 +TOGGLE_FILE_PATTERN = ".toggle-[01]"
47 +_last_frontend_reload_notification_at = 0.0
48 +
49 +
50 +class PluginMetadata(BaseModel):
51 + name: str = ""
52 + title: str = ""
53 + description: str = ""
54 + version: str = ""
55 + settings_sections: List[str] = Field(default_factory=list)
56 + per_project_config: bool = False
57 + per_agent_config: bool = False
58 + always_enabled: bool = False
59 +
60 +
61 +class PluginListItem(BaseModel):
62 + name: str
63 + path: str
64 + display_name: str = ""
65 + description: str = ""
66 + version: str = ""
67 + settings_sections: List[str] = Field(default_factory=list)
68 + per_project_config: bool = False
69 + per_agent_config: bool = False
70 + always_enabled: bool = False
71 + is_custom: bool = False
72 + has_main_screen: bool = False
73 + has_config_screen: bool = False
74 + has_readme: bool = False
75 + has_license: bool = False
76 + has_init_script: bool = False
77 + toggle_state: ToggleState = "disabled"
78 +
79 +
80 +def after_plugin_change(plugin_names: list[str] | None = None):
81 + clear_plugin_cache()
82 + send_frontend_reload_notification(plugin_names)
83 +
84 +
85 +def clear_plugin_cache():
86 + cache.clear("*(plugins)*")
87 +
88 +
89 +def get_plugin_roots(plugin_name: str = "") -> List[str]:
90 + """Plugin root directories, ordered by priority (user first)."""
91 + return [
92 + files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, plugin_name),
93 + files.get_abs_path(files.PLUGINS_DIR, plugin_name),
94 + ]
95 +
96 +
97 +def get_plugins_list():
98 + result: list[str] = []
99 + seen_names: set[str] = set()
100 + for root in get_plugin_roots():
101 + for dir in Path(root).iterdir():
102 + if not dir.is_dir() or dir.name.startswith("."):
103 + continue
104 + if dir.name in seen_names:
105 + continue
106 + if files.exists(str(dir), META_FILE_NAME):
107 + seen_names.add(dir.name)
108 + result.append(dir.name)
109 + result.sort(key=lambda p: Path(p).name)
110 + return result
111 +
112 +
113 +def get_enhanced_plugins_list(
114 + custom: bool = True, builtin: bool = True
115 +) -> List[PluginListItem]:
116 + """Discover plugins by directory convention. First root wins on ID conflict."""
117 + results = []
118 +
119 + def load_plugins(root_path: str, is_custom: bool):
120 + for d in sorted(Path(root_path).iterdir(), key=lambda p: p.name):
121 + try:
122 + if not d.is_dir() or d.name.startswith("."):
123 + continue
124 + meta_file = str(d / META_FILE_NAME)
125 + if not files.exists(meta_file):
126 + continue
127 + meta = PluginMetadata.model_validate(files.read_file_yaml(meta_file))
128 + has_main_screen = files.exists(str(d / "webui" / "main.html"))
129 + has_config_screen = files.exists(str(d / "webui" / "config.html"))
130 + has_readme = files.exists(str(d / "README.md"))
131 + has_license = files.exists(str(d / "LICENSE"))
132 + has_init_script = files.exists(str(d / "initialize.py"))
133 + toggle_state = get_toggle_state(d.name)
134 + results.append(
135 + PluginListItem(
136 + name=d.name,
137 + path=str(d),
138 + display_name=meta.title or d.name,
139 + description=meta.description,
140 + version=meta.version,
141 + settings_sections=meta.settings_sections,
142 + per_project_config=meta.per_project_config,
143 + per_agent_config=meta.per_agent_config,
144 + always_enabled=meta.always_enabled,
145 + is_custom=is_custom,
146 + has_main_screen=has_main_screen,
147 + has_config_screen=has_config_screen,
148 + has_readme=has_readme,
149 + has_license=has_license,
150 + has_init_script=has_init_script,
151 + toggle_state=toggle_state,
152 + )
153 + )
154 + except Exception as e:
155 + print_style.PrintStyle.error(f"Failed to load plugin {d.name}: {e}")
156 + continue
157 +
158 + if custom:
159 + load_plugins(files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR), True)
160 + if builtin:
161 + load_plugins(files.get_abs_path(files.PLUGINS_DIR), False)
162 + return results
163 +
164 +
165 +def get_plugin_meta(plugin_name: str):
166 + plugin_dir = find_plugin_dir(plugin_name)
167 + if not plugin_dir:
168 + return None
169 + return PluginMetadata.model_validate(
170 + files.read_file_yaml(files.get_abs_path(plugin_dir, META_FILE_NAME))
171 + )
172 +
173 +
174 +def find_plugin_dir(plugin_name: str):
175 + if not plugin_name:
176 + return None
177 +
178 + # check if the plugin is in the user directory
179 + user_plugin_path = files.get_abs_path(
180 + files.USER_DIR, files.PLUGINS_DIR, plugin_name, META_FILE_NAME
181 + )
182 + if files.exists(user_plugin_path):
183 + return files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, plugin_name)
184 +
185 + # check if the plugin is in the default directory
186 + default_plugin_path = files.get_abs_path(
187 + files.PLUGINS_DIR, plugin_name, META_FILE_NAME
188 + )
189 + if files.exists(default_plugin_path):
190 + return files.get_abs_path(files.PLUGINS_DIR, plugin_name)
191 +
192 + return None
193 +
194 +
195 +def delete_plugin(plugin_name: str):
196 + plugin_dir = find_plugin_dir(plugin_name)
197 + if not plugin_dir:
198 + raise FileNotFoundError(f"Plugin '{plugin_name}' not found")
199 + custom_plugins_dir = files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR)
200 + if not files.is_in_dir(plugin_dir, custom_plugins_dir):
201 + raise ValueError("Only custom plugins can be deleted")
202 + send_frontend_reload_notification([plugin_name]) # send before deletion to properly check the extensions, second notification will be skipped automatically
203 + files.delete_dir(plugin_dir)
204 + after_plugin_change([plugin_name])
205 +
206 +
207 +def get_plugin_paths(*subpaths: str) -> List[str]:
208 + sub = "*/" + "/".join(subpaths) if subpaths else "*"
209 + paths: List[str] = []
210 + for root in get_plugin_roots():
211 + paths.extend(
212 + files.find_existing_paths_by_pattern(files.get_abs_path(root, sub))
213 + )
214 + return paths
215 +
216 +
217 +def get_enabled_plugin_paths(agent: Agent | None, *subpaths: str) -> List[str]:
218 + enabled = get_enabled_plugins(agent)
219 + paths: list[str] = []
220 +
221 + for plugin in enabled:
222 + base_dir = find_plugin_dir(plugin)
223 + if not base_dir:
224 + continue
225 +
226 + if not subpaths:
227 + if files.exists(base_dir):
228 + paths.append(base_dir)
229 + continue
230 +
231 + path_pattern = files.get_abs_path(base_dir, *subpaths)
232 + paths.extend(files.find_existing_paths_by_pattern(path_pattern))
233 +
234 + return paths
235 +
236 +
237 +def get_enabled_plugins(agent: Agent | None):
238 + plugins = get_plugins_list()
239 + active = []
240 +
241 + for plugin in plugins:
242 + # plugins are toggled via .enabled / .disabled files
243 + # every plugin is on by default, unless disabled in usr dir
244 + enabled = True
245 +
246 + # root plugin paths
247 + plugin_paths = get_plugin_roots(plugin)
248 +
249 + # + agent paths
250 + if agent:
251 + from helpers import subagents
252 +
253 + agent_paths = subagents.get_paths(
254 + agent,
255 + files.PLUGINS_DIR,
256 + plugin,
257 + must_exist_completely=True,
258 + include_default=False,
259 + include_user=False,
260 + include_plugins=False,
261 + include_project=True,
262 + )
263 + plugin_paths = agent_paths + plugin_paths
264 +
265 + # go through paths in reverse order and determine the state
266 + enabled = determined_toggle_from_paths(enabled, reversed(plugin_paths))
267 +
268 + if enabled:
269 + active.append(plugin)
270 +
271 + return active
272 +
273 +
274 +def determined_toggle_from_paths(default: bool, paths: Iterator[str]):
275 + enabled = default
276 + for plugin_path in paths:
277 + if enabled:
278 + enabled = not files.exists(
279 + files.get_abs_path(plugin_path, DISABLED_FILE_NAME)
280 + )
281 + else:
282 + enabled = files.exists(files.get_abs_path(plugin_path, ENABLED_FILE_NAME))
283 + return enabled
284 +
285 +
286 +def get_toggle_state(plugin_name: str) -> ToggleState:
287 + meta = get_plugin_meta(plugin_name)
288 + if not meta:
289 + return "disabled"
290 + if meta.always_enabled:
291 + return "enabled"
292 +
293 + # root plugin paths
294 + plugin_paths = get_plugin_roots(plugin_name)
295 + state = (
296 + "enabled"
297 + if determined_toggle_from_paths(True, reversed(plugin_paths))
298 + else "disabled"
299 + )
300 +
301 + # global toggles
302 + usr_toggles = [
303 + files.find_existing_paths_by_pattern(
304 + files.get_abs_path(files.PLUGINS_DIR, plugin_name, TOGGLE_FILE_PATTERN)
305 + ),
306 + files.find_existing_paths_by_pattern(
307 + files.get_abs_path(
308 + files.USER_DIR, files.PLUGINS_DIR, plugin_name, TOGGLE_FILE_PATTERN
309 + )
310 + ),
311 + ]
312 +
313 + # additional toggles in project/agent directories, return advanced
314 + if meta.per_agent_config or meta.per_project_config:
315 + configs = find_plugin_assets(
316 + TOGGLE_FILE_PATTERN,
317 + plugin_name=plugin_name,
318 + project_name="*" if meta.per_project_config else "",
319 + agent_profile="*" if meta.per_agent_config else "",
320 + only_first=False,
321 + )
322 +
323 + # Advanced if there are specific overrides (project or agent specific)
324 + if any(c.get("project_name") or c.get("agent_profile") for c in configs):
325 + state = "advanced"
326 +
327 + return state
328 +
329 +
330 +def toggle_plugin(
331 + plugin_name: str,
332 + enabled: bool,
333 + project_name: str = "",
334 + agent_profile: str = "",
335 + clear_overrides: bool = False,
336 +):
337 + if clear_overrides:
338 + all_toggles = find_plugin_assets(
339 + TOGGLE_FILE_PATTERN,
340 + plugin_name=plugin_name,
341 + project_name="*",
342 + agent_profile="*",
343 + only_first=False,
344 + )
345 + for toggle in all_toggles:
346 + files.delete_file(toggle["path"])
347 +
348 + enabled_file = determine_plugin_asset_path(
349 + plugin_name, project_name, agent_profile, ENABLED_FILE_NAME
350 + )
351 + disabled_file = determine_plugin_asset_path(
352 + plugin_name, project_name, agent_profile, DISABLED_FILE_NAME
353 + )
354 +
355 + # ensure clean state by deleting both potential files first
356 + files.delete_file(enabled_file)
357 + files.delete_file(disabled_file)
358 +
359 + if enabled:
360 + files.write_file(enabled_file, "")
361 + else:
362 + files.write_file(disabled_file, "")
363 + after_plugin_change([plugin_name])
364 +
365 +
366 +def get_plugin_config(
367 + plugin_name: str,
368 + agent: Agent | None = None,
369 + project_name: str | None = None,
370 + agent_profile: str | None = None,
371 +):
372 +
373 + if project_name is None and agent is not None:
374 + from helpers import projects
375 +
376 + project_name = projects.get_context_project_name(agent.context)
377 + if agent_profile is None and agent is not None:
378 + agent_profile = agent.config.profile
379 +
380 + # find config.json in all possible places
381 + file = find_plugin_asset(
382 + plugin_name,
383 + CONFIG_FILE_NAME,
384 + project_name=project_name or "",
385 + agent_profile=agent_profile or "",
386 + )
387 + file_path = file.get("path", "") if file else ""
388 +
389 + # use default config if not found
390 + if not file_path:
391 + file_path = files.get_abs_path(
392 + find_plugin_dir(plugin_name), CONFIG_DEFAULT_FILE_NAME
393 + )
394 + if file_path and files.exists(file_path):
395 + return (
396 + json.loads if file_path.lower().endswith(".json") else yaml_helper.loads
397 + )(files.read_file(file_path))
398 + return None
399 +
400 +
401 +def get_default_plugin_config(plugin_name: str):
402 + file_path = files.get_abs_path(
403 + find_plugin_dir(plugin_name), CONFIG_DEFAULT_FILE_NAME
404 + )
405 + if file_path and files.exists(file_path):
406 + return (
407 + json.loads if file_path.lower().endswith(".json") else yaml_helper.loads
408 + )(files.read_file(file_path))
409 + return None
410 +
411 +
412 +def save_plugin_config(
413 + plugin_name: str, project_name: str, agent_profile: str, settings: dict
414 +):
415 + file_path = determine_plugin_asset_path(
416 + plugin_name, project_name, agent_profile, CONFIG_FILE_NAME
417 + )
418 + if file_path:
419 + files.write_file(file_path, json.dumps(settings))
420 + after_plugin_change([plugin_name])
421 +
422 +
423 +def find_plugin_asset(
424 + plugin_name: str, *subpaths: str, project_name="", agent_profile=""
425 +):
426 + result = find_plugin_assets(
427 + *subpaths,
428 + plugin_name=plugin_name,
429 + project_name=project_name,
430 + agent_profile=agent_profile,
431 + only_first=True,
432 + )
433 + return result[0] if result else None
434 +
435 +
436 +def find_plugin_assets(
437 + *subpaths: str,
438 + plugin_name: str = "*",
439 + project_name: str = "*",
440 + agent_profile: str = "*",
441 + only_first: bool = False,
442 +) -> list[PluginAssetFile]:
443 + from helpers import projects, subagents
444 +
445 + results: list[PluginAssetFile] = []
446 +
447 + def _collect(path: str, proj: str, profile: str) -> bool:
448 + is_glob = glob.has_magic(path)
449 + matched_paths = (
450 + files.find_existing_paths_by_pattern(path)
451 + if is_glob
452 + else ([path] if files.exists(path) else [])
453 + )
454 +
455 + need_proj = proj == "*"
456 + need_prof = profile == "*"
457 +
458 + def _after(s: str, marker: str, last: bool = False) -> str:
459 + i = s.rfind(marker) if last else s.find(marker)
460 + if i == -1:
461 + return ""
462 + start = i + len(marker)
463 + end = s.find("/", start)
464 + return s[start:] if end == -1 else s[start:end]
465 +
466 + for matched in matched_paths:
467 + inferred_proj = _after(matched, "/projects/") if need_proj else proj
468 + inferred_prof = (
469 + _after(matched, "/agents/", last=True) if need_prof else profile
470 + )
471 + results.append(
472 + {
473 + "project_name": inferred_proj,
474 + "agent_profile": inferred_prof,
475 + "path": matched,
476 + }
477 + )
478 + if only_first:
479 + return True
480 + return False
481 +
482 + # project/.a0proj/agents/<profile>/plugins/<plugin_name>/...
483 + if project_name:
484 + if agent_profile:
485 + path = projects.get_project_meta(
486 + project_name,
487 + files.AGENTS_DIR,
488 + agent_profile,
489 + files.PLUGINS_DIR,
490 + plugin_name,
491 + *subpaths,
492 + )
493 + if _collect(path, project_name, agent_profile):
494 + return results
495 + if not agent_profile or agent_profile == "*":
496 + # project/.a0proj/plugins/<plugin_name>/...
497 + path = projects.get_project_meta(
498 + project_name, files.PLUGINS_DIR, plugin_name, *subpaths
499 + )
500 + if _collect(path, project_name, ""):
501 + return results
502 +
503 + # usr/agents/<profile>/plugins/<plugin_name>/...
504 + if agent_profile:
505 + path = files.get_abs_path(
506 + subagents.USER_AGENTS_DIR,
507 + agent_profile,
508 + files.PLUGINS_DIR,
509 + plugin_name,
510 + *subpaths,
511 + )
512 + if _collect(path, "", agent_profile):
513 + return results
514 +
515 + # usr?/plugins/<any_plugin>/agents/<profile>/plugins/<plugin_name>/...
516 + for plugin_base in get_enabled_plugin_paths(None):
517 + path = files.get_abs_path(
518 + plugin_base,
519 + files.AGENTS_DIR,
520 + agent_profile,
521 + files.PLUGINS_DIR,
522 + plugin_name,
523 + *subpaths,
524 + )
525 + if _collect(path, "", agent_profile):
526 + return results
527 +
528 + # agents/<profile>/plugins/<plugin_name>/...
529 + path = files.get_abs_path(
530 + subagents.DEFAULT_AGENTS_DIR,
531 + agent_profile,
532 + files.PLUGINS_DIR,
533 + plugin_name,
534 + *subpaths,
535 + )
536 + if _collect(path, "", agent_profile):
537 + return results
538 +
539 + # usr/plugins/<plugin_name>/...
540 + path = files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, plugin_name, *subpaths)
541 + if _collect(path, "", ""):
542 + return results
543 +
544 + # plugins/<plugin_name>/...
545 + path = files.get_abs_path(files.PLUGINS_DIR, plugin_name, *subpaths)
546 + _collect(path, "", "")
547 +
548 + return results
549 +
550 +
551 +def determine_plugin_asset_path(
552 + plugin_name: str, project_name: str, agent_profile: str, *subpaths: str
553 +):
554 + base_path = files.get_abs_path(files.USER_DIR)
555 +
556 + if project_name:
557 + from helpers import projects
558 +
559 + base_path = projects.get_project_meta(project_name)
560 +
561 + if agent_profile:
562 + base_path = files.get_abs_path(base_path, files.AGENTS_DIR, agent_profile)
563 +
564 + return files.get_abs_path(base_path, files.PLUGINS_DIR, plugin_name, *subpaths)
565 +
566 +
567 +def send_frontend_reload_notification(plugin_names: list[str] | None = None):
568 + """If the plugin changed has webui extensions, notify frontend to reload the page"""
569 + global _last_frontend_reload_notification_at
570 +
571 + display_time = 5
572 + now = time.monotonic()
573 + if now - _last_frontend_reload_notification_at < display_time:
574 + return
575 +
576 + if plugin_names:
577 + has_webui_extension = False
578 + for plugin_name in plugin_names:
579 + plugin_dir = find_plugin_dir(plugin_name)
580 + if plugin_dir and files.exists(
581 + files.get_abs_path(plugin_dir, "extensions", "webui")
582 + ):
583 + has_webui_extension = True
584 + break
585 + if not has_webui_extension:
586 + return
587 +
588 + async def _send_later():
589 + global _last_frontend_reload_notification_at
590 +
591 + await asyncio.sleep(1)
592 +
593 + _last_frontend_reload_notification_at = time.monotonic()
594 +
595 + notification.NotificationManager.send_notification(
596 + type=notification.NotificationType.INFO,
597 + priority=notification.NotificationPriority.NORMAL,
598 + title="Plugins with frontend extensions updated, page plugins/_plugin_scanmended",
599 + message="""<button type="button" class="button confirm" onclick="window.location.reload()"><span class="icon material-symbols-outlined">refresh</span>Reload page</button>""",
600 + detail="",
601 + display_time=display_time,
602 + group="plugins_changed",
603 + id="plugins_frontend_reload",
604 + )
605 +
606 + DeferredTask().start_task(_send_later)
plugins/_chat_branching/plugin.yaml renamed
+1
@@ -1,3 +1,4 @@
1 +name: _chat_branching
2 title: Chat Branching
3 description: Branch a chat from any message, creating a new chat with history up to that point.
4 version: 1.0.0
\ No newline at end of file
plugins/_code_execution/default_config.yaml renamed
plugins/_code_execution/extensions/python/system_prompt/_20_code_execution_prompt.py renamed
+3
@@ -10,5 +10,8 @@ class CodeExecutionPrompt(Extension):
10 loop_data: LoopData = LoopData(),
11 **kwargs,
12 ):
13 + if not self.agent:
14 + return
15 +
16 system_prompt.append(self.agent.read_prompt("agent.system.tool.code_exe.md"))
17 system_prompt.append(self.agent.read_prompt("agent.system.tool.input.md"))
plugins/_code_execution/extensions/webui/get_message_handler/code-exe-handler.js renamed
plugins/_code_execution/helpers/__init__.py renamed
plugins/_code_execution/helpers/shell_local.py renamed
+2 -2
@@ -5,8 +5,8 @@ import time
5 import sys
6 from typing import Optional, Tuple
7 from helpers import runtime
8 -from plugins.code_execution.helpers import tty_session
9 -from plugins.code_execution.helpers.shell_ssh import clean_string
8 +from plugins._code_execution.helpers import tty_session
9 +from plugins._code_execution.helpers.shell_ssh import clean_string
10
11 class LocalInteractiveSession:
12 def __init__(self, cwd: str|None = None):
plugins/_code_execution/helpers/shell_ssh.py renamed
plugins/_code_execution/helpers/tty_session.py renamed
+1 -1
@@ -22,7 +22,7 @@ class TTYSession:
22 self.encoding = encoding
23 self.echo = echo # ← store preference
24 self._proc = None
25 - self._buf = None
25 + self._buf: asyncio.Queue = None # type: ignore
26
27 def __del__(self):
28 # Simple cleanup on object destruction
plugins/_code_execution/plugin.yaml renamed
+1
@@ -1,3 +1,4 @@
1 +name: _code_execution
2 title: Code Execution
3 description: Code execution tool supporting terminal, Python, and Node.js runtimes via local TTY or SSH.
4 version: 1.0.0
plugins/_code_execution/prompts/agent.system.tool.code_exe.md renamed
plugins/_code_execution/prompts/agent.system.tool.input.md renamed
plugins/_code_execution/prompts/fw.code.info.md renamed
plugins/_code_execution/prompts/fw.code.max_time.md renamed
plugins/_code_execution/prompts/fw.code.no_out_time.md renamed
plugins/_code_execution/prompts/fw.code.no_output.md renamed
plugins/_code_execution/prompts/fw.code.pause_dialog.md renamed
plugins/_code_execution/prompts/fw.code.pause_time.md renamed
plugins/_code_execution/prompts/fw.code.reset.md renamed
plugins/_code_execution/prompts/fw.code.running.md renamed
plugins/_code_execution/prompts/fw.code.runtime_wrong.md renamed
plugins/_code_execution/tools/code_execution_tool.py renamed
+3 -3
@@ -11,8 +11,8 @@ from helpers.strings import truncate_text as truncate_text_string
11 from helpers.messages import truncate_text as truncate_text_agent
12 from helpers import plugins
13
14 -from plugins.code_execution.helpers.shell_local import LocalInteractiveSession
15 -from plugins.code_execution.helpers.shell_ssh import SSHInteractiveSession
14 +from plugins._code_execution.helpers.shell_local import LocalInteractiveSession
15 +from plugins._code_execution.helpers.shell_ssh import SSHInteractiveSession
16
17
18 @dataclass
@@ -504,7 +504,7 @@ def _parse_timeouts(cfg: dict, prefix: str, defaults: tuple[int, ...]) -> dict:
504
505
506 def _get_config(agent) -> dict:
507 - cfg = plugins.get_plugin_config("code_execution", agent=agent) or {}
507 + cfg = plugins.get_plugin_config("_code_execution", agent=agent) or {}
508
509 return {
510 "ssh_enabled": _resolve_ssh_enabled(cfg.get("ssh_enabled", "auto")),
plugins/_code_execution/tools/input.py renamed
+1 -1
@@ -1,5 +1,5 @@
1 from helpers.tool import Tool, Response
2 -from plugins.code_execution.tools.code_execution_tool import CodeExecution
2 +from plugins._code_execution.tools.code_execution_tool import CodeExecution
3
4
5 class Input(Tool):
plugins/_code_execution/webui/config.html renamed
plugins/_error_retry/default_config.yaml renamed
plugins/_error_retry/extensions/python/agent_Agent_handle_exception_end/_80_retry_critical_exception.py renamed
+1 -1
@@ -7,7 +7,7 @@ from helpers.errors import RepairableException, HandledException
7 from helpers import errors
8 from helpers.print_style import PrintStyle
9
10 -from plugins.error_retry.extensions.python.agent_Agent_monologue_start._10_reset_critical_exception_counter import DATA_NAME_COUNTER
10 +from plugins._error_retry.extensions.python.agent_Agent_monologue_start._10_reset_critical_exception_counter import DATA_NAME_COUNTER
11
12 class RetryCriticalException(Extension):
13 async def execute(self, data: dict = {}, **kwargs):
plugins/_error_retry/extensions/python/agent_Agent_monologue_start/_10_reset_critical_exception_counter.py renamed
plugins/_error_retry/plugin.yaml renamed
+1
@@ -1,3 +1,4 @@
1 +name: _error_retry
2 title: Error Retry
3 description: Retry on critical exceptions before failing
4 version: 1.0.0
plugins/_error_retry/webui/config.html renamed
plugins/_infection_check/README.md renamed
plugins/_infection_check/default_config.yaml renamed
plugins/_infection_check/extensions/python/reasoning_stream_chunk/_50_infection_collect.py renamed
+1 -1
@@ -1,6 +1,6 @@
1 from helpers.extension import Extension
2 from agent import LoopData
3 -from plugins.infection_check.helpers.checker import get_checker
3 +from plugins._infection_check.helpers.checker import get_checker
4
5
6 class InfectionCollectReasoning(Extension):
plugins/_infection_check/extensions/python/response_stream/_50_infection_analyze.py renamed
+1 -1
@@ -1,6 +1,6 @@
1 from helpers.extension import Extension
2 from agent import LoopData
3 -from plugins.infection_check.helpers.checker import get_checker
3 +from plugins._infection_check.helpers.checker import get_checker
4
5
6 class InfectionAnalyzeThoughts(Extension):
plugins/_infection_check/extensions/python/response_stream_chunk/_50_infection_collect.py renamed
+1 -1
@@ -1,6 +1,6 @@
1 from helpers.extension import Extension
2 from agent import LoopData
3 -from plugins.infection_check.helpers.checker import get_checker
3 +from plugins._infection_check.helpers.checker import get_checker
4
5
6 class InfectionCollectResponse(Extension):
plugins/_infection_check/extensions/python/response_stream_end/_50_infection_analyze.py renamed
+1 -1
@@ -1,6 +1,6 @@
1 from helpers.extension import Extension
2 from agent import LoopData
3 -from plugins.infection_check.helpers.checker import get_checker
3 +from plugins._infection_check.helpers.checker import get_checker
4
5
6 class InfectionAnalyzeEnd(Extension):
plugins/_infection_check/extensions/python/tool_execute_before/_50_infection_check.py renamed
+1 -1
@@ -1,5 +1,5 @@
1 from helpers.extension import Extension
2 -from plugins.infection_check.helpers.checker import get_checker
2 +from plugins._infection_check.helpers.checker import get_checker
3
4
5 class InfectionAwaitCheck(Extension):
plugins/_infection_check/helpers/checker.py renamed
+1 -1
@@ -13,7 +13,7 @@ if TYPE_CHECKING:
13 from agent import Agent
14 from helpers.log import LogItem
15
16 -PLUGIN_NAME = "infection_check"
16 +PLUGIN_NAME = "_infection_check"
17 DATA_KEY = f"_plugin.{PLUGIN_NAME}"
18 DATA_KEY_PASSED = f"{DATA_KEY}.passed"
19 DATA_KEY_MONO = f"{DATA_KEY}.mono"
plugins/_infection_check/plugin.yaml renamed
+1
@@ -1,3 +1,4 @@
1 +name: _infection_check
2 title: Infection Check
3 description: Safety check for prompt injection from external sources.
4 version: 1.0.0
plugins/_infection_check/webui/config.html renamed
plugins/_memory/api/import_knowledge.py renamed
+1 -1
@@ -1,7 +1,7 @@
1 from helpers.api import ApiHandler, Request, Response
2 from helpers import files
3 from helpers.security import safe_filename
4 -from plugins.memory.helpers.memory import Memory, get_custom_knowledge_subdir_abs
4 +from plugins._memory.helpers.memory import Memory, get_custom_knowledge_subdir_abs
5 import os
6
7
plugins/_memory/api/knowledge_path_get.py renamed
+1 -1
@@ -1,6 +1,6 @@
1 from helpers.api import ApiHandler, Request, Response
2 from helpers import files, projects
3 -from plugins.memory.helpers.memory import get_custom_knowledge_subdir_abs
3 +from plugins._memory.helpers.memory import get_custom_knowledge_subdir_abs
4
5
6 class GetKnowledgePath(ApiHandler):
plugins/_memory/api/knowledge_reindex.py renamed
+1 -1
@@ -1,5 +1,5 @@
1 from helpers.api import ApiHandler, Request, Response
2 -from plugins.memory.helpers.memory import Memory
2 +from plugins._memory.helpers.memory import Memory
3
4
5 class ReindexKnowledge(ApiHandler):
plugins/_memory/api/memory_dashboard.py renamed
+1 -1
@@ -4,7 +4,7 @@ from models import ModelConfig, ModelType
4 from langchain_core.documents import Document
5 from agent import AgentContext
6
7 -from plugins.memory.helpers.memory import Memory, get_existing_memory_subdirs, get_context_memory_subdir
7 +from plugins._memory.helpers.memory import Memory, get_existing_memory_subdirs, get_context_memory_subdir
8
9
10 class MemoryDashboard(ApiHandler):
plugins/_memory/default_config.yaml renamed
plugins/_memory/extensions/python/embedding_model_changed/_10_memory_reload.py renamed
+1 -1
@@ -1,7 +1,7 @@
1 from helpers.extension import Extension
2
3 # Direct import - this extension lives inside the memory plugin
4 -from plugins.memory.helpers.memory import reload as memory_reload
4 +from plugins._memory.helpers.memory import reload as memory_reload
5
6
7 class MemoryReload(Extension):
plugins/_memory/extensions/python/message_loop_prompts_after/_50_recall_memories.py renamed
+4 -4
@@ -4,8 +4,8 @@ from agent import LoopData
4 from helpers import dirty_json, errors, log, plugins
5
6 # Direct import - this extension lives inside the memory plugin
7 -from plugins.memory.helpers.memory import Memory
8 -from plugins.memory.tools.memory_load import DEFAULT_THRESHOLD as DEFAULT_MEMORY_THRESHOLD
7 +from plugins._memory.helpers.memory import Memory
8 +from plugins._memory.tools.memory_load import DEFAULT_THRESHOLD as DEFAULT_MEMORY_THRESHOLD
9
10
11 DATA_NAME_TASK = "_recall_memories_task"
@@ -27,7 +27,7 @@ class RecallMemories(Extension):
27 if not self.agent:
28 return
29
30 - set = plugins.get_plugin_config("memory", self.agent)
30 + set = plugins.get_plugin_config("_memory", self.agent)
31 if not set:
32 return None
33
@@ -69,7 +69,7 @@ class RecallMemories(Extension):
69 del extras["solutions"]
70
71
72 - set = plugins.get_plugin_config("memory", self.agent)
72 + set = plugins.get_plugin_config("_memory", self.agent)
73 if not set:
74 return None
75 # try:
plugins/_memory/extensions/python/message_loop_prompts_after/_91_recall_wait.py renamed
+2 -2
@@ -1,6 +1,6 @@
1 from helpers.extension import Extension
2 from agent import LoopData
3 -from plugins.memory.extensions.python.message_loop_prompts_after._50_recall_memories import DATA_NAME_TASK as DATA_NAME_TASK_MEMORIES, DATA_NAME_ITER as DATA_NAME_ITER_MEMORIES
3 +from plugins._memory.extensions.python.message_loop_prompts_after._50_recall_memories import DATA_NAME_TASK as DATA_NAME_TASK_MEMORIES, DATA_NAME_ITER as DATA_NAME_ITER_MEMORIES
4 from helpers import plugins
5
6 class RecallWait(Extension):
@@ -9,7 +9,7 @@ class RecallWait(Extension):
9 if not self.agent:
10 return
11
12 - set = plugins.get_plugin_config("memory", self.agent)
12 + set = plugins.get_plugin_config("_memory", self.agent)
13 if not set:
14 return None
15
plugins/_memory/extensions/python/monologue_end/_50_memorize_fragments.py renamed
+6 -7
@@ -7,18 +7,18 @@ from helpers.log import LogItem
7 from helpers.defer import DeferredTask, THREAD_BACKGROUND
8
9 # Direct import - this extension lives inside the memory plugin
10 -from plugins.memory.helpers.memory import Memory
11 -from plugins.memory.tools.memory_load import DEFAULT_THRESHOLD as DEFAULT_MEMORY_THRESHOLD
10 +from plugins._memory.helpers.memory import Memory
11 +from plugins._memory.tools.memory_load import DEFAULT_THRESHOLD as DEFAULT_MEMORY_THRESHOLD
12
13
14 class MemorizeMemories(Extension):
15
16 - async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
16 + def execute(self, loop_data: LoopData = LoopData(), **kwargs):
17 # try:
18 if not self.agent:
19 return
20
21 - set = plugins.get_plugin_config("memory", self.agent)
21 + set = plugins.get_plugin_config("_memory", self.agent)
22 if not set:
23 return None
24
@@ -35,14 +35,13 @@ class MemorizeMemories(Extension):
35 task = DeferredTask(thread_name=THREAD_BACKGROUND)
36 task.start_task(self.memorize, loop_data, log_item)
37 # task = asyncio.create_task(self.memorize(loop_data, log_item))
38 - return task
38
39 async def memorize(self, loop_data: LoopData, log_item: LogItem, **kwargs):
40 if not self.agent:
41 return
42
43 try:
45 - set = plugins.get_plugin_config("memory", self.agent)
44 + set = plugins.get_plugin_config("_memory", self.agent)
45 if not set:
46 return None
47
@@ -118,7 +117,7 @@ class MemorizeMemories(Extension):
117
118 try:
119 # Use intelligent consolidation system
121 - from plugins.memory.helpers.memory_consolidation import create_memory_consolidator
120 + from plugins._memory.helpers.memory_consolidation import create_memory_consolidator
121 consolidator = create_memory_consolidator(
122 self.agent,
123 similarity_threshold=DEFAULT_MEMORY_THRESHOLD, # More permissive for discovery
plugins/_memory/extensions/python/monologue_end/_51_memorize_solutions.py renamed
+6 -7
@@ -7,17 +7,17 @@ from helpers.log import LogItem
7 from helpers.defer import DeferredTask, THREAD_BACKGROUND
8
9 # Direct import - this extension lives inside the memory plugin
10 -from plugins.memory.helpers.memory import Memory
11 -from plugins.memory.tools.memory_load import DEFAULT_THRESHOLD as DEFAULT_MEMORY_THRESHOLD
10 +from plugins._memory.helpers.memory import Memory
11 +from plugins._memory.tools.memory_load import DEFAULT_THRESHOLD as DEFAULT_MEMORY_THRESHOLD
12
13 class MemorizeSolutions(Extension):
14
15 - async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
15 + def execute(self, loop_data: LoopData = LoopData(), **kwargs):
16 # try:
17 if not self.agent:
18 return
19
20 - set = plugins.get_plugin_config("memory", self.agent)
20 + set = plugins.get_plugin_config("_memory", self.agent)
21 if not set:
22 return None
23
@@ -34,7 +34,6 @@ class MemorizeSolutions(Extension):
34 task = DeferredTask(thread_name=THREAD_BACKGROUND)
35 task.start_task(self.memorize, loop_data, log_item)
36 # task = asyncio.create_task(self.memorize(loop_data, log_item))
37 - return task
37
38 async def memorize(self, loop_data: LoopData, log_item: LogItem, **kwargs):
39 if not self.agent:
@@ -42,7 +41,7 @@ class MemorizeSolutions(Extension):
41
42 try:
43
45 - set = plugins.get_plugin_config("memory", self.agent)
44 + set = plugins.get_plugin_config("_memory", self.agent)
45 if not set:
46 return None
47
@@ -128,7 +127,7 @@ class MemorizeSolutions(Extension):
127 if set["memory_memorize_consolidation"]:
128 try:
129 # Use intelligent consolidation system
131 - from plugins.memory.helpers.memory_consolidation import create_memory_consolidator
130 + from plugins._memory.helpers.memory_consolidation import create_memory_consolidator
131 consolidator = create_memory_consolidator(
132 self.agent,
133 similarity_threshold=DEFAULT_MEMORY_THRESHOLD, # More permissive for discovery
plugins/_memory/extensions/python/monologue_start/_10_memory_init.py renamed
+1 -1
@@ -2,7 +2,7 @@ from helpers.extension import Extension
2 from agent import LoopData
3
4 # Direct import - this extension lives inside the memory plugin
5 -from plugins.memory.helpers import memory
5 +from plugins._memory.helpers import memory
6
7
8 class MemoryInit(Extension):
plugins/_memory/extensions/python/system_prompt/_20_behaviour_prompt.py renamed
+1 -1
@@ -3,7 +3,7 @@ from agent import Agent, LoopData
3 from helpers import files
4
5 # Direct import - this extension lives inside the memory plugin
6 -from plugins.memory.helpers import memory
6 +from plugins._memory.helpers import memory
7
8
9 class BehaviourPrompt(Extension):
plugins/_memory/extensions/webui/sidebar-quick-actions-dropdown-start/memory-entry.html renamed
+1 -1
@@ -5,7 +5,7 @@
5 class="dropdown-item"
6 id="memory-dash-dropdown"
7 title="Memory"
8 - @click="openModal('../plugins/memory/webui/memory-dashboard.html'); dropdownOpen = false">
8 + @click="openModal('../plugins/_memory/webui/memory-dashboard.html'); dropdownOpen = false">
9 <span class="material-symbols-outlined">psychology</span>
10 <span>Memory</span>
11 </button>
plugins/_memory/extensions/webui/sidebar-quick-actions-main-start/memory-entry.html renamed
+1 -1
@@ -1,6 +1,6 @@
1 <!-- Memory -->
2 <div x-data>
3 - <button x-move-after=".config-button#dashboard" class="config-button" id="memory-dash" @click="openModal('../plugins/memory/webui/memory-dashboard.html')"
3 + <button x-move-after=".config-button#dashboard" class="config-button" id="memory-dash" @click="openModal('../plugins/_memory/webui/memory-dashboard.html')"
4 title="Memory">
5 <span class="material-symbols-outlined">psychology</span>
6 </button>
plugins/_memory/helpers/knowledge_import.py renamed
plugins/_memory/helpers/memory.py renamed
+1 -1
@@ -515,7 +515,7 @@ def get_memory_subdir_abs(agent: Agent) -> str:
515
516
517 def get_agent_memory_subdir(agent: Agent) -> str:
518 - config = plugins.get_plugin_config("memory", agent)
518 + config = plugins.get_plugin_config("_memory", agent)
519
520 if not config:
521 return "default"
plugins/_memory/helpers/memory_consolidation.py renamed
+2 -2
@@ -7,12 +7,12 @@ from enum import Enum
7
8 from langchain_core.documents import Document
9
10 -from plugins.memory.helpers.memory import Memory
10 +from plugins._memory.helpers.memory import Memory
11 from helpers.dirty_json import DirtyJson
12 from helpers.log import LogItem
13 from helpers.print_style import PrintStyle
14 from agent import Agent
15 -from plugins.memory.tools.memory_load import DEFAULT_THRESHOLD as DEFAULT_MEMORY_THRESHOLD
15 +from plugins._memory.tools.memory_load import DEFAULT_THRESHOLD as DEFAULT_MEMORY_THRESHOLD
16
17
18 class ConsolidationAction(Enum):
plugins/_memory/plugin.yaml renamed
+1
@@ -1,3 +1,4 @@
1 +name: _memory
2 title: Memory
3 description: Provides persistent memory capabilities to Agent Zero agents.
4 version: 1.0.0
plugins/_memory/prompts/agent.system.memories.md renamed
plugins/_memory/prompts/agent.system.solutions.md renamed
plugins/_memory/prompts/agent.system.tool.memory.md renamed
plugins/_memory/prompts/fw.memory.hist_suc.sys.md renamed
plugins/_memory/prompts/fw.memory.hist_sum.sys.md renamed
plugins/_memory/prompts/fw.memory_saved.md renamed
plugins/_memory/prompts/memory.consolidation.msg.md renamed
plugins/_memory/prompts/memory.consolidation.sys.md renamed
plugins/_memory/prompts/memory.keyword_extraction.msg.md renamed
plugins/_memory/prompts/memory.keyword_extraction.sys.md renamed
plugins/_memory/prompts/memory.memories_filter.msg.md renamed
plugins/_memory/prompts/memory.memories_filter.sys.md renamed
plugins/_memory/prompts/memory.memories_query.msg.md renamed
plugins/_memory/prompts/memory.memories_query.sys.md renamed
plugins/_memory/prompts/memory.memories_sum.sys.md renamed
plugins/_memory/prompts/memory.recall_delay_msg.md renamed
plugins/_memory/prompts/memory.solutions_query.sys.md renamed
plugins/_memory/prompts/memory.solutions_sum.sys.md renamed
plugins/_memory/tools/behaviour_adjustment.py renamed
+1 -1
@@ -2,7 +2,7 @@ from helpers import files
2 from helpers.tool import Tool, Response
3 from agent import Agent
4 from helpers.log import LogItem
5 -from plugins.memory.helpers import memory
5 +from plugins._memory.helpers import memory
6
7
8 class UpdateBehaviour(Tool):
plugins/_memory/tools/memory_delete.py renamed
+1 -1
@@ -1,5 +1,5 @@
1 from helpers.tool import Tool, Response
2 -from plugins.memory.helpers.memory import Memory
2 +from plugins._memory.helpers.memory import Memory
3
4
5
plugins/_memory/tools/memory_forget.py renamed
+1 -1
@@ -1,5 +1,5 @@
1 from helpers.tool import Tool, Response
2 -from plugins.memory.helpers.memory import Memory
2 +from plugins._memory.helpers.memory import Memory
3
4 from tools.memory_load import DEFAULT_THRESHOLD
5
plugins/_memory/tools/memory_load.py renamed
+1 -1
@@ -1,5 +1,5 @@
1 from helpers.tool import Tool, Response
2 -from plugins.memory.helpers.memory import Memory
2 +from plugins._memory.helpers.memory import Memory
3
4 DEFAULT_THRESHOLD = 0.7
5 DEFAULT_LIMIT = 10
plugins/_memory/tools/memory_save.py renamed
+1 -1
@@ -1,5 +1,5 @@
1 from helpers.tool import Tool, Response
2 -from plugins.memory.helpers.memory import Memory
2 +from plugins._memory.helpers.memory import Memory
3
4
5
plugins/_memory/webui/config.html renamed
+1 -1
@@ -50,7 +50,7 @@
50 </div>
51 <div class="field-control">
52 <button class="btn btn-field"
53 - @click="openModal('/plugins/memory/webui/memory-dashboard.html');">
53 + @click="openModal('/plugins/_memory/webui/memory-dashboard.html');">
54 Open Dashboard
55 </button>
56 </div>
plugins/_memory/webui/main.html renamed
+1 -1
@@ -6,6 +6,6 @@
6 <title>Memory Dashboard</title>
7 </head>
8 <body>
9 - <x-component path="/plugins/memory/webui/memory-dashboard.html"></x-component>
9 + <x-component path="/plugins/_memory/webui/memory-dashboard.html"></x-component>
10 </body>
11 </html>
plugins/_memory/webui/memory-dashboard-store.js renamed
+3 -3
@@ -3,7 +3,7 @@ import { getContext } from "/index.js";
3 import * as API from "/js/api.js";
4 import { openModal, closeModal } from "/js/modals.js";
5 import { store as notificationStore } from "/components/notifications/notification-store.js";
6 -const MEMORY_DASHBOARD_API = "/plugins/memory/memory_dashboard";
6 +const MEMORY_DASHBOARD_API = "/plugins/_memory/memory_dashboard";
7
8 // Helper function for toasts
9 function justToast(text, type = "info", timeout = 5000) {
@@ -54,7 +54,7 @@ const memoryDashboardStore = {
54 pollingEnabled: false,
55
56 async openModal() {
57 - await openModal("../plugins/memory/webui/memory-dashboard.html");
57 + await openModal("../plugins/_memory/webui/memory-dashboard.html");
58 },
59
60 init() {
@@ -449,7 +449,7 @@ ${memory.content_full}
449 this.editMode = false;
450 this.editMemoryBackup = null;
451 // Use global modal system
452 - openModal("../plugins/memory/webui/memory-detail-modal.html");
452 + openModal("../plugins/_memory/webui/memory-detail-modal.html");
453 },
454
455 closeMemoryDetails() {
plugins/_memory/webui/memory-dashboard.html renamed
+1 -1
@@ -3,7 +3,7 @@
3 <head>
4 <title>Memory Dashboard</title>
5 <script type="module">
6 - import { store } from "/plugins/memory/webui/memory-dashboard-store.js";
6 + import { store } from "/plugins/_memory/webui/memory-dashboard-store.js";
7 </script>
8 </head>
9
plugins/_memory/webui/memory-detail-modal.html renamed
plugins/_plugin_installer/api/plugin_install.py renamed
+3 -2
@@ -3,7 +3,7 @@ from __future__ import annotations
3 from helpers.api import ApiHandler, Input, Output, Request
4 from werkzeug.datastructures import FileStorage
5
6 -from plugins.plugin_installer.helpers.install import (
6 +from plugins._plugin_installer.helpers.install import (
7 get_marketplace_index,
8 install_from_git,
9 install_uploaded_zip,
@@ -42,10 +42,11 @@ class PluginInstall(ApiHandler):
42 def _install_git(self, input: dict) -> dict:
43 git_url = (input.get("git_url", "") or "").strip()
44 git_token = (input.get("git_token", "") or "").strip() or None
45 + plugin_name = input.get("plugin_name", "")
46 if not git_url:
47 return {"success": False, "error": "Git URL is required"}
48
48 - return install_from_git(git_url, git_token)
49 + return install_from_git(url=git_url, token=git_token, plugin_name=plugin_name)
50
51 def _fetch_index(self, input: dict) -> dict:
52 return {"success": True, **get_marketplace_index()}
plugins/_plugin_installer/extensions/webui/plugins-list-header-buttons/install-buttons.html renamed
+2 -2
@@ -1,11 +1,11 @@
1 <span x-data>
2 <script type="module">
3 - import { store } from "/plugins/plugin_installer/webui/pluginInstallStore.js";
3 + import { store } from "/plugins/_plugin_installer/webui/pluginInstallStore.js";
4 </script>
5
6 <button type="button"
7 class="button confirm"
8 - @click="openModal('../plugins/plugin_installer/webui/main.html')">
8 + @click="openModal('../plugins/_plugin_installer/webui/main.html')">
9 <span class="icon material-symbols-outlined">add</span> Install
10 </button>
11 </span>
plugins/_plugin_installer/helpers/__init__.py renamed
plugins/_plugin_installer/helpers/install.py renamed
+24 -47
@@ -2,17 +2,15 @@ from __future__ import annotations
2
3 import json
4 import os
5 -import re
5 import shutil
6 import time
7 import urllib.request
8 import uuid
9 import zipfile
10 from pathlib import Path
12 -from typing import Any, Optional
11 +from typing import Any
12
13 from helpers import files
15 -from helpers.git import extract_author_repo
14 from helpers import yaml as yaml_helper
15 from helpers.plugins import (
16 META_FILE_NAME,
@@ -28,34 +26,14 @@ def _get_user_plugins_dir() -> str:
26 return files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR)
27
28
31 -def _derive_git_plugin_name(url: str) -> str:
32 - """Derive the canonical plugin ID from a Git repository URL."""
33 - parts = extract_author_repo(url)
34 - repo_name = "__".join(
35 - cleaned
36 - for part in parts
37 - if (cleaned := re.sub(r"_+", "_", re.sub(r"[^0-9A-Za-z]+", "_", part)).strip("_"))
38 - )
39 - if not repo_name:
40 - raise ValueError("Could not derive plugin name from URL")
41 - return repo_name
42 -
43 -
44 -def _derive_zip_plugin_name(
45 - plugin_root: str, extract_dir: str, original_filename: str | None
46 -) -> str:
47 - """Resolve the plugin ID for an uploaded ZIP archive."""
48 - if plugin_root != extract_dir:
49 - return os.path.basename(plugin_root)
50 -
51 - upload_name = Path((original_filename or "").strip()).name
52 - plugin_name = Path(upload_name).stem
29 +def _get_plugin_name(meta: PluginMetadata) -> str:
30 + plugin_name = (meta.name or "").strip()
31 if not plugin_name:
54 - raise ValueError("Could not derive plugin name from uploaded filename")
32 + raise ValueError(f"{META_FILE_NAME} is missing required field 'name'")
33 return plugin_name
34
35
58 -def validate_plugin_dir(path: str) -> PluginMetadata:
36 +def validate_plugin_dir(path: str, plugin_name:str="") -> PluginMetadata:
37 """Check directory contains plugin.yaml and return parsed metadata.
38 Raises ValueError if plugin.yaml is missing or invalid."""
39 meta_path = os.path.join(path, META_FILE_NAME)
@@ -64,7 +42,10 @@ def validate_plugin_dir(path: str) -> PluginMetadata:
42 with open(meta_path, "r", encoding="utf-8") as f:
43 content = f.read()
44 data = yaml_helper.loads(content)
67 - return PluginMetadata.model_validate(data)
45 + model = PluginMetadata.model_validate(data)
46 + if plugin_name and plugin_name != model.name:
47 + raise ValueError(f"Plugin name is incorrect: expected '{plugin_name}', got '{model.name}'. The author needs to correct this in the plugin.yaml file.")
48 + return model
49
50
51 def check_plugin_conflict(name: str) -> None:
@@ -130,7 +111,7 @@ def install_from_zip(zip_path: str, original_filename: str | None = None) -> dic
111 # Find plugin.yaml
112 plugin_root = _find_plugin_root(extract_dir)
113 meta = validate_plugin_dir(plugin_root)
133 - plugin_name = _derive_zip_plugin_name(plugin_root, extract_dir, original_filename)
114 + plugin_name = _get_plugin_name(meta)
115
116 check_plugin_conflict(plugin_name)
117
@@ -155,17 +136,14 @@ def install_from_zip(zip_path: str, original_filename: str | None = None) -> dic
136 pass
137
138
158 -def install_from_git(url: str, token: Optional[str] = None) -> dict:
139 +def install_from_git(url: str, token: str | None = None, plugin_name: str="") -> dict:
140 """Clone git repo into usr/plugins/, validate plugin.yaml.
141 Returns dict with plugin name and metadata."""
142 from helpers.git import clone_repo
143
163 - repo_name = _derive_git_plugin_name(url)
164 -
165 - check_plugin_conflict(repo_name)
166 -
167 - dest = os.path.join(_get_user_plugins_dir(), repo_name)
168 - os.makedirs(os.path.dirname(dest), exist_ok=True)
144 + temp_name = f"tmp_plugin_{time.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
145 + dest = files.get_abs_path(files.TEMP_DIR, "plugins_installer", temp_name)
146 + files.create_dir_safe(dest)
147
148 try:
149 clone_repo(url, dest, token=token or None)
@@ -175,19 +153,23 @@ def install_from_git(url: str, token: Optional[str] = None) -> dict:
153 raise ValueError(f"Git clone failed: {e}") from e
154
155 try:
178 - meta = validate_plugin_dir(dest)
156 + meta = validate_plugin_dir(dest, plugin_name=plugin_name)
157 except ValueError:
158 # No plugin.yaml — remove cloned repo
159 shutil.rmtree(dest, ignore_errors=True)
160 raise
161
184 - after_plugin_change([repo_name])
162 + plugin_name = _get_plugin_name(meta)
163 + check_plugin_conflict(plugin_name)
164 + final_dest = os.path.join(_get_user_plugins_dir(), plugin_name)
165 + files.move_dir(dest, final_dest)
166 + after_plugin_change([plugin_name])
167
168 return {
169 "success": True,
188 - "plugin_name": repo_name,
189 - "title": meta.title or repo_name,
190 - "path": files.deabsolute_path(dest),
170 + "plugin_name": plugin_name,
171 + "title": meta.title or plugin_name,
172 + "path": files.deabsolute_path(final_dest),
173 }
174
175
@@ -206,12 +188,7 @@ def get_marketplace_index() -> dict[str, Any]:
188 for key, plugin_data in plugins.items():
189 if not isinstance(plugin_data, dict):
190 continue
209 - github_url = plugin_data.get("github", "")
210 - try:
211 - plugin_name = _derive_git_plugin_name(github_url)
212 - except ValueError:
213 - continue
214 - if plugin_name in installed_dirs:
191 + if key in installed_dirs:
192 installed_keys.append(key)
193
194 return {"index": index_data, "installed_plugins": installed_keys}
plugins/_plugin_installer/plugin.yaml renamed
+1
@@ -1,3 +1,4 @@
1 +name: _plugin_installer
2 title: Plugin Installer
3 description: Install plugins from ZIP files, Git repositories, or the community index.
4 version: 1.0.0
plugins/_plugin_installer/webui/install-detail.html renamed
+1 -1
@@ -2,7 +2,7 @@
2 <head>
3 <title>Plugin Details</title>
4 <script type="module">
5 - import { store } from "/plugins/plugin_installer/webui/pluginInstallStore.js";
5 + import { store } from "/plugins/_plugin_installer/webui/pluginInstallStore.js";
6 </script>
7 </head>
8 <body>
plugins/_plugin_installer/webui/install-git.html renamed
+1 -1
@@ -2,7 +2,7 @@
2 <head>
3 <title>Install Plugin from Git</title>
4 <script type="module">
5 - import { store } from "/plugins/plugin_installer/webui/pluginInstallStore.js";
5 + import { store } from "/plugins/_plugin_installer/webui/pluginInstallStore.js";
6 </script>
7 </head>
8 <body>
plugins/_plugin_installer/webui/install-index.html renamed
+1 -1
@@ -2,7 +2,7 @@
2 <head>
3 <title>Browse Plugins</title>
4 <script type="module">
5 - import { store } from "/plugins/plugin_installer/webui/pluginInstallStore.js";
5 + import { store } from "/plugins/_plugin_installer/webui/pluginInstallStore.js";
6 </script>
7 </head>
8 <body>
plugins/_plugin_installer/webui/install-zip.html renamed
+1 -1
@@ -2,7 +2,7 @@
2 <head>
3 <title>Install Plugin from ZIP</title>
4 <script type="module">
5 - import { store } from "/plugins/plugin_installer/webui/pluginInstallStore.js";
5 + import { store } from "/plugins/_plugin_installer/webui/pluginInstallStore.js";
6 </script>
7 </head>
8 <body>
plugins/_plugin_installer/webui/main.html renamed
+4 -4
@@ -3,7 +3,7 @@
3 <head>
4 <title>Install Plugin</title>
5 <script type="module">
6 - import { store } from "/plugins/plugin_installer/webui/pluginInstallStore.js";
6 + import { store } from "/plugins/_plugin_installer/webui/pluginInstallStore.js";
7 </script>
8 </head>
9
@@ -35,15 +35,15 @@
35 </ul>
36
37 <template x-if="$store.pluginInstallStore.activeTab === 'store'">
38 - <x-component path="/plugins/plugin_installer/webui/install-index.html"></x-component>
38 + <x-component path="/plugins/_plugin_installer/webui/install-index.html"></x-component>
39 </template>
40
41 <template x-if="$store.pluginInstallStore.activeTab === 'git'">
42 - <x-component path="/plugins/plugin_installer/webui/install-git.html"></x-component>
42 + <x-component path="/plugins/_plugin_installer/webui/install-git.html"></x-component>
43 </template>
44
45 <template x-if="$store.pluginInstallStore.activeTab === 'zip'">
46 - <x-component path="/plugins/plugin_installer/webui/install-zip.html"></x-component>
46 + <x-component path="/plugins/_plugin_installer/webui/install-zip.html"></x-component>
47 </template>
48
49 </div>
plugins/_plugin_installer/webui/pluginInstallStore.js renamed
+7 -22
@@ -8,7 +8,7 @@ import { store as imageViewerStore } from "/components/modals/image-viewer/image
8 import { store as pluginListStore } from "/components/plugins/list/pluginListStore.js";
9 import { store as pluginInitStore } from "/components/plugins/list/plugin-init-store.js";
10
11 -const PLUGIN_API = "plugins/plugin_installer/plugin_install";
11 +const PLUGIN_API = "plugins/_plugin_installer/plugin_install";
12 const PER_PAGE = 20;
13
14 const SECURITY_WARNING = {
@@ -82,23 +82,6 @@ const model = {
82 return url.replace("https://github.com/", "https://raw.githubusercontent.com/");
83 },
84
85 - _pluginName(plugin) {
86 - const githubUrl = (plugin?.github || "").trim().replace(/\.git$/i, "");
87 - const match = githubUrl.match(/github\.com\/([^/]+)\/([^/]+)/i);
88 - if (!match) return plugin?.key || "";
89 -
90 - const parts = [match[1], match[2]]
91 - .map((part) =>
92 - String(part)
93 - .replace(/[^0-9A-Za-z]+/g, "_")
94 - .replace(/_+/g, "_")
95 - .replace(/^_+|_+$/g, "")
96 - )
97 - .filter(Boolean);
98 -
99 - return parts.join("__") || plugin?.key || "";
100 - },
101 -
85 _pluginPrimaryTag(plugin) {
86 const tags = Array.isArray(plugin?.tags) ? plugin.tags.filter(Boolean) : [];
87 return tags[0] || "";
@@ -359,7 +342,7 @@ const model = {
342 },
343
344 openDetail(plugin) {
362 - this.selectedPlugin = { ...plugin, name: this._pluginName(plugin) };
345 + this.selectedPlugin = { ...plugin, name: plugin?.key || "" };
346 this.error = "";
347 this.result = null;
348 this.installedPluginInfo = null;
@@ -369,7 +352,7 @@ const model = {
352 this.fetchInstalledPluginInfo(this.selectedPlugin.name);
353 }
354 this.fetchReadme(this.selectedPlugin);
372 - openModal("/plugins/plugin_installer/webui/install-detail.html");
355 + openModal("/plugins/_plugin_installer/webui/install-detail.html");
356 },
357
358 async fetchReadme(plugin) {
@@ -428,6 +411,7 @@ const model = {
411 const data = await api.callJsonApi(PLUGIN_API, {
412 action: "install_git",
413 git_url: plugin.github,
414 + plugin_name: plugin.key,
415 });
416
417 if (!data.success) {
@@ -439,13 +423,14 @@ const model = {
423 if (installedKey && !this.installedPlugins.includes(installedKey)) {
424 this.installedPlugins = [...this.installedPlugins, installedKey];
425 }
426 +
427 this.selectedPlugin = {
428 ...plugin,
444 - ...(this.selectedPlugin || {}),
429 + name: plugin.key || "",
430 installed: true,
431 };
432 this.detailThumbnailUrl = this.getThumbnailUrl(this.selectedPlugin);
448 - this.fetchInstalledPluginInfo(data.plugin_name);
433 + this.fetchInstalledPluginInfo(plugin.key || data.plugin_name);
434
435 toastFrontendSuccess(
436 `Plugin "${data.title || data.plugin_name}" installed`,
plugins/_plugin_scan/api/plugin_scan_queue.py renamed
plugins/_plugin_scan/api/plugin_scan_start.py renamed
plugins/_plugin_scan/extensions/webui/confirm_dialog_after_render/add-marketplace-scan-action.js renamed
+1 -1
@@ -1,4 +1,4 @@
1 -import { store as pluginScanStore } from "/plugins/plugin_scan/webui/plugin-scan-store.js";
1 +import { store as pluginScanStore } from "../../../webui/plugin-scan-store.js";
2
3 const NOTE_CLASS = "confirm-dialog-extension-note";
4 const BUTTON_CLASS = "confirm-dialog-plugin-scan-button";
plugins/_plugin_scan/plugin.yaml renamed
+1
@@ -1,3 +1,4 @@
1 +name: _plugin_scan
2 title: Plugin Scanner
3 description: Security scanner for third-party A0 plugins.
4 version: 1.0.0
plugins/_plugin_scan/webui/main.html renamed
+1 -1
@@ -6,6 +6,6 @@
6 <title>Plugin Scanner</title>
7 </head>
8 <body>
9 - <x-component path="/plugins/plugin_scan/webui/plugin-scan.html"></x-component>
9 + <x-component path="/plugins/_plugin_scan/webui/plugin-scan.html"></x-component>
10 </body>
11 </html>
plugins/_plugin_scan/webui/plugin-scan-checks.json renamed
plugins/_plugin_scan/webui/plugin-scan-prompt.md renamed
plugins/_plugin_scan/webui/plugin-scan-store.js renamed
+5 -5
@@ -3,7 +3,7 @@ import { createStore } from "/js/AlpineStore.js";
3 import * as api from "/js/api.js";
4 import { openModal } from "/js/modals.js";
5
6 -const BASE = "/plugins/plugin_scan/webui";
6 +const BASE = "/plugins/_plugin_scan/webui";
7
8 /** @type {{ ratings: Record<string, {icon:string,label:string}>, checks: Record<string, {label:string,detail:string,criteria:Record<string,string>}> } | null} */
9 let _config = null;
@@ -94,7 +94,7 @@ export const store = createStore("pluginScan", {
94
95 async openModal(url) {
96 this.gitUrl = url || "";
97 - await openModal("/plugins/plugin_scan/webui/plugin-scan.html");
97 + await openModal("/plugins/_plugin_scan/webui/plugin-scan.html");
98 },
99
100 async buildPrompt() {
@@ -165,14 +165,14 @@ export const store = createStore("pluginScan", {
165
166 if (_running) {
167 try {
168 - await api.callJsonApi("/plugins/plugin_scan/plugin_scan_queue", { context: ctxId, text: capturedPrompt, queued: true });
168 + await api.callJsonApi("/plugins/_plugin_scan/plugin_scan_queue", { context: ctxId, text: capturedPrompt, queued: true });
169 } catch { /* best-effort */ }
170 _queue.push({ gen, ctxId, prompt: capturedPrompt });
171 this.queued = true;
172 this.scanning = false;
173 } else {
174 try {
175 - await api.callJsonApi("/plugins/plugin_scan/plugin_scan_queue", { context: ctxId, text: capturedPrompt });
175 + await api.callJsonApi("/plugins/_plugin_scan/plugin_scan_queue", { context: ctxId, text: capturedPrompt });
176 } catch { /* best-effort */ }
177 this.queued = false;
178 this.scanning = true;
@@ -184,7 +184,7 @@ export const store = createStore("pluginScan", {
184 async _runNext(gen, ctxId, prompt) {
185 _running = { gen, ctxId };
186 try {
187 - await api.callJsonApi("/plugins/plugin_scan/plugin_scan_start", { text: prompt, context: ctxId });
187 + await api.callJsonApi("/plugins/_plugin_scan/plugin_scan_start", { text: prompt, context: ctxId });
188 await this._pollLoop(gen, ctxId);
189 } catch (/** @type {any} */ e) {
190 if (gen === _pollGen) {
plugins/_plugin_scan/webui/plugin-scan.html renamed
+1 -1
@@ -2,7 +2,7 @@
2 <head>
3 <title>Plugin Scanner</title>
4 <script type="module">
5 - import { store } from "/plugins/plugin_scan/webui/plugin-scan-store.js";
5 + import { store } from "/plugins/_plugin_scan/webui/plugin-scan-store.js";
6 </script>
7 </head>
8 <body>
plugins/_text_editor/default_config.yaml renamed
plugins/_text_editor/extensions/.gitkeep renamed
plugins/_text_editor/extensions/python/system_prompt/_15_text_editor_prompt.py renamed
+4 -1
@@ -11,7 +11,10 @@ class TextEditorPrompt(Extension):
11 loop_data: LoopData = LoopData(),
12 **kwargs,
13 ):
14 - config = plugins.get_plugin_config("text_editor", agent=self.agent) or {}
14 + if not self.agent:
15 + return
16 +
17 + config = plugins.get_plugin_config("_text_editor", agent=self.agent) or {}
18 default_line_count = config.get("default_line_count", 100)
19 prompt = self.agent.read_prompt(
20 "agent.system.tool.text_editor.md",
plugins/_text_editor/helpers/__init__.py renamed
plugins/_text_editor/helpers/file_ops.py renamed
plugins/_text_editor/plugin.yaml renamed
+1
@@ -1,3 +1,4 @@
1 +name: _text_editor
2 title: Text Editor
3 description: Native tool to read, write, and patch text files in an LLM-friendly way.
4 version: 1.0.0
plugins/_text_editor/prompts/agent.system.tool.text_editor.md renamed
plugins/_text_editor/prompts/fw.text_editor.patch_error.md renamed
plugins/_text_editor/prompts/fw.text_editor.patch_need_read.md renamed
plugins/_text_editor/prompts/fw.text_editor.patch_ok.md renamed
plugins/_text_editor/prompts/fw.text_editor.patch_stale_read.md renamed
plugins/_text_editor/prompts/fw.text_editor.read_error.md renamed
plugins/_text_editor/prompts/fw.text_editor.read_ok.md renamed
plugins/_text_editor/prompts/fw.text_editor.write_error.md renamed
plugins/_text_editor/prompts/fw.text_editor.write_ok.md renamed
plugins/_text_editor/tools/text_editor.py renamed
+2 -2
@@ -1,7 +1,7 @@
1 from helpers.tool import Tool, Response
2 from helpers.extension import call_extensions_async
3 from helpers import plugins, runtime
4 -from plugins.text_editor.helpers.file_ops import (
4 +from plugins._text_editor.helpers.file_ops import (
5 FileInfo,
6 read_file,
7 write_file,
@@ -312,7 +312,7 @@ def _check_mtime(agent, info: FileInfo) -> str:
312 # ------------------------------------------------------------------
313
314 def _get_config(agent) -> dict:
315 - config = plugins.get_plugin_config("text_editor", agent=agent) or {}
315 + config = plugins.get_plugin_config("_text_editor", agent=agent) or {}
316 return {
317 "max_line_tokens": int(config.get("max_line_tokens", 500)),
318 "default_line_count": int(config.get("default_line_count", 100)),
plugins/_text_editor/webui/config.html renamed
tmp/plugins_installer/tmp_plugin_20260310_203515_4e21b18c new
+1
@@ -0,0 +1 @@
1 +Subproject commit 861209a0ca379a024e8a80964967fc5ea8f04d7c
tools/knowledge_tool._py
+2 -2
@@ -1,7 +1,7 @@
1 import asyncio
2 from helpers import dotenv, perplexity_search, duckduckgo_search
3 -from plugins.memory.helpers.memory import Memory
4 -from plugins.memory.tools.memory_load import DEFAULT_THRESHOLD as DEFAULT_MEMORY_THRESHOLD
3 +from plugins._memory.helpers.memory import Memory
4 +from plugins._memory.tools.memory_load import DEFAULT_THRESHOLD as DEFAULT_MEMORY_THRESHOLD
5
6 from helpers.tool import Tool, Response
7 from helpers.document_query import DocumentQueryHelper
usr/plugins/a0_community_plugins__linear deleted
-1
@@ -1 +0,0 @@
1 -Subproject commit 337839c21c250881bf7063dbc9421c7750989ed1
usr/plugins/example_plugin new
+1
@@ -0,0 +1 @@
1 +Subproject commit 8348aa7e7fdbea5ccedcc55316ee0473f5346bda
webui/components/plugins/list/plugin-list.html
+1 -1
@@ -178,7 +178,7 @@
178 </template>
179
180 <template x-if="$store.pluginListStore.activeTab === 'marketplace'">
181 - <x-component path="/plugins/plugin_installer/webui/install-index.html"></x-component>
181 + <x-component path="/plugins/_plugin_installer/webui/install-index.html"></x-component>
182 </template>
183 </div>
184 </template>
webui/components/welcome/welcome-store.js
+1 -1
@@ -1,7 +1,7 @@
1 import { createStore } from "/js/AlpineStore.js";
2 import { getContext } from "/index.js";
3 import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
4 -import { store as memoryStore } from "/plugins/memory/webui/memory-dashboard-store.js";
4 +import { store as memoryStore } from "../../../plugins/_memory/webui/memory-dashboard-store.js";
5 import { store as projectsStore } from "/components/projects/projects-store.js";
6 import { store as chatInputStore } from "/components/chat/input/input-store.js";
7 import * as API from "/js/api.js";