noesium 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- noesium/agents/askura_agent/__init__.py +22 -0
- noesium/agents/askura_agent/askura_agent.py +480 -0
- noesium/agents/askura_agent/conversation.py +164 -0
- noesium/agents/askura_agent/extractor.py +175 -0
- noesium/agents/askura_agent/memory.py +14 -0
- noesium/agents/askura_agent/models.py +239 -0
- noesium/agents/askura_agent/prompts.py +202 -0
- noesium/agents/askura_agent/reflection.py +234 -0
- noesium/agents/askura_agent/summarizer.py +30 -0
- noesium/agents/askura_agent/utils.py +6 -0
- noesium/agents/deep_research/__init__.py +13 -0
- noesium/agents/deep_research/agent.py +398 -0
- noesium/agents/deep_research/prompts.py +84 -0
- noesium/agents/deep_research/schemas.py +42 -0
- noesium/agents/deep_research/state.py +54 -0
- noesium/agents/search/__init__.py +5 -0
- noesium/agents/search/agent.py +474 -0
- noesium/agents/search/state.py +28 -0
- noesium/core/__init__.py +1 -1
- noesium/core/agent/base.py +10 -2
- noesium/core/goalith/decomposer/llm_decomposer.py +1 -1
- noesium/core/llm/__init__.py +1 -1
- noesium/core/llm/base.py +2 -2
- noesium/core/llm/litellm.py +42 -21
- noesium/core/llm/llamacpp.py +25 -4
- noesium/core/llm/ollama.py +43 -22
- noesium/core/llm/openai.py +25 -5
- noesium/core/llm/openrouter.py +1 -1
- noesium/core/toolify/base.py +9 -2
- noesium/core/toolify/config.py +2 -2
- noesium/core/toolify/registry.py +21 -5
- noesium/core/tracing/opik_tracing.py +7 -7
- noesium/core/vector_store/__init__.py +2 -2
- noesium/core/vector_store/base.py +1 -1
- noesium/core/vector_store/pgvector.py +10 -13
- noesium/core/vector_store/weaviate.py +2 -1
- noesium/toolkits/__init__.py +1 -0
- noesium/toolkits/arxiv_toolkit.py +310 -0
- noesium/toolkits/audio_aliyun_toolkit.py +441 -0
- noesium/toolkits/audio_toolkit.py +370 -0
- noesium/toolkits/bash_toolkit.py +332 -0
- noesium/toolkits/document_toolkit.py +454 -0
- noesium/toolkits/file_edit_toolkit.py +552 -0
- noesium/toolkits/github_toolkit.py +395 -0
- noesium/toolkits/gmail_toolkit.py +575 -0
- noesium/toolkits/image_toolkit.py +425 -0
- noesium/toolkits/memory_toolkit.py +398 -0
- noesium/toolkits/python_executor_toolkit.py +334 -0
- noesium/toolkits/search_toolkit.py +451 -0
- noesium/toolkits/serper_toolkit.py +623 -0
- noesium/toolkits/tabular_data_toolkit.py +537 -0
- noesium/toolkits/user_interaction_toolkit.py +365 -0
- noesium/toolkits/video_toolkit.py +168 -0
- noesium/toolkits/wikipedia_toolkit.py +420 -0
- {noesium-0.1.0.dist-info → noesium-0.2.0.dist-info}/METADATA +56 -48
- {noesium-0.1.0.dist-info → noesium-0.2.0.dist-info}/RECORD +59 -23
- {noesium-0.1.0.dist-info → noesium-0.2.0.dist-info}/licenses/LICENSE +1 -1
- {noesium-0.1.0.dist-info → noesium-0.2.0.dist-info}/WHEEL +0 -0
- {noesium-0.1.0.dist-info → noesium-0.2.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Memory toolkit for persistent text storage and manipulation.
|
|
3
|
+
|
|
4
|
+
Provides tools for storing, retrieving, and editing persistent text content
|
|
5
|
+
with safety features and comprehensive error handling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Callable, Dict, Optional
|
|
11
|
+
|
|
12
|
+
from noesium.core.toolify.base import AsyncBaseToolkit
|
|
13
|
+
from noesium.core.toolify.config import ToolkitConfig
|
|
14
|
+
from noesium.core.toolify.registry import register_toolkit
|
|
15
|
+
from noesium.core.utils.logging import get_logger
|
|
16
|
+
|
|
17
|
+
logger = get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@register_toolkit("memory")
|
|
21
|
+
class MemoryToolkit(AsyncBaseToolkit):
|
|
22
|
+
"""
|
|
23
|
+
Toolkit for persistent memory storage and manipulation.
|
|
24
|
+
|
|
25
|
+
This toolkit provides capabilities for:
|
|
26
|
+
- Storing and retrieving persistent text content
|
|
27
|
+
- Editing memory content with string replacement
|
|
28
|
+
- Multiple memory slots for different contexts
|
|
29
|
+
- File-based persistence across sessions
|
|
30
|
+
- Safety warnings for overwrite operations
|
|
31
|
+
- Search and pattern matching in memory
|
|
32
|
+
|
|
33
|
+
Features:
|
|
34
|
+
- In-memory and file-based storage options
|
|
35
|
+
- Multiple named memory slots
|
|
36
|
+
- String replacement with occurrence counting
|
|
37
|
+
- Content validation and safety checks
|
|
38
|
+
- Backup and versioning support
|
|
39
|
+
- Search and filtering capabilities
|
|
40
|
+
|
|
41
|
+
Use cases:
|
|
42
|
+
- Maintaining conversation context
|
|
43
|
+
- Storing intermediate results
|
|
44
|
+
- Building knowledge bases
|
|
45
|
+
- Caching computed information
|
|
46
|
+
- Maintaining state across operations
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, config: ToolkitConfig = None):
|
|
50
|
+
"""
|
|
51
|
+
Initialize the memory toolkit.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
config: Toolkit configuration
|
|
55
|
+
"""
|
|
56
|
+
super().__init__(config)
|
|
57
|
+
|
|
58
|
+
# Configuration
|
|
59
|
+
self.storage_type = self.config.config.get("storage_type", "memory") # "memory" or "file"
|
|
60
|
+
self.storage_dir = Path(self.config.config.get("storage_dir", "./memory_storage"))
|
|
61
|
+
self.max_memory_size = self.config.config.get("max_memory_size", 1024 * 1024) # 1MB
|
|
62
|
+
self.enable_versioning = self.config.config.get("enable_versioning", False)
|
|
63
|
+
|
|
64
|
+
# In-memory storage
|
|
65
|
+
self.memory_slots: Dict[str, str] = {}
|
|
66
|
+
|
|
67
|
+
# File storage setup
|
|
68
|
+
if self.storage_type == "file":
|
|
69
|
+
self.storage_dir.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
self._load_persistent_memory()
|
|
71
|
+
|
|
72
|
+
self.logger.info(f"Memory toolkit initialized with {self.storage_type} storage")
|
|
73
|
+
|
|
74
|
+
def _get_memory_file_path(self, slot_name: str) -> Path:
|
|
75
|
+
"""Get file path for a memory slot."""
|
|
76
|
+
safe_name = "".join(c for c in slot_name if c.isalnum() or c in "._-")
|
|
77
|
+
return self.storage_dir / f"{safe_name}.txt"
|
|
78
|
+
|
|
79
|
+
def _load_persistent_memory(self):
|
|
80
|
+
"""Load memory from persistent storage."""
|
|
81
|
+
try:
|
|
82
|
+
for file_path in self.storage_dir.glob("*.txt"):
|
|
83
|
+
slot_name = file_path.stem
|
|
84
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
85
|
+
self.memory_slots[slot_name] = f.read()
|
|
86
|
+
|
|
87
|
+
self.logger.info(f"Loaded {len(self.memory_slots)} memory slots from storage")
|
|
88
|
+
|
|
89
|
+
except Exception as e:
|
|
90
|
+
self.logger.warning(f"Failed to load persistent memory: {e}")
|
|
91
|
+
|
|
92
|
+
def _save_memory_slot(self, slot_name: str, content: str):
|
|
93
|
+
"""Save a memory slot to persistent storage."""
|
|
94
|
+
if self.storage_type != "file":
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
file_path = self._get_memory_file_path(slot_name)
|
|
99
|
+
|
|
100
|
+
# Create backup if versioning is enabled
|
|
101
|
+
if self.enable_versioning and file_path.exists():
|
|
102
|
+
backup_path = file_path.with_suffix(f".{int(os.path.getmtime(file_path))}.bak")
|
|
103
|
+
file_path.rename(backup_path)
|
|
104
|
+
|
|
105
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
106
|
+
f.write(content)
|
|
107
|
+
|
|
108
|
+
except Exception as e:
|
|
109
|
+
self.logger.error(f"Failed to save memory slot '{slot_name}': {e}")
|
|
110
|
+
|
|
111
|
+
def _validate_content_size(self, content: str) -> bool:
|
|
112
|
+
"""Validate that content doesn't exceed size limits."""
|
|
113
|
+
return len(content.encode("utf-8")) <= self.max_memory_size
|
|
114
|
+
|
|
115
|
+
async def read_memory(self, slot_name: str = "default") -> str:
|
|
116
|
+
"""
|
|
117
|
+
Read the contents of a memory slot.
|
|
118
|
+
|
|
119
|
+
This tool retrieves the current content stored in the specified memory slot.
|
|
120
|
+
Memory slots allow you to maintain separate contexts or information stores.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
slot_name: Name of the memory slot to read (default: "default")
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Current content of the memory slot, or empty string if slot doesn't exist
|
|
127
|
+
|
|
128
|
+
Example:
|
|
129
|
+
content = await read_memory("conversation_context")
|
|
130
|
+
print(f"Current context: {content}")
|
|
131
|
+
"""
|
|
132
|
+
self.logger.info(f"Reading memory slot: {slot_name}")
|
|
133
|
+
|
|
134
|
+
content = self.memory_slots.get(slot_name, "")
|
|
135
|
+
|
|
136
|
+
if not content:
|
|
137
|
+
return f"Memory slot '{slot_name}' is empty or does not exist."
|
|
138
|
+
|
|
139
|
+
self.logger.info(f"Read {len(content)} characters from slot '{slot_name}'")
|
|
140
|
+
return content
|
|
141
|
+
|
|
142
|
+
async def write_memory(self, content: str, slot_name: str = "default") -> str:
|
|
143
|
+
"""
|
|
144
|
+
Write content to a memory slot, replacing any existing content.
|
|
145
|
+
|
|
146
|
+
This tool stores content in the specified memory slot. If the slot already
|
|
147
|
+
contains content, it will be completely replaced. A warning is provided
|
|
148
|
+
when overwriting existing content.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
content: Content to store in memory
|
|
152
|
+
slot_name: Name of the memory slot to write to (default: "default")
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Success message, including warning if overwriting existing content
|
|
156
|
+
|
|
157
|
+
Example:
|
|
158
|
+
result = await write_memory("Important information to remember", "notes")
|
|
159
|
+
"""
|
|
160
|
+
self.logger.info(f"Writing to memory slot: {slot_name}")
|
|
161
|
+
|
|
162
|
+
# Validate content size
|
|
163
|
+
if not self._validate_content_size(content):
|
|
164
|
+
return f"Error: Content too large ({len(content)} chars, max: {self.max_memory_size})"
|
|
165
|
+
|
|
166
|
+
# Check if overwriting existing content
|
|
167
|
+
existing_content = self.memory_slots.get(slot_name, "")
|
|
168
|
+
warning_msg = ""
|
|
169
|
+
|
|
170
|
+
if existing_content:
|
|
171
|
+
warning_msg = (
|
|
172
|
+
f"Warning: Overwriting existing content in slot '{slot_name}'. "
|
|
173
|
+
f"Previous content ({len(existing_content)} chars) was:\n"
|
|
174
|
+
f"{existing_content[:200]}{'...' if len(existing_content) > 200 else ''}\n\n"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Store the content
|
|
178
|
+
self.memory_slots[slot_name] = content
|
|
179
|
+
|
|
180
|
+
# Save to persistent storage if enabled
|
|
181
|
+
self._save_memory_slot(slot_name, content)
|
|
182
|
+
|
|
183
|
+
result_msg = f"Memory slot '{slot_name}' updated successfully with {len(content)} characters."
|
|
184
|
+
|
|
185
|
+
self.logger.info(f"Wrote {len(content)} characters to slot '{slot_name}'")
|
|
186
|
+
return warning_msg + result_msg
|
|
187
|
+
|
|
188
|
+
async def edit_memory(self, old_string: str, new_string: str, slot_name: str = "default") -> str:
|
|
189
|
+
"""
|
|
190
|
+
Edit memory content by replacing occurrences of a string.
|
|
191
|
+
|
|
192
|
+
This tool performs string replacement within a memory slot. It provides
|
|
193
|
+
safety checks for multiple occurrences and clear feedback about changes made.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
old_string: String to find and replace
|
|
197
|
+
new_string: String to replace with
|
|
198
|
+
slot_name: Name of the memory slot to edit (default: "default")
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
Result message indicating success, failure, or warnings
|
|
202
|
+
|
|
203
|
+
Example:
|
|
204
|
+
result = await edit_memory("old info", "new info", "notes")
|
|
205
|
+
"""
|
|
206
|
+
self.logger.info(f"Editing memory slot: {slot_name}")
|
|
207
|
+
|
|
208
|
+
# Check if slot exists
|
|
209
|
+
if slot_name not in self.memory_slots:
|
|
210
|
+
return f"Error: Memory slot '{slot_name}' does not exist."
|
|
211
|
+
|
|
212
|
+
current_content = self.memory_slots[slot_name]
|
|
213
|
+
|
|
214
|
+
# Check if old_string exists
|
|
215
|
+
if old_string not in current_content:
|
|
216
|
+
return f"Error: String '{old_string}' not found in memory slot '{slot_name}'."
|
|
217
|
+
|
|
218
|
+
# Count occurrences
|
|
219
|
+
occurrence_count = current_content.count(old_string)
|
|
220
|
+
|
|
221
|
+
if occurrence_count > 1:
|
|
222
|
+
return (
|
|
223
|
+
f"Warning: Found {occurrence_count} occurrences of '{old_string}' "
|
|
224
|
+
f"in slot '{slot_name}'. Please use more specific context to avoid "
|
|
225
|
+
"unintended replacements, or use replace_all_in_memory for intentional "
|
|
226
|
+
"multiple replacements."
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Perform replacement
|
|
230
|
+
new_content = current_content.replace(old_string, new_string, 1)
|
|
231
|
+
|
|
232
|
+
# Validate new content size
|
|
233
|
+
if not self._validate_content_size(new_content):
|
|
234
|
+
return f"Error: Edited content would be too large"
|
|
235
|
+
|
|
236
|
+
# Update memory
|
|
237
|
+
self.memory_slots[slot_name] = new_content
|
|
238
|
+
self._save_memory_slot(slot_name, new_content)
|
|
239
|
+
|
|
240
|
+
self.logger.info(f"Edited memory slot '{slot_name}': replaced 1 occurrence")
|
|
241
|
+
return f"Successfully replaced 1 occurrence of '{old_string}' with '{new_string}' in slot '{slot_name}'."
|
|
242
|
+
|
|
243
|
+
async def append_to_memory(self, content: str, slot_name: str = "default", separator: str = "\n") -> str:
|
|
244
|
+
"""
|
|
245
|
+
Append content to an existing memory slot.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
content: Content to append
|
|
249
|
+
slot_name: Name of the memory slot (default: "default")
|
|
250
|
+
separator: Separator to use between existing and new content
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
Success message
|
|
254
|
+
"""
|
|
255
|
+
existing_content = self.memory_slots.get(slot_name, "")
|
|
256
|
+
|
|
257
|
+
if existing_content:
|
|
258
|
+
new_content = existing_content + separator + content
|
|
259
|
+
else:
|
|
260
|
+
new_content = content
|
|
261
|
+
|
|
262
|
+
if not self._validate_content_size(new_content):
|
|
263
|
+
return f"Error: Combined content would be too large"
|
|
264
|
+
|
|
265
|
+
self.memory_slots[slot_name] = new_content
|
|
266
|
+
self._save_memory_slot(slot_name, new_content)
|
|
267
|
+
|
|
268
|
+
self.logger.info(f"Appended {len(content)} characters to slot '{slot_name}'")
|
|
269
|
+
return f"Successfully appended content to memory slot '{slot_name}'."
|
|
270
|
+
|
|
271
|
+
async def clear_memory(self, slot_name: str = "default") -> str:
|
|
272
|
+
"""
|
|
273
|
+
Clear the contents of a memory slot.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
slot_name: Name of the memory slot to clear (default: "default")
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
Success message
|
|
280
|
+
"""
|
|
281
|
+
if slot_name in self.memory_slots:
|
|
282
|
+
del self.memory_slots[slot_name]
|
|
283
|
+
|
|
284
|
+
# Remove from persistent storage
|
|
285
|
+
if self.storage_type == "file":
|
|
286
|
+
file_path = self._get_memory_file_path(slot_name)
|
|
287
|
+
if file_path.exists():
|
|
288
|
+
file_path.unlink()
|
|
289
|
+
|
|
290
|
+
self.logger.info(f"Cleared memory slot: {slot_name}")
|
|
291
|
+
return f"Memory slot '{slot_name}' has been cleared."
|
|
292
|
+
else:
|
|
293
|
+
return f"Memory slot '{slot_name}' does not exist."
|
|
294
|
+
|
|
295
|
+
async def list_memory_slots(self) -> str:
|
|
296
|
+
"""
|
|
297
|
+
List all available memory slots with their sizes.
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
Formatted list of memory slots
|
|
301
|
+
"""
|
|
302
|
+
if not self.memory_slots:
|
|
303
|
+
return "No memory slots exist."
|
|
304
|
+
|
|
305
|
+
slot_info = []
|
|
306
|
+
for slot_name, content in self.memory_slots.items():
|
|
307
|
+
size = len(content)
|
|
308
|
+
preview = content[:50].replace("\n", " ") if content else "(empty)"
|
|
309
|
+
if len(content) > 50:
|
|
310
|
+
preview += "..."
|
|
311
|
+
|
|
312
|
+
slot_info.append(f" {slot_name}: {size} chars - {preview}")
|
|
313
|
+
|
|
314
|
+
result = f"Memory slots ({len(self.memory_slots)} total):\n" + "\n".join(slot_info)
|
|
315
|
+
return result
|
|
316
|
+
|
|
317
|
+
async def search_memory(self, query: str, slot_name: Optional[str] = None) -> str:
|
|
318
|
+
"""
|
|
319
|
+
Search for text within memory slots.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
query: Text to search for
|
|
323
|
+
slot_name: Specific slot to search (if None, searches all slots)
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
Search results with context
|
|
327
|
+
"""
|
|
328
|
+
results = []
|
|
329
|
+
|
|
330
|
+
slots_to_search = {slot_name: self.memory_slots[slot_name]} if slot_name else self.memory_slots
|
|
331
|
+
|
|
332
|
+
for name, content in slots_to_search.items():
|
|
333
|
+
if query.lower() in content.lower():
|
|
334
|
+
# Find all occurrences with context
|
|
335
|
+
lines = content.split("\n")
|
|
336
|
+
for i, line in enumerate(lines):
|
|
337
|
+
if query.lower() in line.lower():
|
|
338
|
+
context_start = max(0, i - 1)
|
|
339
|
+
context_end = min(len(lines), i + 2)
|
|
340
|
+
context_lines = lines[context_start:context_end]
|
|
341
|
+
|
|
342
|
+
results.append(f"Slot '{name}', line {i+1}:")
|
|
343
|
+
results.extend(f" {j+context_start+1}: {line}" for j, line in enumerate(context_lines))
|
|
344
|
+
results.append("")
|
|
345
|
+
|
|
346
|
+
if not results:
|
|
347
|
+
search_scope = f"slot '{slot_name}'" if slot_name else "all memory slots"
|
|
348
|
+
return f"No matches found for '{query}' in {search_scope}."
|
|
349
|
+
|
|
350
|
+
return "\n".join(results)
|
|
351
|
+
|
|
352
|
+
async def get_memory_stats(self) -> str:
|
|
353
|
+
"""
|
|
354
|
+
Get statistics about memory usage.
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
Formatted memory statistics
|
|
358
|
+
"""
|
|
359
|
+
total_slots = len(self.memory_slots)
|
|
360
|
+
total_chars = sum(len(content) for content in self.memory_slots.values())
|
|
361
|
+
total_bytes = sum(len(content.encode("utf-8")) for content in self.memory_slots.values())
|
|
362
|
+
|
|
363
|
+
if total_slots == 0:
|
|
364
|
+
return "No memory slots exist."
|
|
365
|
+
|
|
366
|
+
avg_size = total_chars // total_slots
|
|
367
|
+
largest_slot = max(self.memory_slots.items(), key=lambda x: len(x[1]))
|
|
368
|
+
|
|
369
|
+
stats = [
|
|
370
|
+
f"Memory Statistics:",
|
|
371
|
+
f" Total slots: {total_slots}",
|
|
372
|
+
f" Total characters: {total_chars:,}",
|
|
373
|
+
f" Total bytes: {total_bytes:,}",
|
|
374
|
+
f" Average slot size: {avg_size:,} characters",
|
|
375
|
+
f" Largest slot: '{largest_slot[0]}' ({len(largest_slot[1]):,} chars)",
|
|
376
|
+
f" Storage type: {self.storage_type}",
|
|
377
|
+
f" Max slot size: {self.max_memory_size:,} bytes",
|
|
378
|
+
]
|
|
379
|
+
|
|
380
|
+
return "\n".join(stats)
|
|
381
|
+
|
|
382
|
+
async def get_tools_map(self) -> Dict[str, Callable]:
|
|
383
|
+
"""
|
|
384
|
+
Get the mapping of tool names to their implementation functions.
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
Dictionary mapping tool names to callable functions
|
|
388
|
+
"""
|
|
389
|
+
return {
|
|
390
|
+
"read_memory": self.read_memory,
|
|
391
|
+
"write_memory": self.write_memory,
|
|
392
|
+
"edit_memory": self.edit_memory,
|
|
393
|
+
"append_to_memory": self.append_to_memory,
|
|
394
|
+
"clear_memory": self.clear_memory,
|
|
395
|
+
"list_memory_slots": self.list_memory_slots,
|
|
396
|
+
"search_memory": self.search_memory,
|
|
397
|
+
"get_memory_stats": self.get_memory_stats,
|
|
398
|
+
}
|