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";