From 44e5d57e30e2a7022f250844b04da9b6962da3e9 Mon Sep 17 00:00:00 2001 From: AmriaLin <325560502+AmriaLin@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:22:31 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=E6=89=8B=E5=8A=A8=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=20DMN=E3=80=81=E5=A4=87=E5=BF=98=E5=BD=95=E5=9B=9E?= =?UTF-8?q?=E8=AF=BB=E4=B8=8E=E8=AE=B0=E5=BF=86=E5=88=97=E8=A1=A8=E5=88=86?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /memory dmn 手动触发潜意识调度(仅超级用户) - /memory memo 以合并转发查看当前作用域备忘录 - 新增 read_memo 工具,返回备忘录全文与字数 - list_memory 支持 offset 分页,单页上限 50,不再全量拉取 - /memory user|group list 支持页码翻页 --- README.md | 9 ++- amrita_plugin_memory/matchers.py | 96 +++++++++++++++++++++-- amrita_plugin_memory/rethinking/runner.py | 12 +++ amrita_plugin_memory/tools.py | 64 +++++++++++---- amrita_plugin_memory/vector.py | 13 +-- pyproject.toml | 2 +- 6 files changed, 165 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 5078ac0..4affb5c 100644 --- a/README.md +++ b/README.md @@ -402,7 +402,8 @@ 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` | —(读取当前作用域备忘录全文) | | `update_memo` | content(L1 备忘录整篇替换) | | `knowledge_list` | — | | `knowledge_read` | kid, start_line?, end_line? | @@ -450,12 +451,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 — 立即触发一轮潜意识调度(仅超级用户) ``` 兼容旧格式 `/memory list` 等(默认以个人范围执行)。 diff --git a/amrita_plugin_memory/matchers.py b/amrita_plugin_memory/matchers.py index c38f49d..232c07b 100644 --- a/amrita_plugin_memory/matchers.py +++ b/amrita_plugin_memory/matchers.py @@ -1,13 +1,14 @@ """NoneBot2 命令:/memory""" from amrita.plugins.menu.models import MatcherData -from nonebot import on_command +from nonebot import get_bot, get_driver, logger, on_command from nonebot.adapters.onebot.v11 import GroupMessageEvent, Message, MessageEvent from nonebot.exception import FinishedException from nonebot.matcher import Matcher from nonebot.params import CommandArg from .keys import make_scope_id +from .memo import get_memo from .vector import AsyncUserMemory, get_db_conn memory_cmd = on_command( @@ -35,6 +36,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 +84,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 +106,86 @@ 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 "仅超级用户可手动触发潜意识调度" + from .rethinking import _state + + runner = _state.get_runner() + if runner is None: + return "潜意识调度未启动:检查 enabled 与 target_user_id" + await runner.trigger_now() + return "已触发,潜意识将在后台跑一轮" + + +MEMO_PAGE_CHARS = 600 + + +async def _send_memo_forward( + matcher: Matcher, event: MessageEvent, content: str +) -> None: + """把备忘录按段拆成合并转发节点,失败则退回纯文本。""" + chunks = [ + content[i : i + MEMO_PAGE_CHARS] + for i in range(0, len(content), MEMO_PAGE_CHARS) + ] or [""] + self_id = str(event.self_id) + nodes = [ + { + "type": "node", + "data": { + "name": "备忘录", + "uin": self_id, + "content": f"第 {i + 1}/{len(chunks)} 段\n{chunk}", + }, + } + for i, chunk in enumerate(chunks) + ] + try: + bot = get_bot(self_id) + if isinstance(event, GroupMessageEvent): + await bot.call_api( + "send_group_forward_msg", group_id=event.group_id, messages=nodes + ) + else: + await bot.call_api( + "send_private_forward_msg", user_id=event.user_id, messages=nodes + ) + except Exception as e: + logger.warning(f"[Memo] forward failed, fallback to text: {e}") + await matcher.send(content) + + 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 +194,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..8eac05d 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: diff --git a/amrita_plugin_memory/tools.py b/amrita_plugin_memory/tools.py index 39929ce..f2bbb7b 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,15 @@ 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=[]), +) + UPDATE_MEMO_FUN = FunctionDefinitionSchema( name="update_memo", description=( @@ -383,22 +395,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 +426,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 +442,20 @@ 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(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", From c1678d146f4efd62999c97d169477a4674592937 Mon Sep 17 00:00:00 2001 From: AmriaLin <325560502+AmriaLin@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:27:20 +0000 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20append=5Fmemo?= =?UTF-8?q?=20=E5=A2=9E=E9=87=8F=E8=BF=BD=E5=8A=A0=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 备忘录只支持整篇替换时,补一条规则要重写全文,容易丢内容。append_memo 在末尾追加并以换行分隔,仍受 memo_max_chars 约束。 --- README.md | 1 + amrita_plugin_memory/tools.py | 40 +++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/README.md b/README.md index 4affb5c..7a1d86f 100644 --- a/README.md +++ b/README.md @@ -404,6 +404,7 @@ flowchart TD | `delete_memory` | id, 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? | diff --git a/amrita_plugin_memory/tools.py b/amrita_plugin_memory/tools.py index f2bbb7b..45a1b16 100644 --- a/amrita_plugin_memory/tools.py +++ b/amrita_plugin_memory/tools.py @@ -188,6 +188,23 @@ def _check_required(ctx: ToolContext, tool_name: str, *params: str) -> str | Non 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=( @@ -456,6 +473,29 @@ async def read_memo(ctx: ToolContext) -> str: 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"): From 1f650f9a922d6566b3ea7a528ca7abea0a49f396 Mon Sep 17 00:00:00 2001 From: AmriaLin <325560502+AmriaLin@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:46:25 +0000 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=E5=91=BD=E4=BB=A4=E5=B8=AE=E5=8A=A9?= =?UTF-8?q?=E8=A1=A5=E4=B8=8A=20memo=20=E4=B8=8E=20dmn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- amrita_plugin_memory/matchers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/amrita_plugin_memory/matchers.py b/amrita_plugin_memory/matchers.py index 232c07b..5b8170a 100644 --- a/amrita_plugin_memory/matchers.py +++ b/amrita_plugin_memory/matchers.py @@ -16,8 +16,8 @@ aliases={"记忆"}, state=MatcherData( name="记忆管理", - description="查看/搜索/删除长期记忆", - usage="/memory (list|search|delete) [参数]", + description="查看/搜索/删除长期记忆,回看备忘录,手动触发潜意识调度", + usage="/memory (list|search|delete|memo|dmn) [参数]", ).model_dump(), ) From 0a9c56adc38edf19baacb620d258a5dc431e68c4 Mon Sep 17 00:00:00 2001 From: AmriaLin <325560502+AmriaLin@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:48:38 +0000 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=E6=89=8B=E5=8A=A8=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E5=89=8D=E6=A0=A1=E9=AA=8C=E9=85=8D=E7=BD=AE=E5=90=AF=E7=94=A8?= =?UTF-8?q?=EF=BC=8C=E6=BD=9C=E6=84=8F=E8=AF=86=E7=94=A8=E9=87=8F=E8=AE=A1?= =?UTF-8?q?=E5=85=A5=E7=9B=AE=E6=A0=87=E7=94=A8=E6=88=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trigger_now 之前只查 runner 是否存在,配置关掉时提示含糊;改为先读 subconscious.enabled 与 target_user_id。另外本轮 token 原先只进全局 InsightsModel,现同步累加到目标用户元数据,与用户自身对话的统计口径一致。 --- README.md | 2 +- amrita_plugin_memory/matchers.py | 10 ++++++++- amrita_plugin_memory/rethinking/runner.py | 27 ++++++++++++++++++----- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7a1d86f..5b5e6b2 100644 --- a/README.md +++ b/README.md @@ -459,7 +459,7 @@ flowchart TD /memory group search <关键词> — 搜索群共享记忆 /memory group delete — 删除群共享记忆(仅管理员 / 群主) /memory memo — 以合并转发查看当前作用域备忘录 -/memory dmn — 立即触发一轮潜意识调度(仅超级用户) +/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 5b8170a..31f8786 100644 --- a/amrita_plugin_memory/matchers.py +++ b/amrita_plugin_memory/matchers.py @@ -7,6 +7,7 @@ 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 @@ -110,11 +111,18 @@ 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 "潜意识调度未启动:检查 enabled 与 target_user_id" + return "潜意识调度未启动:钩子挂载失败或启动异常,检查日志" await runner.trigger_now() return "已触发,潜意识将在后台跑一轮" diff --git a/amrita_plugin_memory/rethinking/runner.py b/amrita_plugin_memory/rethinking/runner.py index 8eac05d..b9a2af2 100644 --- a/amrita_plugin_memory/rethinking/runner.py +++ b/amrita_plugin_memory/rethinking/runner.py @@ -233,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 @@ -271,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。 @@ -295,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]: From 160c369621e401648c79edd084fbff5ea8b73155 Mon Sep 17 00:00:00 2001 From: AmriaLin <325560502+AmriaLin@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:02:17 +0000 Subject: [PATCH 5/6] =?UTF-8?q?refactor:=20=E5=A4=87=E5=BF=98=E5=BD=95?= =?UTF-8?q?=E8=BD=AC=E5=8F=91=E6=94=B9=E7=94=A8=20Amrita=20=E7=9A=84=20sen?= =?UTF-8?q?d=5Fforward=5Fmsg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原先固定 600 字硬切,会把句子和规则从中间劈开。改为按行装箱、超长行按句切,发送直接复用 amrita.utils.send.send_forward_msg,去掉自写的合并转发调用与段号前缀。 --- amrita_plugin_memory/matchers.py | 71 +++++++++++++++++++------------- 1 file changed, 42 insertions(+), 29 deletions(-) diff --git a/amrita_plugin_memory/matchers.py b/amrita_plugin_memory/matchers.py index 31f8786..77017cd 100644 --- a/amrita_plugin_memory/matchers.py +++ b/amrita_plugin_memory/matchers.py @@ -1,8 +1,15 @@ """NoneBot2 命令:/memory""" +from amrita.plugins.chat.utils.functions import split_message_into_chats from amrita.plugins.menu.models import MatcherData +from amrita.utils.send import send_forward_msg from nonebot import get_bot, get_driver, logger, on_command -from nonebot.adapters.onebot.v11 import GroupMessageEvent, Message, MessageEvent +from nonebot.adapters.onebot.v11 import ( + GroupMessageEvent, + Message, + MessageEvent, + MessageSegment, +) from nonebot.exception import FinishedException from nonebot.matcher import Matcher from nonebot.params import CommandArg @@ -127,42 +134,48 @@ async def _trigger_dmn(event: MessageEvent) -> str: return "已触发,潜意识将在后台跑一轮" -MEMO_PAGE_CHARS = 600 +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: - """把备忘录按段拆成合并转发节点,失败则退回纯文本。""" - chunks = [ - content[i : i + MEMO_PAGE_CHARS] - for i in range(0, len(content), MEMO_PAGE_CHARS) - ] or [""] - self_id = str(event.self_id) - nodes = [ - { - "type": "node", - "data": { - "name": "备忘录", - "uin": self_id, - "content": f"第 {i + 1}/{len(chunks)} 段\n{chunk}", - }, - } - for i, chunk in enumerate(chunks) - ] + """以合并转发发送备忘录,沿用 Amrita 的 send_forward_msg。""" + nodes = _pack_memo_nodes(content) try: - bot = get_bot(self_id) - if isinstance(event, GroupMessageEvent): - await bot.call_api( - "send_group_forward_msg", group_id=event.group_id, messages=nodes - ) - else: - await bot.call_api( - "send_private_forward_msg", user_id=event.user_id, messages=nodes - ) + await send_forward_msg( + 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}") - await matcher.send(content) + for node in nodes: + await matcher.send(node) async def _handle_list( From b93c3f152cd96b369efb180ef99b4c790730a6d0 Mon Sep 17 00:00:00 2001 From: AmriaLin Date: Sun, 18 Oct 2026 03:53:47 +0000 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20send=5Fforward=5Fmsg=20=E9=9C=80?= =?UTF-8?q?=E8=A6=81=20OneBot=20V11=20Bot=EF=BC=8C=E6=98=BE=E5=BC=8F=20cas?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nonebot.get_bot 返回内部 Bot 类型,Pyright 在 CI 上会报 reportArgumentType, 这里取到的本来就是 OneBot V11 适配器实例,用 cast 收窄类型即可。 --- amrita_plugin_memory/matchers.py | 5 ++++- uv.lock | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/amrita_plugin_memory/matchers.py b/amrita_plugin_memory/matchers.py index 77017cd..4f1b4d2 100644 --- a/amrita_plugin_memory/matchers.py +++ b/amrita_plugin_memory/matchers.py @@ -1,10 +1,13 @@ """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 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, @@ -166,7 +169,7 @@ async def _send_memo_forward( nodes = _pack_memo_nodes(content) try: await send_forward_msg( - get_bot(str(event.self_id)), + cast(Bot, get_bot(str(event.self_id))), event, "备忘录", uin=str(event.self_id), 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" },