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
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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? |
Expand Down Expand Up @@ -450,12 +452,14 @@ flowchart TD
### 用户命令 `/memory`

```text
/memory user list — 列出个人记忆
/memory user list [页码] — 分页列出个人记忆(每页10条)
/memory user search <关键词> — 搜索个人记忆
/memory user delete <ID> — 删除个人记忆
/memory group list — 列出群共享记忆
/memory group list [页码] — 分页列出群共享记忆
/memory group search <关键词> — 搜索群共享记忆
/memory group delete <ID> — 删除群共享记忆(仅管理员 / 群主)
/memory memo — 以合并转发查看当前作用域备忘录
/memory dmn — 立即触发一轮潜意识调度(仅超级用户,需 subconscious.enabled=true 且已配置 target_user_id)
```

兼容旧格式 `/memory list` 等(默认以个人范围执行)。
Expand Down
126 changes: 115 additions & 11 deletions amrita_plugin_memory/matchers.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,34 @@
"""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(
"memory",
aliases={"记忆"},
state=MatcherData(
name="记忆管理",
description="查看/搜索/删除长期记忆",
usage="/memory (list|search|delete) [参数]",
description="查看/搜索/删除长期记忆,回看备忘录,手动触发潜意识调度",
usage="/memory (list|search|delete|memo|dmn) [参数]",
).model_dump(),
)

Expand All @@ -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()

Expand Down Expand Up @@ -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":
Expand All @@ -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 ""
Expand All @@ -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:
Expand Down
39 changes: 34 additions & 5 deletions amrita_plugin_memory/rethinking/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
"""取消当前计划,递增惩罚计数器,重新计算延迟并调度一次运行。
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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。
Expand All @@ -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]:
Expand Down
Loading
Loading