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
152 changes: 121 additions & 31 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,19 @@ def __init__(self, default_client, fast_client=None):
self._default = default_client
self._fast = fast_client or default_client

async def chat(self, messages: list):
req = LLMRequest(messages=list(messages))
@staticmethod
def _to_request(messages):
"""Convert messages to LLMRequest if needed (idempotent)."""
if isinstance(messages, LLMRequest):
return messages
return LLMRequest(messages=list(messages))

async def chat(self, messages):
req = self._to_request(messages)
return await self._default.chat(req)

async def chat_fast(self, messages: list):
req = LLMRequest(messages=list(messages))
async def chat_fast(self, messages):
req = self._to_request(messages)
return await self._fast.chat(req)


Expand Down Expand Up @@ -125,6 +132,10 @@ def __init__(self, ctx, cfg: dict):
self._auto_migrate = bool(cfg.get("auto_migrate_legacy_db", True))
self._enable_decay = bool(cfg.get("enable_decay", True))

# Model selection for extraction (fast, cheap) vs reflection (strong, expensive)
self._extraction_model = cfg.get("extraction_model", None)
self._reflection_model = cfg.get("reflection_model", None)

# Which sections of the profile/memory to inject into system prompt
self._inject_profile = bool(cfg.get("inject_profile", True))
self._inject_facts = bool(cfg.get("inject_facts", True))
Expand Down Expand Up @@ -202,29 +213,64 @@ async def initialize(self):
except Exception as e:
logger.error(f"Hippocampus migration failed (non-fatal): {e}", exc_info=True)

# Wire in the host LLM as the hippocampus LLM client. The extractor
# expects `await client.chat(messages_list)` returning `.text_response`
# — KiraAI's LLMModelClient uses `.chat(LLMRequest)`, so we adapt.
try:
default_llm = self.ctx.get_default_llm_client()
except Exception as e:
default_llm = None
logger.warning(f"Could not resolve default LLM client: {e}")
try:
fast_llm = self.ctx.get_default_fast_llm_client()
except Exception:
fast_llm = None
# Wire LLM clients for hippocampus extraction (fast, cheap) vs reflection (slow, strong).
extraction_client = None
reflection_client = None

if default_llm is not None:
adapter = _MemoryLLMAdapter(default_llm, fast_llm)
self.memory_manager.set_llm_client(adapter)
# 1. Try user-configured extraction_model
if self._extraction_model:
try:
extraction_client = self.ctx.get_llm_client(model_uuid=self._extraction_model)
if extraction_client:
logger.info(f"Extraction model: {self._extraction_model}")
except Exception as e:
logger.warning(f"Failed to resolve extraction model '{self._extraction_model}': {e}")

# 2. Fall back to default fast LLM
if extraction_client is None:
try:
extraction_client = self.ctx.get_default_fast_llm_client()
logger.info("Extraction model: default fast LLM")
except Exception as e:
logger.warning(f"Could not resolve extraction LLM: {e}")
Comment on lines +230 to +235

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fall back to the normal model when no fast client exists

When the host has a working default LLM but no configured default-fast client, this leaves extraction_client as None while still wiring the reflection client. MemoryManager._hippocampus_process() then re-buffers every batch because extraction is mandatory, so automatic fact extraction never runs. The previous adapter explicitly fell back from a missing fast client to the normal default client; preserve that fallback here as well.

Useful? React with 👍 / 👎.


# 3. Try user-configured reflection_model
if self._reflection_model:
try:
reflection_client = self.ctx.get_llm_client(model_uuid=self._reflection_model)
if reflection_client:
logger.info(f"Reflection model: {self._reflection_model}")
except Exception as e:
logger.warning(f"Failed to resolve reflection model '{self._reflection_model}': {e}")

# 4. Fall back to default LLM
if reflection_client is None:
try:
reflection_client = self.ctx.get_default_llm_client()
logger.info("Reflection model: default LLM")
except Exception as e:
logger.warning(f"Could not resolve reflection LLM: {e}")

# 5. Extraction is mandatory for the hippocampus loop — without it every
# batch gets re-buffered forever. Fall back to the reflection client so a
# host with no fast LLM configured still extracts (matches the pre-split
# adapter, which fell back from a missing fast client to the default one).
if extraction_client is None and reflection_client is not None:
logger.warning("No fast LLM available — using reflection LLM for extraction")
extraction_client = reflection_client

