mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-04 10:13:06 +00:00
The backend import package is now docsgpt, the name it will carry on PyPI; application was far too generic to install into anyone's site-packages. git mv plus a mechanical rewrite of every import, dotted string and path reference: 734 Python files, the compose files, Dockerfile, workflows, docs, setup scripts, devcontainer, k8s manifests, vscode config, pytest and coverage config, .gitignore. Behaviour is unchanged. Kept for one release: - A top-level application package whose meta-path finder resolves application.x.y to the already-imported docsgpt.x.y object, so old imports and entry points (celery -A application.app.celery, uvicorn application.asgi:asgi_app) keep working with a FutureWarning. - Celery registers every application.* task name as an alias of its docsgpt.* task on start-up, so messages queued by the previous release still run. The redbeat key prefix moves to redbeat:docsgpt:v2: so schedule entries the previous release wrote are left unread instead of firing twice. The backend image builds from the repository root (docker build -f docsgpt/Dockerfile .) so it can ship the alias package; a root .dockerignore allow-lists docsgpt/ and application/ and keeps caches, local data, .env files, the sample index files and the Dockerfile out. Compose and the image workflows point at the new context.
524 lines
20 KiB
Python
524 lines
20 KiB
Python
from typing import Any, Dict, List, Optional
|
|
import logging
|
|
import uuid
|
|
|
|
from .base import Tool
|
|
from .path_utils import validate_tool_path
|
|
from docsgpt.storage.db.repositories.memories import MemoriesRepository
|
|
from docsgpt.storage.db.session import db_readonly, db_session
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class MemoryTool(Tool):
|
|
"""Memory
|
|
|
|
Stores and retrieves information across conversations through a memory file directory.
|
|
"""
|
|
|
|
def __init__(self, tool_config: Optional[Dict[str, Any]] = None, user_id: Optional[str] = None) -> None:
|
|
"""Initialize the tool.
|
|
|
|
Args:
|
|
tool_config: Optional tool configuration. Should include:
|
|
- tool_id: Unique identifier for this memory tool instance (from user_tools._id)
|
|
This ensures each user's tool configuration has isolated memories
|
|
user_id: The authenticated user's id (should come from decoded_token["sub"]).
|
|
"""
|
|
self.user_id: Optional[str] = user_id
|
|
|
|
# Get tool_id from configuration (passed from user_tools._id in production)
|
|
# In production, tool_id is the UUID string from user_tools.id.
|
|
if tool_config and "tool_id" in tool_config:
|
|
self.tool_id = tool_config["tool_id"]
|
|
elif user_id:
|
|
# Fallback for backward compatibility or testing
|
|
self.tool_id = f"default_{user_id}"
|
|
else:
|
|
# Last resort fallback (shouldn't happen in normal use)
|
|
self.tool_id = str(uuid.uuid4())
|
|
|
|
def _pg_enabled(self) -> bool:
|
|
"""Return True if this MemoryTool's tool_id is a real ``user_tools.id``.
|
|
|
|
The ``memories`` PG table has a UUID foreign key to ``user_tools``.
|
|
The sentinel ``default_{uid}`` fallback tool_id is not a UUID and
|
|
has no row in ``user_tools``, so any storage operation would fail
|
|
the foreign-key check. After the Postgres cutover Postgres is the
|
|
only store, so for the sentinel case there is nowhere to read or
|
|
write — operations become no-ops and the tool returns an
|
|
explanatory error to the caller.
|
|
"""
|
|
tool_id = getattr(self, "tool_id", None)
|
|
if not tool_id or not isinstance(tool_id, str):
|
|
return False
|
|
if tool_id.startswith("default_"):
|
|
logger.debug(
|
|
"Skipping Postgres operation for MemoryTool with sentinel tool_id=%s",
|
|
tool_id,
|
|
)
|
|
return False
|
|
from docsgpt.storage.db.base_repository import looks_like_uuid
|
|
|
|
if not looks_like_uuid(tool_id):
|
|
logger.debug(
|
|
"Skipping Postgres operation for MemoryTool with non-UUID tool_id=%s",
|
|
tool_id,
|
|
)
|
|
return False
|
|
return True
|
|
|
|
# -----------------------------
|
|
# Action implementations
|
|
# -----------------------------
|
|
def execute_action(self, action_name: str, **kwargs: Any) -> str:
|
|
"""Execute an action by name.
|
|
|
|
Args:
|
|
action_name: One of memory_view, memory_create, memory_str_replace,
|
|
memory_insert, memory_delete, memory_rename (legacy unprefixed
|
|
names are accepted too).
|
|
**kwargs: Parameters for the action.
|
|
|
|
Returns:
|
|
A human-readable string result.
|
|
"""
|
|
# Stripping the namespace prefix accepts both the published names
|
|
# (memory_view) and legacy unprefixed names from saved user_tools rows.
|
|
action_name = action_name.removeprefix("memory_")
|
|
|
|
if not self.user_id:
|
|
return "Error: MemoryTool requires a valid user_id."
|
|
|
|
if not self._pg_enabled():
|
|
return (
|
|
"Error: MemoryTool is not configured with a persistent tool_id; "
|
|
"memory storage is unavailable for this session."
|
|
)
|
|
|
|
if action_name == "view":
|
|
return self._view(
|
|
kwargs.get("path", "/"),
|
|
kwargs.get("view_range")
|
|
)
|
|
|
|
if action_name == "create":
|
|
return self._create(
|
|
kwargs.get("path", ""),
|
|
kwargs.get("file_text", "")
|
|
)
|
|
|
|
if action_name == "str_replace":
|
|
return self._str_replace(
|
|
kwargs.get("path", ""),
|
|
kwargs.get("old_str", ""),
|
|
kwargs.get("new_str", "")
|
|
)
|
|
|
|
if action_name == "insert":
|
|
return self._insert(
|
|
kwargs.get("path", ""),
|
|
kwargs.get("insert_line", 1),
|
|
kwargs.get("insert_text", "")
|
|
)
|
|
|
|
if action_name == "delete":
|
|
return self._delete(kwargs.get("path", ""))
|
|
|
|
if action_name == "rename":
|
|
return self._rename(
|
|
kwargs.get("old_path", ""),
|
|
kwargs.get("new_path", "")
|
|
)
|
|
|
|
return f"Unknown action: {action_name}"
|
|
|
|
def get_actions_metadata(self) -> List[Dict[str, Any]]:
|
|
"""Return JSON metadata describing supported actions for tool schemas."""
|
|
return [
|
|
{
|
|
"name": "memory_view",
|
|
"description": (
|
|
"View the memory directory listing or a memory file's contents, "
|
|
"with an optional line range. Check memory before answering "
|
|
"questions that may rely on previously saved context."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {
|
|
"type": "string",
|
|
"description": "Path to file or directory (e.g., /notes.txt or /project/ or /)."
|
|
},
|
|
"view_range": {
|
|
"type": "array",
|
|
"items": {"type": "integer"},
|
|
"description": "Optional [start_line, end_line] to view specific lines (1-indexed)."
|
|
}
|
|
},
|
|
"required": ["path"]
|
|
},
|
|
},
|
|
{
|
|
"name": "memory_create",
|
|
"description": (
|
|
"Create or overwrite a memory file. Use it to save durable "
|
|
"facts, preferences, and project context worth remembering "
|
|
"across conversations."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {
|
|
"type": "string",
|
|
"description": "File path to create (e.g., /notes.txt or /project/task.txt)."
|
|
},
|
|
"file_text": {
|
|
"type": "string",
|
|
"description": "Content to write to the file."
|
|
}
|
|
},
|
|
"required": ["path", "file_text"]
|
|
},
|
|
},
|
|
{
|
|
"name": "memory_str_replace",
|
|
"description": "Replace a string in a memory file with a new string.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {
|
|
"type": "string",
|
|
"description": "File path (e.g., /notes.txt)."
|
|
},
|
|
"old_str": {
|
|
"type": "string",
|
|
"description": "String to find."
|
|
},
|
|
"new_str": {
|
|
"type": "string",
|
|
"description": "String to replace with."
|
|
}
|
|
},
|
|
"required": ["path", "old_str", "new_str"]
|
|
},
|
|
},
|
|
{
|
|
"name": "memory_insert",
|
|
"description": "Insert text at a specific line in a memory file (1-indexed).",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {
|
|
"type": "string",
|
|
"description": "File path (e.g., /notes.txt)."
|
|
},
|
|
"insert_line": {
|
|
"type": "integer",
|
|
"description": "Line number to insert at (1-indexed)."
|
|
},
|
|
"insert_text": {
|
|
"type": "string",
|
|
"description": "Text to insert."
|
|
}
|
|
},
|
|
"required": ["path", "insert_line", "insert_text"]
|
|
},
|
|
},
|
|
{
|
|
"name": "memory_delete",
|
|
"description": "Delete a memory file or directory.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {
|
|
"type": "string",
|
|
"description": "Path to delete (e.g., /notes.txt or /project/)."
|
|
}
|
|
},
|
|
"required": ["path"]
|
|
},
|
|
},
|
|
{
|
|
"name": "memory_rename",
|
|
"description": "Rename or move a memory file or directory.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"old_path": {
|
|
"type": "string",
|
|
"description": "Current path (e.g., /old.txt)."
|
|
},
|
|
"new_path": {
|
|
"type": "string",
|
|
"description": "New path (e.g., /new.txt)."
|
|
}
|
|
},
|
|
"required": ["old_path", "new_path"]
|
|
},
|
|
},
|
|
]
|
|
|
|
def get_config_requirements(self) -> Dict[str, Any]:
|
|
"""Return configuration requirements."""
|
|
return {}
|
|
|
|
# -----------------------------
|
|
# Path validation
|
|
# -----------------------------
|
|
def _validate_path(self, path: str) -> Optional[str]:
|
|
"""Validate and normalize path (delegates to the shared util)."""
|
|
return validate_tool_path(path)
|
|
|
|
# -----------------------------
|
|
# Internal helpers
|
|
# -----------------------------
|
|
def _view(self, path: str, view_range: Optional[List[int]] = None) -> str:
|
|
"""View directory contents or file contents."""
|
|
validated_path = self._validate_path(path)
|
|
if not validated_path:
|
|
return "Error: Invalid path."
|
|
|
|
# Check if viewing directory (ends with / or is root)
|
|
if validated_path == "/" or validated_path.endswith("/"):
|
|
return self._view_directory(validated_path)
|
|
|
|
# Otherwise view file
|
|
return self._view_file(validated_path, view_range)
|
|
|
|
def _view_directory(self, path: str) -> str:
|
|
"""List files in a directory."""
|
|
# Ensure path ends with / for proper prefix matching
|
|
search_path = path if path.endswith("/") else path + "/"
|
|
|
|
with db_readonly() as conn:
|
|
docs = MemoriesRepository(conn).list_by_prefix(
|
|
self.user_id, self.tool_id, search_path
|
|
)
|
|
|
|
if not docs:
|
|
return f"Directory: {path}\n(empty)"
|
|
|
|
# Extract filenames relative to the directory
|
|
files = []
|
|
for doc in docs:
|
|
file_path = doc["path"]
|
|
# Remove the directory prefix
|
|
if file_path.startswith(search_path):
|
|
relative = file_path[len(search_path):]
|
|
if relative:
|
|
files.append(relative)
|
|
|
|
files.sort()
|
|
file_list = "\n".join(f"- {f}" for f in files)
|
|
return f"Directory: {path}\n{file_list}"
|
|
|
|
def _view_file(self, path: str, view_range: Optional[List[int]] = None) -> str:
|
|
"""View file contents with optional line range."""
|
|
with db_readonly() as conn:
|
|
doc = MemoriesRepository(conn).get_by_path(
|
|
self.user_id, self.tool_id, path
|
|
)
|
|
|
|
if not doc or not doc.get("content"):
|
|
return f"Error: File not found: {path}"
|
|
|
|
content = str(doc["content"])
|
|
|
|
# Apply view_range if specified
|
|
if view_range and len(view_range) == 2:
|
|
lines = content.split("\n")
|
|
start, end = view_range
|
|
# Convert to 0-indexed
|
|
start_idx = max(0, start - 1)
|
|
end_idx = min(len(lines), end)
|
|
|
|
if start_idx >= len(lines):
|
|
return f"Error: Line range out of bounds. File has {len(lines)} lines."
|
|
|
|
selected_lines = lines[start_idx:end_idx]
|
|
# Add line numbers (enumerate with 1-based start)
|
|
numbered_lines = [f"{i}: {line}" for i, line in enumerate(selected_lines, start=start)]
|
|
return "\n".join(numbered_lines)
|
|
|
|
return content
|
|
|
|
def _create(self, path: str, file_text: str) -> str:
|
|
"""Create or overwrite a file."""
|
|
validated_path = self._validate_path(path)
|
|
if not validated_path:
|
|
return "Error: Invalid path."
|
|
|
|
if validated_path == "/" or validated_path.endswith("/"):
|
|
return "Error: Cannot create a file at directory path."
|
|
|
|
with db_session() as conn:
|
|
MemoriesRepository(conn).upsert(
|
|
self.user_id, self.tool_id, validated_path, file_text
|
|
)
|
|
|
|
return f"File created: {validated_path}"
|
|
|
|
def _str_replace(self, path: str, old_str: str, new_str: str) -> str:
|
|
"""Replace text in a file."""
|
|
validated_path = self._validate_path(path)
|
|
if not validated_path:
|
|
return "Error: Invalid path."
|
|
|
|
if not old_str:
|
|
return "Error: old_str is required."
|
|
|
|
with db_session() as conn:
|
|
repo = MemoriesRepository(conn)
|
|
doc = repo.get_by_path(self.user_id, self.tool_id, validated_path)
|
|
|
|
if not doc or not doc.get("content"):
|
|
return f"Error: File not found: {validated_path}"
|
|
|
|
current_content = str(doc["content"])
|
|
|
|
# Check if old_str exists (case-insensitive)
|
|
if old_str.lower() not in current_content.lower():
|
|
return f"Error: String '{old_str}' not found in file."
|
|
|
|
# Case-insensitive replace
|
|
import re as regex_module
|
|
updated_content = regex_module.sub(
|
|
regex_module.escape(old_str),
|
|
new_str,
|
|
current_content,
|
|
flags=regex_module.IGNORECASE,
|
|
)
|
|
|
|
repo.upsert(self.user_id, self.tool_id, validated_path, updated_content)
|
|
|
|
return f"File updated: {validated_path}"
|
|
|
|
def _insert(self, path: str, insert_line: int, insert_text: str) -> str:
|
|
"""Insert text at a specific line."""
|
|
validated_path = self._validate_path(path)
|
|
if not validated_path:
|
|
return "Error: Invalid path."
|
|
|
|
if not insert_text:
|
|
return "Error: insert_text is required."
|
|
|
|
with db_session() as conn:
|
|
repo = MemoriesRepository(conn)
|
|
doc = repo.get_by_path(self.user_id, self.tool_id, validated_path)
|
|
|
|
if not doc or not doc.get("content"):
|
|
return f"Error: File not found: {validated_path}"
|
|
|
|
current_content = str(doc["content"])
|
|
lines = current_content.split("\n")
|
|
|
|
# Convert to 0-indexed
|
|
index = insert_line - 1
|
|
if index < 0 or index > len(lines):
|
|
return f"Error: Invalid line number. File has {len(lines)} lines."
|
|
|
|
lines.insert(index, insert_text)
|
|
updated_content = "\n".join(lines)
|
|
|
|
repo.upsert(self.user_id, self.tool_id, validated_path, updated_content)
|
|
|
|
return f"Text inserted at line {insert_line} in {validated_path}"
|
|
|
|
def _delete(self, path: str) -> str:
|
|
"""Delete a file or directory."""
|
|
validated_path = self._validate_path(path)
|
|
if not validated_path:
|
|
return "Error: Invalid path."
|
|
|
|
if validated_path == "/":
|
|
# Delete all files for this user and tool
|
|
with db_session() as conn:
|
|
deleted = MemoriesRepository(conn).delete_all(
|
|
self.user_id, self.tool_id
|
|
)
|
|
return f"Deleted {deleted} file(s) from memory."
|
|
|
|
# Check if it's a directory (ends with /)
|
|
if validated_path.endswith("/"):
|
|
with db_session() as conn:
|
|
deleted = MemoriesRepository(conn).delete_by_prefix(
|
|
self.user_id, self.tool_id, validated_path
|
|
)
|
|
return f"Deleted directory and {deleted} file(s)."
|
|
|
|
# Try as directory first (without trailing slash)
|
|
search_path = validated_path + "/"
|
|
with db_session() as conn:
|
|
repo = MemoriesRepository(conn)
|
|
directory_deleted = repo.delete_by_prefix(
|
|
self.user_id, self.tool_id, search_path
|
|
)
|
|
if directory_deleted > 0:
|
|
return f"Deleted directory and {directory_deleted} file(s)."
|
|
|
|
# Otherwise delete a single file
|
|
file_deleted = repo.delete_by_path(
|
|
self.user_id, self.tool_id, validated_path
|
|
)
|
|
|
|
if file_deleted:
|
|
return f"Deleted: {validated_path}"
|
|
return f"Error: File not found: {validated_path}"
|
|
|
|
def _rename(self, old_path: str, new_path: str) -> str:
|
|
"""Rename or move a file/directory."""
|
|
validated_old = self._validate_path(old_path)
|
|
validated_new = self._validate_path(new_path)
|
|
|
|
if not validated_old or not validated_new:
|
|
return "Error: Invalid path."
|
|
|
|
if validated_old == "/" or validated_new == "/":
|
|
return "Error: Cannot rename root directory."
|
|
|
|
# Directory rename: do all path updates inside one transaction so
|
|
# the rename is atomic from the caller's perspective.
|
|
if validated_old.endswith("/"):
|
|
# Ensure validated_new also ends with / for proper path replacement
|
|
if not validated_new.endswith("/"):
|
|
validated_new = validated_new + "/"
|
|
|
|
with db_session() as conn:
|
|
repo = MemoriesRepository(conn)
|
|
docs = repo.list_by_prefix(
|
|
self.user_id, self.tool_id, validated_old
|
|
)
|
|
|
|
if not docs:
|
|
return f"Error: Directory not found: {validated_old}"
|
|
|
|
for doc in docs:
|
|
old_file_path = doc["path"]
|
|
new_file_path = old_file_path.replace(
|
|
validated_old, validated_new, 1
|
|
)
|
|
repo.update_path(
|
|
self.user_id, self.tool_id, old_file_path, new_file_path
|
|
)
|
|
|
|
return f"Renamed directory: {validated_old} -> {validated_new} ({len(docs)} files)"
|
|
|
|
# Single-file rename: lookup, collision check, and update in one txn.
|
|
with db_session() as conn:
|
|
repo = MemoriesRepository(conn)
|
|
doc = repo.get_by_path(self.user_id, self.tool_id, validated_old)
|
|
if not doc:
|
|
return f"Error: File not found: {validated_old}"
|
|
|
|
existing = repo.get_by_path(self.user_id, self.tool_id, validated_new)
|
|
if existing:
|
|
return f"Error: File already exists at {validated_new}"
|
|
|
|
repo.update_path(
|
|
self.user_id, self.tool_id, validated_old, validated_new
|
|
)
|
|
|
|
return f"Renamed: {validated_old} -> {validated_new}"
|