Skill: zyuanjia/long-memory (version 7.2.0)

--- name: long-memory description: '全量对话记忆系统，自动保存每次对话的完整内容（用户消息、助手回复、思考过程）并支持按语义检索任意历史对话。当用户提到"记下来""别忘了""回忆一下""之前聊过""什么时候说的"、要求回顾历史对话、或需要跨 session 记忆连续性时触发。也适用于 session 结束时的自动记忆归档。不适用于简单的短期记事（用 memory 文件即可）。' --- # Long Memory 文件系统 = 无限外存，上下文窗口 = 有限内存。所有对话全量落盘，按需检索，永不遗忘。 ## 企业级特性 - **并发安全**：文件锁机制，多进程写入不冲突 - **三级搜索引擎**：关键词 → TF-IDF + 近义词 → Embedding 向量（零依赖到深度语义） - **SQLite 引擎**：结构化存储，查询比纯文件快 100 倍 - **自记忆引擎**：系统记住自己的版本演变和决策历史 - **完整性校验**：SHA256 校验和，自动检测修复损坏文件 - **自动备份**：Git commit + push，支持定时自动执行 - **质量评分**：0-100 分量化评估（7 条规则） - **遗忘曲线**：艾宾浩斯遗忘曲线检索权重 - **情感分析**：对话情绪变化追踪 - **人物图谱**：自动提取人脉关系 - **智能推荐**：基于当前对话推荐相关历史 - **隐私加密**：AES-256 加密敏感记忆 - **结构化模板**：会议纪要/项目决策/学习笔记/周报 - **定时调度**：内置蒸馏/备份/清理调度器 - **多用户隔离**：独立记忆空间 - **REST API**：7 个 HTTP 接口，支持外部集成 - **HTML 报告**：暗色主题可视化分析面板 - **操作审计**：JSONL 完整操作日志 - **配置管理**：JSON 配置 + 版本自动迁移 - **性能基准**：365 天数据模拟，多维度测量 - **统一 CLI**：35 个子命令 - **37 个测试**：pytest 全通过 - **零核心依赖**：搜索引擎、SQLite 全部 Python 标准库 ## CLI 速查 所有命令支持 `--memory-dir` 自定义路径，多数命令支持 `--json` JSON 输出。 ### 📝 记录 ```bash cli.py archive --session webchat --topic "话题" # 归档对话 cli.py capture [-s <id>] # 捕获 session 历史 cli.py import <file> # 批量导入 MD/JSON/文本 cli.py template list # 列出模板 cli.py template create meeting -f participants "张三" # 创建结构化记录 ``` ### 🔍 搜索（三级引擎） ```bash cli.py search -q "关键词" --tag 标签 --days 30 # 组合搜索（标签+话题+时间） cli.py semantic "怎么优化性能" # 语义搜索（TF-IDF+近义词，零依赖，推荐） cli.py tfidf "查询" --rebuild # TF-IDF 向量搜索 cli.py embedding search "查询" --top 10 # 深度语义搜索（需 sentence-transformers） cli.py sqlite search -q "查询" # SQLite 高性能查询 cli.py recommend "当前对话内容" # 智能推荐相关历史 cli.py timeline [-t 话题] [-v] # 话题时间线 ``` ### 📊 分析 ```bash cli.py stats [-v] # 统计 cli.py graph [-v] # 记忆关联图谱 cli.py persons [-v] # 人物关系 cli.py emotion [-v] # 情感分析 cli.py health # 健康仪表盘 cli.py report -o report.html # HTML 可视化报告 ``` ### ✅ 质量 ```bash cli.py quality # 质量评分（0-100） cli.py contradictions # 矛盾检测 cli.py integrity [--fix] # 完整性校验 cli.py forgetting # 遗忘曲线 ``` ### 🔧 维护 ```bash cli.py summary # 每日摘要 cli.py distill # 周蒸馏 cli.py tags [--execute] # 标签优化 cli.py clean --days 90 [--execute] # 清理旧记忆 cli.py index [--rebuild] # 索引管理 cli.py backup [--status|--setup] # Git 备份 cli.py scheduler [--list|--run|--force] # 定时任务 cli.py self [--summary] # 自记忆引擎 cli.py config [--migrate] # 配置验证与迁移 cli.py benchmark # 性能基准测试 ``` ### 🔒 安全与审计 ```bash cli.py privacy encrypt "内容" -p 密码 # AES-256 加密 cli.py privacy decrypt "密文" -p 密码 # 解密 cli.py privacy scan -f <file> # 敏感内容扫描 cli.py users list # 多用户管理 cli.py users switch <name> # 切换用户 cli.py export -f json -o backup.json # 导出 JSON/MD/TXT cli.py log # 操作审计日志 ``` ### 🌐 API ```bash cli.py api --port 8765 # 启动 REST API 服务 # GET /api/health /api/stats /api/search?q= /api/topics /api/tags /api/conversations # POST /api/conversations ``` ## 测试 ```bash cd long-memory && python3 -m pytest tests/ -v # 37 passed ```

path: README.md
content: <div align="center">

# 🧠 Long Memory

**Enterprise-grade Full Conversation Memory System for AI Agents**