if extraction_client or reflection_client:
# Wrap both in adapters (handles the LLMRequest-vs-list shape mismatch)
extraction_adapter = _MemoryLLMAdapter(extraction_client) if extraction_client else None
reflection_adapter = _MemoryLLMAdapter(reflection_client) if reflection_client else None
self.memory_manager.set_llm_clients(extraction_adapter, reflection_adapter)
logger.info(
f"Hippocampus LLM client wired (default={getattr(default_llm.model, 'model_id', '?')}, "
f"fast={getattr(fast_llm.model, 'model_id', '?') if fast_llm else 'same'})"
f"Hippocampus LLM clients wired (extraction={getattr(extraction_client.model, 'model_id', '?') if extraction_client else 'none'}, "
f"reflection={getattr(reflection_client.model, 'model_id', '?') if reflection_client else 'none'})"
)
else:
logger.warning(
"No default LLM client available — hippocampus will skip fact extraction "
"No LLM clients available — hippocampus will skip fact extraction "
"(memory_add/search/profile_* tools still work via TOML+FTS5)"
)

Expand All @@ -236,10 +282,7 @@ async def initialize(self):
# ── Discover & register skills ──────────────────────────────
skills = self.skill_router.discover()
any_with_resources = False
for skill in skills:
if skill.name in self._disabled_skills:
logger.info(f"Skill '{skill.name}' is disabled, skipping registration")
continue
for skill in self._filter_enabled_skills(skills):
self._register_skill_tool(skill)
if skill.has_resources():
any_with_resources = True
Expand Down Expand Up @@ -283,6 +326,54 @@ async def _disable_builtin_memory(self):
except Exception as e:
logger.warning(f"检查内置记忆插件状态时出错: {e}")

def _filter_enabled_skills(self, skills: list[SkillInfo]) -> list[SkillInfo]:
"""Skills that pass both the plugin denylist and the framework WebUI toggle.

Used by initial registration and hot reload alike — a skill disabled in the
WebUI must stay unregistered across reloads, not come back until restart.
"""
framework_enabled = self._get_framework_skill_toggles()
enabled = []
for skill in skills:
if skill.name in self._disabled_skills:
logger.info(f"Skill '{skill.name}' disabled by plugin config, skipping registration")
continue
if framework_enabled is not None and not framework_enabled.get(skill.name, True):
logger.info(f"Skill '{skill.name}' disabled in framework skills.json, skipping registration")
continue
enabled.append(skill)
return enabled

def _get_framework_skill_toggles(self) -> Optional[dict[str, bool]]:
"""Read framework skill enable/disable state from WebUI-managed skills.json.

Returns {skill_name: bool} or None if unavailable.
Tries ctx.message_processor.skills_manager first (runtime state), falls back to
parsing data/config/skills.json directly (file state).
"""
# Path 1: live SkillsManager instance (fastest, reflects runtime state)
try:
mgr = self.ctx.message_processor.skills_manager
if mgr is not None:
return mgr.get_skill_config_dict()
except AttributeError:
pass # message_processor or skills_manager not available yet

# Path 2: read the JSON file directly (works even if framework changed structure)
try:
from core.utils.path_utils import get_config_path
skills_json = get_config_path() / "skills.json"
if not skills_json.exists():
return None
import json
with open(skills_json, "r", encoding="utf-8") as f:
raw = json.load(f)
# Filter out reserved keys like "_scope" (same logic as SkillsManager._build_enabled_dict)
return {k: v for k, v in raw.items() if not k.startswith("_")}
except Exception as e:
logger.warning(f"Could not read framework skill toggles: {e}")
return None

async def terminate(self):
if self._webui_server:
try:
Expand Down Expand Up @@ -498,11 +589,10 @@ async def _reload_skills(self):

skills = self.skill_router.reload()
any_with_resources = False
for skill in skills:
if skill.name not in self._disabled_skills:
self._register_skill_tool(skill)
if skill.has_resources():
any_with_resources = True
for skill in self._filter_enabled_skills(skills):
self._register_skill_tool(skill)
if skill.has_resources():
any_with_resources = True
self._command_map = self.skill_router.get_commands(enabled_only=set(self._registered_skill_names))

