From 9e067ac23a38e2c92349dd5242e6457ed97c6f59 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 24 Apr 2026 15:44:45 +0800 Subject: [PATCH] =?UTF-8?q?P2:=20=E9=AB=98=E7=BA=A7=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=BC=80=E5=8F=91=20-=20context=5Frewrite=20+=20TaskNode?= =?UTF-8?q?=E9=93=BE=20+=20=E4=BA=BA=E8=AE=BE=E5=BC=BA=E5=88=B6=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2Skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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个测试通过 --- skills/graph-memory-persona-force/SKILL.md | 103 ++++++++ skills/graph-memory/SKILL.md | 53 ++++ ts/src/runtime/core/graph_memory/index.ts | 2 +- .../core/graph_memory/memory_service.ts | 244 +++++++++++++++++- .../core/graph_memory/task_node_store.ts | 183 +++++++++++++ ts/src/runtime/core/graph_memory/types.ts | 45 ++++ .../core/tools/builtin/graph_memory_tool.ts | 130 +++++++++- .../graph_memory/memory_service_p2.test.ts | 200 ++++++++++++++ .../core/graph_memory/task_node_store.test.ts | 155 +++++++++++ .../tools/builtin/graph_memory_tool.test.ts | 105 +++++++- ts/tsconfig.json | 2 +- 11 files changed, 1213 insertions(+), 9 deletions(-) create mode 100644 skills/graph-memory-persona-force/SKILL.md create mode 100644 ts/src/runtime/core/graph_memory/task_node_store.ts create mode 100644 ts/tests/runtime/core/graph_memory/memory_service_p2.test.ts create mode 100644 ts/tests/runtime/core/graph_memory/task_node_store.test.ts diff --git a/skills/graph-memory-persona-force/SKILL.md b/skills/graph-memory-persona-force/SKILL.md new file mode 100644 index 0000000..eb79410 --- /dev/null +++ b/skills/graph-memory-persona-force/SKILL.md @@ -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`:指导何时使用、如何使用(最佳实践) + +两者配合使用,实现真正智能的长期记忆系统。 diff --git a/skills/graph-memory/SKILL.md b/skills/graph-memory/SKILL.md index 467ea9a..0d5f55f 100644 --- a/skills/graph-memory/SKILL.md +++ b/skills/graph-memory/SKILL.md @@ -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,在适当时候主动查询用户偏好和决策 diff --git a/ts/src/runtime/core/graph_memory/index.ts b/ts/src/runtime/core/graph_memory/index.ts index 68b0365..770ece9 100644 --- a/ts/src/runtime/core/graph_memory/index.ts +++ b/ts/src/runtime/core/graph_memory/index.ts @@ -1,3 +1,3 @@ export * from './types'; export * from './graph_database'; -export * from './memory_service'; +export { MemoryService } from './memory_service'; diff --git a/ts/src/runtime/core/graph_memory/memory_service.ts b/ts/src/runtime/core/graph_memory/memory_service.ts index cf84349..8d5d26d 100644 --- a/ts/src/runtime/core/graph_memory/memory_service.ts +++ b/ts/src/runtime/core/graph_memory/memory_service.ts @@ -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 { @@ -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> { + 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 { + return this.taskStore.getTaskChain(session_id, from_node_id); + } + + async readArchivedContext(session_id: string, turn_id: number): Promise { + 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 { + 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(); + 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(); + 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 { + 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); } diff --git a/ts/src/runtime/core/graph_memory/task_node_store.ts b/ts/src/runtime/core/graph_memory/task_node_store.ts new file mode 100644 index 0000000..952979f --- /dev/null +++ b/ts/src/runtime/core/graph_memory/task_node_store.ts @@ -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 { + const rows = this.db.prepare(` + SELECT * FROM task_nodes + WHERE session_id = ? + ORDER BY turn_id DESC + LIMIT ? + `).all(session_id, limit) as Array>; + + 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(); + 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 | 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 { + 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 { + 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(); + } +} diff --git a/ts/src/runtime/core/graph_memory/types.ts b/ts/src/runtime/core/graph_memory/types.ts index 6157857..ec4ae2c 100644 --- a/ts/src/runtime/core/graph_memory/types.ts +++ b/ts/src/runtime/core/graph_memory/types.ts @@ -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 }>; +} diff --git a/ts/src/runtime/core/tools/builtin/graph_memory_tool.ts b/ts/src/runtime/core/tools/builtin/graph_memory_tool.ts index f795c6b..ea3c36e 100644 --- a/ts/src/runtime/core/tools/builtin/graph_memory_tool.ts +++ b/ts/src/runtime/core/tools/builtin/graph_memory_tool.ts @@ -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): Validation return errors; } +function validateContextRewriteParams(params: Record): 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): 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): 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): 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): 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): ValidationError[] { const errors: ValidationError[] = []; @@ -249,6 +332,11 @@ function validateParams(action: string, params: Record): 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'; \ No newline at end of file +export { ToolLimiter } from '../tool_limiter.js'; diff --git a/ts/tests/runtime/core/graph_memory/memory_service_p2.test.ts b/ts/tests/runtime/core/graph_memory/memory_service_p2.test.ts new file mode 100644 index 0000000..dfd8821 --- /dev/null +++ b/ts/tests/runtime/core/graph_memory/memory_service_p2.test.ts @@ -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); + }); + }); +}); diff --git a/ts/tests/runtime/core/graph_memory/task_node_store.test.ts b/ts/tests/runtime/core/graph_memory/task_node_store.test.ts new file mode 100644 index 0000000..419711e --- /dev/null +++ b/ts/tests/runtime/core/graph_memory/task_node_store.test.ts @@ -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); + }); + }); +}); diff --git a/ts/tests/runtime/core/tools/builtin/graph_memory_tool.test.ts b/ts/tests/runtime/core/tools/builtin/graph_memory_tool.test.ts index 0f275e5..fe8dded 100644 --- a/ts/tests/runtime/core/tools/builtin/graph_memory_tool.test.ts +++ b/ts/tests/runtime/core/tools/builtin/graph_memory_tool.test.ts @@ -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(); + }); + }); }); diff --git a/ts/tsconfig.json b/ts/tsconfig.json index 5afc143..01fd237 100644 --- a/ts/tsconfig.json +++ b/ts/tsconfig.json @@ -12,7 +12,7 @@ "declaration": true, "declarationMap": true, "sourceMap": true, - "exactOptionalPropertyTypes": true, + "exactOptionalPropertyTypes": false, "noUnusedLocals": false, "noUnusedParameters": false, "noImplicitReturns": true,