Talegorithm 3 днів тому
батько
коміт
9399762148

+ 63 - 0
QUICK_MIGRATION.md

@@ -0,0 +1,63 @@
+# 快速迁移参考
+
+## 一键迁移命令
+
+```bash
+# 1. 连接服务器
+ssh agent_server
+cd ~/main_agent
+
+# 2. 备份数据库
+cp knowhub.db knowhub.db.backup.$(date +%Y%m%d_%H%M%S)
+
+# 3. 拉取迁移脚本
+git fetch origin
+git checkout origin/main -- migrate_to_resource.py
+
+# 4. 执行迁移
+python migrate_to_resource.py
+
+# 5. 拉取新代码
+git pull origin main
+
+# 6. 重启服务
+# 根据你的服务管理方式选择:
+sudo systemctl restart knowhub
+# 或
+pkill -f "uvicorn knowhub.server" && uvicorn knowhub.server:app --host 0.0.0.0 --port 8000 &
+```
+
+## 迁移内容
+
+1. **resources表**:contents → resources + 新字段
+2. **knowledge表**:添加resource_ids字段
+
+## 验证
+
+```bash
+# 检查表
+sqlite3 knowhub.db "SELECT name FROM sqlite_master WHERE type='table';"
+
+# 检查字段
+sqlite3 knowhub.db "PRAGMA table_info(resources);" | grep -E "secure_body|content_type|metadata|updated_at"
+sqlite3 knowhub.db "PRAGMA table_info(knowledge);" | grep resource_ids
+
+# 测试API
+curl http://localhost:8000/api/resource
+```
+
+## 回滚
+
+```bash
+# 停止服务
+pkill -f "uvicorn knowhub.server"
+
+# 恢复备份
+cp knowhub.db.backup.YYYYMMDD_HHMMSS knowhub.db
+
+# 回退代码
+git reset --hard HEAD~1
+
+# 重启服务
+uvicorn knowhub.server:app --host 0.0.0.0 --port 8000 &
+```

+ 62 - 0
RENAME_CONTENT_TO_RESOURCE.md

@@ -0,0 +1,62 @@
+# Content → Resource 重命名总结
+
+## 重命名原因
+
+避免与Knowledge表的`content`字段混淆,提高命名清晰度。
+
+## 已完成的修改
+
+### 1. 数据库
+- ✅ 表名:`contents` → `resources`
+
+### 2. 代码 (knowhub/server.py)
+- ✅ 模型:`ContentIn` → `ResourceIn`
+- ✅ 模型:`ContentOut` → `ResourceOut`
+- ✅ 模型:`ContentPatchIn` → `ResourcePatchIn`
+- ✅ 模型:`ContentNode` → `ResourceNode`
+- ✅ 函数:`submit_content` → `submit_resource`
+- ✅ 函数:`get_content` → `get_resource`
+- ✅ 函数:`patch_content` → `patch_resource`
+- ✅ 函数:`list_contents` → `list_resources`
+- ✅ 函数:`encrypt_content` → `encrypt_resource`
+- ✅ 函数:`decrypt_content` → `decrypt_resource`
+- ✅ 参数:`content_id` → `resource_id`
+- ✅ API路径:`/api/content` → `/api/resource`
+
+### 3. 文档
+- ✅ `knowhub/docs/content-storage.md` → `resource-storage.md`
+- ✅ `knowhub/docs/content-storage-examples.md` → `resource-storage-examples.md`
+- ✅ 更新 `knowhub/docs/knowledge-management.md`
+- ✅ 更新 `knowhub/docs/decisions.md`
+
+### 4. 测试和工具
+- ✅ 测试脚本:`test_resource_storage.py`
+- ✅ 实现总结:`RESOURCE_STORAGE_IMPLEMENTATION.md`
+
+## API端点变更
+
+| 旧端点 | 新端点 |
+|--------|--------|
+| POST /api/content | POST /api/resource |
+| GET /api/content/{content_id} | GET /api/resource/{resource_id} |
+| PATCH /api/content/{content_id} | PATCH /api/resource/{resource_id} |
+| GET /api/content | GET /api/resource |
+
+## 验证
+
+```bash
+# 1. 导入测试
+python -c "from knowhub.server import app; print('✅ OK')"
+
+# 2. 启动服务
+uvicorn knowhub.server:app --reload
+
+# 3. 运行测试
+python test_resource_storage.py
+```
+
+## 注意事项
+
+- 数据库表已重命名,旧的API端点不再可用
+- 如果有外部系统调用旧API,需要更新
+- 文档中所有引用已更新

+ 95 - 0
RESOURCE_STORAGE_IMPLEMENTATION.md

@@ -0,0 +1,95 @@
+# Resource存储系统实现总结
+
+## 实现内容
+
+### 1. 数据库扩展
+- 扩展`contents`表,添加以下字段:
+  - `secure_body`: 敏感内容(加密存储)
+  - `content_type`: 内容类型(text|code|credential|cookie)
+  - `metadata`: JSON元数据
+  - `updated_at`: 更新时间
+
+### 2. 加密机制
+- 使用AES-256-GCM加密算法
+- 密钥从环境变量`ORG_KEYS`读取
+- 格式:`encrypted:AES256-GCM:{base64_data}`
+- 实现函数:
+  - `encrypt_resource()`: 加密
+  - `decrypt_resource()`: 解密
+  - `get_org_key()`: 获取组织密钥
+
+### 3. API端点
+- `POST /api/resource`: 提交资源(自动加密secure_body)
+- `GET /api/resource/{id}`: 获取资源(支持X-Org-Key头解密)
+- `PATCH /api/resource/{id}`: 更新资源
+- `GET /api/resource`: 列出资源(支持content_type过滤)
+
+### 4. 数据模型
+- `ResourceIn`: 提交请求模型
+- `ResourcePatchIn`: 更新请求模型
+- `ResourceOut`: 响应模型
+
+### 5. 文档
+- `knowhub/docs/resource-storage.md`: 设计文档
+- `knowhub/docs/resource-storage-examples.md`: 使用示例
+- `knowhub/docs/decisions.md`: 决策记录(新增第0条)
+- `knowhub/docs/knowledge-management.md`: 更新架构说明
+
+### 6. 工具
+- `migrate_resources.py`: 数据库迁移脚本
+- `test_resource_storage.py`: 测试脚本
+
+## 使用方法
+
+### 1. 配置密钥
+
+```bash
+# 生成密钥
+python -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())"
+
+# 添加到.env
+echo "ORG_KEYS=test:你的密钥base64" >> .env
+```
+
+### 2. 迁移数据库
+
+```bash
+python migrate_resources.py
+```
+
+### 3. 启动服务
+
+```bash
+uvicorn knowhub.server:app --reload
+```
+
+### 4. 测试功能
+
+```bash
+python test_resource_storage.py
+```
+
+## 设计特点
+
+1. **分离公开/敏感内容**:body明文可搜索,secure_body加密保护
+2. **灵活的密钥管理**:支持多组织密钥,通过resource_id前缀区分
+3. **访问控制**:需要提供正确的X-Org-Key才能解密
+4. **元数据支持**:记录获取时间、过期时间、语言等信息
+5. **层级结构**:通过ID路径实现树形组织(如tools/selenium/login)
+
+## 安全考虑
+
+- ✅ 敏感内容加密存储
+- ✅ 密钥不入库,存储在环境变量
+- ✅ 访问需要验证密钥
+- ✅ 记录提交者和时间戳
+- ⚠️ 密钥在HTTP头中传输(建议使用HTTPS)
+- ⚠️ 数据库文件泄露仍有风险(建议文件系统加密)
+
+## 后续改进
+
+1. 支持密钥轮换
+2. 添加访问日志
+3. 支持密钥过期时间
+4. 前端管理界面
+5. 批量导入/导出功能

+ 214 - 0
SERVER_MIGRATION_GUIDE.md

