diff --git a/README.md b/README.md index 5078ac0..5b5e6b2 100644 --- a/README.md +++ b/README.md @@ -402,7 +402,9 @@ flowchart TD | `read_memory` | query, top_k(5), importance?, scope(enum) | | `update_memory` | id, scope, content?, tags?, importance? | | `delete_memory` | id, scope | -| `list_memory` | limit, scope | +| `list_memory` | limit(10, 上限50), offset, scope | +| `read_memo` | —(读取当前作用域备忘录全文) | +| `append_memo` | content(在备忘录末尾追加一段) | | `update_memo` | content(L1 备忘录整篇替换) | | `knowledge_list` | — | | `knowledge_read` | kid, start_line?, end_line? | @@ -450,12 +452,14 @@ flowchart TD ### 用户命令 `/memory` ```text -/memory user list — 列出个人记忆 +/memory user list [页码] — 分页列出个人记忆(每页10条) /memory user search <关键词> — 搜索个人记忆 /memory user delete — 删除个人记忆 -/memory group list — 列出群共享记忆 +/memory group list [页码] — 分页列出群共享记忆 /memory group search <关键词> — 搜索群共享记忆 /memory group delete — 删除群共享记忆(仅管理员 / 群主) +/memory memo — 以合并转发查看当前作用域备忘录 +/memory dmn — 立即触发一轮潜意识调度(仅超级用户,需 subconscious.enabled=true 且已配置 target_user_id) ``` 兼容旧格式 `/memory list` 等(默认以个人范围执行)。 diff --git a/amrita_plugin_memory/matchers.py b/amrita_plugin_memory/matchers.py index c38f49d..4f1b4d2 100644 --- a/amrita_plugin_memory/matchers.py +++ b/amrita_plugin_memory/matchers.py @@ -1,13 +1,25 @@ """NoneBot2 命令:/memory""" +from typing import cast + +from amrita.plugins.chat.utils.functions import split_message_into_chats from amrita.plugins.menu.models import MatcherData -from nonebot import on_command -from nonebot.adapters.onebot.v11 import GroupMessageEvent, Message, MessageEvent +from amrita.utils.send import send_forward_msg +from nonebot import get_bot, get_driver, logger, on_command +from nonebot.adapters.onebot.v11 import ( + Bot, + GroupMessageEvent, + Message, + MessageEvent, + MessageSegment, +) from nonebot.exception import FinishedException from nonebot.matcher import Matcher from nonebot.params import CommandArg +from .config import DataManager from .keys import make_scope_id +from .memo import get_memo from .vector import AsyncUserMemory, get_db_conn memory_cmd = on_command( @@ -15,8 +27,8 @@ aliases={"记忆"}, state=MatcherData( name="记忆管理", - description="查看/搜索/删除长期记忆", - usage="/memory (list|search|delete) [参数]", + description="查看/搜索/删除长期记忆,回看备忘录,手动触发潜意识调度", + usage="/memory (list|search|delete|memo|dmn) [参数]", ).model_dump(), ) @@ -35,6 +47,21 @@ async def _( sub = args_list[0].lower() if args_list else "" rest = args_list[1] if len(args_list) > 1 else "" + if sub in ("memo", "备忘录"): + uni_id = ( + make_scope_id(event.group_id, is_group=True) + if isinstance(event, GroupMessageEvent) + else make_scope_id(event.user_id, is_group=False) + ) + content = await get_memo(uni_id) + if not content: + await matcher.finish("当前作用域还没有备忘录") + await _send_memo_forward(matcher, event, content) + await matcher.finish() + + if sub in ("dmn", "run", "触发"): + await matcher.finish(await _trigger_dmn(event)) + ope = AsyncUserMemory(get_db_conn()) await ope.init() @@ -68,7 +95,7 @@ async def _( pid = partitions.get(scope) if scope else None if scope and pid and action in ("", "list"): - await _handle_list(matcher, ope, pid, scope) + await _handle_list(matcher, ope, pid, scope, action_args) elif scope and pid and action == "search": await _handle_search(matcher, ope, pid, scope, action_args) elif scope and pid and action == "delete": @@ -90,24 +117,99 @@ async def _( await matcher.finish(hint) +async def _trigger_dmn(event: MessageEvent) -> str: + """手动触发潜意识调度,返回给用户的提示文案。""" + if str(event.get_user_id()) not in get_driver().config.superusers: + return "仅超级用户可手动触发潜意识调度" + + cfg = (await DataManager().safe_get_config()).subconscious + if not cfg.enabled: + return "潜意识调度未启用:subconscious.enabled 为 false" + if not cfg.target_user_id: + return "潜意识调度未启用:subconscious.target_user_id 为空" + + from .rethinking import _state + + runner = _state.get_runner() + if runner is None: + return "潜意识调度未启动:钩子挂载失败或启动异常,检查日志" + await runner.trigger_now() + return "已触发,潜意识将在后台跑一轮" + + +MEMO_SEGMENT_CHARS = 600 + + +def _pack_memo_nodes(content: str) -> list[str]: + """按行装箱成转发节点,单行过长时再按句切,不从句子中间断开。""" + units: list[str] = [] + for line in content.splitlines(keepends=True): + if len(line) <= MEMO_SEGMENT_CHARS: + units.append(line) + else: + units.extend(split_message_into_chats(line, max_length=MEMO_SEGMENT_CHARS)) + nodes: list[str] = [] + buf = "" + for unit in units: + if buf and len(buf) + len(unit) > MEMO_SEGMENT_CHARS: + nodes.append(buf) + buf = unit + else: + buf += unit + if buf: + nodes.append(buf) + packed = [n.rstrip("\n") for n in nodes if n.strip()] + return packed or [content] + + +async def _send_memo_forward( + matcher: Matcher, event: MessageEvent, content: str +) -> None: + """以合并转发发送备忘录,沿用 Amrita 的 send_forward_msg。""" + nodes = _pack_memo_nodes(content) + try: + await send_forward_msg( + cast(Bot, get_bot(str(event.self_id))), + event, + "备忘录", + uin=str(event.self_id), + msgs=[MessageSegment.text(i) for i in nodes], + ) + except Exception as e: + logger.warning(f"[Memo] forward failed, fallback to text: {e}") + for node in nodes: + await matcher.send(node) + + async def _handle_list( matcher: Matcher, ope: AsyncUserMemory, partition_id: str, scope: str, + arg: str = "", ): try: - result = await ope.get_all_notes( - partition_id, include=["metadatas", "documents"] - ) - if not result or not result.get("ids"): + size = 10 + page = int(arg.strip()) if arg.strip().isdigit() else 1 + page = max(1, page) + total = await ope.count_user_notes(partition_id) + if total == 0: await matcher.finish(f"{_label(scope)} 暂无记忆") - ids = result["ids"] + pages = (total + size - 1) // size + page = min(page, pages) + offset = (page - 1) * size + result = await ope.get_all_notes( + partition_id, + include=["metadatas", "documents"], + limit=size, + offset=offset, + ) + ids = result.get("ids") or [] documents = result.get("documents") or [""] * len(ids) metadatas = result.get("metadatas") or [{}] * len(ids) - lines = [f"📋 {_label(scope)} 记忆列表(共 {len(ids)} 条):"] + lines = [f"📋 {_label(scope)} 记忆列表(第 {page}/{pages} 页,共 {total} 条):"] for i, doc_id in enumerate(ids): meta = metadatas[i] if i < len(metadatas) else {} doc = documents[i] if i < len(documents) else "" @@ -116,6 +218,8 @@ async def _handle_list( f"[{meta.get('importance', '-')}] [{doc_id}] " f"{preview} | tag: {meta.get('tags', '-')}" ) + if page < pages: + lines.append(f"下一页: /memory {scope} list {page + 1}") await matcher.finish("\n".join(lines)) except FinishedException: diff --git a/amrita_plugin_memory/rethinking/runner.py b/amrita_plugin_memory/rethinking/runner.py index e124a12..b9a2af2 100644 --- a/amrita_plugin_memory/rethinking/runner.py +++ b/amrita_plugin_memory/rethinking/runner.py @@ -69,6 +69,7 @@ def __init__(self, config: SubconsciousConfig) -> None: self._config = config self._job_id = f"subconscious_{config.target_user_id}" self._is_running = False + self._manual_task: asyncio.Task | None = None self._last_abstracts: list[str] = [] self._total_runs = 0 self._prompt_dir = DATA_PATH.parent.parent / "config" / "amrita_plugin_memory" @@ -119,6 +120,8 @@ async def start(self) -> None: def stop(self) -> None: with contextlib.suppress(Exception): scheduler.remove_job(self._job_id) + if self._manual_task is not None and not self._manual_task.done(): + self._manual_task.cancel() async def cancel_and_reschedule(self) -> None: """取消当前计划,递增惩罚计数器,重新计算延迟并调度一次运行。 @@ -154,6 +157,15 @@ def _schedule_once(self, delay_seconds: int) -> None: replace_existing=True, ) + async def trigger_now(self) -> None: + """手动触发:取消待执行任务并立刻跑一轮,不阻塞调用方。""" + with contextlib.suppress(Exception): + scheduler.remove_job(self._job_id) + if self._manual_task is not None and not self._manual_task.done(): + self._manual_task.cancel() + # 持有引用,避免任务被 GC 回收 + self._manual_task = asyncio.create_task(self._run()) + async def _run(self) -> None: """调度入口 — 保证任何异常路径都会释放运行标志。""" if self._is_running: @@ -221,7 +233,7 @@ def _is_native_thinking(preset: ModelPreset) -> bool: async def _post_process(self, chat_obj: ChatObject) -> None: """本轮结束后:提取摘要、更新全局 usage、持久化、调度下次运行。""" # 1. 更新全局 usage(复用 Bot 的 InsightsModel 统计) - await self._update_global_usage(chat_obj) + await self._update_usage(chat_obj) # 2. 提取压缩节点产出的摘要 mem = chat_obj._di_memory.memory @@ -259,9 +271,8 @@ async def _post_process(self, chat_obj: ChatObject) -> None: f"penalty NOT reset (penalty={_state.get_penalty_count()})" ) - @staticmethod - async def _update_global_usage(chat_obj: ChatObject) -> None: - """更新全局 InsightsModel usage,复用 Bot 的 add_usage 逻辑。 + async def _update_usage(self, chat_obj: ChatObject) -> None: + """更新全局 InsightsModel 与目标用户的 usage,复用 Bot 的 add_usage 逻辑。 chat_obj._di_resp.response 是 LLM 最终返回的 UniResponse, chat_obj._di_resp.extra_usage 是压缩等组件累积的额外 token。 @@ -283,8 +294,26 @@ async def _update_global_usage(chat_obj: ChatObject) -> None: f"(input={usage.prompt_tokens}, output={usage.completion_tokens}, " f"count={insights.usage_count})" ) + await self._add_user_usage(usage) + except Exception as e: + logger.warning(f"[Subconscious] Update usage failed: {e}") + + async def _add_user_usage(self, usage: UniResponseUsage[int]) -> None: + """把本轮 token 记到目标用户的元数据,与用户自己聊天的统计同一口径。""" + uid = self._config.target_user_id + if not uid: + return + try: + repo = CachedUserDataRepository() + meta = await repo.get_metadata(make_scope_id(uid, is_group=False)) + add_usage(meta, usage) + await repo.update_metadata(meta) + logger.debug( + f"[Subconscious] User usage updated (uid={uid}, " + f"input={usage.prompt_tokens}, output={usage.completion_tokens})" + ) except Exception as e: - logger.warning(f"[Subconscious] Update global usage failed: {e}") + logger.warning(f"[Subconscious] Update user usage failed: {e}") @staticmethod async def _read_state_payload(uid: str) -> dict[str, Any]: diff --git a/amrita_plugin_memory/tools.py b/amrita_plugin_memory/tools.py index 39929ce..45a1b16 100644 --- a/amrita_plugin_memory/tools.py +++ b/amrita_plugin_memory/tools.py @@ -17,7 +17,7 @@ from .config import DataManager from .keys import Scope, resolve_scope_uni_id -from .memo import MemoTooLongError, set_memo +from .memo import MemoTooLongError, get_memo, set_memo from .vector import AsyncUserMemory, MemoryMetadata, get_db_conn @@ -163,12 +163,15 @@ def _check_required(ctx: ToolContext, tool_name: str, *params: str) -> str | Non LIST_MEMORY_FUN = FunctionDefinitionSchema( name="list_memory", - description="列出当前用户(或群组)的所有记忆(返回tag+id)", + description="分页列出当前用户(或群组)的记忆(返回tag+id),单页最多50条", parameters=FunctionParametersSchema( type="object", properties={ "limit": FunctionPropertySchema( - type="integer", description="返回数量,默认5条", default=5 + type="integer", description="每页数量,默认10条,上限50条", default=10 + ), + "offset": FunctionPropertySchema( + type="integer", description="跳过条数,用于翻页,默认0", default=0 ), "scope": _SCOPE_PROP, }, @@ -176,6 +179,32 @@ def _check_required(ctx: ToolContext, tool_name: str, *params: str) -> str | Non ), ) +READ_MEMO_FUN = FunctionDefinitionSchema( + name="read_memo", + description=( + "读取当前作用域备忘录的完整原文与字数。" + "备忘录已常驻注入系统提示,仅在需要核对或回滚全文时调用" + ), + parameters=FunctionParametersSchema(type="object", properties={}, required=[]), +) + +APPEND_MEMO_FUN = FunctionDefinitionSchema( + name="append_memo", + description=( + "在备忘录末尾追加一段内容,不覆盖已有条目。" + "适合逐条补充规则或偏好;整篇重写请用 update_memo" + ), + parameters=FunctionParametersSchema( + type="object", + properties={ + "content": FunctionPropertySchema( + type="string", description="要追加的文本,会与原文以换行分隔" + ), + }, + required=["content"], + ), +) + UPDATE_MEMO_FUN = FunctionDefinitionSchema( name="update_memo", description=( @@ -383,22 +412,24 @@ async def list_memory(ctx: ToolContext) -> str: logger.debug(f"scope={scope}, partition_id={partition_id}") try: - limit = ctx.data.get("limit", 5) + limit = max(1, min(int(ctx.data.get("limit", 10)), 50)) + offset = max(0, int(ctx.data.get("offset", 0))) + total = await ope.count_user_notes(partition_id) + if total == 0: + return _ok(memories=[], total=0, offset=offset, limit=limit) + result = await ope.get_all_notes( - partition_id, include=["metadatas", "documents"] + partition_id, + include=["metadatas", "documents"], + limit=limit, + offset=offset, ) - - if not result or not result.get("ids"): - return _ok(memories=[], total=0) - - memories = [] - ids = result["ids"] + ids = result.get("ids") or [] documents = result.get("documents") or [""] * len(ids) metadatas = result.get("metadatas") or [{}] * len(ids) + memories = [] for i, doc_id in enumerate(ids): - if i >= limit: - break meta = metadatas[i] if i < len(metadatas) else {} doc = documents[i] if i < len(documents) else "" @@ -412,7 +443,15 @@ async def list_memory(ctx: ToolContext) -> str: } ) - return _ok(memories=memories, total=len(ids)) + next_offset = offset + len(ids) + return _ok( + memories=memories, + total=total, + offset=offset, + limit=limit, + has_more=next_offset < total, + next_offset=next_offset if next_offset < total else None, + ) except Exception as e: logger.opt(exception=e, colors=True, raw=True).exception( f"列出记忆时发生错误: {e}" @@ -420,6 +459,43 @@ async def list_memory(ctx: ToolContext) -> str: return _err(f"列出记忆失败: {e!s}") +@on_tools(READ_MEMO_FUN, custom_run=True, strict=True) +async def read_memo(ctx: ToolContext) -> str: + event: OB11Event = _get_event(ctx) + uni_id = get_uni_user_id(event) + try: + content = await get_memo(uni_id) + except Exception as e: + logger.opt(exception=e, colors=True, raw=True).exception( + f"读取备忘录时发生错误: {e}" + ) + return _err(f"读取备忘录失败: {e!s}") + return _ok(memo=content, chars=len(content), empty=not content) + + +@on_tools(APPEND_MEMO_FUN, custom_run=True, strict=True) +async def append_memo(ctx: ToolContext) -> str: + if err := _check_required(ctx, "append_memo", "content"): + return err + event: OB11Event = _get_event(ctx) + uni_id = get_uni_user_id(event) + addition: str = str(ctx.data["content"]).strip() + if not addition: + return _err("追加内容为空") + try: + current = await get_memo(uni_id) + merged = f"{current}\n{addition}" if current else addition + await set_memo(uni_id, merged) + except MemoTooLongError as e: + return _err(str(e)) + except Exception as e: + logger.opt(exception=e, colors=True, raw=True).exception( + f"追加备忘录时发生错误: {e}" + ) + return _err(f"追加备忘录失败: {e!s}") + return _ok(message="备忘录已追加", chars=len(merged), appended=len(addition)) + + @on_tools(UPDATE_MEMO_FUN, custom_run=True, strict=True) async def update_memo(ctx: ToolContext) -> str: if err := _check_required(ctx, "update_memo", "content"): diff --git a/amrita_plugin_memory/vector.py b/amrita_plugin_memory/vector.py index 0ab746d..513f8c7 100644 --- a/amrita_plugin_memory/vector.py +++ b/amrita_plugin_memory/vector.py @@ -455,17 +455,20 @@ async def get_all_notes( user_id: str, include: chromadb.Include = ["metadatas", "documents"], ids: list[str] | None = None, + limit: int | None = None, + offset: int | None = None, ) -> chromadb.GetResult: async with get_lock(self._collection_name, user_id): if ids is not None: return await any_to_thread( self._collection.get, include=include, ids=ids ) - return await any_to_thread( - self._collection.get, - include=include, - where={"user_id": user_id}, - ) + kwargs: dict = {"include": include, "where": {"user_id": user_id}} + if limit is not None: + kwargs["limit"] = limit + if offset is not None: + kwargs["offset"] = offset + return await any_to_thread(self._collection.get, **kwargs) @require_init async def delete_note(self, user_id: str, doc_id: str): diff --git a/pyproject.toml b/pyproject.toml index 0480f8d..351172a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "amrita_plugin_memory" description = "Amrita长期记忆插件" -version = "0.6.0" +version = "0.7.0" dependencies = [ "aiohttp>=3.13.3", "aiologic>=0.16.0", diff --git a/uv.lock b/uv.lock index 77e4f30..91f2dd9 100644 --- a/uv.lock +++ b/uv.lock @@ -319,7 +319,7 @@ wheels = [ [[package]] name = "amrita-plugin-memory" -version = "0.5.0" +version = "0.7.0" source = { editable = "." } dependencies = [ { name = "aiohttp" },