if any_with_resources and not self._resource_tool_registered:
Expand Down
70 changes: 44 additions & 26 deletions memory/memory_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,30 @@ class MemoryExtractor:
def __init__(
self,
tree_store: TomlTreeStore,
llm_client=None,
extraction_client=None,
reflection_client=None,
*,
llm_client=None, # Backward compatibility
Comment on lines +57 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve positional constructor compatibility

Existing callers can pass the former llm_client as MemoryExtractor(store, client). After this signature change, that argument binds only to extraction_client, leaving _reflection_client unset and silently disabling generate_reflections(). The new keyword-only compatibility argument does not help those valid positional calls, so the legacy second positional argument should continue to initialize both clients.

Useful? React with 👍 / 👎.

llm_chat_timeout: float = _DEFAULT_LLM_CHAT_TIMEOUT,
):
self.tree_store = tree_store
self.index: MemoryIndex = tree_store.index
# 每条 LLM 调用的超时;超过即被当成失败、走空提取兜底(不会丢 chunks,
# 上游的 hippocampus_process 会 re-buffer 回 pending 等下次触发)。
self.llm_chat_timeout: float = float(llm_chat_timeout)
self._llm_client = llm_client
self._fast_llm_client = None # 轻量模型,用于去重/合并等低复杂度任务

# Backward compatibility: the pre-split signature was
# `MemoryExtractor(tree_store, llm_client)`, where one client served both
# roles. A lone second argument (positional or via the llm_client kwarg)
# keeps that meaning — otherwise reflection would silently go unwired.
if llm_client is not None:
extraction_client = extraction_client or llm_client
reflection_client = reflection_client or llm_client
elif extraction_client is not None and reflection_client is None:
reflection_client = extraction_client

self._extraction_client = extraction_client # for extract_*, _check_conflict, merge_facts
self._reflection_client = reflection_client # for generate_reflections, profile compact (future)
Comment on lines +79 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the client attribute used by memory_add

When memory_add classifies a semantically related entry as an update, tools.py still evaluates extractor._llm_client before calling merge_facts(). This change removes that attribute in favor of _extraction_client, so the update path raises AttributeError, is caught by the tool's outer handler, and returns Failed to add memory without persisting the new information. Update that caller or retain a compatibility alias.

Useful? React with 👍 / 👎.


# 升维阈值:facts 积累达到此数量时触发反思
self.reflection_threshold = 5
Expand All @@ -83,17 +96,22 @@ def _truncate_conversation(cls, text: str) -> str:
# 避免模型把开头不完整的句子当成完整事实。
return "[…earlier conversation truncated…]\n" + text[-cls.MAX_CONVERSATION_CHARS:]

def set_llm_client(self, llm_client):
self._llm_client = llm_client
def set_extraction_client(self, client):
"""Set the LLM client for extraction, dedup, and merge operations."""
self._extraction_client = client

def set_reflection_client(self, client):
"""Set the LLM client for reflection generation and profile compaction."""
self._reflection_client = client

def set_fast_llm_client(self, fast_llm_client):
"""设置轻量 LLM 客户端,用于去重/合并(回退到 _llm_client)"""
self._fast_llm_client = fast_llm_client
def set_llm_client(self, client):
"""Backward compatibility: set both extraction and reflection to the same client."""
self._extraction_client = client
self._reflection_client = client

@property
def _fast_or_default(self):
"""获取快速 LLM 客户端,未设置则回退到主 LLM"""
return self._fast_llm_client or self._llm_client
def set_fast_llm_client(self, client):
"""Backward compatibility: set extraction client (fast operations)."""
self._extraction_client = client

