feat(plugins): consolidate official plugins with independent releases (LANG-180) (#2797)

Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
RockChinQ
2026-10-28 17:32:24 +08:00
committed by GitHub
co-authored by multica-agent
parent d099033d79
commit 3a77306c3c
1144 changed files with 142049 additions and 130 deletions
@@ -0,0 +1,583 @@
from __future__ import annotations
import json
import logging
import math
import time
import uuid as uuid_mod
from datetime import datetime, timezone
from typing import Any
from langbot_plugin.api.definition.components.knowledge_engine.engine import (
KnowledgeEngine,
KnowledgeEngineCapability,
)
from langbot_plugin.api.entities.builtin.rag.context import (
RetrievalContext,
RetrievalResponse,
RetrievalResultEntry,
)
from langbot_plugin.api.entities.builtin.rag.models import (
IngestionContext,
IngestionResult,
)
from langbot_plugin.api.entities.builtin.rag.enums import DocumentStatus
from store.serialization import serialized_write
logger = logging.getLogger(__name__)
EMBEDDING_BATCH_SIZE = 32
class LongTermMemoryEngine(KnowledgeEngine):
"""Long-term memory KnowledgeEngine.
Serves as the configuration entry point for the memory plugin
(embedding model, isolation mode) and handles L2 episodic memory
retrieval and import.
"""
@property
def _writes(self):
return self.plugin.memory_store._writes
@classmethod
def get_capabilities(cls) -> list[str]:
return [KnowledgeEngineCapability.DOC_INGESTION]
async def on_knowledge_base_create(self, kb_id: str, config: dict) -> None:
existing = await self.plugin.memory_store.get_kb_configs()
if existing:
raise ValueError(
"Only one memory knowledge base is supported per plugin instance. "
"Please delete the existing one before creating a new one."
)
logger.info("Memory KB created: %s, config: %s", kb_id, config)
await self.plugin.memory_store.save_kb_config(kb_id, config)
async def on_knowledge_base_delete(self, kb_id: str) -> None:
logger.info("Memory KB deleted: %s", kb_id)
await self.plugin.memory_store.remove_kb_config(kb_id)
# ================================================================
# time-decay scoring
# ================================================================
@staticmethod
def _time_decay_score(
similarity: float, timestamp_str: str, half_life_days: float = 30.0,
) -> float:
"""Blend similarity with exponential time decay.
final_score = similarity * exp(-ln2 * age_days / half_life_days)
A memory exactly ``half_life_days`` old retains 50 % of its
similarity weight; very recent memories are nearly unaffected.
"""
if not timestamp_str:
return similarity * 0.5
try:
ts = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
now = datetime.now(timezone.utc)
age_days = max(0.0, (now - ts).total_seconds() / 86400.0)
decay = math.exp(-math.log(2) * age_days / half_life_days)
return similarity * decay
except (ValueError, TypeError):
return similarity * 0.5
@staticmethod
def _parse_importance(metadata: dict[str, Any]) -> int:
raw = metadata.get("importance", "2")
try:
return max(1, min(5, int(raw)))
except (TypeError, ValueError):
return 2
@staticmethod
def _importance_weight(importance: int) -> float:
# Importance should influence ranking, but not overpower semantic match.
# The weights keep importance as a secondary signal.
return {
1: 0.9,
2: 1.0,
3: 1.08,
4: 1.16,
5: 1.24,
}.get(importance, 1.0)
@staticmethod
def _speaker_match_weight(
metadata: dict[str, Any],
current_sender_id: str,
) -> float:
if not current_sender_id:
return 1.0
if str(metadata.get("sender_id", "") or "") == current_sender_id:
return 1.12
return 1.0
@staticmethod
def _has_update_signal(metadata: dict[str, Any]) -> bool:
raw_tags = str(metadata.get("tags", "") or "")
tags = {
item.strip().lower()
for item in raw_tags.split(",")
if item.strip()
}
return bool(tags & {
"correction",
"clarification",
"profile-update",
"preference-change",
"fact-update",
"更正",
"修正",
"纠正",
"偏好变化",
})
@staticmethod
def _recency_hint(timestamp_str: str) -> str:
if not timestamp_str:
return "unknown"
try:
ts = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
now = datetime.now(timezone.utc)
age_days = max(0.0, (now - ts).total_seconds() / 86400.0)
except (ValueError, TypeError):
return "unknown"
if age_days < 1:
return "fresh"
if age_days < 7:
return "recent"
if age_days < 30:
return "still-relevant"
if age_days < 90:
return "older"
return "old"
# ================================================================
# retrieve - called by LocalAgentRunner before LLM invocation
# ================================================================
async def retrieve(self, context: RetrievalContext) -> RetrievalResponse:
query = context.query
collection_id = context.get_collection_id()
settings = context.creation_settings
retrieval_settings = context.retrieval_settings
store = self.plugin.memory_store
embedding_model_uuid = settings.get("embedding_model_uuid", "")
top_k = retrieval_settings.get("top_k", 5)
logger.info(
"[LongTermMemory] engine retrieve called: collection_id=%s top_k=%s session_name=%s sender_id=%s bot_uuid=%s query_len=%s",
collection_id,
top_k,
retrieval_settings.get("session_name"),
retrieval_settings.get("sender_id", ""),
retrieval_settings.get("bot_uuid", ""),
len(query),
)
if not query.strip() or not embedding_model_uuid:
logger.info(
"[LongTermMemory] engine retrieve skipped: collection_id=%s reason=%s",
collection_id,
"missing_query" if not query.strip() else "missing_embedding_model_uuid",
)
return RetrievalResponse(results=[], total_found=0)
# embed the query
query_vectors = await self.plugin.invoke_embedding(
embedding_model_uuid, [query]
)
query_vector = query_vectors[0]
session_name = retrieval_settings.get("session_name")
sender_id = str(retrieval_settings.get("sender_id", "") or "")
bot_uuid = str(retrieval_settings.get("bot_uuid", "") or "")
isolation = settings.get("isolation", "session")
# Over-fetch to allow time-decay re-ranking to surface recent
# memories that would otherwise be pushed out by pure similarity.
fetch_k = top_k * 3
retrieval_strategy = store.normalize_retrieval_strategy(
settings.get("retrieval_strategy", "auto")
)
vector_weight = store.normalize_vector_weight(settings.get("vector_weight", 0.7))
exact_match_boost = bool(settings.get("exact_match_boost", True))
results: list[dict[str, Any]] = []
seen_ids: set[str] = set()
async def extend_results(filters: dict[str, Any] | None) -> None:
nonlocal results
logger.info(
"[LongTermMemory] engine vector search: collection_id=%s fetch_k=%s strategy=%s filters=%s",
collection_id,
fetch_k,
retrieval_strategy,
filters,
)
search_kwargs = {
"collection_id": collection_id,
"query_vector": query_vector,
"top_k": fetch_k,
"filters": filters,
}
if retrieval_strategy in {"auto", "hybrid"}:
try:
batch = await self.plugin.vector_search(
**search_kwargs,
search_type="hybrid",
query_text=query,
vector_weight=vector_weight,
)
except Exception:
if retrieval_strategy == "hybrid":
logger.warning(
"[LongTermMemory] engine hybrid search failed; falling back to vector search",
exc_info=True,
)
else:
logger.info(
"[LongTermMemory] engine auto hybrid search unavailable; falling back to vector search",
exc_info=True,
)
batch = await self.plugin.vector_search(**search_kwargs)
else:
batch = await self.plugin.vector_search(**search_kwargs)
for item in batch:
item_id = item.get("id", "")
if item_id and item_id in seen_ids:
continue
if not store.episode_status_included(item.get("metadata", {})):
continue
if item_id:
seen_ids.add(item_id)
results.append(item)
if len(results) >= fetch_k:
return
if session_name:
scope_key = store.get_scope_key_from_session_name(
bot_uuid, session_name, isolation
)
if sender_id:
await extend_results({"$and": [{"user_key": scope_key}, {"sender_id": sender_id}]})
if len(results) < fetch_k:
await extend_results({"user_key": scope_key})
else:
# Refuse to search without scope — returning unfiltered results
# would leak memories across sessions / users.
logger.warning(
"[LongTermMemory] engine retrieve refused: collection_id=%s reason=missing_session_name",
collection_id,
)
return RetrievalResponse(results=[], total_found=0)
# Time-decay re-ranking: blend similarity with recency so that
# recent memories can outrank older ones with marginally higher
# vector similarity.
half_life = float(settings.get("recency_half_life_days", 30))
for r in results:
sim = r.get("score") or (1.0 - r.get("distance", 1.0))
metadata = r.get("metadata", {})
ts = metadata.get("timestamp", "")
importance = self._parse_importance(metadata)
time_score = self._time_decay_score(float(sim), ts, half_life)
importance_weight = self._importance_weight(importance)
speaker_weight = self._speaker_match_weight(metadata, sender_id)
update_weight = 1.08 if self._has_update_signal(metadata) else 1.0
exact_boost = 1.0
if exact_match_boost:
exact_score = store._metadata_exact_match_score(
query,
r.get("id", ""),
metadata,
)
exact_boost += min(exact_score, 2.0) * 0.25
final_score = (
time_score
* importance_weight
* speaker_weight
* update_weight
* exact_boost
)
r["_final_score"] = max(0.0, min(1.0, final_score))
r["_recency_hint"] = self._recency_hint(ts)
r["_importance"] = importance
r["_speaker_match"] = speaker_weight > 1.0
r["_has_update_signal"] = update_weight > 1.0
r["_exact_match"] = exact_boost > 1.0
results.sort(key=lambda r: r["_final_score"], reverse=True)
results = results[:top_k]
logger.info(
"[LongTermMemory] engine retrieve completed: collection_id=%s result_count=%s half_life_days=%s",
collection_id,
len(results),
half_life,
)
entries: list[RetrievalResultEntry] = []
for r in results:
meta = r.get("metadata", {})
content = meta.get("content", "")
timestamp = meta.get("timestamp", "")
importance = str(r.get("_importance", self._parse_importance(meta)))
tags = meta.get("tags", "")
hints = [f"importance:{importance}"]
recency_hint = r.get("_recency_hint", "unknown")
if recency_hint != "unknown":
hints.append(f"recency:{recency_hint}")
if r.get("_speaker_match"):
hints.append("speaker:current")
if r.get("_has_update_signal"):
hints.append("signal:update")
if r.get("_exact_match"):
hints.append("match:exact")
display = f"[{timestamp}] ({', '.join(hints)})"
if tags:
display += f" [{tags}]"
# Wrap content in data markers to reduce prompt-injection risk.
display += f" <mem>{content}</mem>"
entries.append(
RetrievalResultEntry(
id=r.get("id", ""),
content=[{"type": "text", "text": display}],
metadata=meta,
score=r.get("_final_score", r.get("score")),
distance=1.0 - r.get("_final_score", r.get("score", 0.0)),
)
)
return RetrievalResponse(results=entries, total_found=len(entries))
# ================================================================
# ingest - import memories from JSON file
# ================================================================
async def ingest(self, context: IngestionContext) -> IngestionResult:
audit_entries: list[dict[str, Any]] = []
result = await self._ingest(context.get_collection_id(), context, audit_entries)
# User-scope audit writes run after releasing the collection lock.
for entry in audit_entries:
await self.plugin.memory_store.append_audit_entry(**entry)
return result
@serialized_write
async def _ingest(
self,
collection_id: str,
context: IngestionContext,
audit_entries: list[dict[str, Any]],
) -> IngestionResult:
doc_id = context.file_object.metadata.document_id
filename = context.file_object.metadata.filename
settings = context.creation_settings
embedding_model_uuid = settings.get("embedding_model_uuid", "")
logger.info("Ingesting memory file: %s (doc=%s)", filename, doc_id)
if not embedding_model_uuid:
return IngestionResult(
document_id=doc_id,
status=DocumentStatus.FAILED,
error_message="No embedding model configured.",
)
# read file content
try:
content_bytes = await self.plugin.get_knowledge_file_stream(
context.file_object.storage_path
)
except Exception as e:
logger.error("Failed to read file: %s", e)
return IngestionResult(
document_id=doc_id,
status=DocumentStatus.FAILED,
error_message=f"Could not read file: {e}",
)
# parse JSON array of memory entries
try:
text = content_bytes.decode("utf-8")
# support pre-parsed content from a Parser plugin
if context.parsed_content and context.parsed_content.text:
text = context.parsed_content.text
memories = json.loads(text)
if not isinstance(memories, list):
memories = [memories]
except Exception as e:
logger.error("Failed to parse memory file: %s", e)
return IngestionResult(
document_id=doc_id,
status=DocumentStatus.FAILED,
error_message=f"Invalid JSON format: {e}",
)
if not memories:
return IngestionResult(
document_id=doc_id,
status=DocumentStatus.COMPLETED,
chunks_created=0,
)
# batch embed and upsert
total_stored = 0
batch_texts: list[str] = []
batch_ids: list[str] = []
batch_metas: list[dict[str, Any]] = []
imported_by_user: dict[str, int] = {}
for mem in memories:
content = mem.get("content", "")
if not content:
continue
tags = mem.get("tags", [])
importance = mem.get("importance", 2)
try:
raw_timestamp = mem.get("timestamp", "")
if raw_timestamp:
timestamp = self.plugin.memory_store.normalize_timestamp(
str(raw_timestamp)
)
else:
timestamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
except ValueError as exc:
return IngestionResult(
document_id=doc_id,
status=DocumentStatus.FAILED,
error_message=str(exc),
)
user_key = mem.get("user_key", "imported")
status = self.plugin.memory_store.normalize_episode_status_value(
mem.get("status", "active")
)
user_key_text = str(user_key or "imported")
imported_by_user[user_key_text] = imported_by_user.get(user_key_text, 0) + 1
mid = uuid_mod.uuid4().hex[:12]
batch_texts.append(content)
batch_ids.append(mid)
batch_metas.append({
"content": content,
"tags": ",".join(tags) if isinstance(tags, list) else str(tags),
"importance": str(importance),
"timestamp": timestamp,
"user_key": user_key,
"source": "import",
"document_id": doc_id,
"status": status,
})
if len(batch_texts) >= EMBEDDING_BATCH_SIZE:
total_stored += await self._embed_and_upsert(
collection_id, embedding_model_uuid,
batch_texts, batch_ids, batch_metas,
)
batch_texts, batch_ids, batch_metas = [], [], []
# flush remaining
if batch_texts:
total_stored += await self._embed_and_upsert(
collection_id, embedding_model_uuid,
batch_texts, batch_ids, batch_metas,
)
logger.info("Ingestion complete: %d memories stored", total_stored)
for user_key, count in imported_by_user.items():
audit_entries.append(dict(
scope_key=user_key,
user_key=user_key,
operation="import_l2",
target_type="document",
target_id=doc_id,
summary=f"Imported {count} L2 episode(s) from {filename}",
metadata={
"kb_id": collection_id,
"filename": filename,
"count": count,
},
))
return IngestionResult(
document_id=doc_id,
status=DocumentStatus.COMPLETED,
chunks_created=total_stored,
)
@serialized_write
async def _embed_and_upsert(
self,
collection_id: str,
embedding_model_uuid: str,
texts: list[str],
ids: list[str],
metas: list[dict[str, Any]],
) -> int:
logger.info(
"[LongTermMemory] engine embed_and_upsert: collection_id=%s batch_size=%s ids=%s",
collection_id,
len(texts),
ids,
)
vectors = await self.plugin.invoke_embedding(embedding_model_uuid, texts)
await self._writes.mutate(
self.plugin,
collection_id,
lambda: self.plugin.vector_upsert(
collection_id=collection_id,
vectors=vectors,
ids=ids,
metadata=metas,
documents=texts,
),
"Host vector_upsert outcome unknown",
)
return len(texts)
# ================================================================
# delete_document - remove imported memories by document_id
# ================================================================
async def delete_document(self, kb_id: str, document_id: str) -> bool:
logger.info(
"[LongTermMemory] delete_document called: kb_id=%s document_id=%s",
kb_id,
document_id,
)
count = await self._delete_document_vectors(kb_id, document_id)
logger.info(
"[LongTermMemory] delete_document completed: kb_id=%s document_id=%s deleted_count=%s",
kb_id,
document_id,
count,
)
await self.plugin.memory_store.append_audit_entry(
scope_key=f"document:{document_id}",
operation="delete_document",
target_type="document",
target_id=document_id,
summary=f"Deleted imported memory document {document_id}",
metadata={"kb_id": kb_id, "deleted": count},
)
return count > 0
@serialized_write
async def _delete_document_vectors(self, kb_id: str, document_id: str) -> int:
return await self._writes.mutate(
self.plugin,
kb_id,
lambda: self.plugin.vector_delete(
collection_id=kb_id,
filters={"document_id": document_id},
),
"Host vector_delete outcome unknown",
)
@@ -0,0 +1,203 @@
apiVersion: v1
kind: KnowledgeEngine
metadata:
name: long_term_memory
label:
en_US: LongTermMemory
zh_Hans: 长期记忆
description:
en_US: 'Long-term memory for LangBot. Configure embedding model and isolation mode here. Supports memory import via JSON files.'
zh_Hans: 'LangBot 长期记忆。在此配置嵌入模型和隔离模式,支持通过 JSON 文件导入记忆。'
spec:
creation_schema:
- name: embedding_model_uuid
label:
en_US: Embedding Model
zh_Hans: 嵌入模型
description:
en_US: Embedding model used for semantic search.
zh_Hans: 用于语义检索的嵌入模型。
type: embedding-model-selector
required: true
default: ''
- name: isolation
label:
en_US: Isolation Mode
zh_Hans: 隔离模式
description:
en_US: 'session: each group/chat has independent memory. bot: all sessions share memory.'
zh_Hans: 'session:每个群/会话独立记忆。bot:所有会话共享记忆。'
type: select
options:
- name: session
label:
en_US: Session
zh_Hans: 会话隔离
- name: bot
label:
en_US: Bot
zh_Hans: 全局共享
default: session
- name: recency_half_life_days
label:
en_US: Recency Half-Life Days
zh_Hans: 时效半衰期天数
description:
en_US: Controls how quickly older episodic memories lose ranking weight during automatic recall.
zh_Hans: 控制自动召回时旧情景记忆的时效衰减速度。
type: integer
required: false
default: 30
- name: auto_recall_top_k
label:
en_US: Auto Recall Top K
zh_Hans: 自动召回条数
description:
en_US: Number of episodic memories injected automatically before model invocation.
zh_Hans: 模型调用前自动注入的情景记忆条数。
type: integer
required: false
default: 3
- name: retrieval_strategy
label:
en_US: Retrieval Strategy
zh_Hans: 检索策略
description:
en_US: "auto tries hybrid search first and falls back to vector search. Use vector for maximum backend compatibility."
zh_Hans: "auto 会优先尝试混合检索,失败时回退向量检索。vector 兼容性最好。"
type: select
options:
- name: auto
label:
en_US: Auto
zh_Hans: 自动
- name: vector
label:
en_US: Vector
zh_Hans: 向量
- name: hybrid
label:
en_US: Hybrid
zh_Hans: 混合
required: false
default: auto
- name: vector_weight
label:
en_US: Vector Weight
zh_Hans: 向量权重
description:
en_US: Vector similarity weight used by hybrid search.
zh_Hans: 混合检索中的向量相似度权重。
type: number
required: false
default: 0.7
- name: exact_match_boost
label:
en_US: Exact Match Boost
zh_Hans: 精确匹配增强
description:
en_US: Boost exact matches on episode IDs, sender identity, tags, and content snippets.
zh_Hans: 对 episode ID、说话人、标签和内容片段的精确匹配进行增强。
type: boolean
required: false
default: true
- name: consolidation_enabled
label:
en_US: Enable Consolidation Run
zh_Hans: 启用记忆整理执行
description:
en_US: Allows !memory consolidate run to archive scoped candidates and write a summary. Preview is always available.
zh_Hans: 允许 !memory consolidate run 归档当前范围候选并写入摘要。预览始终可用。
type: boolean
required: false
default: false
- name: consolidation_min_age_days
label:
en_US: Consolidation Min Age Days
zh_Hans: 记忆整理最小天数
description:
en_US: Active low-importance memories must be at least this old before consolidation can propose archiving them.
zh_Hans: 低重要度活跃记忆至少达到该天数后,整理流程才会建议归档。
type: integer
required: false
default: 7
- name: consolidation_max_candidates
label:
en_US: Consolidation Max Candidates
zh_Hans: 记忆整理候选上限
description:
en_US: Maximum number of scoped episodes considered in one consolidation preview or run.
zh_Hans: 单次整理预览或执行最多处理的当前范围情景记忆数量。
type: integer
required: false
default: 20
- name: consolidation_apply_profile_updates
label:
en_US: Apply Profile Updates During Consolidation
zh_Hans: 整理时更新画像
description:
en_US: Reserved for future profile-update proposals. Keep disabled unless explicitly supported.
zh_Hans: 预留给未来画像更新建议。除非明确支持,否则保持关闭。
type: boolean
required: false
default: false
- name: candidate_extraction_enabled
label:
en_US: Enable Memory Candidate Extraction
zh_Hans: 启用记忆候选提取
description:
en_US: Extract reviewable memory candidates after normal responses. Disabled by default to avoid memory pollution.
zh_Hans: 在普通回复后提取可审核的记忆候选。默认关闭以避免污染记忆。
type: boolean
required: false
default: false
- name: candidate_auto_apply
label:
en_US: Auto Apply Memory Candidates
zh_Hans: 自动应用记忆候选
description:
en_US: Automatically accept extracted candidates. Keep disabled unless you accept the risk of memory pollution.
zh_Hans: 自动接受提取出的候选。除非能接受记忆污染风险,否则保持关闭。
type: boolean
required: false
default: false
- name: candidate_max_per_turn
label:
en_US: Max Candidates Per Turn
zh_Hans: 单轮候选上限
description:
en_US: Maximum number of memory candidates created from one response turn.
zh_Hans: 单次回复最多创建的记忆候选数量。
type: integer
required: false
default: 3
retrieval_schema:
- name: top_k
label:
en_US: Top K
zh_Hans: 返回条数
description:
en_US: Number of results to return.
zh_Hans: 检索返回的结果数量。
type: integer
default: 5
execution:
python:
path: memory_engine.py
attr: LongTermMemoryEngine