feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。

**目前能用的(真环境实测,非推断)**:

    profile/other : 200, 7075 字节  —— 博主主页指标(粉丝/获赞/作品数/昵称)
    aweme/detail  : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)

**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
  1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
     带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
     同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
     挑中的恰好是「批量拉作品列表」这个最敏感的动作。
  2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
     代价,过几小时要重测)。

  所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。

**排查中控住变量后得到的两条事实**(都写进注释了):
  · `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
    UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
    (我先前猜的 sec-ch-ua 不是关键。)
  · 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
    从 CDP 取齐 cookie + UA + hints,而不是各自写死。

「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。

测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
2026-10-10 17:12:25 +08:00
parent 21ff01b894
commit 2112c1a870
1601 changed files with 367425 additions and 0 deletions
@@ -0,0 +1,130 @@
---
name: memory_manager
version: 1.2.0
description: 智能体记忆管理技能——每日对话提炼、去重、冲突检测、版本管理、记忆导入导出、语义检索
triggers:
- 记忆管理
- 记忆更新
- 每日摘要
- 每日提炼
- 对话提炼
- 整理记忆
- 记忆去重
- 冲突检测
- 知识版本
- 记忆备份
- 记忆导出
- 记忆检查
- 查记忆
- 语义检索
- 记忆搜索
- 搜索记忆
- update memory
---
# Memory Manager 技能
## 概述
管理 AgentOS 的 L0→L1→L1_vec→L2→L3 五级记忆体系统,负责每日对话提炼、去重、冲突消解、版本管理、语义检索和记忆导入导出。
## 核心能力
### 1. 每日对话提炼(daily_digest)
- 触发方式:每日凌晨 2:00 自动执行,或手动触发
- 输入:昨日所有对话记录
- 处理流程:
1. 提取关键事实(who/what/when/where/decision)
2. 去重(对比 L2 已有事实,相似度 > 0.9 则跳过)
3. 冲突检测(新事实与旧事实矛盾?→ 标记 OVERRIDE 或提示用户确认)
4. 生成摘要(100-200 字/条)
5. 更新 L1 关键词索引(仅写入 L2 成功的事实,避免孤儿索引)
6. 更新 L1_vec 向量索引(ChromaDB + oMLX embedding,新事实自动向量化)
7. 关键对话原文 → 压缩后写入 L3
- 输出:
- 更新 04_memory/daily_summaries/YYYY-MM-DD.md
- 更新 04_memory/long_term/facts.db
- 更新 04_memory/vector_db/keyword_index.json
- 更新 04_memory/vector_db/chroma/(ChromaDB 向量库)
- 发送摘要到 Obsidian 01_daily/ 日记
### 2. 冲突消解(memory_cleanup)
- 冲突检测优先级:新事实 > 旧事实,用户明确修正 > 系统推断
- 三种变化处理:
- 硬事实改变 → 更新原卡片,旧值写入 previous_version
- 软升级/延伸 → 保留 V1,新建 V2,通过 related 双向链接
- 观点→事实 → 更新 nature 字段,通知 kb_manager 移动文件
### 3. 记忆导入导出
- export_memories.py:将记忆打包为 JSON + Markdown 压缩包
- import_memories.py:从压缩包恢复记忆,合并时智能去重
### 4. 语义检索(semantic_search)
- BM25 关键词检索 + 向量语义检索 + RRF 加权融合
- 向量引擎:ChromaDB(持久化)+ oMLX Qwen3-Embedding-0.6B(1024维)
- 融合策略:Reciprocal Rank Fusion (RRF),BM25 权重 0.4 / 向量权重 0.6
- CLI 用法:
- 检索:`python3 semantic_search.py search --root ~/workbuddy-agent-os/agent-sync --query "查询内容" --top-k 5`
- 回填:`python3 semantic_search.py backfill --root ~/workbuddy-agent-os/agent-sync`
- 重建:`python3 semantic_search.py rebuild --root ~/workbuddy-agent-os/agent-sync`
- 测试:`python3 semantic_search.py embed --root ~/workbuddy-agent-os/agent-sync --text "测试"`
## 文件结构
```
memory_manager/
├── SKILL.md # 本文件
├── daily_digest.py # 每日对话提炼脚本
├── semantic_search.py # BM25 + 向量语义混合检索
├── memory_cleanup.py # 冲突消解与过期记忆清理
├── agent_memory_init.py # 首次初始化记忆体
├── bootstrap_from_memory.py # 冷启动:历史数据全量导入
├── export_memories.py # 记忆导出(JSON + L3 原文)
├── import_memories.py # 记忆导入(智能去重合并)
└── version.json
```
## 使用说明
> **Python 路径**:所有脚本使用 agent-os 专用虚拟环境
> `/Users/chengzige/.workbuddy/binaries/python/envs/agent-os/bin/python3`
1. 初始化:`python3 agent_memory_init.py --root ~/workbuddy-agent-os/agent-sync`
2. 冷启动:`python3 bootstrap_from_memory.py --root ~/workbuddy-agent-os/agent-sync`
3. 每日提炼:`python3 daily_digest.py --root ~/workbuddy-agent-os/agent-sync --date YYYY-MM-DD`
4. 冲突清理:`python3 memory_cleanup.py --root ~/workbuddy-agent-os/agent-sync`
5. 导出记忆:`python3 export_memories.py --root ~/workbuddy-agent-os/agent-sync --output ~/backup/`
6. 导入记忆:`python3 import_memories.py --root ~/workbuddy-agent-os/agent-sync --input ~/backup/memories.zip`
## 数据源
| 数据源 | 路径 | 说明 |
|--------|------|------|
| Claw 工作日志 | `~/WorkBuddy/Claw/.workbuddy/memory/YYYY-MM-DD.md` | 每次对话后自动写入 |
| WorkBuddy 系统画像 | `~/.workbuddy/memery/*.md` | 系统自动维护 |
| 上轮摘要 | `04_memory/daily_summaries/YYYY-MM-DD.md` | 防丢失兜底 |
## 自动化
- WorkBuddy 自动化「AgentOS 每日记忆提炼」已配置:每日凌晨 2:00 执行
- 命令:`/Users/chengzige/.workbuddy/binaries/python/envs/agent-os/bin/python3 ~/workbuddy-agent-os/agent-sync/02_skills/memory_manager/daily_digest.py --root ~/workbuddy-agent-os/agent-sync`
## 依赖
| 包 | 用途 | 安装状态 |
|---|---|---|
| sqlite3 | L2 事实库 | ✅ 标准库 |
| sqlite-utils | SQLite 增强 | ✅ 已安装 |
| rank_bm25 | BM25 关键词检索 | ✅ 已安装 |
| chromadb | 向量存储与检索 | ✅ 1.5.8 |
| numpy | 向量计算 | ✅ 2.4.4 |
| requests | HTTP 客户端(embedding API) | ✅ 已安装 |
| json/re/os/argparse | 基础 | ✅ 标准库 |
## 向量检索依赖
| 组件 | 说明 |
|------|------|
| oMLX API | localhost:8000/v1/embeddings |
| 模型 | Qwen3-Embedding-0.6B(1024 维) |
| 向量库 | ChromaDB,持久化到 04_memory/vector_db/chroma/ |
@@ -0,0 +1,57 @@
skill:
name: "记忆管理器"
id: "memory_manager"
version: "1.2.0"
category: "core-system"
status: "active"
created: "2026-04-25"
updated: "2026-04-29"
description:
short: "每日对话提炼、记忆固化检测、向量化、版本管理"
use_cases:
- "每日凌晨自动提炼前一天对话记忆"
- "检测高频/重要记忆,推送到协同inbox/memory/"
- "冷启动时从已有记忆初始化知识库"
tags: ["记忆", "提炼", "固化", "向量化"]
ownership:
master_editor: "my-macbook_macOS14.4_16GB_master"
readonly_on_other_devices: true
environment:
os: "macos"
python_path: "~/.workbuddy/binaries/python/envs/agent-os/bin/python3"
pip_dependencies:
- "sqlite-utils>=3.35"
- "chromadb>=1.0.0"
setup:
install_script: ""
post_check: "python3 -c 'import sqlite3; print(\"OK\")'"
usage:
entry_point: "daily_digest.py"
scheduled: "每日 02:00(WorkBuddy 自动化)"
manual_trigger: "记忆更新"
scripts:
- daily_digest.py # 每日提炼主程序
- memory_extractor.py # 记忆固化提取(推送inbox/memory/)
- bootstrap_from_memory.py
- agent_memory_init.py
- export_memories.py
- import_memories.py
- memory_cleanup.py
- semantic_search.py
changelog:
- version: "1.2.0"
date: "2026-04-29"
changes: "新增 memory_extractor.py,支持固化条件检测+推送inbox/memory/"
- version: "1.1.0"
date: "2026-04-27"
changes: "接入 oMLX Embedding,支持向量化"
- version: "1.0.0"
date: "2026-04-25"
changes: "初始版本"
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""
AgentOS 记忆体初始化脚本
用途:首次运行时创建 L1 索引文件、L2 事实库、L3 原文目录
使用:python3 agent_memory_init.py --root ~/workbuddy-agent-os/agent-sync
"""
import argparse
import json
import os
import sqlite3
from pathlib import Path
def parse_args():
parser = argparse.ArgumentParser(description="AgentOS 记忆体初始化")
parser.add_argument("--root", required=True, help="agent-os 根目录路径")
return parser.parse_args()
def init_l1_index(root: str):
"""初始化 L1 关键词索引"""
index_path = Path(root) / "04_memory" / "vector_db" / "keyword_index.json"
if index_path.exists():
print(f"[INFO] L1 索引已存在: {index_path}")
return
index_data = {
"version": "1.0.0",
"last_updated": "",
"entries": []
}
index_path.parent.mkdir(parents=True, exist_ok=True)
with open(index_path, "w", encoding="utf-8") as f:
json.dump(index_data, f, ensure_ascii=False, indent=2)
print(f"[OK] L1 关键词索引已创建: {index_path}")
def init_l2_facts(root: str):
"""初始化 L2 事实库"""
db_path = Path(root) / "04_memory" / "long_term" / "facts.db"
if db_path.exists():
print(f"[INFO] L2 事实库已存在: {db_path}")
return
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
cursor.execute('''CREATE TABLE IF NOT EXISTS facts (
id TEXT PRIMARY KEY,
subject TEXT NOT NULL,
predicate TEXT NOT NULL,
object TEXT NOT NULL,
confidence REAL DEFAULT 0.7,
nature TEXT DEFAULT 'fact',
domain TEXT,
source TEXT,
date_created TEXT,
date_modified TEXT,
previous_version TEXT,
superseded_by TEXT,
version INTEGER DEFAULT 1
)''')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_facts_subject ON facts(subject)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_facts_domain ON facts(domain)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_facts_confidence ON facts(confidence)')
conn.commit()
conn.close()
print(f"[OK] L2 事实库已创建: {db_path}")
def init_l3_raw(root: str):
"""初始化 L3 原文存档目录"""
raw_dir = Path(root) / "04_memory" / "long_term" / "raw"
raw_dir.mkdir(parents=True, exist_ok=True)
gitkeep = raw_dir / ".gitkeep"
if not gitkeep.exists():
gitkeep.touch()
print(f"[OK] L3 原文目录已就绪: {raw_dir}")
def init_logs(root: str):
"""初始化日志目录"""
log_dir = Path(root) / "04_memory" / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
# 创建空日志文件
for log_name in ["kb_ingest.log", "errors.log", "conflicts.log", "kb_reclassify.log"]:
log_file = log_dir / log_name
if not log_file.exists():
log_file.touch()
print(f"[OK] 日志目录已就绪: {log_dir}")
def main():
args = parse_args()
root = os.path.expanduser(args.root)
print("=" * 50)
print("AgentOS 记忆体初始化")
print("=" * 50)
init_l1_index(root)
init_l2_facts(root)
init_l3_raw(root)
init_logs(root)
print()
print("=" * 50)
print("[OK] 记忆体初始化完成!")
print("=" * 50)
if __name__ == "__main__":
main()
@@ -0,0 +1,388 @@
#!/usr/bin/env python3
"""
AgentOS 记忆体冷启动脚本
用途:首次运行时,将已有的 MEMORY.md 和工作日志全量写入 L1/L2/L3
这是记忆系统的「时间起点」——没有此步骤,L1/L2 将永远为空
使用:
python3 bootstrap_from_memory.py --root ~/workbuddy-agent-os/agent-sync
python3 bootstrap_from_memory.py --root ~/workbuddy-agent-os/agent-sync --dry-run # 预览不写入
数据来源(全量导入):
1. ~/WorkBuddy/Claw/.workbuddy/memory/MEMORY.md ← 长期精炼记忆
2. ~/WorkBuddy/Claw/.workbuddy/memory/YYYY-MM-DD.md ← 所有历史日志
3. ~/.workbuddy/memery/*.md ← 系统用户画像(V79+)
"""
import argparse
import json
import os
import re
import sqlite3
from datetime import datetime
from pathlib import Path
WORKBUDDY_MEMORY_DIR = Path.home() / "WorkBuddy" / "Claw" / ".workbuddy" / "memory"
WORKBUDDY_SYSTEM_MEMERY_DIR = Path.home() / ".workbuddy" / "memery"
TODAY = datetime.now().strftime("%Y-%m-%d")
def parse_args():
parser = argparse.ArgumentParser(description="AgentOS 记忆体冷启动")
parser.add_argument("--root", required=True, help="agent-os 根目录路径")
parser.add_argument("--dry-run", action="store_true", help="仅预览,不写入")
return parser.parse_args()
def ensure_db(root: str):
"""确保 L2 数据库存在并建好表"""
db_path = Path(root) / "04_memory" / "long_term" / "facts.db"
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
cursor.execute('''CREATE TABLE IF NOT EXISTS facts (
id TEXT PRIMARY KEY,
subject TEXT NOT NULL,
predicate TEXT NOT NULL,
object TEXT NOT NULL,
confidence REAL DEFAULT 0.7,
nature TEXT DEFAULT 'fact',
domain TEXT,
source TEXT,
date_created TEXT,
date_modified TEXT,
previous_version TEXT,
superseded_by TEXT,
version INTEGER DEFAULT 1
)''')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_facts_subject ON facts(subject)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_facts_domain ON facts(domain)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_facts_confidence ON facts(confidence)')
conn.commit()
conn.close()
return db_path
def parse_memory_md(content: str) -> list:
"""
解析 MEMORY.md 的结构化内容,提取事实列表。
支持格式:
- **字段名**:值
- 普通列表项
"""
facts = []
lines = content.split("\n")
current_section = "general"
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith(">"):
continue
# 章节标题
if stripped.startswith("## "):
current_section = stripped[3:].strip()
continue
if stripped.startswith("# "):
continue
# 格式 1:**Key**:Value 或 **Key**: Value
m = re.match(r'\*\*(.+?)\*\*[::]\s*(.+)', stripped)
if m:
key, value = m.group(1).strip(), m.group(2).strip()
if len(value) > 5:
facts.append({
"subject": "ghai",
"predicate": key,
"object": value[:300],
"confidence": 0.9,
"nature": "fact",
"domain": _infer_domain(current_section, value),
"source": "MEMORY.md"
})
continue
# 格式 2:- 列表项(含有实质内容的行)
if stripped.startswith("- ") and len(stripped) > 10:
item = stripped[2:].strip()
# 过滤纯说明性短句
if len(item) < 15 or item.startswith("(") or item.startswith("("):
continue
predicate = _infer_predicate(current_section, item)
facts.append({
"subject": "ghai",
"predicate": predicate,
"object": item[:300],
"confidence": 0.8,
"nature": "fact",
"domain": _infer_domain(current_section, item),
"source": "MEMORY.md"
})
return facts
def _infer_domain(section: str, text: str) -> str:
"""根据章节标题和内容推断 domain"""
section_lower = section.lower()
if any(w in section_lower for w in ["项目", "工具", "agenos", "技能"]):
return "engineering"
if any(w in section_lower for w in ["偏好", "用户", "个人"]):
return "personal-management"
if any(w in section_lower for w in ["金融", "股票", "finance"]):
return "finance"
if any(w in text for w in ["代码", "脚本", "Node", "Python", "npm"]):
return "engineering"
return "personal-management"
def _infer_predicate(section: str, text: str) -> str:
"""根据章节和文本推断谓语"""
if "偏好" in section or "偏好" in text:
return "preference"
if "工具" in section or "工具链" in section:
return "uses_tool"
if "项目" in section:
return "owns_project"
if "关注" in section:
return "focuses_on"
if "动态" in section:
return "recent_activity"
return "knows"
def parse_daily_log(content: str, date: str) -> list:
"""解析每日工作日志,提取完成事项和决策"""
facts = []
lines = content.split("\n")
current_section = ""
for line in lines:
stripped = line.strip()
if not stripped:
continue
if stripped.startswith("## ") or stripped.startswith("### "):
current_section = stripped.lstrip("#").strip()
continue
# 提取列表项(已完成事项)
if (stripped.startswith("- ") or stripped.startswith("✅") or
stripped.startswith("1.") or re.match(r'^\d+\.', stripped)):
item = re.sub(r'^[-\d\.\s✅]+', '', stripped).strip()
if len(item) < 10:
continue
facts.append({
"subject": "ghai",
"predicate": "completed",
"object": (f"[{current_section}] " if current_section else "") + item[:250],
"confidence": 0.8,
"nature": "fact",
"domain": _infer_domain(current_section, item),
"source": f"daily_log_{date}"
})
return facts
def insert_facts_to_l2(db_path: Path, facts: list, dry_run: bool = False) -> dict:
"""批量插入事实到 L2,自动去重"""
if dry_run:
print(f" [DRY-RUN] 将写入 {len(facts)} 条事实到 L2")
for f in facts[:5]:
print(f" → [{f['domain']}] {f['predicate']}: {f['object'][:60]}...")
if len(facts) > 5:
print(f" ... 还有 {len(facts)-5} 条")
return {"inserted": 0, "skipped": len(facts)}
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
inserted = 0
skipped = 0
# 获取全局已有总数,用于生成唯一 ID
cursor.execute("SELECT COUNT(*) FROM facts")
base = cursor.fetchone()[0]
for i, fact in enumerate(facts):
# 精确去重:subject + predicate + object 三元组完全相同才跳过
cursor.execute(
"SELECT id FROM facts WHERE subject=? AND predicate=? AND object=?",
(fact["subject"], fact["predicate"], fact["object"])
)
if cursor.fetchone():
skipped += 1
continue
fact_id = f"FACT-BOOTSTRAP-{base + i + 1:05d}"
cursor.execute(
"""INSERT INTO facts
(id, subject, predicate, object, confidence, nature, domain, source,
date_created, date_modified, version)
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
(fact_id, fact["subject"], fact["predicate"], fact["object"],
fact.get("confidence", 0.7), fact.get("nature", "fact"),
fact.get("domain", ""), fact.get("source", "bootstrap"),
TODAY, TODAY, 1)
)
fact["id"] = fact_id
inserted += 1
conn.commit()
conn.close()
return {"inserted": inserted, "skipped": skipped}
def update_l1_index(root: str, facts: list, dry_run: bool = False):
"""将事实写入 L1 关键词索引"""
index_path = Path(root) / "04_memory" / "vector_db" / "keyword_index.json"
index_path.parent.mkdir(parents=True, exist_ok=True)
if index_path.exists():
with open(index_path, "r", encoding="utf-8") as f:
index_data = json.load(f)
else:
index_data = {"version": "1.0.0", "last_updated": "", "entries": []}
existing_ids = {e.get("fact_id") for e in index_data["entries"]}
added = 0
for fact in facts:
fid = fact.get("id", "")
if not fid or fid in existing_ids:
continue
keywords = list(set(re.findall(r'[\u4e00-\u9fff]{2,}|[a-zA-Z]{3,}', fact["object"])))[:12]
index_data["entries"].append({
"fact_id": fid,
"keywords": keywords,
"domain": fact.get("domain", ""),
"nature": fact.get("nature", "fact"),
"summary": fact["object"][:120],
"date": TODAY,
"source": fact.get("source", "")
})
existing_ids.add(fid)
added += 1
index_data["last_updated"] = datetime.now().isoformat()
if not dry_run:
with open(index_path, "w", encoding="utf-8") as f:
json.dump(index_data, f, ensure_ascii=False, indent=2)
return added
def archive_to_l3(root: str, source_name: str, content: str, dry_run: bool = False):
"""归档原文到 L3"""
raw_dir = Path(root) / "04_memory" / "long_term" / "raw"
raw_dir.mkdir(parents=True, exist_ok=True)
archive_file = raw_dir / f"bootstrap_{source_name}.md"
if dry_run:
print(f" [DRY-RUN] 将归档到 L3: {archive_file.name}")
return
archive_file.write_text(
f"# L3 Bootstrap 存档 — {source_name}\n> 冷启动时间: {datetime.now().isoformat()}\n\n{content}",
encoding="utf-8"
)
print(f" [OK] L3 归档: {archive_file.name}")
def main():
args = parse_args()
root = os.path.expanduser(args.root)
dry_run = args.dry_run
print("=" * 60)
print("AgentOS 记忆体冷启动(Bootstrap)")
print(f"时间起点: {TODAY}")
if dry_run:
print("[DRY-RUN 模式:仅预览,不写入任何文件]")
print("=" * 60)
db_path = ensure_db(root)
all_facts = []
# ── 来源 1:MEMORY.md 长期精炼记忆 ─────────────────────────────
memory_md = WORKBUDDY_MEMORY_DIR / "MEMORY.md"
if memory_md.exists():
print(f"\n[来源1] MEMORY.md 长期记忆")
content = memory_md.read_text(encoding="utf-8")
facts = parse_memory_md(content)
print(f" → 解析出 {len(facts)} 条事实")
archive_to_l3(root, "MEMORY_md", content, dry_run)
all_facts.extend(facts)
else:
print(f"[WARN] MEMORY.md 不存在: {memory_md}")
# ── 来源 2:每日工作日志(所有历史日期)──────────────────────────
print(f"\n[来源2] 工作日志({WORKBUDDY_MEMORY_DIR})")
log_total = 0
if WORKBUDDY_MEMORY_DIR.exists():
for log_file in sorted(WORKBUDDY_MEMORY_DIR.glob("????-??-??.md")):
date_str = log_file.stem
content = log_file.read_text(encoding="utf-8")
facts = parse_daily_log(content, date_str)
print(f" → {date_str}: {len(facts)} 条")
archive_to_l3(root, f"daily_{date_str}", content, dry_run)
all_facts.extend(facts)
log_total += len(facts)
print(f" 合计: {log_total} 条")
# ── 来源 3:WorkBuddy 系统用户画像 ──────────────────────────────
print(f"\n[来源3] 系统用户画像({WORKBUDDY_SYSTEM_MEMERY_DIR})")
if WORKBUDDY_SYSTEM_MEMERY_DIR.exists():
for md_file in WORKBUDDY_SYSTEM_MEMERY_DIR.glob("*_memery.md"):
content = md_file.read_text(encoding="utf-8")
# 从用户画像提取结构化信息
facts = []
# 提取偏好段
for pattern, pred, conf in [
(r"用户名\s*([\w]+),", "username", 0.95),
(r"常居\s*([^\n。,,]+)", "city", 0.95),
(r"(偏好.{20,200}?)[\n。]", "preference_summary", 0.85),
(r"(期望.{20,200}?)[\n。]", "expectation", 0.85),
]:
m = re.search(pattern, content, re.DOTALL)
if m:
facts.append({
"subject": "ghai",
"predicate": pred,
"object": m.group(1).strip()[:300],
"confidence": conf,
"nature": "fact",
"domain": "personal-management",
"source": f"system_memery_{md_file.stem}"
})
print(f" → {md_file.name}: {len(facts)} 条")
archive_to_l3(root, f"system_{md_file.stem}", content, dry_run)
all_facts.extend(facts)
# ── 写入 L2 + L1 ─────────────────────────────────────────────
print(f"\n[总计] {len(all_facts)} 条候选事实")
print("\n[写入 L2] 更新 facts.db...")
stats = insert_facts_to_l2(db_path, all_facts, dry_run)
print(f" → 新增 {stats['inserted']} 条,跳过重复 {stats['skipped']} 条")
print("\n[写入 L1] 更新 keyword_index.json...")
l1_added = update_l1_index(root, all_facts, dry_run)
print(f" → L1 索引新增 {l1_added} 条")
# ── 输出摘要 ─────────────────────────────────────────────────
print("\n" + "=" * 60)
if dry_run:
print("✅ DRY-RUN 完成,未写入任何文件")
else:
print("✅ 冷启动完成!记忆系统已建立初始状态")
print(f" L1 关键词索引: {Path(root) / '04_memory' / 'vector_db' / 'keyword_index.json'}")
print(f" L2 事实库: {Path(root) / '04_memory' / 'long_term' / 'facts.db'}")
print(f" L3 原文存档: {Path(root) / '04_memory' / 'long_term' / 'raw' / '*.md'}")
print()
print("下一步:每日自动提炼已配置,从明天起 daily_digest.py 将自动运行")
print("=" * 60)
if __name__ == "__main__":
main()
@@ -0,0 +1,536 @@
#!/usr/bin/env python3
"""
AgentOS 每日对话提炼脚本
用途:从对话记录中提取关键事实,更新 L1 索引、L2 摘要库、L3 原文存档
数据源优先级(按顺序逐一尝试,全部合并):
1. WorkBuddy Claw 工作记忆日志 → ~/WorkBuddy/Claw/.workbuddy/memory/YYYY-MM-DD.md
2. WorkBuddy 系统用户画像 → ~/.workbuddy/memery/*.md
3. agent-os 每日摘要 → ~/workbuddy-agent-os/agent-sync/04_memory/daily_summaries/YYYY-MM-DD.md
使用:
python3 daily_digest.py --root ~/workbuddy-agent-os/agent-sync --date 2026-04-25
python3 daily_digest.py --root ~/workbuddy-agent-os/agent-sync # 默认处理昨天
"""
import argparse
import json
import os
import re
import sqlite3
import sys
from datetime import datetime, timedelta
from pathlib import Path
# ─── 角色检查:所有机器都执行记忆提炼,但各自保存本地 ───
try:
sys.path.insert(0, str(Path.home() / "workbuddy-agent-os" / "agent-sync" / "05_tools" / "01_system"))
from role_check import check_capability
if not check_capability("memory_digestion"):
print("⛔ 能力 memory_digestion 未启用,跳过 daily_digest")
sys.exit(0)
except ImportError as e:
print(f"⚠️ 角色检查模块不可用 ({e}),继续执行")
# ─── 数据源路径配置 ──────────────────────────────────────────────
# 如果工作记忆路径变更,只改这里即可
WORKBUDDY_MEMORY_DIR = Path.home() / "WorkBuddy" / "Claw" / ".workbuddy" / "memory"
WORKBUDDY_SYSTEM_MEMERY_DIR = Path.home() / ".workbuddy" / "memery"
def parse_args():
parser = argparse.ArgumentParser(description="AgentOS 每日对话提炼")
parser.add_argument("--root", required=True, help="agent-os 根目录路径")
parser.add_argument("--date", default=None, help="日期 (YYYY-MM-DD),默认昨天")
parser.add_argument("--bootstrap", action="store_true",
help="冷启动模式:处理所有可用日期,用于首次初始化")
return parser.parse_args()
def load_conversations(root: str, target_date: str) -> list:
"""
从以下来源加载对话内容(全部合并):
1. WorkBuddy Claw 工作记忆日志(每次工作后由 AI 写入)
2. WorkBuddy 系统用户画像文件
3. agent-os/04_memory/daily_summaries/(上一轮提炼结果,避免信息丢失)
"""
conversations = []
# ── 来源 1:Claw 工作记忆日志(最核心的数据源)──────────────────
claw_daily = WORKBUDDY_MEMORY_DIR / f"{target_date}.md"
if claw_daily.exists():
content = claw_daily.read_text(encoding="utf-8")
if content.strip():
conversations.append({
"source": str(claw_daily),
"source_type": "claw_daily_log",
"date": target_date,
"content": content
})
print(f" [+] 来源1 Claw工作日志: {claw_daily} ({len(content)} 字符)")
else:
print(f" [-] 来源1 Claw工作日志不存在: {claw_daily}")
# ── 来源 2:WorkBuddy 系统用户画像 ──────────────────────────────
if WORKBUDDY_SYSTEM_MEMERY_DIR.exists():
for md_file in WORKBUDDY_SYSTEM_MEMERY_DIR.glob("*_memery.md"):
content = md_file.read_text(encoding="utf-8")
# 检查文件中是否包含目标日期(粗过滤)
if target_date in content or "Last updated" in content:
conversations.append({
"source": str(md_file),
"source_type": "workbuddy_system_memery",
"date": target_date,
"content": content
})
print(f" [+] 来源2 系统用户画像: {md_file.name} ({len(content)} 字符)")
# ── 来源 3:上一轮 agent-os 摘要(防丢失)──────────────────────
daily_dir = Path(root) / "04_memory" / "daily_summaries"
prev_summary = daily_dir / f"{target_date}.md"
if prev_summary.exists():
content = prev_summary.read_text(encoding="utf-8")
if content.strip():
conversations.append({
"source": str(prev_summary),
"source_type": "agent_os_summary",
"date": target_date,
"content": content
})
print(f" [+] 来源3 上轮摘要: {prev_summary.name} ({len(content)} 字符)")
return conversations
def load_all_available_dates(root: str) -> list:
"""冷启动模式:扫描所有可用日期(用于首次运行)"""
dates = set()
# 扫描 Claw 工作记忆日志
if WORKBUDDY_MEMORY_DIR.exists():
for f in WORKBUDDY_MEMORY_DIR.glob("????-??-??.md"):
dates.add(f.stem)
# 扫描 agent-os daily_summaries
daily_dir = Path(root) / "04_memory" / "daily_summaries"
if daily_dir.exists():
for f in daily_dir.glob("????-??-??.md"):
dates.add(f.stem)
return sorted(dates)
def extract_facts(content: str, date: str, source_type: str = "unknown") -> list:
"""
从对话内容中提取关键事实。
提取策略:
- Claw工作日志(claw_daily_log):按 ## 章节和 ### 子项提取结构化决策
- 系统用户画像(workbuddy_system_memery):提取用户基本信息和偏好
- 通用:包含决策/配置/部署等关键词的行
"""
facts = []
if source_type == "workbuddy_system_memery":
# 用户画像:提取姓名、城市、偏好等结构化信息
patterns = [
(r"\*\*Name\*\*[::]\s*(.+)", "user_profile", "name"),
(r"\*\*City\*\*[::]\s*(.+)", "user_profile", "city"),
(r"用户名\s*(.+?),", "user_profile", "username"),
(r"常居\s*(.+?)。", "user_profile", "city"),
]
for pattern, subject, predicate in patterns:
m = re.search(pattern, content)
if m:
facts.append({
"subject": subject,
"predicate": predicate,
"object": m.group(1).strip()[:200],
"confidence": 0.9,
"nature": "fact",
"domain": "personal-management",
"date_created": date,
"date_modified": date,
"version": 1,
"source": source_type
})
# 提取偏好段落整体
pref_match = re.search(r"(偏好.{10,200})", content, re.DOTALL)
if pref_match:
facts.append({
"subject": "user",
"predicate": "preference",
"object": pref_match.group(1).strip()[:300],
"confidence": 0.85,
"nature": "opinion",
"domain": "personal-management",
"date_created": date,
"date_modified": date,
"version": 1,
"source": source_type
})
return facts
# ── 通用提取:决策/配置/完成事项 ────────────────────────────────
lines = content.split("\n")
current_section = ""
for line in lines:
line_stripped = line.strip()
if not line_stripped:
continue
# 记录当前章节标题(用于 domain 推断)
if line_stripped.startswith("## ") or line_stripped.startswith("### "):
current_section = line_stripped.lstrip("#").strip()
continue
# 决策性关键词
decision_keywords = [
"决定", "确认", "选择", "修改", "配置", "部署", "安装", "更新",
"修复", "完成", "创建", "初始化", "验证", "生成", "已部署", "已完成",
"设计决策", "关键决策"
]
for kw in decision_keywords:
if kw in line_stripped and len(line_stripped) > 10:
# 推断领域
domain = "personal-management"
if any(w in current_section for w in ["代码", "脚本", "技能", "工具", "初始化", "部署"]):
domain = "engineering"
elif any(w in current_section for w in ["知识", "记忆", "学习"]):
domain = "personal-management"
facts.append({
"subject": "ghai",
"predicate": kw,
"object": (f"[{current_section}] " if current_section else "") + line_stripped[:250],
"confidence": 0.75,
"nature": "fact",
"domain": domain,
"date_created": date,
"date_modified": date,
"version": 1,
"source": source_type
})
break # 一行只取一次
return facts
def check_duplicate(fact: dict, cursor) -> bool:
"""检查事实是否已存在(去重)"""
cursor.execute(
"SELECT id FROM facts WHERE subject = ? AND predicate = ? AND object = ?",
(fact["subject"], fact["predicate"], fact["object"])
)
return cursor.fetchone() is not None
def check_conflict(fact: dict, cursor):
"""检查是否存在内容不同但主谓相同的旧事实(冲突)"""
cursor.execute(
"SELECT id, object, confidence FROM facts WHERE subject = ? AND predicate = ? AND object != ?",
(fact["subject"], fact["predicate"], fact["object"])
)
return cursor.fetchone()
def generate_fact_id(date: str, index: int) -> str:
return f"FACT-{date}-{index:04d}"
def update_l1_index(root: str, facts: list):
"""更新 L1 关键词索引"""
index_path = Path(root) / "04_memory" / "vector_db" / "keyword_index.json"
index_path.parent.mkdir(parents=True, exist_ok=True)
if index_path.exists():
with open(index_path, "r", encoding="utf-8") as f:
index_data = json.load(f)
else:
index_data = {"version": "1.0.0", "last_updated": "", "entries": []}
# 避免重复插入:已有 fact_id 的不重复加
existing_ids = {e.get("fact_id") for e in index_data["entries"]}
for fact in facts:
fid = fact.get("id", "")
if fid in existing_ids:
continue
keywords = list(set(re.findall(r'[\u4e00-\u9fff]{2,}|[a-zA-Z]{3,}', fact["object"])))[:12]
entry = {
"fact_id": fid,
"keywords": keywords,
"domain": fact.get("domain", ""),
"nature": fact.get("nature", "fact"),
"summary": fact["object"][:120],
"date": fact.get("date_created", ""),
"source": fact.get("source", "")
}
index_data["entries"].append(entry)
existing_ids.add(fid)
index_data["last_updated"] = datetime.now().isoformat()
with open(index_path, "w", encoding="utf-8") as f:
json.dump(index_data, f, ensure_ascii=False, indent=2)
def update_l2_facts(root: str, facts: list, date: str):
"""更新 L2 事实库(SQLite)"""
db_path = Path(root) / "04_memory" / "long_term" / "facts.db"
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
cursor.execute('''CREATE TABLE IF NOT EXISTS facts (
id TEXT PRIMARY KEY,
subject TEXT NOT NULL,
predicate TEXT NOT NULL,
object TEXT NOT NULL,
confidence REAL DEFAULT 0.7,
nature TEXT DEFAULT 'fact',
domain TEXT,
source TEXT,
date_created TEXT,
date_modified TEXT,
previous_version TEXT,
superseded_by TEXT,
version INTEGER DEFAULT 1
)''')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_facts_subject ON facts(subject)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_facts_domain ON facts(domain)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_facts_confidence ON facts(confidence)')
# 获取当日已有多少条,生成连续 ID
cursor.execute("SELECT COUNT(*) FROM facts WHERE date_created = ?", (date,))
existing_count = cursor.fetchone()[0]
conflicts = []
duplicates = 0
inserted = 0
for i, fact in enumerate(facts):
fact_id = generate_fact_id(date, existing_count + i + 1)
fact["id"] = fact_id
if check_duplicate(fact, cursor):
duplicates += 1
fact["_skipped"] = True
continue
conflict = check_conflict(fact, cursor)
if conflict:
existing_id, existing_obj, existing_conf = conflict
if fact["confidence"] > existing_conf:
# 新事实置信度更高 → 覆盖,保留旧值到 previous_version
cursor.execute(
"""UPDATE facts SET object=?, confidence=?, date_modified=?,
previous_version=?, version=version+1 WHERE id=?""",
(fact["object"], fact["confidence"], date, existing_obj, existing_id)
)
conflicts.append({"action": "override", "fact_id": existing_id,
"reason": f"新事实置信度更高({fact['confidence']} > {existing_conf})"})
else:
conflicts.append({"action": "skip", "fact_id": fact_id,
"reason": f"旧事实置信度更高({existing_conf} >= {fact['confidence']}),需用户确认"})
duplicates += 1
fact["_skipped"] = True
continue
cursor.execute(
"""INSERT INTO facts
(id, subject, predicate, object, confidence, nature, domain, source,
date_created, date_modified, version)
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
(fact_id, fact["subject"], fact["predicate"], fact["object"],
fact.get("confidence", 0.7), fact.get("nature", "fact"),
fact.get("domain", ""), fact.get("source", ""),
fact.get("date_created", date), fact.get("date_modified", date),
fact.get("version", 1))
)
inserted += 1
conn.commit()
conn.close()
return {"inserted": inserted, "duplicates": duplicates, "conflicts": conflicts}
def archive_to_l3(root: str, date: str, conversations: list):
"""将原始对话内容压缩归档到 L3"""
raw_dir = Path(root) / "04_memory" / "long_term" / "raw"
raw_dir.mkdir(parents=True, exist_ok=True)
archive_file = raw_dir / f"{date}.md"
if archive_file.exists():
# 追加模式,不覆盖历史
return
content = f"# L3 原文存档 — {date}\n\n"
for conv in conversations:
content += f"## 来源:{conv['source_type']} | {conv['source']}\n\n"
content += conv["content"]
content += "\n\n---\n\n"
archive_file.write_text(content, encoding="utf-8")
print(f" [OK] L3 原文已归档: {archive_file}")
def save_daily_summary(root: str, date: str, facts: list, stats: dict):
"""保存每日提炼摘要"""
summary_dir = Path(root) / "04_memory" / "daily_summaries"
summary_dir.mkdir(parents=True, exist_ok=True)
summary_file = summary_dir / f"{date}.md"
content = f"# {date} 记忆提炼摘要\n\n"
content += f"> 提炼时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
content += f"## 统计\n"
content += f"- 新增事实: {stats['inserted']}\n"
content += f"- 重复跳过: {stats['duplicates']}\n"
content += f"- 冲突处理: {len(stats['conflicts'])}\n\n"
if facts:
content += "## 提取的事实\n\n"
for fact in facts:
marker = "📌" if fact.get("nature") == "fact" else "💭"
content += f"- {marker} **[{fact.get('domain', '?')}]** `{fact.get('predicate')}` → {fact['object'][:120]}\n"
if stats["conflicts"]:
content += "\n## 冲突记录\n\n"
for c in stats["conflicts"]:
content += f"- `{c['fact_id']}`: **{c['action']}** — {c['reason']}\n"
summary_file.write_text(content, encoding="utf-8")
print(f" [OK] 每日摘要已保存: 04_memory/daily_summaries/{date}.md")
def write_conflict_log(root: str, conflicts: list, date: str):
"""写入冲突日志"""
if not conflicts:
return
log_dir = Path(root) / "04_memory" / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / "conflicts.log"
with open(log_file, "a", encoding="utf-8") as f:
for c in conflicts:
f.write(f"{datetime.now().isoformat()} | {date} | {c['fact_id']} | {c['action']} | {c['reason']}\n")
def process_date(root: str, target_date: str):
"""处理单个日期的完整流程"""
print(f"\n{'='*55}")
print(f"处理日期: {target_date}")
print(f"{'='*55}")
# 1. 加载对话
print("[步骤1] 加载对话数据源...")
conversations = load_conversations(root, target_date)
if not conversations:
print(f" [WARN] {target_date} 无可用数据源,跳过")
return
# 2. 提取事实
print(f"[步骤2] 提取关键事实...")
all_facts = []
for conv in conversations:
facts = extract_facts(conv["content"], target_date, conv["source_type"])
all_facts.extend(facts)
print(f" → 从 {conv['source_type']} 提取 {len(facts)} 条候选事实")
if not all_facts:
print(f" [INFO] {target_date} 无新事实可提取")
return
print(f" [INFO] 共 {len(all_facts)} 条候选事实")
# 3. 更新 L2 事实库
print("[步骤3] 更新 L2 事实库...")
stats = update_l2_facts(root, all_facts, target_date)
print(f" [OK] 新增 {stats['inserted']} 条,重复 {stats['duplicates']} 条,冲突 {len(stats['conflicts'])} 条")
# 4. 更新 L1 关键词索引(仅更新实际写入 L2 的事实,避免孤儿索引)
print("[步骤4] 更新 L1 关键词索引...")
inserted_facts = [f for f in all_facts if f.get("id") and not f.get("_skipped")]
update_l1_index(root, inserted_facts)
print(f" [OK] L1 索引已更新 ({len(inserted_facts)} 条)")
# 4.5 更新向量库(为新事实生成 embedding 写入 ChromaDB)
if inserted_facts:
try:
print("[步骤4.5] 更新向量库...")
# 动态导入同目录的 semantic_search 模块
skill_dir = Path(__file__).parent
if str(skill_dir) not in sys.path:
sys.path.insert(0, str(skill_dir))
from semantic_search import VectorStore
vector_dir = str(Path(root) / "04_memory" / "vector_db" / "chroma")
vs = VectorStore(vector_dir)
items = []
for f in inserted_facts:
text = f"{f['subject']} {f['predicate']} {f['object']}"
items.append({
"fact_id": f["id"],
"text": text,
"metadata": {
"summary": f["object"][:120],
"domain": f.get("domain", ""),
"nature": f.get("nature", "fact"),
"source": f.get("source", ""),
"date_created": f.get("date_created", target_date),
"confidence": f.get("confidence", 0.7)
}
})
vs.upsert_batch(items)
print(f" [OK] 向量库已更新 ({len(items)} 条)")
except Exception as e:
print(f" [WARN] 向量库更新失败(不阻塞主流程): {e}")
# 5. 归档原文到 L3
print("[步骤5] 归档原文到 L3...")
archive_to_l3(root, target_date, conversations)
# 6. 保存摘要
print("[步骤6] 保存每日摘要...")
save_daily_summary(root, target_date, all_facts, stats)
# 7. 记录冲突
print("[步骤7] 记录冲突...")
write_conflict_log(root, stats["conflicts"], target_date)
if stats["conflicts"]:
print(f" [WARN] {len(stats['conflicts'])} 条冲突已记录到 conflicts.log")
return stats
def main():
args = parse_args()
root = os.path.expanduser(args.root)
if args.bootstrap:
# 冷启动模式:处理所有历史日期
print("=" * 55)
print("AgentOS 记忆体冷启动(处理所有历史记忆)")
print("=" * 55)
dates = load_all_available_dates(root)
if not dates:
print("[WARN] 未找到任何历史记忆文件")
return
print(f"发现 {len(dates)} 个日期: {', '.join(dates)}")
for d in dates:
process_date(root, d)
print("\n[OK] 冷启动完成!")
else:
# 普通模式:处理指定日期(默认昨天)
if args.date:
target_date = args.date
else:
target_date = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
process_date(root, target_date)
if __name__ == "__main__":
main()
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
AgentOS 记忆导出脚本
用途:将 L1 索引 + L2 事实库 + L3 原文目录 + 日志打包为 JSON + Markdown 归档
使用:python3 export_memories.py --root ~/workbuddy-agent-os/agent-sync --output ~/backup/
"""
import argparse
import json
import os
import shutil
import sqlite3
from datetime import datetime
from pathlib import Path
def parse_args():
parser = argparse.ArgumentParser(description="AgentOS 记忆导出")
parser.add_argument("--root", required=True, help="agent-os 根目录路径")
parser.add_argument("--output", required=True, help="导出目录路径")
return parser.parse_args()
def export_l2(db_path: str, output_dir: Path):
"""导出 L2 事实库为 JSON"""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT * FROM facts ORDER BY date_created DESC")
facts = [dict(row) for row in cursor.fetchall()]
conn.close()
out_file = output_dir / "l2_facts.json"
with open(out_file, "w", encoding="utf-8") as f:
json.dump(facts, f, ensure_ascii=False, indent=2)
print(f" [OK] L2 导出: {len(facts)} 条事实 → {out_file.name}")
return facts
def export_l1(index_path: Path, output_dir: Path):
"""导出 L1 关键词索引"""
with open(index_path, "r", encoding="utf-8") as f:
data = json.load(f)
out_file = output_dir / "l1_keyword_index.json"
with open(out_file, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f" [OK] L1 导出: {len(data['entries'])} 条索引 → {out_file.name}")
def export_l3(raw_dir: Path, output_dir: Path):
"""导出 L3 原文存档"""
l3_out = output_dir / "l3_raw"
l3_out.mkdir(parents=True, exist_ok=True)
count = 0
for md_file in raw_dir.glob("*.md"):
shutil.copy2(md_file, l3_out / md_file.name)
count += 1
print(f" [OK] L3 导出: {count} 个文件 → l3_raw/")
def export_logs(log_dir: Path, output_dir: Path):
"""导出日志文件"""
logs_out = output_dir / "logs"
logs_out.mkdir(parents=True, exist_ok=True)
count = 0
for log_file in log_dir.glob("*.log"):
shutil.copy2(log_file, logs_out / log_file.name)
count += 1
print(f" [OK] 日志导出: {count} 个文件 → logs/")
def main():
args = parse_args()
root = os.path.expanduser(args.root)
output_dir = Path(os.path.expanduser(args.output))
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
export_dir = output_dir / f"agent-os-memory-{timestamp}"
export_dir.mkdir(parents=True, exist_ok=True)
print("=" * 50)
print("AgentOS 记忆导出")
print("=" * 50)
print(f"源目录: {root}/04_memory/")
print(f"导出到: {export_dir}\n")
# 写入元数据
meta = {
"export_time": datetime.now().isoformat(),
"source_root": root,
"agent_os_version": "2.0",
}
with open(export_dir / "export_meta.json", "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
# 逐层导出
memory_root = Path(root) / "04_memory"
export_l1(memory_root / "vector_db" / "keyword_index.json", export_dir)
export_l2(str(memory_root / "long_term" / "facts.db"), export_dir)
export_l3(memory_root / "long_term" / "raw", export_dir)
export_logs(memory_root / "logs", export_dir)
# 打包(可选)
archive_path = str(export_dir) + ".zip"
shutil.make_archive(str(export_dir), "zip", export_dir.parent, export_dir.name)
print(f"\n[OK] 导出完成!")
print(f" 目录: {export_dir}")
print(f" 压缩包: {archive_path}")
if __name__ == "__main__":
main()
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""
AgentOS 记忆导入脚本
用途:从导出归档恢复记忆,合并时智能去重
使用:python3 import_memories.py --root ~/workbuddy-agent-os/agent-sync --input ~/backup/agent-os-memory-20260426.zip
"""
import argparse
import json
import os
import shutil
import sqlite3
import zipfile
from datetime import datetime
from pathlib import Path
def parse_args():
parser = argparse.ArgumentParser(description="AgentOS 记忆导入")
parser.add_argument("--root", required=True, help="agent-os 根目录路径")
parser.add_argument("--input", required=True, help="导入的 zip 文件或目录路径")
parser.add_argument("--dry-run", action="store_true", help="仅预览,不实际写入")
return parser.parse_args()
def extract_source(input_path: str) -> Path:
"""从 zip 或目录获取源路径"""
p = Path(input_path)
if p.is_dir():
return p
if p.suffix == ".zip":
extract_dir = p.parent / p.stem
if extract_dir.exists():
print(f" [INFO] 解压目录已存在: {extract_dir}")
else:
with zipfile.ZipFile(p, "r") as z:
z.extractall(extract_dir)
print(f" [OK] 已解压: {extract_dir}")
return extract_dir
raise ValueError(f"不支持的输入格式: {input_path}")
def import_l2(src_dir: Path, db_path: Path, dry_run: bool):
"""导入 L2 事实,智能去重"""
src_file = src_dir / "l2_facts.json"
if not src_file.exists():
print(" [WARN] 未找到 l2_facts.json,跳过 L2 导入")
return
with open(src_file, "r", encoding="utf-8") as f:
imported_facts = json.load(f)
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
# 确保表存在
cursor.execute('''CREATE TABLE IF NOT EXISTS facts (
id TEXT PRIMARY KEY,
subject TEXT NOT NULL,
predicate TEXT NOT NULL,
object TEXT NOT NULL,
confidence REAL DEFAULT 0.7,
nature TEXT DEFAULT 'fact',
domain TEXT,
source TEXT,
date_created TEXT,
date_modified TEXT,
previous_version TEXT,
superseded_by TEXT,
version INTEGER DEFAULT 1
)''')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_facts_subject ON facts(subject)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_facts_domain ON facts(domain)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_facts_confidence ON facts(confidence)')
inserted = 0
skipped = 0
updated = 0
for fact in imported_facts:
fid = fact.get("id", "")
# 检查是否已存在
cursor.execute("SELECT id, object, confidence FROM facts WHERE id = ?", (fid,))
existing = cursor.fetchone()
if existing:
# 已存在,检查是否需要更新
if fact.get("date_modified", "") > (existing[2] or ""):
# 导入版本更新 → 覆盖
if not dry_run:
cursor.execute(
"""UPDATE facts SET object=?, confidence=?, nature=?, domain=?,
source=?, date_modified=?, version=? WHERE id=?""",
(fact["object"], fact["confidence"], fact["nature"],
fact["domain"], fact["source"], fact["date_modified"],
fact.get("version", 1), fid)
)
updated += 1
else:
skipped += 1
continue
# 三元组去重
cursor.execute(
"SELECT id FROM facts WHERE subject=? AND predicate=? AND object=?",
(fact["subject"], fact["predicate"], fact["object"])
)
if cursor.fetchone():
skipped += 1
continue
# 新事实,插入
if not dry_run:
cursor.execute(
"""INSERT INTO facts
(id, subject, predicate, object, confidence, nature, domain, source,
date_created, date_modified, version)
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
(fid, fact["subject"], fact["predicate"], fact["object"],
fact.get("confidence", 0.7), fact.get("nature", "fact"),
fact.get("domain", ""), fact.get("source", "import"),
fact.get("date_created", ""), fact.get("date_modified", ""),
fact.get("version", 1))
)
inserted += 1
if not dry_run:
conn.commit()
conn.close()
print(f" L2 导入: 新增 {inserted} 条,更新 {updated} 条,跳过 {skipped} 条")
def import_l1(src_dir: Path, index_path: Path, db_path: Path, dry_run: bool):
"""导入 L1 关键词索引,与 L2 对齐"""
src_file = src_dir / "l1_keyword_index.json"
if not src_file.exists():
print(" [WARN] 未找到 l1_keyword_index.json,跳过 L1 导入")
return
with open(src_file, "r", encoding="utf-8") as f:
imported_data = json.load(f)
# 获取 L2 中实际存在的 ID
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
l2_ids = {row[0] for row in cursor.execute("SELECT id FROM facts").fetchall()}
conn.close()
# 读取现有 L1
if index_path.exists():
with open(index_path, "r", encoding="utf-8") as f:
l1 = json.load(f)
else:
l1 = {"version": "1.0.0", "last_updated": "", "entries": []}
existing_ids = {e["fact_id"] for e in l1["entries"]}
added = 0
for entry in imported_data["entries"]:
fid = entry["fact_id"]
if fid in existing_ids or fid not in l2_ids:
continue
l1["entries"].append(entry)
existing_ids.add(fid)
added += 1
l1["last_updated"] = datetime.now().isoformat()
if not dry_run:
index_path.parent.mkdir(parents=True, exist_ok=True)
with open(index_path, "w", encoding="utf-8") as f:
json.dump(l1, f, ensure_ascii=False, indent=2)
print(f" L1 导入: 新增 {added} 条索引")
def import_l3(src_dir: Path, raw_dir: Path, dry_run: bool):
"""导入 L3 原文存档"""
src_l3 = src_dir / "l3_raw"
if not src_l3.exists():
print(" [WARN] 未找到 l3_raw/ 目录,跳过 L3 导入")
return
raw_dir.mkdir(parents=True, exist_ok=True)
count = 0
for md_file in src_l3.glob("*.md"):
dest = raw_dir / md_file.name
if dest.exists():
continue # 不覆盖已有文件
if not dry_run:
shutil.copy2(md_file, dest)
count += 1
print(f" L3 导入: {count} 个文件")
def main():
args = parse_args()
root = os.path.expanduser(args.root)
dry_run = args.dry_run
print("=" * 50)
print("AgentOS 记忆导入")
if dry_run:
print("[DRY-RUN 模式:仅预览,不写入任何文件]")
print("=" * 50)
src_dir = extract_source(args.input)
print(f"导入源: {src_dir}\n")
memory_root = Path(root) / "04_memory"
db_path = memory_root / "long_term" / "facts.db"
index_path = memory_root / "vector_db" / "keyword_index.json"
raw_dir = memory_root / "long_term" / "raw"
import_l2(src_dir, db_path, dry_run)
import_l1(src_dir, index_path, db_path, dry_run)
import_l3(src_dir, raw_dir, dry_run)
print(f"\n{'✅ DRY-RUN 完成' if dry_run else '✅ 导入完成!'}")
if __name__ == "__main__":
main()
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""
AgentOS 记忆清理与冲突消解脚本
用途:清理过期记忆、消解冲突、归档冷数据
使用:python3 memory_cleanup.py --root ~/workbuddy-agent-os/agent-sync [--dry-run]
"""
import argparse
import json
import os
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
def parse_args():
parser = argparse.ArgumentParser(description="AgentOS 记忆清理")
parser.add_argument("--root", required=True, help="agent-os 根目录路径")
parser.add_argument("--dry-run", action="store_true", help="仅预览,不实际执行")
return parser.parse_args()
def cleanup_expired_facts(root: str, dry_run: bool = False):
"""清理过期事实(技术类知识 180 天未更新自动标记待复核)"""
db_path = Path(root) / "04_memory" / "long_term" / "facts.db"
if not db_path.exists():
print("[WARN] facts.db 不存在,跳过")
return
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
# 查找 180 天未更新的技术类事实
cutoff = (datetime.now() - timedelta(days=180)).strftime("%Y-%m-%d")
cursor.execute(
"SELECT id, subject, date_modified FROM facts WHERE date_modified < ? AND nature != 'axiom' AND nature != 'opinion'",
(cutoff,)
)
expired = cursor.fetchall()
if not expired:
print("[INFO] 无过期事实")
conn.close()
return
print(f"[INFO] 发现 {len(expired)} 条可能过期的事实:")
for fact in expired:
print(f" - {fact[0]}: {fact[1]} (最后更新: {fact[2]})")
if not dry_run:
for fact in expired:
cursor.execute(
"UPDATE facts SET confidence = confidence * 0.8 WHERE id = ?",
(fact[0],)
)
conn.commit()
print(f"[OK] 已降低 {len(expired)} 条过期事实的置信度")
conn.close()
def archive_cold_data(root: str, dry_run: bool = False):
"""归档冷数据(超过 180 天未被检索的数据)"""
archive_dir = Path(root) / "03_knowledge" / "90_archive" / "deprecated"
# 检查 03_knowledge 下的文件修改时间
knowledge_dir = Path(root) / "03_knowledge"
cutoff = datetime.now() - timedelta(days=180)
cold_files = []
for md_file in knowledge_dir.rglob("*.md"):
if "99_system" in str(md_file) or "90_archive" in str(md_file):
continue
mtime = datetime.fromtimestamp(md_file.stat().st_mtime)
if mtime < cutoff:
cold_files.append(md_file)
if cold_files:
print(f"[INFO] 发现 {len(cold_files)} 个冷知识文件:")
for f in cold_files:
print(f" - {f.relative_to(knowledge_dir)}")
if not dry_run:
print("[INFO] 冷数据归档需要确认,请手动处理或通过 kb_manager 技能执行")
else:
print("[INFO] 无冷数据需要归档")
def resolve_conflicts(root: str, dry_run: bool = False):
"""消解记忆冲突"""
log_path = Path(root) / "04_memory" / "logs" / "conflicts.log"
if not log_path.exists() or log_path.stat().st_size == 0:
print("[INFO] 无待消解冲突")
return
with open(log_path, "r", encoding="utf-8") as f:
conflicts = f.readlines()
if conflicts:
print(f"[INFO] 发现 {len(conflicts)} 条冲突记录:")
for c in conflicts[:10]: # 只显示前 10 条
print(f" - {c.strip()}")
if len(conflicts) > 10:
print(f" ... 还有 {len(conflicts) - 10} 条")
def main():
args = parse_args()
root = os.path.expanduser(args.root)
dry_run = args.dry_run
print("=" * 50)
print("AgentOS 记忆清理")
if dry_run:
print("[DRY-RUN 模式:仅预览,不执行]")
print("=" * 50)
cleanup_expired_facts(root, dry_run)
archive_cold_data(root, dry_run)
resolve_conflicts(root, dry_run)
print()
print("[OK] 记忆清理完成")
if __name__ == "__main__":
main()
@@ -0,0 +1,324 @@
#!/usr/bin/env python3
"""
memory_extractor.py — 记忆固化提取器
从本机 L2 事实库(facts.db)和每日摘要中,检测满足固化条件的记忆,
生成标准 MD 知识卡并推送到协同收件箱 inbox/memory/。
固化条件(满足任意一条即触发):
1. 用户主动标记 --solidify <fact_id>
2. 高频引用:同一主谓对(subject+predicate)在 N 天内出现 >= threshold 次
3. 高置信度:confidence >= 0.85 且 version >= 2(被多次确认未被推翻)
使用:
python3 memory_extractor.py --root ~/workbuddy-agent-os/agent-sync --local ~/workbuddy-agent-os/agent-local
python3 memory_extractor.py --root ~/workbuddy-agent-os/agent-sync --local ~/workbuddy-agent-os/agent-local --solidify FACT-2026-0001
python3 memory_extractor.py --root ~/workbuddy-agent-os/agent-sync --local ~/workbuddy-agent-os/agent-local --dry-run
"""
import argparse
import json
import re
import sqlite3
import sys
from datetime import datetime, timedelta
from pathlib import Path
# ─── 固化阈值配置 ─────────────────────────────────────────────────
DEFAULT_FREQ_DAYS = 7 # 高频检测窗口(天)
DEFAULT_FREQ_THRESHOLD = 3 # 窗口内出现次数阈值
DEFAULT_CONFIDENCE_MIN = 0.85 # 高置信度阈值
DEFAULT_VERSION_MIN = 2 # 最小版本数(被多次确认)
def parse_args():
parser = argparse.ArgumentParser(description="AgentOS 记忆固化提取器")
parser.add_argument("--root", default="~/workbuddy-agent-os/agent-sync", help="agent-os 根目录")
parser.add_argument("--local", default="~/workbuddy-agent-os/agent-local", help="~/workbuddy-agent-os/agent-local")
parser.add_argument("--solidify", default=None, help="强制固化指定 fact_id")
parser.add_argument("--dry-run", action="store_true", help="预览模式,不写文件")
parser.add_argument("--freq-days", type=int, default=DEFAULT_FREQ_DAYS)
parser.add_argument("--freq-threshold", type=int, default=DEFAULT_FREQ_THRESHOLD)
parser.add_argument("--confidence-min", type=float, default=DEFAULT_CONFIDENCE_MIN)
parser.add_argument("--version-min", type=int, default=DEFAULT_VERSION_MIN)
return parser.parse_args()
def open_facts_db(local_root: Path):
"""打开本机 L2 事实库"""
# L2 事实库路径(daily_digest.py 写入的路径)
db_path = local_root / "memory" / "raw" / "04_memory" / "long_term" / "facts.db"
if not db_path.exists():
print(f"[WARN] 事实库不存在: {db_path}")
return None, None
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
return conn, conn.cursor()
def detect_high_frequency(cursor, days: int, threshold: int) -> list:
"""
检测高频引用事实:
在近 N 天内,subject+predicate 组合出现 >= threshold 次的最新事实
"""
since = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
cursor.execute("""
SELECT subject, predicate, COUNT(*) as cnt,
MAX(confidence) as max_conf,
MAX(date_modified) as latest_date
FROM facts
WHERE date_created >= ? AND superseded_by = ''
GROUP BY subject, predicate
HAVING cnt >= ?
ORDER BY cnt DESC
""", (since, threshold))
groups = cursor.fetchall()
results = []
for row in groups:
# 取该组最新/最高置信度的事实记录
cursor.execute("""
SELECT * FROM facts
WHERE subject = ? AND predicate = ? AND superseded_by = ''
ORDER BY confidence DESC, version DESC
LIMIT 1
""", (row["subject"], row["predicate"]))
fact = cursor.fetchone()
if fact:
results.append(dict(fact) | {"solidify_reason": f"高频引用({row['cnt']}次/{days}天)"})
return results
def detect_high_confidence(cursor, conf_min: float, version_min: int) -> list:
"""检测高置信度且多次确认的事实"""
cursor.execute("""
SELECT * FROM facts
WHERE confidence >= ? AND version >= ? AND superseded_by = ''
ORDER BY confidence DESC, version DESC
""", (conf_min, version_min))
rows = cursor.fetchall()
return [dict(r) | {"solidify_reason": f"高置信度({r['confidence']:.2f})·已确认{r['version']}次"} for r in rows]
def get_fact_by_id(cursor, fact_id: str) -> dict:
"""按ID获取指定事实"""
cursor.execute("SELECT * FROM facts WHERE id = ?", (fact_id,))
row = cursor.fetchone()
return dict(row) | {"solidify_reason": "用户主动固化"} if row else None
def already_solidified(inbox_memory_dir: Path, fact_id: str) -> bool:
"""检查该事实是否已被推送到inbox(避免重复)"""
for md in inbox_memory_dir.glob("*.md"):
content = md.read_text(encoding="utf-8", errors="ignore")[:500]
if fact_id in content:
return True
return False
def generate_memory_card(fact: dict, today: str) -> tuple[str, str]:
"""
生成记忆固化知识卡 MD 内容
返回: (filename, content)
"""
fact_id = fact.get("id", "")
subject = fact.get("subject", "unknown")
predicate = fact.get("predicate", "")
obj = fact.get("object", "")
domain = fact.get("domain", "personal-management")
nature = fact.get("nature", "fact")
confidence = fact.get("confidence", 0.7)
source = fact.get("source", "memory_system")
reason = fact.get("solidify_reason", "")
version = fact.get("version", 1)
date_created = fact.get("date_created", today)
# 生成知识卡 ID(MEM前缀区别于KB)
mem_id = f"MEM-{today.replace('-', '')}-{fact_id[-4:] if len(fact_id) >= 4 else '0001'}"
# 标题:用 predicate + 前40字
title = f"{predicate}: {obj[:40]}{'...' if len(obj) > 40 else ''}"
frontmatter = f"""---
id: {mem_id}
source_fact_id: {fact_id}
title: "{title}"
nature: {nature}
domain: [{domain}]
confidence: {confidence}
source: "{source}"
source_type: memory_solidification
solidify_reason: "{reason}"
subject: "{subject}"
predicate: "{predicate}"
version: {version}
date_created: {date_created}
date_solidified: {today}
status: draft
track: memory
---"""
body = f"""# {title}
**来源**:本机记忆系统(自动固化)
**固化原因**:{reason}
**主体**:{subject}
**谓语**:{predicate}
**置信度**:{confidence:.2f}
**原始事实ID**:`{fact_id}`
---
## 内容
{obj}
---
> *本卡片由 memory_extractor.py 自动生成,请主机审核后归档入知识库。*
"""
safe_pred = re.sub(r'[^\w\u4e00-\u9fff-]', '_', predicate)[:20]
filename = f"MEM-{today.replace('-', '')}-{safe_pred}-{fact_id[-6:]}.md"
return filename, frontmatter + "\n" + body
def write_to_inbox(inbox_memory_dir: Path, filename: str, content: str, dry_run: bool) -> Path:
"""写入协同 inbox/memory/ 分区"""
inbox_memory_dir.mkdir(parents=True, exist_ok=True)
target = inbox_memory_dir / filename
if not dry_run:
target.write_text(content, encoding="utf-8")
return target
def mark_solidified(cursor, conn, fact_id: str, dry_run: bool):
"""在 facts.db 中标记该事实已固化(用 superseded_by 记录固化标记)"""
if dry_run:
return
cursor.execute(
"UPDATE facts SET superseded_by = ? WHERE id = ?",
(f"SOLIDIFIED:{datetime.now().strftime('%Y-%m-%d')}", fact_id)
)
conn.commit()
def write_log(root: Path, log_entries: list):
"""记录固化日志"""
log_dir = root / "04_memory" / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / "memory_solidification.log"
with open(log_file, "a", encoding="utf-8") as f:
for entry in log_entries:
f.write(f"{entry}\n")
def main():
args = parse_args()
root = Path(args.root).expanduser()
local_root = Path(args.local).expanduser()
today = datetime.now().strftime("%Y-%m-%d")
# 协同 inbox/memory/ 路径
inbox_memory_dir = root / "03_knowledge" / "00_stream" / "inbox" / "memory"
print("=" * 60)
print("AgentOS 记忆固化提取器")
print(f" root: {root}")
print(f" local: {local_root}")
print(f" inbox: {inbox_memory_dir}")
if args.dry_run:
print(" [DRY-RUN 模式,不写入文件]")
print("=" * 60)
conn, cursor = open_facts_db(local_root)
if conn is None:
print("[ERROR] 无法打开事实库,退出")
sys.exit(1)
# ── 收集待固化事实 ────────────────────────────────────────────
candidates = []
seen_ids = set()
if args.solidify:
# 模式1:用户主动指定
fact = get_fact_by_id(cursor, args.solidify)
if fact:
candidates.append(fact)
seen_ids.add(fact["id"])
print(f"[INFO] 用户指定固化: {args.solidify}")
else:
print(f"[ERROR] 未找到事实: {args.solidify}")
conn.close()
sys.exit(1)
else:
# 模式2:高频检测
freq_facts = detect_high_frequency(cursor, args.freq_days, args.freq_threshold)
for f in freq_facts:
if f["id"] not in seen_ids:
candidates.append(f)
seen_ids.add(f["id"])
# 模式3:高置信度检测
conf_facts = detect_high_confidence(cursor, args.confidence_min, args.version_min)
for f in conf_facts:
if f["id"] not in seen_ids:
candidates.append(f)
seen_ids.add(f["id"])
print(f"[INFO] 检测到 {len(candidates)} 条候选固化事实")
if not candidates:
print("[INFO] 无需固化,退出")
conn.close()
return
# ── 过滤已固化 ────────────────────────────────────────────────
new_candidates = []
for fact in candidates:
if already_solidified(inbox_memory_dir, fact["id"]):
print(f" ⏭ 已固化跳过: {fact['id']}")
else:
new_candidates.append(fact)
print(f"[INFO] 新增固化: {len(new_candidates)} 条")
if not new_candidates:
conn.close()
return
# ── 生成并写入知识卡 ──────────────────────────────────────────
log_entries = []
pushed = 0
for fact in new_candidates:
filename, content = generate_memory_card(fact, today)
target = write_to_inbox(inbox_memory_dir, filename, content, args.dry_run)
action = "[DRY-RUN]" if args.dry_run else "[写入]"
print(f" {action} {filename}")
print(f" 原因: {fact['solidify_reason']}")
# 标记已固化(仅非dry-run)
mark_solidified(cursor, conn, fact["id"], args.dry_run)
log_entries.append(
f"{datetime.now().isoformat()} | {fact['id']} | {fact['solidify_reason']} | {filename}"
)
pushed += 1
# ── 记录日志 ──────────────────────────────────────────────────
if not args.dry_run and log_entries:
write_log(root, log_entries)
conn.close()
print(f"\n[完成] 固化推送 {pushed} 条 → {inbox_memory_dir}")
print(" 主维护机可通过 inbox_refine.py 将这些内容归档入知识库。")
if __name__ == "__main__":
main()
@@ -0,0 +1,664 @@
#!/usr/bin/env python3
"""
AgentOS 语义检索模块
用途:BM25 + 向量语义混合检索,为记忆体提供语义搜索能力
架构:
L1 BM25 检索(关键词匹配,基于 keyword_index.json)
+ L1_vec 向量检索(语义匹配,基于 ChromaDB + oMLX embedding)
→ Reciprocal Rank Fusion (RRF) 加权融合
→ 返回排序后的 fact_id 列表
使用:
# 检索
python3 semantic_search.py search --root ~/workbuddy-agent-os/agent-sync --query "用户偏好什么编程语言" --top-k 5
# 向量化单条
python3 semantic_search.py embed --root ~/workbuddy-agent-os/agent-sync --text "测试文本"
# 回填所有历史数据
python3 semantic_search.py backfill --root ~/workbuddy-agent-os/agent-sync
# 清除向量库并重建
python3 semantic_search.py rebuild --root ~/workbuddy-agent-os/agent-sync
"""
import argparse
import json
import math
import os
import re
import sqlite3
import sys
import time
from collections import defaultdict
from datetime import datetime
from pathlib import Path
# ─── 默认配置 ──────────────────────────────────────────────────
EMBEDDING_API_URL = "http://localhost:8000/v1/embeddings"
EMBEDDING_MODEL = "Qwen3-Embedding-0.6B"
EMBEDDING_API_KEY = "omlx"
EMBEDDING_DIM = 1024
CHROMA_COLLECTION = "agentos_memories"
BM25_WEIGHT = 0.4 # BM25 在融合中的权重
VECTOR_WEIGHT = 0.6 # 向量相似度在融合中的权重
RRF_K = 60 # RRF 常数,越大排名差异的影响越小
# ─── Embedding API 调用 ─────────────────────────────────────────
def get_embeddings(texts, batch_size=5, retry=3, delay=1.0):
"""
调用 oMLX embedding API 获取文本向量。
Args:
texts: 字符串或字符串列表
batch_size: 每批请求的文本数量(oMLX 对大批量可能超时)
retry: 失败重试次数
delay: 批次间延迟(秒)
Returns:
向量列表(与输入顺序对应)
"""
import requests
if isinstance(texts, str):
texts = [texts]
all_embeddings = []
for i in range(0, len(texts), batch_size):
batch = texts[i:i + batch_size]
for attempt in range(retry):
try:
resp = requests.post(
EMBEDDING_API_URL,
json={"model": EMBEDDING_MODEL, "input": batch},
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {EMBEDDING_API_KEY}"
},
timeout=30
)
resp.raise_for_status()
data = resp.json()
# 按 index 排序确保顺序一致
sorted_data = sorted(data["data"], key=lambda x: x["index"])
batch_embeddings = [item["embedding"] for item in sorted_data]
all_embeddings.extend(batch_embeddings)
break
except Exception as e:
if attempt < retry - 1:
time.sleep(delay * (attempt + 1))
else:
raise RuntimeError(f"Embedding API 调用失败(批次 {i//batch_size + 1}): {e}")
if i + batch_size < len(texts):
time.sleep(delay)
return all_embeddings
# ─── ChromaDB 向量存储 ─────────────────────────────────────────
class VectorStore:
"""基于 ChromaDB 的向量存储,持久化到磁盘"""
def __init__(self, persist_dir: str):
import chromadb
self.persist_dir = Path(persist_dir)
self.persist_dir.mkdir(parents=True, exist_ok=True)
self.client = chromadb.PersistentClient(path=str(self.persist_dir))
self.collection = self.client.get_or_create_collection(
name=CHROMA_COLLECTION,
metadata={"hnsw:space": "cosine"}
)
def upsert(self, fact_id: str, text: str, metadata: dict, embedding: list = None):
"""添加或更新一条向量记录"""
if embedding is None:
embeddings = get_embeddings([text])
embedding = embeddings[0]
self.collection.upsert(
ids=[fact_id],
documents=[text],
embeddings=[embedding],
metadatas=[metadata]
)
def upsert_batch(self, items: list):
"""
批量添加/更新向量记录。
Args:
items: [{"fact_id": str, "text": str, "metadata": dict}, ...]
"""
if not items:
return
ids = [item["fact_id"] for item in items]
texts = [item["text"] for item in items]
metadatas = [item["metadata"] for item in items]
# 分批 embedding(避免 API 超时)
batch_size = 5
all_embeddings = []
for i in range(0, len(texts), batch_size):
batch_emb = get_embeddings(texts[i:i + batch_size])
all_embeddings.extend(batch_emb)
if i + batch_size < len(texts):
time.sleep(0.5)
# 分批 upsert(ChromaDB 建议每批 < 100)
upsert_batch_size = 50
for i in range(0, len(ids), upsert_batch_size):
self.collection.upsert(
ids=ids[i:i + upsert_batch_size],
documents=texts[i:i + upsert_batch_size],
embeddings=all_embeddings[i:i + upsert_batch_size],
metadatas=metadatas[i:i + upsert_batch_size]
)
def search(self, query: str, top_k: int = 10, query_embedding: list = None) -> list:
"""
向量相似度搜索。
Returns:
[{"fact_id": str, "score": float, "metadata": dict}, ...]
"""
if query_embedding is None:
query_embedding = get_embeddings([query])[0]
results = self.collection.query(
query_embeddings=[query_embedding],
n_results=min(top_k, self.collection.count()),
include=["metadatas", "documents", "distances"]
)
items = []
if results["ids"] and results["ids"][0]:
for i, fid in enumerate(results["ids"][0]):
distance = results["distances"][0][i] if results["distances"] else 0
# ChromaDB cosine distance → 相似度分数(1 - distance)
score = 1.0 - distance
items.append({
"fact_id": fid,
"score": max(0, score),
"metadata": results["metadatas"][0][i] if results["metadatas"] else {},
"document": results["documents"][0][i] if results["documents"] else ""
})
return items
def delete(self, fact_id: str):
"""删除一条向量记录"""
self.collection.delete(ids=[fact_id])
def count(self) -> int:
"""返回向量总数"""
return self.collection.count()
def get_all_ids(self) -> list:
"""获取所有 fact_id"""
result = self.collection.get(include=[])
return result["ids"] if result["ids"] else []
# ─── BM25 关键词检索 ────────────────────────────────────────────
class BM25Searcher:
"""基于 L1 keyword_index.json 的 BM25 检索"""
def __init__(self, index_path: str):
self.index_path = Path(index_path)
self._build_index()
def _build_index(self):
"""构建 BM25 索引"""
from rank_bm25 import BM25Okapi
if not self.index_path.exists():
self.entries = []
self.bm25 = None
return
with open(self.index_path, "r", encoding="utf-8") as f:
data = json.load(f)
self.entries = data.get("entries", [])
if not self.entries:
self.bm25 = None
return
# 用 keywords + summary 构建 BM25 语料库
self.corpus = []
for entry in self.entries:
# 合并关键词和摘要作为 BM25 文档
doc_words = entry.get("keywords", []) + [entry.get("summary", "")]
# 中文分词:按 2-4 字切分 + 英文按空格分
tokens = []
for w in doc_words:
if re.match(r'^[\u4e00-\u9fff]+$', w):
# 中文:2-4 字 ngram
for n in range(2, min(5, len(w) + 1)):
tokens.extend([w[i:i+n] for i in range(len(w) - n + 1)])
tokens.append(w) # 也保留完整词
else:
tokens.extend(w.lower().split())
self.corpus.append(tokens)
self.bm25 = BM25Okapi(self.corpus)
def search(self, query: str, top_k: int = 10) -> list:
"""
BM25 关键词搜索。
Returns:
[{"fact_id": str, "score": float, "keywords": list}, ...]
"""
if not self.bm25:
return []
# 查询分词
query_tokens = []
for w in re.findall(r'[\u4e00-\u9fff]{2,}|[a-zA-Z]{2,}', query):
if re.match(r'^[\u4e00-\u9fff]+$', w):
for n in range(2, min(5, len(w) + 1)):
query_tokens.extend([w[i:i+n] for i in range(len(w) - n + 1)])
query_tokens.append(w)
else:
query_tokens.extend(w.lower().split())
if not query_tokens:
return []
scores = self.bm25.get_scores(query_tokens)
# 取 top_k
ranked = sorted(enumerate(scores), key=lambda x: x[1], reverse=True)[:top_k]
results = []
for idx, score in ranked:
if score > 0:
entry = self.entries[idx]
results.append({
"fact_id": entry.get("fact_id", ""),
"score": float(score),
"keywords": entry.get("keywords", []),
"summary": entry.get("summary", ""),
"domain": entry.get("domain", ""),
"nature": entry.get("nature", "fact")
})
return results
# ─── Reciprocal Rank Fusion ─────────────────────────────────────
def reciprocal_rank_fusion(bm25_results: list, vector_results: list,
bm25_weight: float = BM25_WEIGHT,
vector_weight: float = VECTOR_WEIGHT,
rrf_k: int = RRF_K) -> list:
"""
使用 Reciprocal Rank Fusion 融合 BM25 和向量检索结果。
RRF 公式:score(d) = Σ 1/(k + rank(d))
加权:score(d) = w_bm25 * rrf_bm25(d) + w_vec * rrf_vec(d)
Returns:
[{"fact_id": str, "rrf_score": float, "bm25_rank": int, "vec_rank": int,
"bm25_score": float, "vec_score": float}, ...]
"""
fact_scores = defaultdict(lambda: {
"rrf_bm25": 0.0, "rrf_vec": 0.0,
"bm25_rank": None, "vec_rank": None,
"bm25_score": 0.0, "vec_score": 0.0,
"summary": "", "domain": "", "nature": ""
})
# BM25 排名贡献
for rank, item in enumerate(bm25_results, 1):
fid = item["fact_id"]
fact_scores[fid]["rrf_bm25"] = 1.0 / (rrf_k + rank)
fact_scores[fid]["bm25_rank"] = rank
fact_scores[fid]["bm25_score"] = item["score"]
fact_scores[fid]["summary"] = item.get("summary", "")
fact_scores[fid]["domain"] = item.get("domain", "")
fact_scores[fid]["nature"] = item.get("nature", "fact")
# 向量排名贡献
for rank, item in enumerate(vector_results, 1):
fid = item["fact_id"]
fact_scores[fid]["rrf_vec"] = 1.0 / (rrf_k + rank)
fact_scores[fid]["vec_rank"] = rank
fact_scores[fid]["vec_score"] = item["score"]
if item.get("metadata"):
fact_scores[fid]["summary"] = item["metadata"].get("summary", fact_scores[fid]["summary"])
fact_scores[fid]["domain"] = item["metadata"].get("domain", fact_scores[fid]["domain"])
fact_scores[fid]["nature"] = item["metadata"].get("nature", fact_scores[fid]["nature"])
if item.get("document") and not fact_scores[fid]["summary"]:
fact_scores[fid]["summary"] = item["document"][:120]
# 加权融合 + 排序
fused = []
for fid, scores in fact_scores.items():
rrf_score = bm25_weight * scores["rrf_bm25"] + vector_weight * scores["rrf_vec"]
fused.append({
"fact_id": fid,
"rrf_score": rrf_score,
"bm25_rank": scores["bm25_rank"],
"vec_rank": scores["vec_rank"],
"bm25_score": scores["bm25_score"],
"vec_score": scores["vec_score"],
"summary": scores["summary"],
"domain": scores["domain"],
"nature": scores["nature"]
})
fused.sort(key=lambda x: x["rrf_score"], reverse=True)
return fused
# ─── 统一检索入口 ──────────────────────────────────────────────
class MemorySearcher:
"""
AgentOS 记忆检索器 — BM25 + 向量语义混合检索
Usage:
searcher = MemorySearcher("/path/to/agent-os")
results = searcher.search("用户偏好什么编程语言", top_k=5)
"""
def __init__(self, root: str):
self.root = Path(root)
self.vector_store = VectorStore(str(self.root / "04_memory" / "vector_db" / "chroma"))
self.bm25_searcher = BM25Searcher(str(self.root / "04_memory" / "vector_db" / "keyword_index.json"))
self.db_path = self.root / "04_memory" / "long_term" / "facts.db"
def search(self, query: str, top_k: int = 10,
bm25_weight: float = BM25_WEIGHT,
vector_weight: float = VECTOR_WEIGHT) -> dict:
"""
混合检索入口。
Args:
query: 检索查询文本
top_k: 返回结果数量
bm25_weight: BM25 权重(0-1)
vector_weight: 向量权重(0-1)
Returns:
{
"query": str,
"total_vector": int, # 向量库总数
"results": [
{
"fact_id": str,
"rrf_score": float,
"bm25_rank": int|null,
"vec_rank": int|null,
"confidence": float, # L2 置信度
"subject": str,
"predicate": str,
"object": str, # L2 完整事实
"summary": str, # L1 摘要
"domain": str,
"nature": str
}, ...
]
}
"""
# 并行执行两种检索
bm25_results = self.bm25_searcher.search(query, top_k=top_k * 2)
vector_results = self.vector_store.search(query, top_k=top_k * 2)
# RRF 融合
fused = reciprocal_rank_fusion(
bm25_results, vector_results,
bm25_weight=bm25_weight,
vector_weight=vector_weight
)
# 截取 top_k
fused = fused[:top_k]
# 从 L2 获取完整事实信息
results = self._enrich_with_l2(fused)
return {
"query": query,
"total_vector": self.vector_store.count(),
"bm25_results": len(bm25_results),
"vector_results": len(vector_results),
"results": results
}
def _enrich_with_l2(self, fused_results: list) -> list:
"""从 L2 facts.db 补充完整事实信息"""
if not self.db_path.exists():
return fused_results
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
enriched = []
for item in fused_results:
fid = item["fact_id"]
row = cursor.execute(
"SELECT subject, predicate, object, confidence, nature, domain FROM facts WHERE id = ?",
(fid,)
).fetchone()
if row:
enriched.append({
"fact_id": fid,
"rrf_score": round(item["rrf_score"], 6),
"bm25_rank": item["bm25_rank"],
"vec_rank": item["vec_rank"],
"confidence": row["confidence"],
"subject": row["subject"],
"predicate": row["predicate"],
"object": row["object"],
"summary": item.get("summary", row["object"][:120]),
"domain": row["domain"],
"nature": row["nature"]
})
else:
# L2 中不存在(不应该发生,但防御性处理)
enriched.append({
"fact_id": fid,
"rrf_score": round(item["rrf_score"], 6),
"bm25_rank": item["bm25_rank"],
"vec_rank": item["vec_rank"],
"confidence": 0,
"subject": "",
"predicate": "",
"object": item.get("summary", ""),
"summary": item.get("summary", ""),
"domain": item.get("domain", ""),
"nature": item.get("nature", "")
})
conn.close()
return enriched
def add_fact(self, fact_id: str, text: str, metadata: dict):
"""添加单条事实到向量库"""
self.vector_store.upsert(fact_id, text, metadata)
def remove_fact(self, fact_id: str):
"""从向量库删除一条事实"""
self.vector_store.delete(fact_id)
def backfill_all(self, batch_size: int = 5, verbose: bool = True):
"""
回填所有 L2 事实到向量库。
从 facts.db 读取所有事实,生成 embedding 并写入 ChromaDB。
"""
if not self.db_path.exists():
print("[ERROR] facts.db 不存在")
return 0
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
facts = cursor.execute(
"SELECT id, subject, predicate, object, confidence, nature, domain, source, date_created FROM facts"
).fetchall()
conn.close()
if not facts:
print("[INFO] facts.db 为空,无需回填")
return 0
# 获取向量库已有 ID
existing_ids = set(self.vector_store.get_all_ids())
# 构建待回填列表
to_fill = []
for fact in facts:
if fact["id"] not in existing_ids:
text = f"{fact['subject']} {fact['predicate']} {fact['object']}"
metadata = {
"summary": fact["object"][:120],
"domain": fact["domain"],
"nature": fact["nature"],
"source": fact["source"],
"date_created": fact["date_created"],
"confidence": fact["confidence"]
}
to_fill.append({
"fact_id": fact["id"],
"text": text,
"metadata": metadata
})
if not to_fill:
print(f"[INFO] 全部 {len(facts)} 条事实已在向量库中,无需回填")
return 0
if verbose:
print(f"[INFO] 需要回填 {len(to_fill)} 条事实(总共 {len(facts)} 条)")
# 分批处理
filled = 0
for i in range(0, len(to_fill), batch_size):
batch = to_fill[i:i + batch_size]
try:
self.vector_store.upsert_batch(batch)
filled += len(batch)
if verbose:
pct = filled / len(to_fill) * 100
print(f" [{pct:5.1f}%] {filled}/{len(to_fill)} 已回填")
except Exception as e:
print(f" [ERROR] 批次 {i//batch_size + 1} 失败: {e}")
# 尝试逐条重试
for item in batch:
try:
self.vector_store.upsert(
item["fact_id"], item["text"],
item["metadata"]
)
filled += 1
if verbose:
print(f" [RETRY] {item['fact_id']} OK")
except Exception as e2:
print(f" [FAIL] {item['fact_id']}: {e2}")
if verbose:
print(f"[OK] 回填完成: {filled}/{len(to_fill)} 成功, 向量库总数: {self.vector_store.count()}")
return filled
def rebuild(self):
"""清除向量库并完全重建"""
import chromadb
persist_dir = str(self.root / "04_memory" / "vector_db" / "chroma")
# 删除旧数据库
import shutil
if os.path.exists(persist_dir):
shutil.rmtree(persist_dir)
print(f"[OK] 已清除旧向量库: {persist_dir}")
# 重新初始化
self.vector_store = VectorStore(persist_dir)
print("[OK] 向量库已重建")
# 回填
return self.backfill_all()
# ─── CLI 入口 ──────────────────────────────────────────────────
def cmd_search(args):
searcher = MemorySearcher(args.root)
result = searcher.search(args.query, top_k=args.top_k)
print(f"\n{'='*60}")
print(f"检索: {result['query']}")
print(f"向量库总数: {result['total_vector']} | "
f"BM25命中: {result['bm25_results']} | 向量命中: {result['vector_results']}")
print(f"{'='*60}\n")
if not result["results"]:
print("(无匹配结果)")
return
for i, item in enumerate(result["results"], 1):
bm25_r = f"BM25#{item['bm25_rank']}" if item["bm25_rank"] else "BM25#-"
vec_r = f"VEC#{item['vec_rank']}" if item["vec_rank"] else "VEC#-"
print(f"[{i}] {item['fact_id']} RRF={item['rrf_score']:.4f} conf={item['confidence']} "
f"{bm25_r} {vec_r}")
print(f" [{item['domain']}] {item['subject']} → {item['object'][:100]}")
print()
def cmd_embed(args):
embeddings = get_embeddings([args.text])
print(f"维度: {len(embeddings[0])}")
print(f"前5值: {embeddings[0][:5]}")
print(f"范数: {math.sqrt(sum(x*x for x in embeddings[0])):.4f}")
def cmd_backfill(args):
searcher = MemorySearcher(args.root)
searcher.backfill_all()
def cmd_rebuild(args):
searcher = MemorySearcher(args.root)
searcher.rebuild()
def main():
parser = argparse.ArgumentParser(description="AgentOS 语义检索模块")
parser.add_argument("--root", required=True, help="agent-os 根目录路径")
subparsers = parser.add_subparsers(dest="command")
# search
sp_search = subparsers.add_parser("search", help="混合检索")
sp_search.add_argument("--query", required=True, help="检索查询")
sp_search.add_argument("--top-k", type=int, default=5, help="返回结果数量")
# embed
sp_embed = subparsers.add_parser("embed", help="测试 embedding")
sp_embed.add_argument("--text", required=True, help="测试文本")
# backfill
subparsers.add_parser("backfill", help="回填所有历史数据到向量库")
# rebuild
subparsers.add_parser("rebuild", help="清除并重建向量库")
args = parser.parse_args()
if args.command == "search":
cmd_search(args)
elif args.command == "embed":
cmd_embed(args)
elif args.command == "backfill":
cmd_backfill(args)
elif args.command == "rebuild":
cmd_rebuild(args)
else:
parser.print_help()
if __name__ == "__main__":
main()
@@ -0,0 +1,78 @@
#!/bin/bash
# =============================================================================
# 向量库增量更新脚本 (vector_backfill.sh v1.1)
# 联邦系统定时任务 — 将 L2 新事实回填到 ChromaDB 向量索引
# 依赖: oMLX macOS App + matrix Python venv
# =============================================================================
set -e
AGENT_SYNC="/Users/7kecheng/workbuddy-agent-os/agent-sync"
PYTHON="/Users/7kecheng/.workbuddy/binaries/python/envs/matrix/bin/python3"
SKILL_DIR="/Users/7kecheng/.workbuddy/skills/memory_manager"
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
LOG_FILE="$AGENT_SYNC/04_memory/vector_db/backfill.log"
OMLX_APP="/Applications/oMLX.app"
OMLX_PORT=8000
OMLX_API_KEY="omlx"
OMLX_CHECK_URL="http://localhost:${OMLX_PORT}/v1/models"
OMLX_CHECK_HEADER="Authorization: Bearer ${OMLX_API_KEY}"
OMLX_WAIT_MAX=60 # 最多等 60 秒(模型加载可能较慢)
echo "[${TIMESTAMP}] 🚀 向量库增量更新开始"
# ═══════════════════════════════════════════════════════════
# 1. 检查 oMLX 是否运行,未运行则自动启动
# ═══════════════════════════════════════════════════════════
if curl -sf -H "$OMLX_CHECK_HEADER" "$OMLX_CHECK_URL" > /dev/null 2>&1; then
echo "[${TIMESTAMP}] ✅ oMLX 运行中"
else
echo "[${TIMESTAMP}] 🚀 oMLX 未运行,正在启动..."
open "$OMLX_APP"
# 等待就绪(最多 60s)
for i in $(seq 1 $OMLX_WAIT_MAX); do
sleep 1
if curl -sf -H "$OMLX_CHECK_HEADER" "$OMLX_CHECK_URL" > /dev/null 2>&1; then
echo "[${TIMESTAMP}] ✅ oMLX 启动完成(${i}s)"
break
fi
if [ $i -eq $OMLX_WAIT_MAX ]; then
echo "[${TIMESTAMP}] ❌ oMLX 启动超时(${OMLX_WAIT_MAX}s),放弃本次更新"
exit 1
fi
done
fi
# ═══════════════════════════════════════════════════════════
# 2. 统计更新前 chroma 文件数
# ═══════════════════════════════════════════════════════════
BEFORE=0
if [ -d "$AGENT_SYNC/04_memory/vector_db/chroma" ]; then
BEFORE=$(find "$AGENT_SYNC/04_memory/vector_db/chroma" -name "*.chroma.sqlite3" 2>/dev/null | wc -l)
fi
# ═══════════════════════════════════════════════════════════
# 3. 执行增量回填
# ═══════════════════════════════════════════════════════════
echo "[${TIMESTAMP}] 🔄 执行增量回填..."
cd "$SKILL_DIR"
OUTPUT=$($PYTHON semantic_search.py --root "$AGENT_SYNC" backfill 2>&1)
EXIT_CODE=$?
# ═══════════════════════════════════════════════════════════
# 4. 统计结果
# ═══════════════════════════════════════════════════════════
SUCCESS=$(echo "$OUTPUT" | grep -c "\[OK\]" || true)
FAIL=$(echo "$OUTPUT" | grep -c "\[FAIL\]" || true)
echo "$OUTPUT" >> "$LOG_FILE"
if [ $EXIT_CODE -eq 0 ]; then
echo "[${TIMESTAMP}] ✅ 向量库更新完成 (成功: $SUCCESS, 失败: $FAIL)"
echo "[${TIMESTAMP}] 更新前 chroma 文件数: $BEFORE"
else
echo "[${TIMESTAMP}] ❌ 向量库更新异常 (code=$EXIT_CODE)"
fi
echo "[${TIMESTAMP}] 📝 日志已保存: $LOG_FILE"
echo "[${TIMESTAMP}] 🏁 结束"
@@ -0,0 +1,7 @@
{
"name": "memory_manager",
"version": "1.2.0",
"description": "智能体记忆管理技能——每日对话提炼、去重、冲突检测、版本管理",
"dependencies": ["sqlite3"],
"mcp_tools": []
}