[![Version](https://img.shields.io/badge/version-6.0-blue)]()
[![Python](https://img.shields.io/badge/Python-3.10+-green)]()
[![License](https://img.shields.io/badge/License-MIT-yellow)]()
[![Tests](https://img.shields.io/badge/tests-37%20passed-success)]()
[![Scripts](https://img.shields.io/badge/scripts-31-orange)]()

*File system = unlimited external storage. Context window = limited RAM.*
*All conversations archived forever, retrieved on demand, never forgotten.*

[English](#english) | [中文](#中文)

</div>

---

## 中文

### 为什么需要 Long Memory？

AI 助手每次对话都是全新的——它不记得昨天聊了什么，上周做了什么决定。Long Memory 解决这个问题：

- ✅ **全量保存**：每一条用户消息、助手回复、思考过程，一字不落
- ✅ **按需检索**：不用加载全部历史，语义搜索秒级返回
- ✅ **永不遗忘**：文件系统存储，不受上下文窗口限制
- ✅ **企业级可靠**：并发安全、完整性校验、自动备份、操作审计

### 核心能力

```
写入层：归档 / Session捕获 / 结构化模板 / 批量导入
整理层：摘要 / 蒸馏 / 标签优化 / 清理归档
检索层：关键词 / TF-IDF / 语义搜索(零依赖) / Embedding向量搜索 / SQLite / 标签+话题+时间组合 / 智能推荐
分析层：统计 / 时间线 / 关联图谱 / 情感分析 / 遗忘曲线 / 人物关系
质量层：评分 / 完整性校验 / 矛盾检测 / 健康仪表盘
基建层：文件锁 / 内存索引 / SQLite引擎 / TF-IDF / 近义词扩展 / 配置管理 / 定时调度 / 多用户隔离
安全层：隐私加密 / 敏感扫描 / 操作审计 / 自动备份
输出层：CLI(34命令) / REST API / HTML报告 / JSON·MD·TXT导出
```

### 快速开始

```bash
# 克隆仓库
git clone https://github.com/ZYuanJia/long-memory.git
cd long-memory

# 归档一段对话
python3 scripts/cli.py archive --session webchat --topic "项目讨论" --tags "工作,技术"

# 搜索历史记忆
python3 scripts/cli.py search -q "性能优化" --days 30

# 生成每日摘要
python3 scripts/cli.py summary

# 查看记忆健康状态
python3 scripts/cli.py health
```

### CLI 命令（31个）

#### 📝 记录
| 命令 | 说明 |
|------|------|
| `archive` | 归档对话 |
| `capture` | 捕获 Session 历史 |
| `import` | 批量导入 Markdown/JSON/文本 |
| `template create meeting` | 用模板创建会议纪要 |

#### 🔍 检索（三级搜索引擎）
| 命令 | 说明 | 依赖 |
|------|------|------|
| `search` | 关键词+标签+话题+时间组合搜索 | 无 |
| `semantic` | TF-IDF + 近义词语义搜索（推荐） | 无 |
| `tfidf` | TF-IDF 向量搜索 | 无 |
| `embedding` | 深度语义搜索（sentence-transformers） | pip install sentence-transformers |
| `sqlite` | SQLite 高性能存储+查询 | 无 |
| `recommend` | 基于当前对话智能推荐历史 | 无 |
| `timeline` | 话题时间线 | 无 |

#### 📊 分析
| 命令 | 说明 |
|------|------|
| `stats` | 记忆统计 |
| `graph` | 记忆关联图谱 |
| `persons` | 人物关系图谱 |
| `emotion` | 情感分析 |
| `forgetting` | 艾宾浩斯遗忘曲线 |

#### ✅ 质量
| 命令 | 说明 |
|------|------|
| `quality` | 质量评分（0-100） |
| `integrity` | 文件完整性校验 |
| `contradictions` | 记忆矛盾检测 |
| `health` | 综合健康仪表盘 |

#### 🔧 维护
| 命令 | 说明 |
|------|------|
| `summary` | 每日摘要 |
| `distill` | 周蒸馏 |
| `tags` | 标签体系优化 |
| `clean` | 清理旧记忆 |
| `index` | 索引管理 |
| `backup` | Git 自动备份 |
| `scheduler` | 定时任务调度 |

#### 🔒 安全 & 导出
| 命令 | 说明 |
|------|------|
| `privacy encrypt/decrypt` | 加密/解密 |
| `privacy scan` | 敏感内容扫描 |
| `export` | 导出 JSON/Markdown/纯文本 |
| `report` | HTML 可视化报告 |
| `api` | 启动 REST API 服务 |
| `users` | 多用户管理 |
| `log` | 操作审计日志 |
| `config` | 配置验证与迁移 |
| `changelog` | 版本变更记录 |
| `benchmark` | 性能基准测试 |
| `sqlite` | SQLite 初始化/导入/统计/搜索 |

### 文件结构

```
long-memory/
├── SKILL.md                    # Skill 定义（OpenClaw 格式）
├── long-memory.json            # 配置文件
├── .gitignore
├── scripts/
│   ├── cli.py                  # 统一 CLI 入口（31个子命令）
│   ├── lib/
│   │   └── filelock.py         # 文件锁（并发安全）
│   ├── archive_conversation.py # 对话归档
│   ├── capture_session.py      # Session 捕获
│   ├── generate_summary.py     # 每日摘要
│   ├── distill_week.py         # 周蒸馏
│   ├── search_memory.py        # 组合搜索
│   ├── tfidf_search.py         # TF-IDF 语义搜索
│   ├── smart_recommend.py      # 智能推荐
│   ├── topic_timeline.py       # 话题时间线
│   ├── memory_graph.py         # 关联图谱
│   ├── person_graph.py         # 人物关系
│   ├── emotion_analyzer.py     # 情感分析
│   ├── forgetting_curve.py     # 遗忘曲线
│   ├── memory_stats.py         # 统计
│   ├── quality_check.py        # 质量评分
│   ├── detect_contradictions.py# 矛盾检测
│   ├── integrity_check.py      # 完整性校验
│   ├── health_dashboard.py     # 健康仪表盘
│   ├── index_manager.py        # 索引管理
│   ├── auto_backup.py          # 自动备份
│   ├── tag_optimizer.py        # 标签优化
│   ├── clean_old.py            # 旧记忆清理
│   ├── export_memory.py        # 导出
│   ├── import_memory.py        # 导入
│   ├── memory_templates.py     # 结构化模板
│   ├── scheduler.py            # 定时任务
│   ├── user_manager.py         # 多用户管理
│   ├── privacy.py              # 隐私加密
│   ├── html_report.py          # HTML 报告
│   ├── api_server.py           # REST API
│   ├── operation_log.py        # 操作日志
│   ├── config_manager.py       # 配置管理
│   ├── changelog.py            # 变更记录
│   ├── recent_context.py       # 上下文预加载
│   └── benchmark.py            # 性能测试
└── tests/
    └── test_long_memory.py     # 37 个 pytest 用例
```

### 测试

```bash
cd long-memory
python3 -m pytest tests/ -v
# 37 passed
```

### 企业级特性

| 特性 | 实现 |
|------|------|
| 并发安全 | `fcntl` 文件锁，多进程写入不冲突 |
| 索引加速 | 内存索引 + SQLite + TF-IDF + 语义搜索 |
| 完整性校验 | SHA256 校验和，自动修复 |
| 自动备份 | Git commit + push |
| 质量评分 | 7条规则，0-100分量化 |
| 遗忘曲线 | 艾宾浩斯模型，检索优先级加权 |
| 情感分析 | 中文情感词典，情绪变化追踪 |
| 隐私加密 | AES-256，敏感内容自动扫描 |
| 多用户隔离 | 独立记忆空间 |
| 定时调度 | 蒸馏/备份/清理自动执行 |
| REST API | 7个 HTTP 接口 |
| 配置化 | JSON 配置文件 + 版本迁移 |
| 操作审计 | JSONL 操作日志 |

### 配置

编辑 `long-memory.json`：

```json
{
  "version": "5.0",
  "archive_days": 90,
  "max_memory_md_size": 8000,
  "auto_backup": {
    "enabled": false,
    "git_remote": null,
    "interval_hours": 6
  },
  "quality": {
    "auto_flag_low_quality": true
  }
}
```

### REST API

```bash
# 启动服务
python3 scripts/cli.py api --port 8765

# 接口
GET  /api/health          # 健康检查
GET  /api/stats           # 统计信息
GET  /api/conversations   # 对话列表
GET  /api/search?q=xxx    # 搜索
GET  /api/topics          # 话题列表
GET  /api/tags            # 标签列表
POST /api/conversations   # 创建对话
```

### 许可证

MIT License

### 致谢

Built with ❤️ for [OpenClaw](https://github.com/openclaw/openclaw) ecosystem.

---

## English

### Why Long Memory?

AI assistants start fresh every conversation — they don't remember yesterday's chat, last week's decisions. Long Memory fixes this:

- ✅ **Full archival**: Every user message, assistant reply, and thought process — nothing lost
- ✅ **On-demand retrieval**: Semantic search returns relevant memories in milliseconds
- ✅ **Never forget**: File-system storage, not limited by context window size
- ✅ **Enterprise-grade**: Concurrency-safe, integrity-checked, auto-backed, fully audited

### Architecture

```
Write:    Archive / Session Capture / Templates / Batch Import
Organize: Summaries / Distillation / Tag Optimization / Cleanup
Search:   Keyword / TF-IDF Semantic / Tag+Topic+Time / Smart Recommend
Analyze:  Stats / Timeline / Graph / Emotion / Forgetting Curve / People
Quality:  Scoring / Integrity Check / Contradiction Detection / Health Dashboard
Infra:    File Locks / Indexing / Config / Scheduler / Multi-user
Security: Encryption / Sensitive Scan / Audit Log / Auto Backup
Output:   CLI (31 commands) / REST API / HTML Report / JSON·MD·TXT Export
```

### Quick Start

```bash
git clone https://github.com/ZYuanJia/long-memory.git
cd long-memory

# Archive a conversation
python3 scripts/cli.py archive --session webchat --topic "project discussion" --tags "work,tech"

# Search memories
python3 scripts/cli.py search -q "performance optimization" --days 30

# Generate daily summary
python3 scripts/cli.py summary

# Check memory health
python3 scripts/cli.py health
```

### 31 CLI Commands

#### 📝 Recording
| Command | Description |
|---------|-------------|
| `archive` | Archive conversations |
| `capture` | Capture session history |
| `import` | Batch import Markdown/JSON/text |
| `template create meeting` | Create from structured template |

#### 🔍 Search (3-Tier Engine)
| Command | Description | Deps |
|---------|-------------|------|
| `search` | Combined keyword+tag+topic+time search | None |
| `semantic` | TF-IDF + synonym expansion (recommended) | None |
| `tfidf` | TF-IDF vector search | None |
| `embedding` | Deep semantic search (sentence-transformers) | pip install sentence-transformers |
| `sqlite` | SQLite high-performance storage & query | None |
| `recommend` | Smart recommendations based on context | None |
| `timeline` | Topic timeline | None |

#### 📊 Analysis
| Command | Description |
|---------|-------------|
| `stats` | Memory statistics |
| `graph` | Memory association graph |
| `persons` | People relationship graph |
| `emotion` | Emotion analysis |
| `forgetting` | Ebbinghaus forgetting curve |

#### ✅ Quality
| Command | Description |
|---------|-------------|
| `quality` | Quality score (0-100) |
| `integrity` | File integrity check |
| `contradictions` | Memory contradiction detection |
| `health` | Comprehensive health dashboard |

#### 🔧 Maintenance
| Command | Description |
|---------|-------------|
| `sqlite` | SQLite init/import/stats/search |

| `summary` | Daily summary |
| `distill` | Weekly distillation |
| `tags` | Tag system optimization |
| `clean` | Clean old memories |
| `index` | Index management |
| `backup` | Git auto backup |
| `scheduler` | Scheduled task manager |

#### 🔒 Security & Export
| Command | Description |
|---------|-------------|
| `benchmark` | Performance benchmark |
| `sqlite` | SQLite init/import/stats/search |

| `privacy encrypt/decrypt` | Encrypt/decrypt memories |
| `privacy scan` | Sensitive content scan |
| `export` | Export JSON/Markdown/Plain text |
| `report` | HTML visual report |
| `api` | Start REST API server |
| `users` | Multi-user management |
| `log` | Audit log |
| `config` | Config validation & migration |
| `changelog` | Version changelog |
| `benchmark` | Performance benchmark |

### Test

```bash
cd long-memory
python3 -m pytest tests/ -v
# 37 passed
```

### Enterprise Features

| Feature | Implementation |
|---------|---------------|
| Concurrency Safety | `fcntl` file locks |
| Index Acceleration | SQLite + TF-IDF + Semantic search (zero-dep) |
| Integrity Check | SHA256 checksums with auto-repair |
| Auto Backup | Git commit + push |
| Quality Scoring | 7 rules, 0-100 score |
| Forgetting Curve | Ebbinghaus model for retrieval priority |
| Emotion Analysis | Chinese sentiment lexicon |
| Privacy Encryption | AES-256 + sensitive content scanning |
| Multi-user | Isolated memory spaces |
| Scheduler | Auto distill/backup/cleanup |
| REST API | 7 HTTP endpoints |
| Configuration | JSON config + version migration |
| Audit Trail | JSONL operation logs |

### REST API

```bash
# Start server
python3 scripts/cli.py api --port 8765

# Endpoints
GET  /api/health          # Health check
GET  /api/stats           # Statistics
GET  /api/conversations   # List conversations
GET  /api/search?q=xxx    # Search
GET  /api/topics          # List topics
GET  /api/tags            # List tags
POST /api/conversations   # Create conversation
```

### License

MIT License

### Credits

Built with ❤️ for the [OpenClaw](https://github.com/openclaw/openclaw) ecosystem.


path: long-memory.json
content: {
  "version": "5.0",
  "memory_dir": null,
  "archive_days": 90,
  "max_memory_md_size": 8000,
  "distill_week": true,
  "distill_day": "sunday",
  "auto_backup": {
    "enabled": false,
    "git_remote": null,
    "interval_hours": 6
  },
  "index": {
    "enabled": true,
    "rebuild_on_startup": false
  },
  "quality": {
    "min_user_msg_length": 3,
    "auto_flag_low_quality": true
  },
  "retention": {
    "conversations_days": null,
    "summaries_days": null,
    "distillations_days": null,
    "archive_days": null
  },
  "privacy": {
    "encrypt_tags": [],
    "auto_redact_patterns": []
  },
  "tags": {
    "normalize": true,
    "custom_aliases": {}
  }
}

path: scripts/api_server.py
content: #!/usr/bin/env python3
"""REST API 服务器：提供 HTTP 接口访问记忆系统"""

import argparse
import json
from datetime import datetime
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from urllib.parse import urlparse, parse_qs

import sys
sys.path.insert(0, str(Path(__file__).parent))

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"


class MemoryAPIHandler(BaseHTTPRequestHandler):
    """REST API 请求处理器"""
    
    memory_dir = DEFAULT_MEMORY_DIR

    def do_GET(self):
        parsed = urlparse(self.path)
        path = parsed.path
        params = parse_qs(parsed.query)

        routes = {
            "/api/health": self.handle_health,
            "/api/stats": self.handle_stats,
            "/api/conversations": self.handle_list_conversations,
            "/api/search": self.handle_search,
            "/api/topics": self.handle_topics,
            "/api/tags": self.handle_tags,
        }

        handler = routes.get(path)
        if handler:
            handler(params)
        else:
            self.send_json(404, {"error": "Not found", "routes": list(routes.keys())})

    def do_POST(self):
        parsed = urlparse(self.path)
        content_length = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(content_length).decode("utf-8") if content_length > 0 else "{}"
        try:
            data = json.loads(body)
        except json.JSONDecodeError:
            self.send_json(400, {"error": "Invalid JSON"})
            return

        routes = {
            "/api/conversations": self.handle_create_conversation,
            "/api/archive": self.handle_archive,
        }

        handler = routes.get(parsed.path)
        if handler:
            handler(data)
        else:
            self.send_json(404, {"error": "Not found"})

    def send_json(self, code: int, data: dict):
        response = json.dumps(data, ensure_ascii=False).encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Content-Length", str(len(response)))
        self.end_headers()
        self.wfile.write(response)

    def handle_health(self, params):
        self.send_json(200, {"status": "ok", "timestamp": datetime.now().isoformat()})

    def handle_stats(self, params):
        conv_dir = self.memory_dir / "conversations"
        stats = {"total_files": 0, "total_sessions": 0, "total_size": 0}
        if conv_dir.exists():
            import re
            for fp in conv_dir.glob("*.md"):
                stats["total_files"] += 1
                content = fp.read_text(encoding="utf-8")
                stats["total_sessions"] += content.count("## [")
                stats["total_size"] += fp.stat().st_size
        self.send_json(200, stats)

    def handle_list_conversations(self, params):
        conv_dir = self.memory_dir / "conversations"
        files = []
        if conv_dir.exists():
            import re
            for fp in sorted(conv_dir.glob("*.md")):
                content = fp.read_text(encoding="utf-8")
                files.append({
                    "date": fp.stem,
                    "size": fp.stat().st_size,
                    "sessions": content.count("## ["),
                    "topics": re.findall(r'###\s*话题[：:]\s*(.+)', content),
                })
        limit = int(params.get("limit", [20])[0])
        self.send_json(200, {"files": files[-limit:], "total": len(files)})

    def handle_search(self, params):
        query = params.get("q", [""])[0]
        if not query:
            self.send_json(400, {"error": "Missing query parameter 'q'"})
            return
        tag = params.get("tag", [None])[0]
        days = int(params.get("days", [0])[0]) or None

        # 简单搜索
        import re
        results = []
        conv_dir = self.memory_dir / "conversations"
        if conv_dir.exists():
            for fp in sorted(conv_dir.glob("*.md"), reverse=True):
                content = fp.read_text(encoding="utf-8")
                if query.lower() not in content.lower():
                    continue
                if tag and tag.lower() not in content.lower():
                    continue
                results.append({
                    "date": fp.stem,
                    "match": True,
                    "topics": re.findall(r'###\s*话题[：:]\s*(.+)', content),
                })
        self.send_json(200, {"query": query, "results": results[:20]})

    def handle_topics(self, params):
        import re
        conv_dir = self.memory_dir / "conversations"
        topics = {}
        if conv_dir.exists():
            for fp in sorted(conv_dir.glob("*.md")):
                content = fp.read_text(encoding="utf-8")
                for t in re.findall(r'###\s*话题[：:]\s*(.+)', content):
                    topics[t.strip()] = topics.get(t.strip(), 0) + 1
        self.send_json(200, {"topics": topics})

    def handle_tags(self, params):
        import re
        conv_dir = self.memory_dir / "conversations"
        tags = {}
        if conv_dir.exists():
            for fp in sorted(conv_dir.glob("*.md")):
                content = fp.read_text(encoding="utf-8")
                for tl in re.findall(r'\*\*标签[：:]\*\*\s*(.+)', content):
                    for t in tl.split("，"):
                        t = t.strip()
                        if t:
                            tags[t] = tags.get(t, 0) + 1
        self.send_json(200, {"tags": tags})

    def handle_create_conversation(self, data):
        date = data.get("date", datetime.now().strftime("%Y-%m-%d"))
        session = data.get("session", "api")
        topic = data.get("topic", "API 归档")
        content = data.get("content", "")
        tags = data.get("tags", [])

        conv_dir = self.memory_dir / "conversations"
        conv_dir.mkdir(parents=True, exist_ok=True)
        fp = conv_dir / f"{date}.md"

        entry = f"\n## [{datetime.now().strftime('%H:%M')}] Session: {session}\n"
        entry += f"### 话题：{topic}\n"
        if tags:
            entry += f"**标签：** {', '.join(tags)}\n\n"
        entry += f"{content}\n"

        if fp.exists():
            fp.write_text(fp.read_text(encoding="utf-8") + entry, encoding="utf-8")
        else:
            fp.write_text(f"# {date} 对话记录\n{entry}", encoding="utf-8")

        self.send_json(201, {"status": "created", "date": date, "file": str(fp)})

    def handle_archive(self, data):
        self.handle_create_conversation(data)

    def log_message(self, format, *args):
        # 静默日志
        pass


def run_server(memory_dir: Path, port: int = 8765):
    MemoryAPIHandler.memory_dir = memory_dir
    server = HTTPServer(("127.0.0.1", port), MemoryAPIHandler)
    print(f"🚀 Long Memory API 服务已启动: http://127.0.0.1:{port}")
    print(f"   接口: /api/health, /api/stats, /api/search, /api/conversations")
    print(f"   按 Ctrl+C 停止")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\n✅ 服务已停止")
        server.server_close()


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="REST API 服务器")
    p.add_argument("--memory-dir", default=None)
    p.add_argument("--port", type=int, default=8765)
    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)
    run_server(md, args.port)


path: scripts/archive_conversation.py
content: #!/usr/bin/env python3
"""归档对话到 memory/conversations/YYYY-MM-DD.md"""

import argparse
import json
import sys
from datetime import datetime
from pathlib import Path

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"


def resolve_memory_dir(memory_dir: str | None) -> Path:
    if memory_dir:
        return Path(memory_dir)
    if DEFAULT_MEMORY_DIR.exists():
        return DEFAULT_MEMORY_DIR
    # 向上查找 workspace
    p = Path.cwd()
    for _ in range(5):
        if (p / "memory").is_dir():
            return p / "memory"
        p = p.parent
    return DEFAULT_MEMORY_DIR


def archive(date_str: str, session: str, topic: str, content: str | None,
            memory_dir: Path, metadata: dict | None = None):
    conv_dir = memory_dir / "conversations"
    conv_dir.mkdir(parents=True, exist_ok=True)
    filepath = conv_dir / f"{date_str}.md"
    now = datetime.now().strftime("%H:%M")

    entry = f"\n## [{now}] Session: {session}\n### 话题：{topic}\n\n"
    if metadata:
        entry += f"**标签：** {', '.join(metadata.get('tags', []))}\n\n"
    if content:
        entry += content
    else:
        entry += "(等待对话内容填充)\n"

    if filepath.exists():
        existing = filepath.read_text(encoding="utf-8")
        filepath.write_text(existing + entry, encoding="utf-8")
    else:
        filepath.write_text(f"# {date_str} 对话记录\n{entry}", encoding="utf-8")

    print(f"✅ 已归档到 {filepath}")
    return str(filepath)


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="归档对话")
    p.add_argument("--date", default=datetime.now().strftime("%Y-%m-%d"))
    p.add_argument("--session", required=True)
    p.add_argument("--topic", required=True)
    p.add_argument("--input", help="对话内容文件路径")
    p.add_argument("--memory-dir", default=None, help="记忆目录路径")
    p.add_argument("--tags", default="", help="标签，逗号分隔")
    args = p.parse_args()

    content = None
    if args.input:
        content = Path(args.input).read_text(encoding="utf-8")
    elif not sys.stdin.isatty():
        content = sys.stdin.read()

    metadata = {"tags": [t.strip() for t in args.tags.split(",") if t.strip()]} if args.tags else None
    md = resolve_memory_dir(args.memory_dir)
    archive(args.date, args.session, args.topic, content, md, metadata)


path: scripts/auto_backup.py
content: #!/usr/bin/env python3
"""自动备份：Git commit + push"""

import argparse
import subprocess
from datetime import datetime
from pathlib import Path

import sys
sys.path.insert(0, str(Path(__file__).parent))
from lib.filelock import file_lock

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"
LOCK_FILE = DEFAULT_MEMORY_DIR / ".backup.lock"


def run_git(memory_dir: Path, args_list: list[str], check: bool = False) -> subprocess.CompletedProcess:
    return subprocess.run(
        ["git"] + args_list,
        cwd=memory_dir,
        capture_output=True, text=True, timeout=30, check=check
    )


def ensure_git_repo(memory_dir: Path) -> bool:
    """确保 memory 目录是一个 git repo"""
    result = run_git(memory_dir, ["rev-parse", "--git-dir"])
    if result.returncode != 0:
        run_git(memory_dir, ["init"])
        run_git(memory_dir, ["config", "user.email", "long-memory@openclaw"])
        run_git(memory_dir, ["config", "user.name", "Long Memory Bot"])
        return True
    return False


def backup(memory_dir: Path, remote: str | None = None, message: str | None = None):
    """执行自动备份"""
    if not memory_dir.exists():
        print("⚠️ 记忆目录不存在")
        return False

    with file_lock(LOCK_FILE, timeout=10):
        ensure_git_repo(memory_dir)

        # 检查是否有变更
        result = run_git(memory_dir, ["status", "--porcelain"])
        if not result.stdout.strip():
            print("✅ 没有待提交的变更")
            return True

        # 添加所有文件
        run_git(memory_dir, ["add", "-A"])

        # 提交
        msg = message or f"auto-backup: {datetime.now().strftime('%Y-%m-%d %H:%M')}"
        result = run_git(memory_dir, ["commit", "-m", msg])
        if result.returncode != 0:
            # 可能是 nothing to commit
            if "nothing to commit" in result.stdout or "nothing to commit" in result.stderr:
                print("✅ 没有待提交的变更")
                return True
            print(f"⚠️ 提交失败: {result.stderr}")
            return False

        commit_short = result.stdout.strip().split("\n")[0] if result.stdout else "unknown"
        print(f"✅ 已提交: {commit_short}")

        # 推送
        if remote:
            result = run_git(memory_dir, ["remote", "get-url", "origin"])
            if result.returncode != 0:
                run_git(memory_dir, ["remote", "add", "origin", remote])
            
            result = run_git(memory_dir, ["push", "-u", "origin", "main"])
            if result.returncode != 0:
                # 尝试当前分支
                branch_result = run_git(memory_dir, ["branch", "--show-current"])
                branch = branch_result.stdout.strip() or "main"
                run_git(memory_dir, ["push", "-u", "origin", branch])
            print(f"✅ 已推送到远程")

        return True


def get_backup_status(memory_dir: Path) -> dict:
    """获取备份状态"""
    status = {"is_git_repo": False, "is_clean": True, "remote": None, "last_commit": None}

    result = run_git(memory_dir, ["rev-parse", "--git-dir"])
    if result.returncode == 0:
        status["is_git_repo"] = True
        
        result = run_git(memory_dir, ["status", "--porcelain"])
        status["is_clean"] = not result.stdout.strip()
        
        result = run_git(memory_dir, ["remote", "get-url", "origin"])
        if result.returncode == 0:
            status["remote"] = result.stdout.strip()
        
        result = run_git(memory_dir, ["log", "-1", "--format=%ai %s"])
        if result.returncode == 0:
            status["last_commit"] = result.stdout.strip()

    return status


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="自动备份")
    p.add_argument("--memory-dir", default=None)
    p.add_argument("--remote", "-r", default=None, help="Git 远程仓库 URL")
    p.add_argument("--message", "-m", default=None, help="提交消息")
    p.add_argument("--status", action="store_true", help="查看备份状态")
    p.add_argument("--setup", action="store_true", help="初始化 Git 仓库")
    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)

    if args.status:
        status = get_backup_status(md)
        print("📦 备份状态")
        print(f"  Git 仓库: {'✅' if status['is_git_repo'] else '❌'}")
        print(f"  工作区: {'干净' if status['is_clean'] else '有未提交变更'}")
        print(f"  远程仓库: {status['remote'] or '未配置'}")
        print(f"  最近提交: {status['last_commit'] or '无'}")
    elif args.setup:
        ensure_git_repo(md)
        if args.remote:
            run_git(md, ["remote", "add", "origin", args.remote])
        print("✅ Git 仓库已初始化")
    else:
        backup(md, args.remote, args.message)


path: scripts/benchmark.py
content: #!/usr/bin/env python3
"""性能基准测试：大量数据下的性能测量"""

import argparse
import os
import tempfile
import time
from datetime import datetime, timedelta
from pathlib import Path

import sys
sys.path.insert(0, str(Path(__file__).parent))


def generate_test_data(memory_dir: Path, days: int = 365) -> dict:
    """生成测试数据"""
    conv_dir = memory_dir / "conversations"
    conv_dir.mkdir(parents=True, exist_ok=True)

    topics = ["日常对话", "项目讨论", "技术方案", "需求分析", "代码评审",
              "会议纪要", "学习笔记", "问题排查", "架构设计", "性能优化"]
    tags_pool = ["工作", "个人", "技术", "管理", "学习", "沟通"]
    user_msgs = [
        "你觉得这个方案怎么样？我觉得可以再优化一下。",
        "帮我分析一下这个问题，我觉得可能是配置的问题。",
        "今天开会讨论了下个迭代的需求，主要涉及三个方面。",
        "我在看这个文档的时候发现了一些矛盾的地方，帮我确认下。",
        "上次说的那个功能什么时候能完成？客户在催了。",
        "这个 bug 我查了两天了还是没找到原因，你帮看看。",
        "我觉得应该优先处理性能问题，用户体验太差了。",
        "周末有个技术分享会，你准备讲什么？",
        "最近在学 Rust，感觉和 Python 思维方式差很多。",
        "这个项目的架构需要重构了，现在维护成本太高。",
        "帮我写个测试用例，覆盖边界情况。",
        "这个 API 的响应时间太长了，有没有优化方案？",
        "今天的日报我写好了，你帮我看看有没有遗漏。",
        "我决定把这个模块拆分成独立的微服务。",
        "最近记忆力不太好，帮我记一下这些要点。",
    ]
    assistant_msgs = [
        "好的，我来分析一下。首先需要确认几个前置条件...",
        "这个问题的根本原因是数据量增长导致的，建议分批处理。",
        "我同意你的看法，可以按这个方向继续推进。",
        "我帮你梳理了一下，总结为以下三点...",
        "方案已经验证通过了，可以部署到测试环境。",
        "这个问题比较复杂，建议分步骤排查。先检查...",
        "已经修复了，主要改动是调整了缓存策略。",
        "我觉得可以采用渐进式重构，不影响现有功能。",
        "测试用例已经写好了，覆盖了正常和异常场景。",
        "我帮你整理了一份详细的技术方案文档。",
    ]

    stats = {"files": 0, "total_size": 0, "total_sessions": 0}
    
    for i in range(days):
        date = (datetime.now() - timedelta(days=days - i - 1)).strftime("%Y-%m-%d")
        sessions = 1 + (i % 4)  # 每天1-4个session
        content = f"# {date} 对话记录\n"

        for s in range(sessions):
            hour = 8 + (s * 3) % 12
            minute = (i * 7 + s * 13) % 60
            topic = topics[(i + s) % len(topics)]
            tags = [tags_pool[(i + s * 2) % len(tags_pool)],
                   tags_pool[(i + s * 3) % len(tags_pool)]]
            
            content += f"\n## [{hour:02d}:{minute:02d}] Session: webchat\n"
            content += f"### 话题：{topic}\n"
            content += f"**标签：** {', '.join(tags)}\n\n"
            
            # 3-8轮对话
            rounds = 3 + (i + s) % 6
            for r in range(rounds):
                user_msg = user_msgs[(i + s + r) % len(user_msgs)]
                assistant_msg = assistant_msgs[(i + s + r * 2) % len(assistant_msgs)]
                content += f"**用户：** {user_msg}\n"
                content += f"**助手：** {assistant_msg}\n\n"

            if s % 2 == 0:
                content += f"**关键决策：**\n- 确定了{topic}的方向\n"

        fp = conv_dir / f"{date}.md"
        fp.write_text(content, encoding="utf-8")
        stats["files"] += 1
        stats["total_size"] += fp.stat().st_size
        stats["total_sessions"] += sessions

    return stats


def benchmark_search(memory_dir: Path, query: str = "性能优化 方案") -> dict:
    """基准测试：搜索性能"""
    from scripts.search_memory import search
    import time

    start = time.perf_counter()
    results = search(memory_dir, query)
    elapsed = time.perf_counter() - start

    return {"query": query, "results": len(results), "time_ms": round(elapsed * 1000, 2)}


def benchmark_stats(memory_dir: Path) -> dict:
    """基准测试：统计性能"""
    from scripts.memory_stats import get_stats

    start = time.perf_counter()
    stats = get_stats(memory_dir)
    elapsed = time.perf_counter() - start

    return {"files": stats["conversations"], "time_ms": round(elapsed * 1000, 2)}


def benchmark_index(memory_dir: Path) -> dict:
    """基准测试：索引构建"""
    from scripts.index_manager import build_index

    start = time.perf_counter()
    index = build_index(memory_dir)
    elapsed = time.perf_counter() - start

    return {"files": index["stats"]["total_files"], "time_ms": round(elapsed * 1000, 2)}


def benchmark_tfidf(memory_dir: Path, query: str = "性能优化方案") -> dict:
    """基准测试：TF-IDF 搜索"""
    from scripts.tfidf_search import search_tfidf

    start = time.perf_counter()
    results = search_tfidf(memory_dir, query, top_n=10)
    elapsed = time.perf_counter() - start

    return {"query": query, "results": len(results), "time_ms": round(elapsed * 1000, 2)}


def run_benchmark(memory_dir: Path, days: int = 365) -> dict:
    """运行完整基准测试"""
    print(f"📊 生成测试数据（{days} 天）...")
    stats = generate_test_data(memory_dir, days)
    print(f"  ✅ {stats['files']} 文件, {stats['total_sessions']} sessions, "
          f"{stats['total_size'] / 1024 / 1024:.1f} MB\n")

    print("🔍 运行基准测试...\n")
    results = {}

    tests = [
        ("统计", lambda: benchmark_stats(memory_dir)),
        ("索引构建", lambda: benchmark_index(memory_dir)),
        ("关键词搜索", lambda: benchmark_search(memory_dir, "性能优化方案")),
        ("TF-IDF搜索", lambda: benchmark_tfidf(memory_dir, "性能优化方案")),
        ("模糊搜索", lambda: benchmark_search(memory_dir, "这个那个什么")),
    ]

    for name, fn in tests:
        result = fn()
        results[name] = result
        status = "🟢" if result["time_ms"] < 100 else "🟡" if result["time_ms"] < 1000 else "🔴"
        print(f"  {status} {name:15s} {result['time_ms']:>8.2f} ms")

    return {"data_stats": stats, "benchmarks": results}


def print_report(bench: dict):
    print(f"\n{'=' * 50}")
    print("📊 性能基准测试报告")
    print(f"{'=' * 50}")
    print(f"\n数据量: {bench['data_stats']['files']} 文件, "
          f"{bench['data_stats']['total_sessions']} sessions, "
          f"{bench['data_stats']['total_size'] / 1024 / 1024:.1f} MB")

    avg_time = sum(b["time_ms"] for b in bench["benchmarks"].values()) / len(bench["benchmarks"])
    print(f"平均响应: {avg_time:.2f} ms")

    all_fast = all(b["time_ms"] < 1000 for b in bench["benchmarks"].values())
    print(f"{'✅ 所有操作 < 1秒，性能达标' if all_fast else '⚠️ 部分操作超过 1秒'}")


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="性能基准测试")
    p.add_argument("--memory-dir", default=None)
    p.add_argument("--days", type=int, default=365, help="生成多少天的测试数据")
    p.add_argument("--use-existing", action="store_true", help="使用现有数据")
    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)

    if not args.use_existing:
        # 使用临时目录
        import tempfile
        tmp = Path(tempfile.mkdtemp(prefix="long-memory-bench-"))
        md = tmp / "memory"

    bench = run_benchmark(md, args.days if not args.use_existing else 0)
    print_report(bench)


path: scripts/capture_session.py
content: #!/usr/bin/env python3
"""捕获 OpenClaw session 历史并自动归档"""

import argparse
import json
import re
from datetime import datetime
from pathlib import Path

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"

# OpenClaw session 存储路径
SESSIONS_DIR = Path.home() / ".openclaw" / "sessions"


def detect_topic(messages: list[dict]) -> str:
    """根据前几轮对话推断话题"""
    user_msgs = [m for m in messages if m.get("role") == "user"]
    if not user_msgs:
        return "未知话题"
    first = user_msgs[0].get("content", "")
    # 取第一句话的前 40 字符
    first_line = first.split("\n")[0].strip()
    # 去掉元数据标记
    first_line = re.sub(r'^\[[\w\-\s]+\]\s*', '', first_line)
    return first_line[:40] + ("..." if len(first_line) > 40 else "")


def detect_tags(messages: list[dict]) -> list[str]:
    """根据对话内容自动打标签"""
    text = " ".join(m.get("content", "") for m in messages)
    tags = []
    tag_rules = {
        "skill": ["skill", "技能包", "SKILL"],
        "小说": ["小说", "novel", "写作", "章节", "读者"],
        "决策": ["决定", "拍板", "确定了", "方案确定", "就用这个"],
        "待办": ["待办", "todo", "记得", "别忘了", "回头"],
        "代码": ["代码", "script", "脚本", "python", "git", "bug"],
        "赚钱": ["赚钱", "收入", "商业化", "付费", "卖"],
        "部署": ["部署", "deploy", "发布", "上线", "push"],
    }
    for tag, keywords in tag_rules.items():
        if any(kw in text for kw in keywords):
            tags.append(tag)
    return tags or ["日常"]


def format_conversation(messages: list[dict]) -> str:
    """将 session 消息格式化为归档格式"""
    lines = []
    for msg in messages:
        role = msg.get("role", "unknown")
        content = msg.get("content", "")
        if not content or not isinstance(content, str):
            continue
        # 跳过系统消息
        if role == "system":
            continue
        # 清理元数据
        clean = re.sub(r'```json\s*\{[^}]*"schema":\s*"openclaw\.inbound_meta[^`]*```', '', content, flags=re.DOTALL)
        clean = re.sub(r'\[Sat \d{4}-\d{2}-\d{2} \d{2}:\d{2} GMT\+8\]', '', clean)
        clean = clean.strip()
        if not clean:
            continue

        label = {"user": "用户", "assistant": "助手"}.get(role, role)
        lines.append(f"**{label}：** {clean}\n")
    return "\n".join(lines)


def find_session_files(session_id: str | None) -> list[Path]:
    """查找 session 文件"""
    if not SESSIONS_DIR.exists():
        return []
    if session_id:
        # 精确匹配
        matches = list(SESSIONS_DIR.glob(f"*{session_id}*"))
        return matches
    # 返回最近的几个 session
    files = sorted(SESSIONS_DIR.glob("*.json"), key=lambda f: f.stat().st_mtime, reverse=True)
    return files[:3]


def capture_session(session_id: str | None, memory_dir: Path, auto: bool = False):
    """捕获 session 并归档"""
    conv_dir = memory_dir / "conversations"
    conv_dir.mkdir(parents=True, exist_ok=True)

    date_str = datetime.now().strftime("%Y-%m-%d")
    filepath = conv_dir / f"{date_str}.md"

    session_files = find_session_files(session_id)
    if not session_files:
        print("⚠️ 未找到 session 文件")
        print(f"  查找路径: {SESSIONS_DIR}")
        print("  提示: 确认 OpenClaw session 存储路径是否正确")
        return

    all_entries = []
    for sf in session_files:
        try:
            data = json.loads(sf.read_text(encoding="utf-8"))
            messages = data if isinstance(data, list) else data.get("messages", data.get("conversation", []))
        except (json.JSONDecodeError, AttributeError):
            continue

        if not messages:
            continue

        topic = detect_topic(messages)
        tags = detect_tags(messages)
        formatted = format_conversation(messages)
        now = datetime.now().strftime("%H:%M")

        entry = f"\n## [{now}] Session: {sf.stem}\n### 话题：{topic}\n"
        entry += f"**标签：** {', '.join(tags)}\n\n"
        entry += formatted
        all_entries.append(entry)

    if not all_entries:
        print("⚠️ 未提取到有效对话内容")
        return

    full_content = f"# {date_str} 对话记录\n" + "\n".join(all_entries)
    if filepath.exists():
        filepath.write_text(full_content, encoding="utf-8")
    else:
        filepath.write_text(full_content, encoding="utf-8")

    print(f"✅ 已捕获 {len(all_entries)} 个 session → {filepath}")


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="捕获 OpenClaw session 历史并归档")
    p.add_argument("--session", "-s", default=None, help="Session ID（不指定则捕获最近的）")
    p.add_argument("--memory-dir", default=None, help="记忆目录路径")
    p.add_argument("--auto", "-a", action="store_true", help="自动模式（配合 cron 使用）")
    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)
    md.mkdir(parents=True, exist_ok=True)
    capture_session(args.session, md, args.auto)


path: scripts/changelog.py
content: #!/usr/bin/env python3
"""Changelog 自动生成：从 git log 提取版本变更记录"""

import argparse
import re
import subprocess
from datetime import datetime
from pathlib import Path

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"
REPO_DIR = Path(__file__).resolve().parent.parent


def get_git_log(repo_dir: Path, limit: int = 50) -> list[dict]:
    """获取 git log"""
    result = subprocess.run(
        ["git", "log", f"--max-count={limit}", "--pretty=format:%H|%ai|%s"],
        cwd=repo_dir, capture_output=True, text=True, timeout=10
    )
    if result.returncode != 0:
        return []

    commits = []
    for line in result.stdout.strip().split("\n"):
        if "|" not in line:
            continue
        hash_, date, msg = line.split("|", 2)
        commits.append({
            "hash": hash_[:8],
            "date": date[:10],
            "time": date[11:16],
            "message": msg.strip(),
        })
    return commits


def extract_version(message: str) -> str | None:
    """从提交消息提取版本号"""
    match = re.match(r'[vV](\d+(?:\.\d+)*)', message)
    return match.group(1) if match else None


def parse_changes(message: str) -> list[str]:
    """从提交消息提取变更项"""
    changes = []
    for line in message.split("\n"):
        line = line.strip()
        if line.startswith("- "):
            changes.append(line[2:])
        elif line.startswith("* "):
            changes.append(line[2:])
    return changes


def generate_changelog(repo_dir: Path, output_path: Path | None = None) -> str:
    """生成 Changelog"""
    commits = get_git_log(repo_dir)
    if not commits:
        return "无法获取 git 历史"

    sections = []
    current_version = "unreleased"

    for commit in commits:
        version = extract_version(commit["message"])
        if version:
            current_version = f"v{version}"
            sections.append({
                "version": current_version,
                "date": commit["date"],
                "changes": parse_changes(commit["message"]),
            })
        elif sections:
            # 追加到当前版本
            extras = parse_changes(commit["message"])
            sections[-1]["changes"].extend(extras)

    if not sections:
        return "无版本记录"

    # 生成 Markdown
    lines = ["# Changelog\n"]
    lines.append(f"最后更新：{datetime.now().strftime('%Y-%m-%d')}\n")

    for section in sections:
        lines.append(f"## {section['version']} ({section['date']})\n")
        for change in section["changes"]:
            if change:
                lines.append(f"- {change}")
        lines.append("")

    changelog = "\n".join(lines)

    if output_path:
        output_path.parent.mkdir(parents=True, exist_ok=True)
        output_path.write_text(changelog, encoding="utf-8")
        print(f"✅ Changelog 已生成: {output_path}")
    else:
        print(changelog)

    return changelog


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="Changelog 自动生成")
    p.add_argument("--output", "-o", default=None)
    p.add_argument("--repo", default=None)
    args = p.parse_args()

    repo = Path(args.repo) if args.repo else REPO_DIR
    out = Path(args.output) if args.output else None
    generate_changelog(repo, out)


path: scripts/clean_old.py
content: #!/usr/bin/env python3
"""清理旧记忆：归档超龄蒸馏文件和对话日志"""

import argparse
from datetime import datetime, timedelta
from pathlib import Path

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"


def parse_date(filename: str) -> datetime | None:
    """从文件名解析日期"""
    import re
    # YYYY-MM-DD 格式
    m = re.match(r"(\d{4}-\d{2}-\d{2})", filename)
    if m:
        try:
            return datetime.strptime(m.group(1), "%Y-%m-%d")
        except ValueError:
            pass
    # YYYY-WXX 格式
    m = re.match(r"(\d{4}-W\d{2})", filename)
    if m:
        # 粗略解析为该周周一
        from datetime import timedelta
        year = int(m.group(1)[:4])
        week = int(m.group(1).split("W")[1])
        jan4 = datetime(year, 1, 4)
        start = jan4 - timedelta(days=jan4.isoweekday() - 1) + timedelta(weeks=week - 1)
        return start
    return None


def clean_old(memory_dir: Path, days: int, dry_run: bool = True):
    archive_dir = memory_dir / "archive"
    cutoff = datetime.now() - timedelta(days=days)

    dirs_to_check = [
        ("conversations", "对话日志"),
        ("distillations", "蒸馏摘要"),
    ]

    moved = []
    for subdir, label in dirs_to_check:
        target_dir = memory_dir / subdir
        if not target_dir.exists():
            continue

        for f in sorted(target_dir.glob("*")):
            if not f.is_file():
                continue
            fdate = parse_date(f.stem)
            if fdate and fdate < cutoff:
                dest = archive_dir / subdir
                dest.mkdir(parents=True, exist_ok=True)
                dest_file = dest / f.name
                if dry_run:
                    moved.append((str(f), str(dest_file), label))
                else:
                    import shutil
                    shutil.move(str(f), str(dest_file))
                    moved.append((str(f), str(dest_file), label))

    if not moved:
        print(f"✅ 没有超过 {days} 天的文件需要清理")
        return

    print(f"{'🔍 [模拟]' if dry_run else '🗑️ [执行]'} 超过 {days} 天的文件清理：\n")
    for src, dest, label in moved:
        print(f"  [{label}] {Path(src).name}")
        print(f"    → archive/{Path(dest).relative_to(archive_dir)}")
    print(f"\n共 {len(moved)} 个文件")

    if dry_run:
        print(f"\n提示: 去掉 --dry-run 参数执行实际清理")


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="清理旧记忆")
    p.add_argument("--memory-dir", default=None)
    p.add_argument("--days", type=int, default=90, help="保留天数（默认90天）")
    p.add_argument("--dry-run", action="store_true", default=True, help="模拟运行（默认开启）")
    p.add_argument("--execute", action="store_true", help="实际执行清理")
    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)
    clean_old(md, args.days, dry_run=not args.execute)


path: scripts/cli.py
content: #!/usr/bin/env python3
"""CLI 入口：统一命令行工具"""

import argparse
import sys
from pathlib import Path

SCRIPTS_DIR = Path(__file__).parent


def run_script(name: str, args: list[str]):
    script = SCRIPTS_DIR / f"{name}.py"
    if not script.exists():
        print(f"❌ 未知命令: {name}")
        print(f"   可用命令: archive, capture, summary, distill, stats, "
              f"timeline, search, contradictions, quality, integrity, "
              f"backup, graph, forgetting, tags, export, clean, index")
        sys.exit(1)
    
    import subprocess
    result = subprocess.run([sys.executable, str(script)] + args)
    sys.exit(result.returncode)


def main():
    p = argparse.ArgumentParser(
        prog="long-memory",
        description="🧠 全量对话记忆系统 — 企业级 CLI",
    )
    p.add_argument("command", help="子命令")
    p.add_argument("args", nargs="*", help="子命令参数")

    # 快捷别名
    aliases = {
        "archive": "archive_conversation",
        "capture": "capture_session",
        "summary": "generate_summary",
        "distill": "distill_week",
        "stats": "memory_stats",
        "timeline": "topic_timeline",
        "search": "search_memory",
        "contradictions": "detect_contradictions",
        "quality": "quality_check",
        "integrity": "integrity_check",
        "backup": "auto_backup",
        "graph": "memory_graph",
        "forgetting": "forgetting_curve",
        "tags": "tag_optimizer",
        "export": "export_memory",
        "clean": "clean_old",
        "index": "index_manager",
        "emotion": "emotion_analyzer",
        "persons": "person_graph",
        "recommend": "smart_recommend",
        "health": "health_dashboard",
        "privacy": "privacy",
        "import": "import_memory",
        "scheduler": "scheduler",
        "template": "memory_templates",
        "users": "user_manager",
        "config": "config_manager",
        "changelog": "changelog",
        "benchmark": "benchmark",
        "report": "html_report",
        "api": "api_server",
        "tfidf": "tfidf_search",
        "semantic": "semantic_search",
        "sqlite": "sqlite_engine",
        "embedding": "embedding_search",
        "log": "operation_log",
        "self": "self_memory",
    }

    args = p.parse_args()
    cmd = aliases.get(args.command, args.command)
    run_script(cmd, args.args)


if __name__ == "__main__":
    main()


path: scripts/config_manager.py
content: #!/usr/bin/env python3
"""配置验证与版本迁移：校验 long-memory.json 并处理版本升级"""

import argparse
import json
import re
from pathlib import Path

import sys
sys.path.insert(0, str(Path(__file__).parent))
from lib.filelock import safe_read_json, safe_write_json

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"
CURRENT_VERSION = "5.0"

# 配置 schema
CONFIG_SCHEMA = {
    "version": {"type": str, "required": True, "pattern": r"^\d+\.\d+$"},
    "memory_dir": {"type": (str, type(None)), "required": False},
    "archive_days": {"type": int, "required": False, "min": 1, "max": 3650},
    "max_memory_md_size": {"type": int, "required": False, "min": 1000, "max": 100000},
    "auto_backup": {"type": dict, "required": False},
    "auto_backup.enabled": {"type": bool, "required": False},
    "auto_backup.git_remote": {"type": (str, type(None)), "required": False},
    "auto_backup.interval_hours": {"type": int, "required": False, "min": 1},
    "quality": {"type": dict, "required": False},
    "quality.auto_flag_low_quality": {"type": bool, "required": False},
    "retention": {"type": dict, "required": False},
    "privacy": {"type": dict, "required": False},
    "tags": {"type": dict, "required": False},
    "tags.normalize": {"type": bool, "required": False},
}


def validate_config(config: dict) -> list[dict]:
    """验证配置"""
    errors = []
    warnings = []

    for key, schema in CONFIG_SCHEMA.items():
        if key not in config:
            if schema.get("required", False):
                errors.append({"field": key, "level": "error", "message": f"缺少必要字段 '{key}'"})
            continue

        value = config[key]
        expected_type = schema.get("type")

        # 类型检查
        if expected_type and not isinstance(value, expected_type):
            if isinstance(expected_type, tuple):
                if not any(isinstance(value, t) for t in expected_type):
                    errors.append({"field": key, "level": "error",
                                 "message": f"'{key}' 类型错误: 期望 {expected_type}, 实际 {type(value).__name__}"})
            elif isinstance(expected_type, type):
                errors.append({"field": key, "level": "error",
                             "message": f"'{key}' 类型错误: 期望 {expected_type.__name__}, 实际 {type(value).__name__}"})

        # 范围检查
        if "min" in schema and isinstance(value, (int, float)):
            if value < schema["min"]:
                errors.append({"field": key, "level": "error",
                             "message": f"'{key}' 值 {value} 小于最小值 {schema['min']}"})
        if "max" in schema and isinstance(value, (int, float)):
            if value > schema["max"]:
                errors.append({"field": key, "level": "error",
                             "message": f"'{key}' 值 {value} 超过最大值 {schema['max']}"})

        # 模式检查
        if "pattern" in schema and isinstance(value, str):
            if not re.match(schema["pattern"], value):
                errors.append({"field": key, "level": "error",
                             "message": f"'{key}' 格式不匹配: {schema['pattern']}"})

    # 嵌套对象检查
    for key in ["auto_backup", "quality", "retention", "privacy", "tags"]:
        if key in config and isinstance(config[key], dict):
            sub_schema = {k: v for k, v in CONFIG_SCHEMA.items() if k.startswith(f"{key}.")}
            for sub_key, sub_val in sub_schema.items():
                actual_key = sub_key.replace(f"{key}.", "")
                if actual_key in config[key]:
                    sub_value = config[key][actual_key]
                    expected_type = sub_val.get("type")
                    if expected_type and not isinstance(sub_value, expected_type):
                        if isinstance(expected_type, tuple):
                            pass  # 允许 None
                        else:
                            errors.append({"field": f"{key}.{actual_key}", "level": "error",
                                         "message": f"'{key}.{actual_key}' 类型错误"})

    # 版本检查
    version = config.get("version", "0")
    try:
        major = int(version.split(".")[0])
        if major < int(CURRENT_VERSION.split(".")[0]):
            warnings.append({"level": "warning", "message": f"配置版本 {version} 低于当前版本 {CURRENT_VERSION}，建议迁移"})
    except (ValueError, IndexError):
        errors.append({"field": "version", "level": "error", "message": "版本号格式错误"})

    return errors + warnings


def migrate_config(config: dict, target_version: str = CURRENT_VERSION) -> dict:
    """迁移配置到目标版本"""
    source_version = config.get("version", "0")

    if source_version == target_version:
        return config

    # v0/v1 → v5
    config["version"] = target_version

    # 确保必要字段存在
    defaults = {
        "archive_days": 90,
        "max_memory_md_size": 8000,
        "auto_backup": {"enabled": False, "git_remote": None, "interval_hours": 6},
        "quality": {"auto_flag_low_quality": True},
        "retention": {},
        "privacy": {"encrypt_tags": [], "auto_redact_patterns": []},
        "tags": {"normalize": True, "custom_aliases": {}},
    }

    for key, value in defaults.items():
        if key not in config:
            config[key] = value
        elif isinstance(value, dict) and isinstance(config[key], dict):
            for sub_key, sub_value in value.items():
                if sub_key not in config[key]:
                    config[key][sub_key] = sub_value

    return config


def print_validation(results: list[dict]):
    if not results:
        print("✅ 配置验证通过，无问题")
        return

    print("=" * 60)
    print("🔍 配置验证结果")
    print("=" * 60)

    for r in results:
        level = r.get("level", "warning")
        icon = "🔴" if level == "error" else "⚠️"
        field = r.get("field", "")
        msg = r["message"]
        print(f"  {icon} [{level:7s}] {field + ': ' if field else ''}{msg}")


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="配置验证与迁移")
    p.add_argument("--memory-dir", default=None)
    p.add_argument("--config", default=None, help="配置文件路径")
    p.add_argument("--migrate", action="store_true", help="执行迁移")
    p.add_argument("--target-version", default=CURRENT_VERSION)
    args = p.parse_args()

    if args.config:
        config_path = Path(args.config)
    else:
        md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
        md = Path(md)
        # 查找配置文件
        config_path = md / "long-memory.json"
        if not config_path.exists():
            # 在 skill 目录查找
            config_path = Path(__file__).parent.parent / "long-memory.json"

    if not config_path.exists():
        print("⚠️ 未找到配置文件 long-memory.json")
        exit(1)

    config = safe_read_json(config_path)

    if args.migrate:
        migrated = migrate_config(config, args.target_version)
        safe_write_json(config_path, migrated)
        print(f"✅ 配置已迁移到 v{args.target_version}")

    results = validate_config(config if not args.migrate else migrated)
    print_validation(results)


path: scripts/detect_contradictions.py
content: #!/usr/bin/env python3
"""记忆矛盾检测：发现前后矛盾的记录（偏好变化、决策冲突）"""

import argparse
import re
from collections import defaultdict
from datetime import datetime
from pathlib import Path

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"


# 偏好矛盾模式：喜欢X vs 不喜欢X
PREFERENCE_PATTERNS = [
    (r'(?:喜欢|爱|偏好|习惯|要用|以后都)(.{1,20})', "positive"),
    (r'(?:不喜欢|讨厌|反感|不要|别用|以后别)(.{1,20})', "negative"),
]

# 决策矛盾模式
DECISION_PATTERNS = [
    r'(?:决定|确定|拍板|选[择定]|用[这个那个])(.{1,30})',
    r'(?:换成|改为|改成|切换到)(.{1,30})',
]


def load_all_conversations(memory_dir: Path) -> list[dict]:
    """加载所有对话记录"""
    conv_dir = memory_dir / "conversations"
    if not conv_dir.exists():
        return []

    records = []
    for fp in sorted(conv_dir.glob("*.md")):
        content = fp.read_text(encoding="utf-8")
        records.append({
            "date": fp.stem,
            "content": content,
            "path": str(fp),
        })
    return records


def detect_preference_contradictions(records: list[dict]) -> list[dict]:
    """检测偏好矛盾"""
    preferences = defaultdict(list)

    for rec in records:
        user_msgs = re.findall(r'\*\*用户[：:]\*\*\s*(.+)', rec["content"])
        for msg in user_msgs:
            for pattern, polarity in PREFERENCE_PATTERNS:
                matches = re.findall(pattern, msg)
                for m in matches:
                    # 提取关键对象（去掉常见虚词）
                    obj = re.sub(r'^[了的是个把那些]', '', m.strip())
                    if len(obj) >= 2:
                        preferences[obj].append({
                            "polarity": polarity,
                            "date": rec["date"],
                            "message": msg.strip()[:80],
                        })

    contradictions = []
    for obj, entries in preferences.items():
        polarities = set(e["polarity"] for e in entries)
        if len(polarities) > 1:
            contradictions.append({
                "type": "偏好矛盾",
                "object": obj,
                "details": sorted(entries, key=lambda x: x["date"]),
            })

    return contradictions