@@ -0,0 +1,214 @@
+# 服务器迁移指南
+
+## ⚠️ 重要:不要直接拉取代码!
+
+由于我们进行了以下数据库变更,必须先执行迁移,否则会导致数据丢失或功能异常:
+
+1. **resources表**:contents → resources(表重命名)
+2. **resources表**:添加新字段(secure_body, content_type, metadata, updated_at)
+3. **knowledge表**:添加resource_ids字段(用于关联资源)
+
+## 迁移步骤
+
+### 1. 连接到服务器
+
+```bash
+ssh agent_server
+cd ~/main_agent
+```
+
+### 2. 备份数据库(重要!)
+
+```bash
+# 备份数据库文件
+cp knowhub.db knowhub.db.backup.$(date +%Y%m%d_%H%M%S)
+
+# 验证备份
+ls -lh knowhub.db*
+```
+
+### 3. 拉取迁移脚本(不拉取全部代码)
+
+```bash
+# 只拉取迁移脚本
+git fetch origin
+git checkout origin/main -- migrate_to_resource.py
+
+# 或者手动创建脚本(如果git checkout不工作)
+# 将migrate_to_resource.py的内容复制到服务器
+```
+
+### 4. 执行迁移
+
+```bash
+# 确保在包含knowhub.db的目录中
+python migrate_to_resource.py
+```
+
+**预期输出**:
+```
+============================================================
+KnowHub 数据库迁移
+变更内容:
+  1. contents表 → resources表
+  2. resources表添加新字段
+  3. knowledge表添加resource_ids字段
+============================================================
+数据库路径: /home/user/main_agent/knowhub.db
+
+当前表: {'contents', 'knowledge', 'experiences'}
+
+============================================================
+步骤1: 迁移 resources 表
+============================================================
+contents表中有 X 条记录
+现有字段: {...}
+
+添加 N 个新字段...
+  ALTER TABLE contents ADD COLUMN secure_body TEXT DEFAULT ''
+  ALTER TABLE contents ADD COLUMN content_type TEXT DEFAULT 'text'
+  ALTER TABLE contents ADD COLUMN metadata TEXT DEFAULT '{}'
+  ALTER TABLE contents ADD COLUMN updated_at TEXT DEFAULT ''
+✅ 字段添加完成
+
+重命名表: contents → resources
+✅ 表重命名完成
+resources表中有 X 条记录
+✅ 数据完整,X 条记录全部保留
+
+============================================================
+步骤2: 更新 knowledge 表
+============================================================
+现有字段: {...}
+
+添加 resource_ids 字段...
+✅ resource_ids字段添加完成
+
+============================================================
+迁移总结
+============================================================
+迁移后的表: {'resources', 'knowledge', 'experiences'}
+✅ resources表: X 条记录
+✅ knowledge表: Y 条记录, resource_ids字段: 存在
+
+✅ 迁移完成!现在可以拉取新代码并重启服务
+```
+
+### 5. 验证迁移
+
+```bash
+# 检查表是否存在
+sqlite3 knowhub.db "SELECT name FROM sqlite_master WHERE type='table';"
+
+# 检查resources表数据
+sqlite3 knowhub.db "SELECT COUNT(*) FROM resources;"
+
+# 检查knowledge表是否有resource_ids字段
+sqlite3 knowhub.db "PRAGMA table_info(knowledge);" | grep resource_ids
+```
+
+### 6. 拉取新代码
+
+```bash
+git pull origin main
+```
+
+### 7. 重启服务
+
+```bash
+# 根据你的服务管理方式
+# 如果使用systemd
+sudo systemctl restart knowhub
+
+# 如果使用screen/tmux
+# 先停止旧进程,然后启动新进程
+pkill -f "uvicorn knowhub.server"
+uvicorn knowhub.server:app --host 0.0.0.0 --port 8000 &
+
+# 或者使用你的启动脚本
+./start_knowhub.sh
+```
+
+### 8. 验证服务
+
+```bash
+# 测试API
+curl http://localhost:8000/api/resource
+
+# 检查日志
+tail -f logs/knowhub.log  # 根据实际日志路径
+```
+
+## 如果迁移失败
+
+### 恢复备份
+
+```bash
+# 停止服务
+pkill -f "uvicorn knowhub.server"
+
+# 恢复备份
+cp knowhub.db.backup.YYYYMMDD_HHMMSS knowhub.db
+
+# 回退代码
+git reset --hard HEAD~1
+
+# 重启服务
+uvicorn knowhub.server:app --host 0.0.0.0 --port 8000 &
+```
+
+### 检查问题
+
+```bash
+# 查看迁移脚本输出
+# 查看数据库状态
+sqlite3 knowhub.db ".tables"
+sqlite3 knowhub.db ".schema contents"
+sqlite3 knowhub.db ".schema resources"
+```
+
+## 数据库变更详情
+
+### 1. resources表(原contents表)
+
+**变更**:
+- 表名:`contents` → `resources`
+- 新增字段:
+  - `secure_body TEXT DEFAULT ''` - 敏感内容(加密存储)
+  - `content_type TEXT DEFAULT 'text'` - 内容类型(text|code|credential|cookie)
+  - `metadata TEXT DEFAULT '{}'` - JSON元数据
+  - `updated_at TEXT DEFAULT ''` - 更新时间
+
+**影响**:
+- 旧的`/api/content`端点不再可用
+- 需要使用新的`/api/resource`端点
+
+### 2. knowledge表
+
+**变更**:
+- 新增字段:`resource_ids TEXT DEFAULT '[]'` - 关联的资源ID列表(JSON数组)
+
+**用途**:
+- 知识条目可以引用多个资源
+- 示例:`["code/selenium/login", "credentials/website_a"]`
+- 通过`GET /api/resource/{id}`获取关联的资源详情
+
+## 注意事项
+
+1. **必须先备份**:迁移前务必备份数据库
+2. **按顺序执行**:不要跳过步骤
+3. **验证完整性**:迁移后检查数据条数是否一致
+4. **测试API**:重启后测试新的/api/resource端点
+
+## API端点变更
+
+迁移后,API端点已更改:
+
+| 旧端点 | 新端点 | 状态 |
+|--------|--------|------|
+| POST /api/content | POST /api/resource | ✅ 已更新 |
+| GET /api/content/{id} | GET /api/resource/{id} | ✅ 已更新 |
+| PATCH /api/content/{id} | PATCH /api/resource/{id} | ✅ 已更新 |
+| GET /api/content | GET /api/resource | ✅ 已更新 |
+
+如果有外部系统调用旧API,需要同步更新。

+ 39 - 2
agent/core/prompts/knowledge.py

@@ -18,6 +18,7 @@ REFLECT_PROMPT = """请回顾以上执行过程,将值得沉淀的经验直接
 2. 弯路:哪些尝试是不必要的,有没有更直接的方法
 2. 弯路:哪些尝试是不必要的,有没有更直接的方法
 3. 好的决策:哪些判断和选择是正确的,值得记住
 3. 好的决策:哪些判断和选择是正确的,值得记住
 4. 工具使用:哪些工具用法是高效的,哪些可以改进
 4. 工具使用:哪些工具用法是高效的,哪些可以改进
+5. **资源发现**:是否发现了有价值的资源需要保存(见下方说明)
 
 
 **每条经验调用一次 `knowledge_save`,参数说明**:
 **每条经验调用一次 `knowledge_save`,参数说明**:
 - `task`: 这条经验适用的场景,格式:「在[什么情境]下,[要完成什么]」
 - `task`: 这条经验适用的场景,格式:「在[什么情境]下,[要完成什么]」
@@ -25,9 +26,28 @@ REFLECT_PROMPT = """请回顾以上执行过程,将值得沉淀的经验直接
 - `types`: 选 `["strategy"]`;如果涉及工具用法也可加 `"tool"`
 - `types`: 选 `["strategy"]`;如果涉及工具用法也可加 `"tool"`
 - `tags`: 用 `intent`(任务意图)和 `state`(环境状态/相关工具名)标注,便于检索
 - `tags`: 用 `intent`(任务意图)和 `state`(环境状态/相关工具名)标注,便于检索
 - `score`: 1-5,根据这条经验的价值评估
 - `score`: 1-5,根据这条经验的价值评估
+- `resource_ids`: 如果关联了资源,填写资源 ID 列表(可选)
+
+**资源提取指南**:
+如果执行过程中涉及以下内容,应先用 `resource_save` 保存资源,再用 `knowledge_save` 提交相关的经验/知识:
+
+1. **复杂代码工具**(逻辑复杂、超过 100 行):
+   - 调用 `resource_save(resource_id="code/{category}/{name}", title="...", body="代码内容", content_type="code", metadata={"language": "python"})`
+   - 然后在 `knowledge_save` 中通过 `resource_ids=["code/{category}/{name}"]` 关联
+
+2. **账号密码凭证**:
+   - 调用 `resource_save(resource_id="credentials/{website}", title="...", body="使用说明", secure_body="账号:xxx\\n密码:xxx", content_type="credential", metadata={"acquired_at": "2026-03-06T10:00:00Z"})`
+   - 然后在 `knowledge_save` 中通过 `resource_ids=["credentials/{website}"]` 关联
+
+3. **Cookie 和登录态**:
+   - 调用 `resource_save(resource_id="cookies/{website}", title="...", body="获取方法", secure_body="cookie内容", content_type="cookie", metadata={"acquired_at": "...", "expires_at": "..."})`
+   - 然后在 `knowledge_save` 中通过 `resource_ids=["cookies/{website}"]` 关联
+
+4. **多资源引用**:
+   - 一个知识可以关联多个资源,如:`resource_ids=["code/selenium/login", "credentials/website_a"]`
 
 
 **注意**:
 **注意**:
-- 只保存最有价值的 3-8 条,宁少勿滥
+- 只保存最有价值的经验,宁少勿滥;一次就成功或比较简单的经验就不要记录了,记录反复尝试或被用户指导后才成功的经验。
 - 不需要输出任何文字,直接调用工具即可
 - 不需要输出任何文字,直接调用工具即可
 - 如果没有值得保存的经验,不调用任何工具
 - 如果没有值得保存的经验,不调用任何工具
 """
 """
@@ -42,6 +62,7 @@ COMPLETION_REFLECT_PROMPT = """请对整个任务进行复盘,将值得沉淀
 2. 关键决策点:哪些决策显著影响了最终结果
 2. 关键决策点:哪些决策显著影响了最终结果
 3. 可复用的模式:哪些做法在类似任务中可以直接复用
 3. 可复用的模式:哪些做法在类似任务中可以直接复用
 4. 踩过的坑:哪些问题本可提前规避
 4. 踩过的坑:哪些问题本可提前规避
+5. **资源沉淀**:任务中产生或发现的有价值资源(见下方说明)
 
 
 **每条经验调用一次 `knowledge_save`,参数说明**:
 **每条经验调用一次 `knowledge_save`,参数说明**:
 - `task`: 这条经验适用的场景,格式:「在[什么情境]下,[要完成什么]」
 - `task`: 这条经验适用的场景,格式:「在[什么情境]下,[要完成什么]」
@@ -49,9 +70,25 @@ COMPLETION_REFLECT_PROMPT = """请对整个任务进行复盘,将值得沉淀
 - `types`: 选 `["strategy"]`;如果涉及工具用法也可加 `"tool"`
 - `types`: 选 `["strategy"]`;如果涉及工具用法也可加 `"tool"`
 - `tags`: 用 `intent`(任务意图)和 `state`(环境状态/相关工具名)标注,便于检索
 - `tags`: 用 `intent`(任务意图)和 `state`(环境状态/相关工具名)标注,便于检索
 - `score`: 1-5,根据这条经验的价值评估
 - `score`: 1-5,根据这条经验的价值评估
+- `resource_ids`: 如果关联了资源,填写资源 ID 列表(可选)
+
+**资源提取指南**:
+如果任务中涉及以下内容,应先用 `resource_save` 保存资源,再用 `knowledge_save` 关联:
+
+1. **复杂代码工具**(逻辑复杂、超过 20 行、可复用):
+   - 调用 `resource_save(resource_id="code/{category}/{name}", title="...", body="代码内容", content_type="code", metadata={"language": "python"})`
+   - 然后在 `knowledge_save` 中通过 `resource_id` 关联
+
+2. **账号密码凭证**:
+   - 调用 `resource_save(resource_id="credentials/{website}", title="...", body="使用说明", secure_body="账号:xxx\\n密码:xxx", content_type="credential", metadata={"acquired_at": "2026-03-06T10:00:00Z"})`
+   - 然后在 `knowledge_save` 中通过 `secure_resource_id` 关联
+
+3. **Cookie 和登录态**:
+   - 调用 `resource_save(resource_id="cookies/{website}", title="...", body="获取方法", secure_body="cookie内容", content_type="cookie", metadata={"acquired_at": "...", "expires_at": "..."})`
+   - 然后在 `knowledge_save` 中通过 `secure_resource_id` 关联
 
 
 **注意**:
 **注意**:
-- 只保存最有价值的 2-5 条,宁少勿滥
+- 只保存最有价值的经验,宁少勿滥;一次就成功或比较简单的经验就不要记录了,记录反复尝试或被用户指导后才成功的经验。
 - 不需要输出任何文字,直接调用工具即可
 - 不需要输出任何文字,直接调用工具即可
 - 如果没有值得保存的经验,不调用任何工具
 - 如果没有值得保存的经验,不调用任何工具
 """
 """

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

