Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# AGENTS.md

给在本仓库工作的 AI 代理(以及新加入的人)看的约定。只写与改动直接相关的硬性要求,风格问题按现有代码对齐。

## 项目概况

- AmritaBot 长期记忆插件:L1 备忘录、L2 向量记忆、L3 离线巩固循环(DMN)三层。
- 表层工具(对话 LLM 可用)注册在全局 ToolsManager;`subconscious_*` 通过 `bound_to=_SUBCONSCIOUS_TOOLS` 注册到隔离管理器,不污染全局。
- 插件入口 `amrita_plugin_memory/__init__.py`,命令入口 `cli.py`(`ambot memory`),对话命令在 `matchers.py`(`/memory`)。

## 模块边界

| 位置 | 职责 |
| --- | --- |
| `config.py` | EnvConfig / ConfigFile(pydantic + uniconf),DATA_PATH 等路径常量 |
| `keys.py` | uni_id 生成与 scope 解析 |
| `vector.py` | ChromaDB 封装、AsyncUserMemory 记忆读写 |
| `embedding.py` / `embed.py` | 嵌入模型与指纹校验、重映射 |
| `memo.py` | L1 备忘录读写 |
| `decay.py` | 时间记忆退化与分层清理 |
| `permissions.py` | 会话侧读取权限校验 |
| `profile_store.py` | 画像路径与 Markdown 解析(纯函数,不依赖 nonebot 运行时) |
| `rethinking/` | DMN 循环:runner、tools、schemas、knowledge、schedule |
| `rethinking/schedule.py` | 空闲窗口规划(纯数据 + 纯函数,`resolve_timezone` 是全局唯一的时区解析入口) |

新增纯逻辑优先放进不依赖 nonebot 的模块(如 `profile_store.py`、`rethinking/schedule.py`),运行器只做编排。

## 环境与依赖

- Python 3.10–3.13,包管理只用 uv。改依赖后跑 `uv sync`,不要用 `pip install` 补装单包。
- 类型检查用 `pyright`,配置在 `pyproject.toml` 的 `[tool.pyright]`(standard 模式)。
- 本仓库 `.venv` 里通常没有 pyright / ruff,用 `--pythonpath .venv/bin/python` 指向仓库环境,否则会因解析不到 amrita_core / chromadb 报一片 Import 错误。

## 常用命令

```bash
uv run ruff check amrita_plugin_memory
uv run ruff format --check amrita_plugin_memory
uv run pyright --pythonpath .venv/bin/python amrita_plugin_memory
python -m py_compile amrita_plugin_memory/**/*.py
```

## 测试要求

本仓库不补单元测试,验证以静态检查加手动冒烟为准:

1. `ruff check` 与 `ruff format --check` 通过
2. `pyright` 零 error(用上面的 pythonpath 跑)
3. 改到的模块 `py_compile` 通过
4. 手动跑一遍改动的命令(`/memory`、`ambot memory`)或工具调用,并在 PR 里说明验证方式与结果

纯逻辑尽量写成可独立执行的形式,方便用 `python -c` 或临时脚本验证;不要把临时脚本提交进仓库。
本仓库无 `tests/` 目录,也不要新建。纯逻辑写成可独立执行的形式,用临时脚本验证后不提交。

## 工具注册的三条路径

- 表层可读、需权限:实现放 `rethinking/tools.py`,`@on_tools(SCHEMA, strict=True, custom_run=True)` 注册到全局,进函数先校验 `permissions.py` 里的判定。
- 仅循环可用:同上但带 `bound_to=_SUBCONSCIOUS_TOOLS`。
- 新工具要同时加入 `rethinking/schemas.py` 的 `_SUBCONSCIOUS_TOOL_FUNCTIONS`(若循环也要用),并在 README 的工具表里登记。

新增工具时注意:schema 的 `name` 是工具名,函数名可另取;`custom_run=True` 时函数签名收 `ToolContext`,返回值必须是字符串。

## 权限口径