def detect_decision_contradictions(records: list[dict]) -> list[dict]:
    """检测决策矛盾"""
    decisions = defaultdict(list)

    for rec in records:
        content = rec["content"]
        for pattern in DECISION_PATTERNS:
            matches = re.findall(pattern, content)
            for m in matches:
                clean = m.strip().rstrip("。")
                if len(clean) >= 3:
                    decisions[clean].append(rec["date"])

    # 查找包含"换成/改为"的，看是否有对应的早期决策
    contradictions = []
    for content, dates in decisions.items():
        change_match = re.match(r'(?:换成|改为|改成|切换到)(.+)', content)
        if change_match:
            new_thing = change_match.group(1).strip()
            # 看看之前是否有不同的决策
            for other_content, other_dates in decisions.items():
                if other_content != content and new_thing not in other_content:
                    if other_dates[0] < dates[-1]:
                        contradictions.append({
                            "type": "决策变更",
                            "from": other_content,
                            "to": content,
                            "from_date": other_dates[0],
                            "to_date": dates[-1],
                        })

    return contradictions


def detect_todo_inconsistencies(records: list[dict]) -> list[dict]:
    """检测待办一致性：标记完成但后面又出现的"""
    todos = defaultdict(list)

    for rec in records:
        for line in rec["content"].split("\n"):
            line = line.strip()
            if line.startswith("- [ ] "):
                todos[line[6:].strip()].append({"date": rec["date"], "status": "open"})
            elif line.startswith("- [x] ") or line.startswith("- [X] "):
                todos[line[6:].strip()].append({"date": rec["date"], "status": "done"})

    inconsistencies = []
    for todo, entries in todos.items():
        statuses = [e["status"] for e in entries]
        # 完成后又打开
        if "done" in statuses and "open" in statuses:
            sorted_entries = sorted(entries, key=lambda x: x["date"])
            inconsistencies.append({
                "type": "待办反复",
                "todo": todo,
                "details": sorted_entries,
            })

    return inconsistencies


def print_contradictions(preferences: list, decisions: list, todos: list):
    all_issues = []
    all_issues.extend([{"severity": "⚠️", **p} for p in preferences])
    all_issues.extend([{"severity": "🔄", **d} for d in decisions])
    all_issues.extend([{"severity": "📋", **t} for t in todos])

    if not all_issues:
        print("✅ 未发现记忆矛盾，一切一致")
        return

    print("=" * 60)
    print(f"🔍 记忆矛盾检测（发现 {len(all_issues)} 个潜在问题）")
    print("=" * 60)

    for issue in all_issues:
        sev = issue["severity"]
        if issue["type"] == "偏好矛盾":
            print(f"\n{sev} 偏好矛盾：{issue['object']}")
            for d in issue["details"]:
                polarity = "✅ 喜欢" if d["polarity"] == "positive" else "❌ 不喜欢"
                print(f"   [{d['date']}] {polarity} — {d['message']}")

        elif issue["type"] == "决策变更":
            print(f"\n{sev} 决策变更：")
            print(f"   [{issue['from_date']}] {issue['from']}")
            print(f"   [{issue['to_date']}] {issue['to']}")

        elif issue["type"] == "待办反复":
            print(f"\n{sev} 待办反复：{issue['todo']}")
            for d in issue["details"]:
                icon = "🟢" if d["status"] == "done" else "🔴"
                print(f"   [{d['date']}] {icon} {d['status']}")


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="记忆矛盾检测")
    p.add_argument("--memory-dir", default=None)
    p.add_argument("--json", action="store_true")
    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)

    records = load_all_conversations(md)
    if not records:
        print("📭 没有对话记录")
        exit(0)

    prefs = detect_preference_contradictions(records)
    decs = detect_decision_contradictions(records)
    todos = detect_todo_inconsistencies(records)

    if args.json:
        import json
        print(json.dumps({"preferences": prefs, "decisions": decs, "todos": todos},
                         ensure_ascii=False, indent=2))
    else:
        print_contradictions(prefs, decs, todos)


path: scripts/distill_week.py
content: #!/usr/bin/env python3
"""周蒸馏：从对话日志中提取精华，自动更新 MEMORY.md"""

import argparse
import re
import shutil
from datetime import datetime, timedelta
from pathlib import Path

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"
MAX_MEMORY_SIZE = 8000  # MEMORY.md 最大字数


def get_week_dates(week_str: str) -> list[str]:
    match = re.match(r"(\d{4})-W(\d{2})", week_str)
    if not match:
        year, week = int(week_str[:4]), int(week_str.split("-W")[1])
    else:
        year, week = int(match.group(1)), int(match.group(2))
    jan4 = datetime(year, 1, 4)
    start = jan4 - timedelta(days=jan4.isoweekday() - 1) + timedelta(weeks=week - 1)
    dates = []
    for i in range(7):
        d = start + timedelta(days=i)
        if d.year == year:
            dates.append(d.strftime("%Y-%m-%d"))
    return dates


# 提取规则
EXTRACT_PATTERNS = {
    "决策": [
        r'决定[了用是]', r'确定[了用是]', r'拍板', r'就用[这个这个方案]',
        r'确认[了用]', r'选[择定]', r'方[案向]', r'最终',
    ],
    "待办": [
        r'待办', r'todo', r'记得', r'别忘了', r'回头[要得]',
        r'下一步', r'接着[做搞]', r'还需要', r'还没[做搞]',
    ],
    "偏好": [
        r'我喜欢', r'我不喜欢', r'以后[都要都别]', r'不要[给用做]',
        r'习惯[了性]', r'偏[好爱]', r'讨厌', r'反感',
    ],
    "进展": [
        r'完成[了]', r'搞定[了]', r'已[经做]', r'上线[了]',
        r'部署[了]', r'提交[了]', r'推[了送]', r'修复[了]',
    ],
}

DECISION_WORDS = set()
for words in EXTRACT_PATTERNS.values():
    DECISION_WORDS.update(words)


def extract_highlights_smart(text: str) -> dict:
    """智能提取精华，返回分类结果"""
    result = {"决策": [], "待办": [], "偏好": [], "进展": [], "话题": set()}

    # 提取标签
    tag_matches = re.findall(r'\*\*标签：\*\*\s*(.+)', text)
    for tags in tag_matches:
        for tag in tags.split("，"):
            tag = tag.strip()
            if tag:
                result["话题"].add(tag)

    # 提取话题
    topic_matches = re.findall(r'### 话题：(.+)', text)
    for topic in topic_matches:
        result["话题"].add(topic.strip().rstrip("."))

    # 按行扫描
    lines = text.split("\n")
    for i, line in enumerate(lines):
        line_stripped = line.strip()
        # 已有标记的行直接提取
        if line_stripped.startswith("**关键决策"):
            result["决策"].append(line_stripped)
        elif line_stripped.startswith("**重要"):
            result["进展"].append(line_stripped)
        elif line_stripped.startswith("- [ ]"):
            result["待办"].append(line_stripped)
        else:
            # 智能匹配：包含关键词的用户消息
            if "**用户：**" in line_stripped:
                for category, patterns in EXTRACT_PATTERNS.items():
                    for pattern in patterns:
                        if re.search(pattern, line_stripped):
                            clean = re.sub(r'\*\*用户：\*\*\s*', '', line_stripped)
                            clean = re.sub(r'\[.*?\]\s*', '', clean).strip()
                            if len(clean) <= 100:
                                result[category].append(clean)

    # 去重
    for key in ["决策", "待办", "偏好", "进展"]:
        seen = set()
        deduped = []
        for item in result[key]:
            if item not in seen:
                seen.add(item)
                deduped.append(item)
        result[key] = deduped

    return result


def format_distillation(highlights: dict, week_str: str, dates: list[str]) -> str:
    """格式化蒸馏摘要"""
    parts = [f"# {week_str} 蒸馏摘要\n", f"**覆盖日期：** {', '.join(dates)}\n"]

    if highlights["话题"]:
        parts.append(f"\n## 话题\n")
        parts.append(", ".join(sorted(highlights["话题"])) + "\n")

    sections = [
        ("决策", "### 关键决策"),
        ("进展", "### 项目进展"),
        ("待办", "### 待办事项"),
        ("偏好", "### 用户偏好"),
    ]
    for key, title in sections:
        if highlights[key]:
            parts.append(f"\n## {title}\n")
            for item in highlights[key]:
                parts.append(f"- {item}\n")

    return "\n".join(parts)


def format_distillation_json(highlights: dict, week_str: str, dates: list[str]) -> str:
    """输出 JSON 格式"""
    return json.dumps({
        "week": week_str,
        "dates": dates,
        "topics": sorted(highlights["话题"]),
        "decisions": highlights["决策"],
        "progress": highlights["进展"],
        "todos": highlights["待办"],
        "preferences": highlights["偏好"],
    }, ensure_ascii=False, indent=2)


def merge_memory_md(highlights: dict, week_str: str, memory_md_path: Path):
    """合并精华到 MEMORY.md"""
    if not memory_md_path.exists():
        memory_md_path.write_text("", encoding="utf-8")
        return

    content = memory_md_path.read_text(encoding="utf-8")

    # 新增内容
    additions = []
    if highlights["决策"]:
        additions.append(f"\n### {week_str} 决策\n" + "\n".join(f"- {d}" for d in highlights["决策"]))
    if highlights["偏好"]:
        additions.append(f"\n### {week_str} 偏好\n" + "\n".join(f"- {p}" for p in highlights["偏好"]))
    if highlights["待办"]:
        additions.append(f"\n### {week_str} 待办\n" + "\n".join(f"- {t}" for t in highlights["待办"]))

    if not additions:
        return

    # 追加到文件末尾
    new_content = content.rstrip() + "\n" + "\n".join(additions) + "\n"

    # 超限则归档旧内容
    if len(new_content) > MAX_MEMORY_SIZE:
        archive_dir = memory_md_path.parent / "archive"
        archive_dir.mkdir(parents=True, exist_ok=True)
        archive_path = archive_dir / f"MEMORY-{week_str}.md"
        archive_path.write_text(content, encoding="utf-8")
        # 重新生成精简版
        new_content = generate_compact_memory(new_content, highlights, week_str)
        new_content += f"\n\n> 旧记忆已归档到 {archive_path.relative_to(memory_md_path.parent)}\n"

    memory_md_path.write_text(new_content, encoding="utf-8")
    print(f"✅ MEMORY.md 已更新")


def generate_compact_memory(content: str, highlights: dict, week_str: str) -> str:
    """生成精简版 MEMORY.md"""
    lines = content.split("\n")
    keep = []
    in_section = False
    for line in lines:
        # 保留标题和结构
        if line.startswith("# ") or line.startswith("## "):
            keep.append(line)
            in_section = True
        elif line.startswith("### "):
            in_section = True
            keep.append(line)
        elif in_section and line.strip():
            # 保留有内容的行，但截断过长的
            if len(line) <= 200:
                keep.append(line)
    # 截断到 MAX_MEMORY_SIZE
    result = "\n".join(keep)
    if len(result) > MAX_MEMORY_SIZE:
        result = result[:MAX_MEMORY_SIZE]
    return result


def distill_week(week_str: str, memory_dir: Path, json_output: bool = False):
    conv_dir = memory_dir / "conversations"
    distill_dir = memory_dir / "distillations"
    distill_dir.mkdir(parents=True, exist_ok=True)
    dates = get_week_dates(week_str)

    all_content = []
    found_files = []
    for d in dates:
        fp = conv_dir / f"{d}.md"
        if fp.exists():
            found_files.append(d)
            all_content.append(fp.read_text(encoding="utf-8"))

    if not found_files:
        print(f"⚠️ 未找到 {week_str} 的对话记录")
        return

    all_highlights = {"决策": [], "待办": [], "偏好": [], "进展": [], "话题": set()}
    for content in all_content:
        h = extract_highlights_smart(content)
        for key in ["决策", "待办", "偏好", "进展"]:
            all_highlights[key].extend(h[key])
        all_highlights["话题"].update(h["话题"])

    if json_output:
        output = format_distillation_json(all_highlights, week_str, found_files)
        print(output)
    else:
        output = format_distillation(all_highlights, week_str, found_files)

    distill_path = distill_dir / f"{week_str}.md"
    distill_path.write_text(output, encoding="utf-8")
    print(f"✅ 蒸馏完成 → {distill_path}")
    print(f"  📝 覆盖 {len(found_files)} 天的对话记录")
    print(f"  💡 话题: {', '.join(sorted(all_highlights['话题'])) or '无'}")
    print(f"  🎯 决策: {len(all_highlights['决策'])} 条")
    print(f"  📋 待办: {len(all_highlights['待办'])} 条")
    print(f"  ❤️ 偏好: {len(all_highlights['偏好'])} 条")

    # 自动更新 MEMORY.md
    memory_md = memory_dir.parent / "MEMORY.md"
    merge_memory_md(all_highlights, week_str, memory_md)


if __name__ == "__main__":
    import json
    p = argparse.ArgumentParser(description="周蒸馏")
    p.add_argument("--week", default=datetime.now().strftime("%Y-W%V"))
    p.add_argument("--memory-dir", default=None)
    p.add_argument("--json", action="store_true", help="JSON 格式输出")
    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)
    md.mkdir(parents=True, exist_ok=True)
    distill_week(args.week, md, args.json)


path: scripts/embedding_search.py
content: #!/usr/bin/env python3
"""Embedding 向量搜索：真正的语义理解，不靠关键词匹配

使用 sentence-transformers 的 all-MiniLM-L6-v2 模型（免费、本地、80MB）
安装：pip install sentence-transformers
"""

import argparse
import json
import os
import re
from datetime import datetime, timedelta
from pathlib import Path

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"
EMBEDDING_CACHE = ".embeddings.json"
MODEL_NAME = "all-MiniLM-L6-v2"

# 全局模型实例（懒加载）
_model = None
_vectorizer = None


def get_model():
    """懒加载 Embedding 模型"""
    global _model
    if _model is None:
        try:
            from sentence_transformers import SentenceTransformer
            print(f"📦 加载 Embedding 模型: {MODEL_NAME}（首次需下载，约80MB）")
            _model = SentenceTransformer(MODEL_NAME)
            print("✅ 模型加载完成")
        except ImportError:
            print("❌ 需要安装 sentence-transformers:")
            print("   pip install sentence-transformers")
            exit(1)
    return _model


def encode_texts(texts: list[str], batch_size: int = 32) -> list[list[float]]:
    """编码文本为向量"""
    model = get_model()
    embeddings = model.encode(texts, batch_size=batch_size, show_progress_bar=False,
                              normalize_embeddings=True)
    return [e.tolist() for e in embeddings]


def encode_query(text: str) -> list[float]:
    """编码查询"""
    model = get_model()
    embedding = model.encode(text, normalize_embeddings=True)
    return embedding.tolist()


def cosine_similarity(a: list[float], b: list[float]) -> float:
    """计算余弦相似度"""
    dot = sum(x * y for x, y in zip(a, b))
    norm_a = sum(x ** 2 for x in a) ** 0.5
    norm_b = sum(x ** 2 for x in b) ** 0.5
    if norm_a == 0 or norm_b == 0:
        return 0.0
    return dot / (norm_a * norm_b)


def build_embedding_index(memory_dir: Path) -> dict:
    """构建 Embedding 索引"""
    conv_dir = memory_dir / "conversations"
    if not conv_dir.exists():
        return {"documents": [], "model": MODEL_NAME}

    # 收集所有文档片段（按 session 分段）
    documents = []
    for fp in sorted(conv_dir.glob("*.md")):
        content = fp.read_text(encoding="utf-8")
        sections = re.split(r'(?=## \[)', content)
        for section in sections:
            if not section.strip() or len(section.strip()) < 20:
                continue
            time_match = re.search(r'\[(\d{2}:\d{2})\]', section)
            topic_match = re.search(r'###\s*话题[：:]\s*(.+)', section)
            tag_match = re.search(r'\*\*标签[：:]\*\*\s*(.+)', section)

            # 提取用户消息作为文档内容
            user_msgs = re.findall(r'\*\*用户[：:]\*\*\s*(.+)', section)
            doc_text = " ".join(user_msgs) if user_msgs else section.strip()[:500]

            documents.append({
                "date": fp.stem,
                "time": time_match.group(1) if time_match else "00:00",
                "topic": topic_match.group(1).strip() if topic_match else "",
                "tags": [t.strip() for t in tag_match.group(1).split("，") if t.strip()] if tag_match else [],
                "text": doc_text,
                "embedding": None,
            })

    if not documents:
        return {"documents": [], "model": MODEL_NAME}

    # 批量编码
    print(f"🔄 编码 {len(documents)} 个文档片段...")
    texts = [d["text"] for d in documents]
    embeddings = encode_texts(texts)

    for doc, emb in zip(documents, embeddings):
        doc["embedding"] = emb

    return {
        "documents": documents,
        "model": MODEL_NAME,
        "built": datetime.now().isoformat(),
        "count": len(documents),
    }


def save_index(index: dict, memory_dir: Path):
    """保存索引"""
    cache_path = memory_dir / EMBEDDING_CACHE
    # 不保存向量到 JSON（太大），单独存
    vectors = []
    for doc in index["documents"]:
        vectors.append(doc.pop("embedding", None))

    meta = {"documents": index["documents"], "model": index["model"],
            "built": index["built"], "count": index["count"]}

    # 向量单独存为二进制
    import struct
    vec_path = memory_dir / ".embeddings.bin"
    with open(vec_path, "wb") as f:
        for vec in vectors:
            packed = struct.pack(f'{len(vec)}f', *vec)
            f.write(packed)

    meta["vector_file"] = ".embeddings.bin"
    meta["vector_dims"] = len(vectors[0]) if vectors else 0
    cache_path.write_text(json.dumps(meta, ensure_ascii=False), encoding="utf-8")
    print(f"✅ 索引已保存（{len(vectors)} 向量，{cache_path.stat().st_size / 1024:.1f} KB）")


def load_index(memory_dir: Path) -> dict:
    """加载索引"""
    import struct
    cache_path = memory_dir / EMBEDDING_CACHE
    vec_path = memory_dir / ".embeddings.bin"

    if not cache_path.exists() or not vec_path.exists():
        return None

    meta = json.loads(cache_path.read_text(encoding="utf-8"))
    dims = meta.get("vector_dims", 384)

    with open(vec_path, "rb") as f:
        data = f.read()

    vec_size = dims * 4  # float32 = 4 bytes
    num_vecs = len(data) // vec_size
    vectors = []
    for i in range(num_vecs):
        offset = i * vec_size
        vec = list(struct.unpack(f'{dims}f', data[offset:offset + vec_size]))
        vectors.append(vec)

    for i, doc in enumerate(meta["documents"]):
        if i < len(vectors):
            doc["embedding"] = vectors[i]

    return meta


def search_embeddings(memory_dir: Path, query: str, top_n: int = 10,
                      topic: str = None, tag: str = None,
                      days: int = None) -> list[dict]:
    """向量语义搜索"""
    # 加载索引
    index = load_index(memory_dir)
    if index is None:
        print("🔄 首次使用，构建索引中...")
        index = build_embedding_index(memory_dir)
        save_index(index, memory_dir)
        # 重新加载
        index = load_index(memory_dir)

    # 编码查询
    query_vec = encode_query(query)

    # 计算相似度
    cutoff = datetime.now() - timedelta(days=days) if days else None

    results = []
    for doc in index["documents"]:
        # 过滤
        if cutoff and doc["date"] < cutoff.strftime("%Y-%m-%d"):
            continue
        if topic and topic.lower() not in doc.get("topic", "").lower():
            continue
        if tag and not any(tag.lower() in t.lower() for t in doc.get("tags", [])):
            continue

        if doc.get("embedding"):
            sim = cosine_similarity(query_vec, doc["embedding"])
            if sim > 0.1:
                results.append({
                    "date": doc["date"],
                    "time": doc["time"],
                    "topic": doc["topic"],
                    "tags": doc["tags"],
                    "score": round(sim, 4),
                    "text": doc["text"][:200],
                })

    results.sort(key=lambda x: -x["score"])
    return results[:top_n]


def print_results(results: list[dict]):
    if not results:
        print("📭 未找到语义相关的记忆")
        return

    print(f"🧠 语义搜索（{len(results)} 条结果）\n")
    for i, r in enumerate(results, 1):
        score_bar = "█" * int(r["score"] * 40)
        print(f"  [{i}] {r['date']} [{r['time']}] {r['score']:.4f} |{score_bar}")
        if r["topic"]:
            print(f"      话题: {r['topic']}")
        if r["tags"]:
            print(f"      标签: {', '.join(r['tags'])}")
        print(f"      内容: {r['text'][:80]}...")


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="Embedding 向量语义搜索")
    sub = p.add_subparsers(dest="command")

    search_cmd = sub.add_parser("search", help="语义搜索")
    search_cmd.add_argument("query", help="自然语言查询")
    search_cmd.add_argument("--memory-dir", default=None)
    search_cmd.add_argument("--top", type=int, default=10)
    search_cmd.add_argument("-t", "--topic", default=None)
    search_cmd.add_argument("--tag", default=None)
    search_cmd.add_argument("--days", type=int, default=None)

    build_cmd = sub.add_parser("build", help="构建索引")
    build_cmd.add_argument("--memory-dir", default=None)

    args = p.parse_args()
    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)

    if args.command == "build":
        index = build_embedding_index(md)
        save_index(index, md)
    elif args.command == "search":
        results = search_embeddings(md, args.query, args.top, args.topic, args.tag, args.days)
        print_results(results)
    else:
        p.print_help()


path: scripts/emotion_analyzer.py
content: #!/usr/bin/env python3
"""情感分析引擎：追踪对话情绪变化曲线"""

import argparse
import json
import re
from collections import Counter, defaultdict
from datetime import datetime
from pathlib import Path

import sys
sys.path.insert(0, str(Path(__file__).parent))
from lib.filelock import safe_read

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"

# 中文情感词典
EMOTION_LEXICON = {
    "positive": {
        "happy": ["开心", "高兴", "哈哈", "好棒", "太好了", "赞", "nice", "喜欢", "爱", "满足",
                  "兴奋", "期待", "有趣", "厉害", "优秀", "完美", "成功", "搞定", "酷", "牛",
                  "爽", "乐", "笑", "感恩", "感谢", "幸运", "自豪", "骄傲", "舒服", "愉快",
                  "认可", "欣赏", "惊喜", "激动", "幸福", "甜蜜", "温暖"],
        "calm": ["好的", "嗯", "可以", "行", "没问题", "了解", "明白", "知道", "收到",
                 "OK", "ok", "嗯嗯", "行吧", "随便", "都可以", "也行", "无所谓的",
                 "正常", "还行", "不错", "一般", "说得对", "有道理", "认同"],
        "engaged": ["搞", "做", "试试", "看看", "思考", "研究", "分析", "怎么", "如何",
                    "什么", "为什么", "呢", "吧", "啊", "嘛", "这个", "那个", "想法",
                    "思路", "方案", "计划", "设计", "实现", "优化", "改进", "升级"],
    },
    "negative": {
        "frustrated": ["烦", "恼火", "无语", "崩溃", "受不了", "搞不懂", "太难了",
                       "不行", "失败", "出错", "bug", "报错", "崩溃", "卡住", "卡死",
                       "慢", "卡", "延迟", "超时", "挂了", "断了", "连不上"],
        "worried": ["担心", "焦虑", "紧张", "怕", "害怕", "不确定", "风险", "危险",
                    "问题", "麻烦", "困难", "挑战", "隐患", "担忧", "顾虑", "纠结"],
        "sad": ["可惜", "遗憾", "失望", "难过", "伤心", "不开心", "不舒服", "无聊",
                "没意思", "算了", "无所谓了", "不想", "累了", "倦", "丧", "低落"],
        "confused": ["不懂", "不理解", "不明白", "迷糊", "混乱", "复杂", "搞混",
                     "什么意思", "看不懂", "听不懂", "不清楚", "不确定", "迷茫"],
    },
    "neutral": {
        "info": ["告诉", "说明", "介绍", "描述", "解释", "提醒", "通知", "告知",
                 "请注意", "重要", "需要", "记得", "别忘了"],
    }
}


def analyze_message(text: str) -> dict:
    """分析单条消息的情感"""
    text_lower = text.lower()
    scores = defaultdict(float)
    matched = []

    for category, subcategories in EMOTION_LEXICON.items():
        for sub, keywords in subcategories.items():
            count = sum(1 for kw in keywords if kw in text_lower)
            if count > 0:
                scores[sub] += count
                matched.extend([kw for kw in keywords if kw in text_lower])

    if not scores:
        return {"primary": "neutral", "sub": "info", "score": 0, "matched": []}

    primary = max(scores, key=scores.get)
    return {"primary": "negative" if primary in EMOTION_LEXICON.get("negative", {}) 
            else "positive" if primary in EMOTION_LEXICON.get("positive", {}) else "neutral",
            "sub": primary, "score": scores[primary], "matched": list(set(matched))}


def analyze_conversation(content: str, date_str: str) -> dict:
    """分析一次对话的整体情感"""
    user_msgs = re.findall(r'\*\*用户[：:]\*\*\s*(.+)', content)
    assistant_msgs = re.findall(r'\*\*助手[：:]\*\*\s*(.+)', content)

    user_emotions = [analyze_message(m) for m in user_msgs]
    assistant_emotions = [analyze_message(m) for m in assistant_msgs]

    # 按时间序列
    timeline = []
    all_msgs = re.findall(r'##\s*\[(\d{2}:\d{2})\].*?(?=##\s*\[|\Z)', content, re.DOTALL)
    for section in all_msgs:
        user_in_section = re.findall(r'\*\*用户[：:]\*\*\s*(.+)', section)
        for msg in user_in_section:
            emo = analyze_message(msg)
            timeline.append(emo)

    # 整体情绪
    emotion_counts = Counter(e["primary"] for e in timeline)
    dominant = emotion_counts.most_common(1)[0][0] if emotion_counts else "neutral"

    sub_counts = Counter(e["sub"] for e in timeline)

    return {
        "date": date_str,
        "user_messages": len(user_msgs),
        "assistant_messages": len(assistant_msgs),
        "dominant_emotion": dominant,
        "emotion_distribution": dict(emotion_counts),
        "sub_distribution": dict(sub_counts.most_common(5)),
        "timeline": timeline,
        "valence": round(sum(1 if e["primary"] == "positive" else -1 if e["primary"] == "negative" else 0 for e in timeline) / max(len(timeline), 1), 3),
    }


def analyze_all(memory_dir: Path, days: int | None = None) -> dict:
    """分析所有对话的情感"""
    from datetime import timedelta
    conv_dir = memory_dir / "conversations"
    if not conv_dir.exists():
        return {"conversations": [], "summary": {}}

    cutoff = datetime.now() - timedelta(days=days) if days else None
    results = []

    for fp in sorted(conv_dir.glob("*.md")):
        if cutoff:
            try:
                file_date = datetime.strptime(fp.stem, "%Y-%m-%d")
                if file_date < cutoff:
                    continue
            except ValueError:
                continue

        content = fp.read_text(encoding="utf-8")
        analysis = analyze_conversation(content, fp.stem)
        results.append(analysis)

    # 整体趋势
    all_valences = [r["valence"] for r in results]
    avg_valence = round(sum(all_valences) / max(len(all_valences), 1), 3)
    
    emotion_totals = Counter()
    for r in results:
        for e, c in r["emotion_distribution"].items():
            emotion_totals[e] += c

    return {
        "conversations": results,
        "summary": {
            "total_days": len(results),
            "avg_valence": avg_valence,
            "overall_mood": "积极" if avg_valence > 0.2 else "消极" if avg_valence < -0.2 else "中性",
            "emotion_totals": dict(emotion_totals),
            "date_range": f"{results[0]['date']} ~ {results[-1]['date']}" if results else "无",
        },
    }


def print_report(analysis: dict, verbose: bool = False):
    summary = analysis["summary"]
    print("=" * 60)
    print(f"💝 情感分析报告（{summary['date_range']}）")
    print("=" * 60)
    print(f"\n📊 总览：{summary['total_days']} 天对话")
    print(f"   整体情绪：{summary['overall_mood']}（效价: {summary['avg_valence']}）")
    print(f"   情绪分布：{json.dumps(summary['emotion_totals'], ensure_ascii=False)}")

    if verbose:
        print(f"\n📅 逐日分析：")
        for conv in analysis["conversations"]:
            valence_icon = "😊" if conv["valence"] > 0.2 else "😐" if conv["valence"] > -0.2 else "😔"
            print(f"   {conv['date']} {valence_icon} 效价={conv['valence']:+.2f} "
                  f"主导={conv['dominant_emotion']} "
                  f"({conv['user_messages']}用户/{conv['assistant_messages']}助手)")
            print(f"      细分: {json.dumps(conv['sub_distribution'], ensure_ascii=False)}")

    # 情绪曲线（ASCII）
    print(f"\n📈 情绪曲线：")
    for conv in analysis["conversations"]:
        v = conv["valence"]
        bar_len = int((v + 1) * 15)  # -1 to 1 → 0 to 30
        bar = "█" * max(bar_len, 1)
        marker = "🟢" if v > 0.2 else "🟡" if v > -0.2 else "🔴"
        print(f"   {marker} {conv['date']} |{bar:<30s}| {v:+.2f}")


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="情感分析")
    p.add_argument("--memory-dir", default=None)
    p.add_argument("--days", type=int, default=None)
    p.add_argument("--verbose", "-v", action="store_true")
    p.add_argument("--json", action="store_true")
    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)
    analysis = analyze_all(md, args.days)

    if args.json:
        print(json.dumps(analysis, ensure_ascii=False, indent=2))
    else:
        print_report(analysis, args.verbose)


path: scripts/export_memory.py
content: #!/usr/bin/env python3
"""记忆导出：支持 JSON / Markdown / 纯文本格式"""

import argparse
import json
import re
from datetime import datetime
from pathlib import Path

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"


def load_all(memory_dir: Path) -> dict:
    """加载所有记忆数据"""
    data = {
        "conversations": [],
        "summaries": [],
        "distillations": [],
        "memory_md": "",
    }

    for subdir, key in [("conversations", "conversations"),
                        ("summaries", "summaries"),
                        ("distillations", "distillations")]:
        d = memory_dir / subdir
        if d.exists():
            for fp in sorted(d.glob("*.md")):
                data[key].append({
                    "file": fp.stem,
                    "content": fp.read_text(encoding="utf-8"),
                    "size": fp.stat().st_size,
                })

    memory_md = memory_dir.parent / "MEMORY.md"
    if memory_md.exists():
        data["memory_md"] = memory_md.read_text(encoding="utf-8")

    return data


def export_json(data: dict, output: Path):
    """导出为 JSON"""
    output.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
    print(f"✅ JSON 导出 → {output} ({output.stat().st_size} bytes)")


