neobot-memory 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.3
2
+ Name: neobot-memory
3
+ Version: 1.0.0
4
+ Summary: Add your description here
5
+ Author: wsrsq, tangtian
6
+ Author-email: wsrsq <wsrsq001@163.com>, tangtian <a14b@126.com>
7
+ Requires-Dist: neobot-contracts
8
+ Requires-Python: >=3.13
9
+ Description-Content-Type: text/markdown
10
+
File without changes
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "neobot-memory"
3
+ version = "1.0.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = ["neobot-contracts"]
8
+
9
+ [[project.authors]]
10
+ name = "wsrsq"
11
+ email = "wsrsq001@163.com"
12
+
13
+ [[project.authors]]
14
+ name = "tangtian"
15
+ email = "a14b@126.com"
16
+
17
+ [tool.uv.sources.neobot-contracts]
18
+ workspace = true
19
+
20
+ [build-system]
21
+ requires = ["uv_build>=0.9.27,<0.10.0"]
22
+ build-backend = "uv_build"
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "neobot-memory"
3
+ version = "1.0.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "wsrsq", email = "wsrsq001@163.com" },
8
+ { name = "tangtian", email = "a14b@126.com" },
9
+ ]
10
+ requires-python = ">=3.13"
11
+ dependencies = ["neobot-contracts"]
12
+
13
+ [tool.uv.sources]
14
+ neobot-contracts = { workspace = true }
15
+
16
+ [build-system]
17
+ requires = ["uv_build>=0.9.27,<0.10.0"]
18
+ build-backend = "uv_build"
@@ -0,0 +1,25 @@
1
+ """neobot_memory — 记忆包公共 API"""
2
+
3
+ from neobot_memory.archive_service import ArchiveMemoryService
4
+ from neobot_memory.defaults import (
5
+ InMemoryArchiveMemoryAccess,
6
+ InMemoryImageAnalysisAccess,
7
+ InMemoryMemoryRepository,
8
+ NullLogger,
9
+ SystemClock,
10
+ )
11
+ from neobot_memory.image_service import ImageAnalysisService
12
+ from neobot_memory.reader import MemoryReader
13
+ from neobot_memory.service import MemoryService
14
+
15
+ __all__ = [
16
+ "ArchiveMemoryService",
17
+ "ImageAnalysisService",
18
+ "MemoryService",
19
+ "MemoryReader",
20
+ "InMemoryMemoryRepository",
21
+ "InMemoryArchiveMemoryAccess",
22
+ "InMemoryImageAnalysisAccess",
23
+ "NullLogger",
24
+ "SystemClock",
25
+ ]
@@ -0,0 +1,718 @@
1
+ """归档记忆服务。
2
+
3
+ 本模块同时是「档案容量治理」的唯一收口点:save_archive、patch_archive 与自动总结
4
+ 计数器三条写入路径最终都走 ArchiveMemoryService.set(),超限判定放在这里才能保证
5
+ 绕过 skill 的写入同样受保护(D1-B)。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from collections.abc import Callable, Iterable
12
+ from dataclasses import dataclass
13
+ from typing import Any, Optional
14
+
15
+ from neobot_contracts.errors import NeoBotError
16
+ from neobot_contracts.models.memory import ArchiveMemory
17
+ from neobot_contracts.ports.logging import Logger
18
+ from neobot_contracts.ports.unit_of_work import UnitOfWorkFactory
19
+
20
+ #: 超限动作默认值:写库成功后异步压缩。
21
+ DEFAULT_OVERFLOW_ACTION = "summarize"
22
+ #: 同一 (table_name, key) 两次自动压缩之间的默认最小间隔(秒)。
23
+ DEFAULT_OVERFLOW_COOLDOWN_SECONDS = 600.0
24
+ #: 超限清单一次最多返回多少条(防止把大表整块塞进面板响应体)。
25
+ _MAX_OVER_LIMIT_PAGE_SIZE = 500
26
+ #: 超限清单里的内容预览字符数。
27
+ _OVER_LIMIT_PREVIEW_CHARS = 200
28
+ #: 同一 (table_name, key) 只保留最近多少份「压缩前快照」(spec(4) §4.10.4 / D15)。
29
+ MAX_ARCHIVE_SNAPSHOTS_PER_KEY = 10
30
+ #: 压缩前快照的全局兜底上限(防止大量不同 key 无限累积)。
31
+ MAX_ARCHIVE_SNAPSHOTS = 2000
32
+
33
+ #: 不参与容量治理的内部表(默认豁免名单)。
34
+ #: ⚠️ 与 agent.memory.archive.allowed_tables 语义完全不同:那个是「限制模型能访问
35
+ #: 哪些表」,这里是「哪些表不做长度上限拦截」,两者绝不可复用。
36
+ #: memory_counter 存的是「待总结消息 JSON」,group_interval=500 时天然超过任何上限:
37
+ #: - 若对它 reject:自动总结连计数都写不进去,整条管线直接断掉;
38
+ #: - 若对它 summarize:压缩器会去「压缩」待总结消息队列,把尚未总结的消息吃掉。
39
+ #: 因此它必须无条件豁免;一旦误伤,表现是「档案自动总结整体失效」,比长度超限严重得多。
40
+ DEFAULT_EXEMPT_TABLES: frozenset[str] = frozenset({"memory_counter"})
41
+
42
+ #: 超限压缩的调度回调签名:(table_name, key) -> 是否已调度。
43
+ #: 必须立即返回(同步):写路径要求「先落库、再异步压缩」,不能等一次完整的 AI 调用。
44
+ OverflowSummaryTrigger = Callable[[str, str], bool]
45
+
46
+
47
+ class ArchiveOverflowRejected(NeoBotError, ValueError):
48
+ """overflow_action="reject" 时拒绝写入(交由模型自行压缩后重试)。"""
49
+
50
+ def __init__(
51
+ self,
52
+ message: str,
53
+ *,
54
+ table_name: str = "",
55
+ key: str = "",
56
+ chars: int = 0,
57
+ max_total_chars: int = 0,
58
+ hint: str = "",
59
+ ) -> None:
60
+ super().__init__(message)
61
+ self.table_name = table_name
62
+ self.key = key
63
+ self.chars = chars
64
+ self.max_total_chars = max_total_chars
65
+ self.hint = hint
66
+
67
+
68
+ class ArchiveVersionConflictError(NeoBotError, ValueError):
69
+ """档案乐观锁版本冲突(服务层公开异常,面板据此返回 409)。
70
+
71
+ 仓库层(neobot_storage)在版本不一致时抛出它自己的同名异常,
72
+ 服务层统一翻译成本类,面板只需要依赖 neobot_memory。
73
+ """
74
+
75
+ def __init__(
76
+ self,
77
+ message: str,
78
+ *,
79
+ table_name: str = "",
80
+ key: str = "",
81
+ expected_version: int = 0,
82
+ actual_version: int = 0,
83
+ ) -> None:
84
+ super().__init__(message)
85
+ self.table_name = table_name
86
+ self.key = key
87
+ self.expected_version = expected_version
88
+ self.actual_version = actual_version
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class ArchiveWriteOutcome:
93
+ """一次档案写入的结果,附带容量治理信息。
94
+
95
+ item 为 None 表示写入被拒绝(只在 overflow_action="reject" 时出现)。
96
+ """
97
+
98
+ item: Optional[ArchiveMemory] = None
99
+ over_limit: bool = False
100
+ action: str = "none" # none | summarize | reject
101
+ scheduled: bool = False # 是否已把后台压缩交给触发方
102
+ chars: int = 0
103
+ max_total_chars: int = 0
104
+ hint: str = ""
105
+ error: str = ""
106
+
107
+ @property
108
+ def ok(self) -> bool:
109
+ """写入是否真的落库。"""
110
+ return self.item is not None
111
+
112
+
113
+ def _normalize_max_total_chars(value: Any) -> int:
114
+ """把配置读成非负整数上限;None/非法值/负数一律视为 0(禁用)。"""
115
+ try:
116
+ parsed = int(value)
117
+ except (TypeError, ValueError):
118
+ return 0
119
+ return parsed if parsed > 0 else 0
120
+
121
+
122
+ def _normalize_overflow_action(value: Any) -> str:
123
+ """把配置读成 summarize / reject;未知值回落到 summarize。"""
124
+ action = str(value or "").strip().lower()
125
+ return action if action in {"summarize", "reject"} else DEFAULT_OVERFLOW_ACTION
126
+
127
+
128
+ def _normalize_cooldown(value: Any) -> float:
129
+ """把配置读成非负冷却秒数;None/非法值回落默认值。"""
130
+ try:
131
+ parsed = float(value)
132
+ except (TypeError, ValueError):
133
+ return DEFAULT_OVERFLOW_COOLDOWN_SECONDS
134
+ return parsed if parsed > 0 else 0.0
135
+
136
+
137
+ class ArchiveMemoryService:
138
+ """归档记忆的增删改查与查询操作服务。"""
139
+
140
+ def __init__(
141
+ self,
142
+ uow_factory: UnitOfWorkFactory,
143
+ logger: Logger,
144
+ *,
145
+ max_total_chars: Any = 0,
146
+ overflow_action: Any = DEFAULT_OVERFLOW_ACTION,
147
+ overflow_summary_cooldown_seconds: Any = DEFAULT_OVERFLOW_COOLDOWN_SECONDS,
148
+ exempt_tables: Iterable[str] | None = None,
149
+ ) -> None:
150
+ self._uow_factory = uow_factory
151
+ self._logger = logger
152
+ # 容量治理默认关闭:只有外部(bootstrap / 自动总结服务)显式配置后才会生效,
153
+ # 从而保证「不接线 = 与现状完全一致」。
154
+ self._max_total_chars = _normalize_max_total_chars(max_total_chars)
155
+ self._overflow_action = _normalize_overflow_action(overflow_action)
156
+ self._overflow_cooldown_seconds = _normalize_cooldown(overflow_summary_cooldown_seconds)
157
+ self._exempt_tables: frozenset[str] = (
158
+ frozenset(str(name) for name in exempt_tables)
159
+ if exempt_tables is not None
160
+ else DEFAULT_EXEMPT_TABLES
161
+ )
162
+ self._overflow_trigger: Optional[OverflowSummaryTrigger] = None
163
+ #: (table_name, key) -> 冷却到期时刻(time.monotonic())
164
+ self._overflow_cooldown_until: dict[tuple[str, str], float] = {}
165
+
166
+ # ── 容量治理配置 ────────────────────────────────────────────────
167
+
168
+ def configure_overflow_policy(
169
+ self,
170
+ *,
171
+ max_total_chars: Any = None,
172
+ overflow_action: Any = None,
173
+ cooldown_seconds: Any = None,
174
+ exempt_tables: Iterable[str] | None = None,
175
+ ) -> None:
176
+ """配置单条档案的存储上限与超限动作;参数为 None 表示保持当前值。
177
+
178
+ max_total_chars=0 表示禁用容量治理(行为回落到现状)。
179
+ """
180
+ if max_total_chars is not None:
181
+ self._max_total_chars = _normalize_max_total_chars(max_total_chars)
182
+ if overflow_action is not None:
183
+ self._overflow_action = _normalize_overflow_action(overflow_action)
184
+ if cooldown_seconds is not None:
185
+ self._overflow_cooldown_seconds = _normalize_cooldown(cooldown_seconds)
186
+ if exempt_tables is not None:
187
+ self._exempt_tables = frozenset(str(name) for name in exempt_tables)
188
+
189
+ def set_overflow_trigger(self, trigger: Optional[OverflowSummaryTrigger]) -> None:
190
+ """注入「档案超限 → 调度一次自动压缩」的回调(None 表示卸载)。"""
191
+ self._overflow_trigger = trigger
192
+
193
+ @property
194
+ def max_total_chars(self) -> int:
195
+ """单条档案的存储硬上限;0 表示禁用。"""
196
+ return self._max_total_chars
197
+
198
+ @property
199
+ def overflow_action(self) -> str:
200
+ """超限动作:summarize / reject。"""
201
+ return self._overflow_action
202
+
203
+ @property
204
+ def exempt_tables(self) -> frozenset[str]:
205
+ """不参与容量治理的内部表集合。"""
206
+ return self._exempt_tables
207
+
208
+ def clear_overflow_cooldown(self, table_name: str, key: str) -> None:
209
+ """清除某个条目的压缩冷却。
210
+
211
+ 压缩失败时由触发方调用:失败不应该被冷却窗口掩盖成 600 秒的静默,
212
+ 清掉冷却后「下次写入再试」才成立(是否真的重试仍由触发方的失败退避决定)。
213
+ """
214
+ self._overflow_cooldown_until.pop((table_name, key), None)
215
+
216
+ # ── 基本读写(签名语义保持不变,面板直接转调) ──────────────────
217
+
218
+ async def get(self, table_name: str, key: str) -> Optional[ArchiveMemory]:
219
+ async with self._uow_factory() as uow:
220
+ item = await uow.archive.get(table_name, key)
221
+ self._logger.debug("存档记忆已获取", table_name=table_name, key=key, found=item is not None)
222
+ return item
223
+
224
+ async def exists(self, table_name: str, key: str) -> bool:
225
+ async with self._uow_factory() as uow:
226
+ exists = await uow.archive.exists(table_name, key)
227
+ self._logger.debug("存档记忆存在检查", table_name=table_name, key=key, exists=exists)
228
+ return exists
229
+
230
+ async def list(
231
+ self,
232
+ table_name: str,
233
+ *,
234
+ tags: Optional[list[str]] = None,
235
+ key_query: Optional[str] = None,
236
+ value_query: Optional[str] = None,
237
+ limit: int = 50,
238
+ offset: int = 0,
239
+ ) -> list[ArchiveMemory]:
240
+ async with self._uow_factory() as uow:
241
+ items = await uow.archive.list(
242
+ table_name,
243
+ tags=tags,
244
+ key_query=key_query,
245
+ value_query=value_query,
246
+ limit=limit,
247
+ offset=offset,
248
+ )
249
+ self._logger.debug(
250
+ "存档记忆列表已获取",
251
+ table_name=table_name,
252
+ count=len(items),
253
+ limit=limit,
254
+ offset=offset,
255
+ )
256
+ return items
257
+
258
+ async def set(self, table_name: str, key: str, value: str, tags: list[str]) -> ArchiveMemory:
259
+ """创建或更新归档记忆条目(容量治理的唯一收口点)。
260
+
261
+ 超过 max_total_chars 时:
262
+ - overflow_action="summarize":先落库(不丢信息),再触发异步压缩,立即返回;
263
+ - overflow_action="reject":不写库,抛 ArchiveOverflowRejected。
264
+ """
265
+ outcome = await self.set_with_outcome(table_name, key, value, tags)
266
+ if outcome.item is None:
267
+ raise ArchiveOverflowRejected(
268
+ outcome.error or f"档案超过存储上限: {table_name}:{key}",
269
+ table_name=table_name,
270
+ key=key,
271
+ chars=outcome.chars,
272
+ max_total_chars=outcome.max_total_chars,
273
+ hint=outcome.hint,
274
+ )
275
+ return outcome.item
276
+
277
+ async def set_with_outcome(
278
+ self, table_name: str, key: str, value: str, tags: list[str]
279
+ ) -> ArchiveWriteOutcome:
280
+ """与 set 相同,但把超限信息(over_limit / 是否已调度压缩 / hint)一并返回。
281
+
282
+ 工具层(archive_crud)用它给模型回 over_limit=True + hint;面板/内部写路径
283
+ 可以直接用 set()。
284
+ """
285
+ limit = self._max_total_chars
286
+ chars = len(value or "")
287
+ governed = table_name not in self._exempt_tables
288
+ over_limit = governed and limit > 0 and chars > limit
289
+ if over_limit and self._overflow_action == "reject":
290
+ hint = (
291
+ f"档案超过存储上限 {limit} 字(当前 {chars} 字),已拒绝写入"
292
+ "(agent.memory.archive.overflow_action=reject)。"
293
+ "请先用 read_archive 的 outline/offset 分页阅读,再用 patch_archive "
294
+ "压缩或删除冗余内容后重试。"
295
+ )
296
+ self._logger.warning(
297
+ "档案超过存储上限,按配置拒绝写入",
298
+ table_name=table_name,
299
+ key=key,
300
+ chars=chars,
301
+ max_total_chars=limit,
302
+ )
303
+ return ArchiveWriteOutcome(
304
+ item=None,
305
+ over_limit=True,
306
+ action="reject",
307
+ chars=chars,
308
+ max_total_chars=limit,
309
+ hint=hint,
310
+ error=f"档案超过存储上限 {limit} 字(当前 {chars} 字),已拒绝写入",
311
+ )
312
+
313
+ async with self._uow_factory() as uow:
314
+ item = await uow.archive.set(table_name, key, value, tags)
315
+ await uow.commit()
316
+ self._logger.debug(
317
+ "存档记忆已保存",
318
+ table_name=table_name,
319
+ key=key,
320
+ version=item.version,
321
+ )
322
+ if not over_limit:
323
+ return ArchiveWriteOutcome(item=item, chars=chars, max_total_chars=limit)
324
+
325
+ # 先写后压缩:原文已经落库,压缩只是在后台把体积收回来(不丢信息)。
326
+ scheduled = self._schedule_overflow_compression(table_name, key, chars=chars, limit=limit)
327
+ if scheduled:
328
+ hint = (
329
+ f"档案已超过存储上限 {limit} 字(当前 {chars} 字),已触发后台自动压缩。"
330
+ "压缩期间请勿整条重写该档案;稍后用 read_archive 查看压缩结果。"
331
+ )
332
+ else:
333
+ hint = (
334
+ f"档案已超过存储上限 {limit} 字(当前 {chars} 字),后台自动压缩暂未调度"
335
+ "(AI 不可用或处于冷却中)。原文已完整保留,下次写入会自动重试。"
336
+ )
337
+ self._logger.warning(
338
+ "档案超过存储上限,已保留原内容并等待压缩",
339
+ table_name=table_name,
340
+ key=key,
341
+ chars=chars,
342
+ max_total_chars=limit,
343
+ compression_scheduled=scheduled,
344
+ )
345
+ return ArchiveWriteOutcome(
346
+ item=item,
347
+ over_limit=True,
348
+ action="summarize",
349
+ scheduled=scheduled,
350
+ chars=chars,
351
+ max_total_chars=limit,
352
+ hint=hint,
353
+ )
354
+
355
+ async def delete(self, table_name: str, key: str) -> bool:
356
+ async with self._uow_factory() as uow:
357
+ deleted = await uow.archive.delete(table_name, key)
358
+ if deleted:
359
+ await uow.commit()
360
+ self._logger.debug("存档记忆已删除", table_name=table_name, key=key, deleted=deleted)
361
+ return deleted
362
+
363
+ # ── 乐观锁与观测能力(面板) ────────────────────────────────────
364
+
365
+ async def set_if_version(
366
+ self,
367
+ table_name: str,
368
+ key: str,
369
+ value: str,
370
+ tags: list[str],
371
+ expected_version: int,
372
+ ) -> ArchiveMemory:
373
+ """带乐观锁的写入:version 不一致时抛 ArchiveVersionConflictError(面板 409)。
374
+
375
+ 注意这里不做容量治理判定:面板是运维兜底入口,管理员必须能写入并看到
376
+ 超限提示,而不是被上限拦在门外(真正超限的档案应通过压缩回收)。
377
+ """
378
+ limit = self._max_total_chars
379
+ chars = len(value or "")
380
+ if limit > 0 and chars > limit and table_name not in self._exempt_tables:
381
+ # 面板是运维兜底入口:必须能写入并看到超限,而不是被上限拦在门外;
382
+ # 这里只记录 WARNING,让超限档案仍能被 list_over_limit 枚举出来。
383
+ self._logger.warning(
384
+ "面板写入的档案超过存储上限(面板路径不拦截,仅记录)",
385
+ table_name=table_name,
386
+ key=key,
387
+ chars=chars,
388
+ max_total_chars=limit,
389
+ )
390
+ async with self._uow_factory() as uow:
391
+ setter = getattr(uow.archive, "set_if_version", None)
392
+ if setter is None:
393
+ # 默认内存实现(neobot_memory.defaults)没有乐观锁:退化成先查后写。
394
+ # 仅在单进程开发/测试路径上使用,真正的并发保护在 SqlAlchemy 仓库里。
395
+ item = await self._set_if_version_fallback(
396
+ uow, table_name, key, value, tags, expected_version
397
+ )
398
+ else:
399
+ try:
400
+ item = await setter(table_name, key, value, tags, expected_version)
401
+ except NeoBotError as exc:
402
+ # 仓库层的冲突异常带 table/key/expected/actual,翻译时原样保留,
403
+ # 面板据此能直接渲染「已被他人修改(当前 version=N)」。
404
+ raise ArchiveVersionConflictError(
405
+ str(exc),
406
+ table_name=str(getattr(exc, "table_name", "") or table_name),
407
+ key=str(getattr(exc, "key", "") or key),
408
+ expected_version=int(getattr(exc, "expected_version", 0) or 0),
409
+ actual_version=int(getattr(exc, "actual_version", 0) or 0),
410
+ ) from exc
411
+ await uow.commit()
412
+ self._logger.debug(
413
+ "存档记忆乐观锁写入",
414
+ table_name=table_name,
415
+ key=key,
416
+ version=item.version,
417
+ )
418
+ return item
419
+
420
+ async def _set_if_version_fallback(
421
+ self,
422
+ uow: Any,
423
+ table_name: str,
424
+ key: str,
425
+ value: str,
426
+ tags: list[str],
427
+ expected_version: int,
428
+ ) -> ArchiveMemory:
429
+ """无原生乐观锁的存储实现:先查后写(有 TOCTOU 窗口,仅开发路径)。"""
430
+ current = await uow.archive.get(table_name, key)
431
+ actual = int(getattr(current, "version", 0) or 0) if current is not None else 0
432
+ try:
433
+ expected = int(expected_version)
434
+ except (TypeError, ValueError):
435
+ expected = 0
436
+ if actual != expected:
437
+ raise ArchiveVersionConflictError(
438
+ f"档案已被其他写入修改: {table_name}:{key}",
439
+ table_name=table_name,
440
+ key=key,
441
+ expected_version=expected,
442
+ actual_version=actual,
443
+ )
444
+ return await uow.archive.set(table_name, key, value, tags)
445
+
446
+ async def list_table_names(self) -> list[str]:
447
+ """库内实际存在哪些档案表(面板表清单;不依赖静态常量)。"""
448
+ async with self._uow_factory() as uow:
449
+ lister = getattr(uow.archive, "list_table_names", None)
450
+ if lister is None:
451
+ self._logger.warning("当前档案存储实现不支持枚举表名,返回空清单")
452
+ return []
453
+ names = await lister()
454
+ return [str(name) for name in names]
455
+
456
+ async def table_stats(self) -> list[dict[str, Any]]:
457
+ """每张档案表的条目数与最大 value 字符数。"""
458
+ async with self._uow_factory() as uow:
459
+ stats = getattr(uow.archive, "table_stats", None)
460
+ if stats is None:
461
+ self._logger.warning("当前档案存储实现不支持表统计,返回空清单")
462
+ return []
463
+ rows = await stats()
464
+ return [dict(row) for row in rows]
465
+
466
+ async def count_over_limit(self, table_name: Optional[str] = None) -> int:
467
+ """当前超过存储上限的档案条目数(上限禁用时为 0)。"""
468
+ limit = self._max_total_chars
469
+ if limit <= 0:
470
+ return 0
471
+ async with self._uow_factory() as uow:
472
+ counter = getattr(uow.archive, "count_over_limit", None)
473
+ if counter is None:
474
+ return 0
475
+ total = await counter(
476
+ limit,
477
+ table_name=table_name,
478
+ exclude_tables=tuple(sorted(self._exempt_tables)),
479
+ )
480
+ return int(total or 0)
481
+
482
+ async def list_over_limit(
483
+ self,
484
+ table_name: Optional[str] = None,
485
+ *,
486
+ limit: int = 100,
487
+ offset: int = 0,
488
+ ) -> list[dict[str, Any]]:
489
+ """枚举「当前超过存储上限」的档案(A7 可观测,供面板直接返回 JSON)。
490
+
491
+ 返回字段:table_name / key / chars / version / updated_at(ISO8601)/ preview。
492
+ 内部豁免表(memory_counter 等)永远不出现在这里——它们天然超限,列出来只会
493
+ 把真正的超标档案淹没。
494
+ """
495
+ max_chars = self._max_total_chars
496
+ if max_chars <= 0:
497
+ return []
498
+ page_size = max(0, min(int(limit), _MAX_OVER_LIMIT_PAGE_SIZE))
499
+ async with self._uow_factory() as uow:
500
+ lister = getattr(uow.archive, "list_over_limit", None)
501
+ if lister is None:
502
+ self._logger.warning("当前档案存储实现不支持超限清单,返回空清单")
503
+ return []
504
+ items = await lister(
505
+ max_chars,
506
+ table_name=table_name,
507
+ exclude_tables=tuple(sorted(self._exempt_tables)),
508
+ limit=page_size,
509
+ offset=max(offset, 0),
510
+ )
511
+ return [self._over_limit_entry(item, max_chars=max_chars) for item in items]
512
+
513
+ @staticmethod
514
+ def _over_limit_entry(item: ArchiveMemory, *, max_chars: int) -> dict[str, Any]:
515
+ value = item.value or ""
516
+ updated_at = item.updated_at
517
+ return {
518
+ "table_name": item.table_name,
519
+ "key": item.key,
520
+ "chars": len(value),
521
+ "version": item.version,
522
+ "updated_at": updated_at.isoformat() if updated_at is not None else None,
523
+ "max_total_chars": max_chars,
524
+ "preview": value[:_OVER_LIMIT_PREVIEW_CHARS],
525
+ }
526
+
527
+ # ── 压缩前快照(spec(4) Part C / D15) ──────────────────────────
528
+
529
+ @staticmethod
530
+ def _snapshot_access(uow: Any) -> Any:
531
+ """取快照访问层;旧实现 / 测试替身没有它时返回 None(快照降级为不落盘)。"""
532
+ return getattr(uow, "archive_snapshots", None)
533
+
534
+ async def save_snapshot(
535
+ self,
536
+ table_name: str,
537
+ key: str,
538
+ value: str,
539
+ *,
540
+ total_chars: Optional[int] = None,
541
+ version: int = 0,
542
+ reason: str = "manual",
543
+ operator_ip: Optional[str] = None,
544
+ ) -> Optional[int]:
545
+ """写入一份压缩前快照并执行保留策略,返回快照 id(存储不支持时为 None)。
546
+
547
+ 写入时机由调用方保证:**压缩开始前**、拿到 chars_before 之后。
548
+ 失败 / 未达标时调用方必须用 ``delete_snapshot`` 删掉本次快照。
549
+ """
550
+ text = str(value or "")
551
+ chars = int(total_chars) if total_chars is not None else len(text)
552
+ async with self._uow_factory() as uow:
553
+ access = self._snapshot_access(uow)
554
+ if access is None:
555
+ self._logger.warning(
556
+ "当前档案存储实现不支持压缩快照,本次压缩不留痕",
557
+ table_name=table_name,
558
+ key=key,
559
+ )
560
+ return None
561
+ row = await access.add(
562
+ table_name,
563
+ key,
564
+ text,
565
+ total_chars=chars,
566
+ version=int(version or 0),
567
+ reason=str(reason or "manual"),
568
+ operator_ip=operator_ip,
569
+ )
570
+ pruned = await access.prune(
571
+ table_name,
572
+ key,
573
+ keep=MAX_ARCHIVE_SNAPSHOTS_PER_KEY,
574
+ max_total=MAX_ARCHIVE_SNAPSHOTS,
575
+ )
576
+ await uow.commit()
577
+ snapshot_id = int(row.get("id") or 0)
578
+ self._logger.debug(
579
+ "压缩前快照已保存",
580
+ table_name=table_name,
581
+ key=key,
582
+ total_chars=chars,
583
+ reason=str(reason or "manual"),
584
+ snapshot_id=snapshot_id,
585
+ pruned=int(pruned or 0),
586
+ )
587
+ return snapshot_id or None
588
+
589
+ async def list_snapshots(
590
+ self,
591
+ table_name: str,
592
+ key: str,
593
+ *,
594
+ limit: int = MAX_ARCHIVE_SNAPSHOTS_PER_KEY,
595
+ include_value: bool = False,
596
+ ) -> list[dict[str, Any]]:
597
+ """列出某条档案最近若干份快照(默认不含全文,避免大档案塞爆响应)。"""
598
+ page = max(0, min(int(limit), MAX_ARCHIVE_SNAPSHOTS_PER_KEY))
599
+ async with self._uow_factory() as uow:
600
+ access = self._snapshot_access(uow)
601
+ if access is None:
602
+ return []
603
+ rows = await access.list(
604
+ table_name, key, limit=page, include_value=include_value
605
+ )
606
+ return [self._snapshot_entry(row) for row in rows]
607
+
608
+ async def get_snapshot(self, snapshot_id: int) -> Optional[dict[str, Any]]:
609
+ """读取某一份快照全文(面板「查看某份快照」)。"""
610
+ async with self._uow_factory() as uow:
611
+ access = self._snapshot_access(uow)
612
+ if access is None:
613
+ return None
614
+ row = await access.get(int(snapshot_id))
615
+ if row is None:
616
+ return None
617
+ entry = self._snapshot_entry(row, include_value=True)
618
+ entry["value"] = str(row.get("value") or "")
619
+ return entry
620
+
621
+ async def delete_snapshot(self, snapshot_id: Optional[int]) -> bool:
622
+ """删除一份快照(压缩失败 / 未达标时回滚本次留痕)。"""
623
+ if not snapshot_id:
624
+ return False
625
+ async with self._uow_factory() as uow:
626
+ access = self._snapshot_access(uow)
627
+ if access is None:
628
+ return False
629
+ deleted = await access.delete(int(snapshot_id))
630
+ if deleted:
631
+ await uow.commit()
632
+ return bool(deleted)
633
+
634
+ async def prune_snapshots(
635
+ self, table_name: Optional[str] = None, key: Optional[str] = None
636
+ ) -> int:
637
+ """按保留策略清理快照:给了 (table_name, key) 时同时做单键与全局两级。"""
638
+ async with self._uow_factory() as uow:
639
+ access = self._snapshot_access(uow)
640
+ if access is None:
641
+ return 0
642
+ removed = await access.prune(
643
+ table_name,
644
+ key,
645
+ keep=MAX_ARCHIVE_SNAPSHOTS_PER_KEY,
646
+ max_total=MAX_ARCHIVE_SNAPSHOTS,
647
+ )
648
+ if removed:
649
+ await uow.commit()
650
+ return int(removed or 0)
651
+
652
+ @staticmethod
653
+ def _snapshot_entry(row: dict[str, Any], *, include_value: bool = False) -> dict[str, Any]:
654
+ """把仓库行投影成面板 / 工具可序列化的字典(时间转 ISO8601)。"""
655
+ created_at = row.get("created_at")
656
+ isoformat = getattr(created_at, "isoformat", None)
657
+ entry: dict[str, Any] = {
658
+ "id": int(row.get("id") or 0),
659
+ "table_name": str(row.get("table_name") or ""),
660
+ "key": str(row.get("key") or ""),
661
+ "total_chars": int(row.get("total_chars") or 0),
662
+ "version": int(row.get("version") or 0),
663
+ "reason": str(row.get("reason") or ""),
664
+ "operator_ip": row.get("operator_ip"),
665
+ "created_at": str(isoformat()) if callable(isoformat) else "",
666
+ }
667
+ if include_value:
668
+ entry["value"] = str(row.get("value") or "")
669
+ return entry
670
+
671
+ # ── 超限压缩调度 ────────────────────────────────────────────────
672
+
673
+ def _in_overflow_cooldown(self, table_name: str, key: str) -> bool:
674
+ until = self._overflow_cooldown_until.get((table_name, key), 0.0)
675
+ return until > 0.0 and time.monotonic() < until
676
+
677
+ def _schedule_overflow_compression(
678
+ self, table_name: str, key: str, *, chars: int, limit: int
679
+ ) -> bool:
680
+ """触发一次异步压缩;返回是否真的调度成功。
681
+
682
+ 冷却与单飞:冷却在这里判(同一 key 在 overflow_summary_cooldown_seconds
683
+ 内不重复触发),单飞由触发方持有(同一 (table, key) 同时只跑一个压缩任务)。
684
+ 触发方返回 False(AI 不可用 / 正在压缩 / 处于失败退避)时不写冷却,
685
+ 这样「下次写入再试」才不会被一次失败锁死 600 秒。
686
+ """
687
+ if self._in_overflow_cooldown(table_name, key):
688
+ self._logger.debug(
689
+ "档案超过存储上限,但仍在压缩冷却期内,跳过本次触发",
690
+ table_name=table_name,
691
+ key=key,
692
+ )
693
+ return False
694
+ trigger = self._overflow_trigger
695
+ if trigger is None:
696
+ self._logger.warning(
697
+ "档案超过存储上限但没有可用的自动压缩入口,仅保留原内容",
698
+ table_name=table_name,
699
+ key=key,
700
+ chars=chars,
701
+ max_total_chars=limit,
702
+ )
703
+ return False
704
+ try:
705
+ scheduled = bool(trigger(table_name, key))
706
+ except Exception as exc:
707
+ self._logger.warning(
708
+ "档案超过存储上限,触发自动压缩失败",
709
+ table_name=table_name,
710
+ key=key,
711
+ error=str(exc) or type(exc).__name__,
712
+ )
713
+ return False
714
+ if scheduled:
715
+ self._overflow_cooldown_until[(table_name, key)] = (
716
+ time.monotonic() + self._overflow_cooldown_seconds
717
+ )
718
+ return scheduled
@@ -0,0 +1,264 @@
1
+ """Defaults — 开箱即用的默认实现"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from neobot_contracts.models import ConversationRef, MemoryRecord
8
+ from neobot_contracts.models.memory import ArchiveMemory, ImageAnalysis
9
+ from neobot_contracts.ports.archive_memory_access import ArchiveMemoryAccess
10
+ from neobot_contracts.time_context import now_utc
11
+ from neobot_contracts.ports.image_analysis_access import ImageAnalysisAccess
12
+ from neobot_contracts.ports.clock import SystemClock as SystemClock # re-export
13
+ from neobot_contracts.ports.logging import NullLogger as NullLogger # re-export
14
+
15
+
16
+ class InMemoryMemoryRepository:
17
+ """纯内存记忆存储,用于测试或独立运行"""
18
+
19
+ def __init__(self) -> None:
20
+ self._records: list[MemoryRecord] = []
21
+
22
+ async def save(self, record: MemoryRecord) -> None:
23
+ self._records.append(record)
24
+
25
+ async def search(
26
+ self, conversation: ConversationRef, query: str, limit: int = 5
27
+ ) -> list[MemoryRecord]:
28
+ matches = [
29
+ r for r in self._records
30
+ if r.conversation == conversation and query.lower() in r.content.lower()
31
+ ]
32
+ return matches[-limit:]
33
+
34
+
35
+ class InMemoryArchiveMemoryAccess:
36
+ """内存档案式记忆存储,用于测试或独立运行"""
37
+
38
+ def __init__(self) -> None:
39
+ self._storage: dict[tuple[str, str], ArchiveMemory] = {}
40
+ self._id_counter = 0
41
+
42
+ async def get(self, table_name: str, key: str) -> Optional[ArchiveMemory]:
43
+ """根据表名和键名获取档案式记忆条目"""
44
+ entry = self._storage.get((table_name, key))
45
+ if entry is None:
46
+ return None
47
+ return self._clone(entry)
48
+
49
+ async def set(
50
+ self,
51
+ table_name: str,
52
+ key: str,
53
+ value: str,
54
+ tags: list[str],
55
+ ) -> ArchiveMemory:
56
+ """创建或更新档案式记忆条目"""
57
+ entry_key = (table_name, key)
58
+
59
+ if entry_key in self._storage:
60
+ # 更新现有条目
61
+ existing = self._storage[entry_key]
62
+ updated = ArchiveMemory(
63
+ id=existing.id,
64
+ table_name=table_name,
65
+ key=key,
66
+ value=value,
67
+ tags=tags.copy(), # 创建副本避免外部修改
68
+ created_at=existing.created_at, # 保留原始创建时间
69
+ updated_at=now_utc(),
70
+ version=existing.version + 1,
71
+ )
72
+ else:
73
+ # 创建新条目
74
+ self._id_counter += 1
75
+ now = now_utc()
76
+ updated = ArchiveMemory(
77
+ id=self._id_counter,
78
+ table_name=table_name,
79
+ key=key,
80
+ value=value,
81
+ tags=tags.copy(),
82
+ created_at=now,
83
+ updated_at=now,
84
+ version=1,
85
+ )
86
+
87
+ self._storage[entry_key] = self._clone(updated)
88
+ return self._clone(updated)
89
+
90
+ async def delete(self, table_name: str, key: str) -> bool:
91
+ """删除档案式记忆条目"""
92
+ entry_key = (table_name, key)
93
+ if entry_key in self._storage:
94
+ del self._storage[entry_key]
95
+ return True
96
+ return False
97
+
98
+ async def exists(self, table_name: str, key: str) -> bool:
99
+ """检查档案式记忆条目是否存在"""
100
+ return (table_name, key) in self._storage
101
+
102
+ async def list(
103
+ self,
104
+ table_name: str,
105
+ *,
106
+ tags: Optional[list[str]] = None,
107
+ key_query: Optional[str] = None,
108
+ value_query: Optional[str] = None,
109
+ limit: int = 50,
110
+ offset: int = 0,
111
+ ) -> list[ArchiveMemory]:
112
+ """列出符合条件的档案式记忆条目"""
113
+ rows = [
114
+ self._clone(entry)
115
+ for (stored_table_name, _), entry in self._storage.items()
116
+ if stored_table_name == table_name
117
+ ]
118
+
119
+ if tags:
120
+ rows = [row for row in rows if all(tag in row.tags for tag in tags)]
121
+ if key_query:
122
+ lowered_key_query = key_query.lower()
123
+ rows = [row for row in rows if lowered_key_query in row.key.lower()]
124
+ if value_query:
125
+ lowered_value_query = value_query.lower()
126
+ rows = [row for row in rows if lowered_value_query in row.value.lower()]
127
+
128
+ rows.sort(key=lambda row: (row.updated_at, row.id), reverse=True)
129
+ return rows[offset : offset + limit]
130
+
131
+ # Type check: ensure class implements the protocol
132
+ def __init_subclass__(cls) -> None:
133
+ super().__init_subclass__()
134
+ # Verify that this class implements the ArchiveMemoryAccess protocol
135
+ if not issubclass(cls, ArchiveMemoryAccess): # type: ignore
136
+ raise TypeError(f"{cls.__name__} does not implement ArchiveMemoryAccess protocol")
137
+
138
+ @staticmethod
139
+ def _clone(entry: ArchiveMemory) -> ArchiveMemory:
140
+ return ArchiveMemory(
141
+ id=entry.id,
142
+ table_name=entry.table_name,
143
+ key=entry.key,
144
+ value=entry.value,
145
+ tags=entry.tags.copy(),
146
+ created_at=entry.created_at,
147
+ updated_at=entry.updated_at,
148
+ version=entry.version,
149
+ )
150
+
151
+
152
+ class InMemoryImageAnalysisAccess:
153
+ """In-memory image analysis cache for tests and standalone runs."""
154
+
155
+ def __init__(self) -> None:
156
+ self._storage: dict[str, ImageAnalysis] = {}
157
+ self._id_counter = 0
158
+
159
+ async def get(self, file_hash: str) -> Optional[ImageAnalysis]:
160
+ entry = self._storage.get(file_hash)
161
+ if entry is None:
162
+ return None
163
+ return self._clone(entry)
164
+
165
+ async def set(
166
+ self,
167
+ file_hash: str,
168
+ *,
169
+ source: Optional[str] = None,
170
+ mime_type: Optional[str] = None,
171
+ original_width: Optional[int] = None,
172
+ original_height: Optional[int] = None,
173
+ processed_width: Optional[int] = None,
174
+ processed_height: Optional[int] = None,
175
+ analysis_text: Optional[str] = None,
176
+ ) -> ImageAnalysis:
177
+ if file_hash in self._storage:
178
+ existing = self._storage[file_hash]
179
+ updated = ImageAnalysis(
180
+ id=existing.id,
181
+ file_hash=file_hash,
182
+ source=source if source is not None else existing.source,
183
+ mime_type=mime_type if mime_type is not None else existing.mime_type,
184
+ original_width=original_width if original_width is not None else existing.original_width,
185
+ original_height=original_height if original_height is not None else existing.original_height,
186
+ processed_width=processed_width if processed_width is not None else existing.processed_width,
187
+ processed_height=processed_height if processed_height is not None else existing.processed_height,
188
+ analysis_text=analysis_text if analysis_text is not None else existing.analysis_text,
189
+ created_at=existing.created_at,
190
+ updated_at=now_utc(),
191
+ version=existing.version + 1,
192
+ )
193
+ else:
194
+ self._id_counter += 1
195
+ now = now_utc()
196
+ updated = ImageAnalysis(
197
+ id=self._id_counter,
198
+ file_hash=file_hash,
199
+ source=source,
200
+ mime_type=mime_type,
201
+ original_width=original_width,
202
+ original_height=original_height,
203
+ processed_width=processed_width,
204
+ processed_height=processed_height,
205
+ analysis_text=analysis_text,
206
+ created_at=now,
207
+ updated_at=now,
208
+ version=1,
209
+ )
210
+
211
+ self._storage[file_hash] = self._clone(updated)
212
+ return self._clone(updated)
213
+
214
+ async def delete(self, file_hash: str) -> bool:
215
+ if file_hash in self._storage:
216
+ del self._storage[file_hash]
217
+ return True
218
+ return False
219
+
220
+ async def exists(self, file_hash: str) -> bool:
221
+ return file_hash in self._storage
222
+
223
+ async def list(
224
+ self,
225
+ *,
226
+ source_query: Optional[str] = None,
227
+ has_analysis_text: Optional[bool] = None,
228
+ limit: int = 50,
229
+ offset: int = 0,
230
+ ) -> list[ImageAnalysis]:
231
+ rows = [self._clone(entry) for entry in self._storage.values()]
232
+
233
+ if source_query:
234
+ lowered = source_query.lower()
235
+ rows = [row for row in rows if row.source and lowered in row.source.lower()]
236
+ if has_analysis_text is True:
237
+ rows = [row for row in rows if row.analysis_text]
238
+ elif has_analysis_text is False:
239
+ rows = [row for row in rows if not row.analysis_text]
240
+
241
+ rows.sort(key=lambda row: (row.updated_at, row.id), reverse=True)
242
+ return rows[offset : offset + limit]
243
+
244
+ def __init_subclass__(cls) -> None:
245
+ super().__init_subclass__()
246
+ if not issubclass(cls, ImageAnalysisAccess): # type: ignore[arg-type]
247
+ raise TypeError(f"{cls.__name__} does not implement ImageAnalysisAccess protocol")
248
+
249
+ @staticmethod
250
+ def _clone(entry: ImageAnalysis) -> ImageAnalysis:
251
+ return ImageAnalysis(
252
+ id=entry.id,
253
+ file_hash=entry.file_hash,
254
+ source=entry.source,
255
+ mime_type=entry.mime_type,
256
+ original_width=entry.original_width,
257
+ original_height=entry.original_height,
258
+ processed_width=entry.processed_width,
259
+ processed_height=entry.processed_height,
260
+ analysis_text=entry.analysis_text,
261
+ created_at=entry.created_at,
262
+ updated_at=entry.updated_at,
263
+ version=entry.version,
264
+ )
@@ -0,0 +1,92 @@
1
+ """图片分析缓存服务。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from neobot_contracts.models.memory import ImageAnalysis
8
+ from neobot_contracts.ports.logging import Logger
9
+ from neobot_contracts.ports.unit_of_work import UnitOfWorkFactory
10
+
11
+
12
+ class ImageAnalysisService:
13
+ """图片分析结果的缓存 CRUD 与查询服务。"""
14
+
15
+ def __init__(self, uow_factory: UnitOfWorkFactory, logger: Logger) -> None:
16
+ self._uow_factory = uow_factory
17
+ self._logger = logger
18
+
19
+ async def get(self, file_hash: str) -> Optional[ImageAnalysis]:
20
+ async with self._uow_factory() as uow:
21
+ item = await uow.images.get(file_hash)
22
+ self._logger.debug("图像分析已获取", file_hash=file_hash, found=item is not None)
23
+ return item
24
+
25
+ async def exists(self, file_hash: str) -> bool:
26
+ async with self._uow_factory() as uow:
27
+ exists = await uow.images.exists(file_hash)
28
+ self._logger.debug("图像分析存在检查", file_hash=file_hash, exists=exists)
29
+ return exists
30
+
31
+ async def list(
32
+ self,
33
+ *,
34
+ source_query: Optional[str] = None,
35
+ has_analysis_text: Optional[bool] = None,
36
+ limit: int = 50,
37
+ offset: int = 0,
38
+ ) -> list[ImageAnalysis]:
39
+ async with self._uow_factory() as uow:
40
+ items = await uow.images.list(
41
+ source_query=source_query,
42
+ has_analysis_text=has_analysis_text,
43
+ limit=limit,
44
+ offset=offset,
45
+ )
46
+ self._logger.debug(
47
+ "图像分析列表已获取",
48
+ count=len(items),
49
+ limit=limit,
50
+ offset=offset,
51
+ has_analysis_text=has_analysis_text,
52
+ )
53
+ return items
54
+
55
+ async def set(
56
+ self,
57
+ file_hash: str,
58
+ *,
59
+ source: Optional[str] = None,
60
+ mime_type: Optional[str] = None,
61
+ original_width: Optional[int] = None,
62
+ original_height: Optional[int] = None,
63
+ processed_width: Optional[int] = None,
64
+ processed_height: Optional[int] = None,
65
+ analysis_text: Optional[str] = None,
66
+ ) -> ImageAnalysis:
67
+ async with self._uow_factory() as uow:
68
+ item = await uow.images.set(
69
+ file_hash,
70
+ source=source,
71
+ mime_type=mime_type,
72
+ original_width=original_width,
73
+ original_height=original_height,
74
+ processed_width=processed_width,
75
+ processed_height=processed_height,
76
+ analysis_text=analysis_text,
77
+ )
78
+ await uow.commit()
79
+ self._logger.debug(
80
+ "图像分析已保存",
81
+ file_hash=file_hash,
82
+ version=item.version,
83
+ )
84
+ return item
85
+
86
+ async def delete(self, file_hash: str) -> bool:
87
+ async with self._uow_factory() as uow:
88
+ deleted = await uow.images.delete(file_hash)
89
+ if deleted:
90
+ await uow.commit()
91
+ self._logger.debug("图像分析已删除", file_hash=file_hash, deleted=deleted)
92
+ return deleted
File without changes
@@ -0,0 +1,13 @@
1
+ """MemoryReader Protocol — 记忆读取抽象"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol
6
+
7
+
8
+ class MemoryReader(Protocol):
9
+ """记忆读取接口,MemoryService 结构性满足此接口"""
10
+
11
+ async def recall(
12
+ self, conversation_id: str, query: str, limit: int = 5
13
+ ) -> list[str]: ...
@@ -0,0 +1,46 @@
1
+ """MemoryService — 记忆读写服务"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from neobot_contracts.models import ConversationRef, MemoryRecord
6
+ from neobot_contracts.ports.clock import Clock
7
+ from neobot_contracts.ports.logging import Logger
8
+ from neobot_contracts.ports.repository import MemoryRepository
9
+
10
+
11
+ class MemoryService:
12
+ """记忆服务,负责存储和检索记忆条目"""
13
+
14
+ def __init__(
15
+ self,
16
+ repository: MemoryRepository,
17
+ logger: Logger,
18
+ clock: Clock,
19
+ ) -> None:
20
+ self._repository = repository
21
+ self._logger = logger
22
+ self._clock = clock
23
+
24
+ async def remember(
25
+ self, conversation_id: str, speaker_id: str, content: str
26
+ ) -> None:
27
+ record = MemoryRecord(
28
+ conversation=ConversationRef(kind="private", id=conversation_id),
29
+ speaker_id=speaker_id,
30
+ content=content,
31
+ created_at=self._clock.now(),
32
+ )
33
+ await self._repository.save(record)
34
+ self._logger.debug("短期记忆已保存", conversation_id=conversation_id, speaker_id=speaker_id)
35
+
36
+ async def recall(
37
+ self, conversation_id: str, query: str, limit: int = 5
38
+ ) -> list[str]:
39
+ ref = ConversationRef(kind="private", id=conversation_id)
40
+ records = await self._repository.search(ref, query, limit)
41
+ self._logger.debug(
42
+ "记忆已检索",
43
+ conversation_id=conversation_id,
44
+ count=len(records),
45
+ )
46
+ return [r.content for r in records]