mirror of
https://github.com/aaPanel/aaPanel.git
synced 2026-08-21 23:22:28 +02:00
323 lines
16 KiB
Python
323 lines
16 KiB
Python
# coding: utf-8
|
|
# -------------------------------------------------------------------
|
|
# aapanel
|
|
# -------------------------------------------------------------------
|
|
# Copyright (c) 2015-2099 aapanel(http://www.aapanel.com) All rights reserved.
|
|
# -------------------------------------------------------------------
|
|
# Author: aapanel
|
|
# -------------------------------------------------------------------
|
|
|
|
"""记忆: NoteSave DocSave"""
|
|
import os
|
|
import re
|
|
import time
|
|
|
|
from typing import Dict
|
|
|
|
from . import register_tool
|
|
from .base import _is_blocked_path, _xml_response, atomic_update_json
|
|
from mod.project.agent.dynamic import MEMORIES_DIR
|
|
|
|
_TOPIC_RE = re.compile(r'^[a-z0-9_]+(?:[_-][a-z0-9]+)*$') # snake_case, 可选 _/- 分隔
|
|
_VALID_TYPES = frozenset({"fact", "preference", "decision", "pitfall", "milestone"})
|
|
|
|
# 指针: [[topic]] / [[docs/name]], 合法格式(cleanup_pointers 用, 区分合法 vs 非法笔误)
|
|
_POINTER_RE = re.compile(r'\[\[(docs/)?([a-z0-9_]+(?:[_-][a-z0-9]+)*)\]\]')
|
|
|
|
|
|
@register_tool(category="Agent", name_cn="NoteSave", risk_level="low", internal=True)
|
|
def NoteSave(topic: str, type: str, content: str, **kwargs) -> str:
|
|
"""Persist a durable fact so it survives across sessions and informs future turns.
|
|
|
|
Call when the conversation yields durable value:
|
|
- Environment/architecture (fact): "main site runs nginx + php-fpm + mysql 8.0"
|
|
- Preferences/conventions (preference): "always back up DB before changes", "use a gentle tone"
|
|
- Decisions (decision): "standardize on mysql 8.0"
|
|
- Resolved pitfalls (pitfall): symptom + root cause + fix
|
|
- Milestones (milestone): "migrated to mysql 8.0"
|
|
|
|
Skip if the turn yielded only ephemeral or already-known information. Resource lifecycle aaPanel already tracks (creating/deleting sites, databases, SSL, firewall rules, cron, etc.) is panel state, not durable knowledge — record the non-obvious *why* behind it, not the event itself.
|
|
|
|
Args:
|
|
topic: short theme label (snake_case, ≤128 chars), e.g. nginx, mysql, site_example_com,
|
|
preferences. Group by subject, not by type — all nginx facts/pitfalls go under
|
|
topic=nginx, not topic=pitfalls. One file per theme — do NOT encode events/dates/instances here (use content).
|
|
type: one of fact / preference / decision / pitfall / milestone.
|
|
content: single self-contained line, one fact per call (multiple facts → multiple
|
|
calls), ≤500 chars (longer → DocSave). For pitfall use "symptom → root cause → fix".
|
|
Duplicates auto-skip; do not paraphrase an existing note just to reword it. Reference
|
|
another memory with [[<topic>]] or a doc with [[docs/<name>]] when its key fact lives
|
|
there (avoid duplicating it); the doc itself is NOT in context — Read it separately
|
|
if you need the details.
|
|
"""
|
|
if not _TOPIC_RE.match(topic or ""):
|
|
return _xml_response("NoteSave", "error", f"invalid topic: {topic}")
|
|
if len(topic) > 128:
|
|
return _xml_response("NoteSave", "error", "topic too long (max 128 chars)")
|
|
if type not in _VALID_TYPES:
|
|
return _xml_response("NoteSave", "error", f"invalid type: {type} (must be one of {sorted(_VALID_TYPES)})")
|
|
if not content:
|
|
return _xml_response("NoteSave", "error", "empty content")
|
|
|
|
# 单行兜底: 压平所有空白(\n/\r/多空格)为单空格, 防破坏 md 列表结构
|
|
content = ' '.join(content.split())
|
|
if not content:
|
|
return _xml_response("NoteSave", "error", "empty content after sanitize")
|
|
if len(content) > 500:
|
|
return _xml_response("NoteSave", "error", "content too long (max 500 chars) — use DocSave for long-form knowledge")
|
|
|
|
os.makedirs(MEMORIES_DIR, exist_ok=True)
|
|
path = os.path.join(MEMORIES_DIR, f"{topic}.md")
|
|
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
existing = f.read()
|
|
except FileNotFoundError:
|
|
existing = ""
|
|
|
|
# 去重: 全文匹配 content
|
|
if content in existing:
|
|
return _xml_response("NoteSave", "done", f"already_exists topic={topic}")
|
|
|
|
line = f"- [{type} {time.strftime('%Y-%m-%d')}] {content}\n"
|
|
if not existing:
|
|
line = f"# {topic}\n\n{line}"
|
|
elif not existing.endswith("\n"):
|
|
line = "\n" + line
|
|
|
|
with open(path, "a", encoding="utf-8") as f:
|
|
f.write(line)
|
|
|
|
return _xml_response("NoteSave", "done", f"saved topic={topic} type={type}")
|
|
|
|
|
|
@register_tool(category="Agent", name_cn="DocSave", risk_level="low", internal=True)
|
|
def DocSave(name: str, description: str, content: str, **kwargs) -> str:
|
|
"""Save a long-form knowledge document to memories/docs/<name>.md (create or overwrite).
|
|
|
|
Use for document-level knowledge too large for a single memory line:
|
|
- Complete troubleshooting playbooks, architecture decisions, standard procedures.
|
|
|
|
Frontmatter (description + updated) is auto-managed. Reference this doc from memories via
|
|
[[docs/<name>]]; inside content, link another doc with [[docs/<name>]] or a short memory
|
|
with [[<topic>]].
|
|
|
|
This overwrites the whole document. If a doc with this name may already exist, Read it first to avoid clobbering; then pass full content. Before creating a new doc, check the Available docs list in context — if a similar one exists, Read and overwrite it instead of creating a new name (prevents fragmentation).
|
|
|
|
Args:
|
|
name: filename (snake_case, ≤128), e.g. mysql_recovery.
|
|
description: one-line summary (frontmatter; reserved for future sleep/cleanup and
|
|
on-demand Read orientation, ≤120 chars).
|
|
content: full Markdown body (multi-line, unlike NoteSave's one-line). Start the
|
|
body with a `# title` heading; frontmatter is auto-prepended above it.
|
|
"""
|
|
if not _TOPIC_RE.match(name or ""):
|
|
return _xml_response("DocSave", "error", f"invalid name: {name}")
|
|
if len(name) > 128:
|
|
return _xml_response("DocSave", "error", "name too long (max 128 chars)")
|
|
if not content or not content.strip():
|
|
return _xml_response("DocSave", "error", "empty content")
|
|
|
|
description = ' '.join((description or "").split()) # 单行兜底, 防 \n 破坏 frontmatter
|
|
if not description:
|
|
return _xml_response("DocSave", "error", "empty description")
|
|
|
|
docs_dir = os.path.join(MEMORIES_DIR, "docs")
|
|
os.makedirs(docs_dir, exist_ok=True)
|
|
path = os.path.join(docs_dir, f"{name}.md")
|
|
|
|
_frontmatter = (
|
|
f"---\n"
|
|
f"description: {description}\n"
|
|
f"updated: {time.strftime('%Y-%m-%d')}\n"
|
|
f"---\n\n"
|
|
)
|
|
try:
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
f.write(_frontmatter + content.strip() + "\n")
|
|
except Exception as e:
|
|
return _xml_response("DocSave", "error", f"write fail: {e}")
|
|
|
|
return _xml_response("DocSave", "done", f"saved docs/{name}.md ({len(content)} chars)")
|
|
|
|
|
|
@register_tool(category="Agent", name_cn="Project Save", risk_level="low", internal=True)
|
|
def ProjectSave(project_id: str, files: Dict[str, str],
|
|
meta_snapshot: Dict[str, str] = None,
|
|
session_id: str = "", sessions_dir: str = "", **kwargs) -> str:
|
|
"""Persist project INFORMATION for a site-design project (memories/projects/<project_id>/).
|
|
Third member of the memory-tool family alongside NoteSave (short facts) and DocSave (long docs) —
|
|
manages the project's info docs + status/snapshot, NOT the generated artifact.
|
|
|
|
**Scope: project information only** — the info docs (`meta.md` / `design.md` / `progress.md`) AND
|
|
status/snapshot (`status` / `subject` / `style` via meta_snapshot). Batches info files in
|
|
one call AND atomically syncs the chat-list snapshot.
|
|
|
|
**Out of scope (use other tools)**:
|
|
- `site/*` artifact (HTML/CSS/JS) → use `Write` (ProjectSave is not for the generated artifact).
|
|
- `ui_spec.md` → fetched directly via `curl -o` (large, ~31KB, never relayed through the model).
|
|
|
|
Never use `Write` / `RunCommand` (echo/cat/sed/tee) for the info docs or to mutate the session
|
|
`meta.json` `project` block — those go through `ProjectSave`.
|
|
|
|
When to use:
|
|
- Project init: write meta.md + design.md + progress.md together; pass meta_snapshot to bind the session
|
|
- Info update: write only changed info docs ({"design.md": ...})
|
|
- Status transition: update meta.md and pass meta_snapshot={"status":...} to sync the chat list
|
|
- Status-only snapshot update (no file change, e.g. just refresh status): pass files={} + meta_snapshot to sync the snapshot only
|
|
|
|
Args:
|
|
project_id: project slug (snake_case + short suffix), e.g. photo_studio_a3f7. Dir auto-created if absent.
|
|
files: map of relative-path -> full content for INFO docs (meta/design/progress). May be empty {} when only updating meta_snapshot. NOT for site/* (use Write). Non-str values rejected.
|
|
meta_snapshot: optional {status/subject/style} to sync into the session meta.json under project.{...}. Unknown keys ignored. Omit when only editing project files.
|
|
"""
|
|
# project_id 复用 NoteSave 的 _TOPIC_RE(同属记忆族, 校验一致)
|
|
if not _TOPIC_RE.match(project_id or ""):
|
|
return _xml_response("ProjectSave", "error", f"invalid project_id: {project_id}")
|
|
if not isinstance(files, dict):
|
|
return _xml_response("ProjectSave", "error", "files must be a {path: content} map")
|
|
if not files and not meta_snapshot:
|
|
return _xml_response("ProjectSave", "error", "nothing to do: provide files and/or meta_snapshot")
|
|
|
|
proj_real = os.path.realpath(os.path.join(MEMORIES_DIR, "projects", project_id))
|
|
written, errors, ignored = [], [], []
|
|
|
|
for rel, content in files.items():
|
|
rel = (rel or "").replace("\\", "/").lstrip("/")
|
|
# 显式拒绝空/./.. 防边界(撞项目根 / 穿越)
|
|
if rel in ("", ".", "..") or rel.startswith("../") or "/.." in rel or rel.startswith("/"):
|
|
errors.append(f"{rel}: rejected (invalid relative path)"); continue
|
|
if not isinstance(content, str):
|
|
errors.append(f"{rel}: content must be str, got {type(content).__name__}"); continue
|
|
target = os.path.realpath(os.path.join(proj_real, rel))
|
|
if not (target == proj_real or target.startswith(proj_real + os.sep)):
|
|
errors.append(f"{rel}: rejected (escapes project dir)"); continue
|
|
# _is_blocked_path 对 memories/projects/ 实际无效(黑名单不覆盖此目录), 保留作双保险
|
|
is_blocked, reason = _is_blocked_path(target)
|
|
if is_blocked:
|
|
errors.append(f"{rel}: blocked ({reason})"); continue
|
|
try:
|
|
os.makedirs(os.path.dirname(target), exist_ok=True)
|
|
with open(target, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
written.append(rel)
|
|
except Exception as e:
|
|
errors.append(f"{rel}: {e}")
|
|
|
|
# files 全成功才同步 snapshot(防状态分裂: meta.md 文件 draft 而 snapshot generated)
|
|
files_ok = not errors
|
|
synced = ""
|
|
if meta_snapshot and files_ok:
|
|
if not (session_id and sessions_dir):
|
|
errors.append("meta_snapshot given but session context missing — session meta NOT synced")
|
|
else:
|
|
allowed = ("status", "subject", "style")
|
|
snap = {k: v for k, v in meta_snapshot.items() if k in allowed}
|
|
bad = [k for k in meta_snapshot if k not in allowed]
|
|
if bad:
|
|
ignored.append(f"ignored meta_snapshot keys: {bad}")
|
|
# sessions_dir为绝对路径
|
|
meta_path = os.path.join(sessions_dir, session_id, "meta.json")
|
|
|
|
def _m(d):
|
|
# 跨会话保护: 当前会话已绑别的 project(existing_id 非空且≠project_id) →
|
|
# 不篡改本会话绑定, 返回 None 取消写(atomic_update_json 见 None 不写 meta.json);
|
|
# 仅新会话(existing_id 空) 或 本会话即该 project(==) 才绑定/刷新快照
|
|
_existing = d.get("project") or {}
|
|
_existing_id = _existing.get("id")
|
|
if _existing_id and _existing_id != project_id:
|
|
_m.skip_bound = _existing_id
|
|
return None
|
|
_m.skip_bound = None
|
|
_existing["id"] = project_id
|
|
_existing.update(snap)
|
|
d["project"] = _existing
|
|
d["chat_type"] = "website"
|
|
return d
|
|
_m.skip_bound = None
|
|
|
|
try:
|
|
ok2, msg2 = atomic_update_json(meta_path, _m)
|
|
if ok2:
|
|
synced = (f"; session meta untouched (cross-session, bound to {_m.skip_bound})"
|
|
if _m.skip_bound
|
|
else f"; session synced ({','.join(snap) or 'id'})")
|
|
else:
|
|
errors.append(f"session meta sync fail: {msg2}")
|
|
except Exception as e:
|
|
errors.append(f"session meta sync exception: {e}")
|
|
|
|
# files 失败→error(不同步 snapshot, 防状态分裂); files 全成功→done(snapshot 失败仅 ERRORS 段, meta.md 已是真相); 无回滚, 模型须重试失败项
|
|
parts = [f"wrote {len(written)}: {written}"]
|
|
if synced:
|
|
parts.append(synced)
|
|
if ignored:
|
|
parts.append("; ".join(ignored))
|
|
if errors:
|
|
parts.append(f"ERRORS: {errors}")
|
|
return _xml_response("ProjectSave", "done" if (files_ok and (written or synced)) else "error", " | ".join(parts))
|
|
|
|
|
|
def cleanup_pointers(memories_dir=None) -> dict:
|
|
"""审计/清理指针: 清非法格式笔误 + 报告 unresolved(合法但目标不存在, 可能前向引用).
|
|
|
|
不删合法指针(可能是前向引用, 允许 — [[x]] 指向未创建文件合法).
|
|
仅清非法格式([[空]] / [[非法字符]]): 删 [[ ]] 保文字. 纯规则, 零 LLM.
|
|
|
|
Returns:
|
|
{invalid_cleaned, unresolved, unresolved_list, report}
|
|
"""
|
|
from mod.project.agent.dynamic import MEMORIES_DIR as _DEFAULT
|
|
_root = memories_dir or _DEFAULT
|
|
_rep = {"invalid_cleaned": 0, "unresolved": 0, "unresolved_list": [], "report": ""}
|
|
|
|
if not os.path.isdir(_root):
|
|
_rep["report"] = "[CLEANUP] memories dir not found"
|
|
return _rep
|
|
|
|
# 目标存在集(合法指针的目标判定)
|
|
_exist = {f[:-3] for f in os.listdir(_root) if f.endswith(".md")}
|
|
_docs_dir = os.path.join(_root, "docs")
|
|
if os.path.isdir(_docs_dir):
|
|
_exist |= {"docs/" + f[:-3] for f in os.listdir(_docs_dir) if f.endswith(".md")}
|
|
|
|
# 待扫描文件(顶层 + docs/)
|
|
_files = [os.path.join(_root, f) for f in sorted(os.listdir(_root)) if f.endswith(".md")]
|
|
if os.path.isdir(_docs_dir):
|
|
_files += [os.path.join(_docs_dir, f) for f in sorted(os.listdir(_docs_dir)) if f.endswith(".md")]
|
|
|
|
_any_re = re.compile(r'\[\[([^\]]*)\]\]') # 所有 [[...]](含非法)
|
|
for _path in _files:
|
|
try:
|
|
with open(_path, "r", encoding="utf-8") as _fh:
|
|
_body = _fh.read()
|
|
except Exception:
|
|
continue
|
|
_orig = _body
|
|
|
|
# 1. 清非法格式指针(非 _POINTER_RE 的 [[...]]): 删 [[ ]] 保文字
|
|
def _clean_illegal(m):
|
|
if _POINTER_RE.fullmatch(m.group(0)):
|
|
return m.group(0) # 合法格式, 保留
|
|
_rep["invalid_cleaned"] += 1
|
|
return m.group(1) # 非法: 删 [[ ]], 保文字
|
|
_body = _any_re.sub(_clean_illegal, _body)
|
|
|
|
# 2. 报告 unresolved(合法指针但目标不存在 — 可能前向引用, 不删)
|
|
for _m in _POINTER_RE.finditer(_orig):
|
|
_tgt = ("docs/" if _m.group(1) else "") + _m.group(2)
|
|
if _tgt not in _exist:
|
|
_rep["unresolved"] += 1
|
|
_rep["unresolved_list"].append(f"{os.path.relpath(_path, _root)}: [[{_tgt}]]")
|
|
|
|
if _body != _orig:
|
|
try:
|
|
with open(_path, "w", encoding="utf-8") as _fh:
|
|
_fh.write(_body)
|
|
except Exception:
|
|
pass
|
|
|
|
_rep["report"] = "[CLEANUP] invalid_cleaned=%d unresolved=%d (forward-refs kept, not deleted)" % (
|
|
_rep["invalid_cleaned"], _rep["unresolved"])
|
|
return _rep
|