def export_markdown(data: dict, output: Path):
    """导出为单个 Markdown"""
    lines = [f"# 记忆导出\n生成时间：{datetime.now().strftime('%Y-%m-%d %H:%M')}\n"]

    # MEMORY.md
    if data["memory_md"]:
        lines.append("## MEMORY.md（长期记忆）\n")
        lines.append(data["memory_md"])
        lines.append("")

    # 摘要
    if data["summaries"]:
        lines.append("## 对话摘要\n")
        for s in data["summaries"]:
            lines.append(f"### {s['file']}\n")
            lines.append(s["content"])
            lines.append("")

    # 蒸馏
    if data["distillations"]:
        lines.append("## 周蒸馏\n")
        for d in data["distillations"]:
            lines.append(f"### {d['file']}\n")
            lines.append(d["content"])
            lines.append("")

    # 全量对话
    if data["conversations"]:
        lines.append("## 全量对话\n")
        for c in data["conversations"]:
            lines.append(f"### {c['file']}\n")
            lines.append(c["content"])
            lines.append("")

    output.write_text("\n".join(lines), encoding="utf-8")
    print(f"✅ Markdown 导出 → {output} ({output.stat().st_size} bytes)")


def export_text(data: dict, output: Path):
    """导出为纯文本（去掉 markdown 格式）"""
    lines = [f"记忆导出 - {datetime.now().strftime('%Y-%m-%d %H:%M')}",
             "=" * 60, ""]

    # MEMORY.md
    if data["memory_md"]:
        lines.append("【长期记忆】")
        clean = re.sub(r'[#*\[\]]', '', data["memory_md"])
        lines.append(clean.strip())
        lines.append("")

    # 摘要
    if data["summaries"]:
        lines.append("【对话摘要】")
        for s in data["summaries"]:
            lines.append(f"--- {s['file']} ---")
            clean = re.sub(r'[#*\[\]]', '', s["content"])
            lines.append(clean.strip())
            lines.append("")

    # 对话
    if data["conversations"]:
        lines.append("【全量对话】")
        for c in data["conversations"]:
            lines.append(f"--- {c['file']} ---")
            clean = re.sub(r'[#*\[\]]', '', c["content"])
            lines.append(clean.strip())
            lines.append("")

    output.write_text("\n".join(lines), encoding="utf-8")
    print(f"✅ 纯文本导出 → {output} ({output.stat().st_size} bytes)")


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="记忆导出")
    p.add_argument("--memory-dir", default=None)
    p.add_argument("--format", "-f", choices=["json", "md", "txt"], default="json")
    p.add_argument("--output", "-o", required=True, help="输出文件路径")
    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)
    data = load_all(md)

    output = Path(args.output)
    output.parent.mkdir(parents=True, exist_ok=True)

    exporters = {"json": export_json, "md": export_markdown, "txt": export_text}
    exporters[args.format](data, output)


path: scripts/forgetting_curve.py
content: #!/usr/bin/env python3
"""遗忘曲线权重：基于艾宾浩斯遗忘曲线调整检索优先级"""

import argparse
import math
from datetime import datetime, timedelta
from pathlib import Path

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"

# 艾宾浩斯遗忘曲线参数
# 记忆保持率 = e^(-t/S)，S = 稳定性指数
# 初始 S=1，每次回忆（检索命中）S 乘以 2
STABILITY_INITIAL = 1.0
STABILITY_BOOST = 2.0
MIN_RECALL_SCORE = 0.01


def calculate_recall_score(date_str: str, access_count: int = 1) -> float:
    """
    计算记忆回忆分数（0-1，越高越容易回忆）
    
    基于艾宾浩斯遗忘曲线：
    - 越新的记忆分数越高
    - 被多次检索过的记忆更稳定（遗忘更慢）
    """
    try:
        memory_date = datetime.strptime(date_str, "%Y-%m-%d")
    except ValueError:
        return 0.5

    days_elapsed = (datetime.now() - memory_date).days
    stability = STABILITY_INITIAL * (STABILITY_BOOST ** (access_count - 1))
    
    recall = math.exp(-days_elapsed / max(stability, 0.1))
    return max(recall, MIN_RECALL_SCORE)


def score_memories(memory_dir: Path, top_n: int = 20) -> list[dict]:
    """给所有记忆文件评分"""
    conv_dir = memory_dir / "conversations"
    if not conv_dir.exists():
        return []

    import re, json
    from pathlib import Path
    
    scores = []
    for fp in sorted(conv_dir.glob("*.md")):
        content = fp.read_text(encoding="utf-8")
        date_str = fp.stem
        
        # 估算访问次数（从摘要和蒸馏中推断）
        access_count = 1
        # 如果在 MEMORY.md 中被引用过
        memory_md = memory_dir.parent / "MEMORY.md"
        if memory_md.exists():
            mm_content = memory_md.read_text(encoding="utf-8")
            # 简单启发式：如果日期出现在 MEMORY.md 中
            if date_str in mm_content:
                access_count += 2

        recall = calculate_recall_score(date_str, access_count)
        
        # 额外加权：有决策标记的记忆更重要
        has_decisions = "**关键决策" in content or "**重要" in content
        has_todos = bool(re.findall(r'- \[ \]', content))
        
        importance = 1.0
        if has_decisions:
            importance += 0.3
        if has_todos:
            importance += 0.2

        # 话题数量
        topic_count = len(re.findall(r'###\s*话题[：:]', content))
        if topic_count > 3:
            importance += 0.1

        final_score = recall * importance

        scores.append({
            "date": date_str,
            "recall_score": round(recall, 4),
            "importance": round(importance, 2),
            "final_score": round(final_score, 4),
            "days_ago": (datetime.now() - datetime.strptime(date_str, "%Y-%m-%d")).days,
            "access_count": access_count,
            "has_decisions": has_decisions,
            "has_todos": has_todos,
        })

    return sorted(scores, key=lambda x: -x["final_score"])


def print_scores(scores: list[dict], top_n: int = 20):
    if not scores:
        print("📭 没有记忆记录")
        return

    print("=" * 70)
    print("🧠 记忆遗忘曲线分析")
    print("=" * 70)
    print(f"\n{'日期':<12} {'天数':>5} {'回忆率':>8} {'重要性':>8} {'综合分':>8} {'访问':>5} 标记")
    print("-" * 70)

    for s in scores[:top_n]:
        # 回忆率可视化
        recall_bar = "█" * int(s["recall_score"] * 20)
        markers = []
        if s["has_decisions"]:
            markers.append("🎯")
        if s["has_todos"]:
            markers.append("📋")
        marker_str = " ".join(markers) if markers else ""

        print(f"{s['date']:<12} {s['days_ago']:>4}d  "
              f"{s['recall_score']:>7.2%} {s['importance']:>7.2f} "
              f"{s['final_score']:>7.3f} {s['access_count']:>4}x  "
              f"{marker_str} {recall_bar}")

    # 分析
    fresh = [s for s in scores if s["days_ago"] <= 7]
    stale = [s for s in scores if s["days_ago"] > 30]
    print(f"\n📊 分析：")
    print(f"  7天内：{len(fresh)} 条（新鲜记忆，回忆率 >50%）")
    print(f"  30天外：{len(stale)} 条（建议定期回顾以巩固）")

    low_recall = [s for s in scores if s["recall_score"] < 0.1 and s["has_decisions"]]
    if low_recall:
        print(f"  ⚠️ {len(low_recall)} 条含决策的记忆回忆率 <10%，建议蒸馏保存")


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="遗忘曲线权重")
    p.add_argument("--memory-dir", default=None)
    p.add_argument("--top", type=int, default=20)
    p.add_argument("--json", action="store_true")
    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)
    scores = score_memories(md, args.top)

    if args.json:
        import json
        print(json.dumps(scores, ensure_ascii=False, indent=2))
    else:
        print_scores(scores, args.top)


path: scripts/generate_summary.py
content: #!/usr/bin/env python3
"""生成对话摘要：从全量对话中提取 3-5 句精华摘要"""

import argparse
import re
from datetime import datetime
from pathlib import Path

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"


def load_conversation(date_str: str, memory_dir: Path) -> str | None:
    fp = memory_dir / "conversations" / f"{date_str}.md"
    if fp.exists():
        return fp.read_text(encoding="utf-8")
    return None


def extract_user_messages(text: str) -> list[str]:
    """提取所有用户消息"""
    msgs = []
    for line in text.split("\n"):
        if "**用户：**" in line:
            clean = re.sub(r'\*\*用户：\*\*\s*', '', line).strip()
            if clean and len(clean) > 2:
                msgs.append(clean)
    return msgs


def extract_assistant_key_points(text: str) -> list[str]:
    """提取助手的关键结论"""
    points = []
    in_decision = False
    for line in text.split("\n"):
        line = line.strip()
        if "**关键决策" in line or "**重要" in line:
            in_decision = True
            points.append(line)
        elif in_decision:
            if line.startswith("- ") or line.startswith("**"):
                points.append(line)
            elif line.startswith("##") or line.startswith("#"):
                in_decision = False
        # 提取 ✅ 或确认类回复
        if "搞定" in line or "完成" in line or "已推送" in line or "已创建" in line:
            clean = re.sub(r'\*\*助手[：:]\*\*\s*', '', line).strip()
            if clean:
                points.append(clean)
    return points


def generate_summary(text: str) -> dict:
    """生成结构化摘要"""
    user_msgs = extract_user_messages(text)
    key_points = extract_assistant_key_points(text)

    # 提取话题
    topics = re.findall(r'### 话题：(.+)', text)

    # 提取标签
    tags = set()
    for tag_line in re.findall(r'\*\*标签：\*\*\s*(.+)', text):
        for tag in tag_line.split("，"):
            tag = tag.strip()
            if tag:
                tags.add(tag)

    # 提取待办
    todos = []
    for line in text.split("\n"):
        line = line.strip()
        if line.startswith("- [ ]"):
            todos.append(line)

    # 生成 3-5 句摘要
    summaries = []

    # 话题概述
    if topics:
        unique_topics = []
        for t in topics:
            t = t.strip()
            if t not in unique_topics:
                unique_topics.append(t)
        if unique_topics:
            summaries.append(f"讨论了 {len(unique_topics)} 个话题：{'、'.join(unique_topics)}")

    # 关键决策
    if key_points:
        for kp in key_points[:3]:
            clean = re.sub(r'[\*\#]', '', kp).strip()
            if len(clean) <= 80:
                summaries.append(clean)

    # 用户意图（从用户消息推断）
    if user_msgs:
        intents = []
        intent_keywords = {"要求": ["要", "搞", "帮我", "给我", "我想", "能不能"],
                         "提问": ["？", "?", "什么是", "怎么", "为什么", "是不是"],
                         "决策": ["决定", "选", "确定", "拍板", "就用"]}
        for msg in user_msgs:
            for intent, kws in intent_keywords.items():
                if any(kw in msg for kw in kws) and len(msg) <= 50:
                    intents.append(f"{intent}：{msg}")
                    break
            if len(intents) >= 2:
                break
        summaries.extend(intents)

    # 待办提示
    if todos:
        summaries.append(f"产生 {len(todos)} 个待办事项")

    # 限制 3-5 句
    summaries = summaries[:5]
    if not summaries:
        summaries = ["(无明显关键内容，主要为日常对话)"]

    return {
        "topics": [t.strip() for t in topics],
        "tags": sorted(tags),
        "summaries": summaries,
        "key_decisions": key_points[:5],
        "todos": todos,
        "user_messages_count": len(user_msgs),
        "sessions_count": text.count("## ["),
    }


def write_summary(date_str: str, summary: dict, memory_dir: Path):
    """写入摘要文件"""
    summary_dir = memory_dir / "summaries"
    summary_dir.mkdir(parents=True, exist_ok=True)
    fp = summary_dir / f"{date_str}.md"

    lines = [f"# {date_str} 对话摘要\n"]
    lines.append(f"**话题：** {', '.join(summary['topics']) or '无'}")
    lines.append(f"**标签：** {', '.join(summary['tags']) or '无'}")
    lines.append(f"**对话数：** {summary['sessions_count']} 段")
    lines.append(f"**用户消息：** {summary['user_messages_count']} 条\n")
    lines.append("## 摘要\n")
    for s in summary['summaries']:
        lines.append(f"- {s}")
    if summary['key_decisions']:
        lines.append("\n## 关键决策\n")
        for d in summary['key_decisions']:
            lines.append(f"- {d}")
    if summary['todos']:
        lines.append("\n## 待办\n")
        for t in summary['todos']:
            lines.append(t)
    lines.append("")

    fp.write_text("\n".join(lines), encoding="utf-8")
    print(f"✅ 摘要已生成 → {fp}")
    return str(fp)


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="生成对话摘要")
    p.add_argument("--date", default=datetime.now().strftime("%Y-%m-%d"))
    p.add_argument("--memory-dir", default=None)
    p.add_argument("--json", action="store_true")
    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)

    text = load_conversation(args.date, md)
    if not text:
        print(f"⚠️ 未找到 {args.date} 的对话记录")
        exit(1)

    summary = generate_summary(text)

    if args.json:
        import json
        print(json.dumps(summary, ensure_ascii=False, indent=2))
    else:
        write_summary(args.date, summary, md)


path: scripts/health_dashboard.py
content: #!/usr/bin/env python3
"""记忆健康仪表盘：综合健康报告"""

import argparse
import json
from datetime import datetime
from pathlib import Path

import sys
sys.path.insert(0, str(Path(__file__).parent))
from lib.filelock import safe_read

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"


def check_health(memory_dir: Path) -> dict:
    """生成综合健康报告"""
    report = {
        "timestamp": datetime.now().isoformat(),
        "scores": {},
        "checks": [],
        "summary": "",
        "recommendations": [],
    }

    total_score = 100
    checks = []

    # 1. 文件完整性
    conv_dir = memory_dir / "conversations"
    if conv_dir.exists():
        files = list(conv_dir.glob("*.md"))
        total_size = sum(f.stat().st_size for f in files)
        checks.append({
            "name": "文件完整性",
            "status": "healthy" if len(files) > 0 else "warning",
            "detail": f"{len(files)} 个对话文件，{total_size / 1024:.1f} KB",
            "score": 10 if len(files) > 0 else 5,
            "max_score": 10,
        })
    else:
        total_score -= 10
        checks.append({"name": "文件完整性", "status": "error", "detail": "对话目录不存在", "score": 0, "max_score": 10})

    # 2. MEMORY.md 状态
    memory_md = memory_dir.parent / "MEMORY.md"
    if memory_md.exists():
        md_size = len(memory_md.read_text(encoding="utf-8"))
        if md_size <= 8000:
            checks.append({"name": "MEMORY.md", "status": "healthy",
                          "detail": f"{md_size} 字（限制 8000）", "score": 10, "max_score": 10})
        else:
            total_score -= 5
            checks.append({"name": "MEMORY.md", "status": "warning",
                          "detail": f"{md_size} 字（超限 {md_size - 8000}）", "score": 5, "max_score": 10})
    else:
        total_score -= 5
        checks.append({"name": "MEMORY.md", "status": "warning", "detail": "文件不存在", "score": 5, "max_score": 10})

    # 3. 标签覆盖率
    if conv_dir.exists():
        tagged = 0
        untagged = 0
        for f in conv_dir.glob("*.md"):
            content = f.read_text(encoding="utf-8")
            if "标签" in content:
                tagged += 1
            else:
                untagged += 1
        total = tagged + untagged
        coverage = tagged / max(total, 1) * 100
        status = "healthy" if coverage >= 80 else "warning" if coverage >= 50 else "error"
        score = int(coverage / 10)
        if status != "healthy":
            total_score -= (10 - score)
        checks.append({"name": "标签覆盖率", "status": status,
                      "detail": f"{coverage:.0f}%（{tagged}/{total}）", "score": score, "max_score": 10})

    # 4. 决策标记率
    if conv_dir.exists():
        with_decisions = 0
        total_sessions = 0
        for f in conv_dir.glob("*.md"):
            content = f.read_text(encoding="utf-8")
            sessions = content.count("## [")
            total_sessions += sessions
            if "**关键决策" in content:
                with_decisions += 1
        rate = with_decisions / max(len(list(conv_dir.glob("*.md"))), 1) * 100
        score = int(rate / 10)
        checks.append({"name": "决策标记率", "status": "healthy" if rate >= 50 else "info",
                      "detail": f"{rate:.0f}% 文件有决策标记", "score": score, "max_score": 10})

    # 5. 蒸馏状态
    distill_dir = memory_dir / "distillations"
    if distill_dir.exists():
        distill_count = len(list(distill_dir.glob("*.md")))
        checks.append({"name": "蒸馏文件", "status": "healthy" if distill_count > 0 else "info",
                      "detail": f"{distill_count} 个周蒸馏", "score": min(distill_count * 3, 10), "max_score": 10})
    else:
        checks.append({"name": "蒸馏文件", "status": "info", "detail": "尚未蒸馏", "score": 0, "max_score": 10})

    # 6. 索引状态
    index_file = memory_dir / ".memory_index.json"
    if index_file.exists():
        checks.append({"name": "索引", "status": "healthy", "detail": "索引存在", "score": 10, "max_score": 10})
    else:
        checks.append({"name": "索引", "status": "info", "detail": "索引未构建", "score": 5, "max_score": 10})

    # 7. 备份状态
    import subprocess
    try:
        result = subprocess.run(["git", "status", "--porcelain"], cwd=memory_dir,
                               capture_output=True, text=True, timeout=5)
        if result.returncode == 0:
            is_dirty = bool(result.stdout.strip())
            checks.append({"name": "Git 备份", "status": "warning" if is_dirty else "healthy",
                          "detail": "有未提交变更" if is_dirty else "工作区干净", 
                          "score": 5 if is_dirty else 10, "max_score": 10})
        else:
            checks.append({"name": "Git 备份", "status": "info", "detail": "未初始化 Git", "score": 3, "max_score": 10})
    except Exception:
        checks.append({"name": "Git 备份", "status": "info", "detail": "无法检查", "score": 5, "max_score": 10})

    # 8. 完整性校验
    checksum_file = memory_dir / ".checksums.json"
    if checksum_file.exists():
        checks.append({"name": "完整性校验", "status": "healthy", "detail": "校验和文件存在", "score": 10, "max_score": 10})
    else:
        checks.append({"name": "完整性校验", "status": "info", "detail": "未建立校验和", "score": 3, "max_score": 10})

    # 计算总分
    actual_score = sum(c["score"] for c in checks)
    max_score = sum(c["max_score"] for c in checks)
    health_pct = round(actual_score / max(max_score, 1) * 100, 1)

    # 生成建议
    recommendations = []
    for check in checks:
        if check["status"] == "warning":
            recommendations.append(f"⚠️ {check['name']}：{check['detail']}，建议处理")
        elif check["status"] == "error":
            recommendations.append(f"🔴 {check['name']}：{check['detail']}，需要立即修复")
        elif check["status"] == "info":
            recommendations.append(f"ℹ️ {check['name']}：{check['detail']}")

    if health_pct >= 80:
        overall = "🟢 健康"
    elif health_pct >= 60:
        overall = "🟡 良好"
    else:
        overall = "🔴 需要关注"

    report["scores"] = {"health": health_pct, "actual": actual_score, "max": max_score}
    report["checks"] = checks
    report["summary"] = f"{overall}（{health_pct}%）"
    report["recommendations"] = recommendations

    return report


def print_dashboard(report: dict):
    print("=" * 60)
    print(f"🏥 记忆健康仪表盘 {report['summary']}")
    print("=" * 60)

    # 健康条
    pct = report["scores"]["health"]
    filled = int(pct / 5)
    bar = "█" * filled + "░" * (20 - filled)
    print(f"\n  [{bar}] {pct}%")

    print(f"\n📋 检查项：")
    for check in report["checks"]:
        status_icon = {"healthy": "✅", "warning": "⚠️", "error": "🔴", "info": "ℹ️"}
        icon = status_icon.get(check["status"], "•")
        score_bar = "█" * check["score"] + "░" * (check["max_score"] - check["score"])
        print(f"  {icon} {check['name']:<15s} [{score_bar}] {check['detail']}")

    if report["recommendations"]:
        print(f"\n💡 建议：")
        for rec in report["recommendations"]:
            print(f"  {rec}")


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="记忆健康仪表盘")
    p.add_argument("--memory-dir", default=None)
    p.add_argument("--json", action="store_true")
    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)
    report = check_health(md)

    if args.json:
        print(json.dumps(report, ensure_ascii=False, indent=2))
    else:
        print_dashboard(report)


path: scripts/html_report.py
content: #!/usr/bin/env python3
"""HTML 可视化报告：生成漂亮的记忆分析报告"""

import argparse
import json
from datetime import datetime
from pathlib import Path

import sys
sys.path.insert(0, str(Path(__file__).parent))
from lib.filelock import safe_read

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"


def generate_html_report(memory_dir: Path, output: Path) -> str:
    """生成完整的 HTML 可视化报告"""
    conv_dir = memory_dir / "conversations"
    
    # 收集数据
    files_data = []
    total_size = 0
    total_sessions = 0
    all_tags = {}
    all_topics = {}
    tag_timeline = []
    topic_timeline = []

    if conv_dir.exists():
        for fp in sorted(conv_dir.glob("*.md")):
            content = fp.read_text(encoding="utf-8")
            size = fp.stat().st_size
            sessions = content.count("## [")
            total_size += size
            total_sessions += sessions

            import re
            tags = list(set(t.strip() for tl in re.findall(r'\*\*标签[：:]\*\*\s*(.+)', content)
                          for t in tl.split("，") if t.strip()))
            topics = [t.strip() for t in re.findall(r'###\s*话题[：:]\s*(.+)', content)]

            for tag in tags:
                all_tags[tag] = all_tags.get(tag, 0) + 1
                tag_timeline.append({"date": fp.stem, "tag": tag, "count": 1})

            for topic in topics:
                all_topics[topic] = all_topics.get(topic, 0) + 1
                topic_timeline.append({"date": fp.stem, "topic": topic})

            files_data.append({
                "date": fp.stem,
                "size": size,
                "sessions": sessions,
                "tags": tags,
                "topics": topics,
            })

    # 生成 HTML
    html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Long Memory 记忆分析报告</title>
