Просмотр исходного кода

Merge branch 'main' of https://git.yishihui.com/howard/Agent

guantao 1 месяц назад
Родитель
Сommit
76ce9549c5
48 измененных файлов с 7334 добавлено и 1158 удалено
  1. 267 0
      agent/cli/extraction_review.py
  2. 15 1
      agent/cli/interactive.py
  3. 393 0
      agent/core/dream.py
  4. 100 0
      agent/core/memory.py
  5. 59 129
      agent/core/prompts/knowledge.py
  6. 79 1
      agent/core/runner.py
  7. 0 416
      agent/docs/memory-plan.md
  8. 620 0
      agent/docs/memory.md
  9. 6 1
      agent/tools/builtin/__init__.py
  10. 5 4
      agent/tools/builtin/content/cache.py
  11. 115 0
      agent/tools/builtin/knowledge.py
  12. 96 0
      agent/tools/builtin/memory.py
  13. 234 0
      agent/trace/extraction_review.py
  14. 6 0
      agent/trace/models.py
  15. 127 0
      agent/trace/run_api.py
  16. 31 1
      agent/trace/store.py
  17. 146 0
      knowhub/docs/db-operations.md
  18. 91 0
      knowhub/docs/decisions.md
  19. 2 0
      knowhub/docs/schema-migration-plan.md
  20. 117 23
      knowhub/docs/schema.md
  21. 52 30
      knowhub/frontend/src/components/common/SideDrawer.tsx
  22. 144 73
      knowhub/frontend/src/components/dashboard/CategoryTree.tsx
  23. 7 3
      knowhub/frontend/src/layouts/MainLayout.tsx
  24. 792 134
      knowhub/frontend/src/pages/Dashboard.tsx
  25. 23 1
      knowhub/frontend/src/services/api.ts
  26. 213 221
      knowhub/frontend/yarn.lock
  27. 65 2
      knowhub/knowhub_db/README.md
  28. 42 0
      knowhub/knowhub_db/cascade.py
  29. 216 0
      knowhub/knowhub_db/migrations/migrate_v4_strategy_and_relation_types.py
  30. 154 0
      knowhub/knowhub_db/migrations/migrate_v5_version_and_images.py
  31. 68 11
      knowhub/knowhub_db/pg_capability_store.py
  32. 111 24
      knowhub/knowhub_db/pg_requirement_store.py
  33. 37 20
      knowhub/knowhub_db/pg_resource_store.py
  34. 142 46
      knowhub/knowhub_db/pg_store.py
  35. 348 0
      knowhub/knowhub_db/pg_strategy_store.py
  36. 42 15
      knowhub/knowhub_db/pg_tool_store.py
  37. 275 0
      knowhub/scripts/ingest_research_output.py
  38. 253 2
      knowhub/server.py
  39. 72 0
      skills4claude/README.md
  40. 71 0
      skills4claude/agent/SKILL.md
  41. 149 0
      skills4claude/agent/config.py
  42. 57 0
      skills4claude/agent/invoke.py
  43. 59 0
      skills4claude/content-search/SKILL.md
  44. 135 0
      skills4claude/install.sh
  45. 83 0
      skills4claude/knowhub/SKILL.md
  46. 366 0
      skills4claude/knowhub/knowhub.py
  47. 86 0
      skills4claude/toolhub/SKILL.md
  48. 763 0
      skills4claude/toolhub/toolhub.py

+ 267 - 0
agent/cli/extraction_review.py

@@ -0,0 +1,267 @@
+"""
+提取审核交互式 CLI
+
+用途
+----
+反思侧分支产出的知识条目默认写为 cognition_log: type="extraction_pending",
+不会直接上传到 KnowHub。本 CLI 提供人工审核 + 批量提交入口。
+
+两种入口(共享同一核心逻辑,见 agent/trace/extraction_review.py):
+- 独立脚本:python -m agent.cli.extraction_review --trace <TRACE_ID> [--list|--review|--commit]
+- interactive.py 菜单项 8/9(见 agent/cli/interactive.py)
+
+用法示例
+--------
+# 查看当前 trace 的所有未审核条目
+python -m agent.cli.extraction_review --trace abc-123 --list
+
+# 交互式逐条审核
+python -m agent.cli.extraction_review --trace abc-123 --review
+
+# 把已 approved 的条目批量提交到 KnowHub
+python -m agent.cli.extraction_review --trace abc-123 --commit
+
+# 一条龙:review 完直接 commit
+python -m agent.cli.extraction_review --trace abc-123
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import json
+import sys
+from pathlib import Path
+from typing import List, Optional
+
+from agent.trace.store import FileSystemTraceStore
+from agent.trace.extraction_review import (
+    PendingExtraction,
+    CommitReport,
+    list_pending,
+    review_one,
+    commit_approved,
+)
+
+
+# ===== 打印工具 =====
+
+_SEP = "─" * 60
+
+
+def _format_payload(payload: dict, max_content: int = 400) -> str:
+    task = payload.get("task", "")
+    content = payload.get("content", "")
+    types = payload.get("types", [])
+    tags = payload.get("tags", {})
+    score = payload.get("score", 0)
+    resource_ids = payload.get("resource_ids", [])
+
+    if len(content) > max_content:
+        content = content[:max_content] + "…(truncated)"
+
+    lines = [
+        f"task:  {task}",
+        f"types: {types}   score: {score}",
+    ]
+    if tags:
+        lines.append(f"tags:  {tags}")
+    if resource_ids:
+        lines.append(f"resources: {resource_ids}")
+    lines.append("")
+    lines.append(content)
+    return "\n".join(lines)
+
+
+def _print_pending(p: PendingExtraction, index: int, total: int) -> None:
+    state = ""
+    if p.committed:
+        state = " [已提交]"
+    elif p.reviewed:
+        state = f" [已审核: {p.decision}]"
+    print()
+    print(f"[{index}/{total}] {p.extraction_id}{state}")
+    print(_SEP)
+    print(_format_payload(p.payload))
+    print(_SEP)
+
+
+def _print_report(report: CommitReport) -> None:
+    print()
+    print("=" * 60)
+    print("提交结果")
+    print("=" * 60)
+    print(f"✅ 成功: {len(report.committed)}")
+    for eid, kid in zip(report.committed, report.knowledge_ids):
+        print(f"   - {eid} → knowledge_id={kid}")
+    if report.failed:
+        print(f"❌ 失败: {len(report.failed)}")
+        for item in report.failed:
+            print(f"   - {item['extraction_id']}: {item['error']}")
+    if report.skipped:
+        print(f"⏭  跳过: {len(report.skipped)}(未 approved 或已提交)")
+    print("=" * 60)
+
+
+# ===== 交互式编辑 =====
+
+def _prompt_edit(payload: dict) -> Optional[dict]:
+    """进入交互式文本编辑模式,返回修改后的 payload(None 表示取消)。
+
+    初版只支持改 task/content/score/tags(最常用字段)。
+    """
+    print("\n编辑模式(空行回车保留原值)")
+    task = input(f"task   [{payload.get('task', '')[:50]}]: ").strip()
+    content_default = payload.get("content", "")
+    print(f"content 当前:\n{content_default}\n")
+    print("输入新 content(单行回车保留原值;多行请在末尾输入 `.` 单独成行结束):")
+    content = _read_multiline_or_keep(content_default)
+    score_raw = input(f"score  [{payload.get('score', 3)}]: ").strip()
+    tags_raw = input(f"tags JSON  [{json.dumps(payload.get('tags', {}), ensure_ascii=False)}]: ").strip()
+
+    new_payload = dict(payload)
+    if task:
+        new_payload["task"] = task
+    if content is not None:
+        new_payload["content"] = content
+    if score_raw:
+        try:
+            new_payload["score"] = int(score_raw)
+        except ValueError:
+            print(f"⚠ score 不是整数,保留原值 {payload.get('score', 3)}")
+    if tags_raw:
+        try:
+            new_payload["tags"] = json.loads(tags_raw)
+        except json.JSONDecodeError as e:
+            print(f"⚠ tags 不是合法 JSON({e}),保留原值")
+
+    confirm = input("\n保存修改?[y/N]: ").strip().lower()
+    if confirm != "y":
+        return None
+    return new_payload
+
+
+def _read_multiline_or_keep(default: str) -> Optional[str]:
+    """单行输入则直接返回(空行表示保留默认);
+    如果输入 `<<` 则进入多行模式,直到 `.` 单独成行结束。"""
+    first = input("> ")
+    if not first.strip():
+        return None
+    if first.strip() != "<<":
+        return first
+    lines = []
+    while True:
+        line = input()
+        if line.strip() == ".":
+            break
+        lines.append(line)
+    return "\n".join(lines)
+
+
+# ===== 三种命令 =====
+
+async def cmd_list(store: FileSystemTraceStore, trace_id: str, show_all: bool) -> int:
+    pendings = await list_pending(store, trace_id, include_reviewed=show_all)
+    if not pendings:
+        msg = "没有" + ("任何提取记录" if show_all else "待审核的提取条目")
+        print(f"trace {trace_id}: {msg}")
+        return 0
+    print(f"trace {trace_id}: 共 {len(pendings)} 条{'' if show_all else '待审核'}")
+    for i, p in enumerate(pendings, 1):
+        _print_pending(p, i, len(pendings))
+    return 0
+
+
+async def cmd_review(store: FileSystemTraceStore, trace_id: str) -> int:
+    pendings = await list_pending(store, trace_id, include_reviewed=False)
+    if not pendings:
+        print(f"trace {trace_id}: 没有待审核的提取条目")
+        return 0
+
+    print(f"trace {trace_id}: 开始审核 {len(pendings)} 条")
+    for i, p in enumerate(pendings, 1):
+        _print_pending(p, i, len(pendings))
+        while True:
+            choice = input("[a]pprove / [e]dit / [d]iscard / [s]kip / [q]uit: ").strip().lower()
+            if choice in ("a", "approve"):
+                await review_one(store, trace_id, p.extraction_id, "approve")
+                print(f"✓ {p.extraction_id} approved")
+                break
+            elif choice in ("d", "discard"):
+                await review_one(store, trace_id, p.extraction_id, "discard")
+                print(f"✗ {p.extraction_id} discarded")
+                break
+            elif choice in ("s", "skip"):
+                print(f"⏭ {p.extraction_id} skipped(保留为 pending)")
+                break
+            elif choice in ("q", "quit"):
+                print("退出审核")
+                return 0
+            elif choice in ("e", "edit"):
+                edited = _prompt_edit(p.payload)
+                if edited is None:
+                    print("取消编辑,请重选")
+                    continue
+                await review_one(store, trace_id, p.extraction_id, "edit", edited_payload=edited)
+                print(f"✎ {p.extraction_id} edited & approved")
+                break
+            else:
+                print("无效选项,请输入 a/e/d/s/q")
+    return 0
+
+
+async def cmd_commit(store: FileSystemTraceStore, trace_id: str) -> int:
+    report = await commit_approved(store, trace_id)
+    _print_report(report)
+    return 0 if not report.failed else 1
+
+
+# ===== argparse 入口 =====
+
+def build_parser() -> argparse.ArgumentParser:
+    p = argparse.ArgumentParser(
+        prog="python -m agent.cli.extraction_review",
+        description="审核并提交反思侧分支暂存的待审核知识条目。",
+    )
+    p.add_argument("--trace", required=True, help="Trace ID")
+    p.add_argument("--base-path", default=".trace", help="TraceStore 根目录(默认 .trace)")
+    group = p.add_mutually_exclusive_group()
+    group.add_argument("--list", action="store_true", help="仅列出未审核条目")
+    group.add_argument("--list-all", action="store_true", help="列出全部条目(含已审核/已提交)")
+    group.add_argument("--review", action="store_true", help="进入交互式审核(不自动 commit)")
+    group.add_argument("--commit", action="store_true", help="仅批量提交已 approved 的条目")
+    return p
+
+
+async def _main_async(args: argparse.Namespace) -> int:
+    if not Path(args.base_path).exists():
+        print(f"❌ TraceStore 根目录不存在: {args.base_path}", file=sys.stderr)
+        return 2
+    store = FileSystemTraceStore(base_path=args.base_path)
+
+    if args.list or args.list_all:
+        return await cmd_list(store, args.trace, show_all=args.list_all)
+    if args.review:
+        return await cmd_review(store, args.trace)
+    if args.commit:
+        return await cmd_commit(store, args.trace)
+
+    # 默认:review 完紧接着 commit
+    rc = await cmd_review(store, args.trace)
+    if rc != 0:
+        return rc
+    print()
+    confirm = input("现在把已 approved 的条目提交到 KnowHub?[Y/n]: ").strip().lower()
+    if confirm in ("", "y", "yes"):
+        return await cmd_commit(store, args.trace)
+    print("未提交。需要时运行 `--commit` 子命令。")
+    return 0
+
+
+def main() -> int:
+    args = build_parser().parse_args()
+    return asyncio.run(_main_async(args))
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 15 - 1
agent/cli/interactive.py

@@ -177,10 +177,12 @@ class InteractiveController:
         print("  5. 从指定消息续跑")
         print("  6. 继续执行")
         print("  7. 停止执行")
+        print("  8. 审核待提交知识(review pending extractions)")
+        print("  9. 提交已审核知识到 KnowHub(commit approved)")
         print("=" * 60)
 
         while True:
-            choice = input("请输入选项 (1-7): ").strip()
+            choice = input("请输入选项 (1-9): ").strip()
 
             if choice == "1":
                 # 插入干预消息
@@ -237,6 +239,18 @@ class InteractiveController:
                 print("\n停止执行...")
                 return {"action": "stop"}
 
+            elif choice == "8":
+                # 审核待提交知识(复用 agent/cli/extraction_review.py 的交互式 review)
+                from agent.cli.extraction_review import cmd_review
+                await cmd_review(self.store, trace_id)
+                continue
+
+            elif choice == "9":
+                # 提交已审核知识到 KnowHub
+                from agent.cli.extraction_review import cmd_commit
+                await cmd_commit(self.store, trace_id)
+                continue
+
             else:
                 print("无效选项,请重新输入")
 

+ 393 - 0
agent/core/dream.py

@@ -0,0 +1,393 @@
+"""
+Dream:记忆反思操作(Phase 3)
+
+两阶段执行:
+    per_trace_reflect    → 为每个有新消息的 trace 生成反思摘要,写 cognition_log
+    cross_trace_integrate → 汇总各 trace 的反思摘要 + 当前记忆文件,
+                             用 dream_prompt 指导 LLM 更新记忆文件
+
+对外入口:
+    run_dream(store, llm_call, memory_config, trace_filter=None, model=...)
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+from dataclasses import dataclass, field
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
+
+from agent.core.memory import MemoryConfig, load_memory_files, format_memory_injection
+from agent.trace.models import Trace
+from agent.trace.store import FileSystemTraceStore
+
+logger = logging.getLogger(__name__)
+
+
+# ===== 默认 prompts =====
+
+DEFAULT_REFLECT_PROMPT = """你正在回顾一次 Agent 执行中发生的事情,为你自己(作为长期身份)的记忆做反思。
+
+请综合下面的执行过程和知识使用情况,回答:
+1. 这次执行中有什么值得记住的经验?(品味、判断、策略)
+2. 哪些知识的评估反映了我的判断需要调整?
+3. 用户的反馈(如果有)说明了什么?
+
+用简洁的第一人称段落写,不要逐条列点,不要重复执行细节 —— 你在沉淀"这对未来的我意味着什么"。
+只输出反思内容本身,不要任何其它前缀或 markdown 标题。"""
+
+
+DEFAULT_DREAM_PROMPT = """你正在整理自己的长期记忆。下面是你最近的反思摘要、以及当前各记忆文件的内容。
+
+请决定哪些文件应该更新、内容怎么改。原则:
+- 只更新真正有新见解的文件,没有变化的就不要动
+- 在原有内容基础上演进,不是重写;保留仍然有效的旧内容
+- 简洁、人类可读的 markdown 格式
+- 新增文件必须是 MemoryConfig.files 已声明的路径(否则不会被下次加载)
+
+**严格按以下 JSON 格式输出,不要任何其它文字**:
+
+```json
+{
+  "updates": [
+    {"path": "taste.md", "new_content": "完整的新文件内容"},
+    {"path": "strategy.md", "new_content": "..."}
+  ],
+  "reasoning": "你为什么做这些更新(简短)"
+}
+```
+
+如果没有任何文件需要更新,输出 `{"updates": [], "reasoning": "..."}`。"""
+
+
+# ===== 数据结构 =====
+
+@dataclass
+class DreamReport:
+    per_trace_summaries: Dict[str, str] = field(default_factory=dict)  # {trace_id: summary}
+    updated_files: List[str] = field(default_factory=list)             # 实际写入的文件路径
+    consumed_reflection_count: int = 0                                  # 本次消化了多少条 reflection
+    reasoning: str = ""
+    skipped_traces: List[str] = field(default_factory=list)
+
+
+LLMCall = Callable[..., Awaitable[Dict[str, Any]]]
+
+
+# ===== Per-trace 反思 =====
+
+async def per_trace_reflect(
+    store: FileSystemTraceStore,
+    llm_call: LLMCall,
+    trace_id: str,
+    memory_config: MemoryConfig,
+    model: str = "gpt-4o-mini",
+) -> Optional[str]:
+    """为单个 trace 生成反思摘要,写入 cognition_log,更新 reflected_at_sequence。
+
+    Returns:
+        反思摘要字符串;若 trace 没有新消息或 LLM 返回空,返回 None。
+    """
+    trace = await store.get_trace(trace_id)
+    if not trace:
+        logger.debug(f"[Dream] trace 不存在: {trace_id}")
+        return None
+
+    start_seq = (trace.reflected_at_sequence or 0) + 1
+    end_seq = trace.last_sequence
+    if start_seq > end_seq:
+        logger.debug(f"[Dream] trace {trace_id} 没有新消息({start_seq} > {end_seq})")
+        return None
+
+    all_msgs = await store.get_trace_messages(trace_id)
+    new_msgs = [m for m in all_msgs if start_seq <= m.sequence <= end_seq]
+    if not new_msgs:
+        logger.debug(f"[Dream] trace {trace_id} 范围内无消息")
+        return None
+
+    log = await store.get_cognition_log(trace_id)
+    events = log.get("events", log.get("entries", []))
+    relevant_events = [
+        e for e in events
+        if e.get("sequence") is not None
+        and start_seq <= e["sequence"] <= end_seq
+        and e.get("type") in ("query", "evaluation", "extraction_pending", "extraction_committed")
+    ]
+
+    user_content = _build_reflect_input(new_msgs, relevant_events)
+    prompt = memory_config.reflect_prompt or DEFAULT_REFLECT_PROMPT
+
+    try:
+        result = await llm_call(
+            messages=[
+                {"role": "system", "content": prompt},
+                {"role": "user", "content": user_content},
+            ],
+            model=model,
+            tools=None,
+            temperature=0.5,
+        )
+    except Exception as e:
+        logger.error(f"[Dream] per_trace_reflect LLM 调用失败 {trace_id}: {e}")
+        return None
+
+    summary = (result.get("content") or "").strip()
+    if not summary:
+        logger.info(f"[Dream] trace {trace_id} 反思 LLM 返回空,视为无值得记录的内容")
+        # 仍然更新 reflected_at_sequence,避免下次重复扫描
+        await store.update_trace(trace_id, reflected_at_sequence=end_seq)
+        return None
+
+    await store.append_cognition_event(
+        trace_id=trace_id,
+        event={
+            "type": "reflection",
+            "sequence_range": [start_seq, end_seq],
+            "summary": summary,
+        },
+    )
+    await store.update_trace(trace_id, reflected_at_sequence=end_seq)
+    logger.info(f"[Dream] trace {trace_id} 反思完成,覆盖 sequence {start_seq}-{end_seq}")
+    return summary
+
+
+def _build_reflect_input(messages: List[Any], events: List[Dict[str, Any]]) -> str:
+    """把消息和事件组织为 LLM 可读的反思输入。"""
+    parts: List[str] = ["## 执行过程"]
+    for m in messages:
+        role = getattr(m, "role", "?")
+        desc = getattr(m, "description", "") or ""
+        seq = getattr(m, "sequence", "?")
+        # 截断,防止单条过长
+        parts.append(f"[{seq}] {role}: {desc[:500]}")
+
+    if events:
+        parts.append("\n## 知识使用与提取情况(来自 cognition_log)")
+        for e in events:
+            etype = e.get("type")
+            if etype == "query":
+                parts.append(
+                    f"- [{e.get('sequence')}] query: {e.get('query', '')[:100]} → "
+                    f"source_ids={e.get('source_ids', [])}"
+                )
+            elif etype == "evaluation":
+                parts.append(
+                    f"- evaluation: knowledge_id={e.get('knowledge_id')} "
+                    f"result={e.get('eval_result')}"
+                )
+            elif etype == "extraction_pending":
+                payload = e.get("payload", {})
+                parts.append(
+                    f"- extraction_pending ({e.get('extraction_id')}): "
+                    f"{payload.get('task', '')[:80]}"
+                )
+            elif etype == "extraction_committed":
+                parts.append(
+                    f"- extraction_committed: extraction={e.get('extraction_id')} "
+                    f"→ knowledge_id={e.get('knowledge_id')}"
+                )
+    return "\n".join(parts)
+
+
+# ===== 跨 trace 整合 =====
+
+async def cross_trace_integrate(
+    store: FileSystemTraceStore,
+    llm_call: LLMCall,
+    memory_config: MemoryConfig,
+    trace_filter: Optional[Callable[[Trace], bool]] = None,
+    model: str = "gpt-4o",
+) -> Tuple[int, List[str], str]:
+    """汇总各 trace 未消化的 reflection 事件,用 LLM 更新记忆文件。
+
+    Args:
+        trace_filter: 可选的 trace 过滤函数(例如按 agent_type / owner);
+                      None 表示扫描 TraceStore 下所有 trace。
+
+    Returns:
+        (consumed_reflection_count, updated_file_paths, reasoning)
+    """
+    all_traces = await store.list_traces(limit=1000)
+    if trace_filter:
+        all_traces = [t for t in all_traces if trace_filter(t)]
+
+    # 收集所有未消化的 reflection 事件
+    reflections: List[Tuple[str, Dict[str, Any]]] = []  # [(trace_id, event)]
+    for t in all_traces:
+        log = await store.get_cognition_log(t.trace_id)
+        events = log.get("events", log.get("entries", []))
+        for e in events:
+            if e.get("type") == "reflection" and not e.get("consumed_at"):
+                reflections.append((t.trace_id, e))
+
+    if not reflections:
+        logger.info("[Dream] 没有未消化的 reflection 事件")
+        return 0, [], ""
+
+    # 读当前记忆文件
+    existing_files = load_memory_files(memory_config)
+    existing_by_path = {rel: (purpose, content) for rel, purpose, content in existing_files}
+
+    user_content = _build_dream_input(reflections, existing_files, memory_config)
+    prompt = memory_config.dream_prompt or DEFAULT_DREAM_PROMPT
+
+    try:
+        result = await llm_call(
+            messages=[
+                {"role": "system", "content": prompt},
+                {"role": "user", "content": user_content},
+            ],
+            model=model,
+            tools=None,
+            temperature=0.3,
+        )
+    except Exception as e:
+        logger.error(f"[Dream] cross_trace_integrate LLM 调用失败: {e}")
+        return 0, [], ""
+
+    raw = (result.get("content") or "").strip()
+    plan = _parse_dream_output(raw)
+    if plan is None:
+        logger.error(f"[Dream] LLM 输出无法解析为 JSON 计划,原文: {raw[:500]}")
+        return 0, [], ""
+
+    updated_paths: List[str] = []
+    base = Path(memory_config.base_path)
+
+    for update in plan.get("updates", []):
+        rel_path = update.get("path", "")
+        new_content = update.get("new_content", "")
+        if not rel_path:
+            continue
+        # 安全检查:禁止路径穿越
+        target = (base / rel_path).resolve()
+        if not str(target).startswith(str(base.resolve())):
+            logger.warning(f"[Dream] 拒绝写入 base_path 之外的路径: {rel_path}")
+            continue
+        target.parent.mkdir(parents=True, exist_ok=True)
+        target.write_text(new_content, encoding="utf-8")
+        updated_paths.append(rel_path)
+        logger.info(f"[Dream] 已更新记忆文件: {rel_path} ({len(new_content)} chars)")
+
+    # 标记所有参与的 reflection 为已消化
+    consumed_at = datetime.now().isoformat()
+    for trace_id, event in reflections:
+        log = await store.get_cognition_log(trace_id)
+        events = log.get("events", log.get("entries", []))
+        target_ts = event.get("timestamp")
+        for e in events:
+            if (
+                e.get("type") == "reflection"
+                and not e.get("consumed_at")
+                and e.get("timestamp") == target_ts
+            ):
+                e["consumed_at"] = consumed_at
+        log_file = store._get_cognition_log_file(trace_id)
+        log_file.write_text(json.dumps(log, indent=2, ensure_ascii=False), encoding="utf-8")
+
+    reasoning = plan.get("reasoning", "")
+    return len(reflections), updated_paths, reasoning
+
+
+def _build_dream_input(
+    reflections: List[Tuple[str, Dict[str, Any]]],
+    existing_files: List[Tuple[str, str, str]],
+    memory_config: MemoryConfig,
+) -> str:
+    """为 dream prompt 准备输入:反思摘要汇总 + 当前记忆文件 + 允许的文件路径。"""
+    parts: List[str] = ["## 最近的反思摘要\n"]
+    for trace_id, e in reflections:
+        seq_range = e.get("sequence_range", [None, None])
+        parts.append(
+            f"### trace {trace_id} (messages {seq_range[0]}-{seq_range[1]})\n"
+            f"{e.get('summary', '')}\n"
+        )
+
+    parts.append("\n## 当前记忆文件\n")
+    if existing_files:
+        parts.append(format_memory_injection(existing_files))
+    else:
+        parts.append("(暂无记忆文件)")
+
+    if memory_config.files:
+        parts.append("\n## 允许更新/新增的文件路径\n")
+        for key, purpose in memory_config.files.items():
+            parts.append(f"- `{key}`" + (f" — {purpose}" if purpose else ""))
+
+    return "\n".join(parts)
+
+
+def _parse_dream_output(raw: str) -> Optional[Dict[str, Any]]:
+    """解析 LLM 的 JSON 计划输出。容忍 ```json ... ``` 包裹。"""
+    stripped = raw.strip()
+    # 去除 markdown 代码块包裹
+    m = re.match(r"^```(?:json)?\s*(.*?)\s*```$", stripped, re.DOTALL)
+    if m:
+        stripped = m.group(1).strip()
+    try:
+        data = json.loads(stripped)
+    except json.JSONDecodeError:
+        return None
+    if not isinstance(data, dict) or "updates" not in data:
+        return None
+    return data
+
+
+# ===== 顶层入口 =====
+
+async def run_dream(
+    store: FileSystemTraceStore,
+    llm_call: LLMCall,
+    memory_config: MemoryConfig,
+    trace_filter: Optional[Callable[[Trace], bool]] = None,
+    reflect_model: str = "gpt-4o-mini",
+    dream_model: str = "gpt-4o",
+) -> DreamReport:
+    """执行完整的 dream 流程:per_trace_reflect → cross_trace_integrate。
+
+    Args:
+        trace_filter: 筛选需要反思的 trace(例如按 agent_type 或 owner);
+                      None 表示扫描所有 trace
+        reflect_model: per-trace 反思用的模型(轻量模型即可)
+        dream_model:   跨 trace 整合用的模型(需要更强推理能力)
+    """
+    report = DreamReport()
+
+    if not memory_config.base_path:
+        logger.warning("[Dream] memory_config.base_path 未配置,跳过")
+        return report
+
+    # Phase 1: per-trace reflect
+    all_traces = await store.list_traces(limit=1000)
+    if trace_filter:
+        all_traces = [t for t in all_traces if trace_filter(t)]
+
+    for t in all_traces:
+        if (t.reflected_at_sequence or 0) >= t.last_sequence:
+            continue
+        try:
+            summary = await per_trace_reflect(
+                store, llm_call, t.trace_id, memory_config, model=reflect_model,
+            )
+            if summary:
+                report.per_trace_summaries[t.trace_id] = summary
+        except Exception as e:
+            logger.error(f"[Dream] per_trace_reflect 异常 {t.trace_id}: {e}")
+            report.skipped_traces.append(t.trace_id)
+
+    # Phase 2: cross-trace integrate
+    try:
+        consumed, updated, reasoning = await cross_trace_integrate(
+            store, llm_call, memory_config,
+            trace_filter=trace_filter, model=dream_model,
+        )
+        report.consumed_reflection_count = consumed
+        report.updated_files = updated
+        report.reasoning = reasoning
+    except Exception as e:
+        logger.error(f"[Dream] cross_trace_integrate 异常: {e}")
+
+    return report

+ 100 - 0
agent/core/memory.py

@@ -0,0 +1,100 @@
+"""
+Memory 系统(Phase 2+)
+
+详见 agent/docs/memory-plan.md。核心概念:
+- Memory:Agent 身份私有的主观记忆,Markdown 文件,人类可读写
+- Dream:记忆反思操作(回顾多个 trace 的执行历史,更新记忆文件)
+
+本模块只提供 MemoryConfig 数据类和记忆文件加载逻辑。
+Dream 操作在 agent/core/dream.py(Phase 3)。
+"""
+
+from __future__ import annotations
+
+import glob as _glob
+import logging
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Dict, List, Optional, Tuple
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class MemoryConfig:
+    """持久化记忆配置(见 agent/docs/memory-plan.md 第五节)"""
+
+    base_path: str = ""
+    # 记忆文件根目录。所有文件路径相对此目录解析。
+
+    files: Optional[Dict[str, str]] = None
+    # {路径模式: 用途说明}
+    # key 支持两种形式:
+    #   - 直接路径:"core/identity.md"
+    #   - glob 模式:"relationships/*.md"、"journals/2026/**.md"
+    # value 是人类可读的用途说明(注入时作为文件分隔标题的一部分)。
+    # 框架只负责按 key 解析文件内容;组织结构由配置者决定。
+
+    dream_prompt: str = ""
+    # Dream 跨 trace 整合 prompt;空则使用默认(Phase 3 定义)
+
+    reflect_prompt: str = ""
+    # Per-trace 记忆反思 prompt;空则使用默认(Phase 3 定义)
+
+
+def load_memory_files(config: MemoryConfig) -> List[Tuple[str, str, str]]:
+    """按 MemoryConfig.files 的 key 解析磁盘上的记忆文件。
+
+    Returns:
+        List[(relative_path, purpose, content)],按 files 声明顺序扁平化,
+        文件不存在则跳过(记 debug 日志),内容为空也保留(方便人类看到占位)。
+    """
+    if not config.base_path or not config.files:
+        return []
+
+    base = Path(config.base_path)
+    if not base.exists():
+        logger.debug(f"[Memory] base_path 不存在: {base}")
+        return []
+
+    results: List[Tuple[str, str, str]] = []
+    seen: set[str] = set()  # 去重(多个 glob 可能命中同一个文件)
+
+    for key, purpose in config.files.items():
+        # 展开 glob;直接路径也走 glob(无通配符时返回单条或空)
+        pattern = str(base / key)
+        matched_paths = sorted(_glob.glob(pattern, recursive=True))
+
+        if not matched_paths:
+            # 直接路径没命中时给个 debug(可能还没写第一版)
+            logger.debug(f"[Memory] {key} 没有匹配文件(尚未创建)")
+            continue
+
+        for fs_path in matched_paths:
+            rel = str(Path(fs_path).relative_to(base))
+            if rel in seen:
+                continue
+            seen.add(rel)
+            try:
+                content = Path(fs_path).read_text(encoding="utf-8")
+            except Exception as e:
+                logger.warning(f"[Memory] 读取失败 {fs_path}: {e}")
+                continue
+            results.append((rel, purpose, content))
+
+    return results
+
+
+def format_memory_injection(files: List[Tuple[str, str, str]]) -> str:
+    """把加载结果格式化为可注入到上下文的 markdown 段。"""
+    if not files:
+        return ""
+    parts = ["## 你的长期记忆\n\n以下是你作为此 Agent 身份积累的记忆(人类可直接编辑):\n"]
+    for rel, purpose, content in files:
+        header = f"### `{rel}`"
+        if purpose:
+            header += f" — {purpose}"
+        parts.append(header)
+        parts.append(content.rstrip() or "_(空文件,尚未积累内容)_")
+        parts.append("")  # 空行分隔
+    return "\n".join(parts).rstrip() + "\n"

+ 59 - 129
agent/core/prompts/knowledge.py

@@ -5,180 +5,110 @@
 - REFLECT_PROMPT:            压缩时阶段性反思(消息量超阈值,对当前批历史提炼)
 - COMPLETION_REFLECT_PROMPT: 任务完成后全局复盘(对整个任务的全局视角)
 
-两个 prompt 都要求 LLM 直接调用 `upload_knowledge` 工具保存经验,
-而不是输出结构化文本再由 runner 解析。
+两个 prompt 都要求 LLM 直接调用 `knowledge_save_pending` 工具暂存为待审核条目,
+每条知识一次调用,不需要输出结构化文本。
+
+"pending" 语义:条目落到 cognition_log 的 extraction_pending 事件,
+等待人工(或 reflect_auto_commit=True 时由框架自动)review + commit 才进入 KnowHub。
+详见 agent/docs/memory-plan.md 第三节"提取-审核-提交两阶段"。
 """
 
 # ===== 压缩时阶段性反思 =====
 
-REFLECT_PROMPT = """请回顾以上执行过程,将值得沉淀的内容直接用 `upload_knowledge` 工具保存到知识库。
+REFLECT_PROMPT = """请回顾以上执行过程,将值得沉淀的内容通过 `knowledge_save_pending` 工具逐条暂存(每条知识一次调用)。
+
+暂存的条目会进入审核队列(不立即入库),等待人工 review 后才会上传到 KnowHub。
 
 ## 两种保存模式
 
-### 模式 1:经验反思(experience)
+### 模式 1:经验反思(types=["experience"]
 总结执行过程中的经验教训,关注:
 1. 人工干预:用户中途的指令说明了哪里出了问题
 2. 弯路:哪些尝试是不必要的,有没有更直接的方法
 3. 好的决策:哪些判断和选择是正确的,值得记住
 4. 工具使用:哪些工具用法是高效的,哪些可以改进
 
-**格式要求**:
-- `主题`: 「在[什么情境]下,[要完成什么]」
-- `内容`: 「当[条件]时,应该[动作](原因:[一句话])。案例:[具体案例]」
-- `类型`: `["experience"]`
-- `标签`: `{"intent": "任务意图", "state": "环境状态/工具名"}`
-- `评分`: 1-5(只保存最有价值的,宁少勿滥)
-
-### 模式 2:原始知识上传(tool/strategy/case)
-如果执行过程中**调研或发现了新知识**(如工具用法、工作流程、案例),直接上传原始知识:
+**参数格式**:
+- `task`: 「在[什么情境]下,[要完成什么]」
+- `content`: 「当[条件]时,应该[动作](原因:[一句话])。案例:[具体案例]」
+- `types`: `["experience"]`
+- `tags`: `{"intent": "任务意图", "state": "环境状态/工具名"}`
+- `score`: 1-5(只保存最有价值的,宁少勿滥)
 
-**要求**:
-- **完整性**:保留原始信息,不要过度总结
-- **来源清晰**:在 `resource_ids` 中关联来源资源,或在 `标签` 中标注来源
-- **原汁原味**:保持原文档/网页的结构和细节
+### 模式 2:原始知识(types=["tool"] / ["strategy"] / ["case"])
+如果执行过程中**调研或发现了新知识**(如工具用法、工作流程、案例),原汁原味暂存:
 
-**知识类型选择**:
 - `["tool"]`:工具知识(单个工具的功能、参数、用法、限制)
 - `["strategy"]`:工序知识(多步骤流程、方案、最佳实践)
 - `["case"]`:用例知识(真实案例、应用场景、效果数据)
 
-**格式要求**:
-- `主题`: 知识的标题(如「Midjourney 的 --ar 参数用法」)
-- `内容`: 原始知识内容(完整、详细、保留结构)
-- `类型`: `["tool"]` / `["strategy"]` / `["case"]`
-- `标签`: `{"source": "来源网站/文档", "domain": "领域", ...}`
+**参数格式**:
+- `task`: 知识的标题(如「Midjourney 的 --ar 参数用法」)
+- `content`: 原始知识内容(完整、详细、保留结构,不要过度总结
+- `types`: 二选一
+- `tags`: `{"source": "来源网站/文档", "domain": "领域", ...}`
 - `resource_ids`: 关联的资源 ID(如果保存了原始文档)
-- `评分`: 1-5(根据知识的价值和可靠性)
-
-## 参数说明
-
-**每条内容调用一次 `upload_knowledge`**:
-- `data`: 包含 knowledge/resources/tools 的字典
-  - `knowledge`: 知识列表,每个知识包含:
-    - `主题`: 标题或场景描述
-    - `内容`: 知识正文(经验用总结格式,原始知识保持完整)
-    - `类型`: `["experience"]` / `["tool"]` / `["strategy"]` / `["case"]`
-    - `标签`: 键值对标签,便于检索
-    - `评分`: 1-5
-    - `resource_ids`: 关联的资源 ID 列表(可选)
-  - `resources`: 资源列表(可选),每个资源包含:
-    - `id`: 资源 ID(如 `code/{category}/{name}`)
-    - `标题`: 资源标题
-    - `内容`: 资源内容
-    - `类型`: code/credential/cookie 等
-    - `元数据`: 额外信息
-  - `tools`: 工具列表(可选)
-- `finalize`: False(增量上传,不立即入库)
-
-**注意**:
-- 只保存最有价值的经验,宁少勿滥;一次就成功或比较简单的经验就不要记录了,记录反复尝试或被用户指导后才成功的经验、或者是调研之后的收获。
+- `score`: 1-5(根据知识的价值和可靠性)
+
+## 其他注意事项
+
+- **一条知识一次 `knowledge_save_pending` 调用**,不要把多条合并
+- 只保存最有价值的经验,宁少勿滥;一次就成功或比较简单的经验就不要记录了,记录反复尝试或被用户指导后才成功的经验、或者是调研之后的收获
 - 不需要输出任何文字,直接调用工具即可
 - 如果没有值得保存的经验,不调用任何工具
-- **完成经验存后立即停止,不要继续执行原有任务**
+- **完成经验暂存后立即停止,不要继续执行原有任务**
 """
 
 
 # ===== 任务完成后全局复盘 =====
 
-COMPLETION_REFLECT_PROMPT = """请对整个任务进行复盘,将值得沉淀的内容直接用 `upload_knowledge` 工具保存到知识库。
+COMPLETION_REFLECT_PROMPT = """请对整个任务进行复盘,将值得沉淀的内容通过 `knowledge_save_pending` 工具逐条暂存(每条知识一次调用)。
+
+暂存的条目会进入审核队列(不立即入库),等待人工 review 后才会上传到 KnowHub。
 
 ## 两种保存模式
 
-### 模式 1:经验反思(experience)
+### 模式 1:经验反思(types=["experience"])
 任务结束后的全局视角,关注:
 1. 任务整体路径:实际走的路径与最初计划的偏差
 2. 关键决策点:哪些决策显著影响了最终结果
 3. 可复用的模式:哪些做法在类似任务中可以直接复用
 4. 踩过的坑:哪些问题本可提前规避
 
-**格式要求**:
-- `主题`: 「在[什么情境]下,[要完成什么]」
-- `内容`: 「当[条件]时,应该[动作](原因:[一句话])。案例:[具体案例]」
-- `类型`: `["experience"]`
-- `标签`: `{"intent": "任务意图", "state": "环境状态/工具名"}`
-- `评分`: 1-5(只保存最有价值的,宁少勿滥)
+**参数格式**:
+- `task`: 「在[什么情境]下,[要完成什么]」
+- `content`: 「当[条件]时,应该[动作](原因:[一句话])。案例:[具体案例]」
+- `types`: `["experience"]`
+- `tags`: `{"intent": "任务意图", "state": "环境状态/工具名"}`
+- `score`: 1-5(只保存最有价值的,宁少勿滥)
 
-### 模式 2:原始知识上传(tool/strategy/case)
-如果任务过程中**调研或发现了新知识**,直接上传原始知识
+### 模式 2:原始知识(types=["tool"] / ["strategy"] / ["case"]
+如果任务过程中**调研或发现了新知识**,完整保留结构和细节
 
-**要求**:
-- **完整性**:保留原始信息的完整结构和细节,不要过度压缩
-- **来源清晰**:标注信息来源(URL、文档名、API 响应等)
-- **原汁原味**:保持原始数据格式(如 API 参数列表、配置示例、步骤说明等)
-
-**知识类型选择**:
 - `["tool"]`:工具知识(工具的功能、参数、用法、限制、版本信息)
 - `["strategy"]`:工序知识(完整的多步骤流程、方案、最佳实践)
 - `["case"]`:用例知识(真实案例、应用场景、效果数据、对比结果)
 
-**格式要求**:
-- `主题`: 知识的标题
-- `内容`: 原始知识内容(完整详细
-- `类型`: `["tool"]` / `["strategy"]` / `["case"]`
-- `标签`: `{"source": "来源", "domain": "领域", ...}`
+**参数格式**:
+- `task`: 知识的标题
+- `content`: 原始知识内容(完整详细,不要过度压缩
+- `types`: 三选一
+- `tags`: `{"source": "来源", "domain": "领域", ...}`
 - `resource_ids`: 关联的资源 ID
-- `评分`: 1-5
-
-## 参数说明
-
-**每条内容调用一次 `upload_knowledge`**:
-- `data`: 包含 tools/resources/knowledge 的字典
-  - `knowledge`: 知识列表,每个知识包含:
-    - `主题`: 这条经验适用的场景,格式:「在[什么情境]下,[要完成什么]」
-    - `内容`: 具体经验内容,格式:「当[条件]时,应该[动作](原因:[一句话])。案例:[具体案例]」
-    - `类型`: 知识类型,选择以下之一:
-      - `["experience"]`: 执行经验(Agent 反思总结,应该/避免做什么)
-      - `["strategy"]`: 工序知识(多步骤流程、方案)
-      - `["tool"]`: 工具知识(单个工具的功能、用法)
-      - `["case"]`: 用例知识(真实案例、应用场景)
-    - `标签`: 用 `intent`(任务意图)和 `state`(环境状态/相关工具名)标注,便于检索
-    - `评分`: 1-5,根据这条经验的价值评估
-    - `resource_ids`: 关联的资源 ID 列表(可选,如果这条知识引用了某个资源)
-  - `resources`: 资源列表(可选)
-  - `tools`: 工具列表(可选)
-- `finalize`: False(增量上传,不立即入库)
-
-**资源提取指南**:
-如果任务中涉及以下内容,应在 `data` 中包含 `resources` 字段:
-
-1. **复杂代码工具**(逻辑复杂、超过 20 行、可复用):
-   ```python
-   {
-     "id": "code/{category}/{name}",
-     "标题": "...",
-     "内容": "代码内容",
-     "类型": "code",
-     "元数据": {"language": "python"}
-   }
-   ```
-
-2. **账号密码凭证**:
-   ```python
-   {
-     "id": "credentials/{website}",
-     "标题": "...",
-     "内容": "使用说明和凭证",
-     "类型": "credential",
-     "元数据": {"acquired_at": "2026-03-06T10:00:00Z"}
-   }
-   ```
-
-3. **Cookie 和登录态**:
-   ```python
-   {
-     "id": "cookies/{website}",
-     "标题": "...",
-     "内容": "获取方法和cookie内容",
-     "类型": "cookie",
-     "元数据": {"acquired_at": "...", "expires_at": "..."}
-   }
-   ```
-
-**注意**:
-- 只保存最有价值的经验,宁少勿滥;一次就成功或比较简单的经验就不要记录了,记录反复尝试或被用户指导后才成功的经验、或者是调研之后的收获。
+- `score`: 1-5
+
+## 关于资源(resource)
+
+如果过程中产出了可复用的代码/凭证/Cookie 等资源,先用 `resource_save` 工具保存,
+再在 `knowledge_save_pending` 的 `resource_ids` 字段中关联资源 ID。
+
+## 其他注意事项
+
+- **一条知识一次 `knowledge_save_pending` 调用**,不要把多条合并
+- 只保存最有价值的经验,宁少勿滥
 - 不需要输出任何文字,直接调用工具即可
 - 如果没有值得保存的经验,不调用任何工具
-- **完成经验存后立即停止,不要继续执行原有任务**
+- **完成经验暂存后立即停止,不要继续执行原有任务**
 """
 
 

+ 79 - 1
agent/core/runner.py

@@ -37,6 +37,7 @@ from agent.skill.models import Skill
 from agent.skill.skill_loader import load_skills_from_dir
 from agent.tools import ToolRegistry, get_tool_registry
 from agent.tools.builtin.knowledge import KnowledgeConfig
+from agent.core.memory import MemoryConfig
 from agent.core.prompts import (
     DEFAULT_SYSTEM_PREFIX,
     TRUNCATION_HINT,
@@ -142,6 +143,9 @@ class RunConfig:
     enable_research_flow: bool = True  # 是否启用自动研究流程(知识检索→经验检索→调研→计划)
     # --- 知识管理配置 ---
     knowledge: KnowledgeConfig = field(default_factory=KnowledgeConfig)
+    # --- Memory 配置(见 agent/docs/memory-plan.md) ---
+    # None = 默认 Agent(无长期记忆);赋值 MemoryConfig 使该 Agent 成为 memory-bearing Agent
+    memory: Optional["MemoryConfig"] = None
 
 
     # BUILTIN_TOOLS 硬编码列表已移除(2026-04)。
@@ -220,12 +224,45 @@ class AgentRunner:
         # key: 图片内容的 hash, value: {"downscaled": ..., "description": ...}
         self._image_opt_cache: Dict[str, Dict[str, Any]] = {}
 
+        # 当前 run 的 MemoryConfig(由 run() 根据 RunConfig.memory 设置)
+        # dream 工具从 context.runner 读取此字段,判断是否 memory-bearing
+        self._current_memory_config: Optional[MemoryConfig] = None
+
     # ===== 核心公开方法 =====
 
     def get_context_usage(self, trace_id: str) -> Optional[ContextUsage]:
         """获取指定 trace 的 context 使用情况"""
         return self._context_usage.get(trace_id)
 
+    async def dream(
+        self,
+        memory_config: MemoryConfig,
+        trace_filter: Optional[Callable[["Trace"], bool]] = None,
+        reflect_model: str = "gpt-4o-mini",
+        dream_model: str = "gpt-4o",
+    ) -> "DreamReport":
+        """执行 dream(整理长期记忆)——外部调度入口。
+
+        Agent 主动调用走 dream 工具;外部调度(定时器、CLI)走这个方法。
+
+        Args:
+            memory_config: 记忆配置
+            trace_filter: 可选 trace 过滤(按 agent_type/owner 等)
+            reflect_model: per-trace 反思模型
+            dream_model: 跨 trace 整合模型
+        """
+        from agent.core.dream import run_dream
+        if not self.trace_store or not self.llm_call:
+            raise RuntimeError("dream 需要 trace_store 和 llm_call 均已配置")
+        return await run_dream(
+            store=self.trace_store,
+            llm_call=self.llm_call,
+            memory_config=memory_config,
+            trace_filter=trace_filter,
+            reflect_model=reflect_model,
+            dream_model=dream_model,
+        )
+
     async def run(
         self,
         messages: List[Dict],
@@ -254,6 +291,9 @@ class AgentRunner:
         config = config or RunConfig()
         trace = None
 
+        # Memory 模式开关(dream 工具会读取此字段)
+        self._current_memory_config = config.memory
+
         try:
             # Phase 1: PREPARE TRACE
             trace, goal_tree, sequence = await self._prepare_trace(messages, config)
@@ -1173,7 +1213,11 @@ class AgentRunner:
 
                 # 追加侧分支 prompt
                 if branch_type == "reflection":
-                    prompt = config.knowledge.get_reflect_prompt()
+                    # 完成场景用全局复盘 prompt,压缩场景用阶段性反思 prompt
+                    if break_after_side_branch:
+                        prompt = config.knowledge.get_completion_reflect_prompt()
+                    else:
+                        prompt = config.knowledge.get_reflect_prompt()
                 elif branch_type == "knowledge_eval":
                     prompt = await self._build_knowledge_eval_prompt(trace_id, goal_tree)
                 else:  # compression
@@ -1490,6 +1534,27 @@ class AgentRunner:
                     # === 反思侧分支退出(超时 + 正常完成统一处理)===
                     self.log.info("反思侧分支退出")
 
+                    # auto-commit hook:默认 pending 要等人工 review,
+                    # 但 reflect_auto_commit=True 时视作全部 approved,直接批量 upload。
+                    if (
+                        self.trace_store
+                        and getattr(config.knowledge, "reflect_auto_commit", False)
+                    ):
+                        try:
+                            from agent.trace.extraction_review import auto_commit_branch
+                            report = await auto_commit_branch(
+                                self.trace_store,
+                                trace_id,
+                                side_branch_ctx.branch_id,
+                            )
+                            if report.committed or report.failed:
+                                self.log.info(
+                                    f"[auto-commit] committed={len(report.committed)} "
+                                    f"failed={len(report.failed)} skipped={len(report.skipped)}"
+                                )
+                        except Exception as e:
+                            self.log.error(f"[auto-commit] 反思分支自动提交失败: {e}")
+
                     # 恢复主路径
                     if self.trace_store:
                         main_path_messages = await self.trace_store.get_main_path_messages(
@@ -2957,6 +3022,19 @@ class AgentRunner:
 
         if config.max_iterations and config.max_iterations > 0:
             system_prompt += f"\n\n## Execution Constraint\n这是一项有严格步数限制的任务。你最多可以用 {config.max_iterations} 轮交互来解决问题。\n请务必【边查边写、随时存档】!每当你收集或得出一个有价值的独立结果(如收集到一个独立 Case),请立刻调用工具写入或追加到结果文件中,绝对不要等到所有任务都做完再最后一次性输出。这样即使触达步数上限被强制打断,你已经收集的成果也能安全保留!"
+        # Memory 注入(memory-bearing Agent)——在 system prompt 末尾追加
+        # 初版选择 system prompt 追加(见 agent/docs/memory-plan.md 待定问题 1)。
+        # 好处:run 启动一次性注入、所有后续轮次都能看到、与 skills 注入方式一致。
+        # 代价:若记忆文件很大会持续占 prompt tokens —— 待观察后决定是否切换方案。
+        if config.memory:
+            try:
+                from agent.core.memory import load_memory_files, format_memory_injection
+                files = load_memory_files(config.memory)
+                memory_text = format_memory_injection(files)
+                if memory_text:
+                    system_prompt += f"\n\n{memory_text}"
+            except Exception as e:
+                self.log.warning(f"[Memory] 加载记忆失败,跳过注入: {e}")
 
         return system_prompt
 

+ 0 - 416
agent/docs/memory-plan.md

@@ -1,416 +0,0 @@
-# Memory 系统与元思考机制设计
-
-> 状态:设计讨论中,未实现
-
----
-
-## 概述
-
-本文档设计两个紧密关联的能力:
-
-1. **Memory**:为需要跨任务维持身份的 Agent 提供持久化记忆(Markdown 文件)
-2. **统一元思考机制**:整合现有的知识提取(reflection)、知识评估(knowledge_eval)和新增的记忆反思(dream),形成完整的"Agent 自我认知"能力
-
----
-
-## 一、现有元思考机制梳理
-
-当前框架已实现两种元思考能力,都基于侧分支(side branch)机制:
-
-### 1.1 知识提取(reflection 侧分支)
-
-**目的**:从执行过程中提取客观知识,保存到 KnowHub(全局共享知识库)。
-
-**触发时机**:
-- 压缩前(`_manage_context_usage`,`runner.py:825-829`)
-- 任务完成后(`runner.py:1834-1838`,`enable_completion_extraction`)
-
-**输出**:调用 `upload_knowledge` 工具,保存 experience/tool/strategy/case 到 KnowHub。
-
-**Prompt**:`REFLECT_PROMPT`(压缩时)和 `COMPLETION_REFLECT_PROMPT`(任务完成后),定义在 `agent/core/prompts/knowledge.py`。
-
-**已知问题**:任务完成时触发的 reflection 使用 `config.knowledge.get_reflect_prompt()`(`runner.py:1249`),没有区分压缩场景和完成场景。应该在完成场景使用 `get_completion_reflect_prompt()`。
-
-### 1.2 知识评估(knowledge_eval 侧分支)
-
-**目的**:评估被注入的知识是否有用,记录到本地 `knowledge_log.json`。
-
-**触发时机**(详见 `knowhub/docs/cognition-log-plan.md`):
-- Goal 完成时(`store.py:update_goal`,设置 `pending_knowledge_eval` 标志)
-- 压缩前(必须在压缩前完成评估,否则执行上下文丢失)
-- 任务结束时(兜底)
-
-**输出**:每条被注入的知识获得评估(irrelevant / unused / helpful / harmful / neutral),写入 `knowledge_log.json`。
-
-**当前局限**:评估结果只存本地,不自动回传 KnowHub。只有用户通过 API 手动反馈才同步。
-
-### 1.3 侧分支队列机制
-
-两种元思考通过 `force_side_branch` 队列协调执行顺序(`runner.py:1198-1207`):
-
-```
-压缩触发时的典型队列:
-  ["reflection", "knowledge_eval", "compression"]
-  
-任务完成时:
-  ["knowledge_eval"](先评估)→ ["reflection"](再提取)
-```
-
-每个侧分支执行完后 pop 队首,继续下一个,直到队列清空回到主路径。
-
----
-
-## 二、缺失的能力:个人记忆
-
-### 问题
-
-现有的两种元思考都面向**全局共享知识**(KnowHub)。但有一类 Agent 需要维护**主观的、属于自己的长期记忆**:
-
-- 品味偏好、策略判断、风格积累、用户反馈
-- 属于特定 Agent 身份,不是公共知识
-- 需要人类能直接阅读和修改
-- 跨多次 trace 持续积累和演化
-- 可能被同一身份下的多个 Agent run 共享读写
-
-KnowHub 不适合承担这个职责——它是"大众点评",不是"个人日记"。
-
-### 记忆文件
-
-记忆以 Markdown 文件存储在指定目录下。每个文件覆盖一个语义维度。
-
-```
-{base_path}/
-├── taste.md        # 偏好判断
-├── strategy.md     # 当前策略
-├── skills.md       # 积累的技巧
-└── ...
-```
-
-**为什么是 Markdown 文件**:
-- 人类可直接阅读和修改(vim/VS Code 打开就能改)
-- Git 版本控制
-- Agent 用 read_file/write_file 工具即可操作,无需新增工具
-- 文件数量少(几个到十几个),不需要检索能力
-
-**共享读写**:同一身份下的多个 Agent run 可以读写同一组记忆文件。哪个 Agent 该关注哪些文件、怎么更新,由 dream prompt 来定义。
-
----
-
-## 三、统一元思考模型
-
-### 三种元思考及其定位
-
-| | 知识提取(已有) | 知识评估(已有) | 记忆反思(新增) |
-|---|---|---|---|
-| **回答的问题** | 我学到了什么客观知识? | 给我的知识有没有用? | 这些经历对我的偏好/策略意味着什么? |
-| **输出目的地** | KnowHub(全局共享) | knowledge_log.json(本地) | 记忆文件(Agent 身份私有) |
-| **触发时机** | 压缩前、任务完成后 | Goal 完成、压缩前、任务结束 | Dream 时(见下文) |
-| **时效性要求** | 高(压缩会丢上下文) | 高(压缩会丢上下文) | 低(可以延迟处理) |
-| **实现机制** | reflection 侧分支 | knowledge_eval 侧分支 | dream 操作(新增) |
-
-**关键区分**:知识提取和知识评估必须在上下文丢失前完成(压缩前/任务结束时),所以是侧分支、即时触发。记忆反思可以延迟——甚至应该延迟,因为用户可能还有反馈、Agent 可能继续执行。
-
-### 为什么记忆反思不在 trace 结束时做
-
-trace 结束只意味着 Agent 行动完一个轮次。后续可能发生:
-- 用户在飞书里说"这个方向不对"
-- Agent 被唤醒在同一任务下继续执行
-- 新的 trace 产生了推翻前一个 trace 结论的信息
-
-如果 trace 一结束就做记忆反思,这些后续信息会被忽略。记忆反思的价值在于**综合一段时间的经历**,不是记录每次行动的即时感受。
-
-### 但知识提取仍然在压缩/完成时做
-
-这不矛盾。知识提取保存的是**客观知识**(工具用法、调研结果),这些不会因为后续反馈而失效。而且压缩会删除历史,如果不在压缩前提取,知识就永久丢失了。
-
----
-
-## 四、Dream:记忆反思的触发与流程
-
-### 什么是 Dream
-
-Dream 是 memory-bearing Agent 的记忆整理操作。它不是一个侧分支(不在某个 trace 的执行过程中插入),而是一个**独立的顶层操作**,回顾多个 trace 的执行历史,更新记忆文件。
-
-### 触发方式
-
-- Agent 主动调用 `dream` 工具("我觉得该整理一下了")
-- 外部调度触发(定时、或人工 CLI 触发)
-- 框架可以在 run 启动时检测距上次 dream 的时间/trace 数量,建议 Agent dream
-
-### 反思状态追踪
-
-```
-Trace 模型新增字段:
-- reflected_at_sequence: Optional[int]    # 上次记忆反思时的最新 message sequence
-                                           # None = 从未被记忆反思处理
-```
-
-反思摘要不存在 Trace 模型中,而是作为 `reflection` 事件写入 `cognition_log.json`(详见 `knowhub/docs/cognition-log-plan.md`)。
-
-- Agent run 产生新 message → `reflected_at_sequence` 自然落后于实际 sequence
-- 记忆反思完成 → 更新 `reflected_at_sequence` 为当前最新 sequence
-- Dream 扫描 `reflected_at_sequence < latest_sequence` 的 trace
-
-### Dream 流程
-
-```
-Dream 触发
-  │
-  ├─ Step 1: 扫描该 Agent 身份下所有 trace
-  │   找到 reflected_at_sequence < latest_sequence 的 trace
-  │
-  ├─ Step 2: Per-trace 记忆反思(逐个 trace)
-  │   对每个需要反思的 trace:
-  │   a. 加载 reflected_at_sequence 之后的消息(增量)
-  │   b. 同时加载该 trace 的 cognition_log.json(查询、评估、提取事件)
-  │   c. 用 reflect_prompt 生成反思摘要
-  │   d. 摘要作为 reflection 事件写入 cognition_log.json
-  │   e. 更新 reflected_at_sequence
-  │
-  ├─ Step 3: 跨 trace 整合
-  │   a. 收集各 trace 的 reflection 事件(cognition_log 中 type="reflection")
-  │   b. 读取当前记忆文件
-  │   c. 汇总 cognition_log 中的评估趋势(多次 harmful/unused 的 source 模式)
-  │   d. 用 dream_prompt 指导 LLM 更新记忆文件
-  │   e. 标记 reflection 事件为已消化
-  │
-  └─ 完成
-```
-
-### Per-trace 反思的输入
-
-反思 prompt 看到的不只是"发生了什么",还包括知识评估结果:
-
-```
-## 执行过程
-[该 trace 中 reflected_at_sequence 之后的消息]
-
-## 知识使用情况(来自 cognition_log.json)
-查询 1(sequence 42):"ControlNet 相关工具知识"
-  → source knowledge-a1b2: helpful — "准确定位了问题"
-  → source knowledge-c3d4: irrelevant — "与当前任务无关"
-  → source knowledge-e5f6: harmful — "建议的方法已过时"
-
-## 请反思
-1. 这次执行中有什么值得记住的经验?
-2. 哪些知识的评估结果反映了我的判断需要调整?
-3. 用户的反馈(如果有)说明了什么?
-```
-
-这样,已有的 knowledge_eval 结果直接成为记忆反思的输入,不需要重复评估。
-
-### Dream 整合的输入
-
-Dream prompt 看到的是:
-
-```
-## 最近的反思摘要
-[各 trace 的 reflect_summary,每份几百 token]
-
-## 知识评估趋势(汇总自各 trace 的 cognition_log)
-- 最近 N 个 trace 中,被评为 harmful 的 source:[列表]
-- 被评为 unused 的高频 source 类型:[统计]
-- 被评为 helpful 的查询模式:[统计]
-
-## 当前记忆文件
-[各文件内容]
-
-## 请更新记忆
-[dream_prompt 的具体指导]
-```
-
----
-
-## 五、与现有实现的集成
-
-### 不改动的部分
-
-| 现有机制 | 保持不变 | 原因 |
-|---|---|---|
-| reflection 侧分支 | ✅ | 知识提取到 KnowHub,时效性要求高,必须在压缩前做 |
-| knowledge_eval 侧分支 | ✅ | 知识评估,时效性要求高,必须在压缩前做 |
-| force_side_branch 队列 | ✅ | 侧分支排序机制,成熟可靠 |
-| cognition_log.json | ✅ | 统一事件流存储(原 knowledge_log.json 扩展),dream 直接读取 |
-| 三个评估触发点 | ✅ | Goal 完成/压缩前/任务结束 |
-
-### 需要修改的部分
-
-**1. 任务完成时的 reflection prompt 选择**
-
-当前 `runner.py:1249` 始终使用 `get_reflect_prompt()`。应区分场景:
-
-```python
-# runner.py:1248-1249 修改
-if branch_type == "reflection":
-    if break_after_side_branch:  # 任务完成后的反思
-        prompt = config.knowledge.get_completion_reflect_prompt()
-    else:  # 压缩前的反思
-        prompt = config.knowledge.get_reflect_prompt()
-```
-
-这是一个独立的 bug fix,不依赖 Memory 系统。
-
-**2. Trace 模型扩展**
-
-`agent/trace/models.py:Trace` 新增字段:
-
-```python
-reflected_at_sequence: Optional[int] = None    # 上次记忆反思的 sequence
-# 反思摘要存在 cognition_log.json 中(type="reflection" 事件),不在 Trace 模型中
-```
-
-**3. RunConfig 扩展**
-
-`agent/core/runner.py:RunConfig` 新增可选字段:
-
-```python
-memory: Optional[MemoryConfig] = None
-```
-
-### 新增的部分
-
-**1. MemoryConfig**
-
-```python
-@dataclass
-class MemoryConfig:
-    """持久化记忆配置"""
-
-    base_path: str = ""                          # 记忆文件目录
-    files: Optional[Dict[str, str]] = None       # {文件名: 用途说明}
-    dream_prompt: str = ""                       # Dream 整合 prompt(空用默认)
-    reflect_prompt: str = ""                     # Per-trace 反思 prompt(空用默认)
-```
-
-**2. Run 启动时记忆加载**
-
-Memory-bearing Agent 的 run 启动时,框架读取 `base_path` 下所有 `files` 中声明的文件,注入上下文。
-
-**3. Dream 操作**
-
-以 `dream` 工具形式提供,Agent 可主动调用:
-
-```python
-@tool
-async def dream() -> ToolResult:
-    """整理长期记忆。回顾最近的执行历史,更新记忆文件。"""
-    # 1. 扫描需要反思的 trace
-    # 2. 逐个 per-trace 反思
-    # 3. 跨 trace 整合,更新记忆文件
-```
-
-也可以作为 `AgentRunner` 的方法暴露,供外部调度直接调用。
-
----
-
-## 六、完整的元思考数据流
-
-```
-Agent 执行任务(Trace)
-  │
-  ├─ 知识查询(ask)→ cognition_log: type="query"(含整合回答 + source_ids)
-  │
-  ├─ Goal 完成 → 触发 knowledge_eval 侧分支 → cognition_log: type="evaluation"
-  │
-  ├─ 压缩触发 →
-  │   队列: [reflection, knowledge_eval, compression]
-  │   reflection: 提取客观知识 → upload → KnowHub + cognition_log: type="extraction"
-  │   knowledge_eval: 评估各 source → cognition_log: type="evaluation"
-  │   compression: 压缩上下文
-  │
-  ├─ 任务完成 →
-  │   knowledge_eval(如有 pending)→ cognition_log: type="evaluation"
-  │   reflection → upload → KnowHub + cognition_log: type="extraction"
-  │
-  └─ Trace 状态更新(新消息使 reflected_at_sequence 落后)
-
-         ···时间流逝,可能有多个 trace···
-
-Dream 触发(Agent 主动调用 / 外部调度)
-  │
-  ├─ Per-trace 记忆反思
-  │   输入: 未反思的消息 + cognition_log 中的 query/evaluation/extraction 事件
-  │   输出: cognition_log: type="reflection"
-  │
-  ├─ 跨 trace 整合
-  │   输入: 各 trace 的 reflection 事件 + evaluation 趋势 + 当前记忆文件
-  │   输出: 更新后的记忆文件(taste.md, strategy.md, ...)
-  │
-  └─ 记忆文件被下次 run 加载 → 影响 Agent 行为 → 新的 Trace → ...
-```
-
-### 三种元思考的时间线
-
-```
-Trace 执行中:
-  ──[Goal完成]──knowledge_eval──[压缩]──reflection→knowledge_eval→compression──
-
-Trace 结束后:
-  ──knowledge_eval──reflection(completion)──
-
-之后某个时刻:
-  ──dream──per-trace记忆反思──跨trace整合──更新记忆文件──
-```
-
-即时的元思考(knowledge_eval、reflection)保护信息不被压缩丢失。
-延迟的元思考(dream)在全局视角下更新个人记忆。两者互补。
-
----
-
-## 七、记忆模型全景
-
-```
-┌─────────────────────────────────────────────────────────────┐
-│ Layer 3: Skills(技能库)                                     │
-│ - Markdown 文件,领域知识和能力描述                            │
-└─────────────────────────────────────────────────────────────┘
-                              ▲
-                              │ 归纳
-┌─────────────────────────────────────────────────────────────┐
-│ Layer 2: Knowledge(知识库)— 全局共享                         │
-│ - KnowHub 数据库,客观知识 + 向量索引                         │
-│ - 来源:reflection 侧分支提取                                │
-│ - 质量信号:knowledge_eval 评估结果                           │
-└─────────────────────────────────────────────────────────────┘
-                              ▲
-                              │ 提取(reflection)/ 评估(knowledge_eval)
-┌─────────────────────────────────────────────────────────────┐
-│ Layer 1.5: Memory(个人记忆)— Agent 身份私有                  │
-│ - Markdown 文件,主观记忆(偏好/策略/反思)                    │
-│ - 来源:dream 操作(per-trace 反思 + 跨 trace 整合)          │
-│ - 人类可直接编辑                                              │
-└─────────────────────────────────────────────────────────────┘
-                              ▲
-                              │ dream 反思
-┌─────────────────────────────────────────────────────────────┐
-│ Layer 1: Trace(任务状态)                                    │
-│ - 当前任务的工作记忆                                          │
-│ - Messages + Goals + cognition_log                           │
-└─────────────────────────────────────────────────────────────┘
-```
-
----
-
-## 八、两类 Agent
-
-| | 默认 Agent | Memory-bearing Agent |
-|---|---|---|
-| 知识提取(reflection) | ✅ 配置 KnowledgeConfig | ✅ 配置 KnowledgeConfig |
-| 知识评估(knowledge_eval) | ✅ 自动 | ✅ 自动 |
-| 个人记忆 | ❌ | ✅ 配置 MemoryConfig |
-| Dream | ❌ | ✅ 可调用 dream 工具 |
-| Run 启动加载记忆 | ❌ | ✅ 自动注入 |
-
-默认行为不变。Memory 是 opt-in 的增量能力。
-
----
-
-## 九、待定问题
-
-1. **记忆注入方式**:system prompt 追加 vs 首条消息前插入 vs 作为工具结果注入?需要实验对比效果。
-2. **并发写冲突**:多个 Agent run 同时写同一个记忆文件怎么办?文件锁?还是 dream 统一写、其他 run 只读?
-3. **记忆膨胀**:记忆文件越来越长怎么办?dream prompt 应该包含精简逻辑,但需要观察实际效果。
-4. **Per-trace 反思的成本控制**:很短的 trace 不值得反思。阈值由框架设定(消息数/token数)还是让 dream 过程自己判断?
-5. **Knowledge eval 结果回传 KnowHub**:是否应该自动同步?自动回传可能影响其他 Agent 的检索。
-6. **Dream 中 knowledge_log 趋势的呈现方式**:在 dream prompt 中注入预计算的统计 vs 让 LLM 自己读原始 log?
-7. **Dream 操作的实现形式**:作为 Agent 工具(`dream()`)vs AgentRunner 方法 vs 两者都提供?

+ 620 - 0
agent/docs/memory.md

@@ -0,0 +1,620 @@
+# Memory 系统与元思考机制
+
+> 状态:已实现(2026-04)。本文档同时承担**设计理由**和**使用规范**。
+> 入口、工具、API 清单见文末"十、实现与入口"。
+> 一~九节解释"为什么这么做",改动前请先读懂论证。
+
+---
+
+## 概述
+
+本文档设计两个紧密关联的能力:
+
+1. **Memory**:为需要跨任务维持身份的 Agent 提供持久化记忆(Markdown 文件)
+2. **统一元思考机制**:整合现有的知识提取(reflection)、知识评估(knowledge_eval)和新增的记忆反思(dream),形成完整的"Agent 自我认知"能力
+
+---
+
+## 一、现有元思考机制梳理
+
+当前框架已实现两种元思考能力,都基于侧分支(side branch)机制:
+
+### 1.1 知识提取(reflection 侧分支)
+
+**目的**:从执行过程中提取客观知识,保存到 KnowHub(全局共享知识库)。
+
+**触发时机**:
+- 压缩前(`_manage_context_usage`,`runner.py:825-829`)
+- 任务完成后(`runner.py:1834-1838`,`enable_completion_extraction`)
+
+**输出**:调用 `upload_knowledge` 工具,保存 experience/tool/strategy/case 到 KnowHub。
+
+**Prompt**:`REFLECT_PROMPT`(压缩时)和 `COMPLETION_REFLECT_PROMPT`(任务完成后),定义在 `agent/core/prompts/knowledge.py`。
+
+**已知问题**:任务完成时触发的 reflection 使用 `config.knowledge.get_reflect_prompt()`(`runner.py:1249`),没有区分压缩场景和完成场景。应该在完成场景使用 `get_completion_reflect_prompt()`。
+
+### 1.2 知识评估(knowledge_eval 侧分支)
+
+**目的**:评估被注入的知识是否有用,记录到本地 `knowledge_log.json`。
+
+**触发时机**(详见 `knowhub/docs/cognition-log-plan.md`):
+- Goal 完成时(`store.py:update_goal`,设置 `pending_knowledge_eval` 标志)
+- 压缩前(必须在压缩前完成评估,否则执行上下文丢失)
+- 任务结束时(兜底)
+
+**输出**:每条被注入的知识获得评估(irrelevant / unused / helpful / harmful / neutral),写入 `knowledge_log.json`。
+
+**当前局限**:评估结果只存本地,不自动回传 KnowHub。只有用户通过 API 手动反馈才同步。
+
+### 1.3 侧分支队列机制
+
+两种元思考通过 `force_side_branch` 队列协调执行顺序(`runner.py:1198-1207`):
+
+```
+压缩触发时的典型队列:
+  ["reflection", "knowledge_eval", "compression"]
+  
+任务完成时:
+  ["knowledge_eval"](先评估)→ ["reflection"](再提取)
+```
+
+每个侧分支执行完后 pop 队首,继续下一个,直到队列清空回到主路径。
+
+---
+
+## 二、缺失的能力:个人记忆
+
+### 问题
+
+现有的两种元思考都面向**全局共享知识**(KnowHub)。但有一类 Agent 需要维护**主观的、属于自己的长期记忆**:
+
+- 品味偏好、策略判断、风格积累、用户反馈
+- 属于特定 Agent 身份,不是公共知识
+- 需要人类能直接阅读和修改
+- 跨多次 trace 持续积累和演化
+- 可能被同一身份下的多个 Agent run 共享读写
+
+KnowHub 不适合承担这个职责——它是"大众点评",不是"个人日记"。
+
+### 记忆文件
+
+记忆以 Markdown 文件存储在指定目录下。每个文件覆盖一个语义维度。
+
+```
+{base_path}/
+├── taste.md        # 偏好判断
+├── strategy.md     # 当前策略
+├── skills.md       # 积累的技巧
+└── ...
+```
+
+**为什么是 Markdown 文件**:
+- 人类可直接阅读和修改(vim/VS Code 打开就能改)
+- Git 版本控制
+- Agent 用 read_file/write_file 工具即可操作,无需新增工具
+- 文件数量少(几个到十几个),不需要检索能力
+
+**共享读写**:同一身份下的多个 Agent run 可以读写同一组记忆文件。哪个 Agent 该关注哪些文件、怎么更新,由 dream prompt 来定义。
+
+---
+
+## 三、统一元思考模型
+
+### 三种元思考及其定位
+
+| | 知识提取(已有) | 知识评估(已有) | 记忆反思(新增) |
+|---|---|---|---|
+| **回答的问题** | 我学到了什么客观知识? | 给我的知识有没有用? | 这些经历对我的偏好/策略意味着什么? |
+| **输出目的地** | KnowHub(全局共享) | knowledge_log.json(本地) | 记忆文件(Agent 身份私有) |
+| **触发时机** | 压缩前、任务完成后 | Goal 完成、压缩前、任务结束 | Dream 时(见下文) |
+| **时效性要求** | 高(压缩会丢上下文) | 高(压缩会丢上下文) | 低(可以延迟处理) |
+| **实现机制** | reflection 侧分支 | knowledge_eval 侧分支 | dream 操作(新增) |
+
+**关键区分**:知识提取和知识评估必须在上下文丢失前完成(压缩前/任务结束时),所以是侧分支、即时触发。记忆反思可以延迟——甚至应该延迟,因为用户可能还有反馈、Agent 可能继续执行。
+
+### 为什么记忆反思不在 trace 结束时做
+
+trace 结束只意味着 Agent 行动完一个轮次。后续可能发生:
+- 用户在飞书里说"这个方向不对"
+- Agent 被唤醒在同一任务下继续执行
+- 新的 trace 产生了推翻前一个 trace 结论的信息
+
+如果 trace 一结束就做记忆反思,这些后续信息会被忽略。记忆反思的价值在于**综合一段时间的经历**,不是记录每次行动的即时感受。
+
+### 但知识提取仍然在压缩/完成时做(采用"提取-审核-提交"两阶段)
+
+知识提取必须在压缩/完成时做,因为压缩会删除历史,不在压缩前提取,知识就永久丢失。
+
+但"立即 upload 到 KnowHub"这一步并不需要立即做。所谓"客观知识"也可能被后续推翻:
+
+- 工具用法可能被后续 trace 发现是错的(例如某个参数其实有副作用)
+- 调研结论可能被用户反馈推翻
+- 一次 trace 的"成功经验"在更长窗口看可能是反模式
+
+如果 reflection 直接 upload 到 KnowHub,错误知识会立刻污染全局检索,影响所有 Agent。
+
+**两阶段方案**:
+
+```
+Step 1: extract(自动,压缩前/任务结束)
+  Reflection 侧分支提取知识 → 写 cognition_log: type="extraction_pending"
+  不调用 upload_knowledge(信息保全已完成)
+
+Step 2: review(人工,CLI 里逐条决策)
+  approve / edit / discard → 写 cognition_log: type="extraction_reviewed"
+
+Step 3: commit(人工触发,批量上传)
+  把 reviewed=approved 的批量 upload_knowledge
+  写 cognition_log: type="extraction_committed"
+```
+
+review 和 commit 分开的理由:review 是逐条语义判断(要不要、内容对不对),commit 是机械批量动作。两者分离允许用户分批 review、最后一次 commit;也允许撤回 review 决策。
+
+**默认行为**:所有 Agent(包括默认 Agent 和 memory-bearing Agent)`reflect_auto_commit` 默认关闭,pending 提取必须人工 review + commit 才会进 KnowHub。如需自动直通(保留旧行为),手动在 `KnowledgeConfig` 里打开 `reflect_auto_commit=True`。
+
+这与"信息保全 vs 全局发布解耦"的原则一致 —— 压缩前必须做的是**保全**(写本地 cognition_log),**发布**到 KnowHub 可以延迟到有人确认时。
+
+---
+
+## 四、Dream:记忆反思的触发与流程
+
+### 什么是 Dream
+
+Dream 是 memory-bearing Agent 的记忆整理操作。它不是一个侧分支(不在某个 trace 的执行过程中插入),而是一个**独立的顶层操作**,回顾多个 trace 的执行历史,更新记忆文件。
+
+### 触发方式
+
+- Agent 主动调用 `dream` 工具("我觉得该整理一下了")
+- 外部调度触发(定时、或人工 CLI 触发)
+- 框架可以在 run 启动时检测距上次 dream 的时间/trace 数量,建议 Agent dream
+
+### 反思状态追踪
+
+```
+Trace 模型新增字段:
+- reflected_at_sequence: Optional[int]    # 上次记忆反思时的最新 message sequence
+                                           # None = 从未被记忆反思处理
+```
+
+反思摘要不存在 Trace 模型中,而是作为 `reflection` 事件写入 `cognition_log.json`(详见 `knowhub/docs/cognition-log-plan.md`)。
+
+- Agent run 产生新 message → `reflected_at_sequence` 自然落后于实际 sequence
+- 记忆反思完成 → 更新 `reflected_at_sequence` 为当前最新 sequence
+- Dream 扫描 `reflected_at_sequence < latest_sequence` 的 trace
+
+### Dream 流程
+
+```
+Dream 触发
+  │
+  ├─ Step 1: 扫描该 Agent 身份下所有 trace
+  │   找到 reflected_at_sequence < latest_sequence 的 trace
+  │
+  ├─ Step 2: Per-trace 记忆反思(逐个 trace)
+  │   对每个需要反思的 trace:
+  │   a. 加载 reflected_at_sequence 之后的消息(增量)
+  │   b. 同时加载该 trace 的 cognition_log.json(查询、评估、提取事件)
+  │   c. 用 reflect_prompt 生成反思摘要
+  │   d. 摘要作为 reflection 事件写入 cognition_log.json
+  │   e. 更新 reflected_at_sequence
+  │
+  ├─ Step 3: 跨 trace 整合
+  │   a. 收集各 trace 的 reflection 事件(cognition_log 中 type="reflection")
+  │   b. 读取当前记忆文件
+  │   c. 汇总 cognition_log 中的评估趋势(多次 harmful/unused 的 source 模式)
+  │   d. 用 dream_prompt 指导 LLM 更新记忆文件
+  │   e. 标记 reflection 事件为已消化
+  │
+  └─ 完成
+```
+
+### Per-trace 反思的输入
+
+反思 prompt 看到的不只是"发生了什么",还包括知识评估结果:
+
+```
+## 执行过程
+[该 trace 中 reflected_at_sequence 之后的消息]
+
+## 知识使用情况(来自 cognition_log.json)
+查询 1(sequence 42):"ControlNet 相关工具知识"
+  → source knowledge-a1b2: helpful — "准确定位了问题"
+  → source knowledge-c3d4: irrelevant — "与当前任务无关"
+  → source knowledge-e5f6: harmful — "建议的方法已过时"
+
+## 请反思
+1. 这次执行中有什么值得记住的经验?
+2. 哪些知识的评估结果反映了我的判断需要调整?
+3. 用户的反馈(如果有)说明了什么?
+```
+
+这样,已有的 knowledge_eval 结果直接成为记忆反思的输入,不需要重复评估。
+
+### Dream 整合的输入
+
+Dream prompt 看到的是:
+
+```
+## 最近的反思摘要
+[各 trace 的 reflect_summary,每份几百 token]
+
+## 知识评估趋势(汇总自各 trace 的 cognition_log)
+- 最近 N 个 trace 中,被评为 harmful 的 source:[列表]
+- 被评为 unused 的高频 source 类型:[统计]
+- 被评为 helpful 的查询模式:[统计]
+
+## 当前记忆文件
+[各文件内容]
+
+## 请更新记忆
+[dream_prompt 的具体指导]
+```
+
+---
+
+## 五、与现有实现的集成
+
+### 不改动的部分
+
+| 现有机制 | 保持不变 | 原因 |
+|---|---|---|
+| reflection 侧分支 | ✅ | 知识提取到 KnowHub,时效性要求高,必须在压缩前做 |
+| knowledge_eval 侧分支 | ✅ | 知识评估,时效性要求高,必须在压缩前做 |
+| force_side_branch 队列 | ✅ | 侧分支排序机制,成熟可靠 |
+| cognition_log.json | ✅ | 统一事件流存储(原 knowledge_log.json 扩展),dream 直接读取 |
+| 三个评估触发点 | ✅ | Goal 完成/压缩前/任务结束 |
+
+### 需要修改的部分
+
+**1. 任务完成时的 reflection prompt 选择**
+
+当前 `runner.py:1249` 始终使用 `get_reflect_prompt()`。应区分场景:
+
+```python
+# runner.py:1248-1249 修改
+if branch_type == "reflection":
+    if break_after_side_branch:  # 任务完成后的反思
+        prompt = config.knowledge.get_completion_reflect_prompt()
+    else:  # 压缩前的反思
+        prompt = config.knowledge.get_reflect_prompt()
+```
+
+这是一个独立的 bug fix,不依赖 Memory 系统。
+
+**2. Trace 模型扩展**
+
+`agent/trace/models.py:Trace` 新增字段:
+
+```python
+reflected_at_sequence: Optional[int] = None    # 上次记忆反思的 sequence
+# 反思摘要存在 cognition_log.json 中(type="reflection" 事件),不在 Trace 模型中
+```
+
+**3. RunConfig 扩展**
+
+`agent/core/runner.py:RunConfig` 新增可选字段:
+
+```python
+memory: Optional[MemoryConfig] = None
+```
+
+**4. KnowledgeConfig 扩展**
+
+`agent/core/runner.py:KnowledgeConfig`(或对应类)新增字段:
+
+```python
+reflect_auto_commit: bool = False
+# False(默认,所有 Agent): reflection 只写 cognition_log: type="extraction_pending"
+#                          人工通过 CLI review + commit 才进 KnowHub
+# True(手动开启)         : reflection 直接 upload_knowledge,保留旧的"提取即上传"行为
+```
+
+**5. Reflection 侧分支行为变更**
+
+当前 reflection 的 prompt 直接指导 LLM 调用 `upload_knowledge`。需要改为:
+- `reflect_auto_commit=False` 时:prompt 指导 LLM 调用新的 `record_pending_extraction` 工具(仅写 cognition_log)
+- `reflect_auto_commit=True` 时:保持当前行为
+
+或者更简洁的实现:reflection 始终调用 `record_pending_extraction`,由侧分支结束后的 hook 根据 `reflect_auto_commit` 决定是否立即调用 `commit_approved`(视为全部 approved)。这避免了 prompt 分叉。
+
+### 新增的部分
+
+**1. MemoryConfig**
+
+```python
+@dataclass
+class MemoryConfig:
+    """持久化记忆配置"""
+
+    base_path: str = ""                          # 记忆文件目录
+    files: Optional[Dict[str, str]] = None       # {路径: 用途说明}
+    # key 是相对 base_path 的路径,支持嵌套(如 "core/identity.md")或 glob
+    # (如 "relationships/*.md")。框架只负责按 key 读文件内容注入上下文,
+    # 组织结构由配置者决定。
+    dream_prompt: str = ""                       # Dream 整合 prompt(空用默认)
+    reflect_prompt: str = ""                     # Per-trace 反思 prompt(空用默认)
+```
+
+**2. Run 启动时记忆加载**
+
+Memory-bearing Agent 的 run 启动时,框架按 `files` 的 key 依次解析(直接路径或 glob 匹配),读取命中的文件内容以字符串形式注入上下文。Agent 可用 write_file 新增文件;只要新文件的路径匹配某条 key(直接路径或 glob),下次 run 启动时自动加载。
+
+**3. Dream 操作**
+
+以 `dream` 工具形式提供,Agent 可主动调用:
+
+```python
+@tool
+async def dream() -> ToolResult:
+    """整理长期记忆。回顾最近的执行历史,更新记忆文件。"""
+    # 1. 扫描需要反思的 trace
+    # 2. 逐个 per-trace 反思
+    # 3. 跨 trace 整合,更新记忆文件
+```
+
+也可以作为 `AgentRunner` 的方法暴露,供外部调度直接调用。
+
+**4. 提取审核 CLI 流程**
+
+为支持"提取-审核-提交"两阶段(见第三节),新增 `agent/cli/extraction_review.py` 模块。**不是 Agent 工具**(Agent 不应自我审核),是 CLI 内部模块 + 独立可执行脚本:
+
+```python
+# agent/cli/extraction_review.py
+
+async def list_pending(trace_id: str) -> list[PendingExtraction]:
+    """读 cognition_log,返回 type=extraction_pending 且未 reviewed 的条目"""
+
+async def review_one(
+    trace_id: str,
+    extraction_id: str,
+    decision: Literal["approve", "edit", "discard"],
+    edited_content: Optional[str] = None,
+) -> None:
+    """写 reviewed 事件到 cognition_log"""
+
+async def commit_approved(trace_id: str) -> CommitReport:
+    """批量上传 approved 条目到 KnowHub,写 committed 事件"""
+```
+
+可独立调用:
+
+```bash
+python -m agent.cli.extraction_review --trace XXX --list
+python -m agent.cli.extraction_review --trace XXX --commit
+```
+
+**集成到现有交互式 CLI**(`agent/cli/interactive.py:174` 的菜单)扩展两项:
+
+```
+  1. 插入干预消息并继续
+  2. 触发经验总结(reflect)         ← 现有
+  ...
+  8. 审核待提交知识(review)        ← 新增
+  9. 提交已审核知识到 KnowHub        ← 新增
+```
+
+`8` 进入交互式 review 循环:
+
+```
+[1/3] tool 经验
+─────────────────────
+nanobanana 工具的 strength 参数 < 0.3 时会丢失原图轮廓...
+─────────────────────
+[a]pprove / [e]dit / [d]iscard / [s]kip / [q]uit:
+```
+
+`9` 显示 approved 列表 + 用户最终确认 → 调 `commit_approved`,输出 commit 报告(成功/失败条数、KnowHub 返回的 ID)。
+
+**实现注意**:
+- 现有 `perform_reflection`(`interactive.py:269`)走 HTTP API(`/api/traces/{trace_id}/reflect`)。新流程同样应该走 API 端点(如 `POST /api/traces/{trace_id}/extractions/{id}/review`、`POST /api/traces/{trace_id}/extractions/commit`),让未来 Web UI 能复用同一套审核流,而不是 CLI 直接读写 cognition_log 文件。
+- "edit" 分支允许用户直接修改 LLM 生成的 markdown 内容;初版只支持改正文文本,后续可扩展到改类型/metadata。
+
+---
+
+## 六、完整的元思考数据流
+
+```
+Agent 执行任务(Trace)
+  │
+  ├─ 知识查询(ask)→ cognition_log: type="query"(含整合回答 + source_ids)
+  │
+  ├─ Goal 完成 → 触发 knowledge_eval 侧分支 → cognition_log: type="evaluation"
+  │
+  ├─ 压缩触发 →
+  │   队列: [reflection, knowledge_eval, compression]
+  │   reflection: 提取客观知识 → cognition_log: type="extraction_pending"
+  │                            (默认不直接 upload,等人工 review)
+  │   knowledge_eval: 评估各 source → cognition_log: type="evaluation"
+  │   compression: 压缩上下文
+  │
+  ├─ 任务完成 →
+  │   knowledge_eval(如有 pending)→ cognition_log: type="evaluation"
+  │   reflection → cognition_log: type="extraction_pending"
+  │
+  ├─ 人工审核(CLI 触发,可发生在任意时刻)→
+  │   逐条 approve/edit/discard → cognition_log: type="extraction_reviewed"
+  │   批量 commit → upload_knowledge → KnowHub
+  │                + cognition_log: type="extraction_committed"
+  │
+  └─ Trace 状态更新(新消息使 reflected_at_sequence 落后)
+
+         ···时间流逝,可能有多个 trace···
+
+Dream 触发(Agent 主动调用 / 外部调度)
+  │
+  ├─ Per-trace 记忆反思
+  │   输入: 未反思的消息 + cognition_log 中的 query/evaluation/extraction 事件
+  │   输出: cognition_log: type="reflection"
+  │
+  ├─ 跨 trace 整合
+  │   输入: 各 trace 的 reflection 事件 + evaluation 趋势 + 当前记忆文件
+  │   输出: 更新后的记忆文件(taste.md, strategy.md, ...)
+  │
+  └─ 记忆文件被下次 run 加载 → 影响 Agent 行为 → 新的 Trace → ...
+```
+
+### 三种元思考的时间线
+
+```
+Trace 执行中:
+  ──[Goal完成]──knowledge_eval──[压缩]──reflection→knowledge_eval→compression──
+
+Trace 结束后:
+  ──knowledge_eval──reflection(completion)──
+
+之后某个时刻:
+  ──dream──per-trace记忆反思──跨trace整合──更新记忆文件──
+```
+
+即时的元思考(knowledge_eval、reflection)保护信息不被压缩丢失。
+延迟的元思考(dream)在全局视角下更新个人记忆。两者互补。
+
+---
+
+## 七、记忆模型全景
+
+Memory 和 Knowledge 是**两条平行的线**,而不是抽象层级。区分维度是"主观 vs 客观"和"私有 vs 共享"。Memory 不会"升级"成 Knowledge,反过来也不会。
+
+```
+                    ┌─────────────────────────────┐
+                    │ Trace(任务状态 / 工作记忆)  │
+                    │ Messages + Goals             │
+                    │ + cognition_log              │
+                    └──────────┬──────────────────┘
+                               │
+              dream 反思       │      reflection 提取(→ pending)
+              (延迟、可选)    │      knowledge_eval 评估
+                               │      (即时、必做)
+                  ┌────────────┴───────────┐
+                  ▼                        ▼
+        ┌──────────────────┐     ┌──────────────────────┐
+        │ Memory           │     │ Knowledge            │
+        │ Agent 身份私有    │     │ KnowHub 全局共享      │
+        ├──────────────────┤     ├──────────────────────┤
+        │ 主观 / 偏好 / 策略 │     │ 客观 / 工具 / 调研    │
+        │ Markdown 文件     │     │ DB + 向量索引         │
+        │ 人类可直接编辑     │     │ 经 review 才入库      │
+        │ 来源: dream       │     │ 来源: reflection      │
+        └────────┬─────────┘     └──────────┬───────────┘
+                 │                          │
+                 └────注入下次 run──────────┘
+```
+
+**两条线的交互**(不是层级关系,是同源 + 互相参考):
+
+- 都源自同一个 Trace(cognition_log 是共同的事件流)
+- dream 在生成记忆摘要时可以参考 cognition_log 中的 evaluation 趋势(来自 Knowledge 这条线)
+- reflection 也可以参考 Memory 来判断"这条经验我已经记过了"
+- 但二者的**读者不同**:Memory 只服务于同一身份的未来 run;Knowledge 服务于所有 Agent
+
+---
+
+## 八、两类 Agent
+
+| | 默认 Agent | Memory-bearing Agent |
+|---|---|---|
+| 知识提取(reflection) | ✅ 配置 KnowledgeConfig | ✅ 配置 KnowledgeConfig |
+| 知识评估(knowledge_eval) | ✅ 自动 | ✅ 自动 |
+| 个人记忆 | ❌ | ✅ 配置 MemoryConfig |
+| Dream | ❌ | ✅ 可调用 dream 工具 |
+| Run 启动加载记忆 | ❌ | ✅ 自动注入 |
+
+Memory 是 opt-in 的增量能力。但**知识提取的提交行为变了**:默认 Agent 也不再自动 upload 到 KnowHub,必须通过 CLI 人工 review + commit;如需保留旧的"提取即上传"行为,手动设置 `KnowledgeConfig.reflect_auto_commit=True`。
+
+---
+
+## 九、开放与已决问题
+
+`[DECIDED]` 已有落地结论;`[OPEN]` 尚未决定,等真实运行数据再定。
+
+1. `[DECIDED]` **记忆注入方式** → system prompt 末尾追加。见 `runner.py:_build_system_prompt` 里调 `format_memory_injection`。代价:若记忆文件很大,每轮 LLM 调用都带 —— 暂时接受,等实际观察到膨胀再换方案。
+2. `[OPEN]` **并发写冲突**:多个 Agent run 同时写同一个记忆文件怎么办?文件锁?还是 dream 统一写、其他 run 只读?当前没做并发保护,假设 dream 是单一写入方。
+3. `[OPEN]` **记忆膨胀**:记忆文件越来越长怎么办?`DEFAULT_DREAM_PROMPT` 已写"在原有基础上演进不要重写",但是否真能控制住要看实际使用。
+4. `[OPEN]` **Per-trace 反思的成本控制**:很短的 trace 不值得反思。当前 `per_trace_reflect` 无下限阈值,所有 `reflected_at_sequence < last_sequence` 的 trace 都会反思。
+5. `[OPEN]` **Knowledge eval 结果回传 KnowHub**:仍然只存本地 cognition_log。
+6. `[DECIDED]` **Dream 中评估趋势的呈现方式** → LLM 直接读 cognition_log 原始事件。见 `dream.py:_build_reflect_input`,把 query / evaluation / extraction_pending / extraction_committed 事件摘要化后一并塞给 LLM,不做预计算统计。
+7. `[DECIDED]` **Dream 操作的实现形式** → 两者都提供。Agent 主动调用走 `dream` 工具(`agent/tools/builtin/memory.py`,`memory` 组),外部调度走 `AgentRunner.dream()` 方法。
+8. `[OPEN]` **未 review 的 pending 提取何时清理**:目前没有 TTL,pending 无限期累积。等观察积压速度再定(例如 30 天未 review 自动 discard / 归档)。
+9. `[OPEN]` **review 的"edit"分支允许多深**:初版只支持改 markdown 字段(task/content/score/tags)。改 type 或 metadata 目前需 discard 重写。
+10. `[OPEN]` **批量 review 的辅助能力**:当前逐条看。未做批量 approve / 相似条目去重 / LLM 预筛。
+11. `[OPEN]` **Dream 的 JSON 解析脆弱性**:`cross_trace_integrate` 依赖 LLM 严格输出 `{updates:[...]}` JSON(见 `dream.py:_parse_dream_output`)。真实 LLM 可能偶尔加前言、用不同键名。首次线上运行需监控 parse 失败率,必要时加重试 + 更严格 prompt。
+
+---
+
+## 十、实现与入口
+
+2026-04 落地清单,供看代码时快速定位。
+
+### 10.1 数据层
+
+| 改动 | 位置 |
+|---|---|
+| Trace 新字段 `reflected_at_sequence` | `agent/trace/models.py:Trace` |
+| cognition_log 事件 schema(含新增的 extraction_pending/reviewed/committed + reflection) | `agent/trace/store.py:append_cognition_event` docstring |
+
+### 10.2 提取-审核-提交两阶段
+
+| 职责 | 位置 |
+|---|---|
+| LLM 暂存用工具(core 组默认可见) | `agent/tools/builtin/knowledge.py:knowledge_save_pending` |
+| 反思 prompts(已改为调 `knowledge_save_pending`) | `agent/core/prompts/knowledge.py` |
+| Auto-commit 开关(默认 False) | `KnowledgeConfig.reflect_auto_commit` |
+| 反思侧分支退出时的 auto-commit hook | `agent/core/runner.py` 反射分支退出分支内 |
+| 核心逻辑(list_pending / review_one / commit_approved / auto_commit_branch) | `agent/trace/extraction_review.py` |
+| **独立 CLI 入口** | `python -m agent.cli.extraction_review --trace <ID> [--list/--list-all/--review/--commit]` |
+| **交互式菜单入口** | `agent/cli/interactive.py` 菜单项 8(review)/ 9(commit) |
+| **HTTP API 入口** | `GET /api/traces/{tid}/extractions`、`POST .../extractions/{eid}/review`、`POST .../extractions/commit`(见 `agent/trace/run_api.py`) |
+
+三种入口共享同一个核心模块 `agent/trace/extraction_review.py`。
+
+### 10.3 Memory + Dream
+
+| 职责 | 位置 |
+|---|---|
+| MemoryConfig 定义 | `agent/core/memory.py:MemoryConfig` |
+| 记忆文件加载(支持 glob + 去重) | `agent/core/memory.py:load_memory_files` |
+| 记忆注入格式 | `agent/core/memory.py:format_memory_injection` |
+| 注入到 system prompt | `agent/core/runner.py:_build_system_prompt`(memory 段落在 skills 段之后) |
+| Dream per-trace 反思 | `agent/core/dream.py:per_trace_reflect` |
+| Dream 跨 trace 整合 | `agent/core/dream.py:cross_trace_integrate` |
+| Dream 顶层入口 | `agent/core/dream.py:run_dream` |
+| **Agent 工具入口(memory 组)** | `agent/tools/builtin/memory.py:dream` |
+| **外部调度入口** | `AgentRunner.dream(memory_config, trace_filter=..., reflect_model=..., dream_model=...)` |
+| 默认 prompts | `dream.py:DEFAULT_REFLECT_PROMPT` / `DEFAULT_DREAM_PROMPT`(可通过 `MemoryConfig.reflect_prompt`/`dream_prompt` 覆盖) |
+
+### 10.4 启用方式
+
+默认 Agent 不启用 memory,但**提取审核仍然生效**(pending 不自动上传 KnowHub)。
+
+要让某个 example 直接上传(恢复旧行为):
+```python
+RunConfig(knowledge=KnowledgeConfig(reflect_auto_commit=True))
+```
+
+要让一个 Agent 变成 memory-bearing:
+```python
+from agent.core.memory import MemoryConfig
+
+RunConfig(
+    memory=MemoryConfig(
+        base_path="/path/to/agent_memory",
+        files={
+            "taste.md": "品味偏好",
+            "strategy.md": "当前策略",
+            "journals/*.md": "执行日记",
+        },
+    ),
+    tool_groups=["core", "memory"],   # memory 组暴露 dream 工具
+)
+```
+
+然后周期性(或 Agent 主动调用 `dream` 工具)触发:
+```python
+await runner.dream(memory_config=rc.memory)
+```
+
+### 10.5 已知 rough edges
+
+- 实施过程发现旧的 `upload_knowledge` 引用是悬空的(仓内无实现),未清理 `examples/*/prompt` 里的残留引用
+- Dream 两次 LLM 调用(reflect + integrate)默认模型写死 `gpt-4o-mini` / `gpt-4o`,未接入 RunConfig 的 utility_llm_call
+- `trace_filter` 没提供按 `agent_type` / `owner` 过滤的便捷函数,调用方传 lambda

+ 6 - 1
agent/tools/builtin/__init__.py

@@ -17,7 +17,9 @@ from agent.tools.builtin.bash import bash_command
 from agent.tools.builtin.skill import skill, list_skills
 from agent.tools.builtin.subagent import agent, evaluate
 # sandbox 工具已废弃(2026-04);search.py / crawler.py 已重构为 content/ 工具族(2026-04)
-from agent.tools.builtin.knowledge import(knowledge_search,knowledge_save,knowledge_list,knowledge_update,knowledge_batch_update,knowledge_slim)
+from agent.tools.builtin.knowledge import(knowledge_search,knowledge_save,knowledge_save_pending,knowledge_list,knowledge_update,knowledge_batch_update,knowledge_slim)
+# Memory / Dream(见 agent/docs/memory-plan.md)
+from agent.tools.builtin.memory import dream
 # 知识上传/查询已统一到 agent 工具:
 #   agent(agent_type="remote_librarian", task=...)         # 查询
 #   agent(agent_type="remote_librarian_ingest", task=...)  # 上传(异步)
@@ -78,4 +80,7 @@ __all__ = [
     "import_content",
     # Goal 管理
     "goal",
+    # Memory & Knowledge 提取审核
+    "knowledge_save_pending",  # 反思侧分支暂存(core 组默认可见)
+    "dream",                    # memory-bearing Agent 整理长期记忆(memory 组)
 ]

+ 5 - 4
agent/tools/builtin/content/cache.py

@@ -2,7 +2,8 @@
 内容搜索缓存(磁盘持久化)
 
 搜索结果按 trace_id 隔离,同一 Agent session 内的 CLI 多次调用也能复用。
-文件格式:/tmp/content_cache_{trace_id}.json
+文件格式:<cwd>/.cache/content_search/{trace_id}.json
+锚在调用方 CWD 的 .cache/ 下,每个项目隔离且 gitignore 友好。
 """
 
 import json
@@ -11,15 +12,15 @@ import time
 from pathlib import Path
 from typing import Any, Dict, List, Optional
 
-import tempfile
-_CACHE_DIR = Path(tempfile.gettempdir()) / "agent_content_cache"
+# CWD 锚定 —— 每个调用项目有独立缓存目录,避免 /tmp 跨会话污染
+_CACHE_DIR = Path(os.getcwd()) / ".cache" / "content_search"
 _CACHE_DIR.mkdir(parents=True, exist_ok=True)
 _CACHE_TTL = 3600  # 1 小时过期
 
 
 def _cache_path(trace_id: str) -> Path:
     safe_id = trace_id.replace("/", "_").replace("..", "_")
-    return _CACHE_DIR / f"content_cache_{safe_id}.json"
+    return _CACHE_DIR / f"{safe_id}.json"
 
 
 def _load_raw(trace_id: str) -> dict:

+ 115 - 0
agent/tools/builtin/knowledge.py

@@ -8,6 +8,7 @@ import os
 import json
 import logging
 import subprocess
+import uuid
 import httpx
 from dataclasses import dataclass
 from typing import List, Dict, Optional, Any
@@ -34,6 +35,12 @@ class KnowledgeConfig:
     enable_completion_extraction: bool = True      # 是否在运行完成后提取知识
     completion_reflect_prompt: str = ""            # 自定义复盘 prompt;空则使用默认,见 agent/core/prompts/knowledge.py:COMPLETION_REFLECT_PROMPT
 
+    # 提取-审核-提交两阶段开关(见 agent/docs/memory-plan.md 第三节)
+    reflect_auto_commit: bool = False
+    # False(默认): reflection 仅写 cognition_log: type="extraction_pending",
+    #               人工通过 CLI(agent/cli/extraction_review.py)review + commit 才进 KnowHub
+    # True         : reflection 直接 upload_knowledge(旧行为),适合无人值守的 example
+
     # 知识注入(agent切换当前工作的goal时,自动注入相关知识)
     enable_injection: bool = True          # 是否在 focus goal 时自动注入相关知识
 
@@ -274,6 +281,114 @@ async def knowledge_save(
         )
 
 
+@tool(groups=["core"], hidden_params=["context"])
+async def knowledge_save_pending(
+    task: str,
+    content: str,
+    types: List[str],
+    tags: Optional[Dict[str, str]] = None,
+    scopes: Optional[List[str]] = None,
+    owner: Optional[str] = None,
+    resource_ids: Optional[List[str]] = None,
+    source_name: str = "",
+    source_category: str = "exp",
+    urls: Optional[List[str]] = None,
+    agent_id: str = "research_agent",
+    submitted_by: str = "",
+    score: int = 3,
+    capability_ids: Optional[List[str]] = None,
+    tool_ids: Optional[List[str]] = None,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    暂存一条待审核的知识提取(不直接写入 KnowHub)。
+
+    写入 cognition_log: type="extraction_pending",等待人工通过 CLI
+    (agent/cli/extraction_review.py)review + commit 才会进入 KnowHub。
+    参数与 knowledge_save 对齐,review 通过后字段透传给 knowledge_save。
+
+    Args:
+        task: 任务描述(在什么情景下 + 要完成什么目标)
+        content: 核心内容
+        types: 知识类型 ["experience"] / ["tool"] / ["strategy"] / ["case"]
+        tags: 业务标签
+        scopes: 可见范围(默认 ["org:cybertogether"],commit 时应用)
+        owner: 所有者(commit 时应用)
+        resource_ids: 关联的资源 ID
+        source_name: 来源名称
+        source_category: 来源类别(paper/exp/skill/book)
+        urls: 参考来源链接
+        agent_id: 执行此调研的 agent ID
+        submitted_by: 提交者
+        score: 初始评分 1-5
+        capability_ids: 关联的能力 ID
+        tool_ids: 关联的工具 ID
+
+    Returns:
+        暂存结果(含 extraction_id,用于后续 review/commit)
+    """
+    try:
+        store = context.get("store") if context else None
+        trace_id = context.get("trace_id") if context else None
+        sequence = context.get("sequence") if context else None
+        goal_id = context.get("goal_id") if context else None
+        side_branch = context.get("side_branch") if context else None
+
+        if not store or not trace_id:
+            return ToolResult(
+                title="❌ 暂存失败",
+                output="缺少 store 或 trace_id,无法写入 cognition_log",
+                error="missing trace context"
+            )
+
+        extraction_id = f"pending-{uuid.uuid4().hex[:12]}"
+
+        payload = {
+            "task": task,
+            "content": content,
+            "types": types,
+            "tags": tags or {},
+            "scopes": scopes,
+            "owner": owner,
+            "resource_ids": resource_ids or [],
+            "source_name": source_name,
+            "source_category": source_category,
+            "urls": urls or [],
+            "agent_id": agent_id,
+            "submitted_by": submitted_by,
+            "score": score,
+            "capability_ids": capability_ids or [],
+            "tool_ids": tool_ids or [],
+        }
+
+        await store.append_cognition_event(
+            trace_id=trace_id,
+            event={
+                "type": "extraction_pending",
+                "extraction_id": extraction_id,
+                "sequence": sequence,
+                "goal_id": goal_id,
+                "branch_id": side_branch.get("branch_id") if side_branch else None,
+                "payload": payload,
+            }
+        )
+
+        return ToolResult(
+            title="✅ 已暂存待审核",
+            output=f"Extraction ID: {extraction_id}\n主题: {task[:80]}\n类型: {types}\n评分: {score}\n\n等待人工 review + commit 才会进入 KnowHub。",
+            long_term_memory=f"暂存知识提取: {extraction_id} - {task[:50]}",
+            metadata={"extraction_id": extraction_id}
+        )
+
+    except Exception as e:
+        logger.error(f"暂存待审核知识失败: {e}")
+        return ToolResult(
+            title="❌ 暂存失败",
+            output=f"错误: {str(e)}",
+            error=str(e)
+        )
+
+
 @tool(groups=["knowledge_internal"], hidden_params=["context"])
 async def knowledge_update(
     knowledge_id: str,

+ 96 - 0
agent/tools/builtin/memory.py

@@ -0,0 +1,96 @@
+"""
+Memory 相关工具 —— 目前只包含 dream 操作(见 agent/docs/memory-plan.md 第四节)。
+
+dream 整理 Agent 身份的长期记忆:回顾最近 trace 的执行历史,
+逐个 trace 做反思,再跨 trace 整合写回记忆文件。
+
+设计要点:
+- 需要 config.memory(MemoryConfig)才可用;否则报错。
+- 不是 knowledge_save_pending 那样每 trace 都要用的日常工具 ——
+  所以放在独立 group "memory",通过 tool_groups 显式开启。
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Optional
+
+from agent.tools import tool, ToolResult, ToolContext
+
+logger = logging.getLogger(__name__)
+
+
+@tool(groups=["memory"], hidden_params=["context"])
+async def dream(
+    reflect_model: str = "",
+    dream_model: str = "",
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """整理长期记忆。回顾最近的执行历史,更新记忆文件。
+
+    本工具做两件事:
+        1. per-trace 反思:扫描未反思的 trace,为每个生成反思摘要
+        2. 跨 trace 整合:汇总未消化的反思 + 当前记忆,让 LLM 更新记忆文件
+
+    需要 RunConfig.memory(MemoryConfig)才可调用。
+
+    Args:
+        reflect_model: per-trace 反思用的模型(空则默认 gpt-4o-mini)
+        dream_model:   跨 trace 整合用的模型(空则默认 gpt-4o)
+    """
+    runner = context.get("runner") if context else None
+    if runner is None:
+        return ToolResult(
+            title="❌ dream 不可用",
+            output="缺少 runner(需要从 AgentRunner 上下文调用)",
+            error="runner not in context",
+        )
+
+    memory_config = getattr(runner, "_current_memory_config", None)
+    if memory_config is None:
+        return ToolResult(
+            title="❌ dream 不可用",
+            output="当前 Agent 未配置 MemoryConfig,不是 memory-bearing Agent",
+            error="memory not configured",
+        )
+
+    if not runner.trace_store or not runner.llm_call:
+        return ToolResult(
+            title="❌ dream 不可用",
+            output="runner 缺少 trace_store 或 llm_call",
+            error="runner dependencies missing",
+        )
+
+    from agent.core.dream import run_dream
+    report = await run_dream(
+        store=runner.trace_store,
+        llm_call=runner.llm_call,
+        memory_config=memory_config,
+        reflect_model=reflect_model or "gpt-4o-mini",
+        dream_model=dream_model or "gpt-4o",
+    )
+
+    lines = []
+    lines.append(f"per-trace 反思: {len(report.per_trace_summaries)} 条")
+    if report.skipped_traces:
+        lines.append(f"跳过: {len(report.skipped_traces)} 条 trace(日志详见 logger)")
+    lines.append(f"消化 reflection: {report.consumed_reflection_count} 条")
+    lines.append(f"更新记忆文件: {len(report.updated_files)} 个")
+    for p in report.updated_files:
+        lines.append(f"  - {p}")
+    if report.reasoning:
+        lines.append(f"\n整合理由: {report.reasoning}")
+
+    output = "\n".join(lines)
+    return ToolResult(
+        title="🧠 dream 完成",
+        output=output,
+        long_term_memory=f"dream: reflected={len(report.per_trace_summaries)}, "
+                         f"consumed={report.consumed_reflection_count}, "
+                         f"files_updated={len(report.updated_files)}",
+        metadata={
+            "per_trace_count": len(report.per_trace_summaries),
+            "consumed": report.consumed_reflection_count,
+            "updated_files": report.updated_files,
+        },
+    )

+ 234 - 0
agent/trace/extraction_review.py

@@ -0,0 +1,234 @@
+"""
+提取审核工具库(Phase 1.2+)
+
+共享核心逻辑给三个入口复用:
+- agent/cli/extraction_review.py  —— 独立 CLI 入口
+- agent/cli/interactive.py        —— 交互式会话菜单
+- agent/trace/run_api.py          —— HTTP API 端点
+
+职责划分:
+- 本模块:从 cognition_log 读 pending、生成 review 事件、批量调 knowledge_save 并写 committed 事件
+- 上游(runner):反思侧分支退出时,若 reflect_auto_commit=True 则调 auto_commit_branch
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any, Dict, List, Literal, Optional
+
+from agent.trace.store import FileSystemTraceStore
+
+ReviewDecision = Literal["approve", "edit", "discard"]
+
+
+@dataclass
+class PendingExtraction:
+    """一条待审核的提取条目(从 cognition_log 还原)"""
+    extraction_id: str
+    sequence: Optional[int]
+    goal_id: Optional[str]
+    branch_id: Optional[str]
+    payload: Dict[str, Any]
+    reviewed: bool = False
+    decision: Optional[ReviewDecision] = None
+    committed: bool = False
+
+
+@dataclass
+class CommitReport:
+    """批量 commit 的结果"""
+    committed: List[str] = field(default_factory=list)     # 成功的 extraction_id 列表
+    knowledge_ids: List[str] = field(default_factory=list) # KnowHub 返回的新增 ID
+    failed: List[Dict[str, str]] = field(default_factory=list)  # [{extraction_id, error}]
+    skipped: List[str] = field(default_factory=list)       # 不是 approved 或已 committed
+
+
+async def list_pending(
+    store: FileSystemTraceStore,
+    trace_id: str,
+    branch_id: Optional[str] = None,
+    include_reviewed: bool = False,
+) -> List[PendingExtraction]:
+    """列出 trace 下的 pending 提取条目。
+
+    Args:
+        branch_id: 若指定,仅返回此反思分支产出的 pending(用于 auto_commit_branch)
+        include_reviewed: 是否包含已 reviewed 的条目(默认只返回未 reviewed)
+
+    Returns:
+        按 pending 出现顺序排列的条目列表
+    """
+    log = await store.get_cognition_log(trace_id)
+    events = log.get("events", log.get("entries", []))
+
+    reviewed_index: Dict[str, ReviewDecision] = {}
+    committed_ids: set[str] = set()
+    for e in events:
+        if e.get("type") == "extraction_reviewed":
+            eid = e.get("extraction_id")
+            if eid:
+                reviewed_index[eid] = e.get("decision")
+        elif e.get("type") == "extraction_committed":
+            eid = e.get("extraction_id")
+            if eid:
+                committed_ids.add(eid)
+
+    pendings: List[PendingExtraction] = []
+    for e in events:
+        if e.get("type") != "extraction_pending":
+            continue
+        eid = e.get("extraction_id")
+        if not eid:
+            continue
+        if branch_id is not None and e.get("branch_id") != branch_id:
+            continue
+        reviewed = eid in reviewed_index
+        if reviewed and not include_reviewed:
+            continue
+        pendings.append(
+            PendingExtraction(
+                extraction_id=eid,
+                sequence=e.get("sequence"),
+                goal_id=e.get("goal_id"),
+                branch_id=e.get("branch_id"),
+                payload=e.get("payload", {}),
+                reviewed=reviewed,
+                decision=reviewed_index.get(eid),
+                committed=eid in committed_ids,
+            )
+        )
+    return pendings
+
+
+async def review_one(
+    store: FileSystemTraceStore,
+    trace_id: str,
+    extraction_id: str,
+    decision: ReviewDecision,
+    edited_payload: Optional[Dict[str, Any]] = None,
+) -> None:
+    """对某条 pending 生成 review 事件。
+
+    - approve: 保留原 payload,标记为可 commit
+    - edit:    用 edited_payload 覆盖原 payload(仅本事件内),标记为可 commit
+    - discard: 丢弃,不会被 commit
+    """
+    event: Dict[str, Any] = {
+        "type": "extraction_reviewed",
+        "extraction_id": extraction_id,
+        "decision": decision,
+    }
+    if decision == "edit" and edited_payload is not None:
+        event["edited_payload"] = edited_payload
+    await store.append_cognition_event(trace_id=trace_id, event=event)
+
+
+def _resolve_effective_payload(
+    pending: PendingExtraction,
+    review_events: List[Dict[str, Any]],
+) -> Dict[str, Any]:
+    """合并原 payload 与最后一次 edit 的 payload。"""
+    for e in reversed(review_events):
+        if (
+            e.get("extraction_id") == pending.extraction_id
+            and e.get("decision") == "edit"
+            and isinstance(e.get("edited_payload"), dict)
+        ):
+            return e["edited_payload"]
+    return pending.payload
+
+
+async def commit_approved(
+    store: FileSystemTraceStore,
+    trace_id: str,
+    branch_id: Optional[str] = None,
+) -> CommitReport:
+    """把已 approved/edited 但未 committed 的条目批量调 knowledge_save 上传。
+
+    Args:
+        branch_id: 若指定,只处理此分支的条目(auto_commit_branch 用)
+    """
+    from agent.tools.builtin.knowledge import knowledge_save
+
+    log = await store.get_cognition_log(trace_id)
+    events = log.get("events", log.get("entries", []))
+    review_events = [e for e in events if e.get("type") == "extraction_reviewed"]
+
+    all_pendings = await list_pending(
+        store, trace_id, branch_id=branch_id, include_reviewed=True
+    )
+
+    report = CommitReport()
+
+    for p in all_pendings:
+        if p.committed:
+            report.skipped.append(p.extraction_id)
+            continue
+        if p.decision not in ("approve", "edit"):
+            report.skipped.append(p.extraction_id)
+            continue
+
+        payload = _resolve_effective_payload(p, review_events)
+
+        try:
+            result = await knowledge_save(
+                task=payload.get("task", ""),
+                content=payload.get("content", ""),
+                types=payload.get("types", []),
+                tags=payload.get("tags"),
+                scopes=payload.get("scopes"),
+                owner=payload.get("owner"),
+                resource_ids=payload.get("resource_ids"),
+                source_name=payload.get("source_name", ""),
+                source_category=payload.get("source_category", "exp"),
+                urls=payload.get("urls"),
+                agent_id=payload.get("agent_id", "research_agent"),
+                submitted_by=payload.get("submitted_by", ""),
+                score=payload.get("score", 3),
+                capability_ids=payload.get("capability_ids"),
+                tool_ids=payload.get("tool_ids"),
+            )
+            knowledge_id = (result.metadata or {}).get("knowledge_id", "unknown")
+            if result.error:
+                raise RuntimeError(result.error)
+
+            await store.append_cognition_event(
+                trace_id=trace_id,
+                event={
+                    "type": "extraction_committed",
+                    "extraction_id": p.extraction_id,
+                    "knowledge_id": knowledge_id,
+                },
+            )
+            report.committed.append(p.extraction_id)
+            report.knowledge_ids.append(knowledge_id)
+
+        except Exception as e:
+            report.failed.append({
+                "extraction_id": p.extraction_id,
+                "error": str(e),
+            })
+
+    return report
+
+
+async def auto_commit_branch(
+    store: FileSystemTraceStore,
+    trace_id: str,
+    branch_id: str,
+) -> CommitReport:
+    """反思侧分支退出时的自动提交(reflect_auto_commit=True 路径)。
+
+    视同全部 approved:对此分支所有未 reviewed 的 pending 先 auto-approve,
+    然后调用 commit_approved。
+    """
+    pendings = await list_pending(store, trace_id, branch_id=branch_id)
+    for p in pendings:
+        if not p.reviewed:
+            await review_one(
+                store=store,
+                trace_id=trace_id,
+                extraction_id=p.extraction_id,
+                decision="approve",
+            )
+    return await commit_approved(store, trace_id, branch_id=branch_id)

+ 6 - 0
agent/trace/models.py

@@ -79,6 +79,11 @@ class Trace:
     # 当前焦点 goal
     current_goal_id: Optional[str] = None
 
+    # Memory 系统 - 记忆反思的进度追踪(见 agent/docs/memory-plan.md 第四节)
+    # dream 操作扫描 reflected_at_sequence < latest_sequence 的 trace 做反思;
+    # None 表示该 trace 从未被记忆反思处理过。
+    reflected_at_sequence: Optional[int] = None
+
     # 结果
     result_summary: Optional[str] = None     # 执行结果摘要
     error_message: Optional[str] = None      # 错误信息
@@ -145,6 +150,7 @@ class Trace:
             "llm_params": self.llm_params,
             "context": self.context,
             "current_goal_id": self.current_goal_id,
+            "reflected_at_sequence": self.reflected_at_sequence,
             "result_summary": self.result_summary,
             "error_message": self.error_message,
             "created_at": self.created_at.isoformat() if self.created_at else None,

+ 127 - 0
agent/trace/run_api.py

@@ -114,6 +114,49 @@ class CompactResponse(BaseModel):
     message: str = ""
 
 
+# ===== 提取审核(见 agent/docs/memory-plan.md 第三节) =====
+
+class PendingExtractionModel(BaseModel):
+    extraction_id: str
+    sequence: Optional[int] = None
+    goal_id: Optional[str] = None
+    branch_id: Optional[str] = None
+    payload: Dict[str, Any]
+    reviewed: bool = False
+    decision: Optional[str] = None
+    committed: bool = False
+
+
+class ListExtractionsResponse(BaseModel):
+    trace_id: str
+    count: int
+    items: List[PendingExtractionModel]
+
+
+class ReviewRequest(BaseModel):
+    decision: str = Field(..., description="approve / edit / discard")
+    edited_payload: Optional[Dict[str, Any]] = Field(
+        None, description="decision=edit 时必填;只对本次 review 生效"
+    )
+
+
+class ReviewResponse(BaseModel):
+    trace_id: str
+    extraction_id: str
+    decision: str
+
+
+class CommitResponse(BaseModel):
+    trace_id: str
+    committed_count: int
+    failed_count: int
+    skipped_count: int
+    committed: List[str]
+    knowledge_ids: List[str]
+    failed: List[Dict[str, str]]
+    skipped: List[str]
+
+
 # ===== 后台执行 =====
 
 _running_tasks: Dict[str, asyncio.Task] = {}
@@ -507,6 +550,90 @@ async def reflect_trace(trace_id: str, req: ReflectRequest):
     )
 
 
+@router.get("/{trace_id}/extractions", response_model=ListExtractionsResponse)
+async def list_extractions(trace_id: str, include_reviewed: bool = False):
+    """列出 trace 的待审核提取条目。"""
+    runner = _get_runner()
+    if not runner.trace_store:
+        raise HTTPException(status_code=503, detail="TraceStore not configured")
+
+    from agent.trace.extraction_review import list_pending
+    pendings = await list_pending(
+        runner.trace_store, trace_id, include_reviewed=include_reviewed
+    )
+    return ListExtractionsResponse(
+        trace_id=trace_id,
+        count=len(pendings),
+        items=[
+            PendingExtractionModel(
+                extraction_id=p.extraction_id,
+                sequence=p.sequence,
+                goal_id=p.goal_id,
+                branch_id=p.branch_id,
+                payload=p.payload,
+                reviewed=p.reviewed,
+                decision=p.decision,
+                committed=p.committed,
+            )
+            for p in pendings
+        ],
+    )
+
+
+@router.post(
+    "/{trace_id}/extractions/{extraction_id}/review",
+    response_model=ReviewResponse,
+)
+async def review_extraction(trace_id: str, extraction_id: str, req: ReviewRequest):
+    """对单条 pending 提交 review 决策(approve/edit/discard)。"""
+    runner = _get_runner()
+    if not runner.trace_store:
+        raise HTTPException(status_code=503, detail="TraceStore not configured")
+
+    if req.decision not in ("approve", "edit", "discard"):
+        raise HTTPException(
+            status_code=400,
+            detail=f"decision must be approve/edit/discard, got {req.decision}",
+        )
+    if req.decision == "edit" and not req.edited_payload:
+        raise HTTPException(
+            status_code=400, detail="decision=edit 必须提供 edited_payload"
+        )
+
+    from agent.trace.extraction_review import review_one
+    await review_one(
+        runner.trace_store,
+        trace_id,
+        extraction_id,
+        req.decision,  # type: ignore[arg-type]
+        edited_payload=req.edited_payload,
+    )
+    return ReviewResponse(
+        trace_id=trace_id, extraction_id=extraction_id, decision=req.decision
+    )
+
+
+@router.post("/{trace_id}/extractions/commit", response_model=CommitResponse)
+async def commit_extractions(trace_id: str):
+    """批量把已 approved/edited 的条目上传到 KnowHub。"""
+    runner = _get_runner()
+    if not runner.trace_store:
+        raise HTTPException(status_code=503, detail="TraceStore not configured")
+
+    from agent.trace.extraction_review import commit_approved
+    report = await commit_approved(runner.trace_store, trace_id)
+    return CommitResponse(
+        trace_id=trace_id,
+        committed_count=len(report.committed),
+        failed_count=len(report.failed),
+        skipped_count=len(report.skipped),
+        committed=report.committed,
+        knowledge_ids=report.knowledge_ids,
+        failed=report.failed,
+        skipped=report.skipped,
+    )
+
+
 @router.post("/{trace_id}/compact", response_model=CompactResponse)
 async def compact_trace(trace_id: str):
     """

+ 31 - 1
agent/trace/store.py

@@ -805,7 +805,37 @@ class FileSystemTraceStore:
         trace_id: str,
         event: Dict[str, Any],
     ) -> None:
-        """追加认知事件(query/evaluation/extraction/reflection)"""
+        """追加认知事件到 cognition_log.json。
+
+        所有事件共有字段:
+            type: str         事件类型(见下表)
+            timestamp: str    ISO 格式时间戳(框架自动写入)
+
+        已定义的事件类型及典型字段:
+
+            type="query" — 知识注入查询(goal focus 时触发)
+                sequence, goal_id, query, response, source_ids, sources
+
+            type="evaluation" — 知识评估(Goal 完成/压缩前/任务结束触发)
+                knowledge_id, eval_result{relevance, utility, notes}, trigger_event
+
+            type="extraction_pending" — 反思侧分支暂存的待审核提取(Phase 1.2+)
+                extraction_id, sequence, goal_id, branch_id, payload
+                (payload 字段与 knowledge_save 参数一一对应)
+
+            type="extraction_reviewed" — 人工审核决策(CLI / HTTP API 写入)
+                extraction_id, decision("approve"/"edit"/"discard"), edited_payload?
+
+            type="extraction_committed" — 已上传到 KnowHub
+                extraction_id, knowledge_id
+
+            type="reflection" — Dream 的 per-trace 反思摘要(Phase 2.4 / 3.1)
+                sequence_range: [start, end]    本次反思覆盖的消息区间
+                summary: str                    LLM 生成的反思摘要
+                consumed_at: 可选, ISO 时间戳   当跨 trace 整合已消化此反思时写入
+
+        其他字段可按需附加,不做强校验(演进友好)。
+        """
         log = await self.get_cognition_log(trace_id)
         if "events" not in log:
             log["events"] = log.pop("entries", [])

+ 146 - 0
knowhub/docs/db-operations.md

@@ -0,0 +1,146 @@
+# 数据库操作规范
+
+**对象**:阿里云 AnalyticDB for PostgreSQL(基于 Greenplum,MPP 架构)
+
+**用途**:写 migration、运维脚本、排查数据库卡死问题时**先读这篇**。所有条目都来自踩过的坑,不是推测。
+
+---
+
+## 1. 致命操作(永远不要做)
+
+| 操作 | 后果 | 替代方案 |
+|------|------|---------|
+| `ALTER TABLE ... RENAME` | 表损坏,需重启实例 | `CREATE TABLE AS SELECT` → `DROP TABLE 旧表` |
+| `ALTER TABLE ... DROP COLUMN` | 表损坏 | 同上 |
+| 事务里执行 DDL | DDL 回滚不完整,部分持久化 | `autocommit=True`,每条 DDL 独立 |
+| `FOREIGN KEY ... ON DELETE CASCADE` | 不支持(底层依赖 trigger) | 应用层级联:`knowhub_db/cascade.py` |
+| `ON CONFLICT DO UPDATE` on 加过新列的表 | AnalyticDB beam 表报错 `modification of part columns...` | 用 `DELETE WHERE id=X; INSERT ...` 代替 |
+
+**改表结构的安全模式**:`CREATE TABLE 新表 → INSERT 数据 → DROP TABLE 旧表`。参考 `migrate_v3_junction_tables.py`。
+
+---
+
+## 2. DDL 需谨慎的操作
+
+| 操作 | 风险 | 推荐做法 |
+|------|------|---------|
+| `ADD COLUMN ... NOT NULL DEFAULT 'X'` | 连接可能被服务端杀掉 | 拆成两步:`ADD COLUMN ... DEFAULT 'X'` → 另起连接 `ALTER COLUMN ... SET NOT NULL` |
+| 任何 DDL(ALTER/CREATE INDEX 等) | 需要 `AccessExclusiveLock`,任何 `idle in transaction` 会话都会阻塞无限等 | 跑前先 kill `idle in transaction`;用 `statement_timeout='30s'` 防挂起 |
+| 批量 DDL 连发 | 每次开新连接会累积 TCP 会话 | 一个长连接跑全部 DDL |
+
+---
+
+## 3. Store / 应用端连接规范
+
+### 3.1 永远 `autocommit = True`
+
+```python
+self.conn = psycopg2.connect(...)
+self.conn.autocommit = True   # 必须
+```
+
+**原因**:`autocommit = False` 下,执行一次 SELECT 就会开启一个隐式事务,如果不显式 commit/rollback,连接停在 `idle in transaction` 状态——**永久持有 AccessShareLock**,阻塞后续所有需要 AccessExclusiveLock 的 DDL。这是最难诊断、最容易卡整个系统的坑。
+
+**代价**:多语句写(entity INSERT + 若干 junction 写)失去事务原子性。但我们的写模式是 `DELETE + INSERT ON CONFLICT DO NOTHING`——幂等,失败重跑即可。
+
+详见 `decisions.md §17`。
+
+### 3.2 Migration 脚本模板
+
+```python
+import os, time, psycopg2
+from dotenv import load_dotenv
+load_dotenv()
+
+conn = psycopg2.connect(..., connect_timeout=10)
+conn.autocommit = True      # 不要开事务
+cur = conn.cursor()
+cur.execute("SET statement_timeout = '30s'")  # 卡超过 30s 自动失败
+
+# 动手前先清 idle-in-tx,防止 DDL 等锁无限阻塞
+cur.execute("""SELECT pid FROM pg_stat_activity WHERE state='idle in transaction' 
+               AND pid!=pg_backend_pid() AND datname=current_database()""")
+for (pid,) in cur.fetchall():
+    cur.execute("SELECT pg_terminate_backend(%s)", (pid,))
+
+# 每条 DDL 前打 flush 的 print(否则卡住时看不到到哪一步)
+for t in TARGETS:
+    print(f"[{time.strftime('%H:%M:%S')}] ALTER {t}...", flush=True)
+    cur.execute(f"ALTER TABLE {t} ...")
+    print(f"  ✓ done", flush=True)
+```
+
+**关键**:
+- `flush=True` 永远带上——挂起时最需要看到卡在哪一步
+- `SET statement_timeout`——宁可快速失败,不要让 client 无限等
+- 先清 idle-in-tx——否则你做的 DDL 会被别人的长事务卡住
+- 每条 DDL 前后打时间戳
+
+---
+
+## 4. 排查手册
+
+### 症状:连接失败 `remaining connection slots are reserved`
+
+**原因**:连接池打满。通常因为:
+- 有 Python 脚本被 kill 但 TCP 会话服务端未释放(等 idle timeout,可能十几分钟)
+- 生产服务(knowhub server)连接泄漏
+
+**排查**:
+```bash
+lsof -i | grep gpdbmaster   # 看本地有没有残留连接
+```
+
+**恢复**(按成本从低到高):
+1. **等**:Alibaba 的 idle session timeout 会自然释放——但"active"或"idle in transaction"不走超时
+2. **阿里云控制台**:实例管理 → 会话管理 → 手动 terminate。**首选**
+3. **重启实例**:所有连接清零,影响 1-2 分钟
+
+### 症状:DDL 挂起几十秒后 timeout 或被连接断开
+
+**原因 99%**:有 `idle in transaction` 会话持有目标表的 `AccessShareLock`。
+
+**查证**:
+```sql
+SELECT l.pid, l.mode, l.granted, c.relname, a.state, now()-a.query_start AS dur
+FROM pg_locks l
+JOIN pg_class c ON l.relation=c.oid
+LEFT JOIN pg_stat_activity a ON l.pid=a.pid
+WHERE c.relname IN ('your_target_table')
+  AND l.pid != pg_backend_pid();
+```
+
+**解决**:
+```sql
+SELECT pg_terminate_backend(<pid>);
+```
+
+### 症状:脚本静默挂住,没有任何输出
+
+**原因**:Python 的 print buffer 没 flush,execute() 已经在 wait 了。
+
+**预防**:所有 print 带 `flush=True`;`python -u` 或设 `PYTHONUNBUFFERED=1`。
+
+---
+
+## 5. 诊断脚本(都在 `knowhub/knowhub_db/scripts/`)
+
+| 脚本 | 用途 |
+|------|------|
+| `kill_db_locks.py` | 列出所有非 idle 会话 + 杀 idle-in-tx |
+| `clear_locks.py` | 清锁(轻量版) |
+| `check_table_structure.py` | 看表结构和行数 |
+| `check_extensions.py` | 看 PG 扩展(pgvector/fastann 等) |
+
+---
+
+## 6. 金句(30 秒能记住的)
+
+- **DDL 前先 `autocommit=True`**;改 store 也必须 `autocommit=True`
+- **每个 print 带 `flush=True`**
+- **`SET statement_timeout='30s'`**——宁可失败别挂死
+- **跑 DDL 前先 kill idle-in-tx**
+- **禁用 RENAME / DROP COLUMN**
+- **禁用 FK ON DELETE CASCADE**——用 `cascade.py`
+- **别在事务里跑 DDL**
+- 连接满了?**去控制台杀会话**,本地 kill 进程只关本地 socket

+ 91 - 0
knowhub/docs/decisions.md

@@ -463,3 +463,94 @@ async def llm_rerank(query: str, candidates: List[dict], top_k: int):
 - `agent/trace/store.py` - 日志管理
 
 **文档**:`knowhub/docs/feedback-timing-design.md`
+
+---
+
+## 16. Schema v4:strategy 实体 + relation_type 语义标签
+
+**日期**:2026-04-15
+
+**背景**:
+- 现有 schema 没有表达「制作策略」概念——一组原子能力的组合 + 可执行正文 + 来源知识
+- `*_knowledge` 边只表达「相关」,无法区分"这个知识构建了能力"还是"这个知识是能力的应用实例"
+- requirement / capability 想直接挂原始 resource(不经过 knowledge 整理层),现有模型不支持
+
+**决策**:
+
+1. **新增 `strategy` 实体**:`id, name, description, body, status, embedding + timestamps`。通过 `requirement_strategy` 边表达"满足关系"——strategy 是给 requirement 服务的。
+
+2. **`*_knowledge` 边加 `relation_type VARCHAR(32) DEFAULT 'related'`**:枚举值 {`source`, `case`, `compose`, `related`}。不加 DB 侧 CHECK 约束,便于扩展。PK 保持不变(一对 entity-knowledge 仍是唯一的分类)。
+
+3. **新增直连 resource 边**:`capability_resource`、`requirement_resource`、`strategy_resource`——承认 knowledge 不是 resource 的必经整理层,实体可直接归纳于原始素材。无 relation_type(存在即 source 语义)。
+
+**为什么不把 strategy 做成 capability 的一种(`capability.kind='strategy'`)**:
+- strategy 的可执行 `body` 字段语义差异大
+- strategy 是 capability 的**消费者**(通过 `strategy_capability` 组合),不是 capability 本身
+- 将来 strategy 会有自己的版本、状态、评估维度,独立表更可维护
+
+**实现位置**:
+- `knowhub/knowhub_db/migrations/migrate_v4_strategy_and_relation_types.py`
+- `knowhub/knowhub_db/pg_strategy_store.py`
+- `knowhub/knowhub_db/pg_store.py`(及其他 store 的 `knowledge_links` 子查询)
+
+---
+
+## 17. Store 连接 autocommit=True
+
+**日期**:2026-04-15
+
+**背景**:执行 v4 schema migration 时发现,`ALTER TABLE` 在 AnalyticDB 上挂起 30-60s 后 timeout。排查发现:所有 `PostgreSQLXxxStore` 的 `self.conn.autocommit = False`,SELECT 执行后连接停在 `idle in transaction` 状态,**永久持有 `AccessShareLock`**——阻塞任何需要 `AccessExclusiveLock` 的 DDL。
+
+**决策**:所有 store `__init__` 和 `_reconnect` 都改为 `self.conn.autocommit = True`。
+
+**权衡**:
+- 失:多语句写路径失去事务原子性(如 insert entity + DELETE junctions + INSERT junctions)
+- 得:任何 SELECT 不再持长锁;未来 DDL 不会再被阻塞
+
+**为什么可以接受失去原子性**:所有 junction 写入都是 `DELETE FROM ... WHERE eid=X` + `INSERT ... ON CONFLICT DO NOTHING`——**幂等**。部分失败后重跑等价于从头跑。实体主键也用 `ON CONFLICT (id) DO UPDATE`。没有"中间状态"需要被事务保护。
+
+**实现位置**:6 个 store 文件(`pg_store.py`, `pg_resource_store.py`, `pg_tool_store.py`, `pg_capability_store.py`, `pg_requirement_store.py`, `pg_strategy_store.py`)。保留原有 `self.conn.commit()` 调用作为 no-op(最小侵入)。
+
+---
+
+## 18. Strategy API / Dashboard 集成 & VCAP 虚拟能力机制退役
+
+**日期**:2026-04-16
+
+**背景**:为 Strategy 这张新表补全后端 API + 前端 Dashboard 集成时,发现三处问题并修复。同时整个"虚拟能力(VCAP_)"机制失去价值,可以退役。
+
+### 修复的 bug
+
+1. **`submit_strategy` (POST) 不设时间戳 + 不算 embedding**
+   - `StrategyIn` 模型没有 `created_at`/`updated_at`,`insert_or_update` 默认 `None` 写入 NULL
+   - POST 时没调用 `get_embedding(name + description)`,向量检索永远看不到新建的 strategy
+   - **修法**:异步化 + 填时间戳 + 生成 embedding(参照 `create_capability`)
+
+2. **`patch_strategy` (PATCH) 改了 name/description 也不重算 embedding**
+   - 向量空间与文本内容漂移
+   - **修法**:检测 `name`/`description` 是否在 updates 里,在就重算 embedding(参照 `patch_capability`)
+
+3. **Dashboard body 解析和 ingest 脚本写入格式不对口**
+   - Dashboard 期望 `body` 解析后是 `{phases: [...]}`(前端 UI 提交格式)
+   - 研究脚本 `ingest_research_output.py` 写入的 `body` 是完整 `strategy.json`:`{selected_strategy: {workflow_outline: [...]}, vs_alternatives: [...], uncovered_requirements: [...]}`
+   - 两种格式不对口 → ingest 的 strategy 在 Dashboard 里节点覆盖率为 0
+   - **修法**:前端容错解析,`parsed.phases || parsed.selected_strategy?.workflow_outline || []` 兼容两种形态
+
+### 虚拟能力(VCAP_)机制退役
+
+**VCAP 原本的用途**:strategy.body 里某个 phase 的 `capability_ids` 若以 `VCAP_` 前缀出现,Dashboard 认识此占位符并构造临时 JS 对象(`is_virtual:true, status:'未沉淀'`)显示,提醒用户"这里有个待补充的能力"。本质是**设计阶段的草稿占位**。
+
+**为什么退役**:
+- 研究调研脚本产出 `is_new=true` 的新能力时,**直接落库为真实 capability 行**(id 形如 `CAP-{version}-{folder}-{idx}`),带完整 embedding + junction
+- 通过 `version` 字段隔离测试数据(如 `tao_dev_1`),不污染 `v0` 生产数据
+- 需要"整批清除未沉淀的测试能力"时用 `cascade.purge_version('tao_dev_1')`,比 VCAP 的"刷页面消失"更精确
+- 不需要两套展示机制
+
+**迁移路径**:
+- 现状:Dashboard 里 VCAP_ 相关的 `virtualCaps` / `is_virtual` / `status:'未沉淀'` 逻辑还在,但实际 strategy body 里都是真实 CAP ID(VCAP_ 不会再出现)
+- 未来清理:Dashboard.tsx 的 `virtualCaps` useMemo + `allCaps = [...dbData.caps, ...virtualCaps]` 合并逻辑可以直接删掉(非紧急)
+- 若要保留"未沉淀"的视觉提示,改走 `capability.status` 字段(让 ingest 给新能力标 `status='未沉淀'`),Dashboard 根据 status 高亮即可
+
+**实现位置**:
+- 修复:`knowhub/server.py:submit_strategy` / `patch_strategy`,`knowhub/frontend/src/pages/Dashboard.tsx:virtualCaps`
+- 退役方向:`knowhub/frontend/src/pages/Dashboard.tsx` 中所有涉及 `VCAP_` / `is_virtual` / `virtualCaps` 的逻辑可逐步删除

+ 2 - 0
knowhub/docs/schema-migration-plan.md

@@ -1,5 +1,7 @@
 # Schema 迁移方案:新库 knowhub
 
+> 历史记录:这是 v3 一次性迁移(knowledge_hub → knowhub)的方案。**跑 DDL 前的通用规范**参见 [db-operations.md](./db-operations.md)。
+
 ## 背景
 
 旧库 `knowledge_hub` 使用 JSONB 数组存储实体间关系,存在一致性、索引、命名问题。迁移目标:在新库 `knowhub` 中建立干净的关联表结构。

+ 117 - 23
knowhub/docs/schema.md

@@ -16,6 +16,16 @@ Embedding 模型:`google/gemini-2.5-flash-lite`(通过 OpenRouter)。实
 
 数据库名:`knowhub`
 
+## 多租户版本字段(version)
+
+6 张实体表中除 tool 外(tool.version 早已存在,指工具自身发布版本),另外 5 张都有 `version VARCHAR(32) NOT NULL DEFAULT 'v0'` 列:`knowledge`, `resource`, `requirement`, `capability`, `strategy`。
+
+- 用途:隔离算法测试数据(tao_dev_1 等)与生产数据(v0),共用一套入库/查询/可视化管道
+- 规则:独立 ID 跨版本唯一(`CAP-v0-001` 和 `CAP-tao_dev_1-00-01` 是两个不同实体)
+- junction 表**不带 version**——自动继承两端实体的 version(通过 JOIN 实现过滤)
+- 批量清除某版本:`cascade.purge_version(cursor, 'tao_dev_1')` 一次清 5 张实体表 + 它们所有 junction 行
+- 默认查询不过滤版本(返回所有数据);过滤时显式传 `version` 参数
+
 ---
 
 ## 设计原则
@@ -32,22 +42,29 @@ Embedding 模型:`google/gemini-2.5-flash-lite`(通过 OpenRouter)。实
 ## 表间关系
 
 ```
-           requirement
-          ╱           ╲
- capability_ids    knowledge_ids
-        ╱                 ╲
-  capability            knowledge ← knowledge_resource → resource
-        ╲                 ╱  ╲
-     tool_ids      knowledge_ids  knowledge_relation
-        ╲            ╱              (知识间关系)
-         tool
-          |
-    tool_provider (执行层索引)
+              requirement ──── requirement_strategy ──── strategy
+            ╱     ╲   ╲                                 ╱  │  ╲
+  capability_ids  knowledge_ids  resource_ids   capability │  knowledge / resource
+           ╱              ╲         ╲                      compose
+     capability          knowledge ← knowledge_resource → resource
+         │  ╲              ╱  ╲                            ↑
+         │  tool_ids  knowledge_ids  knowledge_relation    │
+         │     ╲         ╱            (知识间关系)          │
+         │      tool    ...          ← capability_resource ┤
+    capability_resource                ← requirement_resource
+                                       ← strategy_resource
+                     tool_provider (执行层索引)
 ```
 
+所有 `*_knowledge` / `knowledge_relation` / `strategy_capability` / `strategy_knowledge` 边带 `relation_type` 语义标签:
+- `source` — 知识/资料是该实体的构建来源
+- `case`   — 知识是该实体的应用实例
+- `compose` — 组合关系(strategy → capability)
+- `related` — 默认/未分类
+
 ---
 
-## 实体表(5)
+## 实体表(6
 
 ### knowledge — 知识表
 
@@ -72,10 +89,10 @@ Embedding 模型:`google/gemini-2.5-flash-lite`(通过 OpenRouter)。实
 | `updated_at` | BIGINT | 更新时间戳(秒) |
 | `status` | VARCHAR | pending → approved / rejected / checked |
 
-关联(通过关联表,API 返回时聚合为 `{entity}_ids`):
-- `requirement_ids` ← requirement_knowledge
-- `capability_ids` ← capability_knowledge
-- `tool_ids` ← tool_knowledge
+关联(通过关联表,API 返回时聚合为 `{entity}_ids` 扁平列表 + `{entity}_links` 带 relation_type):
+- `requirement_ids` / `requirement_links` ← requirement_knowledge
+- `capability_ids` / `capability_links` ← capability_knowledge
+- `tool_ids` / `tool_links` ← tool_knowledge
 - `resource_ids` ← knowledge_resource
 - `relations` ← knowledge_relation
 
@@ -91,10 +108,12 @@ Embedding 模型:`google/gemini-2.5-flash-lite`(通过 OpenRouter)。实
 | `secure_body` | TEXT | 敏感内容(AES-256-GCM 加密) |
 | `content_type` | TEXT | text / code / credential / cookie |
 | `metadata` | JSONB | 附加元数据 |
+| `images` | JSONB | 图片 URL 列表 `[url1, url2, ...]`,用于 dashboard 缩略图展示(v5 新增)|
 | `sort_order` | INTEGER | 同级排序 |
 | `submitted_by` | TEXT | 提交者 |
 | `created_at` | BIGINT | 创建时间戳 |
 | `updated_at` | BIGINT | 更新时间戳 |
+| `version` | VARCHAR(32) | 多租户版本标签,DEFAULT `'v0'` |
 
 ### requirement — 需求表
 
@@ -111,7 +130,9 @@ Embedding 模型:`google/gemini-2.5-flash-lite`(通过 OpenRouter)。实
 
 关联:
 - `capability_ids` ← requirement_capability
-- `knowledge_ids` ← requirement_knowledge
+- `knowledge_ids` / `knowledge_links` ← requirement_knowledge
+- `resource_ids` ← requirement_resource
+- `strategy_ids` ← requirement_strategy(满足该需求的 strategy)
 
 ### capability — 原子能力表
 
@@ -128,7 +149,8 @@ Embedding 模型:`google/gemini-2.5-flash-lite`(通过 OpenRouter)。实
 关联:
 - `requirement_ids` ← requirement_capability
 - `tool_ids` ← capability_tool
-- `knowledge_ids` ← capability_knowledge
+- `knowledge_ids` / `knowledge_links` ← capability_knowledge
+- `resource_ids` ← capability_resource
 
 ### tool — 工具表
 
@@ -149,14 +171,35 @@ Embedding 模型:`google/gemini-2.5-flash-lite`(通过 OpenRouter)。实
 
 关联:
 - `capability_ids` ← capability_tool
-- `knowledge_ids` ← tool_knowledge
+- `knowledge_ids` / `knowledge_links` ← tool_knowledge
 - `provider_ids` ← tool_provider
 
+### strategy — 制作策略表
+
+一组原子 capability 的组合,附带自身的可执行 `body`(如工作流脚本、流程描述)与 source 知识。strategy 设计用来满足 requirement。
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | VARCHAR PK | `"strategy-{...}"` |
+| `name` | VARCHAR | 策略名称 |
+| `description` | TEXT | 策略描述 |
+| `body` | TEXT | 可执行正文(工作流/脚本/流程步骤) |
+| `status` | VARCHAR | draft / approved / deprecated 等 |
+| `created_at` | BIGINT | 创建时间戳(秒) |
+| `updated_at` | BIGINT | 更新时间戳(秒) |
+| `embedding` | float4[] | name + description 的向量 |
+
+关联:
+- `requirement_ids` ← requirement_strategy(这个 strategy 被设计用来满足哪些 requirement)
+- `capability_ids` / `capability_links` ← strategy_capability(compose 组合)
+- `knowledge_ids` / `knowledge_links` ← strategy_knowledge(source / case 等)
+- `resource_ids` ← strategy_resource(直接原始素材)
+
 ---
 
-## 关联表(8)
+## 关联表(14
 
-### 实体链(2)
+### 实体链(3
 
 **requirement_capability** — 需求分解为能力
 
@@ -177,7 +220,18 @@ PK: (requirement_id, capability_id)
 
 PK: (capability_id, tool_id)
 
-### 知识链(3)
+**requirement_strategy** — 需求由 strategy 满足
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `requirement_id` | VARCHAR | → requirement.id |
+| `strategy_id` | VARCHAR | → strategy.id |
+
+PK: (requirement_id, strategy_id)
+
+### 知识链(4)
+
+所有 `*_knowledge` 表及 `strategy_knowledge` 都带 `relation_type VARCHAR(32)` 列,值 ∈ {`source`, `case`, `related`}。DEFAULT `'related'`(历史数据)。
 
 **requirement_knowledge** — 需求的方案策略、完成方法
 
@@ -185,6 +239,7 @@ PK: (capability_id, tool_id)
 |------|------|------|
 | `requirement_id` | VARCHAR | → requirement.id |
 | `knowledge_id` | VARCHAR | → knowledge.id |
+| `relation_type` | VARCHAR(32) | source / case / related(默认) |
 
 PK: (requirement_id, knowledge_id)
 
@@ -194,6 +249,7 @@ PK: (requirement_id, knowledge_id)
 |------|------|------|
 | `capability_id` | VARCHAR | → capability.id |
 | `knowledge_id` | VARCHAR | → knowledge.id |
+| `relation_type` | VARCHAR(32) | source / case / related(默认) |
 
 PK: (capability_id, knowledge_id)
 
@@ -203,10 +259,33 @@ PK: (capability_id, knowledge_id)
 |------|------|------|
 | `tool_id` | VARCHAR | → tool.id |
 | `knowledge_id` | VARCHAR | → knowledge.id |
+| `relation_type` | VARCHAR(32) | source / case / related(默认) |
 
 PK: (tool_id, knowledge_id)
 
-### 来源链(1)
+**strategy_knowledge** — strategy 的知识来源
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `strategy_id` | VARCHAR | → strategy.id |
+| `knowledge_id` | VARCHAR | → knowledge.id |
+| `relation_type` | VARCHAR(32) | 默认 `source`;可为 `case` 等 |
+
+PK: (strategy_id, knowledge_id)
+
+### 组合关系(1)
+
+**strategy_capability** — strategy 组合哪些原子能力
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `strategy_id` | VARCHAR | → strategy.id |
+| `capability_id` | VARCHAR | → capability.id |
+| `relation_type` | VARCHAR(32) | 默认 `compose` |
+
+PK: (strategy_id, capability_id)
+
+### 来源链(5)
 
 **knowledge_resource** — 知识的原始来源
 
@@ -217,6 +296,20 @@ PK: (tool_id, knowledge_id)
 
 PK: (knowledge_id, resource_id)
 
+**requirement_resource** — 需求直接来自的原始素材(绕过 knowledge 整理层)
+
+PK: (requirement_id, resource_id)
+
+**capability_resource** — capability 直接来自的原始素材
+
+PK: (capability_id, resource_id)
+
+**strategy_resource** — strategy 直接来自的原始素材
+
+PK: (strategy_id, resource_id)
+
+`*_resource` 边**无 relation_type**——存在本身即"source"语义,不需额外标签。
+
 ### 知识间关系(1)
 
 **knowledge_relation** — 知识之间的关系(替代、扩展、矛盾等)
@@ -251,4 +344,5 @@ PK: (tool_id, provider_id)
 | tool | `embedding` | name + introduction |
 | capability | `embedding` | name + description |
 | requirement | `embedding` | description |
+| strategy | `embedding` | name + description |
 

+ 52 - 30
knowhub/frontend/src/components/common/SideDrawer.tsx

@@ -1,5 +1,6 @@
 import { useState, useEffect } from 'react';
-import { X } from 'lucide-react';
+import { createPortal } from 'react-dom';
+import { ChevronRight, PanelLeftClose, X } from 'lucide-react';
 import { cn } from '../../lib/utils';
 
 interface SideDrawerProps {
@@ -12,53 +13,74 @@ interface SideDrawerProps {
 
 export function SideDrawer({ isOpen, onClose, title, children, width = 'w-[650px]' }: SideDrawerProps) {
   const [mounted, setMounted] = useState(false);
+  const [expanded, setExpanded] = useState(false);
 
   useEffect(() => {
     setMounted(true);
   }, []);
 
   useEffect(() => {
-    if (isOpen) {
-      document.body.style.overflow = 'hidden';
-    } else {
-      document.body.style.overflow = 'auto';
-    }
+    if (isOpen) setExpanded(false);
   }, [isOpen]);
 
   if (!mounted) return null;
 
-  return (
+  return createPortal(
     <>
-      {/* Backdrop */}
-      <div 
+      <div
         className={cn(
-          "fixed inset-0 bg-slate-900/20 backdrop-blur-sm z-[100] transition-opacity duration-300",
-          isOpen ? "opacity-100 visible" : "opacity-0 invisible"
-        )}
-        onClick={onClose}
-      />
-      
-      {/* Drawer */}
-      <div 
-        className={cn(
-          "fixed top-0 right-0 h-full bg-white shadow-2xl z-[101] transform transition-transform duration-300 ease-in-out overflow-hidden flex flex-col border-l border-slate-200",
-          width,
-          isOpen ? "translate-x-0" : "translate-x-full"
+          "fixed top-0 left-0 h-full bg-white shadow-2xl z-[220] transform transition-all duration-300 ease-in-out overflow-hidden flex flex-col border-r border-slate-200",
+          expanded ? width : "w-0",
+          isOpen && expanded ? "translate-x-0" : "-translate-x-full"
         )}
       >
-        <div className="flex justify-between items-center px-6 py-4 border-b border-slate-100 bg-white sticky top-0 z-10">
-          <div className="text-xl font-bold text-slate-900">{title}</div>
-          <button 
-            onClick={onClose}
-            className="p-2 hover:bg-slate-100 rounded-lg text-slate-400 hover:text-slate-600 transition-colors"
-          >
-            <X size={20} />
-          </button>
+        <div className="border-b border-slate-100 bg-white sticky top-0 z-10 px-4 py-4">
+          <div className="flex justify-between items-center gap-2">
+            <button
+              onClick={() => setExpanded(false)}
+              className="p-2 hover:bg-slate-100 rounded-lg text-slate-400 hover:text-slate-600 transition-colors"
+              title="收起"
+            >
+              <PanelLeftClose size={18} />
+            </button>
+            <div className="text-lg font-bold text-slate-900 min-w-0 flex-1 truncate">{title}</div>
+            <button
+              onClick={onClose}
+              className="p-2 hover:bg-slate-100 rounded-lg text-slate-400 hover:text-slate-600 transition-colors"
+              title="关闭"
+            >
+              <X size={18} />
+            </button>
+          </div>
         </div>
         <div className="flex-1 overflow-y-auto p-6 bg-slate-50">
           {children}
         </div>
       </div>
-    </>
+
+      <button
+        onClick={() => {
+          if (!isOpen) {
+            setExpanded(true);
+          } else {
+            setExpanded((prev) => !prev);
+          }
+        }}
+        className={cn(
+          "fixed left-0 top-24 z-[230] flex items-center gap-1 rounded-r-xl border border-l-0 border-slate-200 bg-white/95 px-2 py-3 shadow-lg backdrop-blur transition-all duration-300 hover:bg-white text-slate-500 hover:text-slate-700",
+          isOpen && expanded ? "-translate-x-full opacity-0 pointer-events-none" : "translate-x-0 opacity-100"
+        )}
+        title={isOpen ? "展开详情页" : "打开详情页"}
+      >
+        <ChevronRight size={16} />
+        <span
+          className="text-[10px] font-bold tracking-widest"
+          style={{ writingMode: 'vertical-rl', textOrientation: 'mixed' }}
+        >
+          详情页
+        </span>
+      </button>
+    </>,
+    document.body
   );
 }

+ 144 - 73
knowhub/frontend/src/components/dashboard/CategoryTree.tsx

@@ -1,14 +1,19 @@
 import { useState } from 'react';
+import { createPortal } from 'react-dom';
 import { cn } from '../../lib/utils';
-import { ChevronRight, ChevronDown, ZoomIn, ZoomOut, Maximize, FolderTree } from 'lucide-react';
+import { ChevronRight, ChevronDown, ChevronLeft, ZoomIn, ZoomOut, Maximize, FolderTree } from 'lucide-react';
 
 interface NodeProps {
   node: any;
   onSelect: (node: any) => void;
-  onDoubleClick: (node: any) => void;
   selectedId: string | number | null;
   level: number;
   highlightLeafNames: Set<string> | null; // null = no filter active
+  subtreeHighlightNodeIds: Set<string> | null;
+  sourceNodeIds: Set<string> | null;
+  nodeMetricsMap: Record<string, { reqCount: number; procCount: number; capCount: number; toolCount: number; nodeCount: number }>;
+  dimensionColor: string; // hex color for the node's dimension
+  focusedTreeNodeId?: string | number | null;
 }
 
 // Returns true if this node or any descendant is in the highlight set
@@ -19,88 +24,87 @@ function nodeHasHighlightedLeaf(node: any, highlightLeafNames: Set<string> | nul
   return node.children.some((child: any) => nodeHasHighlightedLeaf(child, highlightLeafNames));
 }
 
-function HorizontalTreeNode({ node, onSelect, onDoubleClick, selectedId, level, highlightLeafNames }: NodeProps) {
+function HorizontalTreeNode({ node, onSelect, selectedId, level, highlightLeafNames, subtreeHighlightNodeIds, sourceNodeIds, nodeMetricsMap, dimensionColor, focusedTreeNodeId = null }: NodeProps) {
   const [expanded, setExpanded] = useState(true);
+  const [hoveredMetric, setHoveredMetric] = useState<null | { key: string; count: number; colorClass: string; x: number; y: number }>(null);
   const hasChildren = node.children && node.children.length > 0;
-
-  const count = node.total_posts_count || 0;
-  const status = node.node_status ?? 0;
-
-  let intensity = 0;
-  if (count < 10) intensity = 0;
-  else if (count < 50) intensity = 1;
-  else if (count < 100) intensity = 2;
-  else if (count < 300) intensity = 3;
-  else if (count < 800) intensity = 4;
-  else intensity = 5;
-
-  const palettes = {
-    0: [
-      { bg: "bg-slate-100", border: "border-slate-200", text: "text-slate-900" },
-      { bg: "bg-slate-200", border: "border-slate-300", text: "text-slate-900" },
-      { bg: "bg-slate-300", border: "border-slate-400", text: "text-slate-900" },
-      { bg: "bg-slate-400", border: "border-slate-500", text: "text-slate-900" },
-      { bg: "bg-slate-500", border: "border-slate-600", text: "text-white" },
-      { bg: "bg-slate-600", border: "border-slate-700", text: "text-white" }
-    ],
-    1: [
-      { bg: "bg-indigo-100", border: "border-indigo-200", text: "text-indigo-900" },
-      { bg: "bg-indigo-300", border: "border-indigo-400", text: "text-indigo-900" },
-      { bg: "bg-indigo-500", border: "border-indigo-600", text: "text-white" },
-      { bg: "bg-indigo-600", border: "border-indigo-700", text: "text-white" },
-      { bg: "bg-indigo-700", border: "border-indigo-800", text: "text-white" },
-      { bg: "bg-indigo-900", border: "border-indigo-950", text: "text-white" }
-    ],
-    2: [
-      { bg: "bg-teal-100", border: "border-teal-200", text: "text-teal-900" },
-      { bg: "bg-teal-300", border: "border-teal-400", text: "text-teal-900" },
-      { bg: "bg-teal-500", border: "border-teal-600", text: "text-white" },
-      { bg: "bg-teal-600", border: "border-teal-700", text: "text-white" },
-      { bg: "bg-teal-700", border: "border-teal-800", text: "text-white" },
-      { bg: "bg-teal-900", border: "border-teal-950", text: "text-white" }
-    ],
-    3: [
-      { bg: "bg-green-100", border: "border-green-200", text: "text-green-900" },
-      { bg: "bg-green-300", border: "border-green-400", text: "text-green-900" },
-      { bg: "bg-green-500", border: "border-green-600", text: "text-white" },
-      { bg: "bg-green-600", border: "border-green-700", text: "text-white" },
-      { bg: "bg-green-700", border: "border-green-800", text: "text-white" },
-      { bg: "bg-green-900", border: "border-green-950", text: "text-white" }
-    ]
-  };
-
-  const theme = palettes[status as keyof typeof palettes][intensity];
-  let textColor = theme.text;
+  const metrics = nodeMetricsMap[String(node.id)] || { reqCount: 0, procCount: 0, capCount: 0, toolCount: 0, nodeCount: 0 };
+  let textColor = "text-slate-800";
   if (hasChildren) textColor = cn(textColor, "font-extrabold");
 
   // Highlight/dim logic for reverse filtering
   const inHighlight = nodeHasHighlightedLeaf(node, highlightLeafNames);
   const isLeafHighlighted = highlightLeafNames && highlightLeafNames.has(node.name);
-
+  const isInSubtreeHighlight = subtreeHighlightNodeIds?.has(String(node.id)) ?? false;
+  const isSourceNode = sourceNodeIds?.has(String(node.id)) ?? false;
+  const isSelected =
+    selectedId !== null &&
+    selectedId !== undefined &&
+    node.id !== null &&
+    node.id !== undefined &&
+    String(selectedId) === String(node.id);
+  const shouldScrollIntoView =
+    focusedTreeNodeId !== null &&
+    focusedTreeNodeId !== undefined &&
+    node.id !== null &&
+    node.id !== undefined &&
+    String(focusedTreeNodeId) === String(node.id);
   return (
     <div className={cn("flex flex-row items-start transition-opacity duration-200", highlightLeafNames && !inHighlight && "opacity-20")}>
       {/* Node Card */}
       <div
         className={cn(
-          "flex items-center px-3 py-1.5 rounded-md border shadow-[0_1px_2px_rgba(0,0,0,0.05)] cursor-pointer whitespace-nowrap transition-all z-10 h-[34px]",
-          theme.bg, theme.border,
-          selectedId === node.id
-            ? "ring-2 ring-indigo-500 ring-offset-1 border-indigo-400"
+          "flex items-center px-3 py-1.5 rounded-md border shadow-[0_1px_2px_rgba(0,0,0,0.05)] cursor-pointer whitespace-nowrap transition-all z-10 h-[34px] bg-white border-slate-200",
+          isSelected
+            ? "ring-2 ring-orange-400 ring-offset-2 border-orange-400 shadow-[0_0_0_2px_rgba(251,146,60,0.16)]"
+            : isSourceNode
+            ? "ring-2 ring-sky-400 ring-offset-1 border-sky-300 shadow-[0_0_0_2px_rgba(56,189,248,0.16)]"
+            : isInSubtreeHighlight
+            ? "border-sky-300 border-dashed bg-white shadow-none"
             : isLeafHighlighted
-            ? "ring-2 ring-orange-400 ring-offset-1"
+            ? "ring-2 ring-sky-300 ring-offset-1 border-sky-300"
             : "hover:brightness-95"
         )}
         onClick={() => onSelect(node)}
-        onDoubleClick={(e) => { e.stopPropagation(); if (!hasChildren) onDoubleClick(node); }}
-        ref={(el) => { if (el && selectedId === node.id) el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); }}
+        ref={(el) => { if (el && (isSelected || shouldScrollIntoView)) el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }); }}
       >
         <span className={cn("text-xs font-bold mr-3", textColor)}>{node.name || "Root"}</span>
-        {node.id && <span className="text-[10px] opacity-60 mr-2 font-mono">{node.id}</span>}
+        {isSourceNode && (
+          <span className="text-[9px] mr-2 px-1.5 py-0.5 rounded bg-sky-100 text-sky-700 border border-sky-300 font-bold whitespace-nowrap shadow-sm">
+            来源
+          </span>
+        )}
 
-        {/* Count Pill */}
-        <div className="flex text-[9px] bg-white/70 rounded px-1 group shadow-sm items-center">
-          <span className="px-1 text-slate-500 font-medium">{node.total_element_count || 0}</span>
-          <span className="px-1 font-bold text-slate-800 border-l border-white/50 pl-1">{node.total_posts_count || 0} ▶</span>
+        <div className="flex text-[9px] bg-slate-100 rounded px-1.5 shadow-sm items-center font-bold text-slate-700">
+          {node.total_posts_count || 0} 帖
+        </div>
+        <div className="relative flex items-center gap-2 ml-2">
+          <span
+            onMouseEnter={(e) => metrics.nodeCount > 0 && setHoveredMetric({ key: 'node', count: metrics.nodeCount, colorClass: 'border text-white', x: e.currentTarget.getBoundingClientRect().left + e.currentTarget.getBoundingClientRect().width / 2, y: e.currentTarget.getBoundingClientRect().top - 8 })}
+            onMouseLeave={() => setHoveredMetric((prev) => (prev?.key === 'node' ? null : prev))}
+            className={cn("w-2.5 h-2.5 rounded-full", metrics.nodeCount > 0 ? "opacity-100" : "opacity-0")}
+            style={metrics.nodeCount > 0 ? { backgroundColor: dimensionColor } : undefined}
+          />
+          <span
+            onMouseEnter={(e) => metrics.reqCount > 0 && setHoveredMetric({ key: 'req', count: metrics.reqCount, colorClass: 'bg-indigo-500 border-indigo-500 text-white', x: e.currentTarget.getBoundingClientRect().left + e.currentTarget.getBoundingClientRect().width / 2, y: e.currentTarget.getBoundingClientRect().top - 8 })}
+            onMouseLeave={() => setHoveredMetric((prev) => (prev?.key === 'req' ? null : prev))}
+            className={cn("w-2.5 h-2.5 rounded-full", metrics.reqCount > 0 ? "bg-indigo-500" : "opacity-0")}
+          />
+          <span
+            onMouseEnter={(e) => metrics.procCount > 0 && setHoveredMetric({ key: 'proc', count: metrics.procCount, colorClass: 'bg-purple-500 border-purple-500 text-white', x: e.currentTarget.getBoundingClientRect().left + e.currentTarget.getBoundingClientRect().width / 2, y: e.currentTarget.getBoundingClientRect().top - 8 })}
+            onMouseLeave={() => setHoveredMetric((prev) => (prev?.key === 'proc' ? null : prev))}
+            className={cn("w-2.5 h-2.5 rounded-full", metrics.procCount > 0 ? "bg-purple-500" : "opacity-0")}
+          />
+          <span
+            onMouseEnter={(e) => metrics.capCount > 0 && setHoveredMetric({ key: 'cap', count: metrics.capCount, colorClass: 'bg-rose-500 border-rose-500 text-white', x: e.currentTarget.getBoundingClientRect().left + e.currentTarget.getBoundingClientRect().width / 2, y: e.currentTarget.getBoundingClientRect().top - 8 })}
+            onMouseLeave={() => setHoveredMetric((prev) => (prev?.key === 'cap' ? null : prev))}
+            className={cn("w-2.5 h-2.5 rounded-full", metrics.capCount > 0 ? "bg-rose-500" : "opacity-0")}
+          />
+          <span
+            onMouseEnter={(e) => metrics.toolCount > 0 && setHoveredMetric({ key: 'tool', count: metrics.toolCount, colorClass: 'bg-green-500 border-green-500 text-white', x: e.currentTarget.getBoundingClientRect().left + e.currentTarget.getBoundingClientRect().width / 2, y: e.currentTarget.getBoundingClientRect().top - 8 })}
+            onMouseLeave={() => setHoveredMetric((prev) => (prev?.key === 'tool' ? null : prev))}
+            className={cn("w-2.5 h-2.5 rounded-full", metrics.toolCount > 0 ? "bg-green-500" : "opacity-0")}
+          />
         </div>
 
         {hasChildren && (
@@ -133,15 +137,28 @@ function HorizontalTreeNode({ node, onSelect, onDoubleClick, selectedId, level,
               <HorizontalTreeNode
                 node={child}
                 onSelect={onSelect}
-                onDoubleClick={onDoubleClick}
                 selectedId={selectedId}
                 level={level + 1}
                 highlightLeafNames={highlightLeafNames}
+                subtreeHighlightNodeIds={subtreeHighlightNodeIds}
+                sourceNodeIds={sourceNodeIds}
+                nodeMetricsMap={nodeMetricsMap}
+                dimensionColor={dimensionColor}
+                focusedTreeNodeId={focusedTreeNodeId}
               />
             </div>
           ))}
         </div>
       )}
+      {hoveredMetric && createPortal(
+        <div
+          className={cn("fixed z-[99999] rounded-lg border shadow-lg min-w-[40px] px-3 py-2 flex items-center justify-center -translate-x-1/2 -translate-y-full", hoveredMetric.key === 'node' ? 'text-white' : hoveredMetric.colorClass)}
+          style={hoveredMetric.key === 'node' ? { left: hoveredMetric.x, top: hoveredMetric.y, backgroundColor: dimensionColor, borderColor: dimensionColor } : { left: hoveredMetric.x, top: hoveredMetric.y }}
+        >
+          <div className="text-sm font-black leading-none text-center">{hoveredMetric.count}</div>
+        </div>,
+        document.body
+      )}
     </div>
   );
 }
@@ -149,21 +166,39 @@ function HorizontalTreeNode({ node, onSelect, onDoubleClick, selectedId, level,
 export function CategoryTree({
   data,
   onSelect,
-  onDoubleClick,
   selectedId,
   highlightLeafNames = null,
+  subtreeHighlightNodeIds = null,
+  sourceNodeIds = null,
+  nodeMetricsMap = {},
+  filterLabel,
+  onClearFilter,
   totalNodeCount,
   wideMode = false,
   onToggleWideMode,
+  treeFocusIndex = 0,
+  treeMatchedCount = 0,
+  focusedTreeNodeId = null,
+  onTreeFocusPrev,
+  onTreeFocusNext,
 }: {
   data: any;
   onSelect: (node: any) => void;
-  onDoubleClick?: (node: any) => void;
   selectedId: any;
   highlightLeafNames?: Set<string> | null;
+  subtreeHighlightNodeIds?: Set<string> | null;
+  sourceNodeIds?: Set<string> | null;
+  nodeMetricsMap?: Record<string, { reqCount: number; procCount: number; capCount: number; toolCount: number; nodeCount: number }>;
+  filterLabel?: string | null;
+  onClearFilter?: () => void;
   totalNodeCount?: number;
   wideMode?: boolean;
   onToggleWideMode?: () => void;
+  treeFocusIndex?: number;
+  treeMatchedCount?: number;
+  focusedTreeNodeId?: string | number | null;
+  onTreeFocusPrev?: () => void;
+  onTreeFocusNext?: () => void;
 }) {
   const [scale, setScale] = useState(1);
 
@@ -183,6 +218,38 @@ export function CategoryTree({
           内容树
         </div>
         <div className="flex items-center gap-2">
+          {filterLabel && (
+            <button
+              type="button"
+              onClick={onClearFilter}
+              className="text-[10px] font-bold px-2 py-1 rounded-md bg-sky-100 text-sky-700 hover:bg-sky-200 transition-colors"
+            >
+              {filterLabel} ×
+            </button>
+          )}
+          {treeMatchedCount > 1 && (
+            <div className="flex items-center gap-1">
+              <button
+                type="button"
+                onClick={onTreeFocusPrev}
+                disabled={treeFocusIndex === 0}
+                className="p-0.5 rounded text-slate-400 hover:text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+              >
+                <ChevronLeft size={13} />
+              </button>
+              <span className="text-[10px] font-bold text-slate-500 min-w-[28px] text-center">
+                {treeFocusIndex + 1}/{treeMatchedCount}
+              </span>
+              <button
+                type="button"
+                onClick={onTreeFocusNext}
+                disabled={treeFocusIndex === treeMatchedCount - 1}
+                className="p-0.5 rounded text-slate-400 hover:text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+              >
+                <ChevronRight size={13} />
+              </button>
+            </div>
+          )}
           <button
             onClick={onToggleWideMode}
             className={cn(
@@ -226,10 +293,10 @@ export function CategoryTree({
               const nodesInDimension = groups[dimensionName] || [];
               if (nodesInDimension.length === 0) return null;
 
-              let color = { bg: 'bg-slate-50', border: 'border-slate-500', text: 'text-slate-800' };
-              if (dimensionName === "形式") color = { bg: 'bg-[#E3F2FD]', border: 'border-[#2196F3]', text: 'text-slate-800' };
-              else if (dimensionName === "实质") color = { bg: 'bg-[#FFF3E0]', border: 'border-[#FF9800]', text: 'text-slate-800' };
-              else if (dimensionName === "意图") color = { bg: 'bg-[#F1F8E9]', border: 'border-[#8BC34A]', text: 'text-slate-800' };
+              let color = { bg: 'bg-slate-50', border: 'border-slate-500', text: 'text-slate-800', hex: '#64748b' };
+              if (dimensionName === "形式") color = { bg: 'bg-[#E3F2FD]', border: 'border-[#2196F3]', text: 'text-slate-800', hex: '#2196F3' };
+              else if (dimensionName === "实质") color = { bg: 'bg-[#FFF3E0]', border: 'border-[#FF9800]', text: 'text-slate-800', hex: '#FF9800' };
+              else if (dimensionName === "意图") color = { bg: 'bg-[#F1F8E9]', border: 'border-[#8BC34A]', text: 'text-slate-800', hex: '#8BC34A' };
 
               return (
                 <div key={dimensionName} className="flex flex-col">
@@ -242,10 +309,14 @@ export function CategoryTree({
                         key={subNode.id || subIdx}
                         node={subNode}
                         onSelect={onSelect}
-                        onDoubleClick={onDoubleClick ?? (() => {})}
                         selectedId={selectedId}
                         level={1}
                         highlightLeafNames={highlightLeafNames}
+                        subtreeHighlightNodeIds={subtreeHighlightNodeIds}
+                        sourceNodeIds={sourceNodeIds}
+                        nodeMetricsMap={nodeMetricsMap}
+                        dimensionColor={color.hex}
+                        focusedTreeNodeId={focusedTreeNodeId}
                       />
                     ))}
                   </div>

+ 7 - 3
knowhub/frontend/src/layouts/MainLayout.tsx

@@ -96,9 +96,13 @@ export function MainLayout({ activeTab, onTabChange, children }: MainLayoutProps
           }}
         >
           {TAB_ORDER.map((tab) => (
-            <div key={tab} className="shrink-0 h-full overflow-y-auto" style={{ width: `${100 / totalTabs}%` }}>
-              <div className="flex justify-center pb-12">
-                <div className="w-full px-6 py-6">
+            <div
+              key={tab}
+              className={tab === 'dashboard' ? "shrink-0 h-full overflow-hidden" : "shrink-0 h-full overflow-y-auto"}
+              style={{ width: `${100 / totalTabs}%` }}
+            >
+              <div className={tab === 'dashboard' ? "flex justify-center h-full" : "flex justify-center pb-12"}>
+                <div className={tab === 'dashboard' ? "w-full h-full px-6 py-6" : "w-full px-6 py-6"}>
                   {children(tab)}
                 </div>
               </div>

Разница между файлами не показана из-за своего большого размера
+ 792 - 134
knowhub/frontend/src/pages/Dashboard.tsx


+ 23 - 1
knowhub/frontend/src/services/api.ts

@@ -2,7 +2,7 @@ import axios from 'axios';
 
 const api = axios.create({
   baseURL: '/api',
-  timeout: 10000,
+  timeout: 60000,
 });
 
 const cache = new Map<string, { data: any; timestamp: number }>();
@@ -32,6 +32,10 @@ export const getTools = async (limit = 100, offset = 0) => {
   return fetchWithCache(`/tool?limit=${limit}&offset=${offset}`);
 };
 
+export const getStrategies = async (limit = 1000, offset = 0) => {
+  return fetchWithCache(`/strategy?limit=${limit}&offset=${offset}`);
+};
+
 export const getKnowledge = async (page = 1, pageSize = 100, filters: Record<string, string> = {}) => {
   const params = new URLSearchParams({ page: page.toString(), page_size: pageSize.toString() });
   for (const [key, val] of Object.entries(filters)) {
@@ -70,6 +74,24 @@ export const getResource = async (resourceId: string) => {
   return fetchWithCache(`/resource/${resourceId}`);
 };
 
+export const getDashboardSnapshot = async (force = false) => {
+  return fetchWithCache('/dashboard/snapshot', force);
+};
+
+export const batchGetResources = async (ids: string[]): Promise<Record<string, any>> => {
+  if (ids.length === 0) return {};
+  const resp = await fetch('/api/resource/batch', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ ids }),
+  });
+  if (!resp.ok) {
+    throw new Error(`batchGetResources failed with status ${resp.status}`);
+  }
+  const data = await resp.json();
+  return data.resources || {};
+};
+
 export const batchGetPosts = async (postIds: string[]): Promise<Record<string, any>> => {
   if (postIds.length === 0) return {};
   const resp = await fetch('/api/pattern/posts/batch', {

+ 213 - 221
knowhub/frontend/yarn.lock

@@ -180,7 +180,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@emnapi/core@npm:^1.8.1":
+"@emnapi/core@npm:1.9.2, @emnapi/core@npm:^1.8.1":
   version: 1.9.2
   resolution: "@emnapi/core@npm:1.9.2"
   dependencies:
@@ -190,7 +190,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@emnapi/runtime@npm:^1.8.1":
+"@emnapi/runtime@npm:1.9.2, @emnapi/runtime@npm:^1.8.1":
   version: 1.9.2
   resolution: "@emnapi/runtime@npm:1.9.2"
   dependencies:
@@ -387,15 +387,15 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@napi-rs/wasm-runtime@npm:^1.1.1":
-  version: 1.1.2
-  resolution: "@napi-rs/wasm-runtime@npm:1.1.2"
+"@napi-rs/wasm-runtime@npm:^1.1.1, @napi-rs/wasm-runtime@npm:^1.1.3":
+  version: 1.1.4
+  resolution: "@napi-rs/wasm-runtime@npm:1.1.4"
   dependencies:
     "@tybys/wasm-util": "npm:^0.10.1"
   peerDependencies:
     "@emnapi/core": ^1.7.1
     "@emnapi/runtime": ^1.7.1
-  checksum: 10c0/725c30ec9c480a8d0c1a6a4ce31dc6c830365d485e23ad560e143d1cb9db89a0c95fbb5b9d53c07121729817a3683db6f1ab65d7e4f38fa7482a11b15ef6c6fd
+  checksum: 10c0/2e88e1955258949ccf2d18c79975821ad38071b465ef126a5e14110977b97868867b016c1ad046e963cccc42c0bd9db6c8ff5fd1ebb61b87bb3487f339041658
   languageName: node
   linkType: hard
 
@@ -428,124 +428,126 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@oxc-project/types@npm:=0.122.0":
-  version: 0.122.0
-  resolution: "@oxc-project/types@npm:0.122.0"
-  checksum: 10c0/2c64dd0db949426fd0c86d4f61eded5902e7b7b166356a825bd3a248aeaa29a495f78918f66ab78e99644b67bd7556096e2a8123cec74ca4141c604f424f4f74
+"@oxc-project/types@npm:=0.124.0":
+  version: 0.124.0
+  resolution: "@oxc-project/types@npm:0.124.0"
+  checksum: 10c0/9564ee3ce41f4b87802ffd0d62a7602d27f4503fbd39c1bedab98d54fde06e2ac254a8f85d8f679af1281a26e8fc7aa053fadbb3e09e786b38178eb38a8e2fb3
   languageName: node
   linkType: hard
 
-"@rolldown/binding-android-arm64@npm:1.0.0-rc.12":
-  version: 1.0.0-rc.12
-  resolution: "@rolldown/binding-android-arm64@npm:1.0.0-rc.12"
+"@rolldown/binding-android-arm64@npm:1.0.0-rc.15":
+  version: 1.0.0-rc.15
+  resolution: "@rolldown/binding-android-arm64@npm:1.0.0-rc.15"
   conditions: os=android & cpu=arm64
   languageName: node
   linkType: hard
 
-"@rolldown/binding-darwin-arm64@npm:1.0.0-rc.12":
-  version: 1.0.0-rc.12
-  resolution: "@rolldown/binding-darwin-arm64@npm:1.0.0-rc.12"
+"@rolldown/binding-darwin-arm64@npm:1.0.0-rc.15":
+  version: 1.0.0-rc.15
+  resolution: "@rolldown/binding-darwin-arm64@npm:1.0.0-rc.15"
   conditions: os=darwin & cpu=arm64
   languageName: node
   linkType: hard
 
-"@rolldown/binding-darwin-x64@npm:1.0.0-rc.12":
-  version: 1.0.0-rc.12
-  resolution: "@rolldown/binding-darwin-x64@npm:1.0.0-rc.12"
+"@rolldown/binding-darwin-x64@npm:1.0.0-rc.15":
+  version: 1.0.0-rc.15
+  resolution: "@rolldown/binding-darwin-x64@npm:1.0.0-rc.15"
   conditions: os=darwin & cpu=x64
   languageName: node
   linkType: hard
 
-"@rolldown/binding-freebsd-x64@npm:1.0.0-rc.12":
-  version: 1.0.0-rc.12
-  resolution: "@rolldown/binding-freebsd-x64@npm:1.0.0-rc.12"
+"@rolldown/binding-freebsd-x64@npm:1.0.0-rc.15":
+  version: 1.0.0-rc.15
+  resolution: "@rolldown/binding-freebsd-x64@npm:1.0.0-rc.15"
   conditions: os=freebsd & cpu=x64
   languageName: node
   linkType: hard
 
-"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.12":
-  version: 1.0.0-rc.12
-  resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.12"
+"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.15":
+  version: 1.0.0-rc.15
+  resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.15"
   conditions: os=linux & cpu=arm
   languageName: node
   linkType: hard
 
-"@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.12":
-  version: 1.0.0-rc.12
-  resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.12"
+"@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.15":
+  version: 1.0.0-rc.15
+  resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.15"
   conditions: os=linux & cpu=arm64 & libc=glibc
   languageName: node
   linkType: hard
 
-"@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.12":
-  version: 1.0.0-rc.12
-  resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.12"
+"@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.15":
+  version: 1.0.0-rc.15
+  resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.15"
   conditions: os=linux & cpu=arm64 & libc=musl
   languageName: node
   linkType: hard
 
-"@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.12":
-  version: 1.0.0-rc.12
-  resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.12"
+"@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.15":
+  version: 1.0.0-rc.15
+  resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.15"
   conditions: os=linux & cpu=ppc64 & libc=glibc
   languageName: node
   linkType: hard
 
-"@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.12":
-  version: 1.0.0-rc.12
-  resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.12"
+"@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.15":
+  version: 1.0.0-rc.15
+  resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.15"
   conditions: os=linux & cpu=s390x & libc=glibc
   languageName: node
   linkType: hard
 
-"@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.12":
-  version: 1.0.0-rc.12
-  resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.12"
+"@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.15":
+  version: 1.0.0-rc.15
+  resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.15"
   conditions: os=linux & cpu=x64 & libc=glibc
   languageName: node
   linkType: hard
 
-"@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.12":
-  version: 1.0.0-rc.12
-  resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.12"
+"@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.15":
+  version: 1.0.0-rc.15
+  resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.15"
   conditions: os=linux & cpu=x64 & libc=musl
   languageName: node
   linkType: hard
 
-"@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.12":
-  version: 1.0.0-rc.12
-  resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.12"
+"@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.15":
+  version: 1.0.0-rc.15
+  resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.15"
   conditions: os=openharmony & cpu=arm64
   languageName: node
   linkType: hard
 
-"@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.12":
-  version: 1.0.0-rc.12
-  resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.12"
+"@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.15":
+  version: 1.0.0-rc.15
+  resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.15"
   dependencies:
-    "@napi-rs/wasm-runtime": "npm:^1.1.1"
+    "@emnapi/core": "npm:1.9.2"
+    "@emnapi/runtime": "npm:1.9.2"
+    "@napi-rs/wasm-runtime": "npm:^1.1.3"
   conditions: cpu=wasm32
   languageName: node
   linkType: hard
 
-"@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.12":
-  version: 1.0.0-rc.12
-  resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.12"
+"@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.15":
+  version: 1.0.0-rc.15
+  resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.15"
   conditions: os=win32 & cpu=arm64
   languageName: node
   linkType: hard
 
-"@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.12":
-  version: 1.0.0-rc.12
-  resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.12"
+"@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.15":
+  version: 1.0.0-rc.15
+  resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.15"
   conditions: os=win32 & cpu=x64
   languageName: node
   linkType: hard
 
-"@rolldown/pluginutils@npm:1.0.0-rc.12":
-  version: 1.0.0-rc.12
-  resolution: "@rolldown/pluginutils@npm:1.0.0-rc.12"
-  checksum: 10c0/f785d1180ea4876bf6a6a67135822808d1c07f902409524ff1088779f7d5318f6e603d281fb107a5145c1ca54b7cabebd359629ec474ebbc2812f2cf53db4023
+"@rolldown/pluginutils@npm:1.0.0-rc.15":
+  version: 1.0.0-rc.15
+  resolution: "@rolldown/pluginutils@npm:1.0.0-rc.15"
+  checksum: 10c0/15eef6a65ee6b2d07405c16999c2333c40d8aeea60bbc35e04957992fe6477c7b278d3f02679688bb928ad2ef3fbd3a6149c116d7dc9928ebf8d1434a0591674
   languageName: node
   linkType: hard
 
@@ -744,11 +746,11 @@ __metadata:
   linkType: hard
 
 "@types/node@npm:^24.12.0":
-  version: 24.12.1
-  resolution: "@types/node@npm:24.12.1"
+  version: 24.12.2
+  resolution: "@types/node@npm:24.12.2"
   dependencies:
     undici-types: "npm:~7.16.0"
-  checksum: 10c0/0917fdf2e87980a8cd61b5b94c96443a8448cdc39b7d1782dc7d9916e4daa1bd6bac526125e9e4901a9cb41bfffee2dafdbeabc9d0639da7b4d620a7c30edc63
+  checksum: 10c0/710050c42f89075c4479e4e1e4c2532486b0c41b1e2a8a13ad88641c88b88cdaea87414e19224f30028719737bd70e327edcaa184d50e86b9418941edd7eb02b
   languageName: node
   linkType: hard
 
@@ -770,105 +772,105 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@typescript-eslint/eslint-plugin@npm:8.58.0":
-  version: 8.58.0
-  resolution: "@typescript-eslint/eslint-plugin@npm:8.58.0"
+"@typescript-eslint/eslint-plugin@npm:8.58.2":
+  version: 8.58.2
+  resolution: "@typescript-eslint/eslint-plugin@npm:8.58.2"
   dependencies:
     "@eslint-community/regexpp": "npm:^4.12.2"
-    "@typescript-eslint/scope-manager": "npm:8.58.0"
-    "@typescript-eslint/type-utils": "npm:8.58.0"
-    "@typescript-eslint/utils": "npm:8.58.0"
-    "@typescript-eslint/visitor-keys": "npm:8.58.0"
+    "@typescript-eslint/scope-manager": "npm:8.58.2"
+    "@typescript-eslint/type-utils": "npm:8.58.2"
+    "@typescript-eslint/utils": "npm:8.58.2"
+    "@typescript-eslint/visitor-keys": "npm:8.58.2"
     ignore: "npm:^7.0.5"
     natural-compare: "npm:^1.4.0"
     ts-api-utils: "npm:^2.5.0"
   peerDependencies:
-    "@typescript-eslint/parser": ^8.58.0
+    "@typescript-eslint/parser": ^8.58.2
     eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
     typescript: ">=4.8.4 <6.1.0"
-  checksum: 10c0/ac45c30f6ba9e188a01144708aa845e7ee8bb8a4d4f9aa6d2dce7784852d0821d42b031fee6832069935c3b885feff6d4014e30145b99693d25d7f563266a9f8
+  checksum: 10c0/87dd29c7a87461c586e3025cde2a6e35c7cc99e69c3a93ee8254f1523ab6d4d5d322cacd476e42a3aa87581fbcf9039ef528a638a80a5c9beb1c5ebb4cc557e2
   languageName: node
   linkType: hard
 
-"@typescript-eslint/parser@npm:8.58.0":
-  version: 8.58.0
-  resolution: "@typescript-eslint/parser@npm:8.58.0"
+"@typescript-eslint/parser@npm:8.58.2":
+  version: 8.58.2
+  resolution: "@typescript-eslint/parser@npm:8.58.2"
   dependencies:
-    "@typescript-eslint/scope-manager": "npm:8.58.0"
-    "@typescript-eslint/types": "npm:8.58.0"
-    "@typescript-eslint/typescript-estree": "npm:8.58.0"
-    "@typescript-eslint/visitor-keys": "npm:8.58.0"
+    "@typescript-eslint/scope-manager": "npm:8.58.2"
+    "@typescript-eslint/types": "npm:8.58.2"
+    "@typescript-eslint/typescript-estree": "npm:8.58.2"
+    "@typescript-eslint/visitor-keys": "npm:8.58.2"
     debug: "npm:^4.4.3"
   peerDependencies:
     eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
     typescript: ">=4.8.4 <6.1.0"
-  checksum: 10c0/56c7ec21675cec4730760bfa37c29e42e80b4d6444e2beca55fad9ef53731392270d142797482ea798405be0d7e28ec6c9c16a1ee2ee1c94f73d3bf0ed29763c
+  checksum: 10c0/7ce3e5086b5376a91f2932fda6e0d6777ff457535eff9c133852b21c895dc56933dcda173430352850e77c2437f81c5699fac9c70207abbbd087882766b88758
   languageName: node
   linkType: hard
 
-"@typescript-eslint/project-service@npm:8.58.0":
-  version: 8.58.0
-  resolution: "@typescript-eslint/project-service@npm:8.58.0"
+"@typescript-eslint/project-service@npm:8.58.2":
+  version: 8.58.2
+  resolution: "@typescript-eslint/project-service@npm:8.58.2"
   dependencies:
-    "@typescript-eslint/tsconfig-utils": "npm:^8.58.0"
-    "@typescript-eslint/types": "npm:^8.58.0"
+    "@typescript-eslint/tsconfig-utils": "npm:^8.58.2"
+    "@typescript-eslint/types": "npm:^8.58.2"
     debug: "npm:^4.4.3"
   peerDependencies:
     typescript: ">=4.8.4 <6.1.0"
-  checksum: 10c0/e6d0cb2f7708ccb31a2ff9eb35817d4999c26e1f1cd3c607539e21d0c73a234daa77c73ee1163bc4e8b139252d619823c444759f1ddabdd138cab4885e9c9794
+  checksum: 10c0/57fa2a54452f9d9058781feb8d99d7a25096d55db15783a552b242d144992ccf893548672d3bc554c1bc0768cd8c80dbb467e9aff0db471ebcc876d4409cf75e
   languageName: node
   linkType: hard
 
-"@typescript-eslint/scope-manager@npm:8.58.0":
-  version: 8.58.0
-  resolution: "@typescript-eslint/scope-manager@npm:8.58.0"
+"@typescript-eslint/scope-manager@npm:8.58.2":
+  version: 8.58.2
+  resolution: "@typescript-eslint/scope-manager@npm:8.58.2"
   dependencies:
-    "@typescript-eslint/types": "npm:8.58.0"
-    "@typescript-eslint/visitor-keys": "npm:8.58.0"
-  checksum: 10c0/bd5c16780f22d62359af0f69909f38a15fa3c55e609124a7cd5c2a04322fe41e586d81066f3ad1dcc3c1eff24dbcb48b78d099626d611fbd680c20c005d48f1d
+    "@typescript-eslint/types": "npm:8.58.2"
+    "@typescript-eslint/visitor-keys": "npm:8.58.2"
+  checksum: 10c0/9bf17c32d99db840500dfa4f0504635f6422fa435e0d2f3c58c36a88434d7af7ffe7ba9a6b13bd105dfa0f36a74307955ef2837ec5f1855e34c3af1843c11d36
   languageName: node
   linkType: hard
 
-"@typescript-eslint/tsconfig-utils@npm:8.58.0, @typescript-eslint/tsconfig-utils@npm:^8.58.0":
-  version: 8.58.0
-  resolution: "@typescript-eslint/tsconfig-utils@npm:8.58.0"
+"@typescript-eslint/tsconfig-utils@npm:8.58.2, @typescript-eslint/tsconfig-utils@npm:^8.58.2":
+  version: 8.58.2
+  resolution: "@typescript-eslint/tsconfig-utils@npm:8.58.2"
   peerDependencies:
     typescript: ">=4.8.4 <6.1.0"
-  checksum: 10c0/0a07fe1a28b2513e625882bc8d4c4e0c5a105cdbcb987beae12fc66dbe71dc9638013e4d1fa8ad10d828a2acd5e3fed987c189c00d41fed0e880009f99adf1b2
+  checksum: 10c0/d3dc874ab43af39245ee8383bb6d39c985e64c43b81a7bbf18b7982047473366c252e19a9fbfe38df30c677b42133aa43a1c0a75e92b8de5d2e64defd4b3a05e
   languageName: node
   linkType: hard
 
-"@typescript-eslint/type-utils@npm:8.58.0":
-  version: 8.58.0
-  resolution: "@typescript-eslint/type-utils@npm:8.58.0"
+"@typescript-eslint/type-utils@npm:8.58.2":
+  version: 8.58.2
+  resolution: "@typescript-eslint/type-utils@npm:8.58.2"
   dependencies:
-    "@typescript-eslint/types": "npm:8.58.0"
-    "@typescript-eslint/typescript-estree": "npm:8.58.0"
-    "@typescript-eslint/utils": "npm:8.58.0"
+    "@typescript-eslint/types": "npm:8.58.2"
+    "@typescript-eslint/typescript-estree": "npm:8.58.2"
+    "@typescript-eslint/utils": "npm:8.58.2"
     debug: "npm:^4.4.3"
     ts-api-utils: "npm:^2.5.0"
   peerDependencies:
     eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
     typescript: ">=4.8.4 <6.1.0"
-  checksum: 10c0/1223733d41f8463be92ef1ad048d546f9663152212b22dc968abbd9f8e4486bd4082e16baa51d2d281e0d4815563bc4b1ecf01684e2940b7897ba17aa26d1196
+  checksum: 10c0/1e7248694c15b5e78aeb573aef755513910f6a7ec1842223ec0c8429b6abd7342996de215aefab78520e64d2e8600c9829bdf56132476cb86703fd54f2492467
   languageName: node
   linkType: hard
 
-"@typescript-eslint/types@npm:8.58.0, @typescript-eslint/types@npm:^8.58.0":
-  version: 8.58.0
-  resolution: "@typescript-eslint/types@npm:8.58.0"
-  checksum: 10c0/f2fe1321758a04591c20d77caba956ae76b77cff0b976a0224b37077d80b1ebd826874d15ec79c3a3b7d57ee5679e5d10756db1b082bde3d51addbd3a8431d38
+"@typescript-eslint/types@npm:8.58.2, @typescript-eslint/types@npm:^8.58.2":
+  version: 8.58.2
+  resolution: "@typescript-eslint/types@npm:8.58.2"
+  checksum: 10c0/6707c1a2ec921b9ae441b35d9cb4e0af11673a67e332a366e3033f1d558ff5db4f39021872c207fb361841670e9ffcc4981f19eb21e4495a3a031d02015637a7
   languageName: node
   linkType: hard
 
-"@typescript-eslint/typescript-estree@npm:8.58.0":
-  version: 8.58.0
-  resolution: "@typescript-eslint/typescript-estree@npm:8.58.0"
+"@typescript-eslint/typescript-estree@npm:8.58.2":
+  version: 8.58.2
+  resolution: "@typescript-eslint/typescript-estree@npm:8.58.2"
   dependencies:
-    "@typescript-eslint/project-service": "npm:8.58.0"
-    "@typescript-eslint/tsconfig-utils": "npm:8.58.0"
-    "@typescript-eslint/types": "npm:8.58.0"
-    "@typescript-eslint/visitor-keys": "npm:8.58.0"
+    "@typescript-eslint/project-service": "npm:8.58.2"
+    "@typescript-eslint/tsconfig-utils": "npm:8.58.2"
+    "@typescript-eslint/types": "npm:8.58.2"
+    "@typescript-eslint/visitor-keys": "npm:8.58.2"
     debug: "npm:^4.4.3"
     minimatch: "npm:^10.2.2"
     semver: "npm:^7.7.3"
@@ -876,32 +878,32 @@ __metadata:
     ts-api-utils: "npm:^2.5.0"
   peerDependencies:
     typescript: ">=4.8.4 <6.1.0"
-  checksum: 10c0/a8cb94cb765b27740a54f9b5378bd8f0dc49e301ceed99a0791dc9d1f61c2a54e3212f7ed9120c8c2df80104ad3117150cf5e7fe8a0b7eec3ed04969a79b103e
+  checksum: 10c0/60a323f60eff9b4bb6eb3121c5f6292e7962517a329a8a9f828e8f07516de78e6a7c1b1b1cfd732f39edf184fe57828ca557fbc63b74c61b54bcb679a69e249c
   languageName: node
   linkType: hard
 
-"@typescript-eslint/utils@npm:8.58.0":
-  version: 8.58.0
-  resolution: "@typescript-eslint/utils@npm:8.58.0"
+"@typescript-eslint/utils@npm:8.58.2":
+  version: 8.58.2
+  resolution: "@typescript-eslint/utils@npm:8.58.2"
   dependencies:
     "@eslint-community/eslint-utils": "npm:^4.9.1"
-    "@typescript-eslint/scope-manager": "npm:8.58.0"
-    "@typescript-eslint/types": "npm:8.58.0"
-    "@typescript-eslint/typescript-estree": "npm:8.58.0"
+    "@typescript-eslint/scope-manager": "npm:8.58.2"
+    "@typescript-eslint/types": "npm:8.58.2"
+    "@typescript-eslint/typescript-estree": "npm:8.58.2"
   peerDependencies:
     eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
     typescript: ">=4.8.4 <6.1.0"
-  checksum: 10c0/457e01a6e6d954dbfe13c49ece3cf8a55e5d8cf19ea9ae7086c0e205d89e3cdbb91153062ab440d2e78ad3f077b174adc42bfb1b6fc24299020a0733e7f9c11c
+  checksum: 10c0/d83e6c7c1b01236d255cabe2a5dc5384eedebc9f9af6aa19cc2ab7d8b280f86912f2b1a87659b2754919afd2606820b4e53862ac91970794e2980bc97487537c
   languageName: node
   linkType: hard
 
-"@typescript-eslint/visitor-keys@npm:8.58.0":
-  version: 8.58.0
-  resolution: "@typescript-eslint/visitor-keys@npm:8.58.0"
+"@typescript-eslint/visitor-keys@npm:8.58.2":
+  version: 8.58.2
+  resolution: "@typescript-eslint/visitor-keys@npm:8.58.2"
   dependencies:
-    "@typescript-eslint/types": "npm:8.58.0"
+    "@typescript-eslint/types": "npm:8.58.2"
     eslint-visitor-keys: "npm:^5.0.0"
-  checksum: 10c0/75f3c9c097a308cc6450822a0f81d44c8b79b524e99dd2c41ded347b12f148ab3bd459ce9cc6bd00f8f0725c5831baab6d2561596ead3394ab76dddbeb32cce1
+  checksum: 10c0/6775a63dbafe7a305f0cf3f0c5eb077e30dba8a60022e4ce3220669c7f1e742c6ea2ebff8c6c0288dc17eeef8f4015089a23abbdc82a6a9382abe4a77950b695
   languageName: node
   linkType: hard
 
@@ -991,13 +993,13 @@ __metadata:
   linkType: hard
 
 "axios@npm:^1.14.0":
-  version: 1.14.0
-  resolution: "axios@npm:1.14.0"
+  version: 1.15.0
+  resolution: "axios@npm:1.15.0"
   dependencies:
     follow-redirects: "npm:^1.15.11"
     form-data: "npm:^4.0.5"
     proxy-from-env: "npm:^2.1.0"
-  checksum: 10c0/2541f4aa215a7d1842429dad006fc682d82bc0e74bd14500823f7d8cce3bbae0e0a8c328c8538946718f366ab8ce5a4c12e9ad40e5a0f3482ff8bff0cd115d45
+  checksum: 10c0/47e0f860e98d4d7aa145e89ce0cae00e1fb0f1d2485f065c21fce955ddb1dba4103a46bd0e47acd18a27208a7f62c96249e620db575521b92a968619ab133409
   languageName: node
   linkType: hard
 
@@ -1016,21 +1018,21 @@ __metadata:
   linkType: hard
 
 "baseline-browser-mapping@npm:^2.10.12":
-  version: 2.10.13
-  resolution: "baseline-browser-mapping@npm:2.10.13"
+  version: 2.10.19
+  resolution: "baseline-browser-mapping@npm:2.10.19"
   bin:
     baseline-browser-mapping: dist/cli.cjs
-  checksum: 10c0/3296604492f600927a9f519c81164522ac26456e63eb7b6816e39bfbb184494b48c58490639f2c0e35be97969d3a03613fddddbfdd3074710592369ed36957d5
+  checksum: 10c0/d7ab47484477d16e29b711b74c56791d751701e796a133fcd6b72cf7f73f95cb72c0bc02070c3a93e78210cd02a4dc6d573191ce6920b863b3a9d8e9aa893bcf
   languageName: node
   linkType: hard
 
 "brace-expansion@npm:^1.1.7":
-  version: 1.1.13
-  resolution: "brace-expansion@npm:1.1.13"
+  version: 1.1.14
+  resolution: "brace-expansion@npm:1.1.14"
   dependencies:
     balanced-match: "npm:^1.0.0"
     concat-map: "npm:0.0.1"
-  checksum: 10c0/384c61bb329b6adfdcc0cbbdd108dc19fb5f3e84ae15a02a74f94c6c791b5a9b035aae73b2a51929a8a478e2f0f212a771eb6a8b5b514cccfb8d0c9f2ce8cbd8
+  checksum: 10c0/b6fdac832bc4e36a753658c9ed052c2e1a2be221763b002df25d1efbf7d21724334e726a6cd5eadc72a4b19ec3efb632d629cc003bc9c62f7af7a7915ffa4385
   languageName: node
   linkType: hard
 
@@ -1094,9 +1096,9 @@ __metadata:
   linkType: hard
 
 "caniuse-lite@npm:^1.0.30001782":
-  version: 1.0.30001784
-  resolution: "caniuse-lite@npm:1.0.30001784"
-  checksum: 10c0/d6ff48177e48819a9041edab27d1ce9089b1ab9ba76f681b4925710dba5b00ff0347f70c6a99269d97fddc59e9f6947d219155b6bf4c1da9dd642503a03e5ce4
+  version: 1.0.30001788
+  resolution: "caniuse-lite@npm:1.0.30001788"
+  checksum: 10c0/d3c4695d0e7a1e95194cc5072e26db59cbcd25adfff64253859213c1a04ce9bc17f7b8ec8b11908ac1ecc6c1a0caf95fae0aec064a64b8df03286dffa629ce8a
   languageName: node
   linkType: hard
 
@@ -1233,9 +1235,9 @@ __metadata:
   linkType: hard
 
 "electron-to-chromium@npm:^1.5.328":
-  version: 1.5.331
-  resolution: "electron-to-chromium@npm:1.5.331"
-  checksum: 10c0/a7687f3bb4df4640bfeac08d1586531624917452bbbbeb67ccf2b07f111e584321e60945da080df664cbb57f272307d7867c8b93e279150ce8385f13d5178c96
+  version: 1.5.336
+  resolution: "electron-to-chromium@npm:1.5.336"
+  checksum: 10c0/8d4f4422f7360e22d7faa308259b74ec66bf92450db5d8da3a26c7976d158b23d4abfdf7ee87377e2ddc551e2288906ea7eca187b1216bd771fc13f172ffa183
   languageName: node
   linkType: hard
 
@@ -1529,12 +1531,12 @@ __metadata:
   linkType: hard
 
 "follow-redirects@npm:^1.15.11":
-  version: 1.15.11
-  resolution: "follow-redirects@npm:1.15.11"
+  version: 1.16.0
+  resolution: "follow-redirects@npm:1.16.0"
   peerDependenciesMeta:
     debug:
       optional: true
-  checksum: 10c0/d301f430542520a54058d4aeeb453233c564aaccac835d29d15e050beb33f339ad67d9bddbce01739c5dc46a6716dbe3d9d0d5134b1ca203effa11a7ef092343
+  checksum: 10c0/a1e2900163e6f1b4d1ed5c221b607f41decbab65534c63fe7e287e40a5d552a6496e7d9d7d976fa4ba77b4c51c11e5e9f683f10b43011ea11e442ff128d0e181
   languageName: node
   linkType: hard
 
@@ -1678,9 +1680,9 @@ __metadata:
   linkType: hard
 
 "globals@npm:^17.4.0":
-  version: 17.4.0
-  resolution: "globals@npm:17.4.0"
-  checksum: 10c0/2be9e8c2b9035836f13d420b22f0247a328db82967d3bebfc01126d888ed609305f06c05895914e969653af5c6ba35fd7a0920f3e6c869afa60666c810630feb
+  version: 17.5.0
+  resolution: "globals@npm:17.5.0"
+  checksum: 10c0/92828102ed2f5637907725f0478038bed02fc83e9fc89300bb753639ba7c022b6c02576fc772117302b431b204591db1f2fa909d26f3f0a9852cc856a941df3f
   languageName: node
   linkType: hard
 
@@ -2072,9 +2074,9 @@ __metadata:
   linkType: hard
 
 "lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1":
-  version: 11.3.3
-  resolution: "lru-cache@npm:11.3.3"
-  checksum: 10c0/f0053c0d6f58caca93b62c0037c8c913dfc02d9bc63b6c90f34279116e301ce051197796640ed5e71d96404fe6cfa5e841f40594dab30b8938f1d4f0b52e1883
+  version: 11.3.5
+  resolution: "lru-cache@npm:11.3.5"
+  checksum: 10c0/5b54ef7b88afb4bd25b7a778f1b2b1cde32d9770913e530da34ab203cf0442413bcaa6e372800cbab9562557a4480e4d8bf32e3a368bb5a91b12218eca085c66
   languageName: node
   linkType: hard
 
@@ -2088,11 +2090,11 @@ __metadata:
   linkType: hard
 
 "lucide-react@npm:^1.7.0":
-  version: 1.7.0
-  resolution: "lucide-react@npm:1.7.0"
+  version: 1.8.0
+  resolution: "lucide-react@npm:1.8.0"
   peerDependencies:
     react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
-  checksum: 10c0/bf913e1c9b8438fd1e19dbee602b4c1a596109faadd361b452e210135e51ff1147cbce9c1aa686a9c419f01541540536507a03202bc2cebebc04eb9a3cf8be2e
+  checksum: 10c0/f7398f89a0f5b2cbfd1b7a7f6b2f00b0abd05d4b21d948bbd281ab141c42cdff9f1d2aed4e32b78b4bbdfa78308b6577705698af8bf9945bad23ed7d56e255b4
   languageName: node
   linkType: hard
 
@@ -2389,7 +2391,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"picomatch@npm:^4.0.3, picomatch@npm:^4.0.4":
+"picomatch@npm:^4.0.4":
   version: 4.0.4
   resolution: "picomatch@npm:4.0.4"
   checksum: 10c0/e2c6023372cc7b5764719a5ffb9da0f8e781212fa7ca4bd0562db929df8e117460f00dff3cb7509dacfc06b86de924b247f504d0ce1806a37fac4633081466b0
@@ -2397,13 +2399,13 @@ __metadata:
   linkType: hard
 
 "postcss@npm:^8.5.8":
-  version: 8.5.8
-  resolution: "postcss@npm:8.5.8"
+  version: 8.5.9
+  resolution: "postcss@npm:8.5.9"
   dependencies:
     nanoid: "npm:^3.3.11"
     picocolors: "npm:^1.1.1"
     source-map-js: "npm:^1.2.1"
-  checksum: 10c0/dd918f7127ee7c60a0295bae2e72b3787892296e1d1c3c564d7a2a00c68d8df83cadc3178491259daa19ccc54804fb71ed8c937c6787e08d8bd4bedf8d17044c
+  checksum: 10c0/7cb2b32202ea1ead03f15cfbb2756a64a0f98942378e99b3dfce33678fe5eaf93e31d675a46e3a0dfb417d7b49b82d8999d0dd42a33c3b128e71ade0f978719a
   languageName: node
   linkType: hard
 
@@ -2436,31 +2438,31 @@ __metadata:
   linkType: hard
 
 "react-dom@npm:^19.2.4":
-  version: 19.2.4
-  resolution: "react-dom@npm:19.2.4"
+  version: 19.2.5
+  resolution: "react-dom@npm:19.2.5"
   dependencies:
     scheduler: "npm:^0.27.0"
   peerDependencies:
-    react: ^19.2.4
-  checksum: 10c0/f0c63f1794dedb154136d4d0f59af00b41907f4859571c155940296808f4b94bf9c0c20633db75b5b2112ec13d8d7dd4f9bf57362ed48782f317b11d05a44f35
+    react: ^19.2.5
+  checksum: 10c0/8067606e9f58e4c2e8cb5f09570217dbc71c4843ebcaa20ae2085912d3e3a351f17d8f7c1713313cdda7f272840c8c34ff6c860fcb840862071bceea218e0c63
   languageName: node
   linkType: hard
 
 "react-router-dom@npm:^7.14.0":
-  version: 7.14.0
-  resolution: "react-router-dom@npm:7.14.0"
+  version: 7.14.1
+  resolution: "react-router-dom@npm:7.14.1"
   dependencies:
-    react-router: "npm:7.14.0"
+    react-router: "npm:7.14.1"
   peerDependencies:
     react: ">=18"
     react-dom: ">=18"
-  checksum: 10c0/f7130c7083c2a8921aa59e9a9755ae4b79ef98b4df0ae84052ab0fd95b27612a7ebd2539b83d299b8073f8b5fc41595e8cc82bf748837d95d166f8ee19bf5f24
+  checksum: 10c0/aa454069e43263c812424a92fc9c099083034e438f9747efc45558885ec48b3dba46ab55bf84b164feac08c24a65e6ac91a3f8a137fd5e79077c95b8c14ca50a
   languageName: node
   linkType: hard
 
-"react-router@npm:7.14.0":
-  version: 7.14.0
-  resolution: "react-router@npm:7.14.0"
+"react-router@npm:7.14.1":
+  version: 7.14.1
+  resolution: "react-router@npm:7.14.1"
   dependencies:
     cookie: "npm:^1.0.1"
     set-cookie-parser: "npm:^2.6.0"
@@ -2470,24 +2472,24 @@ __metadata:
   peerDependenciesMeta:
     react-dom:
       optional: true
-  checksum: 10c0/a496489973cd5e87dcc5c1c7312f4cc99463eb5e0a0f97b3f298467531b754a3227562a83e0c9019b9d2452fd0681d05882ee061af2e0cafb0818f857578b805
+  checksum: 10c0/57b86accdc50b917509e4b21f821dd432807e3f62b5e978d0d5174fc7da3798ce57ce896cbf70a8ba8f6f3d53515a07021e759829908239b4eee34c804695120
   languageName: node
   linkType: hard
 
 "react-zoom-pan-pinch@npm:^4.0.2":
-  version: 4.0.2
-  resolution: "react-zoom-pan-pinch@npm:4.0.2"
+  version: 4.0.3
+  resolution: "react-zoom-pan-pinch@npm:4.0.3"
   peerDependencies:
     react: "*"
     react-dom: "*"
-  checksum: 10c0/98dfb189ad89b9e5985e1598005ee862e34b7a18e74a1a9da63d9be0f8ae076dcfcc6d3993e89eae3d7d4658f12b5339e8035eadd6e5aef81906bff766d09eca
+  checksum: 10c0/611bc498891550c5e59da5ee94996ff9c31eae533affa10f2fa0b0cb7b5333b51c1e7aa1bb918dcfff2a103c42de0b1963e1fdfe4fa87fcae36b046c37a822b1
   languageName: node
   linkType: hard
 
 "react@npm:^19.2.4":
-  version: 19.2.4
-  resolution: "react@npm:19.2.4"
-  checksum: 10c0/cd2c9ff67a720799cc3b38a516009986f7fc4cb8d3e15716c6211cf098d1357ee3e348ab05ad0600042bbb0fd888530ba92e329198c92eafa0994f5213396596
+  version: 19.2.5
+  resolution: "react@npm:19.2.5"
+  checksum: 10c0/4b5f231dbef92886f602533c9ce3bde04d99f0e71dfb5d794c43e02726efaad0421c08688f75fc98a6d6e1dc017372e1af7abbfecdc86a79968f461675931a7a
   languageName: node
   linkType: hard
 
@@ -2498,27 +2500,27 @@ __metadata:
   languageName: node
   linkType: hard
 
-"rolldown@npm:1.0.0-rc.12":
-  version: 1.0.0-rc.12
-  resolution: "rolldown@npm:1.0.0-rc.12"
-  dependencies:
-    "@oxc-project/types": "npm:=0.122.0"
-    "@rolldown/binding-android-arm64": "npm:1.0.0-rc.12"
-    "@rolldown/binding-darwin-arm64": "npm:1.0.0-rc.12"
-    "@rolldown/binding-darwin-x64": "npm:1.0.0-rc.12"
-    "@rolldown/binding-freebsd-x64": "npm:1.0.0-rc.12"
-    "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.0-rc.12"
-    "@rolldown/binding-linux-arm64-gnu": "npm:1.0.0-rc.12"
-    "@rolldown/binding-linux-arm64-musl": "npm:1.0.0-rc.12"
-    "@rolldown/binding-linux-ppc64-gnu": "npm:1.0.0-rc.12"
-    "@rolldown/binding-linux-s390x-gnu": "npm:1.0.0-rc.12"
-    "@rolldown/binding-linux-x64-gnu": "npm:1.0.0-rc.12"
-    "@rolldown/binding-linux-x64-musl": "npm:1.0.0-rc.12"
-    "@rolldown/binding-openharmony-arm64": "npm:1.0.0-rc.12"
-    "@rolldown/binding-wasm32-wasi": "npm:1.0.0-rc.12"
-    "@rolldown/binding-win32-arm64-msvc": "npm:1.0.0-rc.12"
-    "@rolldown/binding-win32-x64-msvc": "npm:1.0.0-rc.12"
-    "@rolldown/pluginutils": "npm:1.0.0-rc.12"
+"rolldown@npm:1.0.0-rc.15":
+  version: 1.0.0-rc.15
+  resolution: "rolldown@npm:1.0.0-rc.15"
+  dependencies:
+    "@oxc-project/types": "npm:=0.124.0"
+    "@rolldown/binding-android-arm64": "npm:1.0.0-rc.15"
+    "@rolldown/binding-darwin-arm64": "npm:1.0.0-rc.15"
+    "@rolldown/binding-darwin-x64": "npm:1.0.0-rc.15"
+    "@rolldown/binding-freebsd-x64": "npm:1.0.0-rc.15"
+    "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.0-rc.15"
+    "@rolldown/binding-linux-arm64-gnu": "npm:1.0.0-rc.15"
+    "@rolldown/binding-linux-arm64-musl": "npm:1.0.0-rc.15"
+    "@rolldown/binding-linux-ppc64-gnu": "npm:1.0.0-rc.15"
+    "@rolldown/binding-linux-s390x-gnu": "npm:1.0.0-rc.15"
+    "@rolldown/binding-linux-x64-gnu": "npm:1.0.0-rc.15"
+    "@rolldown/binding-linux-x64-musl": "npm:1.0.0-rc.15"
+    "@rolldown/binding-openharmony-arm64": "npm:1.0.0-rc.15"
+    "@rolldown/binding-wasm32-wasi": "npm:1.0.0-rc.15"
+    "@rolldown/binding-win32-arm64-msvc": "npm:1.0.0-rc.15"
+    "@rolldown/binding-win32-x64-msvc": "npm:1.0.0-rc.15"
+    "@rolldown/pluginutils": "npm:1.0.0-rc.15"
   dependenciesMeta:
     "@rolldown/binding-android-arm64":
       optional: true
@@ -2552,7 +2554,7 @@ __metadata:
       optional: true
   bin:
     rolldown: bin/cli.mjs
-  checksum: 10c0/0c4e5e3cdcdddce282cb2d84e1c98d6ad8d4e452d5c1402e498b35ec1060026e552dd783efc9f4ba876d7c0863b5973edc79b6a546f565e9832dc1077ec18c2c
+  checksum: 10c0/95df21125dafd2a0ce6ae9a89d926540e47900684023126c84632e18123371020da8f6b3235a188c45af0e4f9a5b963235de33bd9658ee5db9f3ff5862200eed
   languageName: node
   linkType: hard
 
@@ -2705,7 +2707,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"tinyglobby@npm:^0.2.12":
+"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.15":
   version: 0.2.16
   resolution: "tinyglobby@npm:0.2.16"
   dependencies:
@@ -2715,16 +2717,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"tinyglobby@npm:^0.2.15":
-  version: 0.2.15
-  resolution: "tinyglobby@npm:0.2.15"
-  dependencies:
-    fdir: "npm:^6.5.0"
-    picomatch: "npm:^4.0.3"
-  checksum: 10c0/869c31490d0d88eedb8305d178d4c75e7463e820df5a9b9d388291daf93e8b1eb5de1dad1c1e139767e4269fe75f3b10d5009b2cc14db96ff98986920a186844
-  languageName: node
-  linkType: hard
-
 "ts-api-utils@npm:^2.5.0":
   version: 2.5.0
   resolution: "ts-api-utils@npm:2.5.0"
@@ -2751,17 +2743,17 @@ __metadata:
   linkType: hard
 
 "typescript-eslint@npm:^8.57.0":
-  version: 8.58.0
-  resolution: "typescript-eslint@npm:8.58.0"
+  version: 8.58.2
+  resolution: "typescript-eslint@npm:8.58.2"
   dependencies:
-    "@typescript-eslint/eslint-plugin": "npm:8.58.0"
-    "@typescript-eslint/parser": "npm:8.58.0"
-    "@typescript-eslint/typescript-estree": "npm:8.58.0"
-    "@typescript-eslint/utils": "npm:8.58.0"
+    "@typescript-eslint/eslint-plugin": "npm:8.58.2"
+    "@typescript-eslint/parser": "npm:8.58.2"
+    "@typescript-eslint/typescript-estree": "npm:8.58.2"
+    "@typescript-eslint/utils": "npm:8.58.2"
   peerDependencies:
     eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
     typescript: ">=4.8.4 <6.1.0"
-  checksum: 10c0/85b56c1d209d0d6e07c09f05d30e1da4fec88285f96edc22a9b09321c41dc0572d686ee33532747bcf40cc071927f5b9a6b91f2fbe14dc1c45111a490394ab41
+  checksum: 10c0/6065fe90674e89100b3192716fc641d80de4b586fe244c00e2c97d47923166ab3286f895685bf9570919c8606724f1196486f09e7841ca73bdf05d5df0752945
   languageName: node
   linkType: hard
 
@@ -2816,19 +2808,19 @@ __metadata:
   linkType: hard
 
 "vite@npm:^8.0.1":
-  version: 8.0.3
-  resolution: "vite@npm:8.0.3"
+  version: 8.0.8
+  resolution: "vite@npm:8.0.8"
   dependencies:
     fsevents: "npm:~2.3.3"
     lightningcss: "npm:^1.32.0"
     picomatch: "npm:^4.0.4"
     postcss: "npm:^8.5.8"
-    rolldown: "npm:1.0.0-rc.12"
+    rolldown: "npm:1.0.0-rc.15"
     tinyglobby: "npm:^0.2.15"
   peerDependencies:
     "@types/node": ^20.19.0 || >=22.12.0
     "@vitejs/devtools": ^0.1.0
-    esbuild: ^0.27.0
+    esbuild: ^0.27.0 || ^0.28.0
     jiti: ">=1.21.0"
     less: ^4.0.0
     sass: ^1.70.0
@@ -2868,7 +2860,7 @@ __metadata:
       optional: true
   bin:
     vite: bin/vite.js
-  checksum: 10c0/bed9520358080393a02fe22565b3309b4b3b8f916afe4c97577528f3efb05c1bf4b29f7b552179bc5b3938629e50fbd316231727457411dbc96648fa5c9d14bf
+  checksum: 10c0/63474b399612ccf087d0aa025d7eb5c0d675012b6257b7f64332ff39579d4af4d5d7f0ac330906fc99b101abbf592c756adf143bb5748a02aec08f7d3639054d
   languageName: node
   linkType: hard
 

+ 65 - 2
knowhub/knowhub_db/README.md

@@ -2,6 +2,28 @@
 
 PostgreSQL 数据库的封装层。表结构和数据模型详见 [docs/schema.md](../docs/schema.md)。
 
+> ⚠️ **动手前先读 [docs/db-operations.md](../docs/db-operations.md)**——DDL / migration / 排查卡死的操作规范,全是踩过的坑。
+
+---
+
+## 连接约定
+
+所有 Store 使用 **`autocommit = True`**(每条语句独立事务)。原因:
+- `autocommit = False` 时 SELECT 后连接停在 `idle in transaction`,永久持有 `AccessShareLock`,阻塞未来的 DDL(ALTER / CREATE INDEX 等)
+- 我们的多语句写路径(`DELETE + INSERT ON CONFLICT DO NOTHING`)**幂等**,失去事务原子性影响可控——重试即可恢复
+
+各 Store 内仍有 `self.conn.commit()` 调用,autocommit 模式下为 no-op,保留不删是为了最小侵入。
+
+### AnalyticDB "beam" 表的 ON CONFLICT 限制
+
+所有实体表在 AnalyticDB 上是 **beam storage(类似列存/append-optimized)**。一旦通过 `ALTER TABLE ADD COLUMN` 添加过新列(如 v5 加的 `version`、`images`),该表的 `ON CONFLICT DO UPDATE` 语句就会报错:
+
+```
+modification of part columns in OnConflictUpdate is not supported on beam relation
+```
+
+因此 5 张实体表(knowledge/resource/requirement/capability/strategy)的 `insert_or_update` 都改用 **DELETE + INSERT** 模式,而不是 ON CONFLICT DO UPDATE。junction 表仍可用 `ON CONFLICT DO NOTHING`(不涉及 UPDATE)。tool 表没加新列,ON CONFLICT 仍可用。
+
 ---
 
 ## 封装类
@@ -39,7 +61,7 @@ tool 表的 CRUD + 向量检索。关联表:capability_tool, tool_knowledge。
 capability 表的 CRUD + 向量检索。关联表:requirement_capability, capability_tool, capability_knowledge。
 
 ### `PostgreSQLRequirementStore` (`pg_requirement_store.py`)
-requirement 表的 CRUD + 向量检索。关联表:requirement_capability, requirement_knowledge。
+requirement 表的 CRUD + 向量检索。关联表:requirement_capability, requirement_knowledge, requirement_resource, requirement_strategy
 
 | 方法 | 功能 |
 |------|------|
@@ -48,6 +70,45 @@ requirement 表的 CRUD + 向量检索。关联表:requirement_capability, req
 | `search(embedding, limit)` | 向量检索 |
 | `list_all(limit)` | 列出所有需求 |
 | `count()` | 统计总数 |
+| `add_knowledge(req_id, kid, relation_type='related')` | 增量挂接知识 |
+| `add_resource(req_id, resource_id)` | 增量挂接原始素材 |
+| `add_strategy(req_id, strategy_id)` | 增量挂接 strategy |
+
+### `PostgreSQLStrategyStore` (`pg_strategy_store.py`)
+strategy 表(制作策略 = 原子能力的组合 + 可执行 body)。关联表:strategy_capability, strategy_knowledge, strategy_resource, requirement_strategy。
+
+| 方法 | 功能 |
+|------|------|
+| `insert_or_update(strategy)` | 插入或更新 |
+| `get_by_id(id)` | 按 ID 查询 |
+| `search(embedding, limit)` | 向量检索 |
+| `list_all(limit, status)` | 列表查询 |
+| `update(id, updates)` | 更新 |
+| `delete(id)` | 删除(含级联) |
+| `count(status)` | 统计总数 |
+| `add_capability(sid, cap_id, relation_type='compose')` | 增量组合能力 |
+| `add_knowledge(sid, kid, relation_type='source')` | 增量挂接来源知识 |
+| `add_resource(sid, resource_id)` | 增量挂接原始素材 |
+| `add_requirement(sid, req_id)` | 增量挂接所满足的 requirement |
+
+---
+
+## 关联关系类型(relation_type)
+
+`*_knowledge` 和 `strategy_*` 边携带 `relation_type VARCHAR(32)` 语义标签:
+
+| 值 | 含义 | 使用场景 |
+|----|------|---------|
+| `source` | 构建该实体的知识/资料来源 | 研究产出的能力、策略的理论依据 |
+| `case` | 该实体的应用实例 | 工具/能力/策略的使用案例 |
+| `compose` | 组合关系 | 仅 strategy → capability |
+| `related` | 默认/未分类 | 历史数据、弱关联 |
+
+Store 读取时同时返回两种视图:
+- `{entity}_ids: [id1, id2, ...]` —— 扁平 ID 列表(向后兼容)
+- `{entity}_links: [{id, relation_type}, ...]` —— 含类型
+
+写入时两种格式都接受:传扁平 ids 则默认 `'related'`,传 links 则使用指定 type。
 
 ---
 
@@ -57,16 +118,18 @@ requirement 表的 CRUD + 向量检索。关联表:requirement_capability, req
 knowhub_db/
 ├── pg_store.py                # knowledge 表
 ├── pg_resource_store.py       # resource 表
-├── cascade.py                 # 级联删除(应用层,替代 FK ON DELETE CASCADE)
 ├── pg_tool_store.py           # tool 表
 ├── pg_capability_store.py     # capability 表
 ├── pg_requirement_store.py    # requirement 表
+├── pg_strategy_store.py       # strategy 表
+├── cascade.py                 # 级联删除(应用层,替代 FK ON DELETE CASCADE)
 ├── README.md
 ├── migrations/                # 一次性迁移脚本(已执行,保留备查)
 └── scripts/                   # 诊断和运维脚本
     ├── check_table_structure.py   # 查看表结构和行数
     ├── check_extensions.py        # 查看 PostgreSQL 扩展
     ├── clear_locks.py             # 清理数据库锁
+    ├── kill_db_locks.py           # 清理数据库锁
     ├── clean_invalid_knowledge_refs.py  # 清理失效引用
     └── ...
 ```

+ 42 - 0
knowhub/knowhub_db/cascade.py

@@ -15,6 +15,7 @@ _JUNCTIONS = {
         ('knowledge_resource', 'knowledge_id'),
         ('knowledge_relation', 'source_id'),
         ('knowledge_relation', 'target_id'),
+        ('strategy_knowledge', 'knowledge_id'),
     ],
     'tool': [
         ('capability_tool', 'tool_id'),
@@ -25,13 +26,26 @@ _JUNCTIONS = {
         ('requirement_capability', 'capability_id'),
         ('capability_tool', 'capability_id'),
         ('capability_knowledge', 'capability_id'),
+        ('capability_resource', 'capability_id'),
+        ('strategy_capability', 'capability_id'),
     ],
     'requirement': [
         ('requirement_capability', 'requirement_id'),
         ('requirement_knowledge', 'requirement_id'),
+        ('requirement_resource', 'requirement_id'),
+        ('requirement_strategy', 'requirement_id'),
     ],
     'resource': [
         ('knowledge_resource', 'resource_id'),
+        ('capability_resource', 'resource_id'),
+        ('requirement_resource', 'resource_id'),
+        ('strategy_resource', 'resource_id'),
+    ],
+    'strategy': [
+        ('strategy_capability', 'strategy_id'),
+        ('strategy_knowledge', 'strategy_id'),
+        ('strategy_resource', 'strategy_id'),
+        ('requirement_strategy', 'strategy_id'),
     ],
 }
 
@@ -45,3 +59,31 @@ def cascade_delete(cursor, entity_table: str, entity_id: str):
     cursor.execute(
         f"DELETE FROM {entity_table} WHERE id = %s",
         (entity_id,))
+
+
+# 带 version 字段的实体表(tool 不在其中——它的 version 是工具自身发布版本,不是多租户标签)
+_VERSIONED_ENTITIES = ['knowledge', 'resource', 'requirement', 'capability', 'strategy']
+
+
+def purge_version(cursor, version: str) -> dict:
+    """
+    批量清除指定 version 的所有实体(及其 junction)。
+    常用于测试数据迭代——整批抹掉 tao_dev_1,重新入库。
+
+    Returns: {table: row_count_deleted, ...}
+    """
+    if not version or version == 'v0':
+        raise ValueError(f"Refusing to purge protected version: {version!r}(v0 和空字符串受保护)")
+
+    stats = {}
+    for table in _VERSIONED_ENTITIES:
+        # 先清掉所有指向这个版本实体的 junction 行
+        for junction, fk_col in _JUNCTIONS.get(table, []):
+            cursor.execute(
+                f"DELETE FROM {junction} WHERE {fk_col} IN "
+                f"(SELECT id FROM {table} WHERE version = %s)",
+                (version,))
+        # 再删实体本身
+        cursor.execute(f"DELETE FROM {table} WHERE version = %s", (version,))
+        stats[table] = cursor.rowcount if cursor.rowcount is not None else 0
+    return stats

+ 216 - 0
knowhub/knowhub_db/migrations/migrate_v4_strategy_and_relation_types.py

@@ -0,0 +1,216 @@
+#!/usr/bin/env python3
+"""
+数据库迁移 v4:新增 strategy 实体 + 扩展 relation_type
+
+本次变更:
+1. 新增实体表 strategy
+2. 新增 junction 表:
+   - strategy_capability(默认 relation_type='compose')
+   - strategy_knowledge(默认 relation_type='source')
+   - strategy_resource
+   - capability_resource
+   - requirement_resource
+   - requirement_strategy(strategy 满足哪些 requirement)
+3. 为现有 junction 表加 relation_type 列(DEFAULT 'related'):
+   - requirement_knowledge
+   - capability_knowledge
+   - tool_knowledge
+
+关于 PK:不修改现有 *_knowledge 的 PK——保持 (entity_id, knowledge_id) 唯一。
+relation_type 作为分类属性(一对 entity-knowledge 只有一个语义)。
+
+关于枚举:应用层保证写入值属于 {source, case, compose, related}。
+不加 DB 侧 CHECK 约束,为将来扩展保留空间。
+
+幂等:反复执行不破坏已有数据。
+"""
+
+import os
+import psycopg2
+from psycopg2.extras import RealDictCursor
+from dotenv import load_dotenv
+
+_script_dir = os.path.dirname(os.path.abspath(__file__))
+_project_root = os.path.normpath(os.path.join(_script_dir, '..', '..', '..'))
+load_dotenv(os.path.join(_project_root, '.env'))
+
+
+def get_connection():
+    conn = psycopg2.connect(
+        host=os.getenv('KNOWHUB_DB'),
+        port=int(os.getenv('KNOWHUB_PORT', 5432)),
+        user=os.getenv('KNOWHUB_USER'),
+        password=os.getenv('KNOWHUB_PASSWORD'),
+        database=os.getenv('KNOWHUB_DB_NAME'),
+        connect_timeout=10
+    )
+    conn.autocommit = True
+    return conn
+
+
+def table_exists(cursor, name):
+    cursor.execute("SELECT 1 FROM information_schema.tables WHERE table_name = %s", (name,))
+    return cursor.fetchone() is not None
+
+
+def column_exists(cursor, table, column):
+    cursor.execute(
+        "SELECT 1 FROM information_schema.columns WHERE table_name = %s AND column_name = %s",
+        (table, column))
+    return cursor.fetchone() is not None
+
+
+# ─── Step 1: 新增实体表 strategy ─────────────────────────────────────────────
+
+CREATE_STRATEGY = """
+CREATE TABLE IF NOT EXISTS strategy (
+    id          VARCHAR PRIMARY KEY,
+    name        VARCHAR,
+    description TEXT,
+    body        TEXT,
+    status      VARCHAR DEFAULT 'draft',
+    created_at  BIGINT,
+    updated_at  BIGINT,
+    embedding   float4[]
+)
+"""
+
+# ─── Step 2: 新增 junction 表 ─────────────────────────────────────────────────
+
+CREATE_NEW_JUNCTIONS = [
+    # strategy 的组合关系:strategy → capability(compose)
+    """
+    CREATE TABLE IF NOT EXISTS strategy_capability (
+        strategy_id   VARCHAR NOT NULL,
+        capability_id VARCHAR NOT NULL,
+        relation_type VARCHAR(32) NOT NULL DEFAULT 'compose',
+        PRIMARY KEY (strategy_id, capability_id)
+    )
+    """,
+    # strategy 的知识来源:strategy → knowledge(默认 source,也可 case 等)
+    """
+    CREATE TABLE IF NOT EXISTS strategy_knowledge (
+        strategy_id   VARCHAR NOT NULL,
+        knowledge_id  VARCHAR NOT NULL,
+        relation_type VARCHAR(32) NOT NULL DEFAULT 'source',
+        PRIMARY KEY (strategy_id, knowledge_id)
+    )
+    """,
+    # strategy 的原始素材(直接来源,无 type)
+    """
+    CREATE TABLE IF NOT EXISTS strategy_resource (
+        strategy_id VARCHAR NOT NULL,
+        resource_id VARCHAR NOT NULL,
+        PRIMARY KEY (strategy_id, resource_id)
+    )
+    """,
+    # capability 的原始素材(直接来源,无 type)
+    """
+    CREATE TABLE IF NOT EXISTS capability_resource (
+        capability_id VARCHAR NOT NULL,
+        resource_id   VARCHAR NOT NULL,
+        PRIMARY KEY (capability_id, resource_id)
+    )
+    """,
+    # requirement 的原始素材(直接来源,无 type)
+    """
+    CREATE TABLE IF NOT EXISTS requirement_resource (
+        requirement_id VARCHAR NOT NULL,
+        resource_id    VARCHAR NOT NULL,
+        PRIMARY KEY (requirement_id, resource_id)
+    )
+    """,
+    # strategy 被设计用来满足哪些 requirement
+    """
+    CREATE TABLE IF NOT EXISTS requirement_strategy (
+        requirement_id VARCHAR NOT NULL,
+        strategy_id    VARCHAR NOT NULL,
+        PRIMARY KEY (requirement_id, strategy_id)
+    )
+    """,
+]
+
+# ─── Step 3: 为现有 *_knowledge 加 relation_type 列 ───────────────────────────
+
+TABLES_NEEDING_RELATION_TYPE = [
+    'requirement_knowledge',
+    'capability_knowledge',
+    'tool_knowledge',
+]
+
+
+def add_relation_type_column(cursor, table: str):
+    """幂等:若列已存在则跳过"""
+    if column_exists(cursor, table, 'relation_type'):
+        print(f"  {table}.relation_type 已存在,跳过")
+        return
+    cursor.execute(f"""
+        ALTER TABLE {table}
+        ADD COLUMN relation_type VARCHAR(32) NOT NULL DEFAULT 'related'
+    """)
+    print(f"  ✓ {table}.relation_type 已添加(DEFAULT 'related')")
+
+
+# ─── 主流程 ───────────────────────────────────────────────────────────────────
+
+def main():
+    print("=" * 60)
+    print("KnowHub 迁移 v4: strategy + relation_type")
+    print("=" * 60)
+
+    conn = get_connection()
+    cursor = conn.cursor(cursor_factory=RealDictCursor)
+
+    # Step 1: strategy 实体表
+    print("\n[1/3] 创建 strategy 实体表...")
+    cursor.execute(CREATE_STRATEGY)
+    print("  ✓ strategy")
+
+    # Step 2: 5 张新 junction 表
+    print("\n[2/3] 创建新 junction 表...")
+    for sql in CREATE_NEW_JUNCTIONS:
+        cursor.execute(sql)
+    for t in ('strategy_capability', 'strategy_knowledge', 'strategy_resource',
+              'capability_resource', 'requirement_resource', 'requirement_strategy'):
+        print(f"  ✓ {t}")
+
+    # Step 3: 为现有 *_knowledge 加 relation_type 列
+    print("\n[3/3] 为现有 *_knowledge 添加 relation_type 列...")
+    for t in TABLES_NEEDING_RELATION_TYPE:
+        add_relation_type_column(cursor, t)
+
+    # 验证
+    print("\n" + "=" * 60)
+    print("最终表结构验证:")
+    print("=" * 60)
+    check_tables = [
+        'strategy',
+        'strategy_capability', 'strategy_knowledge', 'strategy_resource',
+        'capability_resource', 'requirement_resource', 'requirement_strategy',
+        'requirement_knowledge', 'capability_knowledge', 'tool_knowledge',
+        'knowledge_relation',
+    ]
+    for t in check_tables:
+        try:
+            cursor.execute(f"""
+                SELECT column_name FROM information_schema.columns
+                WHERE table_name = %s ORDER BY ordinal_position
+            """, (t,))
+            cols = [r['column_name'] for r in cursor.fetchall()]
+            cursor.execute(f"SELECT COUNT(*) as count FROM {t}")
+            count = cursor.fetchone()['count']
+            print(f"\n  {t} ({count} rows)")
+            print(f"    {', '.join(cols)}")
+        except Exception as e:
+            print(f"\n  {t}: ERROR - {e}")
+
+    print("\n" + "=" * 60)
+    print("迁移成功!")
+    print("=" * 60)
+
+    cursor.close()
+    conn.close()
+
+
+if __name__ == '__main__':
+    main()

+ 154 - 0
knowhub/knowhub_db/migrations/migrate_v5_version_and_images.py

@@ -0,0 +1,154 @@
+#!/usr/bin/env python3
+"""
+数据库迁移 v5:多租户版本字段 + resource 图片
+
+本次变更:
+1. 给 6 张实体表加 version 列(VARCHAR(32) DEFAULT 'v0'):
+   knowledge, resource, requirement, capability, tool, strategy
+2. 给 resource 表加 images 列(JSONB DEFAULT '[]')
+
+注意:经 v4 踩坑后的做法——
+- 合并 `NOT NULL DEFAULT` 会挂;拆两步:先 ADD COLUMN DEFAULT,再 SET NOT NULL
+- SET NOT NULL 可能因 idle-in-tx 锁冲突失败;失败不致命,DEFAULT 对新行已生效
+- 跑 DDL 前先 kill idle-in-tx,防止 ALTER 被 AccessShareLock 阻塞
+
+幂等:反复执行不破坏已有数据。
+"""
+
+import os
+import time
+import psycopg2
+from psycopg2.extras import RealDictCursor
+from dotenv import load_dotenv
+
+_script_dir = os.path.dirname(os.path.abspath(__file__))
+_project_root = os.path.normpath(os.path.join(_script_dir, '..', '..', '..'))
+load_dotenv(os.path.join(_project_root, '.env'))
+
+
+def log(msg):
+    print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
+
+
+def get_connection():
+    conn = psycopg2.connect(
+        host=os.getenv('KNOWHUB_DB'),
+        port=int(os.getenv('KNOWHUB_PORT', 5432)),
+        user=os.getenv('KNOWHUB_USER'),
+        password=os.getenv('KNOWHUB_PASSWORD'),
+        database=os.getenv('KNOWHUB_DB_NAME'),
+        connect_timeout=10
+    )
+    conn.autocommit = True
+    return conn
+
+
+def column_exists(cursor, table, column):
+    cursor.execute(
+        "SELECT 1 FROM information_schema.columns WHERE table_name = %s AND column_name = %s",
+        (table, column))
+    return cursor.fetchone() is not None
+
+
+def is_not_null(cursor, table, column):
+    cursor.execute(
+        "SELECT is_nullable FROM information_schema.columns WHERE table_name=%s AND column_name=%s",
+        (table, column))
+    row = cursor.fetchone()
+    return row and row['is_nullable'] == 'NO'
+
+
+def kill_idle_in_tx(cursor):
+    """跑 DDL 前清掉所有 idle in transaction 会话(会阻塞 DDL 等锁)"""
+    cursor.execute("""SELECT pid FROM pg_stat_activity
+                      WHERE state='idle in transaction' AND pid != pg_backend_pid()
+                        AND datname=current_database()""")
+    pids = [r['pid'] for r in cursor.fetchall()]
+    if pids:
+        log(f"  killing {len(pids)} idle-in-tx: {pids}")
+        for p in pids:
+            cursor.execute("SELECT pg_terminate_backend(%s)", (p,))
+        time.sleep(1)
+    return len(pids)
+
+
+# ─── 主流程 ───────────────────────────────────────────────────────────────────
+
+ENTITIES = ['knowledge', 'resource', 'requirement', 'capability', 'tool', 'strategy']
+
+
+def main():
+    log("=" * 60)
+    log("KnowHub 迁移 v5: version + resource.images")
+    log("=" * 60)
+
+    conn = get_connection()
+    cursor = conn.cursor(cursor_factory=RealDictCursor)
+    cursor.execute("SET statement_timeout = '30s'")
+
+    # Step 0: 清理可能阻塞 DDL 的会话
+    log("\n[0/3] 清理 idle-in-transaction 会话...")
+    kill_idle_in_tx(cursor)
+
+    # Step 1: 给每个实体表加 version 列
+    log("\n[1/3] 添加 version 列(DEFAULT 'v0')...")
+    for t in ENTITIES:
+        if column_exists(cursor, t, 'version'):
+            log(f"  {t}.version 已存在,跳过")
+            continue
+        try:
+            cursor.execute(f"ALTER TABLE {t} ADD COLUMN version VARCHAR(32) DEFAULT 'v0'")
+            log(f"  ✓ {t}.version 已添加")
+        except Exception as e:
+            log(f"  ✗ {t}.version 失败: {type(e).__name__}: {str(e)[:150]}")
+
+    # Step 2: 给 resource 加 images 列
+    log("\n[2/3] resource.images(JSONB DEFAULT '[]')...")
+    if column_exists(cursor, 'resource', 'images'):
+        log("  resource.images 已存在,跳过")
+    else:
+        try:
+            cursor.execute("ALTER TABLE resource ADD COLUMN images JSONB DEFAULT '[]'")
+            log("  ✓ resource.images 已添加")
+        except Exception as e:
+            log(f"  ✗ resource.images 失败: {type(e).__name__}: {str(e)[:150]}")
+
+    # Step 3: 尝试给 version 列设 NOT NULL(非关键,失败可接受)
+    log("\n[3/3] 尝试给 version 列加 NOT NULL(失败不致命)...")
+    kill_idle_in_tx(cursor)
+    for t in ENTITIES:
+        if is_not_null(cursor, t, 'version'):
+            log(f"  {t}.version 已 NOT NULL")
+            continue
+        try:
+            cursor.execute(f"ALTER TABLE {t} ALTER COLUMN version SET NOT NULL")
+            log(f"  ✓ {t}.version SET NOT NULL")
+        except Exception as e:
+            log(f"  ⚠️ {t}.version SET NOT NULL 未完成: {type(e).__name__}: {str(e)[:120]}")
+
+    # 最终验证
+    log("\n" + "=" * 60)
+    log("最终状态:")
+    log("=" * 60)
+    for t in ENTITIES:
+        cursor.execute("""SELECT column_default, is_nullable FROM information_schema.columns
+                          WHERE table_name=%s AND column_name='version'""", (t,))
+        row = cursor.fetchone()
+        cursor.execute(f"SELECT COUNT(*) AS c FROM {t}")
+        total = cursor.fetchone()['c']
+        if row:
+            log(f"  {t}: version default={row['column_default']!r}, nullable={row['is_nullable']}, rows={total}")
+        else:
+            log(f"  {t}: version MISSING, rows={total}")
+    cursor.execute("""SELECT column_default FROM information_schema.columns
+                      WHERE table_name='resource' AND column_name='images'""")
+    row = cursor.fetchone()
+    log(f"  resource.images: {'default=' + repr(row['column_default']) if row else 'MISSING'}")
+
+    log("\n迁移完成")
+    cursor.close()
+    conn.close()
+
+
+if __name__ == '__main__':
+    main()

+ 68 - 11
knowhub/knowhub_db/pg_capability_store.py

@@ -25,14 +25,35 @@ _REL_SUBQUERIES = """
         json_object_agg(ct2.tool_id, ct2.description), '{}'::json)
      FROM capability_tool ct2 WHERE ct2.capability_id = capability.id AND ct2.description != '') AS implements,
     (SELECT COALESCE(json_agg(ck.knowledge_id), '[]'::json)
-     FROM capability_knowledge ck WHERE ck.capability_id = capability.id) AS knowledge_ids
+     FROM capability_knowledge ck WHERE ck.capability_id = capability.id) AS knowledge_ids,
+    (SELECT COALESCE(json_agg(json_build_object(
+        'id', ck2.knowledge_id, 'relation_type', ck2.relation_type
+     )), '[]'::json)
+     FROM capability_knowledge ck2 WHERE ck2.capability_id = capability.id) AS knowledge_links,
+    (SELECT COALESCE(json_agg(cr.resource_id), '[]'::json)
+     FROM capability_resource cr WHERE cr.capability_id = capability.id) AS resource_ids
 """
 
-_BASE_FIELDS = "id, name, criterion, description, effects"
+_BASE_FIELDS = "id, name, criterion, description,version, effects"
 
 _SELECT_FIELDS = f"{_BASE_FIELDS}, {_REL_SUBQUERIES}"
 
 
+def _normalize_links(data: Dict, links_key: str, ids_key: str, default_type: str):
+    """两种输入格式统一:{links_key: [{id, relation_type}]} 或 {ids_key: [id]}"""
+    if links_key in data and data[links_key] is not None:
+        out = []
+        for item in data[links_key]:
+            if isinstance(item, dict):
+                out.append((item['id'], item.get('relation_type', default_type)))
+            else:
+                out.append((item, default_type))
+        return out
+    if ids_key in data and data[ids_key] is not None:
+        return [(i, default_type) for i in data[ids_key]]
+    return None
+
+
 class PostgreSQLCapabilityStore:
     def __init__(self):
         """初始化 PostgreSQL 连接"""
@@ -43,7 +64,7 @@ class PostgreSQLCapabilityStore:
             password=os.getenv('KNOWHUB_PASSWORD'),
             database=os.getenv('KNOWHUB_DB_NAME')
         )
-        self.conn.autocommit = False
+        self.conn.autocommit = True
         print(f"[PostgreSQL Capability] 已连接到远程数据库: {os.getenv('KNOWHUB_DB')}")
 
     def _reconnect(self):
@@ -54,7 +75,7 @@ class PostgreSQLCapabilityStore:
             password=os.getenv('KNOWHUB_PASSWORD'),
             database=os.getenv('KNOWHUB_DB_NAME')
         )
-        self.conn.autocommit = False
+        self.conn.autocommit = True
 
     def _ensure_connection(self):
         if self.conn.closed != 0:
@@ -98,17 +119,27 @@ class PostgreSQLCapabilityStore:
                         "INSERT INTO capability_tool (capability_id, tool_id, description) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
                         (cap_id, tool_id, desc))
 
-        if 'knowledge_ids' in data:
+        k_links = _normalize_links(data, 'knowledge_links', 'knowledge_ids', 'related')
+        if k_links is not None:
             cursor.execute("DELETE FROM capability_knowledge WHERE capability_id = %s", (cap_id,))
-            for kid in data['knowledge_ids']:
+            for kid, rtype in k_links:
+                cursor.execute(
+                    "INSERT INTO capability_knowledge (capability_id, knowledge_id, relation_type) "
+                    "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                    (cap_id, kid, rtype))
+
+        if 'resource_ids' in data and data['resource_ids'] is not None:
+            cursor.execute("DELETE FROM capability_resource WHERE capability_id = %s", (cap_id,))
+            for rid in data['resource_ids']:
                 cursor.execute(
-                    "INSERT INTO capability_knowledge (capability_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
-                    (cap_id, kid))
+                    "INSERT INTO capability_resource (capability_id, resource_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                    (cap_id, rid))
 
     def insert_or_update(self, cap: Dict):
-        """插入或更新原子能力"""
+        """插入或更新原子能力。AnalyticDB beam 表不支持 ON CONFLICT UPDATE 当含 ALTER 新增列,改用 DELETE+INSERT。"""
         cursor = self._get_cursor()
         try:
+            cursor.execute("DELETE FROM capability WHERE id = %s", (cap['id'],))
             cursor.execute("""
                 INSERT INTO capability (
                     id, name, criterion, description, effects, embedding
@@ -118,7 +149,8 @@ class PostgreSQLCapabilityStore:
                     criterion = EXCLUDED.criterion,
                     description = EXCLUDED.description,
                     effects = EXCLUDED.effects,
-                    embedding = EXCLUDED.embedding
+                    embedding = EXCLUDED.embedding,
+                    version = EXCLUDED.version
             """, (
                 cap['id'],
                 cap.get('name', ''),
@@ -126,6 +158,7 @@ class PostgreSQLCapabilityStore:
                 cap.get('description', ''),
                 json.dumps(cap.get('effects', [])),
                 cap.get('embedding'),
+                cap.get('version', 'v0'),
             ))
             self._save_relations(cursor, cap['id'], cap)
             self.conn.commit()
@@ -183,7 +216,8 @@ class PostgreSQLCapabilityStore:
         try:
             # 分离关联字段
             rel_fields = {}
-            for key in ('requirement_ids', 'implements', 'tool_ids', 'knowledge_ids'):
+            for key in ('requirement_ids', 'implements', 'tool_ids',
+                        'knowledge_ids', 'knowledge_links', 'resource_ids'):
                 if key in updates:
                     rel_fields[key] = updates.pop(key)
 
@@ -241,6 +275,29 @@ class PostgreSQLCapabilityStore:
                 result['implements'] = {}
         return result
 
+    def add_knowledge(self, cap_id: str, knowledge_id: str, relation_type: str = 'related'):
+        """增量挂接 capability-knowledge 边"""
+        cursor = self._get_cursor()
+        try:
+            cursor.execute(
+                "INSERT INTO capability_knowledge (capability_id, knowledge_id, relation_type) "
+                "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                (cap_id, knowledge_id, relation_type))
+            self.conn.commit()
+        finally:
+            cursor.close()
+
+    def add_resource(self, cap_id: str, resource_id: str):
+        """增量挂接 capability-resource 边"""
+        cursor = self._get_cursor()
+        try:
+            cursor.execute(
+                "INSERT INTO capability_resource (capability_id, resource_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                (cap_id, resource_id))
+            self.conn.commit()
+        finally:
+            cursor.close()
+
     def close(self):
         if self.conn:
             self.conn.close()

+ 111 - 24
knowhub/knowhub_db/pg_requirement_store.py

@@ -15,19 +15,42 @@ from knowhub.knowhub_db.cascade import cascade_delete
 
 load_dotenv()
 
-# 关联字段子查询
+# 关联字段子查询。knowledge 边暴露两种视图:knowledge_ids(扁平)+ knowledge_links(含 type)
 _REL_SUBQUERY = """
     (SELECT COALESCE(json_agg(rc.capability_id), '[]'::json)
      FROM requirement_capability rc WHERE rc.requirement_id = requirement.id) AS capability_ids,
     (SELECT COALESCE(json_agg(rk.knowledge_id), '[]'::json)
-     FROM requirement_knowledge rk WHERE rk.requirement_id = requirement.id) AS knowledge_ids
+     FROM requirement_knowledge rk WHERE rk.requirement_id = requirement.id) AS knowledge_ids,
+    (SELECT COALESCE(json_agg(json_build_object(
+        'id', rk2.knowledge_id, 'relation_type', rk2.relation_type
+     )), '[]'::json)
+     FROM requirement_knowledge rk2 WHERE rk2.requirement_id = requirement.id) AS knowledge_links,
+    (SELECT COALESCE(json_agg(rr.resource_id), '[]'::json)
+     FROM requirement_resource rr WHERE rr.requirement_id = requirement.id) AS resource_ids,
+    (SELECT COALESCE(json_agg(rs.strategy_id), '[]'::json)
+     FROM requirement_strategy rs WHERE rs.requirement_id = requirement.id) AS strategy_ids
 """
 
-_BASE_FIELDS = "id, description, source_nodes, status, match_result"
+_BASE_FIELDS = "id, description, source_nodes, status, match_result, version"
 
 _SELECT_FIELDS = f"{_BASE_FIELDS}, {_REL_SUBQUERY}"
 
 
+def _normalize_links(data: Dict, links_key: str, ids_key: str, default_type: str):
+    """两种输入格式统一:{links_key: [{id, relation_type}]} 或 {ids_key: [id]}"""
+    if links_key in data and data[links_key] is not None:
+        out = []
+        for item in data[links_key]:
+            if isinstance(item, dict):
+                out.append((item['id'], item.get('relation_type', default_type)))
+            else:
+                out.append((item, default_type))
+        return out
+    if ids_key in data and data[ids_key] is not None:
+        return [(i, default_type) for i in data[ids_key]]
+    return None
+
+
 class PostgreSQLRequirementStore:
     def __init__(self):
         """初始化 PostgreSQL 连接"""
@@ -38,7 +61,7 @@ class PostgreSQLRequirementStore:
             password=os.getenv('KNOWHUB_PASSWORD'),
             database=os.getenv('KNOWHUB_DB_NAME')
         )
-        self.conn.autocommit = False
+        self.conn.autocommit = True
         print(f"[PostgreSQL Requirement] 已连接到远程数据库: {os.getenv('KNOWHUB_DB')}")
 
     def _reconnect(self):
@@ -49,7 +72,7 @@ class PostgreSQLRequirementStore:
             password=os.getenv('KNOWHUB_PASSWORD'),
             database=os.getenv('KNOWHUB_DB_NAME')
         )
-        self.conn.autocommit = False
+        self.conn.autocommit = True
 
     def _ensure_connection(self):
         if self.conn.closed != 0:
@@ -67,19 +90,14 @@ class PostgreSQLRequirementStore:
         return self.conn.cursor(cursor_factory=RealDictCursor)
 
     def insert_or_update(self, requirement: Dict):
-        """插入或更新需求记录"""
+        """插入或更新需求记录。AnalyticDB beam 表不支持 ON CONFLICT UPDATE 当含 ALTER 新增列,改用 DELETE+INSERT。"""
         cursor = self._get_cursor()
         try:
+            cursor.execute("DELETE FROM requirement WHERE id = %s", (requirement['id'],))
             cursor.execute("""
                 INSERT INTO requirement (
-                    id, description, source_nodes, status, match_result, embedding
-                ) VALUES (%s, %s, %s, %s, %s, %s)
-                ON CONFLICT (id) DO UPDATE SET
-                    description = EXCLUDED.description,
-                    source_nodes = EXCLUDED.source_nodes,
-                    status = EXCLUDED.status,
-                    match_result = EXCLUDED.match_result,
-                    embedding = EXCLUDED.embedding
+                    id, description, source_nodes, status, match_result, embedding, version
+                ) VALUES (%s, %s, %s, %s, %s, %s, %s)
             """, (
                 requirement['id'],
                 requirement.get('description', ''),
@@ -87,6 +105,7 @@ class PostgreSQLRequirementStore:
                 requirement.get('status', '未满足'),
                 requirement.get('match_result', ''),
                 requirement.get('embedding'),
+                requirement.get('version', 'v0'),
             ))
             # 写入关联表
             req_id = requirement['id']
@@ -96,12 +115,26 @@ class PostgreSQLRequirementStore:
                     cursor.execute(
                         "INSERT INTO requirement_capability (requirement_id, capability_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
                         (req_id, cap_id))
-            if 'knowledge_ids' in requirement:
+            k_links = _normalize_links(requirement, 'knowledge_links', 'knowledge_ids', 'related')
+            if k_links is not None:
                 cursor.execute("DELETE FROM requirement_knowledge WHERE requirement_id = %s", (req_id,))
-                for kid in requirement['knowledge_ids']:
+                for kid, rtype in k_links:
+                    cursor.execute(
+                        "INSERT INTO requirement_knowledge (requirement_id, knowledge_id, relation_type) "
+                        "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                        (req_id, kid, rtype))
+            if 'resource_ids' in requirement and requirement['resource_ids'] is not None:
+                cursor.execute("DELETE FROM requirement_resource WHERE requirement_id = %s", (req_id,))
+                for rid in requirement['resource_ids']:
                     cursor.execute(
-                        "INSERT INTO requirement_knowledge (requirement_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
-                        (req_id, kid))
+                        "INSERT INTO requirement_resource (requirement_id, resource_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                        (req_id, rid))
+            if 'strategy_ids' in requirement and requirement['strategy_ids'] is not None:
+                cursor.execute("DELETE FROM requirement_strategy WHERE requirement_id = %s", (req_id,))
+                for sid in requirement['strategy_ids']:
+                    cursor.execute(
+                        "INSERT INTO requirement_strategy (requirement_id, strategy_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                        (req_id, sid))
             self.conn.commit()
         finally:
             cursor.close()
@@ -166,7 +199,11 @@ class PostgreSQLRequirementStore:
         try:
             # 分离关联字段
             cap_ids = updates.pop('capability_ids', None)
-            knowledge_ids = updates.pop('knowledge_ids', None)
+            strategy_ids = updates.pop('strategy_ids', None)
+            rel_data = {}
+            for k in ('knowledge_ids', 'knowledge_links', 'resource_ids'):
+                if k in updates:
+                    rel_data[k] = updates.pop(k)
 
             if updates:
                 set_parts = []
@@ -191,13 +228,63 @@ class PostgreSQLRequirementStore:
                         "INSERT INTO requirement_capability (requirement_id, capability_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
                         (req_id, cap_id))
 
-            if knowledge_ids is not None:
+            k_links = _normalize_links(rel_data, 'knowledge_links', 'knowledge_ids', 'related')
+            if k_links is not None:
                 cursor.execute("DELETE FROM requirement_knowledge WHERE requirement_id = %s", (req_id,))
-                for kid in knowledge_ids:
+                for kid, rtype in k_links:
+                    cursor.execute(
+                        "INSERT INTO requirement_knowledge (requirement_id, knowledge_id, relation_type) "
+                        "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                        (req_id, kid, rtype))
+
+            if 'resource_ids' in rel_data and rel_data['resource_ids'] is not None:
+                cursor.execute("DELETE FROM requirement_resource WHERE requirement_id = %s", (req_id,))
+                for rid in rel_data['resource_ids']:
                     cursor.execute(
-                        "INSERT INTO requirement_knowledge (requirement_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
-                        (req_id, kid))
+                        "INSERT INTO requirement_resource (requirement_id, resource_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                        (req_id, rid))
 
+            if strategy_ids is not None:
+                cursor.execute("DELETE FROM requirement_strategy WHERE requirement_id = %s", (req_id,))
+                for sid in strategy_ids:
+                    cursor.execute(
+                        "INSERT INTO requirement_strategy (requirement_id, strategy_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                        (req_id, sid))
+
+            self.conn.commit()
+        finally:
+            cursor.close()
+
+    def add_knowledge(self, req_id: str, knowledge_id: str, relation_type: str = 'related'):
+        """增量挂接 requirement-knowledge 边"""
+        cursor = self._get_cursor()
+        try:
+            cursor.execute(
+                "INSERT INTO requirement_knowledge (requirement_id, knowledge_id, relation_type) "
+                "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                (req_id, knowledge_id, relation_type))
+            self.conn.commit()
+        finally:
+            cursor.close()
+
+    def add_resource(self, req_id: str, resource_id: str):
+        """增量挂接 requirement-resource 边"""
+        cursor = self._get_cursor()
+        try:
+            cursor.execute(
+                "INSERT INTO requirement_resource (requirement_id, resource_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                (req_id, resource_id))
+            self.conn.commit()
+        finally:
+            cursor.close()
+
+    def add_strategy(self, req_id: str, strategy_id: str):
+        """增量挂接 requirement-strategy 边(该 strategy 满足此 requirement)"""
+        cursor = self._get_cursor()
+        try:
+            cursor.execute(
+                "INSERT INTO requirement_strategy (requirement_id, strategy_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                (req_id, strategy_id))
             self.conn.commit()
         finally:
             cursor.close()
@@ -231,7 +318,7 @@ class PostgreSQLRequirementStore:
         if 'source_nodes' in result and isinstance(result['source_nodes'], str):
             result['source_nodes'] = json.loads(result['source_nodes'])
         # 关联字段(来自 junction table 子查询)
-        for field in ('capability_ids', 'knowledge_ids'):
+        for field in ('capability_ids', 'knowledge_ids', 'resource_ids', 'strategy_ids', 'knowledge_links'):
             if field in result and isinstance(result[field], str):
                 result[field] = json.loads(result[field])
             elif field in result and result[field] is None:

+ 37 - 20
knowhub/knowhub_db/pg_resource_store.py

@@ -25,7 +25,7 @@ class PostgreSQLResourceStore:
             password=os.getenv('KNOWHUB_PASSWORD'),
             database=os.getenv('KNOWHUB_DB_NAME')
         )
-        self.conn.autocommit = False
+        self.conn.autocommit = True
 
     def _reconnect(self):
         self.conn = psycopg2.connect(
@@ -35,7 +35,7 @@ class PostgreSQLResourceStore:
             password=os.getenv('KNOWHUB_PASSWORD'),
             database=os.getenv('KNOWHUB_DB_NAME')
         )
-        self.conn.autocommit = False
+        self.conn.autocommit = True
 
     def _ensure_connection(self):
         if self.conn.closed != 0:
@@ -54,22 +54,19 @@ class PostgreSQLResourceStore:
         return self.conn.cursor(cursor_factory=RealDictCursor)
 
     def insert_or_update(self, resource: Dict):
-        """插入或更新资源"""
+        """插入或更新资源。
+        注:AnalyticDB beam 表不支持 ON CONFLICT DO UPDATE 当含 ALTER 新增列,改用 DELETE+INSERT。
+        junction 不受影响(不带 FK,DELETE 仅删实体行)。
+        """
         cursor = self._get_cursor()
         try:
             now_ts = int(time.time())
+            cursor.execute("DELETE FROM resource WHERE id = %s", (resource['id'],))
             cursor.execute("""
                 INSERT INTO resource (id, title, body, secure_body, content_type,
-                                       metadata, sort_order, submitted_by, created_at, updated_at)
-                VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
-                ON CONFLICT (id) DO UPDATE SET
-                    title = EXCLUDED.title,
-                    body = EXCLUDED.body,
-                    secure_body = EXCLUDED.secure_body,
-                    content_type = EXCLUDED.content_type,
-                    metadata = EXCLUDED.metadata,
-                    sort_order = EXCLUDED.sort_order,
-                    updated_at = EXCLUDED.updated_at
+                                       metadata, sort_order, submitted_by, created_at, updated_at,
+                                       version, images)
+                VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
             """, (
                 resource['id'],
                 resource['title'],
@@ -80,7 +77,9 @@ class PostgreSQLResourceStore:
                 resource.get('sort_order', 0),
                 resource.get('submitted_by', ''),
                 resource.get('created_at', now_ts),
-                now_ts
+                now_ts,
+                resource.get('version', 'v0'),
+                json.dumps(resource.get('images', [])),
             ))
             self.conn.commit()
         finally:
@@ -92,22 +91,27 @@ class PostgreSQLResourceStore:
         try:
             cursor.execute("""
                 SELECT id, title, body, secure_body, content_type, metadata, sort_order,
-                       created_at, updated_at
+                       created_at, updated_at, version, images
                 FROM resource WHERE id = %s
             """, (resource_id,))
             row = cursor.fetchone()
             if not row:
                 return None
             result = dict(row)
-            if result.get('metadata'):
-                result['metadata'] = json.loads(result['metadata']) if isinstance(result['metadata'], str) else result['metadata']
+            if result.get('metadata') and isinstance(result['metadata'], str):
+                result['metadata'] = json.loads(result['metadata'])
+            if result.get('images') and isinstance(result['images'], str):
+                result['images'] = json.loads(result['images'])
+            elif result.get('images') is None:
+                result['images'] = []
             return result
         finally:
             cursor.close()
 
     def list_resources(self, prefix: Optional[str] = None, content_type: Optional[str] = None,
+                       version: Optional[str] = None,
                        limit: int = 100, offset: int = 0) -> List[Dict]:
-        """列出资源"""
+        """列出资源。version=None 返回所有版本。"""
         cursor = self._get_cursor()
         try:
             conditions = []
@@ -119,10 +123,13 @@ class PostgreSQLResourceStore:
             if content_type:
                 conditions.append("content_type = %s")
                 params.append(content_type)
+            if version is not None:
+                conditions.append("version = %s")
+                params.append(version)
 
             where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
             sql = f"""
-                SELECT id, title, content_type, metadata, created_at, updated_at
+                SELECT id, title, content_type, metadata, images, version, created_at, updated_at
                 FROM resource
                 {where_clause}
                 ORDER BY sort_order, id
@@ -132,7 +139,17 @@ class PostgreSQLResourceStore:
 
             cursor.execute(sql, params)
             results = cursor.fetchall()
-            return [dict(r) for r in results]
+            out = []
+            for r in results:
+                d = dict(r)
+                if d.get('images') and isinstance(d['images'], str):
+                    d['images'] = json.loads(d['images'])
+                elif d.get('images') is None:
+                    d['images'] = []
+                if d.get('metadata') and isinstance(d['metadata'], str):
+                    d['metadata'] = json.loads(d['metadata'])
+                out.append(d)
+            return out
         finally:
             cursor.close()
 

+ 142 - 46
knowhub/knowhub_db/pg_store.py

@@ -14,14 +14,29 @@ from knowhub.knowhub_db.cascade import cascade_delete
 
 load_dotenv()
 
-# 关联字段的子查询(从 junction table 读取,返回 JSON 数组)
+# 关联字段的子查询(从 junction table 读取)
+# 对于带 relation_type 的 *_knowledge 边,同时暴露两种视图:
+#   - *_ids       : 扁平 ID 列表(向后兼容,不含 type)
+#   - *_links     : [{id, relation_type}](含 type)
 _REL_SUBQUERIES = """
     (SELECT COALESCE(json_agg(rk.requirement_id), '[]'::json)
      FROM requirement_knowledge rk WHERE rk.knowledge_id = knowledge.id) AS requirement_ids,
+    (SELECT COALESCE(json_agg(json_build_object(
+        'id', rk2.requirement_id, 'relation_type', rk2.relation_type
+     )), '[]'::json)
+     FROM requirement_knowledge rk2 WHERE rk2.knowledge_id = knowledge.id) AS requirement_links,
     (SELECT COALESCE(json_agg(ck.capability_id), '[]'::json)
      FROM capability_knowledge ck WHERE ck.knowledge_id = knowledge.id) AS capability_ids,
+    (SELECT COALESCE(json_agg(json_build_object(
+        'id', ck2.capability_id, 'relation_type', ck2.relation_type
+     )), '[]'::json)
+     FROM capability_knowledge ck2 WHERE ck2.knowledge_id = knowledge.id) AS capability_links,
     (SELECT COALESCE(json_agg(tk.tool_id), '[]'::json)
      FROM tool_knowledge tk WHERE tk.knowledge_id = knowledge.id) AS tool_ids,
+    (SELECT COALESCE(json_agg(json_build_object(
+        'id', tk2.tool_id, 'relation_type', tk2.relation_type
+     )), '[]'::json)
+     FROM tool_knowledge tk2 WHERE tk2.knowledge_id = knowledge.id) AS tool_links,
     (SELECT COALESCE(json_agg(kr.resource_id), '[]'::json)
      FROM knowledge_resource kr WHERE kr.knowledge_id = knowledge.id) AS resource_ids,
     (SELECT COALESCE(json_agg(json_build_object(
@@ -34,7 +49,7 @@ _REL_SUBQUERIES = """
 _BASE_FIELDS = (
     "id, message_id, task, content, types, tags, tag_keys, "
     "scopes, owner, source, eval, "
-    "created_at, updated_at, status"
+    "created_at, updated_at, status, version"
 )
 
 # 完整 SELECT(含关联子查询)
@@ -44,6 +59,26 @@ _SELECT_FIELDS = f"{_BASE_FIELDS}, {_REL_SUBQUERIES}"
 _SELECT_FIELDS_WITH_EMB = f"task_embedding, content_embedding, {_SELECT_FIELDS}"
 
 
+def _normalize_links(data: Dict, links_key: str, ids_key: str, default_type: str):
+    """
+    统一两种输入格式:
+    - {links_key: [{id, relation_type}, ...]}  → 使用指定 type
+    - {ids_key: [id1, id2, ...]}               → 使用 default_type
+    两个 key 都没有返回 None(不更新)
+    """
+    if links_key in data and data[links_key] is not None:
+        out = []
+        for item in data[links_key]:
+            if isinstance(item, dict):
+                out.append((item['id'], item.get('relation_type', default_type)))
+            else:
+                out.append((item, default_type))
+        return out
+    if ids_key in data and data[ids_key] is not None:
+        return [(i, default_type) for i in data[ids_key]]
+    return None
+
+
 class PostgreSQLStore:
     def __init__(self):
         """初始化 PostgreSQL 连接"""
@@ -54,7 +89,7 @@ class PostgreSQLStore:
             password=os.getenv('KNOWHUB_PASSWORD'),
             database=os.getenv('KNOWHUB_DB_NAME')
         )
-        self.conn.autocommit = False
+        self.conn.autocommit = True
         print(f"[PostgreSQL] 已连接到远程数据库: {os.getenv('KNOWHUB_DB')}")
 
     def _reconnect(self):
@@ -65,7 +100,7 @@ class PostgreSQLStore:
             password=os.getenv('KNOWHUB_PASSWORD'),
             database=os.getenv('KNOWHUB_DB_NAME')
         )
-        self.conn.autocommit = False
+        self.conn.autocommit = True
 
     def _ensure_connection(self):
         if self.conn.closed != 0:
@@ -84,15 +119,16 @@ class PostgreSQLStore:
         return self.conn.cursor(cursor_factory=RealDictCursor)
 
     def insert(self, knowledge: Dict):
-        """插入单条知识"""
+        """插入单条知识。若同 id 已存在会先删再插(AnalyticDB beam 不支持 ON CONFLICT UPDATE)。"""
         cursor = self._get_cursor()
         try:
+            cursor.execute("DELETE FROM knowledge WHERE id = %s", (knowledge['id'],))
             cursor.execute("""
                 INSERT INTO knowledge (
                     id, task_embedding, content_embedding, message_id, task, content, types, tags,
                     tag_keys, scopes, owner, source, eval,
-                    created_at, updated_at, status
-                ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+                    created_at, updated_at, status, version
+                ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
             """, (
                 knowledge['id'],
                 knowledge.get('task_embedding') or knowledge.get('embedding'),
@@ -110,21 +146,28 @@ class PostgreSQLStore:
                 knowledge['created_at'],
                 knowledge['updated_at'],
                 knowledge.get('status', 'approved'),
+                knowledge.get('version', 'v0'),
             ))
             # 写入关联表
             kid = knowledge['id']
-            for req_id in knowledge.get('requirement_ids', []):
+            req_links = _normalize_links(knowledge, 'requirement_links', 'requirement_ids', 'related') or []
+            for req_id, rtype in req_links:
                 cursor.execute(
-                    "INSERT INTO requirement_knowledge (requirement_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
-                    (req_id, kid))
-            for cap_id in knowledge.get('capability_ids', []):
+                    "INSERT INTO requirement_knowledge (requirement_id, knowledge_id, relation_type) "
+                    "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                    (req_id, kid, rtype))
+            cap_links = _normalize_links(knowledge, 'capability_links', 'capability_ids', 'related') or []
+            for cap_id, rtype in cap_links:
                 cursor.execute(
-                    "INSERT INTO capability_knowledge (capability_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
-                    (cap_id, kid))
-            for tool_id in knowledge.get('tool_ids', []):
+                    "INSERT INTO capability_knowledge (capability_id, knowledge_id, relation_type) "
+                    "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                    (cap_id, kid, rtype))
+            tool_links = _normalize_links(knowledge, 'tool_links', 'tool_ids', 'related') or []
+            for tool_id, rtype in tool_links:
                 cursor.execute(
-                    "INSERT INTO tool_knowledge (tool_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
-                    (tool_id, kid))
+                    "INSERT INTO tool_knowledge (tool_id, knowledge_id, relation_type) "
+                    "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                    (tool_id, kid, rtype))
             for res_id in knowledge.get('resource_ids', []):
                 cursor.execute(
                     "INSERT INTO knowledge_resource (knowledge_id, resource_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
@@ -220,10 +263,10 @@ class PostgreSQLStore:
         cursor = self._get_cursor()
         try:
             # 分离关联字段和实体字段
-            req_ids = updates.pop('requirement_ids', None)
-            cap_ids = updates.pop('capability_ids', None)
-            tool_ids = updates.pop('tool_ids', None)
-            resource_ids = updates.pop('resource_ids', None)
+            rel_keys = ('requirement_ids', 'requirement_links',
+                        'capability_ids', 'capability_links',
+                        'tool_ids', 'tool_links', 'resource_ids')
+            rel_data = {k: updates.pop(k) for k in rel_keys if k in updates}
 
             if updates:
                 set_parts = []
@@ -240,30 +283,36 @@ class PostgreSQLStore:
                 cursor.execute(sql, params)
 
             # 更新关联表(全量替换)
-            if req_ids is not None:
+            req_links = _normalize_links(rel_data, 'requirement_links', 'requirement_ids', 'related')
+            if req_links is not None:
                 cursor.execute("DELETE FROM requirement_knowledge WHERE knowledge_id = %s", (knowledge_id,))
-                for req_id in req_ids:
+                for req_id, rtype in req_links:
                     cursor.execute(
-                        "INSERT INTO requirement_knowledge (requirement_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
-                        (req_id, knowledge_id))
+                        "INSERT INTO requirement_knowledge (requirement_id, knowledge_id, relation_type) "
+                        "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                        (req_id, knowledge_id, rtype))
 
-            if cap_ids is not None:
+            cap_links = _normalize_links(rel_data, 'capability_links', 'capability_ids', 'related')
+            if cap_links is not None:
                 cursor.execute("DELETE FROM capability_knowledge WHERE knowledge_id = %s", (knowledge_id,))
-                for cap_id in cap_ids:
+                for cap_id, rtype in cap_links:
                     cursor.execute(
-                        "INSERT INTO capability_knowledge (capability_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
-                        (cap_id, knowledge_id))
+                        "INSERT INTO capability_knowledge (capability_id, knowledge_id, relation_type) "
+                        "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                        (cap_id, knowledge_id, rtype))
 
-            if tool_ids is not None:
+            tool_links = _normalize_links(rel_data, 'tool_links', 'tool_ids', 'related')
+            if tool_links is not None:
                 cursor.execute("DELETE FROM tool_knowledge WHERE knowledge_id = %s", (knowledge_id,))
-                for tool_id in tool_ids:
+                for tool_id, rtype in tool_links:
                     cursor.execute(
-                        "INSERT INTO tool_knowledge (tool_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
-                        (tool_id, knowledge_id))
+                        "INSERT INTO tool_knowledge (tool_id, knowledge_id, relation_type) "
+                        "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                        (tool_id, knowledge_id, rtype))
 
-            if resource_ids is not None:
+            if 'resource_ids' in rel_data and rel_data['resource_ids'] is not None:
                 cursor.execute("DELETE FROM knowledge_resource WHERE knowledge_id = %s", (knowledge_id,))
-                for res_id in resource_ids:
+                for res_id in rel_data['resource_ids']:
                     cursor.execute(
                         "INSERT INTO knowledge_resource (knowledge_id, resource_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
                         (knowledge_id, res_id))
@@ -303,6 +352,45 @@ class PostgreSQLStore:
         finally:
             cursor.close()
 
+    def add_requirement(self, knowledge_id: str, requirement_id: str,
+                        relation_type: str = 'related'):
+        """增量挂接 requirement-knowledge 边"""
+        cursor = self._get_cursor()
+        try:
+            cursor.execute(
+                "INSERT INTO requirement_knowledge (requirement_id, knowledge_id, relation_type) "
+                "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                (requirement_id, knowledge_id, relation_type))
+            self.conn.commit()
+        finally:
+            cursor.close()
+
+    def add_capability(self, knowledge_id: str, capability_id: str,
+                       relation_type: str = 'related'):
+        """增量挂接 capability-knowledge 边"""
+        cursor = self._get_cursor()
+        try:
+            cursor.execute(
+                "INSERT INTO capability_knowledge (capability_id, knowledge_id, relation_type) "
+                "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                (capability_id, knowledge_id, relation_type))
+            self.conn.commit()
+        finally:
+            cursor.close()
+
+    def add_tool(self, knowledge_id: str, tool_id: str,
+                 relation_type: str = 'related'):
+        """增量挂接 tool-knowledge 边"""
+        cursor = self._get_cursor()
+        try:
+            cursor.execute(
+                "INSERT INTO tool_knowledge (tool_id, knowledge_id, relation_type) "
+                "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                (tool_id, knowledge_id, relation_type))
+            self.conn.commit()
+        finally:
+            cursor.close()
+
     def count(self) -> int:
         """返回知识总数"""
         cursor = self._get_cursor()
@@ -349,7 +437,8 @@ class PostgreSQLStore:
         if 'eval' in result and isinstance(result['eval'], str):
             result['eval'] = json.loads(result['eval'])
         # 关联字段(来自 junction table 子查询,可能是 JSON 字符串或已解析的列表)
-        for field in ('requirement_ids', 'capability_ids', 'tool_ids', 'resource_ids'):
+        for field in ('requirement_ids', 'capability_ids', 'tool_ids', 'resource_ids',
+                      'requirement_links', 'capability_links', 'tool_links'):
             if field in result and isinstance(result[field], str):
                 result[field] = json.loads(result[field])
             elif field in result and result[field] is None:
@@ -387,31 +476,38 @@ class PostgreSQLStore:
                     k.get('scopes', []), k['owner'],
                     json.dumps(k.get('source', {})), json.dumps(k.get('eval', {})),
                     k['created_at'], k['updated_at'], k.get('status', 'approved'),
+                    k.get('version', 'v0'),
                 ))
 
             execute_batch(cursor, """
                 INSERT INTO knowledge (
                     id, task_embedding, content_embedding, message_id, task, content, types, tags,
                     tag_keys, scopes, owner, source, eval,
-                    created_at, updated_at, status
-                ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+                    created_at, updated_at, status, version
+                ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
             """, data)
 
             # 批量写入关联表
             for k in knowledge_list:
                 kid = k['id']
-                for req_id in k.get('requirement_ids', []):
+                req_links = _normalize_links(k, 'requirement_links', 'requirement_ids', 'related') or []
+                for req_id, rtype in req_links:
                     cursor.execute(
-                        "INSERT INTO requirement_knowledge (requirement_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
-                        (req_id, kid))
-                for cap_id in k.get('capability_ids', []):
+                        "INSERT INTO requirement_knowledge (requirement_id, knowledge_id, relation_type) "
+                        "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                        (req_id, kid, rtype))
+                cap_links = _normalize_links(k, 'capability_links', 'capability_ids', 'related') or []
+                for cap_id, rtype in cap_links:
                     cursor.execute(
-                        "INSERT INTO capability_knowledge (capability_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
-                        (cap_id, kid))
-                for tool_id in k.get('tool_ids', []):
+                        "INSERT INTO capability_knowledge (capability_id, knowledge_id, relation_type) "
+                        "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                        (cap_id, kid, rtype))
+                tool_links = _normalize_links(k, 'tool_links', 'tool_ids', 'related') or []
+                for tool_id, rtype in tool_links:
                     cursor.execute(
-                        "INSERT INTO tool_knowledge (tool_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
-                        (tool_id, kid))
+                        "INSERT INTO tool_knowledge (tool_id, knowledge_id, relation_type) "
+                        "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                        (tool_id, kid, rtype))
                 for res_id in k.get('resource_ids', []):
                     cursor.execute(
                         "INSERT INTO knowledge_resource (knowledge_id, resource_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",

+ 348 - 0
knowhub/knowhub_db/pg_strategy_store.py

@@ -0,0 +1,348 @@
+"""
+PostgreSQL strategy 存储封装
+
+用于存储和检索「制作策略」。strategy 是一组原子 capability 的组合,
+附带自身的 body(可执行描述)与 source 知识。
+
+关联:
+- strategy_capability(默认 relation_type='compose')
+- strategy_knowledge(默认 relation_type='source',也可为 'case' 等)
+- strategy_resource(直接素材,无 type)
+"""
+
+import os
+import psycopg2
+from psycopg2.extras import RealDictCursor
+from typing import List, Dict, Optional
+from dotenv import load_dotenv
+from knowhub.knowhub_db.cascade import cascade_delete
+
+load_dotenv()
+
+# 读取路径:同时暴露扁平 ids 和带 type 的 links
+_REL_SUBQUERIES = """
+    (SELECT COALESCE(json_agg(rs.requirement_id), '[]'::json)
+     FROM requirement_strategy rs WHERE rs.strategy_id = strategy.id) AS requirement_ids,
+    (SELECT COALESCE(json_agg(sc.capability_id), '[]'::json)
+     FROM strategy_capability sc WHERE sc.strategy_id = strategy.id) AS capability_ids,
+    (SELECT COALESCE(json_agg(json_build_object(
+        'id', sc2.capability_id, 'relation_type', sc2.relation_type
+     )), '[]'::json)
+     FROM strategy_capability sc2 WHERE sc2.strategy_id = strategy.id) AS capability_links,
+    (SELECT COALESCE(json_agg(sk.knowledge_id), '[]'::json)
+     FROM strategy_knowledge sk WHERE sk.strategy_id = strategy.id) AS knowledge_ids,
+    (SELECT COALESCE(json_agg(json_build_object(
+        'id', sk2.knowledge_id, 'relation_type', sk2.relation_type
+     )), '[]'::json)
+     FROM strategy_knowledge sk2 WHERE sk2.strategy_id = strategy.id) AS knowledge_links,
+    (SELECT COALESCE(json_agg(sr.resource_id), '[]'::json)
+     FROM strategy_resource sr WHERE sr.strategy_id = strategy.id) AS resource_ids
+"""
+
+_BASE_FIELDS = "id, name, description, body, status, created_at, updated_at, version"
+_SELECT_FIELDS = f"{_BASE_FIELDS}, {_REL_SUBQUERIES}"
+
+
+class PostgreSQLStrategyStore:
+    def __init__(self):
+        self.conn = psycopg2.connect(
+            host=os.getenv('KNOWHUB_DB'),
+            port=int(os.getenv('KNOWHUB_PORT', 5432)),
+            user=os.getenv('KNOWHUB_USER'),
+            password=os.getenv('KNOWHUB_PASSWORD'),
+            database=os.getenv('KNOWHUB_DB_NAME')
+        )
+        self.conn.autocommit = True
+        print(f"[PostgreSQL Strategy] 已连接到远程数据库: {os.getenv('KNOWHUB_DB')}")
+
+    def _reconnect(self):
+        self.conn = psycopg2.connect(
+            host=os.getenv('KNOWHUB_DB'),
+            port=int(os.getenv('KNOWHUB_PORT', 5432)),
+            user=os.getenv('KNOWHUB_USER'),
+            password=os.getenv('KNOWHUB_PASSWORD'),
+            database=os.getenv('KNOWHUB_DB_NAME')
+        )
+        self.conn.autocommit = True
+
+    def _ensure_connection(self):
+        if self.conn.closed != 0:
+            self._reconnect()
+        else:
+            try:
+                c = self.conn.cursor()
+                c.execute("SELECT 1")
+                c.close()
+            except (psycopg2.OperationalError, psycopg2.InterfaceError):
+                self._reconnect()
+
+    def _get_cursor(self):
+        self._ensure_connection()
+        return self.conn.cursor(cursor_factory=RealDictCursor)
+
+    # ─── 关联写入 ────────────────────────────────────────────────
+
+    @staticmethod
+    def _normalize_links(data: Dict, links_key: str, ids_key: str, default_type: str):
+        """
+        统一两种输入:
+        - {links_key: [{id, relation_type}, ...]}   → 使用给定 type
+        - {ids_key: [id1, id2, ...]}                → 使用 default_type
+        返回 [(id, relation_type), ...];若两个 key 都不存在返回 None(表示不更新)
+        """
+        if links_key in data and data[links_key] is not None:
+            out = []
+            for item in data[links_key]:
+                if isinstance(item, dict):
+                    out.append((item['id'], item.get('relation_type', default_type)))
+                else:  # 容错:允许混用
+                    out.append((item, default_type))
+            return out
+        if ids_key in data and data[ids_key] is not None:
+            return [(i, default_type) for i in data[ids_key]]
+        return None
+
+    def _save_relations(self, cursor, strategy_id: str, data: Dict):
+        """全量替换 strategy 的 junction"""
+        cap_links = self._normalize_links(data, 'capability_links', 'capability_ids', 'compose')
+        if cap_links is not None:
+            cursor.execute("DELETE FROM strategy_capability WHERE strategy_id = %s", (strategy_id,))
+            for cap_id, rtype in cap_links:
+                cursor.execute(
+                    "INSERT INTO strategy_capability (strategy_id, capability_id, relation_type) "
+                    "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                    (strategy_id, cap_id, rtype))
+
+        k_links = self._normalize_links(data, 'knowledge_links', 'knowledge_ids', 'source')
+        if k_links is not None:
+            cursor.execute("DELETE FROM strategy_knowledge WHERE strategy_id = %s", (strategy_id,))
+            for kid, rtype in k_links:
+                cursor.execute(
+                    "INSERT INTO strategy_knowledge (strategy_id, knowledge_id, relation_type) "
+                    "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                    (strategy_id, kid, rtype))
+
+        if 'resource_ids' in data and data['resource_ids'] is not None:
+            cursor.execute("DELETE FROM strategy_resource WHERE strategy_id = %s", (strategy_id,))
+            for rid in data['resource_ids']:
+                cursor.execute(
+                    "INSERT INTO strategy_resource (strategy_id, resource_id) "
+                    "VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                    (strategy_id, rid))
+
+        if 'requirement_ids' in data and data['requirement_ids'] is not None:
+            cursor.execute("DELETE FROM requirement_strategy WHERE strategy_id = %s", (strategy_id,))
+            for req_id in data['requirement_ids']:
+                cursor.execute(
+                    "INSERT INTO requirement_strategy (requirement_id, strategy_id) "
+                    "VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                    (req_id, strategy_id))
+
+    # ─── 核心 CRUD ───────────────────────────────────────────────
+
+    def insert_or_update(self, strategy: Dict):
+        """插入或更新 strategy(含关联)。AnalyticDB beam 表不支持 ON CONFLICT UPDATE,改用 DELETE+INSERT。"""
+        cursor = self._get_cursor()
+        try:
+            cursor.execute("DELETE FROM strategy WHERE id = %s", (strategy['id'],))
+            cursor.execute("""
+                INSERT INTO strategy (
+                    id, name, description, body, status, created_at, updated_at, embedding, version
+                ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
+            """, (
+                strategy['id'],
+                strategy.get('name', ''),
+                strategy.get('description', ''),
+                strategy.get('body', ''),
+                strategy.get('status', 'draft'),
+                strategy.get('created_at'),
+                strategy.get('updated_at'),
+                strategy.get('embedding'),
+                strategy.get('version', 'v0'),
+            ))
+            self._save_relations(cursor, strategy['id'], strategy)
+            self.conn.commit()
+        finally:
+            cursor.close()
+
+    def get_by_id(self, strategy_id: str) -> Optional[Dict]:
+        cursor = self._get_cursor()
+        try:
+            cursor.execute(f"SELECT {_SELECT_FIELDS} FROM strategy WHERE id = %s", (strategy_id,))
+            result = cursor.fetchone()
+            return self._format_result(result) if result else None
+        finally:
+            cursor.close()
+
+    def search(self, query_embedding: List[float], limit: int = 10,
+               status: Optional[str] = None) -> List[Dict]:
+        """向量检索 strategy"""
+        cursor = self._get_cursor()
+        try:
+            if status:
+                sql = f"""
+                    SELECT {_SELECT_FIELDS},
+                           1 - (embedding <=> %s::real[]) as score
+                    FROM strategy
+                    WHERE embedding IS NOT NULL AND status = %s
+                    ORDER BY embedding <=> %s::real[]
+                    LIMIT %s
+                """
+                params = (query_embedding, status, query_embedding, limit)
+            else:
+                sql = f"""
+                    SELECT {_SELECT_FIELDS},
+                           1 - (embedding <=> %s::real[]) as score
+                    FROM strategy
+                    WHERE embedding IS NOT NULL
+                    ORDER BY embedding <=> %s::real[]
+                    LIMIT %s
+                """
+                params = (query_embedding, query_embedding, limit)
+            cursor.execute(sql, params)
+            results = cursor.fetchall()
+            return [self._format_result(r) for r in results]
+        finally:
+            cursor.close()
+
+    def list_all(self, limit: int = 100, offset: int = 0,
+                 status: Optional[str] = None) -> List[Dict]:
+        cursor = self._get_cursor()
+        try:
+            if status:
+                cursor.execute(f"""
+                    SELECT {_SELECT_FIELDS} FROM strategy
+                    WHERE status = %s
+                    ORDER BY id
+                    LIMIT %s OFFSET %s
+                """, (status, limit, offset))
+            else:
+                cursor.execute(f"""
+                    SELECT {_SELECT_FIELDS} FROM strategy
+                    ORDER BY id
+                    LIMIT %s OFFSET %s
+                """, (limit, offset))
+            results = cursor.fetchall()
+            return [self._format_result(r) for r in results]
+        finally:
+            cursor.close()
+
+    def update(self, strategy_id: str, updates: Dict):
+        """更新 strategy(关联字段可选)"""
+        cursor = self._get_cursor()
+        try:
+            # 分离关联字段
+            rel_keys = ('requirement_ids',
+                        'capability_ids', 'capability_links',
+                        'knowledge_ids', 'knowledge_links', 'resource_ids')
+            rel_fields = {k: updates.pop(k) for k in rel_keys if k in updates}
+
+            if updates:
+                set_parts = []
+                params = []
+                for key, value in updates.items():
+                    set_parts.append(f"{key} = %s")
+                    params.append(value)
+                params.append(strategy_id)
+                cursor.execute(
+                    f"UPDATE strategy SET {', '.join(set_parts)} WHERE id = %s",
+                    params)
+
+            if rel_fields:
+                self._save_relations(cursor, strategy_id, rel_fields)
+
+            self.conn.commit()
+        finally:
+            cursor.close()
+
+    def delete(self, strategy_id: str):
+        """删除 strategy 及其所有 junction 行"""
+        cursor = self._get_cursor()
+        try:
+            cascade_delete(cursor, 'strategy', strategy_id)
+            self.conn.commit()
+        finally:
+            cursor.close()
+
+    def count(self, status: Optional[str] = None) -> int:
+        cursor = self._get_cursor()
+        try:
+            if status:
+                cursor.execute("SELECT COUNT(*) as count FROM strategy WHERE status = %s", (status,))
+            else:
+                cursor.execute("SELECT COUNT(*) as count FROM strategy")
+            return cursor.fetchone()['count']
+        finally:
+            cursor.close()
+
+    # ─── 增量关联 API(不删已有)─────────────────────────────────
+
+    def add_capability(self, strategy_id: str, capability_id: str,
+                       relation_type: str = 'compose'):
+        cursor = self._get_cursor()
+        try:
+            cursor.execute(
+                "INSERT INTO strategy_capability (strategy_id, capability_id, relation_type) "
+                "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                (strategy_id, capability_id, relation_type))
+            self.conn.commit()
+        finally:
+            cursor.close()
+
+    def add_knowledge(self, strategy_id: str, knowledge_id: str,
+                      relation_type: str = 'source'):
+        cursor = self._get_cursor()
+        try:
+            cursor.execute(
+                "INSERT INTO strategy_knowledge (strategy_id, knowledge_id, relation_type) "
+                "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                (strategy_id, knowledge_id, relation_type))
+            self.conn.commit()
+        finally:
+            cursor.close()
+
+    def add_resource(self, strategy_id: str, resource_id: str):
+        cursor = self._get_cursor()
+        try:
+            cursor.execute(
+                "INSERT INTO strategy_resource (strategy_id, resource_id) "
+                "VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                (strategy_id, resource_id))
+            self.conn.commit()
+        finally:
+            cursor.close()
+
+    def add_requirement(self, strategy_id: str, requirement_id: str):
+        """增量挂接 requirement-strategy 边(这个 strategy 满足该 requirement)"""
+        cursor = self._get_cursor()
+        try:
+            cursor.execute(
+                "INSERT INTO requirement_strategy (requirement_id, strategy_id) "
+                "VALUES (%s, %s) ON CONFLICT DO NOTHING",
+                (requirement_id, strategy_id))
+            self.conn.commit()
+        finally:
+            cursor.close()
+
+    # ─── 辅助 ────────────────────────────────────────────────────
+
+    def _format_result(self, row: Dict) -> Optional[Dict]:
+        if not row:
+            return None
+        import json
+        result = dict(row)
+        for field in ('requirement_ids', 'capability_ids', 'knowledge_ids', 'resource_ids'):
+            if field in result and isinstance(result[field], str):
+                result[field] = json.loads(result[field])
+            elif field in result and result[field] is None:
+                result[field] = []
+        for field in ('capability_links', 'knowledge_links'):
+            if field in result and isinstance(result[field], str):
+                result[field] = json.loads(result[field])
+            elif field in result and result[field] is None:
+                result[field] = []
+        return result
+
+    def close(self):
+        if self.conn:
+            self.conn.close()

+ 42 - 15
knowhub/knowhub_db/pg_tool_store.py

@@ -15,12 +15,16 @@ from knowhub.knowhub_db.cascade import cascade_delete
 
 load_dotenv()
 
-# 关联字段子查询
+# 关联字段子查询。knowledge 边暴露两种视图:knowledge_ids(扁平)+ knowledge_links(含 type)
 _REL_SUBQUERIES = """
     (SELECT COALESCE(json_agg(ct.capability_id), '[]'::json)
      FROM capability_tool ct WHERE ct.tool_id = tool.id) AS capability_ids,
     (SELECT COALESCE(json_agg(tk.knowledge_id), '[]'::json)
      FROM tool_knowledge tk WHERE tk.tool_id = tool.id) AS knowledge_ids,
+    (SELECT COALESCE(json_agg(json_build_object(
+        'id', tk2.knowledge_id, 'relation_type', tk2.relation_type
+     )), '[]'::json)
+     FROM tool_knowledge tk2 WHERE tk2.tool_id = tool.id) AS knowledge_links,
     (SELECT COALESCE(json_agg(tp.provider_id), '[]'::json)
      FROM tool_provider tp WHERE tp.tool_id = tool.id) AS provider_ids
 """
@@ -30,6 +34,21 @@ _BASE_FIELDS = "id, name, version, introduction, tutorial, input, output, update
 _SELECT_FIELDS = f"{_BASE_FIELDS}, {_REL_SUBQUERIES}"
 
 
+def _normalize_links(data: Dict, links_key: str, ids_key: str, default_type: str):
+    """两种输入格式统一:{links_key: [{id, relation_type}]} 或 {ids_key: [id]}"""
+    if links_key in data and data[links_key] is not None:
+        out = []
+        for item in data[links_key]:
+            if isinstance(item, dict):
+                out.append((item['id'], item.get('relation_type', default_type)))
+            else:
+                out.append((item, default_type))
+        return out
+    if ids_key in data and data[ids_key] is not None:
+        return [(i, default_type) for i in data[ids_key]]
+    return None
+
+
 class PostgreSQLToolStore:
     def __init__(self):
         """初始化 PostgreSQL 连接"""
@@ -40,7 +59,7 @@ class PostgreSQLToolStore:
             password=os.getenv('KNOWHUB_PASSWORD'),
             database=os.getenv('KNOWHUB_DB_NAME')
         )
-        self.conn.autocommit = False
+        self.conn.autocommit = True
         print(f"[PostgreSQL Tool] 已连接到远程数据库: {os.getenv('KNOWHUB_DB')}")
 
     def _reconnect(self):
@@ -51,7 +70,7 @@ class PostgreSQLToolStore:
             password=os.getenv('KNOWHUB_PASSWORD'),
             database=os.getenv('KNOWHUB_DB_NAME')
         )
-        self.conn.autocommit = False
+        self.conn.autocommit = True
 
     def _ensure_connection(self):
         if self.conn.closed != 0:
@@ -107,12 +126,14 @@ class PostgreSQLToolStore:
                     cursor.execute(
                         "INSERT INTO capability_tool (capability_id, tool_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
                         (cap_id, tool_id))
-            if 'knowledge_ids' in tool:
+            k_links = _normalize_links(tool, 'knowledge_links', 'knowledge_ids', 'related')
+            if k_links is not None:
                 cursor.execute("DELETE FROM tool_knowledge WHERE tool_id = %s", (tool_id,))
-                for kid in tool['knowledge_ids']:
+                for kid, rtype in k_links:
                     cursor.execute(
-                        "INSERT INTO tool_knowledge (tool_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
-                        (tool_id, kid))
+                        "INSERT INTO tool_knowledge (tool_id, knowledge_id, relation_type) "
+                        "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                        (tool_id, kid, rtype))
             if 'provider_ids' in tool:
                 cursor.execute("DELETE FROM tool_provider WHERE tool_id = %s", (tool_id,))
                 for pid in tool['provider_ids']:
@@ -197,6 +218,7 @@ class PostgreSQLToolStore:
             # 分离关联字段
             cap_ids = updates.pop('capability_ids', None)
             knowledge_ids = updates.pop('knowledge_ids', None)
+            knowledge_links = updates.pop('knowledge_links', None)
             provider_ids = updates.pop('provider_ids', None)
 
             if updates:
@@ -223,12 +245,16 @@ class PostgreSQLToolStore:
                         "INSERT INTO capability_tool (capability_id, tool_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
                         (cap_id, tool_id))
 
-            if knowledge_ids is not None:
+            k_links = _normalize_links(
+                {'knowledge_links': knowledge_links, 'knowledge_ids': knowledge_ids},
+                'knowledge_links', 'knowledge_ids', 'related')
+            if k_links is not None:
                 cursor.execute("DELETE FROM tool_knowledge WHERE tool_id = %s", (tool_id,))
-                for kid in knowledge_ids:
+                for kid, rtype in k_links:
                     cursor.execute(
-                        "INSERT INTO tool_knowledge (tool_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
-                        (tool_id, kid))
+                        "INSERT INTO tool_knowledge (tool_id, knowledge_id, relation_type) "
+                        "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                        (tool_id, kid, rtype))
 
             if provider_ids is not None:
                 cursor.execute("DELETE FROM tool_provider WHERE tool_id = %s", (tool_id,))
@@ -241,13 +267,14 @@ class PostgreSQLToolStore:
         finally:
             cursor.close()
 
-    def add_knowledge(self, tool_id: str, knowledge_id: str):
+    def add_knowledge(self, tool_id: str, knowledge_id: str, relation_type: str = 'related'):
         """向工具添加一条知识关联(不删除已有关联)"""
         cursor = self._get_cursor()
         try:
             cursor.execute(
-                "INSERT INTO tool_knowledge (tool_id, knowledge_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
-                (tool_id, knowledge_id))
+                "INSERT INTO tool_knowledge (tool_id, knowledge_id, relation_type) "
+                "VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
+                (tool_id, knowledge_id, relation_type))
             self.conn.commit()
         finally:
             cursor.close()
@@ -285,7 +312,7 @@ class PostgreSQLToolStore:
                 except json.JSONDecodeError:
                     result[field] = None
         # 关联字段(来自 junction table 子查询)
-        for field in ('capability_ids', 'knowledge_ids', 'provider_ids'):
+        for field in ('capability_ids', 'knowledge_ids', 'provider_ids', 'knowledge_links'):
             if field in result and isinstance(result[field], str):
                 result[field] = json.loads(result[field])
             elif field in result and result[field] is None:

+ 275 - 0
knowhub/scripts/ingest_research_output.py

@@ -0,0 +1,275 @@
+#!/usr/bin/env python3
+"""
+入库研究调研结果(case.json + strategy.json)到 knowhub。
+
+输入目录结构:
+    <root>/
+    ├── 00/
+    │   ├── case.json      ({requirement, cases:[{...}]})
+    │   └── strategy.json  ({selected_strategy:{...}, vs_alternatives, uncovered_requirements})
+    ├── 01/
+    │   ...
+
+入库映射:
+    case.json.requirement      → 按完整描述文本精确匹配现有 requirement(跨版本)
+                                → 找不到则报错(不创建新 requirement——避免语义重复)
+    case.json.cases[]          → resource 实体(多条,URL hash 去重)
+    strategy.json              → strategy 实体(1 条 per folder)
+    strategy.workflow_outline[].capabilities[].is_new=true
+                                → capability 实体(新建,version 隔离)
+                                → capability_id 已存在则直接引用
+
+关联:
+    requirement_resource  (requirement → all cases in its folder)
+    requirement_strategy  (requirement → strategy)
+    strategy_resource     (strategy → all cases in its folder — provenance)
+    strategy_capability   (strategy → all mentioned capabilities, compose)
+
+去重策略:
+    resource id = "resource/research/{platform}/{hash12(source_url)}"
+    ON CONFLICT (id) DO UPDATE → 多次调研遇到同一 URL 自动覆盖
+
+使用:
+    python knowhub/scripts/ingest_research_output.py <output_root> <version>
+    e.g. python knowhub/scripts/ingest_research_output.py /Users/sunlit/Downloads/output tao_dev_1
+
+幂等:反复执行不破坏数据。可选 --purge-first 会先清掉该版本的已有数据。
+"""
+
+import argparse
+import hashlib
+import json
+import sys
+import time
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+
+from knowhub.knowhub_db.pg_store import PostgreSQLStore
+from knowhub.knowhub_db.pg_resource_store import PostgreSQLResourceStore
+from knowhub.knowhub_db.pg_requirement_store import PostgreSQLRequirementStore
+from knowhub.knowhub_db.pg_capability_store import PostgreSQLCapabilityStore
+from knowhub.knowhub_db.pg_strategy_store import PostgreSQLStrategyStore
+from knowhub.knowhub_db.cascade import purge_version
+
+
+def _hash12(s: str) -> str:
+    return hashlib.sha256(s.encode("utf-8")).hexdigest()[:12]
+
+
+def _resource_id(platform: str, source_url: str) -> str:
+    p = (platform or "unknown").lower().strip()
+    return f"resource/research/{p}/{_hash12(source_url)}"
+
+
+def _format_case_body(case: dict) -> str:
+    parts = []
+    if case.get("user_feedback"):
+        parts.append(f"【用户反馈】{case['user_feedback']}")
+    if case.get("input_details"):
+        parts.append(f"【输入详情】{case['input_details']}")
+    if case.get("output_details"):
+        parts.append(f"【输出详情】{case['output_details']}")
+    workflow = case.get("workflow_process", [])
+    if workflow:
+        parts.append("【工作流】\n" + "\n".join(f"- {s}" for s in workflow))
+    return "\n\n".join(parts)
+
+
+def _format_strategy_body(selected: dict) -> str:
+    """strategy.body 存完整 workflow_outline JSON(前端可解析展示)"""
+    return json.dumps(selected, ensure_ascii=False, indent=2)
+
+
+def _find_existing_requirement(cursor, description: str):
+    """按完整描述文本精确匹配现有 requirement(任何版本)。返回 id 或 None。"""
+    cursor.execute("SELECT id FROM requirement WHERE description = %s LIMIT 1", (description,))
+    row = cursor.fetchone()
+    if row:
+        # Dict-like (RealDictRow) or tuple
+        return row['id'] if isinstance(row, dict) or hasattr(row, 'keys') else row[0]
+    return None
+
+
+def ingest_folder(folder: Path, version: str, stores: dict, stats: dict):
+    """入库单个 output/{NN}/ 目录。所有 ID 都在 version namespace 内(除 requirement 复用已有的)。"""
+    folder_key = folder.name  # "00", "01", ...
+    case_path = folder / "case.json"
+    strategy_path = folder / "strategy.json"
+
+    case_doc = json.loads(case_path.read_text(encoding="utf-8"))
+    strategy_doc = json.loads(strategy_path.read_text(encoding="utf-8"))
+
+    requirement_text = case_doc.get("requirement", "")
+    searched_at = case_doc.get("searched_at", "")
+    cases = case_doc.get("cases", [])
+
+    # ─── 1. requirement:按完整描述精确匹配现有 REQ(不创建新 REQ)─────
+    cursor = stores["req"]._get_cursor()
+    try:
+        req_id = _find_existing_requirement(cursor, requirement_text)
+    finally:
+        cursor.close()
+    if not req_id:
+        raise RuntimeError(
+            f"[{folder_key}] 找不到匹配的 requirement。case.json.requirement 应与现有 "
+            f"requirement.description 精确相等。首 80 字:{requirement_text[:80]!r}"
+        )
+    stats["requirement_matched"] += 1
+    print(f"  ✓ requirement: {req_id} (复用已有)", flush=True)
+
+    # ─── 2. resource 实体(每个 case 一条,URL hash 去重)─────────────
+    resource_ids = []
+    for case in cases:
+        src_url = case.get("source_url", "")
+        if not src_url:
+            continue
+        platform = case.get("platform", "unknown")
+        rid = _resource_id(platform, src_url)
+        metrics = case.get("metrics") or {}
+        likes = metrics.get("likes") or 0
+        stores["res"].insert_or_update({
+            "id": rid,
+            "title": case.get("title", ""),
+            "body": _format_case_body(case),
+            "content_type": "research_case",
+            "images": case.get("images", []),
+            "metadata": {
+                "platform": platform,
+                "source_url": src_url,
+                "metrics": metrics,
+                "user_feedback": case.get("user_feedback") or "",
+                "input_details": case.get("input_details") or "",
+                "output_details": case.get("output_details") or "",
+                "workflow_process": case.get("workflow_process") or [],
+                "last_seen": searched_at,
+                "local_case_id": case.get("id") or "",  # 保留原 case_001 便于 strategy 里的文字交叉引用追溯
+            },
+            "sort_order": -likes,
+            "version": version,
+        })
+        resource_ids.append(rid)
+        stats["resource"] += 1
+    print(f"  ✓ resource: {len(resource_ids)} 条", flush=True)
+
+    # ─── 3. strategy 实体 ────────────────────────────────────────────
+    selected = strategy_doc.get("selected_strategy", {})
+    strategy_id = f"strategy-{version}-{folder_key}"
+    stores["strat"].insert_or_update({
+        "id": strategy_id,
+        "name": selected.get("name", ""),
+        "description": selected.get("reasoning", "")[:2000],  # reasoning 太长时截断
+        "body": _format_strategy_body(strategy_doc),  # 完整 JSON 原样入库
+        "status": "draft",
+        "created_at": int(time.time()),
+        "updated_at": int(time.time()),
+        "version": version,
+    })
+    stats["strategy"] += 1
+    print(f"  ✓ strategy: {strategy_id}", flush=True)
+
+    # ─── 4. 处理 strategy 提到的 capability ──────────────────────────
+    # 收集所有 (name, is_new, existing_id) 元组
+    workflow_outline = selected.get("workflow_outline", [])
+    capability_ids_linked = []
+    new_cap_counter = 0
+    for phase_idx, phase in enumerate(workflow_outline):
+        for cap_ref in phase.get("capabilities", []):
+            if cap_ref.get("is_new"):
+                new_cap_counter += 1
+                new_cap_id = f"CAP-{version}-{folder_key}-{new_cap_counter:02d}"
+                stores["cap"].insert_or_update({
+                    "id": new_cap_id,
+                    "name": cap_ref.get("capability_name", ""),
+                    "description": (
+                        f"[测试生成] 来自调研策略 {strategy_id} 阶段 {phase_idx+1}。"
+                        f" 建议工具:{', '.join(cap_ref.get('suggested_tools', [])[:3])}。"
+                    ),
+                    "criterion": "",
+                    "version": version,
+                })
+                capability_ids_linked.append(new_cap_id)
+                stats["capability_new"] += 1
+            elif cap_ref.get("capability_id"):
+                # 引用已有 capability(在 v0 或其他版本)
+                capability_ids_linked.append(cap_ref["capability_id"])
+    print(f"  ✓ capability: new={new_cap_counter}, 总引用={len(capability_ids_linked)}", flush=True)
+
+    # ─── 5. 关联关系 ──────────────────────────────────────────────────
+    # requirement → resources (provenance)
+    for rid in resource_ids:
+        stores["req"].add_resource(req_id, rid)
+    # requirement → strategy (satisfies)
+    stores["req"].add_strategy(req_id, strategy_id)
+    # strategy → resources (build-from provenance)
+    for rid in resource_ids:
+        stores["strat"].add_resource(strategy_id, rid)
+    # strategy → capabilities (compose)
+    for cap_id in capability_ids_linked:
+        stores["strat"].add_capability(strategy_id, cap_id, relation_type="compose")
+    print(f"  ✓ junctions: req_res={len(resource_ids)}, req_strat=1, "
+          f"strat_res={len(resource_ids)}, strat_cap={len(capability_ids_linked)}",
+          flush=True)
+
+
+def main():
+    p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+    p.add_argument("root", help="包含 00/ 01/ 等子目录的 output 根")
+    p.add_argument("version", help="多租户版本标签,如 tao_dev_1")
+    p.add_argument("--purge-first", action="store_true",
+                   help="先清除该 version 的所有已有数据再入库(迭代测试用)")
+    args = p.parse_args()
+
+    root = Path(args.root).expanduser().resolve()
+    if not root.is_dir():
+        print(f"❌ root 不存在或非目录: {root}", file=sys.stderr)
+        sys.exit(1)
+
+    folders = sorted([d for d in root.iterdir() if d.is_dir() and (d / "case.json").exists()])
+    if not folders:
+        print(f"❌ 在 {root} 下未找到任何含 case.json 的子目录", file=sys.stderr)
+        sys.exit(1)
+
+    print(f"📂 检测到 {len(folders)} 个调研目录:{[f.name for f in folders]}")
+    print(f"🏷  version: {args.version}")
+
+    # 初始化 store(autocommit 模式,不持锁)
+    stores = {
+        "k": PostgreSQLStore(),
+        "res": PostgreSQLResourceStore(),
+        "req": PostgreSQLRequirementStore(),
+        "cap": PostgreSQLCapabilityStore(),
+        "strat": PostgreSQLStrategyStore(),
+    }
+
+    try:
+        # Optional: 先 purge 该版本的已有数据
+        if args.purge_first:
+            print(f"\n🧹 purge version={args.version!r}...")
+            conn = stores["res"].conn
+            cur = conn.cursor()
+            try:
+                purge_stats = purge_version(cur, args.version)
+                print(f"  deleted: {purge_stats}")
+            finally:
+                cur.close()
+
+        stats = {"requirement_matched": 0, "resource": 0, "strategy": 0, "capability_new": 0}
+        for folder in folders:
+            print(f"\n📁 {folder.name}/ ...")
+            ingest_folder(folder, args.version, stores, stats)
+
+        print("\n" + "=" * 60)
+        print("入库完成")
+        print("=" * 60)
+        print(f"  requirement (matched): {stats['requirement_matched']}  (不创建新的,复用已有 REQ)")
+        print(f"  resource:              {stats['resource']}")
+        print(f"  strategy:              {stats['strategy']}")
+        print(f"  new capability:        {stats['capability_new']}")
+    finally:
+        for s in stores.values():
+            s.close()
+
+
+if __name__ == "__main__":
+    main()

+ 253 - 2
knowhub/server.py

@@ -51,6 +51,7 @@ from knowhub.knowhub_db.pg_resource_store import PostgreSQLResourceStore
 from knowhub.knowhub_db.pg_tool_store import PostgreSQLToolStore
 from knowhub.knowhub_db.pg_capability_store import PostgreSQLCapabilityStore
 from knowhub.knowhub_db.pg_requirement_store import PostgreSQLRequirementStore
+from knowhub.knowhub_db.pg_strategy_store import PostgreSQLStrategyStore
 from knowhub.embeddings import get_embedding, get_embeddings_batch
 
 BRAND_NAME    = os.getenv("BRAND_NAME", "KnowHub")
@@ -74,6 +75,7 @@ pg_resource_store: Optional[PostgreSQLResourceStore] = None
 pg_tool_store: Optional[PostgreSQLToolStore] = None
 pg_capability_store: Optional[PostgreSQLCapabilityStore] = None
 pg_requirement_store: Optional[PostgreSQLRequirementStore] = None
+pg_strategy_store: Optional[PostgreSQLStrategyStore] = None
 
 # --- 加密/解密 ---
 
@@ -368,6 +370,33 @@ class RequirementPatchIn(BaseModel):
     match_result: Optional[str] = None
 
 
+# --- Strategy Models ---
+
+class StrategyIn(BaseModel):
+    id: str
+    name: str = ""
+    description: str = ""
+    body: str = ""
+    status: str = "draft"
+    version: str = "v0"
+    capability_ids: list[str] = []
+    knowledge_ids: list[str] = []
+    resource_ids: list[str] = []
+    requirement_ids: list[str] = []
+
+
+class StrategyPatchIn(BaseModel):
+    name: Optional[str] = None
+    description: Optional[str] = None
+    body: Optional[str] = None
+    status: Optional[str] = None
+    version: Optional[str] = None
+    capability_ids: Optional[list[str]] = None
+    knowledge_ids: Optional[list[str]] = None
+    resource_ids: Optional[list[str]] = None
+    requirement_ids: Optional[list[str]] = None
+
+
 class ResourceNode(BaseModel):
     id: str
     title: str
@@ -380,6 +409,7 @@ class ResourceOut(BaseModel):
     secure_body: str = ""
     content_type: str = "text"
     metadata: dict = {}
+    images: list[str] = []
     toc: Optional[ResourceNode] = None
     children: list[ResourceNode]
     prev: Optional[ResourceNode] = None
@@ -746,14 +776,15 @@ async def _periodic_processor():
 
 @asynccontextmanager
 async def lifespan(app: FastAPI):
-    global pg_store, pg_resource_store, pg_tool_store, pg_capability_store, pg_requirement_store, knowledge_processor
+    global pg_store, pg_resource_store, pg_tool_store, pg_capability_store, pg_requirement_store, pg_strategy_store, knowledge_processor
 
-    # 初始化 PostgreSQL(knowledge + resources + tools + capabilities + requirements)
+    # 初始化 PostgreSQL(knowledge + resources + tools + capabilities + requirements + strategy
     pg_store = PostgreSQLStore()
     pg_resource_store = PostgreSQLResourceStore()
     pg_tool_store = PostgreSQLToolStore()
     pg_capability_store = PostgreSQLCapabilityStore()
     pg_requirement_store = PostgreSQLRequirementStore()
+    pg_strategy_store = PostgreSQLStrategyStore()
 
     # 初始化去重处理器 + 启动定时兜底任务
     knowledge_processor = KnowledgeProcessor()
@@ -772,6 +803,7 @@ async def lifespan(app: FastAPI):
     pg_tool_store.close()
     pg_capability_store.close()
     pg_requirement_store.close()
+    pg_strategy_store.close()
 
 
 app = FastAPI(title=BRAND_NAME, lifespan=lifespan)
@@ -782,8 +814,79 @@ if STATIC_DIR.exists():
     app.mount("/assets", StaticFiles(directory=str(STATIC_DIR / "assets")), name="assets")
 
 
+# --- 缓存自动失效中间件 ---
+# 任何对核心实体的写操作(POST/PATCH/DELETE)自动清除对应缓存
+_DASHBOARD_INVALIDATE_PREFIXES = ("/api/requirement", "/api/capability", "/api/tool", "/api/strategy", "/api/knowledge")
+
+@app.middleware("http")
+async def auto_invalidate_caches(request: Request, call_next):
+    response = await call_next(request)
+    if request.method in ("POST", "PATCH", "PUT", "DELETE") and response.status_code < 400:
+        path = request.url.path
+        if any(path.startswith(p) for p in _DASHBOARD_INVALIDATE_PREFIXES):
+            _invalidate_dashboard_cache()
+        if path.startswith("/api/resource") and not path.endswith("/batch"):
+            _invalidate_resource_cache()
+    return response
+
+
 # --- Knowledge API ---
 
+# --- Resource Batch API ---
+
+# --- Resource 缓存(与 Dashboard 同 TTL,写入时失效) ---
+_resource_cache: Dict[str, dict] = {}
+_resource_cache_ts: float = 0
+
+
+def _invalidate_resource_cache():
+    global _resource_cache, _resource_cache_ts
+    _resource_cache.clear()
+    _resource_cache_ts = 0
+
+
+def _get_cached_resource(rid: str) -> Optional[dict]:
+    """从缓存取 resource,miss 时查 DB 并写入缓存"""
+    global _resource_cache_ts
+    now = time.time()
+    if now - _resource_cache_ts > _DASHBOARD_CACHE_TTL:
+        _resource_cache.clear()
+        _resource_cache_ts = now
+    if rid in _resource_cache:
+        return _resource_cache[rid]
+    row = pg_resource_store.get_by_id(rid)
+    if not row:
+        return None
+    entry = {
+        "id": row["id"],
+        "title": row["title"],
+        "body": row["body"],
+        "content_type": row.get("content_type", "text"),
+        "metadata": row.get("metadata", {}),
+        "images": row.get("images", []),
+    }
+    _resource_cache[rid] = entry
+    return entry
+
+
+@app.post("/api/resource/batch")
+def batch_get_resources(body: dict = Body(...)):
+    """批量获取 resource 基本信息(不含 toc/children/siblings 导航),用于 Dashboard 等场景。
+    带后端内存缓存(24h TTL,resource 写入时失效)。"""
+    ids = body.get("ids", [])
+    if not ids:
+        return {"resources": {}}
+    resources: Dict[str, dict] = {}
+    for rid in ids:
+        try:
+            entry = _get_cached_resource(rid)
+            if entry:
+                resources[rid] = entry
+        except Exception as e:
+            print(f"[batch_get_resources] Failed to fetch {rid}: {e}")
+    return {"resources": resources}
+
+
 @app.post("/api/resource", status_code=201)
 def submit_resource(resource: ResourceIn):
     """提交资源(存入 PostgreSQL resources 表)"""
@@ -844,6 +947,7 @@ def get_resource(resource_id: str, x_org_key: Optional[str] = Header(None)):
             secure_body=secure_body,
             content_type=row["content_type"],
             metadata=row.get("metadata", {}),
+            images=row.get("images", []),
             toc=toc,
             children=children,
             prev=prev,
@@ -2425,6 +2529,92 @@ def frontend():
     if not index_file.exists():
         return HTMLResponse("<h1>KnowHub Frontend Not Found</h1><p>Please ensure knowhub/frontend/dist/index.html exists. Run 'yarn build' in frontend directory.</p>", status_code=404)
     return FileResponse(str(index_file))
+
+
+# ===== Strategy API =====
+
+@app.post("/api/strategy", status_code=201)
+async def submit_strategy(strategy: StrategyIn):
+    """创建或更新策略(自动填时间戳 + name/description 向量)"""
+    try:
+        now = int(time.time())
+        data = strategy.model_dump()
+        data['created_at'] = data.get('created_at') or now
+        data['updated_at'] = now
+        data['embedding'] = await get_embedding(f"{strategy.name} {strategy.description}")
+        pg_strategy_store.insert_or_update(data)
+        return {"success": True, "id": strategy.id}
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.get("/api/strategy")
+def get_strategies(limit: int = 100, offset: int = 0, status: Optional[str] = None):
+    try:
+        results = pg_strategy_store.list_all(limit=limit, offset=offset, status=status)
+        total = pg_strategy_store.count(status=status)
+        return {"strategies": results, "total": total}
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.get("/api/strategy/search")
+async def search_strategies(q: str = Query(...), top_k: int = 20, status: Optional[str] = None):
+    try:
+        query_embedding = await get_embedding(q)
+        results = pg_strategy_store.search(query_embedding, limit=top_k, status=status)
+        return {"results": results, "count": len(results)}
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.get("/api/strategy/{strategy_id:path}")
+def get_strategy(strategy_id: str):
+    try:
+        result = pg_strategy_store.get_by_id(strategy_id)
+        if not result:
+            raise HTTPException(status_code=404, detail="Strategy not found")
+        return result
+    except HTTPException:
+        raise
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.patch("/api/strategy/{strategy_id:path}")
+async def patch_strategy(strategy_id: str, updates: StrategyPatchIn):
+    """更新策略字段。若改了 name/description,会重算向量。"""
+    try:
+        existing = pg_strategy_store.get_by_id(strategy_id)
+        if not existing:
+            raise HTTPException(status_code=404, detail="Strategy not found")
+
+        update_dict = updates.model_dump(exclude_unset=True)
+        if not update_dict:
+            return {"success": True}
+
+        if 'name' in update_dict or 'description' in update_dict:
+            name = update_dict.get('name', existing.get('name', ''))
+            desc = update_dict.get('description', existing.get('description', ''))
+            update_dict['embedding'] = await get_embedding(f"{name} {desc}")
+
+        update_dict['updated_at'] = int(time.time())
+        pg_strategy_store.update(strategy_id, update_dict)
+        return {"success": True}
+    except HTTPException:
+        raise
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.delete("/api/strategy/{strategy_id:path}")
+def delete_strategy(strategy_id: str):
+    try:
+        if not pg_strategy_store.get_by_id(strategy_id):
+            raise HTTPException(status_code=404, detail="Strategy not found")
+        pg_strategy_store.delete(strategy_id)
+        return {"success": True}
+    except HTTPException:
+        raise
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
 # ===== Relation API =====
 
 @app.get("/api/relation/{table_name}")
@@ -2476,6 +2666,67 @@ async def get_relations(table_name: str, request: Request):
     except Exception as e:
         raise HTTPException(status_code=500, detail=str(e))
 
+# --- Dashboard Snapshot (缓存聚合接口) ---
+
+_dashboard_snapshot_cache: Optional[dict] = None
+_dashboard_snapshot_ts: float = 0
+_DASHBOARD_CACHE_TTL = 24 * 3600  # 24 小时
+
+
+def _invalidate_dashboard_cache():
+    """数据写入后调用,清除 dashboard 快照缓存"""
+    global _dashboard_snapshot_cache, _dashboard_snapshot_ts
+    _dashboard_snapshot_cache = None
+    _dashboard_snapshot_ts = 0
+
+
+def _build_dashboard_snapshot() -> dict:
+    """在后端一次性构建 Dashboard 所需的全部数据"""
+    tree_file = STATIC_DIR / "category_tree.json"
+    tree_data = None
+    if tree_file.exists():
+        tree_data = json.loads(tree_file.read_text(encoding="utf-8"))
+
+    reqs = pg_requirement_store.list_all(limit=1000, offset=0)
+    caps = pg_capability_store.list_all(limit=1000, offset=0)
+    tools = pg_tool_store.list_all(limit=1000, offset=0)
+    procs = pg_strategy_store.list_all(limit=1000, offset=0)
+    know_raw = pg_store.query('(status == "approved" or status == "checked")', limit=1000)
+    know = [to_serializable(r) for r in know_raw]
+
+    return {
+        "tree": tree_data,
+        "reqs": reqs,
+        "caps": caps,
+        "tools": tools,
+        "procs": procs,
+        "know": know,
+        "built_at": time.time(),
+    }
+
+
+@app.get("/api/dashboard/snapshot")
+def get_dashboard_snapshot():
+    """返回 Dashboard 所需的全部数据快照,带服务端内存缓存(24h TTL,写入时失效)"""
+    global _dashboard_snapshot_cache, _dashboard_snapshot_ts
+    now = time.time()
+    if _dashboard_snapshot_cache and (now - _dashboard_snapshot_ts < _DASHBOARD_CACHE_TTL):
+        return _dashboard_snapshot_cache
+    try:
+        _dashboard_snapshot_cache = _build_dashboard_snapshot()
+        _dashboard_snapshot_ts = now
+        return _dashboard_snapshot_cache
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@app.post("/api/dashboard/invalidate")
+def invalidate_dashboard_cache():
+    """手动清除 dashboard 缓存"""
+    _invalidate_dashboard_cache()
+    return {"status": "ok"}
+
+
 @app.get("/category_tree.json")
 def serve_category_tree():
     """类别树JSON数据"""

+ 72 - 0
skills4claude/README.md

@@ -0,0 +1,72 @@
+# skills
+
+Claude Code skills 的版本化源头。所有能给 Claude Code 使用的 skill 都放在这里,通过 `install.sh` 同步到 `~/.claude/skills/`。
+
+## 这里放什么?
+
+只放**本仓库相关**的 skill —— 也就是依赖或协作于 `cyber-agent` / KnowHub / ToolHub 的那些。
+
+| Skill | 形态 | 依赖 |
+|-------|------|------|
+| `agent/` | CLI 薄壳 + 共享 config | `cyber-agent` editable install |
+| `toolhub/` | 自包含单脚本 | 仅 `httpx`(可选 `cyber_sdk` 走 OSS) |
+| `knowhub/` | 自包含单脚本 | 仅 `httpx` |
+| `content-search/` | 纯文档,调 `python -m ...` | `cyber-agent` editable install |
+
+个人/跨项目的 skill(如 `glk-*`、`stitch-images`)不放这里。
+
+## 安装
+
+```bash
+# 默认:symlink,适合本机开发
+bash skills/install.sh
+
+# 跨机器部署:copy,脱离 repo
+bash skills/install.sh --copy
+
+# 只装指定 skill
+bash skills/install.sh --skills agent,toolhub
+
+# 改安装路径
+bash skills/install.sh --target /some/other/dir
+
+# 先看会做什么,不动文件
+bash skills/install.sh --dry-run
+
+# 强制覆盖已有内容(否则冲突即失败)
+bash skills/install.sh --force
+```
+
+`symlink` 模式下在 `skills/X/*` 改文件会立即反映在 `~/.claude/skills/X/`。
+`copy` 模式下每次改完需要重跑脚本。
+
+## 冲突处理
+
+`install.sh` **不自动备份**。遇到目标已存在的情况会这样处理:
+
+- 已是指向本 repo 的 symlink → 安静跳过(no-op)
+- 其他(旧目录 / 错位 symlink / 普通文件)→ **报错退出(exit 2)**,打印现状让你自己决定
+
+想覆盖加 `--force`,它会 `rm -rf` 现有目标再装。**不会**偷偷复制到 `.bak.*` —— 那样只会污染 `~/.claude/skills/`(Claude Code 会把 `.bak.*` 当成 skill 去索引)。旧内容若有价值,大多数时候本来就在 git 里;真要保底,自己 `mv` 一下再跑脚本。
+
+## 跨项目调用
+
+所有脚本都用**绝对路径**调用,不受 CWD 影响:
+
+```bash
+# agent — 本地或远端 agent
+python ~/.claude/skills/agent/invoke.py --agent_type=remote_librarian --task="..."
+
+# toolhub — 远程 AI 工具
+python ~/.claude/skills/toolhub/toolhub.py call --tool_id=flux_gen --params='{...}'
+
+# knowhub — 知识库
+python ~/.claude/skills/knowhub/knowhub.py search --query="..."
+python ~/.claude/skills/knowhub/knowhub.py save --task="..." --content="..."
+python ~/.claude/skills/knowhub/knowhub.py ask --query="..."
+
+# content-search — 内容平台搜索
+python -m agent.tools.builtin.content.tools platforms
+```
+
+每个脚本的 trace / 输出都落在**调用方 CWD 的 `.cache/`** 下,和项目解耦。

+ 71 - 0
skills4claude/agent/SKILL.md

@@ -0,0 +1,71 @@
+---
+name: agent
+description: 调用 Agent 执行任务 —— 能从知识库获取内容制作经验、擅长使用浏览器做深度调研。
+---
+
+# Agent 调用
+
+统一入口调用远端(KnowHub 服务器)或本地 Agent。底层走 `cyber-agent` SDK 的 `invoke_agent()`。
+
+## 前置
+
+需要 `cyber-agent` 包可 import。若未安装,在该仓库根目录执行:
+
+```bash
+pip install -e .
+```
+
+## 用法
+
+```bash
+python <this_skill_dir>/invoke.py \
+    --agent_type=<type> \
+    --task="<任务描述>" \
+    [--skills=skill1,skill2] \
+    [--continue_from=<sub_trace_id>] \
+    [--project_root=<本地项目目录>]
+```
+
+## 远端 Agent(`remote_` 前缀)
+
+**`remote_librarian`** — 知识库查询与上传:
+- `skills=ask_strategy`(默认):查询整合,返回带引用的回答
+- `skills=upload_strategy`:上传(`task` 为 JSON 字符串 `{knowledge:[...], tools:[...], resources:[...]}`)
+
+**`remote_research`** — 深度调研:自动全网搜集 + 总结,成果自动入库。
+
+## 本地 Agent
+
+`agent_type` 无 `remote_` 前缀。**默认使用 skill 目录自带的共享 config**,无需每个项目单独建 `config.py`。
+
+- **默认行为**:不传 `--project_root` → 用 skill 目录的 `config.py`,trace 落在调用方 CWD 的 `.cache/trace/`(自动创建)
+- **项目覆盖**:如果项目需要自己的 `RUN_CONFIG` / `presets.json` / `tools/`,传 `--project_root=<项目目录>`,该目录需满足:
+  - `config.py` 定义 `RUN_CONFIG`(必需)
+  - `presets.json` 定义 preset(可选)
+  - `tools/__init__.py` 注册自定义工具(可选)
+
+## 续跑
+
+首次调用返回的 `sub_trace_id` 作为下次的 `--continue_from`,同一 Agent 累积上下文。
+
+## 返回值
+
+stdout 输出 JSON,成功退出码 0、失败 1:
+
+```json
+{
+  "mode": "remote" | "local",
+  "agent_type": "...",
+  "sub_trace_id": "...",
+  "status": "completed" | "failed",
+  "summary": "Agent 最终产出的 message 文本(结构化信息由 prompt 约定写在这里)",
+  "stats": {"total_messages": N, "total_tokens": N, "total_cost": 0.xxx},
+  "error": null
+}
+```
+
+## 注意
+
+- 远端 `skills` 由服务器白名单过滤,非法项静默丢弃
+- 远端 Agent 无法访问调用方本地文件系统——大数据先通过 knowhub / toolhub 上传再传 ID
+- `summary` 是纯文本;需要结构化字段(引用来源、ID 等)时由 Agent prompt 约定格式,调用方自己 parse

+ 149 - 0
skills4claude/agent/config.py

@@ -0,0 +1,149 @@
+"""
+Agent skill 共享配置
+=====================
+
+这是 /Users/sunlit/.claude/skills/agent/ 的本地 agent 运行配置,所有项目共用。
+如果某个项目需要不同的 RUN_CONFIG / presets / tools,在那个项目里放一份自己的
+config.py 并显式传 `--project_root=<项目目录>`,CLI 会用项目 config 覆盖这份默认。
+
+字段来源:
+- RunConfig        → agent/core/runner.py:97
+- KnowledgeConfig  → agent/tools/builtin/knowledge.py:26
+- MemoryConfig     → agent/core/memory.py(可选,默认 None = 无长期记忆)
+- FileSystemTraceStore(base_path=TRACE_STORE_PATH) → agent/trace/store.py:36
+
+调用方式:
+    python invoke.py --agent_type=<type> --task="..."   # 自动使用本 config
+    python invoke.py --agent_type=<type> --task="..." \
+                     --project_root=/path/to/project    # 改用项目 config
+
+注:CLI 传入的 --agent_type / --skills / --continue_from 会在加载 config 后
+覆盖 RUN_CONFIG 的对应字段(见 agent/client.py:172-176)。
+"""
+
+import os
+from pathlib import Path
+
+from agent.core.runner import RunConfig
+from agent.tools.builtin.knowledge import KnowledgeConfig
+# 如果需要 memory-bearing agent,取消下一行注释:
+# from agent.core.memory import MemoryConfig
+
+
+# =============================================================================
+# Agent 运行配置(RUN_CONFIG)
+# =============================================================================
+# 所有字段都有默认值。仅列出"值得关注或常被调"的字段;完整字段见 runner.py:97。
+# agent_type / skills / trace_id 会被 CLI 覆盖,这里写什么都行(留占位符即可)。
+
+RUN_CONFIG = RunConfig(
+    # --- 模型层 -------------------------------------------------------------
+    model="qwen3.5-plus",           # LLM 模型名。常用:"gpt-4o" / "qwen3.5-plus" / "claude-sonnet-4-6"
+    temperature=0.3,                # 采样温度。调研/规划建议 0.3;创意任务可升到 0.7
+    max_iterations=1000,            # 单次 run 的最大工具调用轮数。远端 agent 一般够用,复杂本地任务可调大
+
+    # 传给 LLM 的额外参数(OpenAI SDK kwargs)。
+    # 常用例子:阿里 Qwen 的 thinking 模式、Claude 的 thinking budget。
+    extra_llm_params={
+        "extra_body": {"enable_thinking": True},   # 启用 Qwen thinking(仅对 qwen 生效,其他模型忽略)
+    },
+
+    # --- 工具选择 -----------------------------------------------------------
+    # tools=None 时按 tool_groups 白名单过滤;显式列 tool name 则精确指定。
+    tools=None,
+    tool_groups=["core"],           # 只开核心工具组;需要 knowledge/browser 工具时加 "knowledge" / "browser"
+    exclude_tools=[],               # 即使在 groups 里命中,也从最终集合里剔除的工具名
+
+    # --- 框架层 -------------------------------------------------------------
+    agent_type="default",           # 被 CLI --agent_type 覆盖,这里写啥都行
+    skills=None,                    # 被 CLI --skills 覆盖;None 时按 preset 决定 skill 注入
+    name=None,                      # Trace 显示名。None 让 utility_llm 基于 task 自动生成
+    enable_memory=True,             # 是否把 goal tree / collaborators 周期性注入(每 5 轮)
+    auto_execute_tools=True,        # False 则每个 tool call 需外部确认后才执行(人工审核场景)
+    enable_prompt_caching=True,     # Anthropic prompt caching,仅 Claude 模型有效
+
+    # --- Goal / 压缩 --------------------------------------------------------
+    goal_compression="on_overflow", # "none" | "on_complete" | "on_overflow";on_overflow 最省 token
+    side_branch_max_turns=5,        # 压缩/反思等侧分支的最大轮数
+
+    # --- Trace 续跑控制 -----------------------------------------------------
+    trace_id=None,                  # 被 CLI --continue_from 覆盖。None = 新 trace
+    parent_trace_id=None,           # 子 agent 调用时由上游注入,人工一般不填
+    parent_goal_id=None,
+    after_sequence=None,            # 指定从哪条 message sequence 后续跑(回溯重跑)
+
+    # --- 研究流程 -----------------------------------------------------------
+    # True = 自动跑"知识检索 → 经验检索 → 调研 → 计划"前置流程;
+    # 如果 agent 本身就是 research 性质,关掉避免双重调研。
+    enable_research_flow=True,
+
+    # --- 知识管理(KnowledgeConfig,详见 knowledge.py:26)-------------------
+    knowledge=KnowledgeConfig(
+        # 压缩触发时的反思提取
+        enable_extraction=False,            # True 在压缩时反思并落知识库
+        reflect_prompt="",                  # 自定义 prompt,空则用默认 REFLECT_PROMPT
+        reflect_auto_commit=False,          # True 自动提交反思产物(风险:知识库污染)
+
+        # 运行完成后的复盘提取
+        enable_completion_extraction=False, # True 在 agent 结束时复盘
+        completion_reflect_prompt="",
+
+        # 知识注入(focus goal 时自动拉相关知识进 context)
+        enable_injection=False,
+
+        # 默认字段(保存/搜索时自动带上)
+        owner="sunlit.howard@gmail.com",    # 空则 fallback 到 git user.email 再到 agent:{agent_id}
+        default_tags={},                    # 例如 {"project": "xyz", "domain": "ai_agent"}
+        default_scopes=["org:cybertogether"],
+        default_search_types=None,          # 搜索时默认的 type 过滤,如 ["strategy"]
+        default_search_owner="",            # 空 = 不按 owner 过滤
+    ),
+
+    # --- Memory(长期记忆,可选)-------------------------------------------
+    # None = 无长期记忆。若要启用:
+    #   from agent.core.memory import MemoryConfig
+    #   memory=MemoryConfig(...)  # 详见 agent/docs/memory.md
+    memory=None,
+
+    # --- 额外上下文(自定义元数据,透传给工具) -----------------------------
+    context={},
+)
+
+
+# =============================================================================
+# 基础设施路径
+# =============================================================================
+
+# --- Trace 存储 -------------------------------------------------------------
+# 锚在**调用方 CWD**,每个项目有独立的 trace 目录。
+# 用绝对路径是为了绕过 client.py:203-204 的 "非绝对路径 → rebase 到 project_root" 逻辑。
+# 如果想所有项目共享一个 trace 目录,把它改成任意绝对路径即可(如 Path.home() / ".agent_trace")。
+_CALLER_CWD = Path(os.getcwd()).resolve()
+_TRACE_DIR = _CALLER_CWD / ".cache" / "trace"
+# FileSystemTraceStore 只做 mkdir(exist_ok=True) 不带 parents,这里预先兜底创建父目录。
+_TRACE_DIR.mkdir(parents=True, exist_ok=True)
+TRACE_STORE_PATH = str(_TRACE_DIR)
+
+
+# --- Skills 目录 ------------------------------------------------------------
+# agent 会从这里加载 @skill 声明的技能定义。默认指向本 skill 目录本身,
+# 空着也没关系(SDK 容错);有自定义 skill 时把路径改到放 skill 的目录。
+SKILLS_DIR = str(Path(__file__).resolve().parent)
+
+
+# =============================================================================
+# 日志 / 调试
+# =============================================================================
+
+DEBUG = False                   # True 会打开更多调试输出(SDK 自己读这个标志)
+LOG_LEVEL = "INFO"              # "DEBUG" / "INFO" / "WARNING" / "ERROR"
+LOG_FILE = None                 # 设置为文件路径可同时输出到文件,None = 仅 stderr
+
+
+# =============================================================================
+# 浏览器配置(仅当启用 browser 工具组时生效)
+# =============================================================================
+# 使用场景:agent 需要访问网页(搜索、抓取、登录等)。不用时这段可忽略。
+
+BROWSER_TYPE = "local"          # "local"(本地 Chrome)/ "cloud"(云端)/ "container"(容器内账户)
+HEADLESS = False                # True 无头(CI / 服务器);False 有界面(本地调试观察)

+ 57 - 0
skills4claude/agent/invoke.py

@@ -0,0 +1,57 @@
+#!/usr/bin/env python
+"""
+薄 CLI 脚本:透传命令行参数到 cyber-agent 的 invoke_agent() SDK。
+远端 / 本地由 agent_type 前缀决定,同步返回 JSON 到 stdout。
+"""
+
+import argparse
+import asyncio
+import json
+import sys
+from pathlib import Path
+
+from agent import invoke_agent
+
+
+# skill 目录本身包含共享 config.py,作为未显式传 --project_root 时的默认锚点。
+_SKILL_DIR = str(Path(__file__).resolve().parent)
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="调用 Agent(远端或本地)")
+    parser.add_argument("--agent_type", required=True,
+                        help="Agent 类型。'remote_' 前缀走 KnowHub 服务器;否则本地执行")
+    parser.add_argument("--task", required=True, help="任务描述")
+    parser.add_argument("--skills", default=None,
+                        help="逗号分隔的 skill 列表(如 ask_strategy,upload_strategy)")
+    parser.add_argument("--continue_from", default=None,
+                        help="已有 sub_trace_id,传入则续跑")
+    parser.add_argument("--messages", default=None,
+                        help="预置消息(JSON 数组字符串,OpenAI 格式)")
+    parser.add_argument("--project_root", default=None,
+                        help="本地 agent 可选——项目目录(含 config.py)。"
+                             "不传则使用 skill 目录自带的共享 config.py")
+    args = parser.parse_args()
+
+    skills = [s.strip() for s in args.skills.split(",") if s.strip()] if args.skills else None
+    messages = json.loads(args.messages) if args.messages else None
+
+    # 本地 agent 且未显式指定项目 → 回落到 skill 自带的 config.py。
+    # 远端 agent(remote_ 前缀)无视 project_root,不影响。
+    project_root = args.project_root or _SKILL_DIR
+
+    result = asyncio.run(invoke_agent(
+        agent_type=args.agent_type,
+        task=args.task,
+        skills=skills,
+        continue_from=args.continue_from,
+        messages=messages,
+        project_root=project_root,
+    ))
+
+    print(json.dumps(result, ensure_ascii=False, indent=2))
+    return 0 if result.get("status") == "completed" else 1
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 59 - 0
skills4claude/content-search/SKILL.md

@@ -0,0 +1,59 @@
+---
+name: content-search
+description: 通过 API 快速搜索指定内容平台(可选择小红书/B站/知乎/GitHub/YouTube/X 等)的帖子和视频,返回结构化数据。无需打开浏览器、无需处理登录/反爬问题,但有时不稳定。需要深度交互或访问API不支持的平台或功能时,请改用浏览器工具。
+---
+
+# Content Search
+
+跨 11 个平台搜索内容,支持 小红书/公众号/视频号/GitHub/头条/抖音/B站/知乎/微博/YouTube/X。
+
+## 前置
+
+需要 `cyber-agent` 包已 editable install(`pip install -e /path/to/Agent`)。
+本 skill 通过 `-m` 形式调用模块,从任意 CWD 都能跑。
+
+## 用法
+
+```bash
+# 查看所有平台
+python -m agent.tools.builtin.content.tools platforms
+
+# 查看指定平台的参数(支持模糊匹配:ID/中文名/别名)
+python -m agent.tools.builtin.content.tools platforms --platform=小红书
+
+# 搜索
+python -m agent.tools.builtin.content.tools search --platform=xhs --keyword=胶片摄影
+python -m agent.tools.builtin.content.tools search --platform=youtube --keyword=Claude
+
+# 查看详情(index 来自搜索结果)
+python -m agent.tools.builtin.content.tools detail --platform=xhs --index=3
+
+# 搜索建议词(仅 xhs/toutiao/douyin/bili/zhihu)
+python -m agent.tools.builtin.content.tools suggest --platform=xhs --keyword=摄影
+```
+
+## 平台专属参数
+
+通过 `--extras` 传 JSON,如小红书筛选:
+
+```bash
+python -m agent.tools.builtin.content.tools search \
+  --platform=xhs --keyword=摄影 --extras='{"sort_type":"最新发布","publish_time":"近7天"}'
+```
+
+不确定参数时先 `platforms --platform=<name>` 查看。
+
+## trace_id
+
+同一 session 内 search → detail 需共享 trace_id(detail 从磁盘缓存取数据):
+
+```bash
+export TRACE_ID=my-session
+python -m agent.tools.builtin.content.tools search --platform=github --keyword=agent
+python -m agent.tools.builtin.content.tools detail --platform=github --index=1
+```
+
+## 注意
+
+- 磁盘缓存(用于 search → detail 复用)默认落在运行时的临时目录,由 cyber-agent 内部管理
+- 为什么不像 toolhub 那样把脚本复制过来:content-search 是 8 个文件的子包(tools.py + registry.py + cache.py + 3 个 platform 模块 + 图片工具),复制出去维护成本高,所以保留 `-m` 形式依赖 editable install

+ 135 - 0
skills4claude/install.sh

@@ -0,0 +1,135 @@
+#!/usr/bin/env bash
+#
+# 把 Agent 仓库的 skills/ 同步到 ~/.claude/skills/
+#
+# 默认用 **symlink**:源头在 repo,edit-in-repo 立即生效,适合本机开发。
+# 跨机器部署/发布用 --copy:把文件 rsync 过去,脱离 repo。
+#
+# 默认行为:
+#   - 目标不存在 → 创建
+#   - 目标已是指向本 repo 的 symlink → 跳过(no-op)
+#   - 目标已存在(其他 symlink / 目录 / 文件)→ **拒绝安装并报错**,让你自己决定如何处理
+#     - 用 --force 才会强制覆盖(先 rm 再装)
+#
+# 不自动备份的原因:skill 已进版本控制,旧内容通常有 git 历史;
+# 盲目 backup 会污染 ~/.claude/skills/(Claude Code 会把 .bak.* 当 skill 索引)。
+#
+# 用法:
+#   bash install.sh                  # symlink 模式(默认,冲突即失败)
+#   bash install.sh --copy           # copy 模式
+#   bash install.sh --force          # 冲突时强制覆盖
+#   bash install.sh --target DIR     # 改安装目录(默认 ~/.claude/skills)
+#   bash install.sh --dry-run        # 只打印会做什么,不动文件
+#   bash install.sh --skills a,b     # 只装指定的(默认全装)
+#
+set -euo pipefail
+
+REPO_SKILLS_DIR="$(cd "$(dirname "$0")" && pwd)"
+TARGET_DIR="${HOME}/.claude/skills"
+MODE="symlink"
+DRY_RUN=0
+FORCE=0
+ONLY_SKILLS=""
+
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --copy)     MODE="copy"; shift ;;
+    --symlink)  MODE="symlink"; shift ;;
+    --target)   TARGET_DIR="$2"; shift 2 ;;
+    --dry-run)  DRY_RUN=1; shift ;;
+    --force)    FORCE=1; shift ;;
+    --skills)   ONLY_SKILLS="$2"; shift 2 ;;
+    -h|--help)
+      grep '^#' "$0" | sed 's/^# \{0,1\}//' | head -30
+      exit 0 ;;
+    *) echo "unknown arg: $1" >&2; exit 1 ;;
+  esac
+done
+
+run() {
+  if [[ $DRY_RUN -eq 1 ]]; then
+    echo "[dry-run] $*"
+  else
+    eval "$@"
+  fi
+}
+
+# 收集要安装的 skill 列表
+if [[ -n "$ONLY_SKILLS" ]]; then
+  IFS=',' read -r -a skills <<< "$ONLY_SKILLS"
+else
+  skills=()
+  for d in "$REPO_SKILLS_DIR"/*/; do
+    [[ -d "$d" ]] || continue
+    name="$(basename "$d")"
+    [[ "$name" == "__pycache__" ]] && continue
+    skills+=("$name")
+  done
+fi
+
+echo "mode   : $MODE"
+echo "source : $REPO_SKILLS_DIR"
+echo "target : $TARGET_DIR"
+echo "force  : $FORCE"
+echo "skills : ${skills[*]}"
+echo "---"
+
+run "mkdir -p '$TARGET_DIR'"
+
+conflict_count=0
+for name in "${skills[@]}"; do
+  src="$REPO_SKILLS_DIR/$name"
+  dst="$TARGET_DIR/$name"
+
+  if [[ ! -d "$src" ]]; then
+    echo "⚠ skip (not found in repo): $name"
+    continue
+  fi
+
+  # 已经是指向本 repo 的 symlink → 跳过
+  if [[ -L "$dst" ]]; then
+    current="$(readlink "$dst")"
+    if [[ "$current" == "$src" ]]; then
+      echo "= $name (already linked, skip)"
+      continue
+    fi
+  fi
+
+  # 目标存在但不是我们想要的状态 → 要么 --force 覆盖,要么报错
+  if [[ -e "$dst" || -L "$dst" ]]; then
+    if [[ $FORCE -eq 0 ]]; then
+      echo "✗ $name: target exists → $dst"
+      echo "    (现状: $( [[ -L "$dst" ]] && echo "symlink → $(readlink "$dst")" || echo "directory/file" ))"
+      echo "    用 --force 覆盖;或手动处理后重跑。"
+      conflict_count=$((conflict_count + 1))
+      continue
+    fi
+    echo "↺ $name (forced: removing existing)"
+    run "rm -rf '$dst'"
+  fi
+
+  case "$MODE" in
+    symlink)
+      run "ln -s '$src' '$dst'"
+      echo "✓ $name (symlinked)"
+      ;;
+    copy)
+      run "cp -R '$src' '$dst'"
+      run "rm -rf '$dst/__pycache__'"
+      echo "✓ $name (copied)"
+      ;;
+  esac
+done
+
+echo ""
+if [[ $conflict_count -gt 0 ]]; then
+  echo "⚠ $conflict_count 个 skill 因冲突未安装。用 --force 覆盖或先手动处理。"
+  exit 2
+fi
+
+echo "安装完成。"
+if [[ "$MODE" == "symlink" ]]; then
+  echo "symlink 模式:在 $REPO_SKILLS_DIR 下改文件,$TARGET_DIR 自动跟随。"
+else
+  echo "copy 模式:repo 改动后需要重跑本脚本才会同步。"
+fi

+ 83 - 0
skills4claude/knowhub/SKILL.md

@@ -0,0 +1,83 @@
+---
+name: knowhub
+description: 查询和上传知识到 KnowHub 知识库。当用户需要检索已有调研成果、工具知识,或上传新的调研结果时使用。
+---
+
+# KnowHub 知识库
+
+自包含单脚本,三种模式覆盖全部典型用法:
+
+| 模式 | 做什么 | 何时用 |
+|------|-------|--------|
+| `ask`    | 深度回顾 —— 调远端 Librarian Agent 做规划+检索+整合 | 需要一个有引用的自然语言答案 |
+| `search` | 快速检索 —— 语义搜索 + LLM 精排 | 只想拿结构化条目列表,快 |
+| `save`   | 保存知识 | 把调研结论/工具使用经验写库 |
+
+**自包含:** 本 skill 目录下 `knowhub.py` 是独立脚本,不依赖 `cyber-agent` 安装,只需 `pip install httpx`。
+
+## 环境变量
+
+| 变量 | 作用 | 默认 |
+|------|------|------|
+| `KNOWHUB_API`   | KnowHub 服务器地址 | `http://43.106.118.91:9999` |
+| `KNOWHUB_OWNER` | `save` 的默认所有者;`search` 留空=不过滤,传 `--owner=$KNOWHUB_OWNER` 限定自己的 | `sunlit.howard@gmail.com` |
+
+也可以在 skill 目录下放 `.env` 文件覆盖默认(仅认 `KEY=VALUE` 纯文本,不依赖 python-dotenv)。
+
+## 用法
+
+### ask —— 深度回顾
+
+```bash
+# 调 remote_librarian(默认,快,基于已入库知识)
+python /Users/sunlit/.claude/skills/knowhub/knowhub.py ask --query="ControlNet 相关的工具"
+
+# 调 remote_research(深,全网调研 + 入库,慢,分钟级)
+python /Users/sunlit/.claude/skills/knowhub/knowhub.py ask --query="..." --deep
+
+# 复用上下文(用上次返回的 sub_trace_id)
+python /Users/sunlit/.claude/skills/knowhub/knowhub.py ask \
+    --query="基于刚才的结果补充..." \
+    --continue_from=65298f18-7cc4-4bc0-9fb8-6f2dd048df31
+```
+
+返回 `{"mode","agent_type","sub_trace_id","status","summary","stats","error"}`,`summary` 字段是带引用的自然语言回答。
+
+### search —— 快速检索
+
+```bash
+python /Users/sunlit/.claude/skills/knowhub/knowhub.py search \
+    --query="图片批量生成" \
+    --top_k=5 \
+    --min_score=3 \
+    --types=strategy,tool
+```
+
+**按关系过滤**(只看某 capability/tool/requirement 关联的):
+
+```bash
+python /Users/sunlit/.claude/skills/knowhub/knowhub.py search \
+    --query="..." --capability_id=CAP-008
+```
+
+返回 `{"query","count","results":[...]}`,`results` 每项含 `id / task / content / eval.score / types`。
+
+### save —— 保存知识
+
+```bash
+python /Users/sunlit/.claude/skills/knowhub/knowhub.py save \
+    --task="在用 flux 生成海报图时, 怎么让文字不错乱" \
+    --content="把文字用 [] 显式标出, 并在 prompt 末尾加 'clean typography, legible text'" \
+    --types=strategy \
+    --score=4 \
+    --source_name="flux 实测 2026-04" \
+    --source_urls=https://example.com/post-1
+```
+
+默认 `owner = $KNOWHUB_OWNER`,`scopes=["org:cybertogether"]`。需要覆盖用 `--owner=...` / `--scopes=...`。
+
+## 注意
+
+- `ask` 是同步阻塞的,timeout 600s;`search` 和 `save` 都在 60s / 30s 内返回
+- 所有命令返回标准 JSON,失败时 `error` 字段有值且退出码 1
+- `--tags` 接 JSON 字符串(如 `'{"project":"xyz"}'`);其他逗号分隔的参数接 CSV

+ 366 - 0
skills4claude/knowhub/knowhub.py

@@ -0,0 +1,366 @@
+#!/usr/bin/env python
+"""
+KnowHub 自包含 CLI
+===================
+
+三种查询/写入 KnowHub 的方式,都是直接 HTTP 调用,不依赖 cyber-agent 包。
+
+1. `ask`    —— 深度回顾:POST /api/agent,agent_type=remote_librarian(默认)或 remote_research
+                远端 Librarian Agent 会规划、检索、整合 → 带引用的自然语言回答
+2. `search` —— 快速检索:GET /api/knowledge/search
+                语义搜索 + LLM 精排,返回结构化知识条目
+3. `save`   —— 保存知识:POST /api/knowledge
+                把单条知识写库(异步校验入库)
+
+## 用法
+
+    python knowhub.py ask    --query="..."
+    python knowhub.py ask    --query="..." --deep         # 走 remote_research
+    python knowhub.py ask    --query="..." --continue_from=SUB_TRACE_ID
+
+    python knowhub.py search --query="..." [--top_k=5] [--min_score=3]
+                              [--types=strategy,tool] [--owner=...]
+                              [--capability_id=CAP-001]
+                              [--tool_id=...] [--requirement_id=...]
+
+    python knowhub.py save   --task="..." --content="..." --types=strategy
+                              [--score=4] [--source_name=...] [--source_urls=u1,u2]
+                              [--tags='{"project":"xyz"}']
+                              [--capability_ids=CAP-001,CAP-002]
+
+## 环境变量(可选)
+
+    KNOWHUB_API   KnowHub 服务器地址                 默认 http://43.106.118.91:9999
+    KNOWHUB_OWNER 默认所有者(save 和 search 均用) 默认 sunlit.howard@gmail.com
+
+.env 文件:在本 skill 目录下放 `.env`,本脚本会自动读取(仅 2 个变量,纯文本解析)。
+
+## 返回
+
+stdout 输出 JSON:
+- ask:    {"mode":"remote", "agent_type":..., "sub_trace_id":..., "status":..., "summary":..., "stats":..., "error":?}
+- search: {"query":..., "count":N, "results":[...]}
+- save:   {"knowledge_id":..., "status":"..."}
+
+退出码:成功 0,失败 1。
+"""
+
+import argparse
+import asyncio
+import json
+import os
+import sys
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+import httpx
+
+
+# ── 默认配置 ────────────────────────────────────────
+
+DEFAULT_KNOWHUB_API = "http://43.106.118.91:9999"
+DEFAULT_OWNER = "sunlit.howard@gmail.com"
+DEFAULT_SCOPES = ["org:cybertogether"]
+
+ASK_TIMEOUT = 600.0     # Librarian agent 规划 + 多轮检索可能需要几分钟
+SEARCH_TIMEOUT = 60.0
+SAVE_TIMEOUT = 30.0
+
+
+# ── .env 读取(超简版,仅认 KEY=VALUE 格式,不依赖 python-dotenv) ──
+
+def _load_local_env() -> None:
+    """从本脚本同目录的 .env 加载 KNOWHUB_API / KNOWHUB_OWNER。现有 env 优先。"""
+    env_file = Path(__file__).resolve().parent / ".env"
+    if not env_file.exists():
+        return
+    for line in env_file.read_text(encoding="utf-8").splitlines():
+        line = line.strip()
+        if not line or line.startswith("#") or "=" not in line:
+            continue
+        key, val = line.split("=", 1)
+        key, val = key.strip(), val.strip().strip('"').strip("'")
+        if key and key not in os.environ:
+            os.environ[key] = val
+
+
+_load_local_env()
+
+KNOWHUB_API = os.getenv("KNOWHUB_API", DEFAULT_KNOWHUB_API).rstrip("/")
+KNOWHUB_OWNER = os.getenv("KNOWHUB_OWNER", DEFAULT_OWNER)
+
+
+# ── 模式实现 ────────────────────────────────────────
+
+async def ask(
+    query: str,
+    deep: bool = False,
+    continue_from: Optional[str] = None,
+    skills: Optional[List[str]] = None,
+) -> Dict[str, Any]:
+    """
+    深度回顾:调用远端 Librarian / Research Agent。
+
+    deep=False → remote_librarian(整合已有知识库的回答)
+    deep=True  → remote_research(全网调研 + 入库,较慢)
+    """
+    agent_type = "remote_research" if deep else "remote_librarian"
+    payload = {
+        "agent_type": agent_type,
+        "task": query,
+        "messages": None,
+        "continue_from": continue_from,
+        "skills": skills,
+    }
+    try:
+        async with httpx.AsyncClient(timeout=ASK_TIMEOUT) as client:
+            r = await client.post(f"{KNOWHUB_API}/api/agent", json=payload)
+            r.raise_for_status()
+            result = r.json()
+        return {
+            "mode": "remote",
+            "agent_type": agent_type,
+            "sub_trace_id": result.get("sub_trace_id"),
+            "status": result.get("status", "completed"),
+            "summary": result.get("summary", ""),
+            "stats": result.get("stats", {}),
+            "error": result.get("error"),
+        }
+    except httpx.HTTPStatusError as e:
+        return {
+            "mode": "remote", "agent_type": agent_type, "status": "failed",
+            "error": f"HTTP {e.response.status_code}: {e.response.text[:200]}",
+        }
+    except Exception as e:
+        return {
+            "mode": "remote", "agent_type": agent_type, "status": "failed",
+            "error": f"{type(e).__name__}: {e}",
+        }
+
+
+async def search(
+    query: str,
+    top_k: int = 5,
+    min_score: int = 3,
+    types: Optional[List[str]] = None,
+    owner: Optional[str] = None,
+    requirement_id: Optional[str] = None,
+    capability_id: Optional[str] = None,
+    tool_id: Optional[str] = None,
+) -> Dict[str, Any]:
+    """快速检索:调 /api/knowledge/search。"""
+    params: Dict[str, Any] = {"q": query, "top_k": top_k, "min_score": min_score}
+    if types:
+        params["types"] = ",".join(types)
+    if owner:  # 显式覆盖才用;None 时不过滤(全库搜)
+        params["owner"] = owner
+    if requirement_id:
+        params["requirement_id"] = requirement_id
+    if capability_id:
+        params["capability_id"] = capability_id
+    if tool_id:
+        params["tool_id"] = tool_id
+
+    try:
+        async with httpx.AsyncClient(timeout=SEARCH_TIMEOUT) as client:
+            r = await client.get(f"{KNOWHUB_API}/api/knowledge/search", params=params)
+            r.raise_for_status()
+            data = r.json()
+        return {
+            "query": query,
+            "count": data.get("count", 0),
+            "results": data.get("results", []),
+        }
+    except httpx.HTTPStatusError as e:
+        return {"query": query, "count": 0, "results": [],
+                "error": f"HTTP {e.response.status_code}: {e.response.text[:200]}"}
+    except Exception as e:
+        return {"query": query, "count": 0, "results": [],
+                "error": f"{type(e).__name__}: {e}"}
+
+
+async def save(
+    task: str,
+    content: str,
+    types: List[str],
+    tags: Optional[Dict[str, str]] = None,
+    scopes: Optional[List[str]] = None,
+    owner: Optional[str] = None,
+    resource_ids: Optional[List[str]] = None,
+    source_name: str = "",
+    source_category: str = "exp",
+    source_urls: Optional[List[str]] = None,
+    agent_id: str = "knowhub_cli",
+    submitted_by: str = "",
+    score: int = 3,
+    message_id: str = "",
+    capability_ids: Optional[List[str]] = None,
+    tool_ids: Optional[List[str]] = None,
+) -> Dict[str, Any]:
+    """保存知识:POST /api/knowledge。"""
+    payload = {
+        "message_id": message_id,
+        "types": types,
+        "task": task,
+        "tags": tags or {},
+        "scopes": scopes or DEFAULT_SCOPES,
+        "owner": owner or KNOWHUB_OWNER,
+        "content": content,
+        "resource_ids": resource_ids or [],
+        "source": {
+            "name": source_name,
+            "category": source_category,
+            "urls": source_urls or [],
+            "agent_id": agent_id,
+            "submitted_by": submitted_by or KNOWHUB_OWNER,
+        },
+        "eval": {"score": score, "helpful": 1, "harmful": 0, "confidence": 0.5},
+        "capability_ids": capability_ids or [],
+        "tool_ids": tool_ids or [],
+    }
+    try:
+        async with httpx.AsyncClient(timeout=SAVE_TIMEOUT) as client:
+            r = await client.post(f"{KNOWHUB_API}/api/knowledge", json=payload)
+            r.raise_for_status()
+            data = r.json()
+        return {
+            "knowledge_id": data.get("knowledge_id"),
+            "status": data.get("status", "submitted"),
+            "owner": payload["owner"],
+        }
+    except httpx.HTTPStatusError as e:
+        return {"knowledge_id": None, "status": "failed",
+                "error": f"HTTP {e.response.status_code}: {e.response.text[:200]}"}
+    except Exception as e:
+        return {"knowledge_id": None, "status": "failed",
+                "error": f"{type(e).__name__}: {e}"}
+
+
+# ── 参数工具 ────────────────────────────────────────
+
+def _split_csv(val: Optional[str]) -> Optional[List[str]]:
+    """'a,b,c' → ['a','b','c'];None → None;空串 → None。"""
+    if not val:
+        return None
+    parts = [x.strip() for x in val.split(",") if x.strip()]
+    return parts or None
+
+
+def _parse_json_maybe(val: Optional[str]) -> Optional[Any]:
+    """把字符串按 JSON 解析;解析失败则原样返回字符串。"""
+    if val is None:
+        return None
+    try:
+        return json.loads(val)
+    except (json.JSONDecodeError, ValueError):
+        return val
+
+
+# ── CLI ───────────────────────────────────────────
+
+def _build_parser() -> argparse.ArgumentParser:
+    parser = argparse.ArgumentParser(
+        description="KnowHub CLI - ask / search / save 知识库",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog=f"默认 API: {KNOWHUB_API}   默认 owner: {KNOWHUB_OWNER}",
+    )
+    sub = parser.add_subparsers(dest="cmd", required=True, metavar="{ask,search,save}")
+
+    # ask
+    p_ask = sub.add_parser("ask", help="深度回顾(远端 Librarian Agent)")
+    p_ask.add_argument("--query", required=True)
+    p_ask.add_argument("--deep", action="store_true", help="改走 remote_research,全网调研 + 入库")
+    p_ask.add_argument("--continue_from", help="已有 sub_trace_id,传入则复用 Librarian 上下文")
+    p_ask.add_argument("--skills", help="逗号分隔的 skill 名单(可选,由服务器白名单过滤)")
+
+    # search
+    p_s = sub.add_parser("search", help="快速检索(语义搜索 + 精排)")
+    p_s.add_argument("--query", required=True)
+    p_s.add_argument("--top_k", type=int, default=5)
+    p_s.add_argument("--min_score", type=int, default=3)
+    p_s.add_argument("--types", help="逗号分隔(user_profile/strategy/tool/usecase/definition/plan)")
+    p_s.add_argument("--owner", help=f"覆盖默认 owner(默认不过滤,用 --owner={KNOWHUB_OWNER} 限定自己的)")
+    p_s.add_argument("--requirement_id")
+    p_s.add_argument("--capability_id")
+    p_s.add_argument("--tool_id")
+
+    # save
+    p_sv = sub.add_parser("save", help="保存知识到 KnowHub")
+    p_sv.add_argument("--task", required=True, help="任务描述:在什么情景下 + 要完成什么目标")
+    p_sv.add_argument("--content", required=True, help="知识的核心内容")
+    p_sv.add_argument("--types", required=True, help="逗号分隔的类型")
+    p_sv.add_argument("--tags", help="JSON 字符串,如 '{\"project\":\"xyz\"}'")
+    p_sv.add_argument("--scopes", help=f"逗号分隔(默认 {','.join(DEFAULT_SCOPES)})")
+    p_sv.add_argument("--owner", help=f"覆盖默认 owner(默认 {KNOWHUB_OWNER})")
+    p_sv.add_argument("--score", type=int, default=3, help="1-5,默认 3")
+    p_sv.add_argument("--source_name", default="")
+    p_sv.add_argument("--source_category", default="exp", help="paper/exp/skill/book")
+    p_sv.add_argument("--source_urls", help="逗号分隔 URL 列表")
+    p_sv.add_argument("--agent_id", default="knowhub_cli")
+    p_sv.add_argument("--submitted_by", default="")
+    p_sv.add_argument("--capability_ids", help="逗号分隔的能力 ID")
+    p_sv.add_argument("--tool_ids", help="逗号分隔的工具 ID")
+    p_sv.add_argument("--resource_ids", help="逗号分隔的资源 ID")
+    p_sv.add_argument("--message_id", default="")
+
+    return parser
+
+
+async def _dispatch(args) -> Dict[str, Any]:
+    if args.cmd == "ask":
+        return await ask(
+            query=args.query,
+            deep=args.deep,
+            continue_from=args.continue_from,
+            skills=_split_csv(args.skills),
+        )
+
+    if args.cmd == "search":
+        return await search(
+            query=args.query,
+            top_k=args.top_k,
+            min_score=args.min_score,
+            types=_split_csv(args.types),
+            owner=args.owner,
+            requirement_id=args.requirement_id,
+            capability_id=args.capability_id,
+            tool_id=args.tool_id,
+        )
+
+    if args.cmd == "save":
+        tags_val = _parse_json_maybe(args.tags) if args.tags else None
+        return await save(
+            task=args.task,
+            content=args.content,
+            types=_split_csv(args.types) or [],
+            tags=tags_val if isinstance(tags_val, dict) else None,
+            scopes=_split_csv(args.scopes),
+            owner=args.owner,
+            resource_ids=_split_csv(args.resource_ids),
+            source_name=args.source_name,
+            source_category=args.source_category,
+            source_urls=_split_csv(args.source_urls),
+            agent_id=args.agent_id,
+            submitted_by=args.submitted_by,
+            score=args.score,
+            message_id=args.message_id,
+            capability_ids=_split_csv(args.capability_ids),
+            tool_ids=_split_csv(args.tool_ids),
+        )
+
+    raise ValueError(f"未知命令: {args.cmd}")
+
+
+def main() -> int:
+    args = _build_parser().parse_args()
+    result = asyncio.run(_dispatch(args))
+    print(json.dumps(result, ensure_ascii=False, indent=2))
+
+    # 退出码:任何 status=failed 或 error 字段非空 → 1
+    if result.get("status") == "failed" or result.get("error"):
+        return 1
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 86 - 0
skills4claude/toolhub/SKILL.md

@@ -0,0 +1,86 @@
+---
+name: toolhub
+description: 搜索和调用 ToolHub 远程 AI 工具(图片生成、图片拼接等)。当用户需要生成图片、调用远程 AI 工具、或查询可用工具时使用。
+---
+
+# ToolHub 远程工具库
+
+ToolHub 托管了各种 AI 工具(图片生成、拼接、风格迁移等),通过 CLI 调用 `http://43.106.118.91:8001`。
+
+**自包含:** 本 skill 目录下 `toolhub.py` 是独立脚本,不依赖 `cyber-agent` 安装,只需 `pip install httpx`。
+如需 OSS 上传能力(见下表),额外装 `cyber_sdk`。
+
+## 用法
+
+```bash
+# 检查服务状态
+python /Users/sunlit/.claude/skills/toolhub/toolhub.py health
+
+# 搜索可用工具(不填 keyword 返回全量)
+python /Users/sunlit/.claude/skills/toolhub/toolhub.py search --keyword=image
+
+# 调用工具
+python /Users/sunlit/.claude/skills/toolhub/toolhub.py call \
+  --tool_id=flux_gen --params='{"prompt":"a cat sitting on the moon"}'
+```
+
+## 图片参数
+
+### 输入(给 ToolHub)
+
+`params` 中的 `image` / `image_url` / `mask_image` / `pose_image` / `reference_image`(单值)和
+`images` / `image_urls` / `reference_images`(数组)都可以传**本地路径 *或* URL**:
+
+- 传 URL(`https://...` / `data:...`)→ 原样透传给 ToolHub 服务
+- 传本地路径 → `_maybe_upload_local` 先把文件上传 OSS,拿到 CDN URL 再传给服务
+
+```bash
+# 示例:传入本地图片(自动 OSS 上传)
+python /Users/sunlit/.claude/skills/toolhub/toolhub.py call \
+  --tool_id=image_stitcher \
+  --params='{"images":["/path/to/a.png","/path/to/b.png"],"direction":"horizontal"}'
+```
+
+### 输出(ToolHub 返回)
+
+每张产出图会同时生成三种形态:
+
+| 形态 | 位置 / 字段 | 用途 |
+|------|-----------|------|
+| 本地文件 | `<cwd>/.cache/toolhub_outputs/{trace_id}/` + `saved_files` | 用户归档、后续工具链按路径引用 |
+| CDN URL | 输出 JSON 里的 `cdn_urls` | **上下文压缩后 LLM 可回看** / 工具 A→B 直接用 URL 串接 |
+| base64(不出现在 CLI JSON) | `ToolResult.images` 给 agent runtime | 当前轮多模态 LLM 直接"看到"图做推理 |
+
+输出目录不存在会自动创建。
+
+## cyber_sdk 依赖矩阵
+
+| 场景 | 需要 `cyber_sdk`? | 未装会发生什么 |
+|------|------------------|---------------|
+| 输入只传 URL | ❌ 不需要 | 正常 |
+| 输入传本地路径 | ✅ **必需** | `_upload_to_oss` 失败返回 None → 本地路径原样传给服务 → ToolHub 服务无法 GET → **工具调用失败** |
+| 输出获取 `cdn_urls` | ⚠️ 可选 | 静默跳过 OSS 上传,`cdn_urls` 为空;本地 `saved_files` 和多模态 base64 都不受影响 |
+| 只调 `health` / `search` | ❌ 不需要 | 正常 |
+
+简言之:**"我要传本地图进去"就必须装 `cyber_sdk`;只消费输出的场景可以不装**。
+
+## trace_id 会话管理
+
+trace_id 控制图片输出子目录,优先级:
+
+1. `--trace_id=xxx`(CLI 参数)
+2. `TRACE_ID` 环境变量
+3. 自动生成(`cli-xxxxxxxx`)
+
+```bash
+export TRACE_ID=my-session-001
+python /Users/sunlit/.claude/skills/toolhub/toolhub.py call --tool_id=flux_gen --params='{"prompt":"..."}'
+# 图片保存到 <cwd>/.cache/toolhub_outputs/my-session-00/
+```
+
+## 注意
+
+- 调用前先用 `search` 查询目标工具的 tool_id 和参数定义
+- 图片生成类工具可能耗时数分钟(GPU 冷启动)
+- 部分工具有生命周期分组(如 RunComfy),需按 search 返回的 `usage_order` 顺序调用
+- 输出为 JSON:`{"trace_id": "...", "output": "...", "error": "...", "metadata": {...}}`

+ 763 - 0
skills4claude/toolhub/toolhub.py

@@ -0,0 +1,763 @@
+"""
+ToolHub - 远程工具库集成模块
+
+将 http://43.106.118.91:8001 的工具库 API 包装为 Agent 可调用的工具。
+提供三个工具:
+1. toolhub_health   - 健康检查
+2. toolhub_search   - 搜索/发现远程工具(GET /tools)
+3. toolhub_call     - 调用远程工具(POST /run_tool)
+
+图片参数统一使用本地文件路径:
+  - 输入:params 中的 image/image_url 等参数直接传本地路径,内部自动上传
+  - 输出:生成的图片自动保存到 outputs/ 目录,返回本地路径
+
+实际 API 端点(通过 /openapi.json 确认):
+  GET  /health      → 健康检查
+  GET  /tools       → 列出所有工具(含分组、参数 schema)
+  POST /run_tool    → 调用工具 {"tool_id": str, "params": dict}
+  POST /chat        → 对话接口(不在此封装)
+
+CLI 用法:
+  python -m agent.tools.builtin.toolhub health
+  python -m agent.tools.builtin.toolhub search --keyword=image
+  python -m agent.tools.builtin.toolhub call --tool_id=flux_gen --params='{"prompt":"a cat"}'
+"""
+
+import base64
+import contextvars
+import json
+import logging
+import mimetypes
+import os
+import time
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+import httpx
+
+# ── agent.tools 解耦 shim ────────────────────────────
+# 原脚本依赖 `from agent.tools import tool, ToolResult`。为了让本 skill 不再
+# 强依赖 cyber-agent editable install,这里 inline 两个等价替身:
+#   - `tool(...)` 装饰器:agent runtime 用来把函数注册进全局 registry。
+#     CLI 场景下直接调用函数、不经过 registry,所以这里是 **纯空转**,
+#     保留 kwargs 仅为了接住原来的 display/groups 等参数不报错。
+#   - `ToolResult`:agent runtime 的统一返回类型。这里复刻必要字段,
+#     只给 CLI 的 `__main__` 段消费(访问 .output / .error / .metadata)。
+from dataclasses import dataclass, field as _field
+from typing import Callable, TypeVar as _TypeVar
+
+_F = _TypeVar("_F", bound=Callable)
+
+
+def tool(**_kwargs) -> Callable[[_F], _F]:  # noqa: D401 — 签名与原装饰器保持兼容
+    """空转装饰器(CLI 模式)。原 agent runtime 里负责向 registry 注册,此处不需要。"""
+    def decorator(func: _F) -> _F:
+        return func
+    return decorator
+
+
+@dataclass
+class ToolResult:
+    """简化版 ToolResult。仅保留本文件实际用到的字段,行为与 agent.tools.models.ToolResult 兼容。"""
+    title: str
+    output: str
+    long_term_memory: Optional[str] = None
+    metadata: Dict[str, Any] = _field(default_factory=dict)
+    error: Optional[str] = None
+    attachments: List[str] = _field(default_factory=list)
+    images: List[Dict[str, Any]] = _field(default_factory=list)
+
+
+logger = logging.getLogger(__name__)
+
+# ── 配置 ─────────────────────────────────────────────
+
+TOOLHUB_BASE_URL = "http://43.106.118.91:8001"
+DEFAULT_TIMEOUT = 30.0
+CALL_TIMEOUT = 600.0   # 图像生成类工具耗时较长,云端机器启动可能需要数分钟
+
+# OSS 上传配置
+OSS_BUCKET_NAME = "aigc-admin"
+OSS_BUCKET_PATH = "toolhub_images"
+
+# 输出目录(锚在调用方 CWD 的 .cache/toolhub_outputs/,每个项目独立;目录不存在会自动创建)
+# 与 agent skill 的 trace 路径风格一致,方便 gitignore:`.cache/` 加一行搞定
+OUTPUT_BASE_DIR = Path(os.getcwd()) / ".cache" / "toolhub_outputs"
+
+# trace_id 上下文变量,由 runner 在执行工具前设置
+_trace_id_var: contextvars.ContextVar[str] = contextvars.ContextVar("toolhub_trace_id", default="")
+
+
+def set_trace_context(trace_id: str):
+    """由 runner 调用,设置当前 trace_id 供图片保存使用"""
+    _trace_id_var.set(trace_id)
+
+
+def _get_output_dir(tool_id: str) -> Path:
+    """获取图片输出目录:outputs/{trace_id}/,无 trace_id 时用时间戳"""
+    trace_id = _trace_id_var.get("")
+    if trace_id:
+        # trace_id 可能含 @ 等特殊字符,取前段作为目录名
+        safe_id = trace_id.split("@")[0][:12] if "@" in trace_id else trace_id[:12]
+        out_dir = OUTPUT_BASE_DIR / safe_id
+    else:
+        out_dir = OUTPUT_BASE_DIR / f"no_trace_{int(time.time())}"
+    out_dir.mkdir(parents=True, exist_ok=True)
+    return out_dir
+
+
+# ── 图片处理辅助 ─────────────────────────────────────
+
+async def _upload_to_oss(local_path: str) -> Optional[str]:
+    """上传本地文件到 OSS,返回 CDN URL"""
+    try:
+        from cyber_sdk.ali_oss import upload_localfile
+        import os
+        safe_path = os.path.abspath(local_path).replace("\\", "/")
+        result = await upload_localfile(
+            file_path=safe_path,
+            bucket_path=OSS_BUCKET_PATH,
+            bucket_name=OSS_BUCKET_NAME,
+        )
+        oss_key = result.get("oss_object_key")
+        if oss_key:
+            cdn_url = f"https://res.cybertogether.net/{oss_key}"
+            logger.info(f"[ToolHub] 图片已上传 OSS: {cdn_url}")
+            return cdn_url
+    except Exception as e:
+        logger.warning(f"[ToolHub] OSS 上传失败: {e}")
+    return None
+
+
+async def _process_images(raw_images: List[str], tool_id: str) -> tuple:
+    """
+    统一处理工具返回的图片列表。
+
+    对每张图片:下载(如需) → 保存本地 → 上传 OSS 拿 CDN URL → base64 供 LLM 查看
+
+    三种形态并存是为了覆盖不同消费者:
+      - saved_paths: 用户本地归档、后续工具链引用
+      - cdn_urls:    永久引用,**上下文压缩后 LLM 若要重新审视,可通过 URL 二次加载**;
+                     也让"A 工具输出 → B 工具输入"直接用 URL 串起来,不经过本地中转
+      - images_for_llm: 当前轮 LLM 多模态推理用(base64 直接嵌 payload)
+
+    Returns:
+        (images_for_llm, cdn_urls, saved_paths)
+    """
+    images_for_llm = []
+    cdn_urls = []
+    saved_paths = []
+
+    out_dir = _get_output_dir(tool_id)
+
+    for idx, img in enumerate(raw_images):
+        if not isinstance(img, str) or len(img) <= 100:
+            continue
+
+        img_bytes = None
+        media_type = "image/png"
+
+        if img.startswith(("http://", "https://")):
+            try:
+                async with httpx.AsyncClient(timeout=60, trust_env=False) as dl:
+                    img_resp = await dl.get(img)
+                    img_resp.raise_for_status()
+                    ct = img_resp.headers.get("content-type", "image/png").split(";")[0].strip()
+                    if not ct.startswith("image/"):
+                        ct = mimetypes.guess_type(img.split("?")[0])[0] or "image/png"
+                    media_type = ct
+                    img_bytes = img_resp.content
+            except Exception as e:
+                logger.warning(f"[ToolHub] 图片下载失败: {e}")
+                continue
+
+        elif img.startswith("data:"):
+            header, b64 = img.split(",", 1)
+            media_type = header.split(";")[0].replace("data:", "")
+            img_bytes = base64.b64decode(b64)
+
+        else:
+            # raw base64
+            img_bytes = base64.b64decode(img)
+
+        if not img_bytes:
+            continue
+
+        # 1. 保存本地(用时间戳区分多次调用)
+        ts = int(time.time() * 1000)
+        ext = {"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp"}.get(media_type, ".png")
+        save_path = out_dir / f"{tool_id}_{ts}_{idx}{ext}"
+        save_path.write_bytes(img_bytes)
+        saved_paths.append(str(save_path))
+
+        # 2. 上传 OSS 拿 CDN URL(best-effort —— cyber_sdk 未装则静默跳过)
+        # 为什么输出也 CDN:上下文压缩会丢 base64,后续如果需要 LLM 重看这张图,URL 是可复访的手段;
+        # 同时对称的 CDN 设计让"工具 A 输出 → 工具 B 输入"能直接用 URL 串接,不用本地中转
+        cdn_url = await _upload_to_oss(str(save_path))
+        if cdn_url:
+            cdn_urls.append(cdn_url)
+
+        # 3. base64 给当前轮 LLM 多模态查看
+        b64_data = base64.b64encode(img_bytes).decode()
+        images_for_llm.append({"type": "base64", "media_type": media_type, "data": b64_data})
+
+    return images_for_llm, cdn_urls, saved_paths
+
+
+_SINGLE_IMAGE_PARAMS = ("image", "image_url", "mask_image", "pose_image", "reference_image")
+_ARRAY_IMAGE_PARAMS = ("images", "image_urls", "reference_images")
+
+
+async def _maybe_upload_local(val: str) -> Optional[str]:
+    """如果 val 是存在的本地文件路径,上传 OSS 并返回 CDN URL;否则返回 None。"""
+    if not isinstance(val, str):
+        return None
+    if val.startswith(("http://", "https://", "data:")):
+        return None
+    try:
+        p = Path(val)
+        if p.exists() and p.is_file():
+            return await _upload_to_oss(str(p.resolve()))
+    except Exception as e:
+        logger.warning(f"[ToolHub] 本地路径处理失败 {val}: {e}")
+    return None
+
+
+async def _preprocess_params(params: Dict[str, Any]) -> Dict[str, Any]:
+    """
+    预处理工具参数:检测本地文件路径,自动上传到 OSS 并替换为 CDN URL。
+
+    支持的单值参数:image, image_url, mask_image, pose_image, reference_image
+    支持的数组参数:images, image_urls, reference_images
+
+    设计要点:远程工具服务的 cwd 和调用方不一样,相对路径在服务器上会找不到文件。
+    所以必须在客户端就把本地路径转成 CDN URL,不能期望服务器侧有 fallback。
+    """
+    if not params:
+        return params
+
+    processed = params.copy()
+
+    # 单值图片参数
+    for key in _SINGLE_IMAGE_PARAMS:
+        if key in processed and isinstance(processed[key], str):
+            val = processed[key]
+            if val.startswith(("http://", "https://", "data:")):
+                continue
+            cdn_url = await _maybe_upload_local(val)
+            if cdn_url:
+                processed[key] = cdn_url
+                logger.info(f"[ToolHub] {key} 本地路径已替换为 CDN: {cdn_url}")
+            elif not os.path.isfile(val):
+                # 既不是远程 URL 也不是已存在的本地文件,直接报错比让远程服务抛神秘的 base64 错误强
+                logger.warning(f"[ToolHub] {key}={val!r} 既不是 URL 也不是存在的本地文件")
+
+    # 数组型图片参数
+    for array_key in _ARRAY_IMAGE_PARAMS:
+        if array_key not in processed or not isinstance(processed[array_key], list):
+            continue
+        new_list = []
+        for idx, item in enumerate(processed[array_key]):
+            if not isinstance(item, str):
+                new_list.append(item)
+                continue
+            if item.startswith(("http://", "https://", "data:")):
+                new_list.append(item)
+                continue
+            cdn_url = await _maybe_upload_local(item)
+            if cdn_url:
+                new_list.append(cdn_url)
+                logger.info(f"[ToolHub] {array_key}[{idx}] 本地路径已替换为 CDN: {cdn_url}")
+            else:
+                new_list.append(item)
+                if not os.path.isfile(item):
+                    logger.warning(
+                        f"[ToolHub] {array_key}[{idx}]={item!r} 既不是 URL 也不是存在的本地文件"
+                    )
+        processed[array_key] = new_list
+
+    return processed
+
+
+# ── 工具实现 ──────────────────────────────────────────
+
+@tool(
+    display={
+        "zh": {"name": "ToolHub 健康检查", "params": {}},
+        "en": {"name": "ToolHub Health Check", "params": {}},
+    },
+    groups=["toolhub"],
+)
+async def toolhub_health() -> ToolResult:
+    """检查 ToolHub 远程工具库服务是否可用
+
+    检查 ToolHub 服务的健康状态,确认服务是否正常运行。
+    建议在调用其他 toolhub 工具之前先检查。
+
+    Returns:
+        ToolResult 包含服务健康状态信息
+    """
+    try:
+        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT, trust_env=False) as client:
+            resp = await client.get(f"{TOOLHUB_BASE_URL}/health")
+            resp.raise_for_status()
+            data = resp.json()
+
+        return ToolResult(
+            title="ToolHub 健康检查",
+            output=json.dumps(data, ensure_ascii=False, indent=2),
+            long_term_memory=f"ToolHub service at {TOOLHUB_BASE_URL} is healthy.",
+        )
+    except httpx.ConnectError:
+        return ToolResult(
+            title="ToolHub 健康检查",
+            output="",
+            error=f"无法连接到 ToolHub 服务 {TOOLHUB_BASE_URL},请确认服务已启动。",
+        )
+    except Exception as e:
+        err_msg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
+        return ToolResult(
+            title="ToolHub 健康检查",
+            output="",
+            error=err_msg,
+        )
+
+
+@tool(
+    display={
+        "zh": {"name": "搜索 ToolHub 工具", "params": {"keyword": "搜索关键词"}},
+        "en": {"name": "Search ToolHub", "params": {"keyword": "Search keyword"}},
+    },
+    groups=["toolhub"],
+)
+async def toolhub_search(keyword: Optional[str] = None) -> ToolResult:
+    """搜索 ToolHub 远程工具库中可用的工具
+
+    从 ToolHub 工具库中获取可用工具列表,返回每个工具的完整信息,包括:
+    tool_id、名称、分类、状态、参数列表(含类型、是否必填、默认值)、输出 schema、
+    分组信息(如 RunComfy 生命周期组)等。
+
+    调用 toolhub_call 之前,应先使用此工具了解目标工具的 tool_id 和所需参数。
+    不填 keyword 则返回所有工具。
+
+    Args:
+        keyword: 搜索关键词,用于过滤工具名称或描述(客户端过滤);为空则返回所有工具
+
+    Returns:
+        ToolResult 包含匹配的工具列表及其参数说明
+    """
+    try:
+        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT, trust_env=False) as client:
+            resp = await client.get(f"{TOOLHUB_BASE_URL}/tools")
+            resp.raise_for_status()
+            data = resp.json()
+
+        tools = data.get("tools", [])
+        groups = data.get("groups", [])
+
+        # 客户端关键词过滤:三层匹配策略
+        # 1) 原始子串匹配(最严格,但会被分隔符切断:nanobanana vs nano_banana)
+        # 2) 归一化子串匹配(去掉 _ - 空格 .,解决分隔符问题)
+        # 3) 分词交集匹配(keyword 拆成 token,任意 token 命中即保留,解决多词查询)
+        if keyword:
+            def _normalize(s: str) -> str:
+                """去掉分隔符和空白,全小写"""
+                return "".join(c for c in s.lower() if c.isalnum())
+
+            def _tokenize(s: str) -> set:
+                """按分隔符拆成 token 集合"""
+                import re
+                return {t for t in re.split(r"[\s_\-.,/]+", s.lower()) if t}
+
+            kw_raw = keyword.lower()
+            kw_norm = _normalize(keyword)
+            kw_tokens = _tokenize(keyword)
+
+            def _matches(t: dict) -> bool:
+                fields = [
+                    t.get("name", ""),
+                    t.get("description", ""),
+                    t.get("tool_id", ""),
+                    t.get("category", ""),
+                ]
+                combined = " ".join(fields).lower()
+                # 原始子串
+                if kw_raw in combined:
+                    return True
+                # 归一化子串(容忍分隔符差异)
+                if kw_norm and kw_norm in _normalize(combined):
+                    return True
+                # token 交集(多词关键词的 OR 匹配)
+                if kw_tokens:
+                    field_tokens = _tokenize(combined)
+                    if kw_tokens & field_tokens:
+                        return True
+                return False
+
+            tools = [t for t in tools if _matches(t)]
+
+        total = len(tools)
+
+        # 构建给 LLM 的结构化摘要
+        summaries = []
+        for t in tools:
+            input_props = t.get("input_schema", {}).get("properties", {})
+            required_fields = t.get("input_schema", {}).get("required", [])
+            params_desc = []
+            for name, info in input_props.items():
+                req = "必填" if name in required_fields else "可选"
+                desc = info.get("description", "")
+                default_str = f", 默认={info['default']}" if info.get("default") is not None else ""
+                enum_str = f", 可选值={info['enum']}" if info.get("enum") else ""
+                params_desc.append(
+                    f"  - {name} ({info.get('type','any')}, {req}): {desc}{default_str}{enum_str}"
+                )
+
+            group_str = ""
+            if t.get("group_ids"):
+                group_str = f"\n  所属分组: {', '.join(t['group_ids'])}"
+
+            tool_block = (
+                f"[{t['tool_id']}] {t['name']}\n"
+                f"  状态: {t['state']} | 运行时: {t['backend_runtime']} | 分类: {t.get('category','')}"
+                f"{group_str}\n"
+                f"  描述: {t.get('description', '')}"
+            )
+            if params_desc:
+                tool_block += "\n  参数:\n" + "\n".join(params_desc)
+            else:
+                tool_block += "\n  参数: 无"
+
+            summaries.append(tool_block)
+
+        # 分组使用说明:仅显示搜出的工具实际所属的分组,避免噪音
+        relevant_group_ids = set()
+        for t in tools:
+            for gid in t.get("group_ids", []) or []:
+                relevant_group_ids.add(gid)
+
+        group_summary = []
+        for g in groups:
+            if g["group_id"] not in relevant_group_ids:
+                continue
+            group_summary.append(
+                f"[组: {g['group_id']}] {g['name']}\n"
+                f"  调用顺序: {' → '.join(g.get('usage_order', []))}\n"
+                f"  说明: {g.get('usage_example', '')}"
+            )
+
+        output_parts = [f"共找到 {total} 个工具({'关键词: ' + keyword if keyword else '全量'}):\n"]
+        output_parts.append("\n\n".join(summaries))
+        if group_summary:
+            output_parts.append("\n\n=== 工具分组(有顺序依赖)===\n" + "\n\n".join(group_summary))
+
+        return ToolResult(
+            title=f"ToolHub 搜索{f': {keyword}' if keyword else '(全量)'}",
+            output="\n".join(output_parts),
+            long_term_memory=(
+                f"ToolHub 共 {total} 个工具: "
+                + ", ".join(t["tool_id"] for t in tools[:15])
+                + ("..." if total > 15 else "")
+            ),
+        )
+    except (httpx.ReadTimeout, httpx.ConnectTimeout, httpx.WriteTimeout, httpx.PoolTimeout) as e:
+        return ToolResult(
+            title="ToolHub /tools 超时",
+            output="",
+            error=f"ToolHub 的 /tools 接口在 {DEFAULT_TIMEOUT:.0f}s 内未响应({type(e).__name__})。"
+                  f"服务器可能在检测各工具状态导致列表慢,请稍后重试或联系维护者。",
+        )
+    except httpx.ConnectError as e:
+        return ToolResult(
+            title="ToolHub 连接失败",
+            output="",
+            error=f"无法连接到 ToolHub 服务 {TOOLHUB_BASE_URL}:{type(e).__name__}: {e}",
+        )
+    except Exception as e:
+        # 注意 httpx 的部分异常 str(e) 是空的,必须带 type name
+        err_msg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
+        return ToolResult(
+            title="搜索 ToolHub 工具失败",
+            output="",
+            error=err_msg,
+        )
+
+
+@tool(
+    display={
+        "zh": {
+            "name": "调用 ToolHub 工具",
+            "params": {"tool_id": "工具ID", "params": "工具参数"},
+        },
+        "en": {
+            "name": "Call ToolHub Tool",
+            "params": {"tool_id": "Tool ID", "params": "Tool parameters"},
+        },
+    },
+    groups=["toolhub"],
+)
+async def toolhub_call(
+    tool_id: str,
+    params: Optional[Dict[str, Any]] = None,
+) -> ToolResult:
+    """调用 ToolHub 远程工具库中的指定工具
+
+    通过 tool_id 调用 ToolHub 工具库中的某个工具,传入该工具所需的参数。
+    不同工具的参数不同,请先用 toolhub_search 查询目标工具的参数说明。
+
+    图片参数(image、image_url、mask_image、pose_image、images)直接传本地文件路径即可,
+    系统会自动上传。生成的图片会自动保存到本地 outputs/ 目录,返回结果中的
+    saved_files 字段包含本地文件路径。
+
+    注意:部分工具为异步生命周期(如 RunComfy、即梦、FLUX),需要按分组顺序
+    依次调用多个工具(如先 launch → 再 executor → 再 stop)。
+
+    Args:
+        tool_id: 要调用的工具 ID(从 toolhub_search 获取)
+        params: 工具参数字典,键值对根据目标工具的参数定义决定。
+                图片参数可直接使用本地文件路径(如 "/path/to/image.png")。
+
+    Returns:
+        ToolResult 包含工具执行结果,图片结果通过 saved_files 返回本地路径
+    """
+    try:
+        # 预处理参数:本地文件路径自动上传成 CDN URL
+        params = await _preprocess_params(params or {})
+
+        payload = {
+            "tool_id": tool_id,
+            "params": params,
+        }
+        async with httpx.AsyncClient(timeout=CALL_TIMEOUT, trust_env=False) as client:
+            resp = await client.post(
+                f"{TOOLHUB_BASE_URL}/run_tool", json=payload
+            )
+            resp.raise_for_status()
+            data = resp.json()
+
+        status = data.get("status")
+        if status == "success":
+            result = data.get("result", {})
+            result_str = json.dumps(result, ensure_ascii=False, indent=2)
+
+            # 提取图片并统一处理(下载 → 保存本地 → 上传 OSS → CDN URL)
+            images = []
+            if isinstance(result, dict):
+                # 收集所有图片(单张 image 字段 + images 列表字段)
+                raw_images = []
+                has_single_image = False
+                has_images_list = False
+
+                if result.get("image") and isinstance(result["image"], str):
+                    raw_images.append(result["image"])
+                    has_single_image = True
+
+                if result.get("images") and isinstance(result["images"], list):
+                    raw_images.extend(result["images"])
+                    has_images_list = True
+
+                if raw_images:
+                    images, cdn_urls, saved_paths = await _process_images(raw_images, tool_id)
+
+                    # 构建文本输出(去掉原始图片数据,以本地路径为主)
+                    result_display = {k: v for k, v in result.items() if k not in ("image", "images")}
+                    result_display["image_count"] = len(images)
+                    if saved_paths:
+                        result_display["saved_files"] = saved_paths
+                    if cdn_urls:
+                        result_display["cdn_urls"] = cdn_urls
+                    result_str = json.dumps(result_display, ensure_ascii=False, indent=2)
+
+            return ToolResult(
+                title=f"ToolHub [{tool_id}] 执行成功",
+                output=result_str,
+                long_term_memory=f"Called ToolHub tool '{tool_id}' → success",
+                images=images,
+            )
+        else:
+            error_msg = data.get("error", "未知错误")
+            return ToolResult(
+                title=f"ToolHub [{tool_id}] 执行失败",
+                output=json.dumps(data, ensure_ascii=False, indent=2),
+                error=error_msg,
+            )
+    except httpx.TimeoutException as e:
+        return ToolResult(
+            title=f"ToolHub [{tool_id}] 调用超时",
+            output="",
+            error=f"调用工具 {tool_id} 超时({CALL_TIMEOUT:.0f}s,{type(e).__name__}),"
+                  f"图像生成类工具可能需要更长时间。",
+        )
+    except Exception as e:
+        err_msg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
+        return ToolResult(
+            title=f"ToolHub [{tool_id}] 调用失败",
+            output="",
+            error=err_msg,
+        )
+
+
+# 注意:image_uploader 和 image_downloader 不再注册为 Agent 工具。
+# toolhub_call 已内置完整的图片管线(输入自动上传,输出自动下载保存),
+# 无需单独暴露上传/下载工具。以下函数保留供内部或 CLI 使用。
+
+
+async def image_uploader(local_path: str) -> ToolResult:
+    """将本地图片上传到 OSS,返回可用的 CDN URL(内部工具,不注册给 Agent)"""
+    import os
+    from pathlib import Path
+
+    p = Path(local_path)
+    if not p.exists():
+        return ToolResult(
+            title="图片上传失败",
+            output="",
+            error=f"文件不存在: {local_path}",
+        )
+    if not p.is_file():
+        return ToolResult(
+            title="图片上传失败",
+            output="",
+            error=f"路径不是文件: {local_path}",
+        )
+
+    cdn_url = await _upload_to_oss(str(p.resolve()))
+    if cdn_url:
+        result = {
+            "local_path": str(p.resolve()),
+            "cdn_url": cdn_url,
+            "file_size": os.path.getsize(p),
+        }
+        return ToolResult(
+            title="图片上传成功",
+            output=json.dumps(result, ensure_ascii=False, indent=2),
+            long_term_memory=f"Uploaded {local_path} → {cdn_url}",
+        )
+    else:
+        return ToolResult(
+            title="图片上传失败",
+            output="",
+            error=f"OSS 上传失败,请检查文件路径和网络连接: {local_path}",
+        )
+
+
+async def image_downloader(url: str, save_path: str = "") -> ToolResult:
+    """下载网络图片到本地文件(内部工具,不注册给 Agent)"""
+    import os
+    from pathlib import Path
+    from urllib.parse import urlparse, unquote
+
+    if not url.startswith(("http://", "https://")):
+        return ToolResult(
+            title="图片下载失败",
+            output="",
+            error=f"无效的 URL(必须以 http:// 或 https:// 开头): {url}",
+        )
+
+    # 自动生成保存路径
+    if not save_path:
+        out_dir = _get_output_dir("download")
+        # 从 URL 提取文件名
+        url_path = urlparse(url).path
+        filename = Path(unquote(url_path)).name if url_path else ""
+        if not filename or not any(filename.lower().endswith(ext) for ext in (".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp")):
+            filename = f"download_{int(time.time())}.png"
+        save_path = str(out_dir / filename)
+
+    # 确保目录存在
+    p = Path(save_path)
+    p.parent.mkdir(parents=True, exist_ok=True)
+
+    try:
+        async with httpx.AsyncClient(timeout=60.0, follow_redirects=True, trust_env=False) as client:
+            resp = await client.get(url)
+            resp.raise_for_status()
+            p.write_bytes(resp.content)
+
+        file_size = os.path.getsize(p)
+        result = {
+            "save_path": str(p.resolve()),
+            "file_size": file_size,
+            "source_url": url,
+        }
+        return ToolResult(
+            title="图片下载成功",
+            output=json.dumps(result, ensure_ascii=False, indent=2),
+            long_term_memory=f"Downloaded {url} → {save_path}",
+        )
+    except httpx.HTTPStatusError as e:
+        return ToolResult(
+            title="图片下载失败",
+            output="",
+            error=f"HTTP 错误 {e.response.status_code}: {url}",
+        )
+    except Exception as e:
+        return ToolResult(
+            title="图片下载失败",
+            output="",
+            error=f"下载失败: {e}",
+        )
+
+
+if __name__ == "__main__":
+    import sys
+
+    COMMANDS = {
+        "health": toolhub_health,
+        "search": toolhub_search,
+        "call": toolhub_call,
+    }
+
+    def _parse_args(argv):
+        kwargs = {}
+        for arg in argv:
+            if arg.startswith("--") and "=" in arg:
+                k, v = arg.split("=", 1)
+                k = k.lstrip("-").replace("-", "_")
+                try:
+                    v = json.loads(v)
+                except (json.JSONDecodeError, ValueError):
+                    pass
+                kwargs[k] = v
+        return kwargs
+
+    if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
+        print(f"用法: python {sys.argv[0]} <command> [--key=value ...]")
+        print(f"可用命令: {', '.join(COMMANDS.keys())}")
+        sys.exit(0)
+
+    cmd = sys.argv[1]
+    if cmd not in COMMANDS:
+        print(f"未知命令: {cmd},可用: {', '.join(COMMANDS.keys())}")
+        sys.exit(1)
+
+    import asyncio
+    import uuid
+    import os
+
+    kwargs = _parse_args(sys.argv[2:])
+
+    # trace_id:CLI 参数 > 环境变量 > 自动生成(用于图片输出目录)
+    trace_id = kwargs.pop("trace_id", None) or os.getenv("TRACE_ID") or f"cli-{uuid.uuid4().hex[:8]}"
+    set_trace_context(trace_id)
+
+    result = asyncio.run(COMMANDS[cmd](**kwargs))
+
+    # 修复双重 JSON 编码:如果 output 已经是一段 JSON 字符串(toolhub_call 内部
+    # 把 result dict 做过 json.dumps),解析回原生 dict 再嵌入 CLI 的最终 JSON,
+    # 避免调用方拿到"output 字段是被字符串化的 JSON"这种反人类形式。
+    output_value = result.output
+    if isinstance(output_value, str):
+        stripped = output_value.lstrip()
+        if stripped.startswith("{") or stripped.startswith("["):
+            try:
+                output_value = json.loads(output_value)
+            except (json.JSONDecodeError, ValueError):
+                pass  # 非 JSON 文本,保持原样
+
+    out = {"trace_id": trace_id, "output": output_value}
+    if result.error:
+        out["error"] = result.error
+    if result.metadata:
+        out["metadata"] = result.metadata
+    print(json.dumps(out, ensure_ascii=False, indent=2))

Некоторые файлы не были показаны из-за большого количества измененных файлов