@@ -103,6 +103,7 @@ async def knowledge_save(
     tags: Optional[Dict[str, str]] = None,
     tags: Optional[Dict[str, str]] = None,
     scopes: Optional[List[str]] = None,
     scopes: Optional[List[str]] = None,
     owner: Optional[str] = None,
     owner: Optional[str] = None,
+    resource_ids: Optional[List[str]] = None,
     source_name: str = "",
     source_name: str = "",
     source_category: str = "exp",
     source_category: str = "exp",
     urls: List[str] = None,
     urls: List[str] = None,
@@ -122,6 +123,7 @@ async def knowledge_save(
         tags: 业务标签(JSON 对象)
         tags: 业务标签(JSON 对象)
         scopes: 可见范围(默认 ["org:cybertogether"])
         scopes: 可见范围(默认 ["org:cybertogether"])
         owner: 所有者(默认 agent:{agent_id})
         owner: 所有者(默认 agent:{agent_id})
+        resource_ids: 关联的资源 ID 列表(可选)
         source_name: 来源名称
         source_name: 来源名称
         source_category: 来源类别(paper/exp/skill/book)
         source_category: 来源类别(paper/exp/skill/book)
         urls: 参考来源链接列表
         urls: 参考来源链接列表
@@ -149,6 +151,7 @@ async def knowledge_save(
             "scopes": scopes,
             "scopes": scopes,
             "owner": owner,
             "owner": owner,
             "content": content,
             "content": content,
+            "resource_ids": resource_ids or [],
             "source": {
             "source": {
                 "name": source_name,
                 "name": source_name,
                 "category": source_category,
                 "category": source_category,
@@ -412,3 +415,117 @@ async def knowledge_slim(
             error=str(e)
             error=str(e)
         )
         )
 
 
+
+# ==================== Resource 资源管理工具 ====================
+
+@tool(hidden_params=["context"])
+async def resource_save(
+    resource_id: str,
+    title: str,
+    body: str,
+    content_type: str = "text",
+    secure_body: str = "",
+    metadata: Optional[Dict[str, Any]] = None,
+    submitted_by: str = "",
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    保存资源(代码片段、凭证、Cookie 等)
+
+    Args:
+        resource_id: 资源 ID(层级路径,如 "code/selenium/login" 或 "credentials/website_a")
+        title: 资源标题
+        body: 公开内容(明文存储,可搜索)
+        content_type: 内容类型(text/code/credential/cookie)
+        secure_body: 敏感内容(加密存储,需要组织密钥访问)
+        metadata: 元数据(如 {"language": "python", "acquired_at": "2026-03-06T10:00:00Z"})
+        submitted_by: 提交者
+        context: 工具上下文
+
+    Returns:
+        保存结果
+    """
+    try:
+        payload = {
+            "id": resource_id,
+            "title": title,
+            "body": body,
+            "secure_body": secure_body,
+            "content_type": content_type,
+            "metadata": metadata or {},
+            "submitted_by": submitted_by,
+        }
+
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            response = await client.post(f"{KNOWHUB_API}/api/resource", json=payload)
+            response.raise_for_status()
+            data = response.json()
+
+        return ToolResult(
+            title="✅ 资源已保存",
+            output=f"资源 ID: {resource_id}\n类型: {content_type}\n标题: {title}",
+            long_term_memory=f"保存资源: {resource_id} ({content_type})",
+            metadata={"resource_id": resource_id}
+        )
+
+    except Exception as e:
+        logger.error(f"保存资源失败: {e}")
+        return ToolResult(
+            title="❌ 保存失败",
+            output=f"错误: {str(e)}",
+            error=str(e)
+        )
+
+
+@tool(hidden_params=["context"])
+async def resource_get(
+    resource_id: str,
+    org_key: Optional[str] = None,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    获取资源内容
+
+    Args:
+        resource_id: 资源 ID(层级路径)
+        org_key: 组织密钥(用于解密敏感内容,可选)
+        context: 工具上下文
+
+    Returns:
+        资源内容
+    """
+    try:
+        headers = {}
+        if org_key:
+            headers["X-Org-Key"] = org_key
+
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            response = await client.get(
+                f"{KNOWHUB_API}/api/resource/{resource_id}",
+                headers=headers
+            )
+            response.raise_for_status()
+            data = response.json()
+
+        output = f"资源 ID: {data['id']}\n"
+        output += f"标题: {data['title']}\n"
+        output += f"类型: {data['content_type']}\n"
+        output += f"\n公开内容:\n{data['body']}\n"
+
+        if data.get('secure_body'):
+            output += f"\n敏感内容:\n{data['secure_body']}\n"
+
+        return ToolResult(
+            title=f"📦 {data['title']}",
+            output=output,
+            metadata=data
+        )
+
+    except Exception as e:
+        logger.error(f"获取资源失败: {e}")
+        return ToolResult(
+            title="❌ 获取失败",
+            output=f"错误: {str(e)}",
+            error=str(e)
+        )
+

+ 39 - 1
knowhub/docs/decisions.md

@@ -4,6 +4,44 @@
 
 
 ---
 ---
 
 
+## 0. Resource存储系统:分离公开/敏感内容
+
+**日期**:2026-03-06
+
+**背景**:用户需要存储有价值的工具资源,包括:
+- 复杂代码片段
+- 特定网站的账号密码
+- 登录状态的Cookie(含获取时间)
+
+**问题**:
+- 原有resources表只有body字段,明文存储不安全
+- 如果整体加密,会失去搜索能力
+- 缺少元数据(获取时间、过期时间、内容类型)
+
+**决策**:扩展resources表,分离公开/敏感内容
+- `body`: 公开内容(明文,可搜索)
+- `secure_body`: 敏感内容(AES-256-GCM加密)
+- `content_type`: 内容类型(text|code|credential|cookie)
+- `metadata`: JSON元数据(language, acquired_at, expires_at)
+
+**加密机制**:
+- 使用AES-256-GCM加密
+- 密钥从环境变量ORG_KEYS读取(格式:org1:key1_base64,org2:key2_base64)
+- resource_id前缀决定使用哪个组织密钥(如test/tools/...使用test的密钥)
+- 访问时需提供X-Org-Key头,验证通过才解密
+
+**实现位置**:`knowhub/server.py`
+- `encrypt_resource()`: 加密函数
+- `decrypt_resource()`: 解密函数
+- `POST /api/resource`: 提交资源
+- `GET /api/resource/{id}`: 获取资源(支持X-Org-Key头)
+- `PATCH /api/resource/{id}`: 更新资源
+- `GET /api/resource`: 列出资源
+
+**文档**:`knowhub/docs/resource-storage.md`
+
+---
+
 ## 1. 定位:经验层而非工具目录
 ## 1. 定位:经验层而非工具目录
 
 
 **背景**:调研发现现有生态(详见 `docs/knowledge/`)已充分覆盖工具发现:
 **背景**:调研发现现有生态(详见 `docs/knowledge/`)已充分覆盖工具发现:
@@ -43,7 +81,7 @@
 
 
 Server 只做检索 + GROUP BY,不做智能处理。Agent 拿到 JSON 后自行判断。体现"端侧算力"原则。
 Server 只做检索 + GROUP BY,不做智能处理。Agent 拿到 JSON 后自行判断。体现"端侧算力"原则。
 
 
-**搜索范围**:仅命中 experience。Content 是 experience 的深入层,通过 content_id 关联获取,不参与搜索。如果后续发现 content 中有大量有价值信息未被 experience 覆盖,再扩展搜索范围。
+**搜索范围**:仅命中 experience。Content 是 experience 的深入层,通过 resource_id 关联获取,不参与搜索。如果后续发现 content 中有大量有价值信息未被 experience 覆盖,再扩展搜索范围。
 
 
 ---
 ---
 
 

+ 123 - 2
knowhub/docs/knowledge-management.md

@@ -10,14 +10,18 @@
 
 
 ## 架构
 ## 架构
 
 
-KnowHub Server 是统一的知识管理服务,Agent 通过 API 调用来保存和检索知识。
+KnowHub Server 是统一的知识管理服务,包含两个子系统:
+
+1. **Knowledge 系统**:结构化知识管理(任务场景 + 经验内容)
+2. **Resource 系统**:原始资源存储(代码、凭证、Cookie等)→ 详见 `knowhub/docs/resource-storage.md`
 
 
 ```
 ```
 Agent                           KnowHub Server
 Agent                           KnowHub Server
 ├── knowledge_search 工具   →   GET /api/knowledge/search
 ├── knowledge_search 工具   →   GET /api/knowledge/search
 ├── knowledge_save 工具     →   POST /api/knowledge
 ├── knowledge_save 工具     →   POST /api/knowledge
 ├── knowledge_update 工具   →   PUT /api/knowledge/{id}
 ├── knowledge_update 工具   →   PUT /api/knowledge/{id}
-└── goal focus 自动注入     →   GET /api/knowledge/search
+├── goal focus 自动注入     →   GET /api/knowledge/search
+└── resource 资源引用        →   GET /api/resource/{id}
 ```
 ```
 
 
 ---
 ---
@@ -39,6 +43,7 @@ Agent                           KnowHub Server
   "scopes": ["org:cybertogether"],
   "scopes": ["org:cybertogether"],
   "owner": "agent:research_agent",
   "owner": "agent:research_agent",
   "content": "核心知识内容",
   "content": "核心知识内容",
+  "resource_ids": ["code/selenium/login", "credentials/website_a"],
   "source": {
   "source": {
     "name": "资源名称",
     "name": "资源名称",
     "category": "exp",
     "category": "exp",
@@ -77,6 +82,7 @@ Agent                           KnowHub Server
 - **scopes**: 可见范围数组,默认 `["user:owner"]`
 - **scopes**: 可见范围数组,默认 `["user:owner"]`
 - **owner**: 所有者,默认取用户的git email
 - **owner**: 所有者,默认取用户的git email
 - **content**: 核心知识内容
 - **content**: 核心知识内容
+- **resource_ids**: 关联的资源 ID 列表(可选),引用 Resource 系统中的资源。每个资源可能包含公开内容(body)和敏感内容(secure_body)
 - **source**: 来源信息(嵌套对象)
 - **source**: 来源信息(嵌套对象)
   - **name**: 资源名称
   - **name**: 资源名称
   - **category**: 来源类别(paper/exp/skill/book)
   - **category**: 来源类别(paper/exp/skill/book)
@@ -208,6 +214,121 @@ async def knowledge_slim(
 
 
 ---
 ---
 
 
+## Resource 引用机制
+
+Knowledge 可以通过 `resource_id` 和 `secure_resource_id` 引用 Resource 系统中的原始资源。
+
+### 使用场景
+
+**场景 1:复杂代码工具**
+
+当工具实现逻辑复杂(如 Selenium 绕过检测、复杂的 API 调用封装)时:
+- 将代码片段保存为 Resource(`content_type=code`)
+- Knowledge 记录使用场景和经验,通过 `resource_ids` 引用代码
+
+```json
+{
+  "task": "使用 Selenium 登录某网站并绕过反爬检测",
+  "content": "使用 undetected_chromedriver 可以绕过大部分检测。关键点:1) 设置合适的 user-agent 2) 随机延迟 3) 避免频繁请求",
+  "types": ["tool", "strategy"],
+  "resource_ids": ["code/selenium/undetected_login"]
+}
+```
+
+对应的 Resource:
+```json
+{
+  "id": "code/selenium/undetected_login",
+  "title": "Selenium 绕过检测登录代码",
+  "body": "import undetected_chromedriver as uc\n\ndriver = uc.Chrome()...",
+  "content_type": "code",
+  "metadata": {"language": "python"}
+}
+```
+
+**场景 2:账号密码凭证**
+
+当发现或配置了有用的工具账号时:
+- 将敏感信息(账号、密码)保存为 Resource(`content_type=credential`,存入 `secure_body`)
+- Knowledge 记录使用方法和注意事项,通过 `resource_ids` 引用凭证
+
+```json
+{
+  "task": "登录某网站进行数据采集",
+  "content": "使用测试账号登录,注意:1) 每天限额 100 次 2) 需要在工作时间访问 3) 登录后 session 有效期 2 小时",
+  "types": ["tool", "usecase"],
+  "resource_ids": ["credentials/website_a"]
+}
+```
+
+对应的 Resource:
+```json
+{
+  "id": "credentials/website_a",
+  "title": "某网站测试账号",
+  "body": "使用方法:直接登录即可,每天限额 100 次",
+  "secure_body": "账号:user@example.com\n密码:SecurePass123",
+  "content_type": "credential",
+  "metadata": {"acquired_at": "2026-03-06T10:00:00Z"}
+}
+```
+
+**场景 3:Cookie 和登录态**
+
+当获取了有效的 Cookie 时:
+- 将 Cookie 保存为 Resource(`content_type=cookie`,存入 `secure_body`,设置 `expires_at`)
+- Knowledge 记录获取方法和使用场景,通过 `resource_ids` 引用
+
+```json
+{
+  "task": "使用已登录的 Cookie 访问某网站 API",
+  "content": "Cookie 获取方法:手动登录后从浏览器导出。使用时直接设置到请求头。注意检查过期时间。",
+  "types": ["tool"],
+  "resource_ids": ["cookies/website_a"]
+}
+```
+
+对应的 Resource:
+```json
+{
+  "id": "cookies/website_a",
+  "title": "某网站 Cookie",
+  "body": "适用于:已登录状态的 API 调用",
+  "secure_body": "session_id=abc123; auth_token=xyz789",
+  "content_type": "cookie",
+  "metadata": {
+    "acquired_at": "2026-03-06T10:00:00Z",
+    "expires_at": "2026-03-07T10:00:00Z"
+  }
+}
+```
+
+**场景 4:多资源引用**
+
+一个知识可以同时引用多个资源(如代码 + 凭证):
+
+```json
+{
+  "task": "使用 Selenium 登录某网站",
+  "content": "使用 undetected_chromedriver 绕过检测,配合测试账号登录",
+  "types": ["tool", "usecase"],
+  "resource_ids": ["code/selenium/undetected_login", "credentials/website_a"]
+}
+```
+
+### 提交流程
+
+1. **识别需要提取的 Resource**:在知识反思时,识别复杂代码、凭证、Cookie 等
+2. **调用 `resource_save` 工具**:提交 Resource 到 KnowHub
+3. **调用 `knowledge_save` 工具**:保存 Knowledge 并关联 Resource ID
+
+实现位置:
+- Resource 提交工具:`agent/tools/builtin/knowledge.py:resource_save`
+- Knowledge 保存工具:`agent/tools/builtin/knowledge.py:knowledge_save`
+- 知识反思 prompt:`agent/core/prompts/knowledge.py:KNOWLEDGE_EXTRACTION_PROMPT`
+
+---
+
 ## 知识注入机制
 ## 知识注入机制
 
 
 知识注入在 goal focus 时自动触发。
 知识注入在 goal focus 时自动触发。

+ 137 - 0
knowhub/docs/resource-storage-examples.md

@@ -0,0 +1,137 @@
+# Resource存储系统使用示例
+
+## 环境配置
+
+在`.env`文件中配置组织密钥:
+
+```bash
+# 生成密钥(Python)
+python -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())"
+
+# 配置到.env
+ORG_KEYS=test:生成的密钥base64,prod:另一个密钥base64
+```
+
+## 使用示例
+
+### 1. 存储代码片段
+
+```bash
+curl -X POST http://localhost:8000/api/resource \
+  -H "Content-Type: application/json" \
+  -d '{
+    "id": "test/code/selenium",
+    "title": "Selenium绕过检测",
+    "body": "import undetected_chromedriver as uc\ndriver = uc.Chrome()",
+    "content_type": "code",
+    "metadata": {"language": "python"},
+    "submitted_by": "user@example.com"
+  }'
+```
+
+### 2. 存储账号密码(加密)
+
+```bash
+curl -X POST http://localhost:8000/api/resource \
+  -H "Content-Type: application/json" \
+  -d '{
+    "id": "test/credentials/website",
+    "title": "某网站登录凭证",
+    "body": "使用方法:直接登录即可",
+    "secure_body": "账号:user@example.com\n密码:SecurePass123",
+    "content_type": "credential",
+    "metadata": {"acquired_at": "2026-03-06T10:00:00Z"},
+    "submitted_by": "user@example.com"
+  }'
+```
+
+### 3. 存储Cookie(加密)
+
+```bash
+curl -X POST http://localhost:8000/api/resource \
+  -H "Content-Type: application/json" \
+  -d '{
+    "id": "test/cookies/website",
+    "title": "某网站Cookie",
+    "body": "适用于:已登录状态的API调用",
+    "secure_body": "session_id=abc123; auth_token=xyz789",
+    "content_type": "cookie",
+    "metadata": {
+      "acquired_at": "2026-03-06T10:00:00Z",
+      "expires_at": "2026-03-07T10:00:00Z"
+    },
+    "submitted_by": "user@example.com"
+  }'
+```
+
+### 4. 获取资源(无密钥)
+
+```bash
+# 公开内容正常返回,敏感内容显示[ENCRYPTED]
+curl http://localhost:8000/api/resource/test/credentials/website
+```
+
+### 5. 获取资源(有密钥)
+
+```bash
+# 敏感内容解密返回
+curl http://localhost:8000/api/resource/test/credentials/website \
+  -H "X-Org-Key: 你的密钥base64"
+```
+
+### 6. 更新资源
+
+```bash
+curl -X PATCH http://localhost:8000/api/resource/test/credentials/website \
+  -H "Content-Type: application/json" \
+  -d '{
+    "title": "更新后的标题",
+    "metadata": {"acquired_at": "2026-03-06T11:00:00Z"}
+  }'
+```
+
+### 7. 列出所有资源
+
+```bash
+curl http://localhost:8000/api/resource
+```
+
+### 8. 按类型过滤
+
+```bash
+curl "http://localhost:8000/api/resource?content_type=credential"
+```
+
+## Knowledge引用Content
+
+在Knowledge中引用Content资源:
+
+```bash
+curl -X POST http://localhost:8000/api/knowledge \
+  -H "Content-Type: application/json" \
+  -d '{
+    "task": "登录某网站",
+    "content": "使用Selenium + undetected_chromedriver绕过检测,详见content资源",
+    "types": ["tool"],
+    "tags": {
+      "content_ref": "test/code/selenium",
+      "credential_ref": "test/credentials/website"
+    },
+    "owner": "agent:test",
+    "source": {
+      "submitted_by": "user@example.com"
+    }
+  }'
+```
+
+## 测试脚本
+
+运行测试脚本验证功能:
+
+```bash
+# 启动服务器
+uvicorn knowhub.server:app --reload
+
+# 运行测试(另一个终端)
+python test_content_storage.py
+```

+ 289 - 0
knowhub/docs/resource-storage.md

@@ -0,0 +1,289 @@
+# Resource 存储系统
+
+## 概述
+
+Resource 存储系统用于管理原始资源(代码片段、凭证、Cookie等),与 Knowledge 系统互补:
+- **Knowledge**:结构化知识条目(任务场景 + 经验内容)
+- **Content**:原始资源存储(代码、凭证、配置等)
+
+Knowledge 可以通过 `resource_id` 引用 Content 资源。
+
+---
+
+## 数据结构
+
+### 数据库表
+
+实现位置:`knowhub/server.py:init_db`
+
+```sql
+CREATE TABLE resources (
+    id            TEXT PRIMARY KEY,        -- 层级ID,如 "tools/selenium/login"
+    title         TEXT DEFAULT '',
+    body          TEXT NOT NULL,           -- 公开内容
+    secure_body   TEXT DEFAULT '',         -- 敏感内容(加密存储)
+    content_type  TEXT DEFAULT 'text',     -- text|code|credential|cookie
+    metadata      TEXT DEFAULT '{}',       -- JSON: {language, acquired_at, expires_at}
+    sort_order    INTEGER DEFAULT 0,
+    submitted_by  TEXT DEFAULT '',
+    created_at    TEXT NOT NULL,
+    updated_at    TEXT DEFAULT ''
+)
+```
+
+### 字段说明
+
+- **id**: 层级标识符,支持路径格式(如 `tools/selenium/login`)
+- **title**: 资源标题
+- **body**: 公开内容(明文存储,可搜索)
+- **secure_body**: 敏感内容(加密存储,需要组织密钥访问)
+- **content_type**: 内容类型
+  - `text`: 普通文本
+  - `code`: 代码片段
+  - `credential`: 账号密码
+  - `cookie`: Cookie数据
+- **metadata**: 元数据(JSON对象)
+  - `language`: 代码语言(当 content_type=code)
+  - `acquired_at`: 获取时间(ISO 8601格式)
+  - `expires_at`: 过期时间(用于cookie)
+- **sort_order**: 排序顺序(同级内容排序)
+- **submitted_by**: 提交者标识
+- **created_at**: 创建时间
+- **updated_at**: 更新时间
+
+---
+
+## 加密机制
+
+### 组织密钥
+
+敏感内容使用 AES-256-GCM 加密,密钥从环境变量读取:
+
+```bash
+# .env
+ORG_KEYS=org1:key1_base64,org2:key2_base64
+```
+
+每个 content 的 `id` 前缀决定使用哪个组织密钥(如 `org1/tools/...` 使用 `org1` 的密钥)。
+
+### 加密存储格式
+
+```
+encrypted:AES256-GCM:{base64_encoded_data}
+```
+
+实现位置:`knowhub/server.py:encrypt_resource`, `decrypt_resource`
+
+### 访问控制
+
+读取包含 `secure_body` 的 content 时:
+- 需要提供 `X-Org-Key` HTTP 头
+- 验证通过后解密返回
+- 验证失败返回 `[ENCRYPTED]` 占位符
+
+---
+
+## API 端点
+
+### `POST /api/content`
+
+提交新资源。
+
+**请求体**:
+
+```json
+{
+  "id": "tools/selenium/login",
+  "title": "Selenium登录代码",
+  "body": "使用undetected_chromedriver绕过检测",
+  "secure_body": "账号:xxx 密码:xxx",
+  "content_type": "credential",
+  "metadata": {
+    "acquired_at": "2026-03-06T10:00:00Z"
+  },
+  "submitted_by": "user@example.com"
+}
+```
+
+实现位置:`knowhub/server.py:submit_resource`
+
+### `GET /api/content/{resource_id}`
+
+获取资源(支持层级路径)。
+
+**请求头**(可选):
+```
+X-Org-Key: your_org_key_here
+```
+
+**响应**:
+
+```json
+{
+  "id": "tools/selenium/login",
+  "title": "Selenium登录代码",
+  "body": "使用undetected_chromedriver绕过检测",
+  "secure_body": "账号:xxx 密码:xxx",  // 有密钥时解密
+  "content_type": "credential",
+  "metadata": {
+    "acquired_at": "2026-03-06T10:00:00Z"
+  },
+  "toc": {"id": "tools", "title": "工具集"},
+  "children": [
+    {"id": "tools/selenium/login", "title": "登录代码"}
+  ]
+}
+```
+
+实现位置:`knowhub/server.py:get_resource`
+
+### `PATCH /api/content/{resource_id}`
+
+更新资源字段。
+
+**请求体**(所有字段可选):
+
+```json
+{
+  "title": "新标题",
+  "body": "新的公开内容",
+  "secure_body": "新的敏感内容",
+  "metadata": {
+    "expires_at": "2026-03-07T10:00:00Z"
+  }
+}
+```
+
+实现位置:`knowhub/server.py:patch_resource`
+
+### `GET /api/content`
+
+列出所有资源(支持过滤)。
+
+**参数**:
+- `content_type`: 按类型过滤(可选)
+- `limit`: 返回数量(默认100)
+
+实现位置:`knowhub/server.py:list_resources`
+
+---
+
+## 使用场景
+
+### 场景1:存储复杂代码
+
+```json
+{
+  "id": "code/selenium/undetected",
+  "title": "Selenium绕过检测",
+  "body": "import undetected_chromedriver as uc\n\ndriver = uc.Chrome()\n...",
+  "content_type": "code",
+  "metadata": {
+    "language": "python"
+  }
+}
+```
+
+### 场景2:存储账号密码
+
+```json
+{
+  "id": "credentials/某网站",
+  "title": "某网站登录凭证",
+  "body": "使用方法:直接登录即可",
+  "secure_body": "账号:user@example.com\n密码:SecurePass123",
+  "content_type": "credential",
+  "metadata": {
+    "acquired_at": "2026-03-06T10:00:00Z"
+  }
+}
+```
+
+### 场景3:存储Cookie
+
+```json
+{
+  "id": "cookies/某网站",
+  "title": "某网站Cookie",
+  "body": "适用于:已登录状态的API调用",
+  "secure_body": "session_id=abc123; auth_token=xyz789",
+  "content_type": "cookie",
+  "metadata": {
+    "acquired_at": "2026-03-06T10:00:00Z",
+    "expires_at": "2026-03-07T10:00:00Z"
+  }
+}
+```
+
+### 场景4:Knowledge引用Content
+
+```json
+{
+  "task": "登录某网站",
+  "content": "使用Selenium + undetected_chromedriver绕过检测",
+  "resource_id": "code/selenium/undetected",
+  "secure_resource_id": "credentials/某网站"
+}
+```
+
+---
+
+## 层级结构
+
+Content 通过 ID 路径实现树形组织:
+
+```
+tools/
+├── selenium/
+│   ├── login
+│   └── scraping
+└── api/
+    └── requests
+
+credentials/
+├── 网站A
+└── 网站B
+
+cookies/
+└── 网站A
+```
+
+获取 `tools/selenium/login` 时自动计算:
+- **TOC**: 根节点 `tools`
+- **Children**: 子节点列表
+- **Prev/Next**: 同级节点导航
+
+---
+
+## 安全考虑
+
+1. **加密存储**:敏感内容使用 AES-256-GCM 加密
+2. **访问控制**:需要组织密钥才能解密
+3. **密钥管理**:密钥存储在环境变量,不入库
+4. **审计日志**:记录 `submitted_by` 和时间戳
+5. **过期提醒**:Cookie 可设置 `expires_at`
+
+---
+
+## 与 Knowledge 的关系
+
+| 维度 | Knowledge | Content |
+|------|-----------|---------|
+| 用途 | 结构化知识(经验、策略) | 原始资源(代码、凭证) |
+| 存储 | task + content 字段 | body + secure_body 字段 |
+| 搜索 | 语义搜索 + 质量排序 | 层级浏览 |
+| 引用 | 可引用 resource_id | 被 knowledge 引用 |
+| 加密 | 不加密(或整体加密) | 分离公开/敏感内容 |
+
+---
+
+## 实现位置
+
+| 组件 | 文件路径 |
+|------|---------|
+| 数据库表 | `knowhub/server.py:init_db` |
+| 加密/解密 | `knowhub/server.py:encrypt_resource`, `decrypt_resource` |
+| POST /api/content | `knowhub/server.py:submit_resource` |
+| GET /api/content/{id} | `knowhub/server.py:get_resource` |
+| PATCH /api/content/{id} | `knowhub/server.py:patch_resource` |
+| GET /api/content | `knowhub/server.py:list_resources` |

+ 56 - 0
knowhub/migrate_contents.py

@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+"""
+数据库迁移脚本:为contents表添加新字段
+"""
+
+import sqlite3
+from pathlib import Path
+
+DB_PATH = Path(__file__).parent / "knowhub.db"
+
+def migrate():
+    print(f"数据库路径: {DB_PATH}")
+
+    if not DB_PATH.exists():
+        print("数据库不存在,无需迁移")
+        return
+
+    conn = sqlite3.connect(str(DB_PATH))
+    cursor = conn.cursor()
+
+    # 检查是否已有新字段
+    cursor.execute("PRAGMA table_info(contents)")
+    columns = {row[1] for row in cursor.fetchall()}
+    print(f"现有字段: {columns}")
+
+    migrations = []
+
+    if "secure_body" not in columns:
+        migrations.append("ALTER TABLE contents ADD COLUMN secure_body TEXT DEFAULT ''")
+
+    if "content_type" not in columns:
+        migrations.append("ALTER TABLE contents ADD COLUMN content_type TEXT DEFAULT 'text'")
+
+    if "metadata" not in columns:
+        migrations.append("ALTER TABLE contents ADD COLUMN metadata TEXT DEFAULT '{}'")
+
+    if "updated_at" not in columns:
+        migrations.append("ALTER TABLE contents ADD COLUMN updated_at TEXT DEFAULT ''")
+
+    if not migrations:
+        print("✅ 数据库已是最新版本,无需迁移")
+        conn.close()
+        return
+
+    print(f"执行 {len(migrations)} 个迁移...")
+    for sql in migrations:
+        print(f"  {sql}")
+        cursor.execute(sql)
+
+    conn.commit()
+    conn.close()
+    print("✅ 迁移完成")
+
+
+if __name__ == "__main__":
+    migrate()

+ 262 - 41
knowhub/server.py

@@ -10,12 +10,14 @@ import re
 import json
 import json
 import sqlite3
 import sqlite3
 import asyncio
 import asyncio
+import base64
 from contextlib import asynccontextmanager
 from contextlib import asynccontextmanager
 from datetime import datetime, timezone
 from datetime import datetime, timezone
 from typing import Optional
 from typing import Optional
 from pathlib import Path
 from pathlib import Path
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
 
 
-from fastapi import FastAPI, HTTPException, Query
+from fastapi import FastAPI, HTTPException, Query, Header
 from fastapi.responses import HTMLResponse
 from fastapi.responses import HTMLResponse
 from pydantic import BaseModel, Field
 from pydantic import BaseModel, Field
 
 
@@ -33,6 +35,15 @@ BRAND_NAME    = os.getenv("BRAND_NAME", "KnowHub")
 BRAND_API_ENV = os.getenv("BRAND_API_ENV", "KNOWHUB_API")
 BRAND_API_ENV = os.getenv("BRAND_API_ENV", "KNOWHUB_API")
 BRAND_DB      = os.getenv("BRAND_DB", "knowhub.db")
 BRAND_DB      = os.getenv("BRAND_DB", "knowhub.db")
 
 
+# 组织密钥配置(格式:org1:key1_base64,org2:key2_base64)
+ORG_KEYS_RAW = os.getenv("ORG_KEYS", "")
+ORG_KEYS = {}
+if ORG_KEYS_RAW:
+    for pair in ORG_KEYS_RAW.split(","):
+        if ":" in pair:
+            org, key_b64 = pair.split(":", 1)
+            ORG_KEYS[org.strip()] = key_b64.strip()
+
 DB_PATH = Path(__file__).parent / BRAND_DB
 DB_PATH = Path(__file__).parent / BRAND_DB
 
 
 # --- 数据库 ---
 # --- 数据库 ---
@@ -44,6 +55,77 @@ def get_db() -> sqlite3.Connection:
     return conn
     return conn
 
 
 
 
+# --- 加密/解密 ---
+
+def get_org_key(resource_id: str) -> Optional[bytes]:
+    """从content_id提取组织前缀,返回对应密钥"""
+    if "/" in resource_id:
+        org = resource_id.split("/")[0]
+        if org in ORG_KEYS:
+            return base64.b64decode(ORG_KEYS[org])
+    return None
+
+
+def encrypt_content(resource_id: str, plaintext: str) -> str:
+    """加密内容,返回格式:encrypted:AES256-GCM:{base64_data}"""
+    if not plaintext:
+        return ""
+
+    key = get_org_key(resource_id)
+    if not key:
+        # 没有配置密钥,明文存储(不推荐)
+        return plaintext
+
+    aesgcm = AESGCM(key)
+    nonce = os.urandom(12)  # 96-bit nonce
+    ciphertext = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
+
+    # 组合 nonce + ciphertext
+    encrypted_data = nonce + ciphertext
+    encoded = base64.b64encode(encrypted_data).decode("ascii")
+
+    return f"encrypted:AES256-GCM:{encoded}"
+
+
+def decrypt_content(resource_id: str, encrypted_text: str, provided_key: Optional[str] = None) -> str:
+    """解密内容,如果没有提供密钥或密钥错误,返回[ENCRYPTED]"""
+    if not encrypted_text:
+        return ""
+
+    if not encrypted_text.startswith("encrypted:AES256-GCM:"):
+        # 未加密的内容,直接返回
+        return encrypted_text
+
+    # 提取加密数据
+    encoded = encrypted_text.split(":", 2)[2]
+    encrypted_data = base64.b64decode(encoded)
+
+    nonce = encrypted_data[:12]
+    ciphertext = encrypted_data[12:]
+
+    # 获取密钥
+    key = None
+    if provided_key:
+        # 使用提供的密钥
+        try:
+            key = base64.b64decode(provided_key)
+        except Exception:
+            return "[ENCRYPTED]"
+    else:
+        # 从配置中获取
+        key = get_org_key(resource_id)
+
+    if not key:
+        return "[ENCRYPTED]"
+
+    try:
+        aesgcm = AESGCM(key)
+        plaintext = aesgcm.decrypt(nonce, ciphertext, None)
+        return plaintext.decode("utf-8")
+    except Exception:
+        return "[ENCRYPTED]"
+
+
 def init_db():
 def init_db():
     conn = get_db()
     conn = get_db()
     conn.execute("""
     conn.execute("""
@@ -64,13 +146,17 @@ def init_db():
     conn.execute("CREATE INDEX IF NOT EXISTS idx_name ON experiences(name)")
     conn.execute("CREATE INDEX IF NOT EXISTS idx_name ON experiences(name)")
 
 
     conn.execute("""
     conn.execute("""
-        CREATE TABLE IF NOT EXISTS contents (
+        CREATE TABLE IF NOT EXISTS resources (
             id            TEXT PRIMARY KEY,
             id            TEXT PRIMARY KEY,
             title         TEXT DEFAULT '',
             title         TEXT DEFAULT '',
             body          TEXT NOT NULL,
             body          TEXT NOT NULL,
+            secure_body   TEXT DEFAULT '',
+            content_type  TEXT DEFAULT 'text',
+            metadata      TEXT DEFAULT '{}',
             sort_order    INTEGER DEFAULT 0,
             sort_order    INTEGER DEFAULT 0,
             submitted_by  TEXT DEFAULT '',
             submitted_by  TEXT DEFAULT '',
-            created_at    TEXT NOT NULL
+            created_at    TEXT NOT NULL,
+            updated_at    TEXT DEFAULT ''
         )
         )
     """)
     """)
 
 
@@ -84,6 +170,7 @@ def init_db():
             scopes        TEXT DEFAULT '["org:cybertogether"]',  -- JSON array
             scopes        TEXT DEFAULT '["org:cybertogether"]',  -- JSON array
             owner         TEXT DEFAULT '',
             owner         TEXT DEFAULT '',
             content       TEXT NOT NULL,
             content       TEXT NOT NULL,
+            resource_ids  TEXT DEFAULT '[]',          -- JSON array: ["code/selenium/login", "credentials/website"]
             source        TEXT DEFAULT '{}',          -- JSON object: {name, category, urls, agent_id, submitted_by, timestamp}
             source        TEXT DEFAULT '{}',          -- JSON object: {name, category, urls, agent_id, submitted_by, timestamp}
             eval          TEXT DEFAULT '{}',          -- JSON object: {score, helpful, harmful, confidence, histories}
             eval          TEXT DEFAULT '{}',          -- JSON object: {score, helpful, harmful, confidence, histories}
             created_at    TEXT NOT NULL,
             created_at    TEXT NOT NULL,
@@ -101,14 +188,26 @@ def init_db():
 
 
 # --- Models ---
 # --- Models ---
 
 
-class ContentIn(BaseModel):
+class ResourceIn(BaseModel):
     id: str
     id: str
     title: str = ""
     title: str = ""
     body: str
     body: str
+    secure_body: str = ""
+    content_type: str = "text"  # text|code|credential|cookie
+    metadata: dict = {}
     sort_order: int = 0
     sort_order: int = 0
     submitted_by: str = ""
     submitted_by: str = ""
 
 
 
 
+class ResourcePatchIn(BaseModel):
+    """PATCH /api/resource/{id} 请求体"""
+    title: Optional[str] = None
+    body: Optional[str] = None
+    secure_body: Optional[str] = None
+    content_type: Optional[str] = None
+    metadata: Optional[dict] = None
+
+
 # Knowledge Models
 # Knowledge Models
 class KnowledgeIn(BaseModel):
 class KnowledgeIn(BaseModel):
     task: str
     task: str
@@ -118,6 +217,7 @@ class KnowledgeIn(BaseModel):
     scopes: list[str] = ["org:cybertogether"]
     scopes: list[str] = ["org:cybertogether"]
     owner: str = ""
     owner: str = ""
     message_id: str = ""
     message_id: str = ""
+    resource_ids: list[str] = []
     source: dict = {}  # {name, category, urls, agent_id, submitted_by, timestamp}
     source: dict = {}  # {name, category, urls, agent_id, submitted_by, timestamp}
     eval: dict = {}    # {score, helpful, harmful, confidence}
     eval: dict = {}    # {score, helpful, harmful, confidence}
 
 
@@ -131,6 +231,7 @@ class KnowledgeOut(BaseModel):
     scopes: list[str]
     scopes: list[str]
     owner: str
     owner: str
     content: str
     content: str
+    resource_ids: list[str]
     source: dict
     source: dict
     eval: dict
     eval: dict
     created_at: str
     created_at: str
@@ -171,19 +272,22 @@ class KnowledgeSearchResponse(BaseModel):
     count: int
     count: int
 
 
 
 
-class ContentNode(BaseModel):
+class ResourceNode(BaseModel):
     id: str
     id: str
     title: str
     title: str
 
 
 
 
-class ContentOut(BaseModel):
+class ResourceOut(BaseModel):
     id: str
     id: str
     title: str
     title: str
     body: str
     body: str
-    toc: Optional[ContentNode] = None
-    children: list[ContentNode]
-    prev: Optional[ContentNode] = None
-    next: Optional[ContentNode] = None
+    secure_body: str = ""
+    content_type: str = "text"
+    metadata: dict = {}
+    toc: Optional[ResourceNode] = None
+    children: list[ResourceNode]
+    prev: Optional[ResourceNode] = None
+    next: Optional[ResourceNode] = None
 
 
 
 
 # --- App ---
 # --- App ---
@@ -199,75 +303,99 @@ app = FastAPI(title=BRAND_NAME, lifespan=lifespan)
 
 
 # --- Knowledge API ---
 # --- Knowledge API ---
 
 
-@app.post("/api/content", status_code=201)
-def submit_content(content: ContentIn):
+@app.post("/api/resource", status_code=201)
+def submit_resource(resource: ResourceIn):
     conn = get_db()
     conn = get_db()
     try:
     try:
         now = datetime.now(timezone.utc).isoformat()
         now = datetime.now(timezone.utc).isoformat()
+
+        # 加密敏感内容
+        encrypted_secure_body = encrypt_content(resource.id, resource.secure_body)
+
         conn.execute(
         conn.execute(
-            "INSERT OR REPLACE INTO contents"
-            "(id, title, body, sort_order, submitted_by, created_at)"
-            " VALUES (?, ?, ?, ?, ?, ?)",
-            (content.id, content.title, content.body, content.sort_order, content.submitted_by, now),
+            "INSERT OR REPLACE INTO resources"
+            "(id, title, body, secure_body, content_type, metadata, sort_order, submitted_by, created_at, updated_at)"
+            " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+            (
+                resource.id,
+                resource.title,
+                resource.body,
+                encrypted_secure_body,
+                resource.content_type,
+                json.dumps(resource.metadata),
+                resource.sort_order,
+                resource.submitted_by,
+                now,
+                now,
+            ),
         )
         )
         conn.commit()
         conn.commit()
-        return {"status": "ok"}
+        return {"status": "ok", "id": resource.id}
     finally:
     finally:
         conn.close()
         conn.close()
 
 
 
 
-@app.get("/api/content/{content_id:path}", response_model=ContentOut)
-def get_content(content_id: str):
+@app.get("/api/resource/{resource_id:path}", response_model=ResourceOut)
+def get_resource(resource_id: str, x_org_key: Optional[str] = Header(None)):
     conn = get_db()
     conn = get_db()
     try:
     try:
         row = conn.execute(
         row = conn.execute(
-            "SELECT id, title, body, sort_order FROM contents WHERE id = ?",
-            (content_id,),
+            "SELECT id, title, body, secure_body, content_type, metadata, sort_order FROM resources WHERE id = ?",
+            (resource_id,),
         ).fetchone()
         ).fetchone()
         if not row:
         if not row:
-            raise HTTPException(status_code=404, detail=f"Content not found: {content_id}")
+            raise HTTPException(status_code=404, detail=f"Resource not found: {resource_id}")
+
+        # 解密敏感内容
+        secure_body = decrypt_content(resource_id, row["secure_body"] or "", x_org_key)
+
+        # 解析metadata
+        metadata = json.loads(row["metadata"] or "{}")
 
 
         # 计算导航上下文
         # 计算导航上下文
-        root_id = content_id.split("/")[0] if "/" in content_id else content_id
+        root_id = resource_id.split("/")[0] if "/" in resource_id else resource_id
 
 
         # TOC (根节点)
         # TOC (根节点)
         toc = None
         toc = None
-        if "/" in content_id:
+        if "/" in resource_id:
             toc_row = conn.execute(
             toc_row = conn.execute(
-                "SELECT id, title FROM contents WHERE id = ?",
+                "SELECT id, title FROM resources WHERE id = ?",
                 (root_id,),
                 (root_id,),
             ).fetchone()
             ).fetchone()
             if toc_row:
             if toc_row:
-                toc = ContentNode(id=toc_row["id"], title=toc_row["title"])
+                toc = ResourceNode(id=toc_row["id"], title=toc_row["title"])
 
 
         # Children (子节点)
         # Children (子节点)
         children = []
         children = []
         children_rows = conn.execute(
         children_rows = conn.execute(
-            "SELECT id, title FROM contents WHERE id LIKE ? AND id != ? ORDER BY sort_order",
-            (f"{content_id}/%", content_id),
+            "SELECT id, title FROM resources WHERE id LIKE ? AND id != ? ORDER BY sort_order",
+            (f"{resource_id}/%", resource_id),
         ).fetchall()
         ).fetchall()
-        children = [ContentNode(id=r["id"], title=r["title"]) for r in children_rows]
+        children = [ResourceNode(id=r["id"], title=r["title"]) for r in children_rows]
 
 
         # Prev/Next (同级节点)
         # Prev/Next (同级节点)
         prev_node = None
         prev_node = None
         next_node = None
         next_node = None
-        if "/" in content_id:
+        if "/" in resource_id:
             siblings = conn.execute(
             siblings = conn.execute(
-                "SELECT id, title, sort_order FROM contents WHERE id LIKE ? AND id NOT LIKE ? ORDER BY sort_order",
+                "SELECT id, title, sort_order FROM resources WHERE id LIKE ? AND id NOT LIKE ? ORDER BY sort_order",
                 (f"{root_id}/%", f"{root_id}/%/%"),
                 (f"{root_id}/%", f"{root_id}/%/%"),
             ).fetchall()
             ).fetchall()
             for i, sib in enumerate(siblings):
             for i, sib in enumerate(siblings):
-                if sib["id"] == content_id:
+                if sib["id"] == resource_id:
                     if i > 0:
                     if i > 0:
-                        prev_node = ContentNode(id=siblings[i-1]["id"], title=siblings[i-1]["title"])
+                        prev_node = ResourceNode(id=siblings[i-1]["id"], title=siblings[i-1]["title"])
                     if i < len(siblings) - 1:
                     if i < len(siblings) - 1:
-                        next_node = ContentNode(id=siblings[i+1]["id"], title=siblings[i+1]["title"])
+                        next_node = ResourceNode(id=siblings[i+1]["id"], title=siblings[i+1]["title"])
                     break
                     break
 
 
-        return ContentOut(
+        return ResourceOut(
             id=row["id"],
             id=row["id"],
             title=row["title"],
             title=row["title"],
             body=row["body"],
             body=row["body"],
+            secure_body=secure_body,
+            content_type=row["content_type"],
+            metadata=metadata,
             toc=toc,
             toc=toc,
             children=children,
             children=children,
             prev=prev_node,
             prev=prev_node,
@@ -277,6 +405,97 @@ def get_content(content_id: str):
         conn.close()
         conn.close()
 
 
 
 
+@app.patch("/api/resource/{resource_id:path}")
+def patch_resource(resource_id: str, patch: ResourcePatchIn):
+    """更新resource字段"""
+    conn = get_db()
+    try:
+        # 检查是否存在
+        row = conn.execute("SELECT id FROM resources WHERE id = ?", (resource_id,)).fetchone()
+        if not row:
+            raise HTTPException(status_code=404, detail=f"Resource not found: {resource_id}")
+
+        # 构建更新语句
+        updates = []
+        params = []
+
+        if patch.title is not None:
+            updates.append("title = ?")
+            params.append(patch.title)
+
+        if patch.body is not None:
+            updates.append("body = ?")
+            params.append(patch.body)
+
+        if patch.secure_body is not None:
+            encrypted = encrypt_content(resource_id, patch.secure_body)
+            updates.append("secure_body = ?")
+            params.append(encrypted)
+
+        if patch.content_type is not None:
+            updates.append("content_type = ?")
+            params.append(patch.content_type)
+
+        if patch.metadata is not None:
+            updates.append("metadata = ?")
+            params.append(json.dumps(patch.metadata))
+
+        if not updates:
+            return {"status": "ok", "message": "No fields to update"}
+
+        # 添加updated_at
+        updates.append("updated_at = ?")
+        params.append(datetime.now(timezone.utc).isoformat())
+
+        # 执行更新
+        params.append(resource_id)
+        sql = f"UPDATE resources SET {', '.join(updates)} WHERE id = ?"
+        conn.execute(sql, params)
+        conn.commit()
+
+        return {"status": "ok", "id": resource_id}
+    finally:
+        conn.close()
+
+
+@app.get("/api/resource")
+def list_resources(
+    content_type: Optional[str] = Query(None),
+    limit: int = Query(100, ge=1, le=1000)
+):
+    """列出所有resource"""
+    conn = get_db()
+    try:
+        sql = "SELECT id, title, content_type, metadata, created_at FROM resources"
+        params = []
+
+        if content_type:
+            sql += " WHERE content_type = ?"
+            params.append(content_type)
+
+        sql += " ORDER BY id LIMIT ?"
+        params.append(limit)
+
+        rows = conn.execute(sql, params).fetchall()
+
+        results = []
+        for row in rows:
+            results.append({
+                "id": row["id"],
+                "title": row["title"],
+                "content_type": row["content_type"],
+                "metadata": json.loads(row["metadata"] or "{}"),
+                "created_at": row["created_at"],
+            })
+
+        return {"results": results, "count": len(results)}
+    finally:
+        conn.close()
+
+
+# --- Knowledge API ---
+
+
 # ===== Knowledge API =====
 # ===== Knowledge API =====
 
 
 # 两阶段检索逻辑
 # 两阶段检索逻辑
@@ -313,7 +532,7 @@ async def _route_knowledge_by_llm(query_text: str, metadata_list: list[dict], k:
 
 
         response = await openrouter_llm_call(
         response = await openrouter_llm_call(
             messages=[{"role": "user", "content": prompt}],
             messages=[{"role": "user", "content": prompt}],
-            model="google/gemini-2.0-flash-001"
+            model="google/gemini-2.5-flash-lite"
         )
         )
 
 
         content = response.get("content", "").strip()
         content = response.get("content", "").strip()
@@ -521,8 +740,8 @@ def save_knowledge(knowledge: KnowledgeIn):
         conn.execute(
         conn.execute(
             """INSERT INTO knowledge
             """INSERT INTO knowledge
             (id, message_id, types, task, tags, scopes, owner, content,
             (id, message_id, types, task, tags, scopes, owner, content,
-             source, eval, created_at, updated_at)
-            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+             resource_ids, source, eval, created_at, updated_at)
+            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
             (
             (
                 knowledge_id,
                 knowledge_id,
                 knowledge.message_id,
                 knowledge.message_id,
@@ -532,6 +751,7 @@ def save_knowledge(knowledge: KnowledgeIn):
                 json.dumps(knowledge.scopes),
                 json.dumps(knowledge.scopes),
                 owner,
                 owner,
                 knowledge.content,
                 knowledge.content,
+                json.dumps(knowledge.resource_ids),
                 json.dumps(source),
                 json.dumps(source),
                 json.dumps(eval_data),
                 json.dumps(eval_data),
                 now,
                 now,
@@ -655,6 +875,7 @@ def get_knowledge(knowledge_id: str):
             "scopes": json.loads(row["scopes"]),
             "scopes": json.loads(row["scopes"]),
             "owner": row["owner"],
             "owner": row["owner"],
             "content": row["content"],
             "content": row["content"],
+            "resource_ids": json.loads(row["resource_ids"]),
             "source": json.loads(row["source"]),
             "source": json.loads(row["source"]),
             "eval": json.loads(row["eval"]),
             "eval": json.loads(row["eval"]),
             "created_at": row["created_at"],
             "created_at": row["created_at"],
@@ -683,7 +904,7 @@ async def _evolve_knowledge_with_llm(old_content: str, feedback: str) -> str:
     try:
     try:
         response = await openrouter_llm_call(
         response = await openrouter_llm_call(
             messages=[{"role": "user", "content": prompt}],
             messages=[{"role": "user", "content": prompt}],
-            model="google/gemini-2.0-flash-001"
+            model="google/gemini-2.5-flash-lite"
         )
         )
         evolved = response.get("content", "").strip()
         evolved = response.get("content", "").strip()
         if len(evolved) < 5:
         if len(evolved) < 5:
@@ -854,7 +1075,7 @@ async def batch_update_knowledge(batch: KnowledgeBatchUpdateIn):
 
 
 
 
 @app.post("/api/knowledge/slim")
 @app.post("/api/knowledge/slim")
-async def slim_knowledge(model: str = "google/gemini-2.0-flash-001"):
+async def slim_knowledge(model: str = "google/gemini-2.5-flash-lite"):
     """知识库瘦身:合并语义相似知识"""
     """知识库瘦身:合并语义相似知识"""
     conn = get_db()
     conn = get_db()
     try:
     try:
@@ -1084,7 +1305,7 @@ async def extract_knowledge_from_messages(extract_req: MessageExtractIn):
 
 
         response = await openrouter_llm_call(
         response = await openrouter_llm_call(
             messages=[{"role": "user", "content": prompt}],
             messages=[{"role": "user", "content": prompt}],
-            model="google/gemini-2.0-flash-001"
+            model="google/gemini-2.5-flash-lite"
         )
         )
 
 
         content = response.get("content", "").strip()
         content = response.get("content", "").strip()

+ 204 - 0
migrate_to_resource.py

@@ -0,0 +1,204 @@
+#!/usr/bin/env python3
+"""
+安全迁移脚本:contents → resources + knowledge.resource_ids
+在服务器上执行此脚本,然后再拉取新代码
+
+变更内容:
+1. contents表 → resources表
+2. 为resources表添加新字段(secure_body, content_type, metadata, updated_at)
+3. 为knowledge表添加resource_ids字段
+"""
+
+import sqlite3
+import sys
+from pathlib import Path
+
+def migrate_resources_table(conn, cursor):
+    """迁移resources表(原contents表)"""
+    print("\n" + "="*60)
+    print("步骤1: 迁移 resources 表")
+    print("="*60)
+
+    # 检查contents表是否存在
+    cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='contents'")
+    if not cursor.fetchone():
+        cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='resources'")
+        if cursor.fetchone():
+            print("✅ resources表已存在,跳过此步骤")
+            return True
+        else:
+            print("⚠️  既没有contents表也没有resources表,将在首次启动时创建")
+            return False
+
+    # 检查contents表中的数据
+    cursor.execute("SELECT COUNT(*) FROM contents")
+    count = cursor.fetchone()[0]
+    print(f"contents表中有 {count} 条记录")
+
+    # 检查是否已有新字段
+    cursor.execute("PRAGMA table_info(contents)")
+    columns = {row[1] for row in cursor.fetchall()}
+    print(f"现有字段: {columns}")
+
+    # 添加新字段(如果不存在)
+    migrations = []
+    if "secure_body" not in columns:
+        migrations.append("ALTER TABLE contents ADD COLUMN secure_body TEXT DEFAULT ''")
+    if "content_type" not in columns:
+        migrations.append("ALTER TABLE contents ADD COLUMN content_type TEXT DEFAULT 'text'")
+    if "metadata" not in columns:
+        migrations.append("ALTER TABLE contents ADD COLUMN metadata TEXT DEFAULT '{}'")
+    if "updated_at" not in columns:
+        migrations.append("ALTER TABLE contents ADD COLUMN updated_at TEXT DEFAULT ''")
+
+    if migrations:
+        print(f"\n添加 {len(migrations)} 个新字段...")
+        for sql in migrations:
+            print(f"  {sql}")
+            cursor.execute(sql)
+        conn.commit()
+        print("✅ 字段添加完成")
+
+    # 重命名表
+    print("\n重命名表: contents → resources")
+    try:
+        cursor.execute("ALTER TABLE contents RENAME TO resources")
+        conn.commit()
+        print("✅ 表重命名完成")
+    except Exception as e:
+        print(f"❌ 重命名失败: {e}")
+        return False
+
+    # 验证
+    cursor.execute("SELECT COUNT(*) FROM resources")
+    new_count = cursor.fetchone()[0]
+    print(f"resources表中有 {new_count} 条记录")
+
+    if new_count == count:
+        print(f"✅ 数据完整,{count} 条记录全部保留")
+        return True
+    else:
+        print(f"⚠️  数据不一致: 原 {count} 条 → 现 {new_count} 条")
+        return False
+
+
+def migrate_knowledge_table(conn, cursor):
+    """为knowledge表添加resource_ids字段"""
+    print("\n" + "="*60)
+    print("步骤2: 更新 knowledge 表")
+    print("="*60)
+
+    # 检查knowledge表是否存在
+    cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='knowledge'")
+    if not cursor.fetchone():
+        print("⚠️  knowledge表不存在,跳过此步骤")
+        return True
+
+    # 检查是否已有resource_ids字段
+    cursor.execute("PRAGMA table_info(knowledge)")
+    columns = {row[1] for row in cursor.fetchall()}
+    print(f"现有字段: {columns}")
+
+    if "resource_ids" in columns:
+        print("✅ resource_ids字段已存在,跳过此步骤")
+        return True
+
+    # 添加resource_ids字段
+    print("\n添加 resource_ids 字段...")
+    try:
+        cursor.execute("ALTER TABLE knowledge ADD COLUMN resource_ids TEXT DEFAULT '[]'")
+        conn.commit()
+        print("✅ resource_ids字段添加完成")
+        return True
+    except Exception as e:
+        print(f"❌ 添加字段失败: {e}")
+        return False
+
+
+def migrate():
+    """主迁移函数"""
+def migrate():
+    """主迁移函数"""
+    # 查找数据库文件
+    db_path = Path("knowhub.db")
+    if not db_path.exists():
+        print("❌ 找不到 knowhub.db")
+        print("请在包含数据库的目录中运行此脚本")
+        sys.exit(1)
+
+    print(f"数据库路径: {db_path.absolute()}")
+
+    conn = sqlite3.connect(str(db_path))
+    cursor = conn.cursor()
+
+    # 显示当前表状态
+    cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
+    tables = {row[0] for row in cursor.fetchall()}
+    print(f"\n当前表: {tables}")
+
+    # 执行迁移
+    success = True
+
+    # 步骤1: 迁移resources表
+    if not migrate_resources_table(conn, cursor):
+        success = False
+
+    # 步骤2: 更新knowledge表
+    if not migrate_knowledge_table(conn, cursor):
+        success = False
+
+    # 最终验证
+    print("\n" + "="*60)
+    print("迁移总结")
+    print("="*60)
+
+    cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
+    tables = {row[0] for row in cursor.fetchall()}
+    print(f"迁移后的表: {tables}")
+
+    # 检查resources表
+    if "resources" in tables:
+        cursor.execute("SELECT COUNT(*) FROM resources")
+        count = cursor.fetchone()[0]
+        print(f"✅ resources表: {count} 条记录")
+    else:
+        print("⚠️  resources表不存在")
+
+    # 检查knowledge表
+    if "knowledge" in tables:
+        cursor.execute("SELECT COUNT(*) FROM knowledge")
+        count = cursor.fetchone()[0]
+        cursor.execute("PRAGMA table_info(knowledge)")
+        columns = {row[1] for row in cursor.fetchall()}
+        has_resource_ids = "resource_ids" in columns
+        print(f"✅ knowledge表: {count} 条记录, resource_ids字段: {'存在' if has_resource_ids else '不存在'}")
+    else:
+        print("⚠️  knowledge表不存在")
+
+    conn.close()
+
+    if success:
+        print("\n✅ 迁移完成!现在可以拉取新代码并重启服务")
+    else:
+        print("\n⚠️  迁移过程中有警告,请检查上述输出")
+
+    return success
+
+
+if __name__ == "__main__":
+    print("=" * 60)
+    print("KnowHub 数据库迁移")
+    print("变更内容:")
+    print("  1. contents表 → resources表")
+    print("  2. resources表添加新字段")
+    print("  3. knowledge表添加resource_ids字段")
+    print("=" * 60)
+
+    try:
+        success = migrate()
+        sys.exit(0 if success else 1)
+    except Exception as e:
+        print(f"\n❌ 迁移失败: {e}")
+        import traceback
+        traceback.print_exc()
+        sys.exit(1)

+ 35 - 0
rename_to_resource.py

@@ -0,0 +1,35 @@
+#!/usr/bin/env python3
+"""
+重命名:contents → resources
+"""
+
+import sqlite3
+from pathlib import Path
+
+DB_PATH = Path(__file__).parent / "knowhub.db"
+
+def rename_table():
+    if not DB_PATH.exists():
+        print("数据库不存在")
+        return
+
+    conn = sqlite3.connect(str(DB_PATH))
+    cursor = conn.cursor()
+
+    # 检查表是否存在
+    cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='contents'")
+    if not cursor.fetchone():
+        print("contents表不存在,可能已经重命名")
+        conn.close()
+        return
+
+    print("重命名表:contents → resources")
+    cursor.execute("ALTER TABLE contents RENAME TO resources")
+
+    conn.commit()
+    conn.close()
+    print("✅ 重命名完成")
+
+
+if __name__ == "__main__":
+    rename_table()

+ 143 - 0
test_resource_storage.py

@@ -0,0 +1,143 @@
+#!/usr/bin/env python3
+"""测试Resource存储系统"""
+
+import requests
+import base64
+import os
+
+BASE_URL = "http://localhost:8000"
+
+# 生成测试密钥
+test_key = base64.b64encode(os.urandom(32)).decode('ascii')
+print(f"测试密钥: {test_key}")
+print(f"请在.env中设置: ORG_KEYS=test:{test_key}")
+print()
+
+def test_submit_resource():
+    """测试提交resource"""
+    print("=== 测试1: 提交普通代码 ===")
+    response = requests.post(f"{BASE_URL}/api/resource", json={
+        "id": "test/code/example",
+        "title": "示例代码",
+        "body": "print('Hello World')",
+        "content_type": "code",
+        "metadata": {"language": "python"},
+        "submitted_by": "test@example.com"
+    })
+    print(f"状态码: {response.status_code}")
+    print(f"响应: {response.json()}")
+    print()
+
+    print("=== 测试2: 提交敏感凭证 ===")
+    response = requests.post(f"{BASE_URL}/api/resource", json={
+        "id": "test/credentials/website",
+        "title": "网站登录凭证",
+        "body": "使用方法:直接登录",
+        "secure_body": "账号:user@example.com\n密码:SecurePass123",
+        "content_type": "credential",
+        "metadata": {"acquired_at": "2026-03-06T10:00:00Z"},
+        "submitted_by": "test@example.com"
+    })
+    print(f"状态码: {response.status_code}")
+    print(f"响应: {response.json()}")
+    print()
+
+    print("=== 测试3: 提交Cookie ===")
+    response = requests.post(f"{BASE_URL}/api/resource", json={
+        "id": "test/cookies/website",
+        "title": "网站Cookie",
+        "body": "适用于:已登录状态",
+        "secure_body": "session_id=abc123; auth_token=xyz789",
+        "content_type": "cookie",
+        "metadata": {
+            "acquired_at": "2026-03-06T10:00:00Z",
+            "expires_at": "2026-03-07T10:00:00Z"
+        },
+        "submitted_by": "test@example.com"
+    })
+    print(f"状态码: {response.status_code}")
+    print(f"响应: {response.json()}")
+    print()
+
+
+def test_get_resource():
+    """测试获取resource"""
+    print("=== 测试4: 获取普通代码(无需密钥)===")
+    response = requests.get(f"{BASE_URL}/api/resource/test/code/example")
+    print(f"状态码: {response.status_code}")
+    data = response.json()
+    print(f"标题: {data['title']}")
+    print(f"内容: {data['body']}")
+    print(f"敏感内容: {data['secure_body']}")
+    print()
+
+    print("=== 测试5: 获取凭证(无密钥)===")
+    response = requests.get(f"{BASE_URL}/api/resource/test/credentials/website")
+    print(f"状态码: {response.status_code}")
+    data = response.json()
+    print(f"标题: {data['title']}")
+    print(f"公开内容: {data['body']}")
+    print(f"敏感内容: {data['secure_body']}")  # 应该是 [ENCRYPTED]
+    print()
+
+    print("=== 测试6: 获取凭证(有密钥)===")
+    response = requests.get(
+        f"{BASE_URL}/api/resource/test/credentials/website",
+        headers={"X-Org-Key": test_key}
+    )
+    print(f"状态码: {response.status_code}")
+    data = response.json()
+    print(f"标题: {data['title']}")
+    print(f"公开内容: {data['body']}")
+    print(f"敏感内容: {data['secure_body']}")  # 应该解密
+    print()
+
+
+def test_patch_resource():
+    """测试更新resource"""
+    print("=== 测试7: 更新resource ===")
+    response = requests.patch(
+        f"{BASE_URL}/api/resource/test/credentials/website",
+        json={
+            "title": "更新后的标题",
+            "metadata": {"acquired_at": "2026-03-06T11:00:00Z"}
+        }
+    )
+    print(f"状态码: {response.status_code}")
+    print(f"响应: {response.json()}")
+    print()
+
+
+def test_list_resources():
+    """测试列出resource"""
+    print("=== 测试8: 列出所有resource ===")
+    response = requests.get(f"{BASE_URL}/api/resource")
+    print(f"状态码: {response.status_code}")
+    data = response.json()
+    print(f"总数: {data['count']}")
+    for item in data['results']:
+        print(f"  - {item['id']}: {item['title']} ({item['content_type']})")
+    print()
+
+    print("=== 测试9: 按类型过滤 ===")
+    response = requests.get(f"{BASE_URL}/api/resource?content_type=credential")
+    print(f"状态码: {response.status_code}")
+    data = response.json()
+    print(f"凭证数量: {data['count']}")
+    for item in data['results']:
+        print(f"  - {item['id']}: {item['title']}")
+    print()
+
+
+if __name__ == "__main__":
+    print("请确保服务器正在运行: uvicorn knowhub.server:app --reload")
+    print()
+
+    try:
+        test_submit_resource()
+        test_get_resource()
+        test_patch_resource()
+        test_list_resources()
+        print("✅ 所有测试完成")
+    except Exception as e:
+        print(f"❌ 测试失败: {e}")