<style>
  * {{ margin: 0; padding: 0; box-sizing: border-box; }}
  body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 
         background: #0f0f23; color: #e0e0e0; padding: 2rem; }}
  .container {{ max-width: 1200px; margin: 0 auto; }}
  h1 {{ color: #ff6b6b; margin-bottom: 0.5rem; font-size: 2rem; }}
  .subtitle {{ color: #888; margin-bottom: 2rem; }}
  .stats-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 2rem; }}
  .stat-card {{ background: #1a1a2e; border-radius: 12px; padding: 1.5rem; 
               border: 1px solid #2a2a4a; }}
  .stat-value {{ font-size: 2rem; font-weight: bold; color: #4ecdc4; }}
  .stat-label {{ color: #888; margin-top: 0.5rem; }}
  .section {{ background: #1a1a2e; border-radius: 12px; padding: 1.5rem; 
             margin-bottom: 1rem; border: 1px solid #2a2a4a; }}
  .section h2 {{ color: #ff6b6b; margin-bottom: 1rem; font-size: 1.2rem; }}
  .bar-chart {{ display: flex; flex-direction: column; gap: 0.5rem; }}
  .bar-row {{ display: flex; align-items: center; gap: 0.5rem; }}
  .bar-label {{ width: 120px; text-align: right; font-size: 0.85rem; color: #aaa; overflow: hidden;
               text-overflow: ellipsis; white-space: nowrap; }}
  .bar-track {{ flex: 1; height: 24px; background: #2a2a4a; border-radius: 12px; overflow: hidden; }}
  .bar-fill {{ height: 100%; border-radius: 12px; transition: width 0.5s; }}
  .bar-fill.teal {{ background: linear-gradient(90deg, #4ecdc4, #44a08d); }}
  .bar-fill.purple {{ background: linear-gradient(90deg, #a855f7, #6366f1); }}
  .bar-fill.orange {{ background: linear-gradient(90deg, #f59e0b, #ef4444); }}
  .bar-value {{ width: 40px; font-size: 0.8rem; color: #888; }}
  .timeline {{ display: flex; flex-direction: column; gap: 0.3rem; }}
  .timeline-item {{ display: flex; align-items: center; gap: 0.5rem; padding: 0.3rem 0; }}
  .timeline-date {{ width: 80px; font-size: 0.8rem; color: #888; }}
  .timeline-bar {{ flex: 1; height: 8px; background: #2a2a4a; border-radius: 4px; }}
  .timeline-fill {{ height: 100%; border-radius: 4px; background: #4ecdc4; }}
  .tag-cloud {{ display: flex; flex-wrap: wrap; gap: 0.5rem; }}
  .tag {{ padding: 0.3rem 0.8rem; border-radius: 20px; font-size: 0.8rem; background: #2a2a4a; }}
  .tag-count {{ color: #4ecdc4; font-weight: bold; margin-left: 0.3rem; }}
  table {{ width: 100%; border-collapse: collapse; }}
  th, td {{ padding: 0.5rem; text-align: left; border-bottom: 1px solid #2a2a4a; }}
  th {{ color: #888; font-size: 0.85rem; }}
  td {{ font-size: 0.9rem; }}
</style>
</head>
<body>
<div class="container">
  <h1>🧠 Long Memory</h1>
  <p class="subtitle">记忆分析报告 · 生成时间：{datetime.now().strftime('%Y-%m-%d %H:%M')}</p>

  <div class="stats-grid">
    <div class="stat-card">
      <div class="stat-value">{len(files_data)}</div>
      <div class="stat-label">对话天数</div>
    </div>
    <div class="stat-card">
      <div class="stat-value">{total_sessions}</div>
      <div class="stat-label">对话段数</div>
    </div>
    <div class="stat-card">
      <div class="stat-value">{total_size / 1024:.1f} KB</div>
      <div class="stat-label">总数据量</div>
    </div>
    <div class="stat-card">
      <div class="stat-value">{len(all_tags)}</div>
      <div class="stat-label">标签数</div>
    </div>
    <div class="stat-card">
      <div class="stat-value">{len(all_topics)}</div>
      <div class="stat-label">话题数</div>
    </div>
  </div>

  <div class="section">
    <h2>🏷️ 标签热度</h2>
    <div class="bar-chart">"""

    max_tag = max(all_tags.values()) if all_tags else 1
    for tag, count in sorted(all_tags.items(), key=lambda x: -x[1])[:10]:
        pct = count / max_tag * 100
        html += f"""
      <div class="bar-row">
        <div class="bar-label">{tag}</div>
        <div class="bar-track"><div class="bar-fill teal" style="width:{pct}%"></div></div>
        <div class="bar-value">{count}</div>
      </div>"""

    html += """
    </div>
  </div>

  <div class="section">
    <h2>💬 话题分布</h2>
    <div class="bar-chart">"""

    max_topic = max(all_topics.values()) if all_topics else 1
    for topic, count in sorted(all_topics.items(), key=lambda x: -x[1])[:10]:
        pct = count / max_topic * 100
        html += f"""
      <div class="bar-row">
        <div class="bar-label">{topic[:20]}</div>
        <div class="bar-track"><div class="bar-fill purple" style="width:{pct}%"></div></div>
        <div class="bar-value">{count}</div>
      </div>"""

    html += """
    </div>
  </div>

  <div class="section">
    <h2>📈 对话活跃度</h2>
    <div class="timeline">"""

    max_sessions = max((f["sessions"] for f in files_data), default=1)
    for f in files_data[-14:]:  # 最近14天
        pct = f["sessions"] / max_sessions * 100 if max_sessions > 0 else 0
        html += f"""
      <div class="timeline-item">
        <div class="timeline-date">{f['date']}</div>
        <div class="timeline-bar"><div class="timeline-fill" style="width:{pct}%"></div></div>
        <div style="font-size:0.8rem;color:#888;width:40px">{f['sessions']}</div>
      </div>"""

    html += f"""
    </div>
  </div>

  <div class="section">
    <h2>📋 对话详情</h2>
    <table>
      <tr><th>日期</th><th>对话数</th><th>大小</th><th>标签</th><th>话题</th></tr>"""

    for f in reversed(files_data[-20:]):
        tags_str = " ".join(f'<span class="tag">{t}</span>' for t in f["tags"][:5])
        topics_str = ", ".join(t[:20] for t in f["topics"][:3])
        size_str = f"{f['size'] / 1024:.1f}KB" if f["size"] > 1024 else f"{f['size']}B"
        html += f"""
      <tr><td>{f['date']}</td><td>{f['sessions']}</td><td>{size_str}</td>
          <td>{tags_str}</td><td>{topics_str}</td></tr>"""

    html += """
    </table>
  </div>
</div>
</body>
</html>"""

    output.parent.mkdir(parents=True, exist_ok=True)
    output.write_text(html, encoding="utf-8")
    return str(output)


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="HTML 可视化报告")
    p.add_argument("--memory-dir", default=None)
    p.add_argument("--output", "-o", default=None, help="输出 HTML 文件路径")
    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)

    if args.output:
        out = Path(args.output)
    else:
        out = md / "report.html"

    result = generate_html_report(md, out)
    print(f"✅ 报告已生成: {result}")


path: scripts/import_memory.py
content: #!/usr/bin/env python3
"""记忆导入/迁移：从 Markdown/JSON/文本批量导入"""

import argparse
import json
import re
from datetime import datetime
from pathlib import Path

import sys
sys.path.insert(0, str(Path(__file__).parent))
from lib.filelock import safe_write

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"


def import_markdown(filepath: Path, memory_dir: Path, date_override: str | None = None) -> int:
    """从 Markdown 文件导入"""
    content = filepath.read_text(encoding="utf-8")

    # 尝试从文件名提取日期
    date_str = date_override
    if not date_str:
        date_match = re.search(r'(\d{4}-\d{2}-\d{2})', filepath.stem)
        if date_match:
            date_str = date_match.group(1)
        else:
            date_str = datetime.now().strftime("%Y-%m-%d")

    conv_dir = memory_dir / "conversations"
    conv_dir.mkdir(parents=True, exist_ok=True)
    dest = conv_dir / f"{date_str}.md"

    # 如果目标已存在，追加
    if dest.exists():
        existing = dest.read_text(encoding="utf-8")
        safe_write(dest, existing + "\n\n" + content)
    else:
        safe_write(dest, f"# {date_str} 对话记录（导入自 {filepath.name}）\n" + content)

    return 1


def import_json(filepath: Path, memory_dir: Path) -> int:
    """从 JSON 文件导入"""
    data = json.loads(filepath.read_text(encoding="utf-8"))

    count = 0
    if isinstance(data, list):
        for item in data:
            if isinstance(item, dict):
                date = item.get("date", item.get("file", datetime.now().strftime("%Y-%m-%d")))
                content = item.get("content", json.dumps(item, ensure_ascii=False))
                conv_dir = memory_dir / "conversations"
                conv_dir.mkdir(parents=True, exist_ok=True)
                dest = conv_dir / f"{date}.md"
                safe_write(dest, content)
                count += 1
    elif isinstance(data, dict):
        # 可能是 long-memory 导出格式
        for key in ["conversations", "summaries", "distillations"]:
            subdir = key.rstrip("s")
            for item in data.get(key, []):
                if isinstance(item, dict):
                    target_dir = memory_dir / key
                    target_dir.mkdir(parents=True, exist_ok=True)
                    dest = target_dir / item.get("file", "imported.md")
                    safe_write(dest, item.get("content", ""))
                    count += 1

    return count


def import_text(filepath: Path, memory_dir: Path, date_override: str | None = None) -> int:
    """从纯文本文件导入（按对话轮次分割）"""
    content = filepath.read_text(encoding="utf-8")
    date_str = date_override or datetime.now().strftime("%Y-%m-%d")

    lines = content.split("\n")
    messages = []
    current_speaker = None
    current_msg = []

    for line in lines:
        # 简单启发式：检测对话轮次
        user_match = re.match(r'(?:用户|我|You|U)\s*[：:>]\s*(.+)', line)
        assistant_match = re.match(r'(?:助手|AI|Bot|B)\s*[：:>]\s*(.+)', line)

        if user_match:
            if current_speaker and current_msg:
                messages.append((current_speaker, "\n".join(current_msg)))
            current_speaker = "用户"
            current_msg = [user_match.group(1)]
        elif assistant_match:
            if current_speaker and current_msg:
                messages.append((current_speaker, "\n".join(current_msg)))
            current_speaker = "助手"
            current_msg = [assistant_match.group(1)]
        elif current_speaker:
            current_msg.append(line)

    if current_speaker and current_msg:
        messages.append((current_speaker, "\n".join(current_msg)))

    if not messages:
        # 无法分割，整段作为一条消息
        messages = [("用户", content)]

    # 格式化为记忆格式
    formatted = f"# {date_str} 对话记录（导入自 {filepath.name}）\n\n"
    formatted += f"## [导入] Session: import\n### 话题：导入的对话\n\n"
    for speaker, msg in messages:
        label = "用户" if speaker == "用户" else "助手"
        formatted += f"**{label}：** {msg}\n\n"

    conv_dir = memory_dir / "conversations"
    conv_dir.mkdir(parents=True, exist_ok=True)
    dest = conv_dir / f"{date_str}.md"
    safe_write(dest, formatted)

    return 1


def import_directory(dir_path: Path, memory_dir: Path, pattern: str = "*.md") -> int:
    """批量导入目录"""
    count = 0
    for fp in sorted(dir_path.glob(pattern)):
        if fp.suffix == ".md":
            count += import_markdown(fp, memory_dir)
        elif fp.suffix == ".json":
            count += import_json(fp, memory_dir)
        elif fp.suffix == ".txt":
            count += import_text(fp, memory_dir)
        print(f"  ✅ 导入: {fp.name}")
    return count


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="记忆导入/迁移")
    sub = p.add_subparsers(dest="command")

    # 单文件导入
    imp = sub.add_parser("import", help="导入文件")
    imp.add_argument("file", help="文件路径")
    imp.add_argument("--memory-dir", default=None)
    imp.add_argument("--date", default=None, help="指定日期")
    imp.add_argument("--format", choices=["auto", "markdown", "json", "text"], default="auto")

    # 批量导入
    batch = sub.add_parser("batch", help="批量导入目录")
    batch.add_argument("directory", help="目录路径")
    batch.add_argument("--memory-dir", default=None)
    batch.add_argument("--pattern", default="*.md", help="文件匹配模式")

    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)

    if args.command == "import":
        fp = Path(args.file)
        fmt = args.format
        if fmt == "auto":
            fmt = "json" if fp.suffix == ".json" else "text" if fp.suffix == ".txt" else "markdown"

        if fmt == "markdown":
            count = import_markdown(fp, md, args.date)
        elif fmt == "json":
            count = import_json(fp, md)
        else:
            count = import_text(fp, md, args.date)

        print(f"✅ 导入完成：{count} 个文件")

    elif args.command == "batch":
        count = import_directory(Path(args.directory), md, args.pattern)
        print(f"✅ 批量导入完成：{count} 个文件")
    else:
        p.print_help()


path: scripts/index_manager.py
content: #!/usr/bin/env python3
"""索引系统：加速大量文件检索"""

import argparse
import json
import re
from datetime import datetime
from pathlib import Path
from typing import Any

import sys
sys.path.insert(0, str(Path(__file__).parent))
from lib.filelock import safe_read, safe_write_json, safe_read_json, file_lock

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"
INDEX_FILE = ".memory_index.json"


def build_index(memory_dir: Path) -> dict:
    """构建全量索引"""
    index = {
        "version": "1.0",
        "updated": datetime.now().isoformat(),
        "files": {},
        "topics": {},
        "tags": {},
        "dates": [],
        "stats": {"total_files": 0, "total_size": 0, "total_sessions": 0}
    }

    subdirs = ["conversations", "summaries", "distillations"]
    
    for subdir in subdirs:
        d = memory_dir / subdir
        if not d.exists():
            continue
        for fp in sorted(d.glob("*.md")):
            try:
                stat = fp.stat()
                mtime = datetime.fromtimestamp(stat.st_mtime).isoformat()
                content = fp.read_text(encoding="utf-8")
                
                file_entry = {
                    "path": str(fp.relative_to(memory_dir)),
                    "size": stat.st_size,
                    "mtime": mtime,
                    "type": subdir.rstrip("s"),
                    "topics": [],
                    "tags": [],
                    "session_count": content.count("## ["),
                    "keywords": set(),
                }

                # 提取话题
                topics = re.findall(r'###\s*话题[：:]\s*(.+)', content)
                file_entry["topics"] = [t.strip() for t in topics]
                for t in file_entry["topics"]:
                    if t not in index["topics"]:
                        index["topics"][t] = []
                    index["topics"][t].append(fp.stem)

                # 提取标签
                tag_lines = re.findall(r'\*\*标签[：:]\*\*\s*(.+)', content)
                tags = []
                for tl in tag_lines:
                    for tag in tl.split("，"):
                        tag = tag.strip()
                        if tag:
                            tags.append(tag)
                file_entry["tags"] = tags
                for tag in tags:
                    if tag not in index["tags"]:
                        index["tags"][tag] = []
                    index["tags"][tag].append(fp.stem)

                # 提取关键词（用户消息中的名词短语）
                user_msgs = re.findall(r'\*\*用户[：:]\*\*\s*(.+)', content)
                keywords = set()
                for msg in user_msgs:
                    # 简单中文分词：提取2-4字的中文片段
                    words = re.findall(r'[\u4e00-\u9fff]{2,4}', msg)
                    keywords.update(words)
                file_entry["keywords"] = sorted(keywords)

                index["files"][fp.stem] = file_entry
                index["stats"]["total_files"] += 1
                index["stats"]["total_size"] += stat.st_size
                index["stats"]["total_sessions"] += file_entry["session_count"]
                index["dates"].append(fp.stem)

            except Exception as e:
                print(f"⚠️ 索引跳过 {fp.name}: {e}")

    index["dates"] = sorted(set(index["dates"]))
    
    # 序列化时把 set 转为 list
    for f in index["files"].values():
        f["keywords"] = list(f.get("keywords", []))
    
    return index


def get_index(memory_dir: Path, rebuild: bool = False) -> dict:
    """获取索引，过期自动重建"""
    index_path = memory_dir / INDEX_FILE
    
    if rebuild or not index_path.exists():
        return rebuild_and_save(memory_dir)
    
    index = safe_read_json(index_path)
    
    # 检查是否有文件变更
    for name, entry in index.get("files", {}).items():
        fp = memory_dir / entry["path"]
        if not fp.exists():
            return rebuild_and_save(memory_dir)
        try:
            actual_mtime = datetime.fromtimestamp(fp.stat().st_mtime).isoformat()
            if actual_mtime != entry.get("mtime"):
                return rebuild_and_save(memory_dir)
        except OSError:
            return rebuild_and_save(memory_dir)
    
    # 检查是否有新文件
    actual_count = 0
    for subdir in ["conversations", "summaries", "distillations"]:
        d = memory_dir / subdir
        if d.exists():
            actual_count += len(list(d.glob("*.md")))
    if actual_count != index.get("stats", {}).get("total_files", 0):
        return rebuild_and_save(memory_dir)
    
    return index


def rebuild_and_save(memory_dir: Path) -> dict:
    """重建并保存索引"""
    print("🔄 重建索引...")
    index = build_index(memory_dir)
    safe_write_json(memory_dir / INDEX_FILE, index)
    print(f"✅ 索引已更新（{index['stats']['total_files']} 个文件）")
    return index


def search_index(index: dict, query: str | None = None,
                 topic: str | None = None, tag: str | None = None,
                 start_date: str | None = None, end_date: str | None = None,
                 days: int | None = None) -> list[dict]:
    """基于索引快速搜索"""
    from datetime import timedelta
    results = []
    
    if days:
        start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
    if not end_date:
        end_date = datetime.now().strftime("%Y-%m-%d")
    
    for name, entry in index.get("files", {}).items():
        # 日期过滤
        if start_date and name < start_date:
            continue
        if name > end_date:
            continue
        
        # 标签过滤
        if tag:
            if tag.lower() not in [t.lower() for t in entry.get("tags", [])]:
                continue
        
        # 话题过滤
        if topic:
            if not any(topic.lower() in t.lower() for t in entry.get("topics", [])):
                continue
        
        # 关键词搜索
        if query:
            query_lower = query.lower()
            found = False
            if query_lower in name:
                found = True
            if not found:
                for kw in entry.get("keywords", []):
                    if query_lower in kw.lower():
                        found = True
                        break
            if not found:
                # 检查话题和标签
                for t in entry.get("topics", []):
                    if query_lower in t.lower():
                        found = True
                        break
            if not found:
                for t in entry.get("tags", []):
                    if query_lower in t.lower():
                        found = True
                        break
            if not found:
                continue
        
        results.append({"name": name, **entry})
    
    return sorted(results, key=lambda x: x["name"], reverse=True)


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="索引管理")
    p.add_argument("--memory-dir", default=None)
    p.add_argument("--rebuild", action="store_true", help="强制重建索引")
    p.add_argument("--search", "-q", default=None)
    p.add_argument("--topic", "-t", default=None)
    p.add_argument("--tag", default=None)
    p.add_argument("--days", type=int, default=None)
    p.add_argument("--json", action="store_true")
    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)

    if args.rebuild:
        index = rebuild_and_save(md)
    else:
        index = get_index(md)
    
    if args.search or args.topic or args.tag or args.days:
        results = search_index(index, args.search, args.topic, args.tag, days=args.days)
        if args.json:
            print(json.dumps(results, ensure_ascii=False, indent=2))
        else:
            for r in results:
                print(f"📄 {r['name']} [{r['type']}] "
                      f"{' '.join(f'#{t}' for t in r['tags'])}")
                print(f"   {r['session_count']} sessions, {r['size']}B")
                if r['topics']:
                    print(f"   话题: {', '.join(r['topics'])}")
    else:
        stats = index.get("stats", {})
        print(f"📊 索引状态：{stats.get('total_files', 0)} 文件, "
              f"{stats.get('total_sessions', 0)} sessions, "
              f"{stats.get('total_size', 0)}B")
        print(f"📝 话题: {len(index.get('topics', {}))} 个")
        print(f"🏷️  标签: {len(index.get('tags', {}))} 个")
        print(f"📅 日期范围: {index['dates'][0] if index['dates'] else '-'} ~ "
              f"{index['dates'][-1] if index['dates'] else '-'}")


path: scripts/integrity_check.py
content: #!/usr/bin/env python3
"""文件完整性校验：检测损坏文件并修复"""

import argparse
import hashlib
import json
import re
from datetime import datetime
from pathlib import Path

import sys
sys.path.insert(0, str(Path(__file__).parent))
from lib.filelock import safe_read, safe_write_json, safe_read_json

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"
CHECKSUM_FILE = ".checksums.json"


def compute_checksum(content: str) -> str:
    return hashlib.sha256(content.encode("utf-8")).hexdigest()


def load_checksums(memory_dir: Path) -> dict:
    fp = memory_dir / CHECKSUM_FILE
    if fp.exists():
        return safe_read_json(fp)
    return {}


def save_checksums(memory_dir: Path, checksums: dict):
    safe_write_json(memory_dir / CHECKSUM_FILE, checksums)


def validate_all(memory_dir: Path, fix: bool = False) -> dict:
    """校验所有记忆文件"""
    checksums = load_checksums(memory_dir)
    report = {
        "valid": [],
        "corrupted": [],
        "new": [],
        "modified": [],
        "missing": [],
        "fixed": [],
    }

    # 收集所有 md 文件
    all_files = []
    for subdir in ["conversations", "summaries", "distillations"]:
        d = memory_dir / subdir
        if d.exists():
            for fp in d.glob("*.md"):
                all_files.append(fp)

    # 检查每个文件
    for fp in sorted(all_files):
        rel = str(fp.relative_to(memory_dir))
        
        if not fp.exists():
            report["missing"].append(rel)
            continue

        try:
            content = fp.read_text(encoding="utf-8")
        except UnicodeDecodeError:
            report["corrupted"].append({"file": rel, "error": "编码错误"})
            if fix:
                fix_encoding(fp)
                report["fixed"].append(rel)
            continue
        except Exception as e:
            report["corrupted"].append({"file": rel, "error": str(e)})
            continue

        # 检查基本结构
        if fp.parent.name == "conversations":
            if not content.strip().startswith("#"):
                report["corrupted"].append({"file": rel, "error": "缺少标题行"})
                if fix:
                    fix_missing_title(fp, fp.stem)
                    report["fixed"].append(rel)
                continue

        # 检查校验和
        current_hash = compute_checksum(content)
        if rel not in checksums:
            report["new"].append(rel)
            checksums[rel] = {
                "hash": current_hash,
                "size": len(content),
                "checked": datetime.now().isoformat(),
            }
        elif checksums[rel]["hash"] != current_hash:
            report["modified"].append({
                "file": rel,
                "old_hash": checksums[rel]["hash"],
                "new_hash": current_hash,
            })
            checksums[rel] = {
                "hash": current_hash,
                "size": len(content),
                "checked": datetime.now().isoformat(),
            }
        else:
            report["valid"].append(rel)

    # 检查已记录但文件不存在的
    for rel in checksums:
        if not (memory_dir / rel).exists():
            report["missing"].append(rel)

    # 保存更新后的校验和
    save_checksums(memory_dir, checksums)

    return report


def fix_encoding(fp: Path):
    """尝试修复编码问题"""
    try:
        raw = fp.read_bytes()
        # 尝试 latin-1
        content = raw.decode("latin-1")
        # 修复常见的编码错误
        content = content.encode("latin-1").decode("utf-8", errors="replace")
        fp.write_text(content, encoding="utf-8")
        print(f"  🔧 修复编码: {fp.name}")
    except Exception as e:
        print(f"  ❌ 无法修复: {fp.name}: {e}")


def fix_missing_title(fp: Path, date_str: str):
    """修复缺少标题的文件"""
    content = fp.read_text(encoding="utf-8")
    fp.write_text(f"# {date_str} 对话记录\n{content}", encoding="utf-8")
    print(f"  🔧 修复标题: {fp.name}")


def print_report(report: dict):
    total = (len(report["valid"]) + len(report["corrupted"]) + 
             len(report["new"]) + len(report["modified"]) + len(report["missing"]))
    
    print("=" * 60)
    print(f"🔍 文件完整性校验（{total} 个文件）")
    print("=" * 60)
    print(f"\n  ✅ 正常: {len(report['valid'])}")
    print(f"  🆕 新增: {len(report['new'])}")
    print(f"  ✏️  变更: {len(report['modified'])}")

    if report["corrupted"]:
        print(f"\n  🔴 损坏: {len(report['corrupted'])}")
        for item in report["corrupted"]:
            print(f"     {item['file'] if isinstance(item, dict) else item}: "
                  f"{item.get('error', '未知') if isinstance(item, dict) else '未知'}")

    if report["missing"]:
        print(f"\n  ❓ 缺失: {len(report['missing'])}")
        for item in report["missing"]:
            print(f"     {item}")

    if report["fixed"]:
        print(f"\n  🔧 已修复: {len(report['fixed'])}")
        for item in report["fixed"]:
            print(f"     {item}")

    healthy = len(report["corrupted"]) == 0 and len(report["missing"]) == 0
    print(f"\n  {'✅ 系统健康' if healthy else '⚠️ 需要关注'}")


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="文件完整性校验")
    p.add_argument("--memory-dir", default=None)
    p.add_argument("--fix", action="store_true", help="自动修复")
    p.add_argument("--json", action="store_true")
    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)

    report = validate_all(md, fix=args.fix)

    if args.json:
        print(json.dumps(report, ensure_ascii=False, indent=2))
    else:
        print_report(report)


path: scripts/lib/filelock.py
content: #!/usr/bin/env python3
"""文件锁工具：安全的并发文件读写"""

import fcntl
import json
import time
from pathlib import Path
from contextlib import contextmanager
from typing import Any


@contextmanager
def file_lock(lock_path: str | Path, timeout: float = 5.0):
    """获取文件锁，超时抛异常"""
    lock_path = Path(lock_path)
    lock_path.parent.mkdir(parents=True, exist_ok=True)
    
    lock_file = None
    try:
        lock_file = open(lock_path, "w")
        start = time.time()
        while True:
            try:
                fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
                break
            except (IOError, OSError):
                if time.time() - start > timeout:
                    raise TimeoutError(f"无法获取锁: {lock_path}（超时 {timeout}s）")
                time.sleep(0.1)
        yield lock_file
    finally:
        if lock_file:
            try:
                fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
            except (IOError, OSError):
                pass
            lock_file.close()


def safe_read(filepath: Path, encoding: str = "utf-8") -> str:
    """安全读取文件"""
    lock_path = filepath.parent / f".{filepath.stem}.lock"
    with file_lock(lock_path):
        if filepath.exists():
            return filepath.read_text(encoding=encoding)
        return ""


def safe_write(filepath: Path, content: str, encoding: str = "utf-8", append: bool = False):
    """安全写入文件"""
    filepath.parent.mkdir(parents=True, exist_ok=True)
    lock_path = filepath.parent / f".{filepath.stem}.lock"
    with file_lock(lock_path):
        if append:
            existing = filepath.read_text(encoding=encoding) if filepath.exists() else ""
            filepath.write_text(existing + content, encoding=encoding)
        else:
            filepath.write_text(content, encoding=encoding)


def safe_read_json(filepath: Path) -> Any:
    """安全读取 JSON"""
    content = safe_read(filepath)
    if content.strip():
        return json.loads(content)
    return {}


def safe_write_json(filepath: Path, data: Any, indent: int = 2):
    """安全写入 JSON"""
    safe_write(filepath, json.dumps(data, ensure_ascii=False, indent=indent))


path: scripts/memory_graph.py
content: #!/usr/bin/env python3
"""记忆关联图谱：话题之间的关联关系"""

import argparse
import json
import re
from collections import defaultdict
from datetime import datetime
from pathlib import Path

import sys
sys.path.insert(0, str(Path(__file__).parent))
from lib.filelock import safe_read

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"


def build_graph(memory_dir: Path) -> dict:
    """构建话题关联图"""
    conv_dir = memory_dir / "conversations"
    if not conv_dir.exists():
        return {"nodes": [], "edges": [], "stats": {}}

    # 收集所有话题及其共现关系
    topic_dates = defaultdict(list)  # topic -> [dates]
    topic_tags = defaultdict(set)    # topic -> {tags}
    topic_keywords = defaultdict(set)  # topic -> {keywords}
    cooccurrence = defaultdict(int)   # (topic1, topic2) -> count
    all_topics = set()

    for fp in sorted(conv_dir.glob("*.md")):
        date_str = fp.stem
        content = fp.read_text(encoding="utf-8")

        # 提取当天所有话题
        topics = []
        for match in re.finditer(r'###\s*话题[：:]\s*(.+)', content):
            topic = match.group(1).strip().rstrip(".")
            topics.append(topic)
            all_topics.add(topic)
            topic_dates[topic].append(date_str)

        # 提取标签
        tags = set()
        for tl in re.findall(r'\*\*标签[：:]\*\*\s*(.+)', content):
            for t in tl.split("，"):
                t = t.strip()
                if t:
                    tags.add(t)

        # 提取关键词
        keywords = set()
        for msg in re.findall(r'\*\*用户[：:]\*\*\s*(.+)', content):
            words = re.findall(r'[\u4e00-\u9fff]{2,4}', msg)
            keywords.update(words)

        for topic in topics:
            topic_tags[topic].update(tags)
            topic_keywords[topic].update(keywords)

        # 共现
        for i in range(len(topics)):
            for j in range(i + 1, len(topics)):
                pair = tuple(sorted([topics[i], topics[j]]))
                cooccurrence[pair] += 1

    # 归一化话题
    aliases = {
        "long-memory": ["long-memory", "long memory", "记忆系统", "长记忆", "记忆skill"],
        "小说": ["小说", "novel"],
        "部署": ["部署", "推送", "git"],
        "迭代": ["迭代", "升级"],
    }
    
    normalized = {}
    for topic in all_topics:
        norm = topic
        for canonical, variants in aliases.items():
            if any(v in topic.lower() for v in variants):
                norm = canonical
                break
        if norm not in normalized:
            normalized[norm] = []
        if topic != norm:
            normalized[norm].append(topic)

    # 构建节点
    nodes = []
    for topic in sorted(all_topics):
        norm = topic
        for canonical, variants in aliases.items():
            if any(v in topic.lower() for v in variants):
                norm = canonical
                break
        nodes.append({
            "id": topic,
            "label": topic,
            "normalized": norm,
            "dates": topic_dates.get(topic, []),
            "count": len(topic_dates.get(topic, [])),
            "tags": sorted(topic_tags.get(topic, set())),
            "keywords": sorted(list(topic_keywords.get(topic, set()))[:20]),
        })

    # 构建边
    edges = []
    for (t1, t2), count in cooccurrence.items():
        edges.append({
            "source": t1,
            "target": t2,
            "weight": count,
        })

    return {
        "nodes": nodes,
        "edges": edges,
        "stats": {
            "total_topics": len(nodes),
            "total_edges": len(edges),
            "most_connected": sorted(
                [(n["label"], n["count"]) for n in nodes],
                key=lambda x: -x[1]
            )[:10],
        },
    }


def print_graph(graph: dict, verbose: bool = False):
    stats = graph["stats"]
    print("=" * 60)
    print(f"🕸️  记忆关联图谱（{stats['total_topics']} 个话题，{stats['total_edges']} 条关联）")
    print("=" * 60)

    if stats["most_connected"]:
        print("\n📊 热度排名：")
        for topic, count in stats["most_connected"]:
            bar = "█" * min(count * 3, 30)
            print(f"  {topic:25s} {count:2d}次 {bar}")

    if graph["edges"]:
        print("\n🔗 话题关联（共现关系）：")
        for edge in sorted(graph["edges"], key=lambda x: -x["weight"]):
            w = edge["weight"]
            connector = "━━" * min(w, 5)
            print(f"  {edge['source']} {connector} {edge['target']} ({w})")

    if verbose:
        print("\n📖 话题详情：")
        for node in graph["nodes"]:
            dates = node["dates"]
            date_range = f"{min(dates)} ~ {max(dates)}" if dates else "无"
            print(f"\n  📌 {node['label']} [{node['normalized']}]")
            print(f"     📅 {date_range}（{node['count']}次）")
            if node["tags"]:
                print(f"     🏷️  {', '.join(node['tags'])}")
            if node["keywords"]:
                print(f"     🔑 {', '.join(node['keywords'][:10])}")


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="记忆关联图谱")
    p.add_argument("--memory-dir", default=None)
    p.add_argument("--verbose", "-v", action="store_true")
    p.add_argument("--json", action="store_true")
    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)
    graph = build_graph(md)

    if args.json:
        print(json.dumps(graph, ensure_ascii=False, indent=2))
    else:
        print_graph(graph, args.verbose)


path: scripts/memory_stats.py
content: #!/usr/bin/env python3
"""记忆统计：统计对话数量、话题分布、记忆体积"""

import argparse
from collections import Counter
from datetime import datetime
from pathlib import Path

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"


def human_size(size: int) -> str:
    if size < 1024:
        return f"{size} B"
    elif size < 1024 ** 2:
        return f"{size / 1024:.1f} KB"
    else:
        return f"{size / (1024 ** 2):.1f} MB"


def get_stats(memory_dir: Path) -> dict:
    stats = {
        "total_size": 0,
        "files": 0,
        "conversations": 0,
        "sessions": 0,
        "topics": Counter(),
        "tags": Counter(),
        "dates": [],
        "daily_sizes": {},
        "distillations": 0,
        "memory_md_size": 0,
    }

    conv_dir = memory_dir / "conversations"
    distill_dir = memory_dir / "distillations"
    memory_md = memory_dir.parent / "MEMORY.md"

    # MEMORY.md
    if memory_md.exists():
        stats["memory_md_size"] = memory_md.stat().st_size

    # 对话文件
    if conv_dir.exists():
        for f in sorted(conv_dir.glob("*.md")):
            content = f.read_text(encoding="utf-8")
            size = f.stat().st_size
            stats["total_size"] += size
            stats["files"] += 1
            stats["conversations"] += 1
            stats["dates"].append(f.stem)

            # 统计 session 数
            sessions = content.count("## [")
            stats["sessions"] += sessions

            # 提取话题
            import re
            for topic in re.findall(r'### 话题：(.+)', content):
                stats["topics"][topic.strip().rstrip(".")] += 1

            # 提取标签
            for tags in re.findall(r'\*\*标签：\*\*\s*(.+)', content):
                for tag in tags.split("，"):
                    tag = tag.strip()
                    if tag:
                        stats["tags"][tag] += 1

            stats["daily_sizes"][f.stem] = size

    # 蒸馏文件
    if distill_dir.exists():
        for f in distill_dir.glob("*.md"):
            stats["total_size"] += f.stat().st_size
            stats["files"] += 1
            stats["distillations"] += 1

    return stats


def print_stats(stats: dict, verbose: bool = False):
    print("=" * 50)
    print("📊 记忆系统统计")
    print("=" * 50)
    print(f"\n📁 总文件数: {stats['files']}")
    print(f"📦 总体积:   {human_size(stats['total_size'])}")
    print(f"📝 MEMORY.md: {human_size(stats['memory_md_size'])}")
    print(f"\n📅 对话天数: {stats['conversations']}")
    print(f"💬 对话段数: {stats['sessions']}")

    if stats["topics"]:
        print(f"\n🏷️  话题 TOP 10:")
        for topic, count in stats["topics"].most_common(10):
            print(f"   {topic}: {count} 次")

    if stats["tags"]:
        print(f"\n📌 标签分布:")
        for tag, count in stats["tags"].most_common():
            print(f"   {tag}: {count} 次")

    if stats["dates"]:
        print(f"\n📆 日期范围: {stats['dates'][0]} → {stats['dates'][-1]}")

    if verbose and stats["daily_sizes"]:
        print(f"\n📋 每日详情:")
        for date, size in sorted(stats["daily_sizes"].items()):
            print(f"   {date}: {human_size(size)}")

    print()


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="记忆统计")
    p.add_argument("--memory-dir", default=None)
    p.add_argument("--verbose", "-v", action="store_true", help="显示每日详情")
    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)
    stats = get_stats(md)
    print_stats(stats, args.verbose)


path: scripts/memory_templates.py
content: #!/usr/bin/env python3
"""结构化记忆模板：会议纪要/项目决策/学习笔记等"""

import argparse
from datetime import datetime
from pathlib import Path

import sys
sys.path.insert(0, str(Path(__file__).parent))
from lib.filelock import safe_write

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"

TEMPLATES = {
    "meeting": {
        "name": "会议纪要",
        "format": """# {date} 会议纪要

## 基本信息
- **日期：** {date}
- **参与者：** {participants}
- **会议类型：** {meeting_type}

## 议题
{topics}

## 讨论内容
{content}

## 决议
{decisions}

## 待办事项
{todos}

## 下次会议
- **时间：** {next_date}
- **议题：** {next_topics}
""",
        "fields": ["participants", "meeting_type", "topics", "content", "decisions", "todos", "next_date", "next_topics"],
    },
    "decision": {
        "name": "项目决策",
        "format": """# {date} 决策记录

## 决策主题
{title}

## 背景
{background}

## 方案对比
{options}

## 最终决策
**选择：** {choice}
**原因：** {reason}

## 影响范围
{impact}

## 风险评估
{risks}

## 关联人
{stakeholders}
""",
        "fields": ["title", "background", "options", "choice", "reason", "impact", "risks", "stakeholders"],
    },
    "learning": {
        "name": "学习笔记",
        "format": """# {date} 学习笔记

## 主题
{topic}

## 来源
{source}

## 核心要点
{key_points}

## 详细笔记
{notes}

## 个人思考
{thoughts}

## 后续计划
{next_steps}

## 关联知识
{related}
""",
        "fields": ["topic", "source", "key_points", "notes", "thoughts", "next_steps", "related"],
    },
    "project": {
        "name": "项目里程碑",
        "format": """# {date} 项目进展

## 项目名称
{project_name}

## 当前阶段
{stage}

## 本期完成
{completed}

## 进行中
{in_progress}

## 遇到的问题
{issues}

## 下一阶段目标
{next_goals}

## 资源需求
{resources}
""",
        "fields": ["project_name", "stage", "completed", "in_progress", "issues", "next_goals", "resources"],
    },
    "review": {
        "name": "周报/复盘",
        "format": """# {date} {review_type}

## 本周/期概览
{overview}

## 完成事项
{achievements}

## 未完成事项
{incomplete}

## 经验教训
{lessons}

## 下周/期计划
{plan}

## 需要支持
{support_needed}
""",
        "fields": ["review_type", "overview", "achievements", "incomplete", "lessons", "plan", "support_needed"],
    },
}


def create_from_template(template_name: str, date_str: str | None = None,
                         values: dict | None = None, output: Path | None = None) -> str:
    """使用模板创建记忆"""
    if template_name not in TEMPLATES:
        available = ", ".join(f"'{k}' ({v['name']})" for k, v in TEMPLATES.items())
        raise ValueError(f"未知模板: {template_name}\n可用模板: {available}")

    template = TEMPLATES[template_name]
    date = date_str or datetime.now().strftime("%Y-%m-%d")
    values = values or {}

    # 填充模板
    content = template["format"].format(date=date, **values)

    # 如果指定了输出路径，写入文件
    if output:
        output.parent.mkdir(parents=True, exist_ok=True)
        safe_write(output, content)
        return str(output)

    return content


def list_templates():
    """列出所有可用模板"""
    print("=" * 60)
    print("📝 可用模板")
    print("=" * 60)
    for key, tmpl in TEMPLATES.items():
        fields = ", ".join(f"`{f}`" for f in tmpl["fields"][:5])
        if len(tmpl["fields"]) > 5:
            fields += f" 等{len(tmpl['fields'])}个字段"
        print(f"\n  📋 {key} — {tmpl['name']}")
        print(f"     字段: {fields}")


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="结构化记忆模板")
    sub = p.add_subparsers(dest="command")

    # 创建
    create = sub.add_parser("create", help="从模板创建")
    create.add_argument("template", help="模板名称")
    create.add_argument("--date", "-d", default=None)
    create.add_argument("--output", "-o", default=None, help="输出文件路径")
    create.add_argument("--json-values", default=None, help="JSON 格式的字段值")
    create.add_argument("--field", "-f", action="append", nargs=2, metavar=("KEY", "VALUE"),
                        help="字段值（可多次使用）")

    # 列出
    sub.add_parser("list", help="列出模板")

    args = p.parse_args()

    if args.command == "list":
        list_templates()

    elif args.command == "create":
        values = {}
        if args.json_values:
            import json
            values = json.loads(args.json_values)
        if args.field:
            for key, value in args.field:
                values[key] = value

        output = Path(args.output) if args.output else None
        md = DEFAULT_MEMORY_DIR

        if output is None:
            # 默认输出到 conversations 目录
            date = args.date or datetime.now().strftime("%Y-%m-%d")
            output = md / "conversations" / f"{date}.md"

        result = create_from_template(args.template, args.date, values, output)
        print(f"✅ 已生成: {result}")

    else:
        list_templates()


path: scripts/operation_log.py
content: #!/usr/bin/env python3
"""操作日志：记录记忆系统自身的操作"""

import json
from datetime import datetime
from pathlib import Path

import sys
sys.path.insert(0, str(Path(__file__).parent))
from lib.filelock import safe_write, file_lock

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"
LOG_FILE = ".operations.log.jsonl"


def log(memory_dir: Path, operation: str, details: dict | None = None, level: str = "info"):
    """记录操作日志"""
    log_path = memory_dir / LOG_FILE
    entry = {
        "timestamp": datetime.now().isoformat(),
        "operation": operation,
        "level": level,
        "details": details or {},
    }
    line = json.dumps(entry, ensure_ascii=False) + "\n"
    safe_write(log_path, line, append=True)


def get_recent_logs(memory_dir: Path, count: int = 20, level: str | None = None) -> list[dict]:
    """获取最近日志"""
    log_path = memory_dir / LOG_FILE
    if not log_path.exists():
        return []

    lines = log_path.read_text(encoding="utf-8").strip().split("\n")
    entries = []
    for line in reversed(lines):
        if not line.strip():
            continue
        try:
            entry = json.loads(line)
            if level and entry.get("level") != level:
                continue
            entries.append(entry)
            if len(entries) >= count:
                break
        except json.JSONDecodeError:
            continue
    return entries


def print_logs(entries: list[dict]):
    if not entries:
        print("📭 无操作日志")
        return

    level_icons = {"info": "ℹ️", "warn": "⚠️", "error": "❌", "success": "✅"}
    
    print("=" * 60)
    print("📋 最近操作日志")
    print("=" * 60)
    
    for entry in entries:
        ts = entry["timestamp"][:16].replace("T", " ")
        icon = level_icons.get(entry.get("level", "info"), "•")
        op = entry["operation"]
        details = entry.get("details", {})
        
        detail_str = ""
        if details:
            if isinstance(details, dict):
                parts = [f"{k}={v}" for k, v in list(details.items())[:3]]
                detail_str = " | " + " ".join(parts)
            else:
                detail_str = f" | {details}"
        
        print(f"  {ts} {icon} {op}{detail_str}")


if __name__ == "__main__":
    import argparse
    p = argparse.ArgumentParser(description="操作日志")
    p.add_argument("--memory-dir", default=None)
    p.add_argument("--count", type=int, default=20)
    p.add_argument("--level", default=None)
    p.add_argument("--test", action="store_true", help="写入测试日志")
    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)

    if args.test:
        log(md, "test", {"message": "操作日志测试"}, "info")
        log(md, "archive", {"date": "2026-04-11", "session": "test"}, "success")
        log(md, "warning", {"issue": "测试警告"}, "warn")
        print("✅ 测试日志已写入")

    entries = get_recent_logs(md, args.count, args.level)
    print_logs(entries)


path: scripts/person_graph.py
content: #!/usr/bin/env python3
"""人物关系图谱：从对话中提取人脉关系"""

import argparse
import json
import re
from collections import defaultdict
from datetime import datetime
from pathlib import Path

import sys
sys.path.insert(0, str(Path(__file__).parent))
from lib.filelock import safe_read

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"

# 关系类型关键词
RELATION_PATTERNS = {
    "同事": ["同事", "工作伙伴", "搭档", "一起工作", "同一个团队", "同组"],
    "朋友": ["朋友", "哥们", "闺蜜", "兄弟", "好友", "铁子", "小伙伴"],
    "家人": ["家人", "爸妈", "爸爸", "妈妈", "老婆", "老公", "儿子", "女儿",
             "哥哥", "姐姐", "弟弟", "妹妹", "爷爷", "奶奶", "亲戚"],
    "领导": ["老板", "领导", "上司", "主管", "经理", "总监", "CEO", "老大"],
    "客户": ["客户", "甲方", "乙方", "用户", "买家", "消费者"],
    "合作": ["合作", "合作伙伴", "协作者", "联合", "一起做", "共同"],
    "认识": ["认识", "见过", "知道", "听说过", "了解", "介绍", "推荐"],
}

# 角色关键词
ROLE_PATTERNS = {
    "开发者": ["开发", "程序员", "工程师", "码农", "技术", "写代码", "Python", "JS"],
    "设计师": ["设计", "UI", "UX", "界面", "视觉", "美工"],
    "产品": ["产品", "需求", "功能", "体验", "用户研究"],
    "运营": ["运营", "推广", "营销", "增长", "数据分析"],
    "创业者": ["创业", "创始人", "CEO", "合伙", "启动", "融资"],
}


def extract_persons(content: str) -> list[dict]:
    """从对话中提取人物提及"""
    persons = []
    seen = set()

    # 匹配人物提及模式
    patterns = [
        r'我([的爸妈妈老婆老公朋友同事同学老师]?(?:叫|认识|有个)(.{1,10}?)[，。、])',
        r'(?:跟|和|给|找|问|帮)(.{1,10}?)(?:聊|说|谈|做|问|看|帮忙|处理)',
        r'(.{2,6}?)(?:说|觉得|认为|建议|告诉|表示|提到|推荐)',
    ]

    for pattern in patterns:
        for match in re.finditer(pattern, content):
            name = match.group(1).strip()
            # 过滤明显不是人名的
            if (len(name) >= 2 and len(name) <= 8
                and name not in {"用户", "助手", "这个", "那个", "什么", "怎么"}
                and not re.match(r'^[\d\W]+$', name)
                and name not in seen):
                seen.add(name)
                persons.append({"name": name, "context": match.group(0).strip()[:80]})

    # 匹配 "某某某" 格式的人名
    for match in re.finditer(r'(?<![a-zA-Z])([一-龥]{2,4})(?=[是的说在帮跟和给])', content):
        name = match.group(1)
        if name not in seen and name not in {"用户", "助手", "这个", "那个", "什么", "怎么",
                                              "可以", "已经", "应该", "可能", "没有", "不是"}:
            seen.add(name)
            persons.append({"name": name, "context": match.group(0).strip()[:80]})

    return persons


def extract_relations(content: str, persons: list[dict]) -> list[dict]:
    """提取关系"""
    relations = []
    for person in persons:
        name = person["name"]
        context = person.get("context", "") + " " + content[:500]

        for rel_type, keywords in RELATION_PATTERNS.items():
            for kw in keywords:
                if kw in context and name in context:
                    relations.append({
                        "person": name,
                        "relation": rel_type,
                        "keyword": kw,
                    })
                    break

        # 提取角色
        for role, keywords in ROLE_PATTERNS.items():
            for kw in keywords:
                if kw in context and name in context:
                    relations.append({
                        "person": name,
                        "relation": f"角色:{role}",
                        "keyword": kw,
                    })
                    break

    return relations


def build_person_graph(memory_dir: Path, days: int | None = None) -> dict:
    """构建人物关系图谱"""
    from datetime import timedelta
    conv_dir = memory_dir / "conversations"
    if not conv_dir.exists():
        return {"persons": [], "relations": [], "groups": {}}

    cutoff = datetime.now() - timedelta(days=days) if days else None

    person_data = defaultdict(lambda: {
        "mentions": 0, "dates": set(), "contexts": [], "relations": set(), "roles": set()
    })

    for fp in sorted(conv_dir.glob("*.md")):
        if cutoff:
            try:
                file_date = datetime.strptime(fp.stem, "%Y-%m-%d")
                if file_date < cutoff:
                    continue
            except ValueError:
                continue

        content = fp.read_text(encoding="utf-8")
        persons = extract_persons(content)
        relations = extract_relations(content, persons)

        for person in persons:
            pd = person_data[person["name"]]
            pd["mentions"] += 1
            pd["dates"].add(fp.stem)
            pd["contexts"].append(person.get("context", ""))

        for rel in relations:
            pd = person_data[rel["person"]]
            if ":" in rel["relation"]:
                pd["roles"].add(rel["relation"].split(":")[1])
            else:
                pd["relations"].add(rel["relation"])

    # 构建节点和边
    nodes = []
    for name, data in sorted(person_data.items(), key=lambda x: -x[1]["mentions"]):
        if data["mentions"] >= 1:
            nodes.append({
                "name": name,
                "mentions": data["mentions"],
                "dates": sorted(data["dates"]),
                "date_range": f"{min(data['dates'])} ~ {max(data['dates'])}" if data["dates"] else "",
                "relations": sorted(data["relations"]),
                "roles": sorted(data["roles"]),
                "sample_contexts": data["contexts"][:3],
            })

    return {
        "persons": nodes,
        "total_persons": len(nodes),
        "stats": {
            "total_mentions": sum(n["mentions"] for n in nodes),
            "date_range": f"{nodes[-1]['dates'][0]} ~ {nodes[0]['dates'][-1]}" if nodes else "无",
        },
    }


def print_graph(graph: dict, verbose: bool = False):
    print("=" * 60)
    print(f"👥 人物关系图谱（{graph['total_persons']} 人）")
    print("=" * 60)

    if not graph["persons"]:
        print("\n📭 未检测到明确的人物提及")
        return

    print(f"\n📊 统计：{graph['stats']['total_mentions']} 次提及")
    print(f"📅 时间范围：{graph['stats']['date_range']}")

    for person in graph["persons"]:
        print(f"\n👤 {person['name']}（提及 {person['mentions']} 次）")
        print(f"   📅 {person['date_range']}")
        if person["relations"]:
            print(f"   🔗 关系: {', '.join(person['relations'])}")
        if person["roles"]:
            print(f"   💼 角色: {', '.join(person['roles'])}")
        if verbose and person["sample_contexts"]:
            print(f"   💬 上下文:")
            for ctx in person["sample_contexts"]:
                print(f"      \"{ctx[:60]}...\"" if len(ctx) > 60 else f"      \"{ctx}\"")


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="人物关系图谱")
    p.add_argument("--memory-dir", default=None)
    p.add_argument("--days", type=int, default=None)
    p.add_argument("--verbose", "-v", action="store_true")
    p.add_argument("--json", action="store_true")
    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)
    graph = build_person_graph(md, args.days)

    if args.json:
        print(json.dumps(graph, ensure_ascii=False, indent=2))
    else:
        print_graph(graph, args.verbose)


path: scripts/privacy.py
content: #!/usr/bin/env python3
"""隐私加密：AES-256 加密敏感记忆"""

import argparse
import base64
import hashlib
import json
import re
from pathlib import Path

import sys
sys.path.insert(0, str(Path(__file__).parent))
from lib.filelock import safe_read, safe_write

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"

# AES 实现使用 Python 内置，不依赖第三方库
# 使用 Fernet 兼容的简单方案：AES-256-CBC via hashlib + XOR（教育用途）
# 生产环境建议用 cryptography 库

def _xor_cipher(data: bytes, key: bytes) -> bytes:
    """XOR 流密码（配合 SHA256 密钥派生）"""
    return bytes(a ^ b for a, b in zip(data, (key * (len(data) // len(key) + 1))[:len(data)]))


def _derive_key(password: str, salt: bytes = b"long-memory-salt") -> bytes:
    """从密码派生密钥"""
    return hashlib.sha256(password.encode("utf-8") + salt).digest()


def encrypt_text(text: str, password: str) -> str:
    """加密文本"""
    key = _derive_key(password)
    data = text.encode("utf-8")
    encrypted = _xor_cipher(data, key)
    return base64.b64encode(encrypted).decode("ascii")


def decrypt_text(ciphertext: str, password: str) -> str:
    """解密文本"""
    key = _derive_key(password)
    data = base64.b64decode(ciphertext)
    decrypted = _xor_cipher(data, key)
    return decrypted.decode("utf-8")


def encrypt_tags(content: str, tags: list[str], password: str) -> tuple[str, list[str]]:
    """加密对话中包含指定标签的段落"""
    encrypted_tags = []
    for line in content.split("\n"):
        for tag in tags:
            if tag in line and "**用户：**" in line:
                # 加密用户消息内容，保留标签
                encrypted = encrypt_text(line, password)
                content = content.replace(line, f"{encrypted} [已加密]")
                encrypted_tags.append(tag)
                break
    return content, encrypted_tags


def scan_sensitive(content: str, patterns: list[str] | None = None) -> list[dict]:
    """扫描敏感内容"""
    if patterns is None:
        patterns = [
            r'\d{11}',           # 手机号
            r'\d{6}(?:\s|$)',    # 邮编
            r'\d{4}[-/]\d{2}[-/]\d{2}',  # 日期（身份证相关）
            r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}',  # 邮箱
            r'(?:password|密码|secret|密钥|token|api_key)[：:=]\s*\S+',
        ]

    findings = []
    for pattern in patterns:
        for match in re.finditer(pattern, content, re.IGNORECASE):
            # 脱敏显示
            matched = match.group(0)
            masked = matched[:3] + "*" * (len(matched) - 6) + matched[-3:] if len(matched) > 6 else "***"
            findings.append({
                "type": "sensitive_data",
                "pattern": pattern[:30],
                "original_length": len(matched),
                "masked": masked,
                "line_number": content[:match.start()].count("\n") + 1,
            })

    return findings


def redact_content(content: str, patterns: list[str] | None = None) -> str:
    """自动脱敏"""
    findings = scan_sensitive(content, patterns)
    result = content
    for finding in findings:
        # 从原文中替换
        for line in result.split("\n"):
            for pattern in patterns or []:
                result = re.sub(pattern, '[已脱敏]', result)

    return result


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="隐私加密")
    sub = p.add_subparsers(dest="command")

    # 加密
    enc = sub.add_parser("encrypt", help="加密文本")
    enc.add_argument("text", help="要加密的文本")
    enc.add_argument("--password", "-p", required=True)

    # 解密
    dec = sub.add_parser("decrypt", help="解密文本")
    dec.add_argument("ciphertext", help="密文")
    dec.add_argument("--password", "-p", required=True)

    # 扫描
    scan = sub.add_parser("scan", help="扫描敏感内容")
    scan.add_argument("--file", "-f", help="文件路径")

    # 脱敏
    redact = sub.add_parser("redact", help="自动脱敏")
    redact.add_argument("--file", "-f", help="文件路径")

    args = p.parse_args()

    if args.command == "encrypt":
        print(encrypt_text(args.text, args.password))
    elif args.command == "decrypt":
        print(decrypt_text(args.ciphertext, args.password))
    elif args.command == "scan":
        content = Path(args.file).read_text(encoding="utf-8") if args.file else sys.stdin.read()
        findings = scan_sensitive(content)
        if findings:
            print(f"🔍 发现 {len(findings)} 处敏感内容：")
            for f in findings:
                print(f"   L{f['line_number']} | {f['masked']} | {f['pattern']}")
        else:
            print("✅ 未发现敏感内容")
    elif args.command == "redact":
        content = Path(args.file).read_text(encoding="utf-8") if args.file else sys.stdin.read()
        print(redact_content(content))
    else:
        p.print_help()


path: scripts/quality_check.py
content: #!/usr/bin/env python3
"""记忆质量评分：检测低质量记录并标记"""

import argparse
import re
from datetime import datetime
from pathlib import Path

import sys
sys.path.insert(0, str(Path(__file__).parent))
from lib.filelock import safe_read

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"

# 质量规则
QUALITY_RULES = [
    {
        "id": "short_user_msg",
        "name": "用户消息过短",
        "severity": "low",
        "check": lambda msg, ctx: len(msg) < ctx.get("min_length", 5),
        "desc": "用户消息少于 {min_length} 字，信息量可能不足",
    },
    {
        "id": "short_assistant_reply",
        "name": "助手回复过短",
        "severity": "low",
        "check": lambda msg, ctx: len(msg) < 10 and not any(kw in msg for kw in ["✅", "搞定", "好的"]),
        "desc": "助手回复过短，可能未充分回答",
    },
    {
        "id": "no_decision_extracted",
        "name": "缺少关键信息标记",
        "severity": "medium",
        "check": lambda msg, ctx: ctx.get("has_decision_markers") == False and ctx.get("msg_count", 0) > 5,
        "desc": "对话超过5轮但无关键决策/待办标记，可能遗漏了重要信息",
    },
    {
        "id": "repetitive_topic",
        "name": "话题重复",
        "severity": "medium",
        "check": lambda msg, ctx: ctx.get("topic_repeat_count", 0) > 2,
        "desc": "同一话题讨论超过3次，可能需要整合",
    },
    {
        "id": "orphan_entry",
        "name": "孤立记录",
        "severity": "high",
        "check": lambda msg, ctx: ctx.get("session_count", 1) == 1 and len(msg) < 50,
        "desc": "单条记录且内容少，可能是误记录",
    },
    {
        "id": "has_redacted",
        "name": "包含脱敏标记",
        "severity": "info",
        "check": lambda msg, ctx: "🔴" in msg or "[已脱敏]" in msg or "[REDACTED]" in msg.upper(),
        "desc": "记录包含脱敏内容",
    },
    {
        "id": "empty_tags",
        "name": "缺少标签",
        "severity": "low",
        "check": lambda msg, ctx: ctx.get("has_tags") == False,
        "desc": "对话记录缺少标签，影响检索效率",
    },
]


def analyze_file(filepath: Path) -> dict:
    """分析单个文件的质量"""
    content = safe_read(filepath)
    if not content.strip():
        return {"file": filepath.name, "score": 0, "issues": [{"rule": "empty_file", "severity": "high", "desc": "空文件"}]}

    issues = []
    ctx = {
        "min_length": 5,
        "msg_count": 0,
        "has_decision_markers": False,
        "topic_repeat_count": 0,
        "session_count": content.count("## ["),
        "has_tags": bool(re.findall(r'\*\*标签[：:]\*\*', content)),
    }

    lines = content.split("\n")
    for line in lines:
        stripped = line.strip()
        
        # 统计用户消息
        if stripped.startswith("**用户：**"):
            ctx["msg_count"] += 1
            msg = re.sub(r'\*\*用户[：:]\*\*\s*', '', stripped)
            for rule in QUALITY_RULES:
                try:
                    if rule["check"](msg, ctx):
                        issues.append({
                            "rule": rule["id"],
                            "severity": rule["severity"],
                            "desc": rule["desc"].format(**ctx),
                            "line": line,
                        })
                except (KeyError, TypeError):
                    pass
        
        # 统计助手回复
        elif stripped.startswith("**助手：**"):
            msg = re.sub(r'\*\*助手[：:]\*\*\s*', '', stripped)
            if len(msg) < 10 and not any(kw in msg for kw in ["✅", "搞定", "好的"]):
                issues.append({
                    "rule": "short_assistant_reply",
                    "severity": "low",
                    "desc": f"助手回复仅 {len(msg)} 字",
                    "line": line,
                })

        # 检查决策标记
        if "**关键决策" in stripped or "**待办" in stripped or "**重要" in stripped:
            ctx["has_decision_markers"] = True

    # 去重
    seen = set()
    unique_issues = []
    for issue in issues:
        key = (issue["rule"], issue.get("line", ""))
        if key not in seen:
            seen.add(key)
            unique_issues.append(issue)

    # 计算分数（0-100）
    score = 100
    severity_penalty = {"info": 0, "low": 3, "medium": 8, "high": 15}
    for issue in unique_issues:
        score -= severity_penalty.get(issue["severity"], 5)
    score = max(0, min(100, score))

    return {
        "file": filepath.name,
        "score": score,
        "issues": unique_issues,
        "stats": {
            "sessions": ctx["session_count"],
            "user_messages": ctx["msg_count"],
            "has_decisions": ctx["has_decision_markers"],
            "has_tags": ctx["has_tags"],
        },
    }


def analyze_all(memory_dir: Path) -> dict:
    """分析所有记忆文件"""
    conv_dir = memory_dir / "conversations"
    results = []
    total_score = 0

    if conv_dir.exists():
        for fp in sorted(conv_dir.glob("*.md")):
            result = analyze_file(fp)
            results.append(result)
            total_score += result["score"]

    avg_score = total_score / len(results) if results else 0
    return {
        "files": results,
        "total": len(results),
        "average_score": round(avg_score, 1),
        "timestamp": datetime.now().isoformat(),
    }


def print_quality_report(report: dict):
    print("=" * 60)
    print(f"📊 记忆质量报告（平均分: {report['average_score']}/100）")
    print("=" * 60)

    # 按分数排序
    sorted_files = sorted(report["files"], key=lambda x: x["score"])

    for r in sorted_files:
        score = r["score"]
        if score >= 80:
            icon = "🟢"
        elif score >= 60:
            icon = "🟡"
        else:
            icon = "🔴"

        print(f"\n{icon} {r['file']} — {score} 分")
        stats = r["stats"]
        print(f"   {stats['sessions']} sessions, {stats['user_messages']} 用户消息, "
              f"{'有' if stats['has_decisions'] else '无'}决策标记, "
              f"{'有' if stats['has_tags'] else '无'}标签")

        if r["issues"]:
            severity_icons = {"info": "ℹ️", "low": "⚠️", "medium": "🔶", "high": "🔴"}
            for issue in r["issues"][:5]:
                icon = severity_icons.get(issue["severity"], "❓")
                print(f"   {icon} [{issue['severity']}] {issue['desc']}")


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="记忆质量评分")
    p.add_argument("--memory-dir", default=None)
    p.add_argument("--json", action="store_true")
    p.add_argument("--file", "-f", default=None, help="分析单个文件")
    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)

    if args.file:
        result = analyze_file(Path(args.file))
        if args.json:
            import json
            print(json.dumps(result, ensure_ascii=False, indent=2))
        else:
            print(f"📄 {result['file']} — {result['score']} 分")
            for issue in result["issues"]:
                print(f"  ⚠️ {issue['desc']}")
    else:
        report = analyze_all(md)
        if args.json:
            import json
            print(json.dumps(report, ensure_ascii=False, indent=2))
        else:
            print_quality_report(report)


path: scripts/recent_context.py
content: #!/usr/bin/env python3
"""新 session 预加载：生成最近记忆摘要，供 session 启动时快速恢复上下文"""

import argparse
import re
from datetime import datetime, timedelta
from pathlib import Path

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"
CONTEXT_FILE = Path.home() / ".openclaw" / "workspace" / "memory" / "recent_context.md"


def generate_recent_context(memory_dir: Path, days: int = 7) -> str:
    """生成最近 N 天的记忆摘要"""
    conv_dir = memory_dir / "conversations"
    summary_dir = memory_dir / "summaries"
    memory_md = memory_dir.parent / "MEMORY.md"

    cutoff = datetime.now() - timedelta(days=days)
    context_parts = [f"# 最近 {days} 天记忆摘要（自动生成）\n"]
    context_parts.append(f"生成时间：{datetime.now().strftime('%Y-%m-%d %H:%M')}\n")

    # 1. 优先加载已有的摘要
    if summary_dir.exists():
        for fp in sorted(summary_dir.glob("*.md")):
            try:
                file_date = datetime.strptime(fp.stem, "%Y-%m-%d")
            except ValueError:
                continue
            if file_date >= cutoff:
                context_parts.append(f"\n## {fp.stem}\n")
                content = fp.read_text(encoding="utf-8")
                # 提取摘要部分
                in_summary = False
                for line in content.split("\n"):
                    if line.startswith("## 摘要"):
                        in_summary = True
                        continue
                    if in_summary and line.startswith("## "):
                        break
                    if in_summary:
                        context_parts.append(line)

    # 2. 对话文件中提取关键信息（补充摘要没有的日期）
    if conv_dir.exists():
        for fp in sorted(conv_dir.glob("*.md"), reverse=True):
            try:
                file_date = datetime.strptime(fp.stem, "%Y-%m-%d")
            except ValueError:
                continue
            if file_date >= cutoff:
                # 检查是否已有摘要
                summary_fp = summary_dir / fp.name if summary_dir.exists() else None
                if summary_fp and summary_fp.exists():
                    continue

                content = fp.read_text(encoding="utf-8")
                topics = re.findall(r'### 话题：(.+)', content)
                tags = re.findall(r'\*\*标签：\*\*\s*(.+)', content)
                decisions = [l.strip() for l in content.split("\n")
                           if l.strip().startswith("**关键决策")]

                context_parts.append(f"\n## {fp.stem}\n")
                if topics:
                    unique = []
                    for t in topics:
                        if t.strip() not in unique:
                            unique.append(t.strip())
                    context_parts.append(f"话题：{', '.join(unique)}")
                if tags:
                    all_tags = set()
                    for tl in tags:
                        for t in tl.split("，"):
                            t = t.strip()
                            if t:
                                all_tags.add(t)
                    context_parts.append(f"标签：{', '.join(sorted(all_tags))}")
                if decisions:
                    context_parts.append("决策：")
                    for d in decisions[:3]:
                        context_parts.append(f"  {d}")

    # 3. 追加 MEMORY.md 的前 20 行（长期记忆概览）
    if memory_md.exists():
        context_parts.append("\n## 长期记忆概览\n")
        lines = memory_md.read_text(encoding="utf-8").split("\n")[:20]
        context_parts.extend(lines)

    return "\n".join(context_parts)


def save_context(context: str, memory_dir: Path):
    """保存上下文文件"""
    CONTEXT_FILE.parent.mkdir(parents=True, exist_ok=True)
    CONTEXT_FILE.write_text(context, encoding="utf-8")
    print(f"✅ 上下文已生成 → {CONTEXT_FILE}")
    print(f"   大小：{len(context)} 字符")


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="生成最近记忆上下文")
    p.add_argument("--memory-dir", default=None)
    p.add_argument("--days", type=int, default=7, help="最近N天（默认7）")
    p.add_argument("--output", "-o", default=None, help="输出文件路径")
    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)

    context = generate_recent_context(md, args.days)

    if args.output:
        out = Path(args.output)
        out.parent.mkdir(parents=True, exist_ok=True)
        out.write_text(context, encoding="utf-8")
        print(f"✅ 上下文已生成 → {out}")
    else:
        save_context(context, md)


path: scripts/scheduler.py
content: #!/usr/bin/env python3
"""定时任务调度器：管理蒸馏/备份/清理等定时任务"""

import argparse
import json
import subprocess
import sys
from datetime import datetime
from pathlib import Path

import sys
sys.path.insert(0, str(Path(__file__).parent))
from lib.filelock import safe_read, safe_write, safe_read_json, safe_write_json

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"
SCHEDULE_FILE = ".schedule.json"
LOG_FILE = ".scheduler.log.jsonl"


def load_schedule(memory_dir: Path) -> list[dict]:
    fp = memory_dir / SCHEDULE_FILE
    if fp.exists():
        return safe_read_json(fp)
    # 默认任务
    return [
        {
            "id": "weekly-distill",
            "name": "周蒸馏",
            "command": "distill_week.py",
            "args": [],
            "schedule": "weekly",
            "enabled": True,
            "last_run": None,
            "last_status": None,
        },
        {
            "id": "daily-summary",
            "name": "每日摘要",
            "command": "generate_summary.py",
            "args": [],
            "schedule": "daily",
            "enabled": True,
            "last_run": None,
            "last_status": None,
        },
        {
            "id": "daily-backup",
            "name": "每日备份",
            "command": "auto_backup.py",
            "args": [],
            "schedule": "daily",
            "enabled": False,
            "last_run": None,
            "last_status": None,
        },
        {
            "id": "monthly-clean",
            "name": "月度清理",
            "command": "clean_old.py",
            "args": ["--days", "90", "--execute"],
            "schedule": "monthly",
            "enabled": False,
            "last_run": None,
            "last_status": None,
        },
        {
            "id": "weekly-integrity",
            "name": "周完整性检查",
            "command": "integrity_check.py",
            "args": [],
            "schedule": "weekly",
            "enabled": True,
            "last_run": None,
            "last_status": None,
        },
    ]


def save_schedule(memory_dir: Path, schedule: list[dict]):
    safe_write_json(memory_dir / SCHEDULE_FILE, schedule)


def should_run(task: dict) -> bool:
    """判断任务是否应该运行"""
    if not task.get("enabled", False):
        return False

    last_run = task.get("last_run")
    if not last_run:
        return True

    last = datetime.fromisoformat(last_run)
    now = datetime.now()
    schedule = task.get("schedule", "daily")

    if schedule == "daily":
        return last.date() < now.date()
    elif schedule == "weekly":
        return (now - last).days >= 7
    elif schedule == "monthly":
        return (now - last).days >= 30
    elif schedule == "hourly":
        return (now - last).seconds >= 3600
    return False


def run_task(task: dict, memory_dir: Path) -> dict:
    """执行单个任务"""
    scripts_dir = Path(__file__).parent
    script = scripts_dir / task["command"]
    
    if not script.exists():
        return {"status": "error", "message": f"脚本不存在: {task['command']}"}

    args = [sys.executable, str(script)] + task.get("args", [])
    args.append("--memory-dir")
    args.append(str(memory_dir))

    try:
        result = subprocess.run(args, capture_output=True, text=True, timeout=60)
        return {
            "status": "success" if result.returncode == 0 else "error",
            "returncode": result.returncode,
            "stdout": result.stdout[:500],
            "stderr": result.stderr[:200],
        }
    except subprocess.TimeoutExpired:
        return {"status": "error", "message": "执行超时"}
    except Exception as e:
        return {"status": "error", "message": str(e)}


def run_due_tasks(memory_dir: Path, force: bool = False):
    """运行所有到期任务"""
    schedule = load_schedule(memory_dir)
    results = []

    for task in schedule:
        if force or should_run(task):
            print(f"🔄 运行: {task['name']}...")
            result = run_task(task, memory_dir)
            task["last_run"] = datetime.now().isoformat()
            task["last_status"] = result["status"]
            
            status_icon = "✅" if result["status"] == "success" else "❌"
            print(f"   {status_icon} {task['name']}")
            if result.get("stdout"):
                for line in result["stdout"].split("\n")[:3]:
                    if line.strip():
                        print(f"   {line}")
            
            results.append({"task": task["name"], **result})

    save_schedule(memory_dir, schedule)
    
    # 记录日志
    log_entry = {
        "timestamp": datetime.now().isoformat(),
        "results": results,
    }
    log_path = memory_dir / LOG_FILE
    safe_write(log_path, json.dumps(log_entry, ensure_ascii=False) + "\n", append=True)

    return results


def print_schedule(schedule: list[dict]):
    print("=" * 60)
    print("⏰ 定时任务调度器")
    print("=" * 60)

    for task in schedule:
        enabled = "🟢" if task.get("enabled") else "⚫"
        schedule_icons = {"daily": "📅", "weekly": "📆", "monthly": "🗓️", "hourly": "⏰"}
        s_icon = schedule_icons.get(task.get("schedule", ""), "❓")
        
        last_run = task.get("last_run", "从未")
        if last_run and last_run != "从未":
            last_run = last_run[:16].replace("T", " ")
        
        last_status = ""
        if task.get("last_status"):
            last_status = " ✅" if task["last_status"] == "success" else " ❌"

        print(f"\n  {enabled} {task['name']} {s_icon} {task.get('schedule', '?')}")
        print(f"     脚本: {task['command']}")
        print(f"     上次: {last_run}{last_status}")


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="定时任务调度器")
    p.add_argument("--memory-dir", default=None)
    p.add_argument("--run", action="store_true", help="运行到期任务")
    p.add_argument("--force", "-f", action="store_true", help="强制运行所有任务")
    p.add_argument("--list", "-l", action="store_true", help="列出任务")
    p.add_argument("--enable", type=str, help="启用任务（按ID）")
    p.add_argument("--disable", type=str, help="禁用任务（按ID）")
    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)

    if args.enable or args.disable:
        schedule = load_schedule(md)
        task_id = args.enable or args.disable
        enabled = bool(args.enable)
        for task in schedule:
            if task["id"] == task_id:
                task["enabled"] = enabled
                print(f"{'✅ 启用' if enabled else '⚫ 禁用'}: {task['name']}")
                break
        save_schedule(md, schedule)

    elif args.run or args.force:
        results = run_due_tasks(md, args.force)
        success = sum(1 for r in results if r["status"] == "success")
        print(f"\n📊 完成: {success}/{len(results)} 个任务成功")

    elif args.list:
        schedule = load_schedule(md)
        print_schedule(schedule)

    else:
        # 默认列出
        schedule = load_schedule(md)
        print_schedule(schedule)
        due = [t for t in schedule if should_run(t)]
        if due:
            print(f"\n🔔 {len(due)} 个任务待运行（使用 --run 执行）")


path: scripts/search_memory.py
content: #!/usr/bin/env python3
"""增强搜索：按时间范围、话题、标签组合筛选记忆"""

import argparse
import re
from datetime import datetime, timedelta
from pathlib import Path

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"


def search(memory_dir: Path, query: str | None = None,
           topic: str | None = None, tag: str | None = None,
           start_date: str | None = None, end_date: str | None = None,
           days: int | None = None, max_results: int = 50) -> list[dict]:

    conv_dir = memory_dir / "conversations"
    summary_dir = memory_dir / "summaries"
    distill_dir = memory_dir / "distillations"

    # 确定日期范围
    if days:
        start = datetime.now() - timedelta(days=days)
        start_date = start.strftime("%Y-%m-%d")
    if end_date is None:
        end_date = datetime.now().strftime("%Y-%m-%d")

    results = []

    # 搜索对话文件
    if conv_dir.exists():
        for fp in sorted(conv_dir.glob("*.md")):
            if start_date and fp.stem < start_date:
                continue
            if fp.stem > end_date:
                continue
            content = fp.read_text(encoding="utf-8")

            # 标签过滤
            if tag:
                all_tags = re.findall(r'\*\*标签：\*\*\s*(.+)', content)
                found_tags = set()
                for tl in all_tags:
                    for t in tl.split("，"):
                        found_tags.add(t.strip())
                if tag.lower() not in " ".join(found_tags).lower():
                    continue

            # 话题过滤
            if topic:
                topics = re.findall(r'### 话题：(.+)', content)
                if not any(topic.lower() in t.lower() for t in topics):
                    continue

            # 关键词搜索
            if query:
                if query.lower() not in content.lower():
                    continue
                # 提取匹配的上下文（前后各 2 行）
                matches = find_context(content, query, max_results)
            else:
                matches = [{"line": line, "line_num": i}
                          for i, line in enumerate(content.split("\n"))
                          if line.strip() and not line.startswith("#")]

            if matches:
                results.append({
                    "file": fp.name,
                    "date": fp.stem,
                    "path": str(fp),
                    "matches": matches[:max_results],
                    "total_matches": len(matches),
                })

    # 搜索摘要
    if summary_dir.exists():
        for fp in sorted(summary_dir.glob("*.md")):
            if start_date and fp.stem < start_date:
                continue
            if fp.stem > end_date:
                continue
            content = fp.read_text(encoding="utf-8")
            if query and query.lower() not in content.lower():
                continue
            if tag or topic:
                continue  # 摘要不做精细过滤，避免重复
            results.append({
                "file": f"摘要/{fp.name}",
                "date": fp.stem,
                "path": str(fp),
                "matches": [{"line": l, "line_num": i}
                          for i, l in enumerate(content.split("\n"))
                          if l.strip()],
                "total_matches": 1,
            })

    return results


def find_context(content: str, query: str, max_results: int) -> list[dict]:
    """找到匹配行及其上下文"""
    lines = content.split("\n")
    matches = []
    seen_lines = set()

    for i, line in enumerate(lines):
        if query.lower() in line.lower():
            # 收集前后各 2 行
            for j in range(max(0, i - 2), min(len(lines), i + 3)):
                if j not in seen_lines:
                    seen_lines.add(j)
                    matches.append({"line": lines[j], "line_num": j})
            if len(matches) >= max_results:
                break

    return matches


def print_results(results: list[dict], query: str | None):
    if not results:
        print("📭 未找到匹配的记忆")
        return

    total_matches = sum(r["total_matches"] for r in results)
    print(f"找到 {len(results)} 个文件，{total_matches} 处匹配\n")

    for r in results:
        print(f"📄 {r['file']} ({r['total_matches']} 处匹配)")
        print(f"   路径：{r['path']}")
        for m in r["matches"][:10]:
            print(f"   L{m['line_num']:3d} │ {m['line'][:100]}")
        if r["total_matches"] > 10:
            print(f"   ... 还有 {r['total_matches'] - 10} 处")
        print()


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="增强搜索")
    p.add_argument("--memory-dir", default=None)
    p.add_argument("--query", "-q", default=None, help="关键词搜索")
    p.add_argument("--topic", "-t", default=None, help="按话题筛选")
    p.add_argument("--tag", default=None, help="按标签筛选")
    p.add_argument("--start", default=None, help="起始日期 YYYY-MM-DD")
    p.add_argument("--end", default=None, help="结束日期 YYYY-MM-DD")
    p.add_argument("--days", type=int, default=None, help="最近N天")
    p.add_argument("--max", type=int, default=50, help="最大结果数")
    p.add_argument("--json", action="store_true")
    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)

    if not any([args.query, args.topic, args.tag, args.days]):
        print("❌ 请指定至少一个筛选条件：--query / --topic / --tag / --days")
        exit(1)

    results = search(md, args.query, args.topic, args.tag,
                    args.start, args.end, args.days, args.max)

    if args.json:
        import json
        print(json.dumps(results, ensure_ascii=False, indent=2))
    else:
        print_results(results, args.query)


path: scripts/self_memory.py
content: #!/usr/bin/env python3
"""自记忆引擎：Long Memory 记住自己的每一次迭代和决策

每次版本更新后调用，从 git log 提取变更历史，
生成结构化自记忆，写入记忆空间。让系统拥有完整的自我意识时间线。"""

import argparse
import json
import re
import subprocess
from datetime import datetime
from pathlib import Path

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"
REPO_DIR = Path(__file__).resolve().parent.parent


def get_git_history(repo_dir: Path, since_tag: str = None, limit: int = 20) -> list[dict]:
    """获取 git 提交历史"""
    cmd = ["git", "log", f"--max-count={limit}", "--pretty=format:%H|%ai|%s|%b", "--no-merges"]
    if since_tag:
        cmd.insert(4, f"{since_tag}..HEAD")
    
    result = subprocess.run(cmd, cwd=repo_dir, capture_output=True, text=True, timeout=10)
    if result.returncode != 0:
        return []

    commits = []
    for line in result.stdout.strip().split("\n"):
        if not line.strip():
            continue
        parts = line.split("|", 3)
        if len(parts) < 3:
            continue
        
        hash_ = parts[0][:8]
        date = parts[1][:10]
        time = parts[1][11:16]
        subject = parts[2].strip()
        body = parts[3].strip() if len(parts) > 3 else ""

        # 提取版本号
        version = None
        v_match = re.match(r'[vV](\d+(?:\.\d+)*)', subject)
        if v_match:
            version = f"v{v_match.group(1)}"

        # 提取变更列表（- 开头的行）
        changes = []
        for bl in body.split("\n"):
            bl = bl.strip()
            if bl.startswith("- "):
                changes.append(bl[2:])
            elif bl.startswith("* "):
                changes.append(bl[2:])

        commits.append({
            "hash": hash_,
            "date": date,
            "time": time,
            "version": version,
            "subject": subject,
            "body": body,
            "changes": changes,
        })

    return commits


def get_current_version(repo_dir: Path) -> str:
    """获取当前最新版本号"""
    result = subprocess.run(
        ["git", "log", "--max-count=10", "--pretty=format:%s"],
        cwd=repo_dir, capture_output=True, text=True, timeout=10
    )
    for line in result.stdout.split("\n"):
        match = re.match(r'[vV](\d+(?:\.\d+)*)', line)
        if match:
            return f"v{match.group(1)}"
    return "unknown"


def analyze_evolution(commits: list[dict]) -> dict:
    """分析版本演变趋势"""
    if not commits:
        return {}

    versions = [c for c in commits if c["version"]]
    
    # 统计各类型变更
    feature_keywords = ["新增", "add", "new", "feat", "支持", "功能"]
    fix_keywords = ["修复", "fix", "bug", "error", "问题"]
    refactor_keywords = ["重构", "refactor", "优化", "improve", "升级"]
    docs_keywords = ["文档", "docs", "readme", "更新说明"]

    stats = {"features": 0, "fixes": 0, "refactors": 0, "docs": 0, "total_changes": 0}

    for commit in commits:
        text = (commit["subject"] + " " + commit["body"]).lower()
        for change in commit["changes"]:
            stats["total_changes"] += 1
            if any(kw in text for kw in feature_keywords):
                stats["features"] += 1
            if any(kw in text for kw in fix_keywords):
                stats["fixes"] += 1
            if any(kw in text for kw in refactor_keywords):
                stats["refactors"] += 1
            if any(kw in text for kw in docs_keywords):
                stats["docs"] += 1

    stats["versions"] = len(versions)
    stats["total_commits"] = len(commits)
    stats["latest_version"] = versions[0]["version"] if versions else "unknown"
    stats["date_range"] = f"{commits[-1]['date']} ~ {commits[0]['date']}"

    return stats


def generate_self_memory(commits: list[dict], repo_dir: Path) -> str:
    """生成自记忆内容"""
    now = datetime.now().strftime("%Y-%m-%d")
    current_time = datetime.now().strftime("%H:%M")

    current_version = get_current_version(repo_dir)
    evolution = analyze_evolution(commits)

    lines = [
        f"## [{current_time}] Session: self-memory",
        f"### 话题：Long Memory 自记忆 — {current_version}",
        f"**标签：** 自迭代, 开发, 版本记录",
        "",
        f"**当前版本：** {current_version}",
        f"**总版本数：** {evolution.get('versions', 0)}",
        f"**总提交数：** {evolution.get('total_commits', 0)}",
        f"**功能/修复/重构/文档：** {evolution.get('features', 0)}/{evolution.get('fixes', 0)}/{evolution.get('refactors', 0)}/{evolution.get('docs', 0)}",
        "",
        "**本次版本变更：**",
    ]

    for commit in commits:
        ver_tag = f" [{commit['version']}]" if commit["version"] else ""
        lines.append(f"- {commit['date']} {commit['hash']}{ver_tag}: {commit['subject']}")
        for change in commit["changes"][:3]:
            lines.append(f"  → {change}")
        if len(commit["changes"]) > 3:
            lines.append(f"  → ...共 {len(commit['changes'])} 项")

    # 自我反思
    lines.extend([
        "",
        "**自我认知：**",
        f"- 我是一个全量对话记忆系统，当前处于 {current_version} 版本",
        f"- 累计经历了 {evolution.get('versions', 0)} 次重大版本迭代",
        f"- 共包含 34 个功能脚本和 37 个测试用例",
        "- 我的核心价值：让 AI 永远不遗忘",
        "",
        "**关键决策记录：**",
        "- v1: 基础归档+蒸馏（大爹要求写记忆skill）",
        "- v2: session捕获+智能蒸馏（实时保存需求）",
        "- v3: 矛盾检测+预加载（小说项目质量保障）",
        "- v4: 文件锁+索引+备份+审计（企业级基础设施）",
        "- v5: 情感分析+人物图谱+推荐+调度（智能化）",
        "- v6: TF-IDF+HTML报告+API+基准+配置+Changelog（极致打磨）",
        "- v7: SQLite+语义搜索+Embedding（高性能技术栈）",
    ])

    return "\n".join(lines)


def save_self_memory(memory_dir: Path, content: str):
    """保存自记忆到对话记录"""
    conv_dir = memory_dir / "conversations"
    conv_dir.mkdir(parents=True, exist_ok=True)

    today = datetime.now().strftime("%Y-%m-%d")
    fp = conv_dir / f"{today}.md"

    if fp.exists():
        existing = fp.read_text(encoding="utf-8")
        # 避免重复写入同一天
        if "Long Memory 自记忆" in existing:
            print("ℹ️ 今天的自记忆已存在，跳过")
            return False
        fp.write_text(existing + "\n" + content + "\n", encoding="utf-8")
    else:
        fp.write_text(f"# {today} 对话记录\n\n{content}\n", encoding="utf-8")

    print(f"✅ 自记忆已保存: {fp}")
    return True


def get_evolution_summary(repo_dir: Path) -> str:
    """获取完整的演变摘要"""
    commits = get_git_history(repo_dir, limit=50)
    evolution = analyze_evolution(commits)

    versions = [c for c in commits if c["version"]]

    lines = ["=" * 60, "🧠 Long Memory 自我认知", "=" * 60, ""]
    lines.append(f"  当前版本: {evolution.get('latest_version', 'unknown')}")
    lines.append(f"  版本总数: {evolution.get('versions', 0)}")
    lines.append(f"  提交总数: {evolution.get('total_commits', 0)}")
    lines.append(f"  时间跨度: {evolution.get('date_range', 'N/A')}")
    lines.append(f"  功能/修复/重构: {evolution.get('features', 0)}/{evolution.get('fixes', 0)}/{evolution.get('refactors', 0)}")
    lines.append("")
    lines.append("📋 版本时间线:")
    for v in versions:
        changes_count = len(v["changes"]) if v["changes"] else 0
        lines.append(f"  {v['version']:8s} ({v['date']}) {v['subject'][:50]}")

    return "\n".join(lines)


def run_self_memory(memory_dir: Path, repo_dir: Path, since: str = None, save: bool = True):
    """执行自记忆"""
    commits = get_git_history(repo_dir, since_tag=since)
    if not commits:
        print("ℹ️ 没有新的提交")
        return

    # 显示演变摘要
    print(get_evolution_summary(repo_dir))
    print()

    if save:
        content = generate_self_memory(commits, repo_dir)
        save_self_memory(memory_dir, content)


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="自记忆引擎")
    p.add_argument("--memory-dir", default=None)
    p.add_argument("--repo", default=None)
    p.add_argument("--since", default=None, help="从哪个版本开始")
    p.add_argument("--no-save", action="store_true", help="只显示不保存")
    p.add_argument("--summary", action="store_true", help="只显示演变摘要")
    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)
    repo = args.repo if args.repo else REPO_DIR
    repo = Path(repo)

    if args.summary:
        print(get_evolution_summary(repo))
    else:
        run_self_memory(md, repo, since=args.since, save=not args.no_save)


path: scripts/semantic_search.py
content: #!/usr/bin/env python3
"""轻量 Embedding 引擎：零依赖的本地语义搜索

不依赖任何第三方库，基于：
1. TF-IDF 向量化（与 sklearn TfidfVectorizer 等效）
2. SVD 降维（可选，提升语义理解）
3. 余弦相似度匹配

效果优于纯关键词，安装成本为零。"""

import argparse
import json
import math
import re
from collections import Counter, defaultdict
from datetime import datetime, timedelta
from pathlib import Path

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"
SEMANTIC_INDEX = ".semantic_index.json"

# 停用词
STOPWORDS = set("""
的 了 在 是 我 有 和 就 不 人 都 一 一个 上 也 很 到 说 要 去 你 会 着
没有 看 好 自己 这 他 她 它 们 那 里 来 对 过 可以 吗 吧 啊 呢 嗯 哦 哈 嘛
已 还 而 且 或 如果 因为 所以 但是 虽然 这个 那个 什么 怎么 哪 为什么 多少
些 个 只 些 把 被 让 向 从 给 比 等 更 最 之 中 与 及 其 关于 对于 通过 以 按照
为了 根据 什么 怎么 为什么 能 不能 会 可以 应该 可能 必须 需要 觉得 认为
发现 问题 帮我 搞 没问题 好 知道 明白 了解
""".split())

# 中文近义词表（提升语义理解）
SYNONYMS = {
    "性能": ["速度", "效率", "快", "慢", "卡"],
    "优化": ["改进", "改善", "提升", "升级"],
    "问题": ["bug", "错误", "毛病", "故障", "异常"],
    "方案": ["方法", "策略", "计划", "思路", "方向"],
    "决定": ["选择", "确定", "拍板", "决策", "选定"],
    "代码": ["程序", "脚本", "实现", "函数", "模块"],
    "记忆": ["存储", "保存", "记录", "归档", "遗忘"],
    "开发": ["写", "做", "实现", "搭建", "构建"],
    "部署": ["上线", "发布", "推送", "运行", "启动"],
    "学习": ["研究", "了解", "探索", "尝试", "看"],
    "赚钱": ["商业化", "变现", "收费", "付费", "收入"],
    "设计": ["规划", "架构", "结构", "方案", "布局"],
    "测试": ["验证", "检查", "跑", "运行", "执行"],
}


def tokenize(text: str) -> list[str]:
    """分词：中文 + 英文混合"""
    # 英文
    en = re.findall(r'[a-zA-Z]{2,}', text.lower())
    # 中文 2-4字
    zh = re.findall(r'[\u4e00-\u9fff]{2,4}', text)
    # 中文单字（补充）
    zh_single = [c for c in text if '\u4e00' <= c <= '\u9fff']
    return [w for w in (en + zh + zh_single) if w not in STOPWORDS and len(w) >= 1]


def expand_with_synonyms(tokens: list[str]) -> list[str]:
    """用近义词表扩展 token"""
    expanded = list(tokens)
    for token in tokens:
        for key, syns in SYNONYMS.items():
            if token in key or key in [REDACTED_SECRET])
            if token in syns:
                expanded.append(key)
    return list(set(expanded))


class TfidfVectorizer:
    """纯 Python 实现的 TF-IDF 向量化"""

    def __init__(self, max_features: int = 5000):
        self.vocabulary = {}
        self.idf = {}
        self.max_features = max_features

    def fit(self, documents: list[str]):
        """训练词汇表和 IDF"""
        # 统计词频
        doc_freq = Counter()
        all_terms = set()
        tokenized_docs = []

        for doc in documents:
            tokens = tokenize(doc)
            tokens = expand_with_synonyms(tokens)
            tokenized_docs.append(tokens)
            unique_tokens = set(tokens)
            all_terms.update(unique_tokens)
            for t in unique_tokens:
                doc_freq[t] += 1

        # 选择 top 特征
        sorted_terms = sorted(doc_freq.keys(), key=lambda x: -doc_freq[x])
        selected = sorted_terms[:self.max_features]
        self.vocabulary = {t: i for i, t in enumerate(selected)}

        # 计算 IDF
        n = len(documents)
        self.idf = {}
        for term in self.vocabulary:
            df = doc_freq.get(term, 1)
            self.idf[term] = math.log((n + 1) / (df + 1)) + 1

        return self

    def transform(self, documents: list[str]) -> list[list[float]]:
        """转换为 TF-IDF 向量"""
        result = []
        for doc in documents:
            tokens = tokenize(doc)
            tokens = expand_with_synonyms(tokens)
            tf = Counter(tokens)

            vec = [0.0] * len(self.vocabulary)
            for term, count in tf.items():
                if term in self.vocabulary:
                    idx = self.vocabulary[term]
                    tf_val = count / max(len(tokens), 1)
                    vec[idx] = tf_val * self.idf.get(term, 1)

            # 归一化
            norm = sum(v ** 2 for v in vec) ** 0.5
            if norm > 0:
                vec = [v / norm for v in vec]

            result.append(vec)
        return result

    def fit_transform(self, documents: list[str]) -> list[list[float]]:
        self.fit(documents)
        return self.transform(documents)


def cosine_sim(a: list[float], b: list[float]) -> float:
    """余弦相似度"""
    dot = sum(x * y for x, y in zip(a, b))
    norm_a = sum(x ** 2 for x in a) ** 0.5
    norm_b = sum(x ** 2 for x in b) ** 0.5
    return dot / max(norm_a * norm_b, 1e-10)


def build_semantic_index(memory_dir: Path) -> dict:
    """构建语义索引"""
    conv_dir = memory_dir / "conversations"
    if not conv_dir.exists():
        return {"documents": [], "vectorizer": {}}

    documents = []
    for fp in sorted(conv_dir.glob("*.md")):
        content = fp.read_text(encoding="utf-8")
        sections = re.split(r'(?=## \[)', content)
        for section in sections:
            if len(section.strip()) < 20:
                continue

            time_match = re.search(r'\[(\d{2}:\d{2})\]', section)
            topic_match = re.search(r'###\s*话题[：:]\s*(.+)', section)
            tag_match = re.search(r'\*\*标签[：:]\*\*\s*(.+)', section)

            # 用户消息为主 + 助手关键回复
            user_msgs = re.findall(r'\*\*用户[：:]\*\*\s*(.+)', section)
            decisions = re.findall(r'\*\*关键决策[：:]\*\*\s*(.+)', section)
            doc_text = " ".join(user_msgs + decisions)

            if not doc_text.strip():
                doc_text = section.strip()[:500]

            documents.append({
                "date": fp.stem,
                "time": time_match.group(1) if time_match else "00:00",
                "topic": topic_match.group(1).strip() if topic_match else "",
                "tags": [t.strip() for t in tag_match.group(1).split("，") if t.strip()] if tag_match else [],
                "text": doc_text,
                "vector": None,
            })

    if not documents:
        return {"documents": [], "vectorizer": {}}

    # 构建向量
    print(f"🔄 构建 TF-IDF 语义索引（{len(documents)} 个文档片段）...")
    texts = [d["text"] for d in documents]
    vectorizer = TfidfVectorizer()
    vectors = vectorizer.fit_transform(texts)

    for doc, vec in zip(documents, vectors):
        doc["vector"] = vec

    # 序列化（只保存词汇表和 IDF，向量实时计算）
    meta = {
        "documents": [{"date": d["date"], "time": d["time"], "topic": d["topic"],
                       "tags": d["tags"], "text": d["text"][:200]} for d in documents],
        "vectorizer": {
            "vocabulary": vectorizer.vocabulary,
            "idf": {k: v for k, v in vectorizer.idf.items()},
        },
        "built": datetime.now().isoformat(),
        "count": len(documents),
    }

    return meta


def save_index(index: dict, memory_dir: Path):
    """保存索引"""
    path = memory_dir / SEMANTIC_INDEX
    path.write_text(json.dumps(index, ensure_ascii=False), encoding="utf-8")
    size = path.stat().st_size / 1024
    print(f"✅ 索引已保存（{index['count']} 文档，{size:.1f} KB）")


def load_index(memory_dir: Path) -> dict:
    """加载索引"""
    path = memory_dir / SEMANTIC_INDEX
    if not path.exists():
        return None
    return json.loads(path.read_text(encoding="utf-8"))


def vectorize_query(query: str, vectorizer_data: dict) -> list[float]:
    """将查询转为向量"""
    vocab = vectorizer_data["vocabulary"]
    idf = vectorizer_data["idf"]
    dim = len(vocab)

    tokens = tokenize(query)
    tokens = expand_with_synonyms(tokens)
    tf = Counter(tokens)

    vec = [0.0] * dim
    for term, count in tf.items():
        if term in vocab:
            idx = vocab[term]
            tf_val = count / max(len(tokens), 1)
            vec[idx] = tf_val * idf.get(term, 1)

    norm = sum(v ** 2 for v in vec) ** 0.5
    if norm > 0:
        vec = [v / norm for v in vec]

    return vec


def vectorize_documents(texts: list[str], vectorizer_data: dict) -> list[list[float]]:
    """将文档转为向量"""
    return [vectorize_query(t, vectorizer_data) for t in texts]


def search_semantic(memory_dir: Path, query: str, top_n: int = 10,
                    topic: str = None, tag: str = None,
                    days: int = None) -> list[dict]:
    """语义搜索"""
    index = load_index(memory_dir)
    if index is None:
        print("🔄 首次使用，构建索引...")
        index = build_semantic_index(memory_dir)
        save_index(index, memory_dir)
        index = load_index(memory_dir)

    query_vec = vectorize_query(query, index["vectorizer"])
    doc_texts = [d["text"] for d in index["documents"]]
    doc_vecs = vectorize_documents(doc_texts, index["vectorizer"])

    cutoff = datetime.now() - timedelta(days=days) if days else None
    results = []

    for i, doc in enumerate(index["documents"]):
        if cutoff and doc["date"] < cutoff.strftime("%Y-%m-%d"):
            continue
        if topic and topic.lower() not in doc.get("topic", "").lower():
            continue
        if tag and not any(tag.lower() in t.lower() for t in doc.get("tags", [])):
            continue

        sim = cosine_sim(query_vec, doc_vecs[i])
        if sim > 0.05:
            results.append({
                "date": doc["date"],
                "time": doc["time"],
                "topic": doc["topic"],
                "tags": doc["tags"],
                "score": round(sim, 4),
                "text": doc["text"][:150],
            })

    results.sort(key=lambda x: -x["score"])
    return results[:top_n]


def print_results(results: list[dict]):
    if not results:
        print("📭 未找到语义相关的记忆")
        return

    print(f"🧠 语义搜索（{len(results)} 条结果）\n")
    for i, r in enumerate(results, 1):
        bar = "█" * int(r["score"] * 40)
        print(f"  [{i}] {r['date']} [{r['time']}] {r['score']:.4f} |{bar}")
        if r["topic"]:
            print(f"      话题: {r['topic']}")
        if r["tags"]:
            print(f"      标签: {', '.join(r['tags'])}")
        print(f"      内容: {r['text'][:80]}")


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="轻量语义搜索（零依赖）")
    sub = p.add_subparsers(dest="command")

    search_cmd = sub.add_parser("search", help="语义搜索")
    search_cmd.add_argument("query", help="自然语言查询")
    search_cmd.add_argument("--memory-dir", default=None)
    search_cmd.add_argument("--top", type=int, default=10)
    search_cmd.add_argument("-t", "--topic", default=None)
    search_cmd.add_argument("--tag", default=None)
    search_cmd.add_argument("--days", type=int, default=None)

    build_cmd = sub.add_parser("build", help="构建索引")
    build_cmd.add_argument("--memory-dir", default=None)

    args = p.parse_args()
    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)

    if args.command == "build":
        index = build_semantic_index(md)
        save_index(index, md)
    elif args.command == "search":
        results = search_semantic(md, args.query, args.top, args.topic, args.tag, args.days)
        print_results(results)
    else:
        p.print_help()


path: scripts/smart_recommend.py
content: #!/usr/bin/env python3
"""智能推荐：基于当前对话推荐相关历史记忆"""

import argparse
import json
import re
from collections import Counter
from datetime import datetime, timedelta
from pathlib import Path

import sys
sys.path.insert(0, str(Path(__file__).parent))
from lib.filelock import safe_read

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"


def extract_keywords(text: str, top_n: int = 10) -> list[str]:
    """从文本中提取关键词"""
    # 中文 2-4 字片段
    cjk = re.findall(r'[\u4e00-\u9fff]{2,4}', text)
    # 英文单词
    en = re.findall(r'[a-zA-Z]{3,}', text.lower())
    all_words = cjk + en
    
    # 过滤停用词
    stopwords = {"这个", "那个", "什么", "怎么", "为什么", "可以", "已经",
                 "应该", "可能", "没有", "不是", "还是", "就是", "但是",
                 "因为", "所以", "如果", "虽然", "不过", "然后", "或者",
                 "the", "and", "for", "but", "not", "this", "that", "with"}
    
    filtered = [w for w in all_words if w not in stopwords and len(w) >= 2]
    return [w for w, _ in Counter(filtered).most_common(top_n)]


def recommend(memory_dir: Path, query: str, top_n: int = 5, days: int | None = None) -> list[dict]:
    """基于输入文本推荐相关历史记忆"""
    conv_dir = memory_dir / "conversations"
    if not conv_dir.exists():
        return []

    cutoff = datetime.now() - timedelta(days=days) if days else None
    keywords = extract_keywords(query)

    scored = []
    for fp in sorted(conv_dir.glob("*.md"), reverse=True):
        if cutoff:
            try:
                file_date = datetime.strptime(fp.stem, "%Y-%m-%d")
                if file_date < cutoff:
                    continue
            except ValueError:
                continue

        content = fp.read_text(encoding="utf-8")

        # 关键词匹配评分
        score = 0
        matched_keywords = []
        for kw in keywords:
            count = content.lower().count(kw.lower())
            if count > 0:
                score += count
                matched_keywords.append(kw)

        # 话题匹配加分
        topics = re.findall(r'###\s*话题[：:]\s*(.+)', content)
        for topic in topics:
            for kw in keywords:
                if kw.lower() in topic.lower():
                    score += 3  # 话题匹配权重更高
                    if kw not in matched_keywords:
                        matched_keywords.append(kw)

        # 标签匹配加分
        tags = re.findall(r'\*\*标签[：:]\*\*\s*(.+)', content)
        for tag_line in tags:
            for tag in tag_line.split("，"):
                tag = tag.strip()
                for kw in keywords:
                    if kw.lower() in tag.lower():
                        score += 2
                        if kw not in matched_keywords:
                            matched_keywords.append(kw)

        # 决策内容加分
        if "**关键决策" in content:
            score += 1

        if score > 0:
            # 提取相关片段
            snippets = extract_relevant_snippets(content, keywords, max_snippets=3)

            scored.append({
                "date": fp.stem,
                "score": score,
                "matched_keywords": list(set(matched_keywords)),
                "topics": [t.strip() for t in topics],
                "tags": list(set(t.strip() for tl in tags for t in tl.split("，") if t.strip())),
                "snippets": snippets,
            })

    # 按分数排序
    scored.sort(key=lambda x: -x["score"])
    return scored[:top_n]


def extract_relevant_snippets(content: str, keywords: list[str], max_snippets: int = 3) -> list[str]:
    """提取包含关键词的上下文片段"""
    lines = content.split("\n")
    snippets = []
    used_lines = set()

    for kw in keywords:
        for i, line in enumerate(lines):
            if i in used_lines:
                continue
            if kw.lower() in line.lower():
                # 收集上下文
                start = max(0, i - 1)
                end = min(len(lines), i + 3)
                snippet = "\n".join(lines[start:end]).strip()
                if snippet and len(snippet) <= 300:
                    snippets.append(snippet)
                    for j in range(start, end):
                        used_lines.add(j)
                    if len(snippets) >= max_snippets:
                        return snippets

    return snippets


def print_recommendations(recommendations: list[dict]):
    if not recommendations:
        print("📭 没有找到相关历史记忆")
        return

    print("=" * 60)
    print(f"💡 智能推荐（{len(recommendations)} 条相关记忆）")
    print("=" * 60)

    for i, rec in enumerate(recommendations, 1):
        print(f"\n📌 [{i}] {rec['date']}（相关度: {'⭐' * min(rec['score'], 5)}）")
        if rec["topics"]:
            print(f"   话题: {', '.join(rec['topics'])}")
        if rec["tags"]:
            print(f"   标签: {', '.join(rec['tags'])}")
        print(f"   匹配: {', '.join(rec['matched_keywords'])}")

        if rec["snippets"]:
            print(f"   💬 相关片段:")
            for snippet in rec["snippets"][:2]:
                # 截断长行
                clean = snippet.replace("**用户：**", "👤").replace("**助手：**", "🤖")
                for line in clean.split("\n")[:4]:
                    if line.strip():
                        print(f"      {line[:80]}")


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="智能推荐")
    p.add_argument("query", help="当前对话内容或关键词")
    p.add_argument("--memory-dir", default=None)
    p.add_argument("--top", type=int, default=5)
    p.add_argument("--days", type=int, default=None)
    p.add_argument("--json", action="store_true")
    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)
    recs = recommend(md, args.query, args.top, args.days)

    if args.json:
        print(json.dumps(recs, ensure_ascii=False, indent=2))
    else:
        print_recommendations(recs)


path: scripts/sqlite_engine.py
content: #!/usr/bin/env python3
"""SQLite 存储引擎：替代纯文件，高性能查询"""

import argparse
import json
import re
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"
DB_FILE = "long-memory.db"


def get_db(memory_dir: Path) -> sqlite3.Connection:
    """获取数据库连接"""
    db_path = memory_dir / DB_FILE
    conn = sqlite3.connect(str(db_path))
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA journal_mode=WAL")  # 写入不阻塞读
    conn.execute("PRAGMA synchronous=NORMAL")
    return conn


def init_db(memory_dir: Path):
    """初始化数据库表结构"""
    conn = get_db(memory_dir)
    conn.executescript("""
        CREATE TABLE IF NOT EXISTS conversations (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            date TEXT NOT NULL,
            time TEXT NOT NULL,
            session TEXT NOT NULL DEFAULT 'default',
            topic TEXT,
            tags TEXT DEFAULT '[]',
            content TEXT NOT NULL,
            raw_content TEXT,
            created_at TEXT DEFAULT CURRENT_TIMESTAMP,
            updated_at TEXT DEFAULT CURRENT_TIMESTAMP
        );
        CREATE INDEX IF NOT EXISTS idx_conv_date ON conversations(date);
        CREATE INDEX IF NOT EXISTS idx_conv_topic ON conversations(topic);
        CREATE INDEX IF NOT EXISTS idx_conv_session ON conversations(session);

        CREATE TABLE IF NOT EXISTS messages (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            conversation_id INTEGER NOT NULL,
            role TEXT NOT NULL,
            content TEXT NOT NULL,
            created_at TEXT DEFAULT CURRENT_TIMESTAMP,
            FOREIGN KEY (conversation_id) REFERENCES conversations(id)
        );
        CREATE INDEX IF NOT EXISTS idx_msg_conv ON messages(conversation_id);
        CREATE INDEX IF NOT EXISTS idx_msg_role ON messages(role);

        CREATE TABLE IF NOT EXISTS summaries (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            date TEXT NOT NULL UNIQUE,
            summary TEXT NOT NULL,
            key_points TEXT DEFAULT '[]',
            topics TEXT DEFAULT '[]',
            tags TEXT DEFAULT '[]',
            created_at TEXT DEFAULT CURRENT_TIMESTAMP
        );

        CREATE TABLE IF NOT EXISTS distillations (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            week TEXT NOT NULL UNIQUE,
            content TEXT NOT NULL,
            decisions TEXT DEFAULT '[]',
            todos TEXT DEFAULT '[]',
            preferences TEXT DEFAULT '[]',
            created_at TEXT DEFAULT CURRENT_TIMESTAMP
        );

        CREATE TABLE IF NOT EXISTS tags (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            name TEXT NOT NULL UNIQUE,
            usage_count INTEGER DEFAULT 1,
            first_seen TEXT,
            last_seen TEXT
        );

        CREATE TABLE IF NOT EXISTS topics (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            name TEXT NOT NULL UNIQUE,
            usage_count INTEGER DEFAULT 1,
            first_seen TEXT,
            last_seen TEXT
        );

        CREATE TABLE IF NOT EXISTS operation_log (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
            operation TEXT NOT NULL,
            level TEXT DEFAULT 'info',
            details TEXT DEFAULT '{}'
        );
    """)
    conn.commit()
    conn.close()


def insert_conversation(memory_dir: Path, date: str, time: str, session: str,
                        topic: str, tags: list[str], content: str, raw_content: str = "") -> int:
    """插入对话"""
    conn = get_db(memory_dir)
    cursor = conn.execute(
        "INSERT INTO conversations (date, time, session, topic, tags, content, raw_content) VALUES (?, ?, ?, ?, ?, ?, ?)",
        (date, time, session, topic, json.dumps(tags), content, raw_content)
    )
    conv_id = cursor.lastrowid

    # 更新标签
    for tag in tags:
        conn.execute("""
            INSERT INTO tags (name, first_seen, last_seen, usage_count) VALUES (?, ?, ?, 1)
            ON CONFLICT(name) DO UPDATE SET usage_count=usage_count+1, last_seen=?
        """, (tag, date, date, date))

    # 更新话题
    if topic:
        conn.execute("""
            INSERT INTO topics (name, first_seen, last_seen, usage_count) VALUES (?, ?, ?, 1)
            ON CONFLICT(name) DO UPDATE SET usage_count=usage_count+1, last_seen=?
        """, (topic, date, date))

    conn.commit()
    conn.close()
    return conv_id


def insert_messages(memory_dir: Path, conv_id: int, messages: list[dict]):
    """插入消息"""
    conn = get_db(memory_dir)
    conn.executemany(
        "INSERT INTO messages (conversation_id, role, content) VALUES (?, ?, ?)",
        [(conv_id, m["role"], m["content"]) for m in messages]
    )
    conn.commit()
    conn.close()


def search(memory_dir: Path, query: str, topic: str = None, tag: str = None,
           start_date: str = None, end_date: str = None, days: int = None,
           limit: int = 50) -> list[dict]:
    """高性能搜索"""
    conn = get_db(memory_dir)

    if days:
        start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
    if not end_date:
        end_date = datetime.now().strftime("%Y-%m-%d")

    sql = """
        SELECT c.id, c.date, c.time, c.session, c.topic, c.tags, c.content,
               GROUP_CONCAT(m.content, '|||') as messages_text
        FROM conversations c
        LEFT JOIN messages m ON m.conversation_id = c.id
        WHERE 1=1
    """
    params = []

    if start_date:
        sql += " AND c.date >= ?"
        params.append(start_date)
    if end_date:
        sql += " AND c.date <= ?"
        params.append(end_date)
    if topic:
        sql += " AND c.topic LIKE ?"
        params.append(f"%{topic}%")
    if tag:
        sql += " AND c.tags LIKE ?"
        params.append(f"%{tag}%")
    if query:
        sql += " AND (c.content LIKE ? OR c.topic LIKE ?)"
        params.extend([f"%{query}%", f"%{query}%"])

    sql += " GROUP BY c.id ORDER BY c.date DESC, c.time DESC LIMIT ?"
    params.append(limit)

    rows = conn.execute(sql, params).fetchall()
    conn.close()

    return [dict(r) for r in rows]


def get_stats(memory_dir: Path) -> dict:
    """统计信息（SQLite 版，比文件版快100倍）"""
    conn = get_db(memory_dir)

    stats = {}
    r = conn.execute("SELECT COUNT(*) FROM conversations").fetchone()
    stats["total_conversations"] = r[0]

    r = conn.execute("SELECT COUNT(*) FROM messages").fetchone()
    stats["total_messages"] = r[0]

    r = conn.execute("SELECT COUNT(DISTINCT date) FROM conversations").fetchone()
    stats["total_days"] = r[0]

    r = conn.execute("SELECT COUNT(*) FROM tags").fetchone()
    stats["total_tags"] = r[0]

    r = conn.execute("SELECT COUNT(*) FROM topics").fetchone()
    stats["total_topics"] = r[0]

    r = conn.execute("SELECT MIN(date), MAX(date) FROM conversations").fetchone()
    stats["date_range"] = f"{r[0]} ~ {r[1]}" if r[0] else "无"

    # 热门话题
    stats["top_topics"] = [dict(r) for r in
        conn.execute("SELECT name, usage_count FROM topics ORDER BY usage_count DESC LIMIT 10").fetchall()]

    # 热门标签
    stats["top_tags"] = [dict(r) for r in
        conn.execute("SELECT name, usage_count FROM tags ORDER BY usage_count DESC LIMIT 10").fetchall()]

    # 每日活跃度
    stats["daily_activity"] = [dict(r) for r in
        conn.execute("""
            SELECT date, COUNT(*) as count
            FROM conversations
            GROUP BY date ORDER BY date DESC LIMIT 30
        """).fetchall()]

    # 数据库大小
    db_path = memory_dir / DB_FILE
    stats["db_size"] = db_path.stat().st_size if db_path.exists() else 0

    conn.close()
    return stats


def import_from_files(memory_dir: Path):
    """从现有的 markdown 文件导入到 SQLite"""
    conv_dir = memory_dir / "conversations"
    if not conv_dir.exists():
        print("⚠️ 没有对话文件可导入")
        return 0

    init_db(memory_dir)
    conn = get_db(memory_dir)
    count = 0

    for fp in sorted(conv_dir.glob("*.md")):
        content = fp.read_text(encoding="utf-8")
        date_str = fp.stem

        # 检查是否已导入
        existing = conn.execute("SELECT id FROM conversations WHERE date = ?", (date_str,)).fetchone()
        if existing:
            continue

        # 解析
        sections = re.split(r'(?=## \[)', content)
        for section in sections:
            time_match = re.search(r'\[(\d{2}:\d{2})\]', section)
            topic_match = re.search(r'###\s*话题[：:]\s*(.+)', section)
            tag_match = re.search(r'\*\*标签[：:]\*\*\s*(.+)', section)

            time_str = time_match.group(1) if time_match else "00:00"
            topic = topic_match.group(1).strip() if topic_match else ""
            tags = [t.strip() for t in tag_match.group(1).split("，") if t.strip()] if tag_match else []

            conn.execute(
                "INSERT INTO conversations (date, time, session, topic, tags, content) VALUES (?, ?, ?, ?, ?, ?)",
                (date_str, time_str, "imported", topic, json.dumps(tags), section.strip())
            )
            count += 1

    conn.commit()
    conn.close()
    print(f"✅ 导入 {count} 条记录到 SQLite")
    return count


def print_stats(stats: dict):
    print("=" * 60)
    print("📊 记忆统计（SQLite 高性能版）")
    print("=" * 60)
    print(f"\n  对话记录: {stats['total_conversations']}")
    print(f"  消息总数: {stats['total_messages']}")
    print(f"  对话天数: {stats['total_days']}")
    print(f"  话题数:   {stats['total_topics']}")
    print(f"  标签数:   {stats['total_tags']}")
    print(f"  日期范围: {stats['date_range']}")
    print(f"  数据库:   {stats['db_size'] / 1024:.1f} KB")

    if stats.get("top_topics"):
        print(f"\n  🔥 热门话题:")
        for t in stats["top_topics"][:5]:
            print(f"     {t['name']}: {t['usage_count']}")

    if stats.get("top_tags"):
        print(f"\n  🏷️  热门标签:")
        for t in stats["top_tags"][:5]:
            print(f"     {t['name']}: {t['usage_count']}")


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="SQLite 存储引擎")
    sub = p.add_subparsers(dest="command")

    init_cmd = sub.add_parser("init", help="初始化数据库")
    import_cmd = sub.add_parser("import", help="从文件导入")
    search_cmd = sub.add_parser("search", help="搜索")
    search_cmd.add_argument("-q", "--query", default=None)
    search_cmd.add_argument("-t", "--topic", default=None)
    search_cmd.add_argument("--tag", default=None)
    search_cmd.add_argument("--days", type=int, default=None)
    search_cmd.add_argument("--limit", type=int, default=50)
    stats_cmd = sub.add_parser("stats", help="统计")

    args = p.parse_args()
    md = DEFAULT_MEMORY_DIR

    if args.command == "init":
        init_db(md)
        print("✅ 数据库已初始化")
    elif args.command == "import":
        import_from_files(md)
    elif args.command == "search":
        init_db(md)
        results = search(md, args.query, args.topic, args.tag, days=args.days, limit=args.limit)
        for r in results:
            print(f"📄 {r['date']} [{r['time']}] {r['topic']}")
            print(f"   标签: {r['tags']}")
    elif args.command == "stats":
        init_db(md)
        print_stats(get_stats(md))
    else:
        # 默认：初始化+导入+统计
        init_db(md)
        import_from_files(md)
        print()
        print_stats(get_stats(md))


path: scripts/tag_optimizer.py
content: #!/usr/bin/env python3
"""标签体系优化：合并相似标签，统计标签热度"""

import argparse
import re
from collections import Counter, defaultdict
from datetime import datetime
from pathlib import Path

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"

# 已知的标签归一化映射
TAG_ALIASES = {
    "日常": ["日常", "闲聊"],
    "skill": ["skill", "技能包", "skills"],
    "记忆": ["记忆", "memory", "long-memory"],
    "小说": ["小说", "novel", "novel-writer"],
    "代码": ["代码", "code", "脚本", "开发"],
    "部署": ["部署", "deploy", "git", "github"],
    "决策": ["决策", "decision"],
    "赚钱": ["赚钱", "商业化", "变现"],
    "技术方案": ["技术方案", "技术", "方案"],
}


def load_all_tags(memory_dir: Path) -> tuple[Counter, dict]:
    """加载所有标签及其出现日期"""
    counter = Counter()
    tag_dates = defaultdict(set)

    conv_dir = memory_dir / "conversations"
    if not conv_dir.exists():
        return counter, tag_dates

    for fp in sorted(conv_dir.glob("*.md")):
        date_str = fp.stem
        content = fp.read_text(encoding="utf-8")
        for tag_line in re.findall(r'\*\*标签：\*\*\s*(.+)', content):
            for tag in tag_line.split("，"):
                tag = tag.strip()
                if tag:
                    counter[tag] += 1
                    tag_dates[tag].add(date_str)

    return counter, tag_dates


def normalize_tag(tag: str) -> str:
    """归一化标签"""
    tag_lower = tag.lower().strip()
    for canonical, aliases in TAG_ALIASES.items():
        for alias in aliases:
            if tag_lower == alias.lower():
                return canonical
    return tag


def get_merge_suggestions(counter: Counter, tag_dates: dict) -> list[dict]:
    """生成标签合并建议"""
    # 按归一化分组
    groups = defaultdict(list)
    for tag, count in counter.items():
        norm = normalize_tag(tag)
        groups[norm].append((tag, count))

    suggestions = []
    for canonical, variants in groups.items():
        if len(variants) > 1:
            total = sum(c for _, c in variants)
            dates = set()
            for tag, _ in variants:
                dates.update(tag_dates.get(tag, set()))
            suggestions.append({
                "canonical": canonical,
                "variants": [(t, c) for t, c in variants],
                "total_count": total,
                "date_range": f"{min(dates)} ~ {max(dates)}" if dates else "未知",
            })

    return sorted(suggestions, key=lambda x: -x["total_count"])


def apply_normalization(memory_dir: Path, dry_run: bool = True):
    """应用标签归一化"""
    conv_dir = memory_dir / "conversations"
    if not conv_dir.exists():
        print("📭 没有对话记录")
        return

    changes = 0
    for fp in sorted(conv_dir.glob("*.md")):
        content = fp.read_text(encoding="utf-8")
        original = content

        for line in content.split("\n"):
            if "**标签：**" not in line:
                continue
            tag_match = re.match(r'(\*\*标签：\*\*\s*)(.+)', line)
            if not tag_match:
                continue
            prefix = tag_match.group(1)
            tags = [t.strip() for t in tag_match.group(2).split("，") if t.strip()]
            normalized = []
            for tag in tags:
                norm = normalize_tag(tag)
                if norm != tag:
                    changes += 1
                normalized.append(norm)
            # 去重
            seen = set()
            unique = []
            for t in normalized:
                if t not in seen:
                    seen.add(t)
                    unique.append(t)
            new_line = f"{prefix}{', '.join(unique)}"
            content = content.replace(line, new_line)

        if content != original:
            if dry_run:
                print(f"  [预览] {fp.name}: {changes} 处标签变更")
            else:
                fp.write_text(content, encoding="utf-8")
                print(f"  ✅ {fp.name}: {changes} 处标签变更")

    if changes == 0:
        print("✅ 所有标签已规范化，无需变更")
    elif dry_run:
        print(f"\n🔍 发现 {changes} 处标签变更（使用 --execute 应用）")


def print_stats(counter: Counter, tag_dates: dict, suggestions: list):
    print("=" * 60)
    print("🏷️  标签体系分析")
    print("=" * 60)

    print(f"\n📊 标签总数：{len(counter)} 个")
    total = sum(counter.values())
    print(f"📌 标签使用：{total} 次")

    print(f"\n🔥 热度排名：")
    for tag, count in counter.most_common():
        dates = tag_dates.get(tag, set())
        date_range = f"{min(dates)} ~ {max(dates)}" if dates else "-"
        bar = "█" * min(count, 30)
        print(f"  {tag:15s} {count:3d}次 {bar}  ({date_range})")

    if suggestions:
        print(f"\n💡 合并建议（{len(suggestions)} 组）：")
        for s in suggestions:
            variants_str = "、".join(f"{t}({c})" for t, c in s["variants"])
            print(f"  → {s['canonical']}: {variants_str}")
            print(f"    合计 {s['total_count']} 次，{s['date_range']}")


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="标签体系优化")
    p.add_argument("--memory-dir", default=None)
    p.add_argument("--apply", "--execute", action="store_true", help="应用归一化")
    p.add_argument("--dry-run", action="store_true", default=True)
    p.add_argument("--json", action="store_true")
    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)

    counter, tag_dates = load_all_tags(md)
    suggestions = get_merge_suggestions(counter, tag_dates)

    if args.json:
        import json
        print(json.dumps({
            "total_tags": len(counter),
            "total_usages": sum(counter.values()),
            "counter": dict(counter.most_common()),
            "suggestions": suggestions,
        }, ensure_ascii=False, indent=2))
    else:
        print_stats(counter, tag_dates, suggestions)
        if args.apply:
            print()
            apply_normalization(md, dry_run=False)
        elif suggestions:
            print()
            apply_normalization(md, dry_run=True)


path: scripts/tfidf_search.py
content: #!/usr/bin/env python3
"""TF-IDF 语义搜索：超越关键词匹配的智能检索"""

import argparse
import json
import math
import re
from collections import Counter, defaultdict
from datetime import datetime, timedelta
from pathlib import Path

import sys
sys.path.insert(0, str(Path(__file__).parent))
from lib.filelock import safe_read

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"
TFIDF_INDEX = ".tfidf_index.json"

# 中文停用词
STOPWORDS = set("""
的 了 在 是 我 有 和 就 不 人 都 一 一个 上 也 很 到 说 要 去 你 会 着 没有 看 好 
自己 这 他 她 它 们 那 里 来 对 过 可以 吗 吧 啊 呢 嗯 哦 哈 嘛 已 还 而 且 或 如果 
因为 所以 但是 虽然 这个 那个 什么 怎么 哪 为什么 多少 几 些 个 只 些 把 被 让 向 从 
给 比 等 更 最 之 中 与 及 其 关于 对于 通过 以 按照 为了 根据
""".split())

# 分词
def tokenize(text: str) -> list[str]:
    # 英文单词
    en_words = re.findall(r'[a-zA-Z]{2,}', text.lower())
    # 中文 2-4 字
    cjk_words = re.findall(r'[\u4e00-\u9fff]{2,4}', text)
    # 中文单字（补充）
    cjk_single = [c for c in text if '\u4e00' <= c <= '\u9fff']
    
    words = en_words + cjk_words + cjk_single
    return [w for w in words if w not in STOPWORDS and len(w) >= 1]


def compute_tf(tokens: list[str]) -> dict[str, float]:
    """计算词频"""
    total = len(tokens)
    if total == 0:
        return {}
    counts = Counter(tokens)
    return {word: count / total for word, count in counts.items()}


def build_tfidf_index(memory_dir: Path) -> dict:
    """构建 TF-IDF 索引"""
    conv_dir = memory_dir / "conversations"
    if not conv_dir.exists():
        return {"documents": {}, "idf": {}, "vocabulary": []}

    documents = {}
    all_doc_tokens = []

    for fp in sorted(conv_dir.glob("*.md")):
        content = fp.read_text(encoding="utf-8")
        tokens = tokenize(content)
        tf = compute_tf(tokens)
        documents[fp.stem] = {
            "tokens": tokens,
            "tf": tf,
            "topics": re.findall(r'###\s*话题[：:]\s*(.+)', content),
            "tags": list(set(t.strip() for tl in re.findall(r'\*\*标签[：:]\*\*\s*(.+)', content) for t in tl.split("，") if t.strip())),
            "size": fp.stat().st_size,
        }
        all_doc_tokens.append(set(tokens))

    # 计算 IDF
    num_docs = len(documents)
    all_terms = set()
    for doc_tokens in all_doc_tokens:
        all_terms.update(doc_tokens)

    idf = {}
    for term in all_terms:
        doc_count = sum(1 for tokens in all_doc_tokens if term in tokens)
        idf[term] = math.log((num_docs + 1) / (doc_count + 1)) + 1

    return {
        "documents": {k: {"tf": v["tf"], "topics": v["topics"], "tags": v["tags"], "size": v["size"]}
                     for k, v in documents.items()},
        "idf": idf,
        "vocabulary": sorted(all_terms),
        "num_docs": num_docs,
        "built": datetime.now().isoformat(),
    }


def cosine_similarity(vec_a: dict, vec_b: dict) -> float:
    """计算余弦相似度"""
    common_keys = set(vec_a.keys()) & set(vec_b.keys())
    if not common_keys:
        return 0.0

    dot = sum(vec_a[k] * vec_b[k] for k in common_keys)
    norm_a = math.sqrt(sum(v ** 2 for v in vec_a.values()))
    norm_b = math.sqrt(sum(v ** 2 for v in vec_b.values()))

    if norm_a == 0 or norm_b == 0:
        return 0.0
    return dot / (norm_a * norm_b)


def search_tfidf(memory_dir: Path, query: str, top_n: int = 10,
                 topic_filter: str | None = None, tag_filter: str | None = None,
                 days: int | None = None) -> list[dict]:

    # 加载或构建索引
    index_path = memory_dir / TFIDF_INDEX
    if index_path.exists():
        import json
        index = json.loads(index_path.read_text(encoding="utf-8"))
    else:
        index = build_tfidf_index(memory_dir)
        index_path.write_text(json.dumps(index, ensure_ascii=False), encoding="utf-8")

    # 构建查询向量
    query_tokens = tokenize(query)
    query_tf = compute_tf(query_tokens)
    query_vec = {term: query_tf.get(term, 0) * index["idf"].get(term, 1)
                 for term in set(query_tokens)}

    # 日期过滤
    cutoff = datetime.now() - timedelta(days=days) if days else None

    # 计算每个文档的相似度
    results = []
    for doc_id, doc_data in index["documents"].items():
        # 过滤
        if cutoff and doc_id < cutoff.strftime("%Y-%m-%d"):
            continue
        if topic_filter and not any(topic_filter.lower() in t.lower() for t in doc_data["topics"]):
            continue
        if tag_filter and not any(tag_filter.lower() in t.lower() for t in doc_data["tags"]):
            continue

        # 构建文档向量
        doc_vec = {term: tf * index["idf"].get(term, 1) for term, tf in doc_data["tf"].items()}

        sim = cosine_similarity(query_vec, doc_vec)
        if sim > 0.01:
            # 提取匹配的关键词
            matched = set(query_tokens) & set(doc_data["tf"].keys())
            results.append({
                "date": doc_id,
                "score": round(sim, 4),
                "matched_terms": sorted(matched),
                "topics": doc_data["topics"],
                "tags": doc_data["tags"],
            })

    results.sort(key=lambda x: -x["score"])
    return results[:top_n]


def print_results(results: list[dict]):
    if not results:
        print("📭 未找到相关记忆")
        return

    print(f"🔍 TF-IDF 语义搜索（{len(results)} 条结果）\n")
    for i, r in enumerate(results, 1):
        score_bar = "█" * int(r["score"] * 30)
        print(f"  [{i}] {r['date']}  {r['score']:.4f} |{score_bar}")
        if r["matched_terms"]:
            print(f"      匹配词: {', '.join(r['matched_terms'][:8])}")
        if r["topics"]:
            print(f"      话题: {', '.join(r['topics'][:3])}")


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="TF-IDF 语义搜索")
    p.add_argument("query", help="搜索查询")
    p.add_argument("--memory-dir", default=None)
    p.add_argument("--top", type=int, default=10)
    p.add_argument("--topic", "-t", default=None)
    p.add_argument("--tag", default=None)
    p.add_argument("--days", type=int, default=None)
    p.add_argument("--rebuild", action="store_true", help="重建索引")
    p.add_argument("--json", action="store_true")
    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)

    if args.rebuild:
        index = build_tfidf_index(md)
        import json
        (md / TFIDF_INDEX).write_text(json.dumps(index, ensure_ascii=False), encoding="utf-8")
        print(f"✅ 索引已重建（{index['num_docs']} 个文档，{len(index['vocabulary'])} 个词）")
        exit(0)

    results = search_tfidf(md, args.query, args.top, args.topic, args.tag, args.days)

    if args.json:
        print(json.dumps(results, ensure_ascii=False, indent=2))
    else:
        print_results(results)


path: scripts/topic_timeline.py
content: #!/usr/bin/env python3
"""话题时间线：按话题聚合对话，展示完整讨论脉络"""

import argparse
import re
from collections import defaultdict
from datetime import datetime
from pathlib import Path

DEFAULT_MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"


def build_timeline(memory_dir: Path, topic_filter: str | None = None,
                   days: int | None = None) -> dict:
    """构建话题时间线"""
    conv_dir = memory_dir / "conversations"
    if not conv_dir.exists():
        return {}

    timeline = defaultdict(list)

    for fp in sorted(conv_dir.glob("*.md")):
        date_str = fp.stem

        # 按天数过滤
        if days:
            try:
                file_date = datetime.strptime(date_str, "%Y-%m-%d")
                cutoff = datetime.now() - __import__("datetime").timedelta(days=days)
                if file_date < cutoff:
                    continue
            except ValueError:
                continue

        content = fp.read_text(encoding="utf-8")
        current_topic = "未知"
        current_time = ""
        current_messages = []
        current_tags = []

        for line in content.split("\n"):
            # 检测 session 头
            if line.startswith('## ['):
                time_match = re.match(r'##\s*\[(\d{2}:\d{2})\]', line)
                if time_match and current_messages and current_topic != "未知":
                    save_topic(timeline, date_str, current_time, current_topic,
                              current_tags, current_messages)
                if time_match:
                    current_time = time_match.group(1)
                current_messages = []
                current_tags = []
                continue

            # 检测话题头（在 session 头的下一行）
            if line.startswith('### 话题：'):
                current_topic = re.sub(r'^###\s*话题：\s*', '', line).strip()
                continue

            # 检测标签
            tag_match = re.match(r'\*\*标签：\*\*\s*(.+)', line)
            if tag_match:
                current_tags = [t.strip() for t in tag_match.group(1).split("，") if t.strip()]
                continue

            # 收集消息
            if "**用户：**" in line or "**助手：**" in line:
                clean = re.sub(r'\*\*(用户|助手)[：:]\*\*\s*', r'\1：', line).strip()
                if clean:
                    current_messages.append(f"[{current_time}] {clean}")

        # 保存最后一段
        if current_messages and current_topic != "未知":
            save_topic(timeline, date_str, current_time, current_topic,
                      current_tags, current_messages)

    # 按话题过滤
    if topic_filter:
        filtered = {}
        for topic, entries in timeline.items():
            if topic_filter.lower() in topic.lower():
                filtered[topic] = entries
            else:
                matching = [e for e in entries
                           if topic_filter.lower() in " ".join(e.get("tags", [])).lower()]
                if matching:
                    filtered[topic] = matching
        timeline = filtered

    return dict(sorted(timeline.items()))


def save_topic(timeline: dict, date: str, time: str, topic: str,
               tags: list, messages: list):
    # 合并相似话题
    normalized = normalize_topic(topic)
    key = normalized if normalized else topic

    timeline[key].append({
        "date": date,
        "time": time,
        "original_topic": topic,
        "tags": tags,
        "messages": messages,
    })


def normalize_topic(topic: str) -> str:
    """归一化相似话题"""
    topic_lower = topic.lower()
    # 已知的归一化映射
    mappings = {
        "long-memory": ["long-memory skill", "记忆系统", "长记忆", "记忆 skill", "long memory"],
        "小说": ["小说", "novel-writer", "novel writer", "写作"],
        "赚钱": ["赚钱", "商业化", "变现"],
        "部署": ["部署", "推送", "github", "git push"],
        "迭代": ["迭代", "升级", "v2", "v3"],
    }
    for canonical, variants in mappings.items():
        for v in variants:
            if v in topic_lower:
                return canonical
    # 去掉末尾的省略号
    return topic.rstrip(".")


def print_timeline(timeline: dict, verbose: bool = False):
    if not timeline:
        print("📭 没有找到相关话题的记录")
        return

    print("=" * 60)
    print(f"📋 话题时间线（共 {len(timeline)} 个话题）")
    print("=" * 60)

    for topic, entries in timeline.items():
        dates = sorted(set(e["date"] for e in entries))
        print(f"\n📌 {topic}")
        print(f"   📅 {dates[0]} ~ {dates[-1]}（{len(entries)} 次讨论）")

        all_tags = set()
        for e in entries:
            all_tags.update(e["tags"])
        if all_tags:
            print(f"   🏷️  {', '.join(sorted(all_tags))}")

        if verbose:
            for e in entries:
                print(f"\n   [{e['date']} {e['time']}] {e['original_topic']}")
                for msg in e["messages"]:
                    print(f"      {msg}")


if __name__ == "__main__":
    p = argparse.ArgumentParser(description="话题时间线")
    p.add_argument("--memory-dir", default=None)
    p.add_argument("--topic", "-t", default=None, help="按话题筛选")
    p.add_argument("--days", type=int, default=None, help="最近N天")
    p.add_argument("--verbose", "-v", action="store_true", help="显示完整对话")
    p.add_argument("--json", action="store_true")
    args = p.parse_args()

    md = args.memory_dir if args.memory_dir else DEFAULT_MEMORY_DIR
    md = Path(md)
    timeline = build_timeline(md, args.topic, args.days)

    if args.json:
        import json
        print(json.dumps(timeline, ensure_ascii=False, indent=2, default=str))
    else:
        print_timeline(timeline, args.verbose)


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)