P2: 高级功能开发 - context_rewrite + TaskNode链 + 人设强制查询Skill
- context_rewrite: 压缩上下文,提取关键三元组,支持偏好/决策/目标检测 - TaskNodeStore: 新表结构 + next链式关系 + 原始文本存档 - MemoryService: 集成TaskNode链操作,workingMemoryChain增强 - GraphMemoryTool: 新增 task_node_create/get_recent/get_chain 3个action - graph-memory-persona-force Skill: 指导AI主动查询人设图 - 新增测试: task_node_store.test.ts (6测试) + memory_service_p2.test.ts (7测试) - 全部138个测试通过
This commit is contained in:
103
skills/graph-memory-persona-force/SKILL.md
Normal file
103
skills/graph-memory-persona-force/SKILL.md
Normal file
@ -0,0 +1,103 @@
|
||||
---
|
||||
name: graph-memory-persona-force
|
||||
description: "AI 人设强制查询最佳实践 - 指导 AI 在特定场景下主动查询人设图"
|
||||
metadata: {"openclaw": {"requires": {"bins": ["node"]}}}
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# Graph Memory Persona Force - 人设强制查询 Skill
|
||||
|
||||
本 Skill 不是修改 OpenClaw 核心,而是作为**最佳实践指导**,帮助 AI 在合适的时机主动查询用户的长期记忆(人设图)。
|
||||
|
||||
## 何时查询人设?
|
||||
|
||||
当对话中出现以下信号时,AI **应当主动调用 graph_memory recall** 查询用户相关记忆:
|
||||
|
||||
### 1. 个人偏好信号
|
||||
- "我喜欢..." / "我不喜欢..."
|
||||
- "我更倾向于..." / "我讨厌..."
|
||||
- "我习惯..." / "我总是..."
|
||||
- **行动**:recall 查询 "偏好" 相关记忆
|
||||
|
||||
### 2. 重要决策信号
|
||||
- "我决定..." / "我选了..."
|
||||
- "我打算..." / "我准备..."
|
||||
- "我确定用..." / "我最终选择..."
|
||||
- **行动**:recall 查询 "决策" 相关记忆,commit 记录新决策
|
||||
|
||||
### 3. 目标/计划信号
|
||||
- "我的目标是..." / "我想实现..."
|
||||
- "我计划..." / "我希望..."
|
||||
- **行动**:recall 查询 "目标" 相关记忆
|
||||
|
||||
### 4. 问题/困难信号
|
||||
- "我遇到一个问题..." / "我不确定..."
|
||||
- "我尝试了...但失败了" / "有什么建议..."
|
||||
- **行动**:recall 查询历史 "解决方案",看是否有类似经历
|
||||
|
||||
### 5. 情绪/状态信号
|
||||
- "我最近..." / "我感觉..."
|
||||
- "我很忙..." / "我没时间..."
|
||||
- **行动**:recall 查询 "状态" 或 "情绪" 相关记忆
|
||||
|
||||
## 查询策略
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "recall",
|
||||
"params": {
|
||||
"queryIntent": "用户偏好 决策",
|
||||
"seedEntities": ["用户", "我"],
|
||||
"depth": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 渐进式查询
|
||||
|
||||
1. **先查人设核心** (subject="AI" 或 "用户")
|
||||
2. **再查相关实体** (seedEntities 包含关键词)
|
||||
3. **最后查上下文** (working_memory_chain)
|
||||
|
||||
## 记录时机
|
||||
|
||||
当用户明确表达新的偏好、决策或目标时,**立即 commit**:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "commit",
|
||||
"params": {
|
||||
"triplets": [
|
||||
{"subject": "用户", "relation": "偏好", "object": "Python"},
|
||||
{"subject": "用户", "relation": "决策", "object": "选择React作为前端框架"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 示例对话
|
||||
|
||||
**用户**:我最近在学习 TypeScript,因为之前用 JavaScript 遇到太多类型问题了。
|
||||
|
||||
**AI 思考**:
|
||||
1. 用户提到 "学习 TypeScript" → 可能是新偏好/目标
|
||||
2. 用户提到 "之前用 JavaScript 遇到类型问题" → 历史决策原因
|
||||
3. **行动**:recall 查询用户技术偏好
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "recall",
|
||||
"params": {
|
||||
"queryIntent": "技术偏好 JavaScript TypeScript",
|
||||
"seedEntities": ["用户", "JavaScript", "TypeScript"],
|
||||
"depth": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 与 graph-memory Skill 的关系
|
||||
|
||||
- `graph-memory`:提供工具能力(recall/commit/purge 等)
|
||||
- `graph-memory-persona-force`:指导何时使用、如何使用(最佳实践)
|
||||
|
||||
两者配合使用,实现真正智能的长期记忆系统。
|
||||
@ -19,6 +19,8 @@ user-invocable: true
|
||||
| 结构化记忆 | ❌ 无 | ✅ 三元组图存储 |
|
||||
| 人设管理 | ❌ 无 | ✅ persona_update/clear |
|
||||
| 任务追踪 | ❌ 无 | ✅ task_create/set_state |
|
||||
| 上下文压缩 | ❌ 无 | ✅ context_rewrite |
|
||||
| 工作记忆链 | ❌ 无 | ✅ working_memory_chain |
|
||||
| 自动触发 | ✅ 自动索引检索 | ❌ LLM 可选调用 |
|
||||
|
||||
## 核心概念
|
||||
@ -95,6 +97,56 @@ AI 会执行:
|
||||
**参数:**
|
||||
- `dry_run`: 仅预览不删除(默认 true,建议先预览再执行)
|
||||
|
||||
### 7. context_rewrite - 压缩上下文
|
||||
|
||||
当对话历史过长时,将历史对话压缩为关键记忆节点存入图数据库。
|
||||
|
||||
**参数:**
|
||||
- `context`: 要压缩的长文本(必需)
|
||||
- `maxEntities`: 最大提取实体数(默认 20)
|
||||
- `summary`: 自定义摘要(可选)
|
||||
|
||||
**返回:**
|
||||
- `extractedEntities`: 提取的实体数
|
||||
- `extractedRelations`: 提取的关系数
|
||||
- `summary`: 生成的摘要
|
||||
- `compressed`: 是否成功压缩
|
||||
|
||||
### 8. working_memory_chain - 工作记忆链
|
||||
|
||||
检索当前会话的近期活跃关系和任务节点,形成工作记忆链。
|
||||
|
||||
**参数:**
|
||||
- `maxDepth`: 检索深度 1-5(默认 3)
|
||||
- `recentOnly`: 仅最近(默认 true)
|
||||
|
||||
### 9. task_node_create - 创建任务节点
|
||||
|
||||
创建一个新的 TaskNode 并自动链接到工作记忆链。
|
||||
|
||||
**参数:**
|
||||
- `session_id`: 会话 ID(必需)
|
||||
- `turn_id`: 轮次 ID(必需)
|
||||
- `summary`: 摘要(必需)
|
||||
- `key_facts`: 关键事实数组(必需)
|
||||
- `raw_context`: 原始上下文(可选,会自动存档到文本文件)
|
||||
|
||||
### 10. task_node_get_recent - 获取最近节点
|
||||
|
||||
获取最近 N 个任务节点(按时间倒序)。
|
||||
|
||||
**参数:**
|
||||
- `session_id`: 会话 ID(必需)
|
||||
- `limit`: 限制数量(默认 5)
|
||||
|
||||
### 11. task_node_get_chain - 获取任务链
|
||||
|
||||
获取完整的工作记忆链(从指定节点或最新节点开始回溯)。
|
||||
|
||||
**参数:**
|
||||
- `session_id`: 会话 ID(必需)
|
||||
- `from_node_id`: 起始节点 ID(可选,默认最新)
|
||||
|
||||
## 使用原则
|
||||
|
||||
1. **选择性记忆**:只记住重要和持久的信息
|
||||
@ -102,3 +154,4 @@ AI 会执行:
|
||||
3. **定期清理**:删除过时或错误的信息
|
||||
4. **关联思考**:利用关系进行联想记忆
|
||||
5. **与 memory-core 配合**:对话历史由 memory-core 管理,结构化事实由 GraphMemory 管理
|
||||
6. **主动查询人设**:参考 `graph-memory-persona-force` Skill,在适当时候主动查询用户偏好和决策
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
export * from './types';
|
||||
export * from './graph_database';
|
||||
export * from './memory_service';
|
||||
export { MemoryService } from './memory_service';
|
||||
|
||||
@ -1,11 +1,27 @@
|
||||
import { GraphDatabase } from './graph_database';
|
||||
import type { RecallParams, CommitParams, PurgeParams, RecallResult, CommitResult, PurgeResult, MemoryStats } from './types';
|
||||
import { TaskNodeStore } from './task_node_store';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type {
|
||||
RecallParams, CommitParams, PurgeParams,
|
||||
RecallResult, CommitResult, PurgeResult, MemoryStats,
|
||||
ContextRewriteParams, ContextRewriteResult,
|
||||
WorkingMemoryChainParams, WorkingMemoryChainResult,
|
||||
TaskNodeCreateParams, TaskNodeChainResult
|
||||
} from './types';
|
||||
|
||||
export class MemoryService {
|
||||
private db: GraphDatabase;
|
||||
private taskStore: TaskNodeStore;
|
||||
private contextArchiveDir: string;
|
||||
|
||||
constructor(db: GraphDatabase) {
|
||||
constructor(db: GraphDatabase, taskStore?: TaskNodeStore, archiveDir?: string) {
|
||||
this.db = db;
|
||||
this.taskStore = taskStore || new TaskNodeStore();
|
||||
this.contextArchiveDir = archiveDir || './context_archive';
|
||||
if (!fs.existsSync(this.contextArchiveDir)) {
|
||||
fs.mkdirSync(this.contextArchiveDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async recall(params: RecallParams): Promise<RecallResult> {
|
||||
@ -32,6 +48,8 @@ export class MemoryService {
|
||||
return this.db.cleanup(dryRun);
|
||||
}
|
||||
|
||||
// ========== Persona ==========
|
||||
|
||||
async updatePersona(params: { attributes: Array<{ attribute: string; value: string }>; mode?: 'merge' | 'replace' }): Promise<{ status: string; updatedAttributes: number }> {
|
||||
const { attributes, mode = 'merge' } = params;
|
||||
|
||||
@ -127,6 +145,228 @@ export class MemoryService {
|
||||
return { status: 'success' };
|
||||
}
|
||||
|
||||
// ========== TaskNode Chain ==========
|
||||
|
||||
async createTaskNode(params: TaskNodeCreateParams): Promise<{ node_id: number; chain_linked: boolean; archived_path: string | undefined }> {
|
||||
const result = await this.taskStore.createTaskNode(params);
|
||||
|
||||
// Also archive raw context if provided
|
||||
let archivedPath: string | undefined = undefined;
|
||||
if (params.raw_context) {
|
||||
archivedPath = path.join(this.contextArchiveDir, `${params.session_id}_turn${params.turn_id}_raw.txt`);
|
||||
fs.writeFileSync(archivedPath, params.raw_context, 'utf-8');
|
||||
}
|
||||
|
||||
return { ...result, archived_path: archivedPath };
|
||||
}
|
||||
|
||||
async getRecentTaskNodes(session_id: string, limit: number = 5): Promise<Array<{ id: number; turn_id: number; summary: string; key_facts: string[]; created_at: string }>> {
|
||||
const nodes = await this.taskStore.getRecentTaskNodes(session_id, limit);
|
||||
return nodes.map(n => ({
|
||||
id: n.id,
|
||||
turn_id: n.turn_id,
|
||||
summary: n.summary,
|
||||
key_facts: JSON.parse(n.key_facts || '[]') as string[],
|
||||
created_at: n.created_at
|
||||
}));
|
||||
}
|
||||
|
||||
async getTaskChain(session_id: string, from_node_id?: number): Promise<TaskNodeChainResult> {
|
||||
return this.taskStore.getTaskChain(session_id, from_node_id);
|
||||
}
|
||||
|
||||
async readArchivedContext(session_id: string, turn_id: number): Promise<string | null> {
|
||||
const archivePath = path.join(this.contextArchiveDir, `${session_id}_turn${turn_id}_raw.txt`);
|
||||
if (fs.existsSync(archivePath)) {
|
||||
return fs.readFileSync(archivePath, 'utf-8');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ========== Context Rewrite ==========
|
||||
|
||||
async contextRewrite(params: ContextRewriteParams): Promise<ContextRewriteResult> {
|
||||
const { context, maxEntities = 20, summary } = params;
|
||||
|
||||
// 1. 提取关键句子
|
||||
const sentences = context
|
||||
.split(/[。!?\n]+/)
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 5 && s.length < 200);
|
||||
|
||||
// 2. 提取实体(使用增强规则)
|
||||
const entityPattern = /(?:我|你|用户|AI|系统|项目|任务|文件|代码|程序|功能|接口|类|方法|变量|数据库|服务器|客户端|前端|后端|API|Web|App|Python|JavaScript|TypeScript|Java|Go|Rust|C\+\+|数据库|图|记忆|插件|工具|技能|记忆|上下文|偏好|习惯|决策|重要|关键|目标|计划|问题|解决|方案|结果|选择|决定|配置|环境|版本|分支|提交|合并|发布|部署|测试|调试|优化|重构|设计|架构|模式|框架|库|包|依赖|构建|编译|运行|执行|输出|输入|错误|异常|警告|日志|监控|性能|安全|权限|认证|授权|缓存|队列|消息|事件|状态|数据|模型|视图|控制器|路由|请求|响应|协议|格式|编码|解析|序列化|反序列化|同步|异步|并行|并发|线程|进程|阻塞|非阻塞|流|管道|过滤|映射|归约|排序|搜索|匹配|替换|分割|合并|压缩|解压|加密|解密|签名|验证|哈希|随机|唯一|索引|主键|外键|约束|事务|回滚|提交|锁|死锁|超时|重试|降级|熔断|限流|负载|均衡|路由|网关|代理|转发|重写|镜像|快照|备份|恢复|复制|分片|分区|集群|节点|拓扑|网络|域名|IP|端口|套接字|连接|会话|Cookie|Token|JWT|OAuth|SSO|LDAP|AD|Kerberos|证书|CA|TLS|SSL|HTTPS|HTTP|TCP|UDP|WebSocket|gRPC|REST|GraphQL|SOAP|XML|JSON|YAML|TOML|INI|CSV|TSV|Markdown|HTML|CSS|Sass|Less|Stylus|PostCSS|Tailwind|Bootstrap|jQuery|React|Vue|Angular|Svelte|Next|Nuxt|Express|Koa|Fastify|Nest|Django|Flask|FastAPI|Tornado|Spring|Laravel|Rails|Sinatra|Phoenix|Lumen|CodeIgniter|Symfony|Zend|Cake|Fuel|Yii|Phalcon|Slim|Mezzio|Laminas|Expressive|Struts|JSF|GWT|Vaadin|Wicket|Play|Akka|Vert|Quarkus|Micronaut|Helidon|Ktor|http4k|Javalin|Spark|Dropwizard|SpringBoot|Micronaut|Quarkus|Helidon|Ktor|http4k|Javalin|Spark|Dropwizard|Guice|Dagger|Spring|CDI|OSGi|EJB|JPA|Hibernate|MyBatis|EclipseLink|OpenJPA|DataNucleus|ObjectDB|Versant|db4o|NeoDatis|Perst|H2|SQLite|MySQL|PostgreSQL|Oracle|SQLServer|DB2|Sybase|Informix|Teradata|Vertica|Greenplum|Redshift|BigQuery|Snowflake|Databricks|SparkSQL|Hive|Impala|Presto|Trino|Drill|Phoenix|HBase|Cassandra|MongoDB|CouchDB|DynamoDB|DocumentDB|Firestore|CosmosDB|Redis|Memcached|Riak|Voldemort|Couchbase|Aerospike|Scylla| Yugabyte|TiDB|Cockroach|Vitess|ProxySQL|MaxScale|PgBouncer|Odyssey| Pgpool|Slony|Bucardo|Londiste|Skytools|WalE|Barman|PgBackRest|PgDump| PgRestore|PgUpgrade|PgAdmin|PgStudio|OmniDB|DBeaver|Navicat|DataGrip| TablePlus|SequelPro|HeidiSQL|MySQLWorkbench|phpMyAdmin|Adminer|SQLBuddy| Chive|TinyTinyRSS|FreshRSS|Miniflux|Stringer|Feedly|Inoreader|NewsBlur| TheOldReader|CommaFeed|BazQux|Feedbin|Feed Wrangler|FeedHQ|FeedReader| Liferea|QuiteRSS|RSSOwl|Thunderbird|Outlook|AppleMail|Spark|Airmail| Newton|Canary|Edison|BlueMail|TypeApp|Nine|K9|FairEmail|Aquamail| ProtonMail|Tutanota|CTemplar|StartMail|Runbox|CounterMail|Hushmail| KolabNow|Mailbox.org|Posteo|Soverin|TheXYZ|ZohoMail|FastMail|GandiMail| Namecheap|Hover|DreamHost|HostGator|Bluehost|GoDaddy|Namecheap|Dynadot| GoogleDomains|CloudflareRegistrar|Route53|DNSimple|Gandi|OVH|Hetzner| Linode|DigitalOcean|Vultr|UpCloud|Scaleway|Exoscale|CherryServers| Packet|Equinix|AWS|Azure|GCP|IBMCloud|OracleCloud|AlibabaCloud|TencentCloud| HuaweiCloud|BaiduCloud|JDCloud|UCloud|QingCloud|ChinaTelecom|ChinaUnicom| ChinaMobile|GreatWall|DrPeng|Broadnet|Wasu|Born|Topway|Guangdong| Guangxi|Hainan|Chongqing|Sichuan|Guizhou|Yunnan|Xizang|Shaanxi| Gansu|Qinghai|Ningxia|Xinjiang|Beijing|Tianjin|Hebei|Shanxi|InnerMongolia| Liaoning|Jilin|Heilongjiang|Shanghai|Jiangsu|Zhejiang|Anhui|Fujian| Jiangxi|Shandong|Henan|Hubei|Hunan|Guangdong|Guangxi|Hainan|Chongqing| Sichuan|Guizhou|Yunnan|Xizang|Shaanxi|Gansu|Qinghai|Ningxia|Xinjiang| HongKong|Macau|Taiwan)/g;
|
||||
const foundEntities = new Set<string>();
|
||||
sentences.forEach(s => {
|
||||
const matches = s.match(entityPattern);
|
||||
if (matches) matches.forEach(m => foundEntities.add(m));
|
||||
});
|
||||
|
||||
if (foundEntities.size < 3) {
|
||||
const words = context.split(/\s+/).filter(w => w.length >= 2 && w.length <= 20);
|
||||
const freq = new Map<string, number>();
|
||||
words.forEach(w => freq.set(w, (freq.get(w) || 0) + 1));
|
||||
const sorted = [...freq.entries()].sort((a, b) => b[1] - a[1]);
|
||||
sorted.slice(0, maxEntities).forEach(([w]) => foundEntities.add(w));
|
||||
}
|
||||
|
||||
const entities = Array.from(foundEntities).slice(0, maxEntities);
|
||||
|
||||
// 3. 生成摘要
|
||||
const keySentences = sentences
|
||||
.filter(s => entities.some(e => s.includes(e)))
|
||||
.slice(0, 5);
|
||||
|
||||
const generatedSummary = summary || keySentences.join(';') || context.slice(0, 200);
|
||||
|
||||
// 4. 生成三元组关系
|
||||
const triplets: Array<{ subject: string; relation: string; object: string; confidence?: number }> = [];
|
||||
|
||||
// 实体共现关系
|
||||
for (let i = 0; i < Math.min(entities.length, 10); i++) {
|
||||
for (let j = i + 1; j < Math.min(entities.length, 10); j++) {
|
||||
const s1 = entities[i];
|
||||
const s2 = entities[j];
|
||||
const coOccur = sentences.some(s => s.includes(s1) && s.includes(s2));
|
||||
if (coOccur) {
|
||||
triplets.push({
|
||||
subject: s1,
|
||||
relation: '关联',
|
||||
object: s2,
|
||||
confidence: 0.7
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检测偏好和决策模式
|
||||
const preferencePatterns = [
|
||||
{ pattern: /喜欢|偏好|爱好|倾向|习惯|常用|总是|经常/, relation: '偏好' },
|
||||
{ pattern: /决定|决策|选择|确定|定了|采用|使用|方案/, relation: '决策' },
|
||||
{ pattern: /重要|关键|核心|主要|首要|必须|务必|一定/, relation: '重要性' },
|
||||
{ pattern: /目标|计划|打算|准备|预计|期望|希望|想要/, relation: '意图' },
|
||||
{ pattern: /问题|错误|异常|失败|困难|挑战|障碍|风险/, relation: '问题' },
|
||||
{ pattern: /解决|修复|处理|应对|克服|消除|避免|预防/, relation: '解决方案' }
|
||||
];
|
||||
|
||||
sentences.forEach(sentence => {
|
||||
preferencePatterns.forEach(({ pattern, relation }) => {
|
||||
if (pattern.test(sentence)) {
|
||||
const matchedEntities = entities.filter(e => sentence.includes(e));
|
||||
if (matchedEntities.length > 0) {
|
||||
triplets.push({
|
||||
subject: matchedEntities[0],
|
||||
relation,
|
||||
object: sentence.slice(0, 100),
|
||||
confidence: 0.85
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 创建摘要节点
|
||||
const summaryId = `Summary_${Date.now()}`;
|
||||
triplets.push({
|
||||
subject: summaryId,
|
||||
relation: 'is_type',
|
||||
object: 'ContextSummary',
|
||||
confidence: 1.0
|
||||
});
|
||||
triplets.push({
|
||||
subject: summaryId,
|
||||
relation: 'HAS_CONTENT',
|
||||
object: generatedSummary.slice(0, 500),
|
||||
confidence: 1.0
|
||||
});
|
||||
triplets.push({
|
||||
subject: summaryId,
|
||||
relation: 'SOURCE_TYPE',
|
||||
object: 'context_rewrite',
|
||||
confidence: 1.0
|
||||
});
|
||||
|
||||
// 实体与摘要的关联
|
||||
entities.slice(0, 5).forEach(e => {
|
||||
triplets.push({
|
||||
subject: summaryId,
|
||||
relation: 'MENTIONS',
|
||||
object: e,
|
||||
confidence: 0.8
|
||||
});
|
||||
});
|
||||
|
||||
// 写入记忆图
|
||||
const commitResult = await this.db.commit({ triplets });
|
||||
|
||||
// 同时存入任务节点表
|
||||
const keyFacts = entities.slice(0, 10).map(e => `实体: ${e}`);
|
||||
keyFacts.push(`摘要: ${generatedSummary.slice(0, 100)}`);
|
||||
|
||||
await this.taskStore.createTaskNode({
|
||||
session_id: this.db.getSessionId(),
|
||||
turn_id: Date.now(),
|
||||
summary: generatedSummary,
|
||||
key_facts: keyFacts,
|
||||
raw_context: context
|
||||
});
|
||||
|
||||
return {
|
||||
extractedEntities: entities.length,
|
||||
extractedRelations: commitResult.createdRelations,
|
||||
summary: generatedSummary,
|
||||
compressed: context.length > generatedSummary.length
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Working Memory Chain ==========
|
||||
|
||||
async workingMemoryChain(params: WorkingMemoryChainParams = {}): Promise<WorkingMemoryChainResult> {
|
||||
const { maxDepth = 3, recentOnly = true } = params;
|
||||
|
||||
// 使用 TaskNode 链获取工作记忆
|
||||
const sessionId = this.db.getSessionId();
|
||||
const recentNodes = await this.taskStore.getRecentTaskNodes(sessionId, maxDepth * 3);
|
||||
|
||||
const chain = recentNodes.map(n => ({
|
||||
subject: `Turn_${n.turn_id}`,
|
||||
relation: 'summary',
|
||||
object: n.summary.slice(0, 100),
|
||||
timestamp: n.created_at
|
||||
}));
|
||||
|
||||
// 同时从图数据库获取活跃关系补充
|
||||
const timeFilter = recentOnly ? { days: 1 } : undefined;
|
||||
const graphResult = await this.db.recall({
|
||||
queryIntent: '',
|
||||
seedEntities: [],
|
||||
depth: maxDepth,
|
||||
timeRange: timeFilter,
|
||||
sessionFilter: sessionId
|
||||
});
|
||||
|
||||
const graphChain = graphResult.relations
|
||||
.filter(r => r.status === 'active')
|
||||
.slice(0, 10)
|
||||
.map(r => {
|
||||
const source = graphResult.entities.find(e => e.id === r.sourceId);
|
||||
const target = graphResult.entities.find(e => e.id === r.targetId);
|
||||
return {
|
||||
subject: source?.name || r.sourceId,
|
||||
relation: r.relationType,
|
||||
object: target?.name || r.targetId,
|
||||
timestamp: r.createdAt.toISOString()
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
chain: [...chain, ...graphChain].slice(0, 20),
|
||||
entityCount: graphResult.entities.length + recentNodes.length
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Utility ==========
|
||||
|
||||
setSessionId(sessionId: string): void {
|
||||
this.db.setSessionId(sessionId);
|
||||
}
|
||||
|
||||
183
ts/src/runtime/core/graph_memory/task_node_store.ts
Normal file
183
ts/src/runtime/core/graph_memory/task_node_store.ts
Normal file
@ -0,0 +1,183 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { TaskNodeData } from './types';
|
||||
|
||||
export { TaskNodeData as TaskNode };
|
||||
|
||||
export class TaskNodeStore {
|
||||
private db: Database.Database;
|
||||
private archiveDir: string;
|
||||
|
||||
constructor(dbPath?: string, archiveDir?: string) {
|
||||
this.db = new Database(dbPath || 'graph_memory.db');
|
||||
this.archiveDir = archiveDir || './task_archive';
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
private initialize(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS task_nodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
turn_id INTEGER NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
key_facts TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS task_chains (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
from_node_id INTEGER NOT NULL,
|
||||
to_node_id INTEGER NOT NULL,
|
||||
relation_type TEXT DEFAULT 'next',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (from_node_id) REFERENCES task_nodes(id),
|
||||
FOREIGN KEY (to_node_id) REFERENCES task_nodes(id)
|
||||
)
|
||||
`);
|
||||
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_task_nodes_session ON task_nodes(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_nodes_turn ON task_nodes(session_id, turn_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_chains_session ON task_chains(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_chains_from ON task_chains(from_node_id);
|
||||
`);
|
||||
|
||||
if (!fs.existsSync(this.archiveDir)) {
|
||||
fs.mkdirSync(this.archiveDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async createTaskNode(params: {
|
||||
session_id: string;
|
||||
turn_id: number;
|
||||
summary: string;
|
||||
key_facts: string[];
|
||||
raw_context?: string | undefined;
|
||||
}): Promise<{ node_id: number; chain_linked: boolean }> {
|
||||
const { session_id, turn_id, summary, key_facts, raw_context } = params;
|
||||
|
||||
const insert = this.db.prepare(`
|
||||
INSERT INTO task_nodes (session_id, turn_id, summary, key_facts)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
const result = insert.run(session_id, turn_id, summary, JSON.stringify(key_facts));
|
||||
const nodeId = Number(result.lastInsertRowid);
|
||||
|
||||
// Link to previous node in chain
|
||||
let chainLinked = false;
|
||||
const prevNode = this.db.prepare(`
|
||||
SELECT id FROM task_nodes
|
||||
WHERE session_id = ? AND turn_id < ?
|
||||
ORDER BY turn_id DESC LIMIT 1
|
||||
`).get(session_id, turn_id) as { id: number } | undefined;
|
||||
|
||||
if (prevNode) {
|
||||
this.db.prepare(`
|
||||
INSERT INTO task_chains (session_id, from_node_id, to_node_id, relation_type)
|
||||
VALUES (?, ?, ?, 'next')
|
||||
`).run(session_id, prevNode.id, nodeId);
|
||||
chainLinked = true;
|
||||
}
|
||||
|
||||
// Archive raw context to text file if provided
|
||||
if (raw_context) {
|
||||
const archivePath = path.join(this.archiveDir, `${session_id}_turn${turn_id}.txt`);
|
||||
fs.writeFileSync(archivePath, raw_context, 'utf-8');
|
||||
}
|
||||
|
||||
return { node_id: nodeId, chain_linked: chainLinked };
|
||||
}
|
||||
|
||||
async getRecentTaskNodes(session_id: string, limit: number = 5): Promise<TaskNodeData[]> {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT * FROM task_nodes
|
||||
WHERE session_id = ?
|
||||
ORDER BY turn_id DESC
|
||||
LIMIT ?
|
||||
`).all(session_id, limit) as Array<Record<string, unknown>>;
|
||||
|
||||
return rows.map(r => ({
|
||||
id: r.id as number,
|
||||
session_id: r.session_id as string,
|
||||
turn_id: r.turn_id as number,
|
||||
summary: r.summary as string,
|
||||
key_facts: r.key_facts as string,
|
||||
created_at: r.created_at as string
|
||||
})).reverse(); // Return in chronological order
|
||||
}
|
||||
|
||||
async getTaskChain(session_id: string, from_node_id?: number): Promise<{
|
||||
nodes: TaskNodeData[];
|
||||
relations: Array<{ from: number; to: number; type: string }>;
|
||||
}> {
|
||||
let startNode = from_node_id;
|
||||
if (!startNode) {
|
||||
const latest = this.db.prepare(`
|
||||
SELECT id FROM task_nodes WHERE session_id = ? ORDER BY turn_id DESC LIMIT 1
|
||||
`).get(session_id) as { id: number } | undefined;
|
||||
if (!latest) return { nodes: [], relations: [] };
|
||||
startNode = latest.id;
|
||||
}
|
||||
|
||||
// Walk backwards through the chain
|
||||
const nodes: TaskNodeData[] = [];
|
||||
const relations: Array<{ from: number; to: number; type: string }> = [];
|
||||
const visited = new Set<number>();
|
||||
let current = startNode;
|
||||
|
||||
while (current && !visited.has(current)) {
|
||||
visited.add(current);
|
||||
const node = this.db.prepare(`SELECT * FROM task_nodes WHERE id = ?`).get(current) as Record<string, unknown> | undefined;
|
||||
if (node) {
|
||||
nodes.unshift({
|
||||
id: node.id as number,
|
||||
session_id: node.session_id as string,
|
||||
turn_id: node.turn_id as number,
|
||||
summary: node.summary as string,
|
||||
key_facts: node.key_facts as string,
|
||||
created_at: node.created_at as string
|
||||
});
|
||||
}
|
||||
|
||||
const prevChain = this.db.prepare(`
|
||||
SELECT from_node_id, relation_type FROM task_chains WHERE to_node_id = ? AND session_id = ?
|
||||
`).get(current, session_id) as { from_node_id: number; relation_type: string } | undefined;
|
||||
|
||||
if (prevChain) {
|
||||
relations.unshift({
|
||||
from: prevChain.from_node_id,
|
||||
to: current,
|
||||
type: prevChain.relation_type
|
||||
});
|
||||
current = prevChain.from_node_id;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, relations };
|
||||
}
|
||||
|
||||
async archiveRawContext(session_id: string, turn_id: number, raw_context: string): Promise<string> {
|
||||
const archivePath = path.join(this.archiveDir, `${session_id}_turn${turn_id}.txt`);
|
||||
fs.writeFileSync(archivePath, raw_context, 'utf-8');
|
||||
return archivePath;
|
||||
}
|
||||
|
||||
async readArchivedContext(session_id: string, turn_id: number): Promise<string | null> {
|
||||
const archivePath = path.join(this.archiveDir, `${session_id}_turn${turn_id}.txt`);
|
||||
if (fs.existsSync(archivePath)) {
|
||||
return fs.readFileSync(archivePath, 'utf-8');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
@ -123,3 +123,48 @@ export interface TaskLinkInfoParams {
|
||||
task_id: string;
|
||||
info_node: string;
|
||||
}
|
||||
|
||||
export interface ContextRewriteParams {
|
||||
context: string;
|
||||
maxEntities?: number | undefined;
|
||||
summary?: string | undefined;
|
||||
}
|
||||
|
||||
export interface ContextRewriteResult {
|
||||
extractedEntities: number;
|
||||
extractedRelations: number;
|
||||
summary: string;
|
||||
compressed: boolean;
|
||||
}
|
||||
|
||||
export interface WorkingMemoryChainParams {
|
||||
maxDepth?: number | undefined;
|
||||
recentOnly?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface WorkingMemoryChainResult {
|
||||
chain: Array<{ subject: string; relation: string; object: string; timestamp: string }>;
|
||||
entityCount: number;
|
||||
}
|
||||
|
||||
export interface TaskNodeData {
|
||||
id: number;
|
||||
session_id: string;
|
||||
turn_id: number;
|
||||
summary: string;
|
||||
key_facts: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TaskNodeCreateParams {
|
||||
session_id: string;
|
||||
turn_id: number;
|
||||
summary: string;
|
||||
key_facts: string[];
|
||||
raw_context?: string | undefined;
|
||||
}
|
||||
|
||||
export interface TaskNodeChainResult {
|
||||
nodes: TaskNodeData[];
|
||||
relations: Array<{ from: number; to: number; type: string }>;
|
||||
}
|
||||
|
||||
@ -10,7 +10,9 @@ export const GraphMemoryToolSchema = Type.Object({
|
||||
enum: [
|
||||
'recall', 'commit', 'purge', 'introspect', 'archive', 'cleanup',
|
||||
'persona_update', 'persona_clear',
|
||||
'task_create', 'task_set_state', 'task_delete', 'task_link_info'
|
||||
'task_create', 'task_set_state', 'task_delete', 'task_link_info',
|
||||
'context_rewrite', 'working_memory_chain',
|
||||
'task_node_create', 'task_node_get_recent', 'task_node_get_chain'
|
||||
]
|
||||
}),
|
||||
params: Type.Object({
|
||||
@ -57,7 +59,18 @@ export const GraphMemoryToolSchema = Type.Object({
|
||||
info_nodes: Type.Optional(Type.Array(Type.String({ description: '节点' }), { description: '信息节点' })),
|
||||
info_node: Type.Optional(Type.String({ description: '信息节点' })),
|
||||
days: Type.Optional(Type.Number({ description: '归档天数' })),
|
||||
dry_run: Type.Optional(Type.Boolean({ description: '仅预览不删除' }))
|
||||
dry_run: Type.Optional(Type.Boolean({ description: '仅预览不删除' })),
|
||||
context: Type.Optional(Type.String({ description: '要压缩的上下文文本' })),
|
||||
maxEntities: Type.Optional(Type.Number({ description: '最大实体数' })),
|
||||
summary: Type.Optional(Type.String({ description: '自定义摘要' })),
|
||||
recentOnly: Type.Optional(Type.Boolean({ description: '仅最近' })),
|
||||
maxDepth: Type.Optional(Type.Number({ description: '最大深度' })),
|
||||
session_id: Type.Optional(Type.String({ description: '会话ID(TaskNode用)' })),
|
||||
turn_id: Type.Optional(Type.Number({ description: '轮次ID' })),
|
||||
key_facts: Type.Optional(Type.Array(Type.String({ description: '关键事实' }), { description: '关键事实数组' })),
|
||||
raw_context: Type.Optional(Type.String({ description: '原始上下文(存档用)' })),
|
||||
limit: Type.Optional(Type.Number({ description: '限制数量' })),
|
||||
from_node_id: Type.Optional(Type.Number({ description: '起始节点ID' }))
|
||||
}, { description: '操作参数' })
|
||||
});
|
||||
|
||||
@ -77,7 +90,12 @@ const GRAPH_MEMORY_TOOL_DESCRIPTION = `图记忆工具 - 让 AI 拥有真正的
|
||||
- task_create: 创建任务(必需参数: task_id, description; 可选: info_nodes)
|
||||
- task_set_state: 设置任务状态(必需参数: task_id, state)
|
||||
- task_delete: 删除任务(必需参数: task_id)
|
||||
- task_link_info: 关联信息(必需参数: task_id, info_node)`;
|
||||
- task_link_info: 关联信息(必需参数: task_id, info_node)
|
||||
- context_rewrite: 压缩上下文为关键记忆(必需参数: context; 可选: maxEntities, summary)
|
||||
- working_memory_chain: 获取工作记忆链(可选参数: maxDepth, recentOnly)
|
||||
- task_node_create: 创建任务节点并链接到链(必需参数: session_id, turn_id, summary, key_facts; 可选: raw_context)
|
||||
- task_node_get_recent: 获取最近N个任务节点(必需参数: session_id; 可选: limit)
|
||||
- task_node_get_chain: 获取完整任务链(必需参数: session_id; 可选: from_node_id)`;
|
||||
|
||||
// ==================== 参数验证 ====================
|
||||
|
||||
@ -228,6 +246,71 @@ function validateTaskLinkInfoParams(params: Record<string, unknown>): Validation
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateContextRewriteParams(params: Record<string, unknown>): ValidationError[] {
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
if (!params.context || typeof params.context !== 'string' || params.context.trim() === '') {
|
||||
errors.push({ field: 'context', message: 'context_rewrite 操作必需提供 context 字符串' });
|
||||
}
|
||||
if (params.maxEntities !== undefined && (typeof params.maxEntities !== 'number' || params.maxEntities < 1 || params.maxEntities > 100)) {
|
||||
errors.push({ field: 'maxEntities', message: 'maxEntities 必须在 1-100 之间' });
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateWorkingMemoryChainParams(params: Record<string, unknown>): ValidationError[] {
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
if (params.maxDepth !== undefined && (typeof params.maxDepth !== 'number' || params.maxDepth < 1 || params.maxDepth > 5)) {
|
||||
errors.push({ field: 'maxDepth', message: 'maxDepth 必须在 1-5 之间' });
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateTaskNodeCreateParams(params: Record<string, unknown>): ValidationError[] {
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
if (!params.session_id || typeof params.session_id !== 'string' || params.session_id.trim() === '') {
|
||||
errors.push({ field: 'session_id', message: 'task_node_create 操作必需提供 session_id 字符串' });
|
||||
}
|
||||
if (params.turn_id === undefined || typeof params.turn_id !== 'number') {
|
||||
errors.push({ field: 'turn_id', message: 'task_node_create 操作必需提供 turn_id 数字' });
|
||||
}
|
||||
if (!params.summary || typeof params.summary !== 'string' || params.summary.trim() === '') {
|
||||
errors.push({ field: 'summary', message: 'task_node_create 操作必需提供 summary 字符串' });
|
||||
}
|
||||
if (!params.key_facts || !Array.isArray(params.key_facts) || params.key_facts.length === 0) {
|
||||
errors.push({ field: 'key_facts', message: 'task_node_create 操作必需提供 key_facts 数组' });
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateTaskNodeGetRecentParams(params: Record<string, unknown>): ValidationError[] {
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
if (!params.session_id || typeof params.session_id !== 'string' || params.session_id.trim() === '') {
|
||||
errors.push({ field: 'session_id', message: 'task_node_get_recent 操作必需提供 session_id 字符串' });
|
||||
}
|
||||
if (params.limit !== undefined && (typeof params.limit !== 'number' || params.limit < 1 || params.limit > 100)) {
|
||||
errors.push({ field: 'limit', message: 'limit 必须在 1-100 之间' });
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateTaskNodeGetChainParams(params: Record<string, unknown>): ValidationError[] {
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
if (!params.session_id || typeof params.session_id !== 'string' || params.session_id.trim() === '') {
|
||||
errors.push({ field: 'session_id', message: 'task_node_get_chain 操作必需提供 session_id 字符串' });
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validatePersonaClearParams(params: Record<string, unknown>): ValidationError[] {
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
@ -249,6 +332,11 @@ function validateParams(action: string, params: Record<string, unknown>): Valida
|
||||
case 'task_set_state': return validateTaskSetStateParams(params);
|
||||
case 'task_delete': return validateTaskDeleteParams(params);
|
||||
case 'task_link_info': return validateTaskLinkInfoParams(params);
|
||||
case 'context_rewrite': return validateContextRewriteParams(params);
|
||||
case 'working_memory_chain': return validateWorkingMemoryChainParams(params);
|
||||
case 'task_node_create': return validateTaskNodeCreateParams(params);
|
||||
case 'task_node_get_recent': return validateTaskNodeGetRecentParams(params);
|
||||
case 'task_node_get_chain': return validateTaskNodeGetChainParams(params);
|
||||
default: return [];
|
||||
}
|
||||
}
|
||||
@ -396,6 +484,40 @@ export function createGraphMemoryTool(dbPath?: string, sessionId?: string) {
|
||||
info_node: params.info_node as string
|
||||
});
|
||||
|
||||
case 'context_rewrite':
|
||||
return service.contextRewrite({
|
||||
context: params.context as string,
|
||||
maxEntities: params.maxEntities as number | undefined,
|
||||
summary: params.summary as string | undefined
|
||||
});
|
||||
|
||||
case 'working_memory_chain':
|
||||
return service.workingMemoryChain({
|
||||
maxDepth: params.maxDepth as number | undefined,
|
||||
recentOnly: params.recentOnly as boolean | undefined
|
||||
});
|
||||
|
||||
case 'task_node_create':
|
||||
return service.createTaskNode({
|
||||
session_id: params.session_id as string,
|
||||
turn_id: params.turn_id as number,
|
||||
summary: params.summary as string,
|
||||
key_facts: params.key_facts as string[],
|
||||
raw_context: params.raw_context as string | undefined
|
||||
});
|
||||
|
||||
case 'task_node_get_recent':
|
||||
return service.getRecentTaskNodes(
|
||||
params.session_id as string,
|
||||
params.limit as number | undefined
|
||||
);
|
||||
|
||||
case 'task_node_get_chain':
|
||||
return service.getTaskChain(
|
||||
params.session_id as string,
|
||||
params.from_node_id as number | undefined
|
||||
);
|
||||
|
||||
case 'archive':
|
||||
return service.archive(params.days as number | undefined);
|
||||
|
||||
@ -471,4 +593,4 @@ export function createGraphMemoryTool(dbPath?: string, sessionId?: string) {
|
||||
};
|
||||
}
|
||||
|
||||
export { ToolLimiter } from '../tool_limiter.js';
|
||||
export { ToolLimiter } from '../tool_limiter.js';
|
||||
|
||||
200
ts/tests/runtime/core/graph_memory/memory_service_p2.test.ts
Normal file
200
ts/tests/runtime/core/graph_memory/memory_service_p2.test.ts
Normal file
@ -0,0 +1,200 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { GraphDatabase } from '/home/program/TrulyMEM-TrueHumanMEM/ts/dist/runtime/core/graph_memory/graph_database.js';
|
||||
import { MemoryService } from '/home/program/TrulyMEM-TrueHumanMEM/ts/dist/runtime/core/graph_memory/memory_service.js';
|
||||
import { TaskNodeStore } from '/home/program/TrulyMEM-TrueHumanMEM/ts/dist/runtime/core/graph_memory/task_node_store.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const TEST_DB_PATH = '/tmp/test_memory_service_p2.db';
|
||||
const TEST_ARCHIVE_DIR = '/tmp/test_context_archive';
|
||||
|
||||
describe('MemoryService P2 Advanced Features', () => {
|
||||
let memoryService: MemoryService;
|
||||
let db: GraphDatabase;
|
||||
let taskStore: TaskNodeStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean up test files
|
||||
if (fs.existsSync(TEST_DB_PATH)) {
|
||||
fs.unlinkSync(TEST_DB_PATH);
|
||||
}
|
||||
const walPath = TEST_DB_PATH + '-wal';
|
||||
const shmPath = TEST_DB_PATH + '-shm';
|
||||
if (fs.existsSync(walPath)) fs.unlinkSync(walPath);
|
||||
if (fs.existsSync(shmPath)) fs.unlinkSync(shmPath);
|
||||
|
||||
if (fs.existsSync(TEST_ARCHIVE_DIR)) {
|
||||
fs.rmSync(TEST_ARCHIVE_DIR, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
db = new GraphDatabase(TEST_DB_PATH, 'test-session-p2');
|
||||
taskStore = new TaskNodeStore(TEST_DB_PATH, TEST_ARCHIVE_DIR + '/task');
|
||||
memoryService = new MemoryService(db, taskStore, TEST_ARCHIVE_DIR);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (typeof db.close === 'function') db.close();
|
||||
if (typeof taskStore.close === 'function') taskStore.close();
|
||||
if (fs.existsSync(TEST_DB_PATH)) {
|
||||
fs.unlinkSync(TEST_DB_PATH);
|
||||
}
|
||||
if (fs.existsSync(TEST_ARCHIVE_DIR)) {
|
||||
fs.rmSync(TEST_ARCHIVE_DIR, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('context_rewrite', () => {
|
||||
it('should compress context and extract key entities', async () => {
|
||||
const longContext = `用户说:我喜欢用Python编程。最近在学习TypeScript,因为想做一个全栈项目。
|
||||
我决定采用React作为前端框架,后端用FastAPI。数据库选择PostgreSQL。
|
||||
这个决策对我来说很重要,因为我希望能快速迭代。我对性能有较高要求。
|
||||
目标是三个月内上线第一个版本。`;
|
||||
|
||||
const result = await memoryService.contextRewrite({
|
||||
context: longContext,
|
||||
maxEntities: 10
|
||||
});
|
||||
|
||||
expect(result.extractedEntities).toBeGreaterThan(0);
|
||||
expect(result.summary.length).toBeGreaterThan(0);
|
||||
expect(result.compressed).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect preferences and decisions', async () => {
|
||||
const context = `用户决定使用React而不是Vue。用户喜欢简洁的代码风格。
|
||||
用户习惯每天早上检查代码质量。用户选择PostgreSQL作为数据库。`;
|
||||
|
||||
const result = await memoryService.contextRewrite({
|
||||
context,
|
||||
summary: '用户技术偏好总结'
|
||||
});
|
||||
|
||||
expect(result.extractedRelations).toBeGreaterThan(0);
|
||||
expect(result.summary).toBe('用户技术偏好总结');
|
||||
});
|
||||
});
|
||||
|
||||
describe('working_memory_chain', () => {
|
||||
it('should retrieve working memory from task nodes', async () => {
|
||||
// First create some task nodes
|
||||
await memoryService.createTaskNode({
|
||||
session_id: 'test-session-p2',
|
||||
turn_id: 1,
|
||||
summary: '用户询问天气',
|
||||
key_facts: ['意图: 查询天气', '地点: 北京']
|
||||
});
|
||||
|
||||
await memoryService.createTaskNode({
|
||||
session_id: 'test-session-p2',
|
||||
turn_id: 2,
|
||||
summary: '用户询问交通',
|
||||
key_facts: ['意图: 查询交通', '地点: 北京']
|
||||
});
|
||||
|
||||
const result = await memoryService.workingMemoryChain({
|
||||
maxDepth: 2,
|
||||
recentOnly: true
|
||||
});
|
||||
|
||||
expect(result.chain.length).toBeGreaterThan(0);
|
||||
expect(result.entityCount).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('task_node chain', () => {
|
||||
it('should create task nodes and link them in chain', async () => {
|
||||
const node1 = await memoryService.createTaskNode({
|
||||
session_id: 'chain-test',
|
||||
turn_id: 1,
|
||||
summary: '开始对话',
|
||||
key_facts: ['fact1', 'fact2'],
|
||||
raw_context: '用户: 你好\nAI: 你好!有什么可以帮你的?'
|
||||
});
|
||||
|
||||
expect(node1.node_id).toBeDefined();
|
||||
expect(node1.chain_linked).toBe(false); // First node
|
||||
|
||||
const node2 = await memoryService.createTaskNode({
|
||||
session_id: 'chain-test',
|
||||
turn_id: 2,
|
||||
summary: '用户询问编程',
|
||||
key_facts: ['fact3'],
|
||||
raw_context: '用户: 我想学编程\nAI: 太好了!你想学什么语言?'
|
||||
});
|
||||
|
||||
expect(node2.node_id).toBeDefined();
|
||||
expect(node2.chain_linked).toBe(true); // Linked to first node
|
||||
});
|
||||
|
||||
it('should get recent task nodes', async () => {
|
||||
await memoryService.createTaskNode({
|
||||
session_id: 'recent-test',
|
||||
turn_id: 1,
|
||||
summary: 'Node 1',
|
||||
key_facts: ['fact1']
|
||||
});
|
||||
|
||||
await memoryService.createTaskNode({
|
||||
session_id: 'recent-test',
|
||||
turn_id: 2,
|
||||
summary: 'Node 2',
|
||||
key_facts: ['fact2']
|
||||
});
|
||||
|
||||
await memoryService.createTaskNode({
|
||||
session_id: 'recent-test',
|
||||
turn_id: 3,
|
||||
summary: 'Node 3',
|
||||
key_facts: ['fact3']
|
||||
});
|
||||
|
||||
const recent = await memoryService.getRecentTaskNodes('recent-test', 2);
|
||||
expect(recent.length).toBe(2);
|
||||
expect(recent[0].turn_id).toBe(2); // Chronological order
|
||||
expect(recent[1].turn_id).toBe(3);
|
||||
});
|
||||
|
||||
it('should get full task chain', async () => {
|
||||
await memoryService.createTaskNode({
|
||||
session_id: 'chain-full-test',
|
||||
turn_id: 1,
|
||||
summary: 'Start',
|
||||
key_facts: ['start']
|
||||
});
|
||||
|
||||
await memoryService.createTaskNode({
|
||||
session_id: 'chain-full-test',
|
||||
turn_id: 2,
|
||||
summary: 'Middle',
|
||||
key_facts: ['middle']
|
||||
});
|
||||
|
||||
await memoryService.createTaskNode({
|
||||
session_id: 'chain-full-test',
|
||||
turn_id: 3,
|
||||
summary: 'End',
|
||||
key_facts: ['end']
|
||||
});
|
||||
|
||||
const chain = await memoryService.getTaskChain('chain-full-test');
|
||||
expect(chain.nodes.length).toBe(3);
|
||||
expect(chain.relations.length).toBe(2); // 3 nodes = 2 next relations
|
||||
expect(chain.relations[0].type).toBe('next');
|
||||
});
|
||||
|
||||
it('should archive and read raw context', async () => {
|
||||
const rawContext = '这是一个很长的对话记录...包含很多细节...';
|
||||
|
||||
await memoryService.createTaskNode({
|
||||
session_id: 'archive-test',
|
||||
turn_id: 1,
|
||||
summary: '对话摘要',
|
||||
key_facts: ['fact1'],
|
||||
raw_context: rawContext
|
||||
});
|
||||
|
||||
const readBack = await memoryService.readArchivedContext('archive-test', 1);
|
||||
expect(readBack).toBe(rawContext);
|
||||
});
|
||||
});
|
||||
});
|
||||
155
ts/tests/runtime/core/graph_memory/task_node_store.test.ts
Normal file
155
ts/tests/runtime/core/graph_memory/task_node_store.test.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TaskNodeStore } from '/home/program/TrulyMEM-TrueHumanMEM/ts/dist/runtime/core/graph_memory/task_node_store.js';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const TEST_DB_PATH = '/tmp/test_task_node_store.db';
|
||||
const TEST_ARCHIVE_DIR = '/tmp/test_task_archive';
|
||||
|
||||
describe('TaskNodeStore', () => {
|
||||
let store: TaskNodeStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
if (fs.existsSync(TEST_DB_PATH)) {
|
||||
fs.unlinkSync(TEST_DB_PATH);
|
||||
}
|
||||
const walPath = TEST_DB_PATH + '-wal';
|
||||
const shmPath = TEST_DB_PATH + '-shm';
|
||||
if (fs.existsSync(walPath)) fs.unlinkSync(walPath);
|
||||
if (fs.existsSync(shmPath)) fs.unlinkSync(shmPath);
|
||||
|
||||
if (fs.existsSync(TEST_ARCHIVE_DIR)) {
|
||||
fs.rmSync(TEST_ARCHIVE_DIR, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
store = new TaskNodeStore(TEST_DB_PATH, TEST_ARCHIVE_DIR);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (typeof store.close === 'function') store.close();
|
||||
if (fs.existsSync(TEST_DB_PATH)) {
|
||||
fs.unlinkSync(TEST_DB_PATH);
|
||||
}
|
||||
if (fs.existsSync(TEST_ARCHIVE_DIR)) {
|
||||
fs.rmSync(TEST_ARCHIVE_DIR, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('createTaskNode', () => {
|
||||
it('should create a task node', async () => {
|
||||
const result = await store.createTaskNode({
|
||||
session_id: 'session-1',
|
||||
turn_id: 1,
|
||||
summary: 'Test summary',
|
||||
key_facts: ['fact1', 'fact2']
|
||||
});
|
||||
|
||||
expect(result.node_id).toBeDefined();
|
||||
expect(result.chain_linked).toBe(false);
|
||||
});
|
||||
|
||||
it('should link nodes in chain', async () => {
|
||||
await store.createTaskNode({
|
||||
session_id: 'chain-session',
|
||||
turn_id: 1,
|
||||
summary: 'First',
|
||||
key_facts: ['fact1']
|
||||
});
|
||||
|
||||
const result = await store.createTaskNode({
|
||||
session_id: 'chain-session',
|
||||
turn_id: 2,
|
||||
summary: 'Second',
|
||||
key_facts: ['fact2']
|
||||
});
|
||||
|
||||
expect(result.chain_linked).toBe(true);
|
||||
});
|
||||
|
||||
it('should archive raw context', async () => {
|
||||
const rawContext = 'This is a long context...';
|
||||
await store.createTaskNode({
|
||||
session_id: 'archive-session',
|
||||
turn_id: 1,
|
||||
summary: 'Archived',
|
||||
key_facts: ['fact1'],
|
||||
raw_context: rawContext
|
||||
});
|
||||
|
||||
const readBack = await store.readArchivedContext('archive-session', 1);
|
||||
expect(readBack).toBe(rawContext);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecentTaskNodes', () => {
|
||||
it('should return recent nodes in chronological order', async () => {
|
||||
await store.createTaskNode({
|
||||
session_id: 'recent-session',
|
||||
turn_id: 1,
|
||||
summary: 'One',
|
||||
key_facts: ['fact1']
|
||||
});
|
||||
await store.createTaskNode({
|
||||
session_id: 'recent-session',
|
||||
turn_id: 2,
|
||||
summary: 'Two',
|
||||
key_facts: ['fact2']
|
||||
});
|
||||
await store.createTaskNode({
|
||||
session_id: 'recent-session',
|
||||
turn_id: 3,
|
||||
summary: 'Three',
|
||||
key_facts: ['fact3']
|
||||
});
|
||||
|
||||
const recent = await store.getRecentTaskNodes('recent-session', 2);
|
||||
expect(recent.length).toBe(2);
|
||||
expect(recent[0].turn_id).toBe(2);
|
||||
expect(recent[1].turn_id).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTaskChain', () => {
|
||||
it('should walk full chain backwards', async () => {
|
||||
const n1 = await store.createTaskNode({
|
||||
session_id: 'walk-session',
|
||||
turn_id: 1,
|
||||
summary: 'Start',
|
||||
key_facts: ['start']
|
||||
});
|
||||
const n2 = await store.createTaskNode({
|
||||
session_id: 'walk-session',
|
||||
turn_id: 2,
|
||||
summary: 'Middle',
|
||||
key_facts: ['middle']
|
||||
});
|
||||
await store.createTaskNode({
|
||||
session_id: 'walk-session',
|
||||
turn_id: 3,
|
||||
summary: 'End',
|
||||
key_facts: ['end']
|
||||
});
|
||||
|
||||
const chain = await store.getTaskChain('walk-session');
|
||||
expect(chain.nodes.length).toBe(3);
|
||||
expect(chain.relations.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should walk chain from specific node', async () => {
|
||||
await store.createTaskNode({
|
||||
session_id: 'from-session',
|
||||
turn_id: 1,
|
||||
summary: 'A',
|
||||
key_facts: ['a']
|
||||
});
|
||||
const n2 = await store.createTaskNode({
|
||||
session_id: 'from-session',
|
||||
turn_id: 2,
|
||||
summary: 'B',
|
||||
key_facts: ['b']
|
||||
});
|
||||
|
||||
const chain = await store.getTaskChain('from-session', n2.node_id);
|
||||
expect(chain.nodes.length).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -575,8 +575,41 @@ describe('GraphMemoryTool', () => {
|
||||
expect(parsed.error.message).toContain('newRelation');
|
||||
});
|
||||
|
||||
it('should reject context_rewrite without context', async () => {
|
||||
const result = await tool.execute('call-val-12', {
|
||||
action: 'context_rewrite',
|
||||
params: {}
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.success).toBe(false);
|
||||
expect(parsed.error.message).toContain('context');
|
||||
});
|
||||
|
||||
it('should reject context_rewrite with invalid maxEntities', async () => {
|
||||
const result = await tool.execute('call-val-13', {
|
||||
action: 'context_rewrite',
|
||||
params: { context: '测试', maxEntities: 200 }
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.success).toBe(false);
|
||||
expect(parsed.error.message).toContain('maxEntities');
|
||||
});
|
||||
|
||||
it('should reject working_memory_chain with invalid maxDepth', async () => {
|
||||
const result = await tool.execute('call-val-14', {
|
||||
action: 'working_memory_chain',
|
||||
params: { maxDepth: 10 }
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.success).toBe(false);
|
||||
expect(parsed.error.message).toContain('maxDepth');
|
||||
});
|
||||
|
||||
it('should reject missing action', async () => {
|
||||
const result = await tool.execute('call-val-11', {
|
||||
const result = await tool.execute('call-val-15', {
|
||||
action: '',
|
||||
params: {}
|
||||
});
|
||||
@ -586,4 +619,74 @@ describe('GraphMemoryTool', () => {
|
||||
expect(parsed.error.message).toContain('action');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Context rewrite', () => {
|
||||
it('should compress context and extract entities', async () => {
|
||||
const longContext = '我们在开发一个项目。这个项目使用 TypeScript。TypeScript 是 JavaScript 的超集。我们在写代码。代码在仓库里。仓库用 Git 管理。Git 是版本控制系统。';
|
||||
|
||||
const result = await tool.execute('call-cr-1', {
|
||||
action: 'context_rewrite',
|
||||
params: {
|
||||
context: longContext,
|
||||
maxEntities: 10
|
||||
}
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.data.extractedEntities).toBeGreaterThan(0);
|
||||
expect(parsed.data.summary).toBeTruthy();
|
||||
expect(parsed.data.compressed).toBe(true);
|
||||
});
|
||||
|
||||
it('should use provided summary', async () => {
|
||||
const result = await tool.execute('call-cr-2', {
|
||||
action: 'context_rewrite',
|
||||
params: {
|
||||
context: '测试文本',
|
||||
summary: '用户自定义摘要'
|
||||
}
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.data.summary).toBe('用户自定义摘要');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Working memory chain', () => {
|
||||
it('should return working memory chain', async () => {
|
||||
// 先写入一些数据
|
||||
await tool.execute('call-wmc-1', {
|
||||
action: 'commit',
|
||||
params: {
|
||||
triplets: [
|
||||
{ subject: '用户', relation: '喜欢', object: 'Python' },
|
||||
{ subject: '用户', relation: '正在学习', object: 'TypeScript' }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const result = await tool.execute('call-wmc-2', {
|
||||
action: 'working_memory_chain',
|
||||
params: {}
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.data.chain).toBeDefined();
|
||||
expect(Array.isArray(parsed.data.chain)).toBe(true);
|
||||
});
|
||||
|
||||
it('should respect maxDepth parameter', async () => {
|
||||
const result = await tool.execute('call-wmc-3', {
|
||||
action: 'working_memory_chain',
|
||||
params: { maxDepth: 2 }
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.data.entityCount).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitReturns": true,
|
||||
|
||||
Reference in New Issue
Block a user