- 知识库只读:`permissions.can_read_knowledge`,超用户 / `lp.admin` / `memory.knowledge` 节点三者之一。
- 用户画像只读:`permissions.can_read_profile`,仅 `subconscious.target_user_id` 本人,不扩散权限节点。
- 取值优先用 `_state.get_target_user_id()`(运行器实际生效值),回退 `DataManager` 配置。空值一律拒。

## 数据与持久化

- 画像:`DATA_PATH/user_profile.md`,格式 `summary` + `---` + 正文;行级增量更新,写入只发生在循环内部。
- 知识库:`DATA_PATH/knowledge/` + `knowledge_index.json` + ChromaDB,文件/索引/向量三方同步。
- DMN 元状态:`SubconsciousState` 表;向量记忆在 ChromaDB collection。
- 写入路径优先走读-改-写整体替换,避免嵌入失败时丢数据(参考 `25f1078`)。

## 代码注释风格

- 注释与 docstring 一律中文,单行短句,讲“为什么”和约束,不复述代码本身在做什么。
- 模块顶部 docstring 写职责、关键约束、与其他模块的边界;函数 docstring 写清参数语义与副作用。
- 不写分节注释(`# ---- 小节名`、`# ====`),用空行和函数拆分表达结构。
- 不写连续多行 `#` 注释;需要成段解释时写进模块或函数 docstring。
- 待办写成 `# TODO(review #<issue>):具体动作`。
- 类型注解必须齐全;pydantic 字段用 `Field(description=...)`,描述用中文。
- 不写注释掉的旧代码,不写“新增/修改”这类只在 diff 里有意义的注释。

## 配置改动

- 配置型在 `config.py`(pydantic + `nonebot_plugin_uniconf`)。新字段必须有中文 `description` 和保守默认值,默认值要保证旧配置文件行为不变。
- 新增开关涉及 DMN 行为时,同步检查 `matchers.py` 的 `/memory dmn` 前置校验与 README 的配置表。

## Git 与 PR

- 分支名用 `feat/` `fix/` `refactor/` 前缀;提交信息中文,首行说明改了什么,正文写动机与影响面。
- PR 拿到 approval 不等于授权合并;合并默认 squash,等明确指令再动。
- GitHub raw 域名可能不可达,取仓库内容走 `api.github.com`。
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,11 +410,17 @@ flowchart TD
| `knowledge_read` | kid, start_line?, end_line? |
| `knowledge_search` | query, top_k? |
| `knowledge_suggest` | action, title, summary, body, reason |
| `read_profile` | start_line?, end_line? |

知识库的三个只读工具 `knowledge_list` / `knowledge_read` / `knowledge_search`
需要 LP 权限节点 `memory.knowledge`;`lp.admin` 与 `.env` 里的超级用户始终持有。
未授权时工具返回错误 JSON,不返回任何知识内容。

用户画像的只读工具 `read_profile` 走另一条口径:只对离线巩固循环的目标用户
(`subconscious.target_user_id`)开放,且只能读自己那份画像,不占用权限节点。
返回值与循环侧一致:`summary` + `content` 行窗口 + `total_lines` / `range_start` / `range_end`。
画像写入仍只发生在循环内部,表层不开放。

### 巩固循环工具(`rethinking/tools.py`,共 20 个)