# ==========================================
# 事实提取(双路径)
Expand All @@ -109,7 +127,7 @@ async def extract_personal_facts(self, conversation_text: str) -> list[dict]:
[{"content": "...", "importance": 7, "tags": [...],
"speaker_id": "12345", "subject": "昵称", "semantic_id": "..."}, ...]
"""
if not self._llm_client:
if not self._extraction_client:
return []
conversation_text = self._truncate_conversation(conversation_text)

Expand Down Expand Up @@ -140,7 +158,7 @@ async def extract_personal_facts(self, conversation_text: str) -> list[dict]:

try:
resp = await asyncio.wait_for(
chat_text(self._llm_client, prompt),
chat_text(self._extraction_client, prompt),
timeout=self.llm_chat_timeout
)
if resp:
Expand All @@ -162,7 +180,7 @@ async def extract_group_facts(self, conversation_text: str) -> list[dict]:
[{"content": "...", "importance": 7, "tags": [...],
"subject": "group", "semantic_id": "..."}, ...]
"""
if not self._llm_client:
if not self._extraction_client:
return []
conversation_text = self._truncate_conversation(conversation_text)

Expand Down Expand Up @@ -194,7 +212,7 @@ async def extract_group_facts(self, conversation_text: str) -> list[dict]:

try:
resp = await asyncio.wait_for(
chat_text(self._llm_client, prompt),
chat_text(self._extraction_client, prompt),
timeout=self.llm_chat_timeout
)
if resp:
Expand All @@ -211,7 +229,7 @@ async def extract_facts(self, conversation_text: str) -> list[dict]:

私聊场景只有一个用户,不需要双路径,走单次提取即可。
"""
if not self._llm_client:
if not self._extraction_client:
return []
conversation_text = self._truncate_conversation(conversation_text)

Expand All @@ -235,7 +253,7 @@ async def extract_facts(self, conversation_text: str) -> list[dict]:

try:
resp = await asyncio.wait_for(
chat_text(self._llm_client, prompt),
chat_text(self._extraction_client, prompt),
timeout=self.llm_chat_timeout
)
if resp:
Expand Down Expand Up @@ -267,7 +285,7 @@ async def extract_self_awareness(
Returns:
觉察文本列表(通常 0-2 条,大部分情况为空)
"""
if not self._llm_client:
if not self._extraction_client:
return []
conversation_text = self._truncate_conversation(conversation_text)

Expand Down Expand Up @@ -297,7 +315,7 @@ async def extract_self_awareness(

try:
resp = await asyncio.wait_for(
chat_text(self._llm_client, prompt),
chat_text(self._extraction_client, prompt),
timeout=self.llm_chat_timeout
)
if resp:
Expand Down Expand Up @@ -338,7 +356,7 @@ async def generate_semantic_id(self, content: str) -> str:

回退策略:文本前缀 + hash
"""
if not self._llm_client:
if not self._extraction_client:
return ""

prompt = f"""为以下记忆内容生成一个简短的 snake_case 文件名标识符(英文,无空格,不超过 30 字符)。
Expand All @@ -350,7 +368,7 @@ async def generate_semantic_id(self, content: str) -> str:

try:
resp = await asyncio.wait_for(
chat_text(self._llm_client, prompt),
chat_text(self._extraction_client, prompt),
timeout=self.llm_chat_timeout
)
if resp:
Expand Down Expand Up @@ -419,7 +437,7 @@ async def deduplicate(

async def _check_conflict(self, new_content: str, existing_content: str) -> str:
"""用 LLM 判断新旧记忆的关系(使用快速模型)"""
client = self._fast_or_default
client = self._extraction_client
if not client:
return "new"

Expand Down Expand Up @@ -454,7 +472,7 @@ async def _check_conflict(self, new_content: str, existing_content: str) -> str:

async def merge_facts(self, existing_text: str, new_text: str) -> str:
"""LLM 合并两条事实为一条(使用快速模型)"""
client = self._fast_or_default
client = self._extraction_client
if not client:
return f"{existing_text};{new_text}"

Expand Down Expand Up @@ -567,7 +585,7 @@ async def generate_reflections(
Returns:
生成的 reflection 文本列表
"""
if not self._llm_client:
if not self._reflection_client:
return []

facts = await self.tree_store.get_all_memories(
Expand Down Expand Up @@ -609,7 +627,7 @@ async def generate_reflections(
generated = []
try:
resp = await asyncio.wait_for(
chat_text(self._llm_client, prompt),
chat_text(self._reflection_client, prompt),
timeout=self.llm_chat_timeout
)
if not resp:
Expand Down
Loading
Loading