记忆与 session/画像工具注册在隔离的 `_SUBCONSCIOUS_TOOLS` 上,不污染全局工具管理器。
Expand Down
53 changes: 53 additions & 0 deletions amrita_plugin_memory/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,59 @@ class SubconsciousConfig(BaseModel):
rethink_max_delay_minutes: int = Field(
default=1440, ge=60, le=10080, description="惩罚延迟上限(分钟),默认1天"
)
use_idle_window_planner: bool = Field(
default=True,
description="是否启用空闲窗口调度(热度图 + 冷段二分);关闭后回退到旧的固定延迟方案",
)
idle_timezone: str = Field(
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
default="Asia/Shanghai", description="热度图与窗口计算使用的时区"
)
idle_fine_tau_days: float = Field(
default=28.0, ge=1.0, description="单片星期几层的衰减常数(天)"
)
idle_coarse_tau_days: float = Field(
default=14.0, ge=1.0, description="全局层的衰减常数(天)"
)
idle_shrink_k: float = Field(
default=4.0, ge=0.0, description="细层向全局收缩的强度,越大越保守"
)
idle_min_sample_days_fine: int = Field(
default=6, ge=1, description="启用单片星期几层所需的最少活跃天数"
)
idle_min_sample_days_mid: int = Field(
default=3, ge=1, description="启用工作日/周末层所需的最少活跃天数"
)
idle_cold_pct_max: float = Field(
default=0.05, ge=0.0, le=1.0, description="冷槽的占比上限"
)
idle_cold_abs_max: float = Field(
default=1.0, ge=0.0, description="冷槽的原始计数上限,避免稀疏样本全部判冷"
)
idle_target_hour: int = Field(
default=3, ge=0, le=23, description="偏好触发时刻(本地时区小时)"
)
idle_base_slots: int = Field(
default=3, ge=1, description="首次计划要求的最短冷段(槽,1 槽=2h)"
)
idle_max_slots: int = Field(default=6, ge=1, description="冷段要求的封顶槽数")
idle_penalty_multiplier: float = Field(
default=1.5, ge=1.0, le=10.0, description="惩罚对冷段要求的放大倍率"
)
idle_max_relax_steps: int = Field(
default=3, ge=0, le=6, description="冷段不足时允许的减半放宽次数"
)
idle_buffer_minutes: int = Field(
default=15, ge=0, description="窗口起止的安全缓冲(分钟)"
)
idle_run_estimate_minutes: int = Field(
default=10, ge=0, description="单轮 DMN 预计耗时(分钟),用于扣减可用窗口"
)
idle_fallback_minutes: int = Field(
default=120, ge=10, description="冷启动或规划失败时的固定窗口长度(分钟)"
)
idle_noise_gap_minutes: int = Field(
default=15, ge=0, description="连发消息合并为单点的最小间隔(分钟)"
)
prompt_file: str = Field(
default="prompt/subconscious_main.md.jinja2",
description="主推理提示词文件名,相对于 config/amrita_plugin_memory/",
Expand Down
40 changes: 37 additions & 3 deletions amrita_plugin_memory/permissions.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
"""知识库读取的权限校验。
"""会话侧读取权限校验(知识库 / 用户画像)。

会话侧(对话 LLM)可以读全局知识库,但需要 LP 节点 ``memory.knowledge``;
知识库:会话侧(对话 LLM)可以读全局知识库,但需要 LP 节点 ``memory.knowledge``;
``lp.admin`` 与 ``.env`` 里的超级用户始终持有该权限。
潜意识循环内部调用不受此限制。

用户画像:只对离线巩固循环(DMN)的目标用户开放只读,
比对 ``subconscious.target_user_id``,不做权限节点扩散。

潜意识循环内部调用均不受此限制。
"""

from __future__ import annotations
Expand Down Expand Up @@ -54,6 +58,36 @@ async def can_read_knowledge(event: Event | None) -> bool:
return await _has_node(event, KNOWLEDGE_NODE)


async def get_dmn_target_user_id() -> str:
"""当前 DMN 目标用户 ID:优先取运行器中实际生效的值,回退到配置。"""
try:
from .rethinking import _state

uid = _state.get_target_user_id()
if uid:
return str(uid)
except Exception as e:
logger.debug(f"[Memory] 取 DMN 运行器失败: {e}")
try:
from .config import DataManager

cfg = (await DataManager().safe_get_config()).subconscious
return str(cfg.target_user_id or "")
except Exception as e:
logger.debug(f"[Memory] 取 DMN 配置失败: {e}")
return ""


async def can_read_profile(event: Event | None) -> bool:
"""是否允许读取用户画像:仅 DMN 目标用户本人。"""
if event is None:
return False
target = await get_dmn_target_user_id()
if not target:
return False
return str(event.get_user_id()) == target


def get_event_from_tool_ctx(ctx: Any) -> Event | None:
"""从 ToolContext 里取出触发本次调用的原始事件。"""
try:
Expand Down
67 changes: 67 additions & 0 deletions amrita_plugin_memory/profile_store.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""用户画像文件(Markdown)的路径与解析。

会话侧只读入口与潜意识循环共用同一份解析逻辑,
避免为读取画像而依赖运行器实例。
"""

from __future__ import annotations

from pathlib import Path
from typing import TypedDict

from nonebot import logger

from .config import DATA_PATH


class ProfileResult(TypedDict):
"""用户画像读取返回值。"""

summary: str
content: str
total_lines: int
range_start: int
range_end: int


#: 画像文件路径
PROFILE_PATH: Path = DATA_PATH / "user_profile.md"


def read_profile(
path: Path,
start_line: int | None = None,
end_line: int | None = None,
) -> ProfileResult:
"""解析画像 Markdown(summary + "---" + body),按行返回滑动窗口。

与运行器解耦:潜意识循环未启动时,会话侧也能读到既有画像文件。
"""
empty: ProfileResult = {
"summary": "",
"content": "",
"total_lines": 0,
"range_start": 0,
"range_end": 0,
}
if not path.exists():
return empty
try:
text = path.read_text(encoding="utf-8")
parts = text.split("---", 1)
summary = parts[0].strip() if parts else ""
body = parts[1].strip() if len(parts) > 1 else ""
body_lines = body.split("\n") if body else []
total = len(body_lines)
s = 0 if start_line is None else max(0, start_line)
e = total if end_line is None else max(s, min(total, end_line))
return {
"summary": summary,
"content": "\n".join(body_lines[s:e]),
"total_lines": total,
"range_start": s,
"range_end": e,
}
except Exception as e:
logger.warning(f"[Subconscious] Read profile failed: {e}")
return empty
53 changes: 53 additions & 0 deletions amrita_plugin_memory/rethinking/_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@
_penalty_count: int = 0
# 知识建议队列 — 对话 LLM 提议创建/更新知识,潜意识 Agent 下一轮审查
_knowledge_suggestions: list[dict[str, str]] = []
# 上一轮 iter_stop 的摘要与结束时间 — 供 subconscious_get_context 读取
_last_iter_summary: str = ""
_last_run_at: float = 0.0
# 本轮是否已调过 subconscious_get_context — iter_stop 据此判定周期是否有效
_context_fetched: bool = False
# 本轮是否算一次有效周期 — 空转的周期不重置惩罚
_cycle_valid: bool = True
# 隔离的工具管理器 — 所有 @on_tools 装饰器通过 bound_to 注册到这里,不污染全局 ToolsManager
_SUBCONSCIOUS_TOOLS = MultiToolsManager()

Expand Down Expand Up @@ -89,3 +96,49 @@ def set_knowledge_suggestions(suggestions: list[dict[str, str]]) -> None:
def add_knowledge_suggestion(suggestion: dict[str, str]) -> None:
global _knowledge_suggestions
_knowledge_suggestions.append(suggestion)


def get_last_iter_summary() -> str:
return _last_iter_summary


def set_last_iter_summary(text: str) -> None:
global _last_iter_summary
_last_iter_summary = text


def mark_context_fetched() -> None:
global _context_fetched
_context_fetched = True


def is_context_fetched() -> bool:
return _context_fetched


def reset_context_fetched() -> None:
global _context_fetched
_context_fetched = False


def mark_cycle_invalid() -> None:
global _cycle_valid
_cycle_valid = False


def is_cycle_valid() -> bool:
return _cycle_valid


def reset_cycle_valid() -> None:
global _cycle_valid
_cycle_valid = True


def get_last_run_at() -> float:
return _last_run_at


def set_last_run_at(ts: float) -> None:
global _last_run_at
_last_run_at = ts
Loading
Loading