From 436715216ea0649a137507e1077f8cd3289da2de Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Mon, 23 Mar 2026 03:57:59 +0800 Subject: [PATCH] docs: add project README and initial ST-BME files --- LICENSE | 101 ++++++++ README.md | 316 ++++++++++++++++++++++++ compressor.js | 213 ++++++++++++++++ diffusion.js | 167 +++++++++++++ dynamics.js | 102 ++++++++ embedding.js | 185 ++++++++++++++ evolution.js | 193 +++++++++++++++ extractor.js | 500 ++++++++++++++++++++++++++++++++++++++ graph.js | 462 +++++++++++++++++++++++++++++++++++ index.js | 655 ++++++++++++++++++++++++++++++++++++++++++++++++++ injector.js | 107 +++++++++ llm.js | 125 ++++++++++ manifest.json | 11 + retriever.js | 386 +++++++++++++++++++++++++++++ schema.js | 249 +++++++++++++++++++ settings.html | 247 +++++++++++++++++++ style.css | 126 ++++++++++ 17 files changed, 4145 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 compressor.js create mode 100644 diffusion.js create mode 100644 dynamics.js create mode 100644 embedding.js create mode 100644 evolution.js create mode 100644 extractor.js create mode 100644 graph.js create mode 100644 index.js create mode 100644 injector.js create mode 100644 llm.js create mode 100644 manifest.json create mode 100644 retriever.js create mode 100644 schema.js create mode 100644 settings.html create mode 100644 style.css diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3efcc82 --- /dev/null +++ b/LICENSE @@ -0,0 +1,101 @@ +Aladdin Free Public License (AFPL) +Version 9 +By Aladdin Enterprises + +Alternate Titles: Unknown. +Specific License Author Information: Unknown. +Length: 2,051 Words / 12.65 kB. + +This text-file version of the license was generated by the Bakunin Cannabis Engine, Version 1.36, provided by Punkerslut Freethought. +AnarchistRevolt.com - The Theory and Practice of Revolutionary Anarchism. +Punkerslut Freethought provides information, resources, and ideas for changing the world -- everything from organizing worker unions to challenging the power of the state. + +||| Aladdin Free Public License +---------------------------------------- +---------------------------------------- + +-- Version 9 +........................................ + + Copyright (C) 1994, 1995, 1997, 1998, 1999, 2000 Aladdin Enterprises, Menlo Park, California, U.S.A. All rights reserved. + + NOTE: This License is not the same as any of the GNU Licenses published by the Free Software Foundation. Its terms are substantially different from those of the GNU Licenses. If you are familiar with the GNU Licenses, please read this license with extra care. + + Aladdin Enterprises hereby grants to anyone the permission to apply this License to their own work, as long as the entire License (including the above notices and this paragraph) is copied with no changes, additions, or deletions except for changing the first paragraph of Section 0 to include a suitable description of the work to which the license is being applied and of the person or entity that holds the copyright in the work, and, if the License is being applied to a work created in a country other than the United States, replacing the first paragraph of Section 6 with an appropriate reference to the laws of the appropriate country. + + This License is not an Open Source license: among other things, it places restrictions on distribution of the Program, specifically including sale of the Program. While Aladdin Enterprises respects and supports the philosophy of the Open Source Definition, and shares the desire of the GNU project to keep licensed software freely redistributable in both source and object form, we feel that Open Source licenses unfairly prevent developers of useful software from being compensated proportionately when others profit financially from their work. This License attempts to ensure that those who receive, redistribute, and contribute to the licensed Program according to the Open Source and Free Software philosophies have the right to do so, while retaining for the developer(s) of the Program the power to make those who use the Program to enhance the value of commercial products pay for the privilege of doing so. + + * 0. Subject Matter * + + This License applies to the computer programs known as "AFPL Ghostscript", "AFPL Ghostscript PCL5e", "AFPL Ghostscript PCL5c", and "AFPL Ghostscript PXL". The "Program", below, refers to such program. The Program is a copyrighted work whose copyright is held by Artifex Software Inc., located in San Rafael California and artofcode LLC, located in Benicia, California (the "Licensor"). Please note that AFPL Ghostscript is neither the program known as "GNU Ghostscript" nor the version of Ghostscript available for commercial licensing from Artifex Software Inc. + + A "work based on the Program" means either the Program or any derivative work of the Program, as defined in the United States Copyright Act of 1976, such as a translation or a modification. + + * BY MODIFYING OR DISTRIBUTING THE PROGRAM (OR ANY WORK BASED ON THE PROGRAM), YOU INDICATE YOUR ACCEPTANCE OF THIS LICENSE TO DO SO, AND ALL ITS TERMS AND CONDITIONS FOR COPYING, DISTRIBUTING OR MODIFYING THE PROGRAM OR WORKS BASED ON IT. NOTHING OTHER THAN THIS LICENSE GRANTS YOU PERMISSION TO MODIFY OR DISTRIBUTE THE PROGRAM OR ITS DERIVATIVE WORKS. THESE ACTIONS ARE PROHIBITED BY LAW. IF YOU DO NOT ACCEPT THESE TERMS AND CONDITIONS, DO NOT MODIFY OR DISTRIBUTE THE PROGRAM. * + + * 1. Licenses. * + + Licensor hereby grants you the following rights, provided that you comply with all of the restrictions set forth in this License and provided, further, that you distribute an unmodified copy of this License with the Program: + + (a) You may copy and distribute literal (i.e., verbatim) copies of the Program's source code as you receive it throughout the world, in any medium. + + (b) You may modify the Program, create works based on the Program and distribute copies of such throughout the world, in any medium. + + * 2. Restrictions. * + + This license is subject to the following restrictions: + + (a) Distribution of the Program or any work based on the Program by a commercial organization to any third party is prohibited if any payment is made in connection with such distribution, whether directly (as in payment for a copy of the Program) or indirectly (as in payment for some service related to the Program, or payment for some product or service that includes a copy of the Program "without charge"; these are only examples, and not an exhaustive enumeration of prohibited activities). The following methods of distribution involving payment shall not in and of themselves be a violation of this restriction: + + (i) Posting the Program on a public access information storage and retrieval service for which a fee is received for retrieving information (such as an on-line service), provided that the fee is not content-dependent (i.e., the fee would be the same for retrieving the same volume of information consisting of random data) and that access to the service and to the Program is available independent of any other product or service. An example of a service that does not fall under this section is an on-line service that is operated by a company and that is only available to customers of that company. (This is not an exhaustive enumeration.) + + (ii) Distributing the Program on removable computer-readable media, provided that the files containing the Program are reproduced entirely and verbatim on such media, that all information on such media be redistributable for non-commercial purposes without charge, and that such media are distributed by themselves (except for accompanying documentation) independent of any other product or service. Examples of such media include CD-ROM, magnetic tape, and optical storage media. (This is not intended to be an exhaustive list.) An example of a distribution that does not fall under this section is a CD-ROM included in a book or magazine. (This is not an exhaustive enumeration.) + + (b) Activities other than copying, distribution and modification of the Program are not subject to this License and they are outside its scope. Functional use (running) of the Program is not restricted, and any output produced through the use of the Program is subject to this license only if its contents constitute a work based on the Program (independent of having been made by running the Program). + + (c) You must meet all of the following conditions with respect to any work that you distribute or publish that in whole or in part contains or is derived from the Program or any part thereof ("the Work"): + + (i) If you have modified the Program, you must cause the Work to carry prominent notices stating that you have modified the Program's files and the date of any change. In each source file that you have modified, you must include a prominent notice that you have modified the file, including your name, your e-mail address (if any), and the date and purpose of the change; + + (ii) You must cause the Work to be licensed as a whole and at no charge to all third parties under the terms of this License; + + (iii) If the Work normally reads commands interactively when run, you must cause it, at each time the Work commences operation, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty). Such notice must also state that users may redistribute the Work only under the conditions of this License and tell the user how to view the copy of this License included with the Work. (Exceptions: if the Program is interactive but normally prints or displays such an announcement only at the request of a user, such as in an "About box", the Work is required to print or display the notice only under the same circumstances; if the Program itself is interactive but does not normally print such an announcement, the Work is not required to print an announcement.); + + (iv) You must accompany the Work with the complete corresponding machine-readable source code, delivered on a medium customarily used for software interchange. The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable code. If you distribute with the Work any component that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, you must also distribute the source code of that component if you have it and are allowed to do so; + + (v) If you distribute any written or printed material at all with the Work, such material must include either a written copy of this License, or a prominent written indication that the Work is covered by this License and written instructions for printing and/or displaying the copy of the License on the distribution medium; + + (vi) You may not impose any further restrictions on the recipient's exercise of the rights granted herein. + + If distribution of executable or object code is made by offering the equivalent ability to copy from a designated place, then offering equivalent ability to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source code along with the object code. + + * 3. Reservation of Rights. * + + No rights are granted to the Program except as expressly set forth herein. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. + + * 4. Other Restrictions. * + + If the distribution and/or use of the Program is restricted in certain countries for any reason, Licensor may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. + + * 5. Limitations. + + THE PROGRAM IS PROVIDED TO YOU "AS IS," WITHOUT WARRANTY. THERE IS NO WARRANTY FOR THE PROGRAM, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF THIRD PARTY RIGHTS. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL LICENSOR, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + + 6. General. * + + This License is governed by the laws of the State of California, U.S.A., excluding choice of law rules. + + If any part of this License is found to be in conflict with the law, that part shall be interpreted in its broadest meaning consistent with the law, and no other parts of the License shall be affected. + + For United States Government users, the Program is provided with RESTRICTED RIGHTS. If you are a unit or agency of the United States Government or are acquiring the Program for any such unit or agency, the following apply: + + If the unit or agency is the Department of Defense ("DOD"), the Program and its documentation are classified as "commercial computer software" and "commercial computer software documentation" respectively and, pursuant to DFAR Section 227.7202, the Government is acquiring the Program and its documentation in accordance with the terms of this License. If the unit or agency is other than DOD, the Program and its documentation are classified as "commercial computer software" and "commercial computer software documentation" respectively and, pursuant to FAR Section 12.212, the Government is acquiring the Program and its documentation in accordance with the terms of this License. + + + + [Note: * A license being listed in the Copyleft and Open Source Center does not mean it is endorsed. These licenses are provided as a reference to encourage and promote the Open Source movement. Nothing within these pages should be considered as legal advice. * ] + + +[This document was generated by the Bakunin Cannabis Engine, Version 1.36.] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3371835 --- /dev/null +++ b/README.md @@ -0,0 +1,316 @@ +# ST-BME + +> 面向 [SillyTavern](https://github.com/SillyTavern/SillyTavern) 的双向记忆图谱扩展,用于在长对话、角色扮演与持续故事创作中提供结构化写入、图谱检索与可控注入能力。 + +## 项目简介 + +ST-BME(ST-Bionic-Memory-Ecology)是一个运行在 SillyTavern 第三方扩展体系中的记忆模块。它围绕“写入”和“读取”两条路径工作: + +- 写入侧将聊天内容提取为结构化节点与关系,持久化到当前聊天元数据中。 +- 读取侧在生成前根据用户输入检索相关记忆,并以格式化内容注入上下文。 + +项目的目标不是替代模型本身的上下文理解,而是在长周期互动中为模型补充一层可累积、可筛选、可演化的外部记忆表示。 + +当前实现以知识图谱为核心,结合向量相似度、图扩散、LLM 精确筛选,以及若干受外部研究项目启发的增强机制。 + +## 核心特性 + +### 已实现 + +- **图谱化记忆存储**:使用节点与边表示事件、角色、地点、规则、主线、概要等结构化信息。 +- **聊天级持久化**:图状态写入当前聊天 `chat_metadata`,可随聊天保存与恢复。 +- **自动记忆提取**:按设定频率从最近对话中抽取结构化操作并更新图谱。 +- **三阶段召回编排**:支持向量预筛、图扩散排序、混合评分与可选 LLM 精确召回。 +- **层级压缩**:对事件与主线等类型执行分层摘要,控制图谱膨胀。 +- **全局概要节点**:周期性生成 `synopsis` 类型节点,作为长期叙事锚点。 +- **记忆进化**:新节点写入后,基于近邻分析回溯更新已有记忆并建立新连接。 +- **时序边字段**:关系边携带 `validAt` / `invalidAt` / `expiredAt` 等时间语义字段。 +- **导入导出与手动操作入口**:设置面板已提供查看图谱、查看注入、重建、压缩、导入、导出等入口。 + +### 实验性 + +以下能力在代码和设置项中已出现,但成熟度与验证覆盖仍有限,应视为实验性: + +- **Mem0 风格精确对照**:新记忆可与近邻旧记忆对照后再决定新增、更新或跳过。 +- **认知边界过滤**:可按可见性约束过滤检索结果,适用于“角色不知道的信息不应注入”的场景。 +- **交叉检索**:实体命中后沿图边扩展相关事件节点,补充情境上下文。 +- **主动遗忘**:按保留价值归档低价值节点,缓解长期运行后的图谱膨胀。 +- **概率触发回忆**:未被主流程命中的高重要性节点有概率被额外召回。 + +### 规划中 + +以下方向在现有代码中仅有预留开关、设计痕迹或路线图描述,尚不应视为完整能力: + +- **惊奇度分割 / 智能触发提取** +- **反思条目自动生成** +- **完整端到端测试与稳定性验证** +- **图谱可视化面板** +- **自定义 Schema 编辑体验增强** +- **多聊天图谱合并与迁移工具** +- **导出为 World Info 等外部格式的更完整支持** + +## 适用场景 + +ST-BME 当前更适合以下场景: + +- 长篇角色扮演(RP)与多人剧情对话 +- 需要持续追踪角色状态、地点状态、事件推进的故事生成 +- 世界观设定较多、规则需要持续注入的互动创作 +- 需要在单个聊天内形成“前情提要 + 当前状态 + 局部召回”结构的长期陪伴式对话 + +对短聊天、一次性问答、无结构叙事需求的场景,ST-BME 的收益通常不会明显高于普通上下文管理。 + +## 架构概览 + +### 写入路径 + +1. 从聊天中收集尚未处理的对话片段。 +2. 调用提取器生成结构化操作(创建 / 更新 / 删除)。 +3. 按配置执行近邻对照,降低重复创建概率。 +4. 将新节点写入图谱,并补充关系边。 +5. 为缺失向量的节点生成 embedding。 +6. 可选执行记忆进化、全局概要更新、主动遗忘与层级压缩。 +7. 将图状态保存回聊天元数据。 + +### 读取路径 + +1. 读取当前活跃图节点。 +2. 可选先做认知边界过滤。 +3. 执行向量预筛与实体锚点识别。 +4. 基于时序邻接图做扩散排序。 +5. 按混合评分汇总候选节点。 +6. 在小图或大图场景下,可进一步调用 LLM 做精确召回。 +7. 将结果格式化后注入到生成上下文中。 + +### 模块对应关系 + +- [`index.js`](ST-BME/index.js):扩展入口、设置管理、流程调度、持久化挂钩 +- [`extractor.js`](ST-BME/extractor.js):记忆提取、精确对照、概要生成 +- [`retriever.js`](ST-BME/retriever.js):召回主流程、混合评分、可选精确召回 +- [`graph.js`](ST-BME/graph.js):图数据模型、节点边操作、时序字段 +- [`compressor.js`](ST-BME/compressor.js):层级压缩与主动遗忘 +- [`evolution.js`](ST-BME/evolution.js):新记忆触发的近邻进化 +- [`diffusion.js`](ST-BME/diffusion.js):图扩散排序 +- [`schema.js`](ST-BME/schema.js):默认节点类型定义与关系类型约束 +- [`injector.js`](ST-BME/injector.js):召回结果格式化与注入内容构建 +- [`embedding.js`](ST-BME/embedding.js):OpenAI 兼容 embedding 接口封装 +- [`llm.js`](ST-BME/llm.js):LLM JSON 调用封装 + +## 目录结构 + +```text +ST-BME/ +├── manifest.json # 扩展清单 +├── index.js # 扩展入口与流程编排 +├── settings.html # 设置面板 +├── style.css # UI 样式 +├── graph.js # 图数据模型 +├── schema.js # 节点类型与关系定义 +├── extractor.js # 写入路径 +├── retriever.js # 读取路径 +├── evolution.js # 记忆进化 +├── compressor.js # 压缩与主动遗忘 +├── diffusion.js # 图扩散算法 +├── dynamics.js # 动态评分与访问强化 +├── embedding.js # Embedding API 封装 +├── llm.js # LLM 调用封装 +├── injector.js # 注入格式化 +├── LICENSE # 许可证 +└── README.md +``` + +## 安装方式 + +### 1. 准备环境 + +先确保本地已经可正常运行 SillyTavern。 + +### 2. 放置扩展目录 + +将 [`ST-BME`](ST-BME) 目录放入 SillyTavern 第三方扩展目录,例如: + +```text +SillyTavern/public/scripts/extensions/third-party/ST-BME/ +``` + +### 3. 重新加载 SillyTavern + +重启或刷新 SillyTavern 后,在扩展面板中应能看到 ST-BME 对应条目。扩展清单定义见 [`manifest.json`](ST-BME/manifest.json)。 + +### 4. 配置 Embedding 接口 + +若要启用向量检索、近邻对照与部分增强能力,需要填写可用的 OpenAI 兼容 embedding 接口: + +- API 地址 +- API Key +- 模型名 + +默认模型名为 `text-embedding-3-small`。 + +## 配置说明 + +设置面板位于 [`settings.html`](ST-BME/settings.html),当前主要配置可分为以下几类。 + +### 基础开关 + +| 配置项 | 默认值 | 说明 | +| ----------------- | ------ | ------------------------------------- | +| 启用记忆图谱 | 关闭 | 总开关,关闭时不执行提取与召回 | +| 每 N 条回复提取 | 1 | 控制提取频率 | +| 启用记忆召回注入 | 开启 | 是否在生成前注入检索结果 | +| 启用 LLM 精确召回 | 开启 | 小图 / 大图场景下可进一步筛选候选记忆 | +| 注入深度 | 4 | 控制注入位置 | + +### 混合评分权重 + +默认权重来自 [`index.js`](ST-BME/index.js): + +| 参数 | 默认值 | +| ---------- | ------ | +| 图扩散权重 | 0.6 | +| 向量权重 | 0.3 | +| 重要性权重 | 0.1 | + +### 增强能力开关 + +| 能力 | 默认状态 | 当前判断 | +| ------------ | -------- | ----------------- | +| 记忆进化 | 开启 | 已实现 | +| 精确对照 | 开启 | 实验性 | +| 全局概要 | 开启 | 已实现 | +| 认知边界 | 关闭 | 实验性 | +| 交叉检索 | 关闭 | 实验性 | +| 惊奇度分割 | 关闭 | 规划中 / 预留开关 | +| 主动遗忘 | 关闭 | 实验性 | +| 概率触发回忆 | 关闭 | 实验性 | +| 反思条目 | 关闭 | 规划中 / 部分预留 | + +## 数据模型 + +### 图状态 + +图状态由以下核心部分组成: + +- `version` +- `lastProcessedSeq` +- `nodes` +- `edges` +- `lastRecallResult` + +其中节点和边统一由 [`graph.js`](ST-BME/graph.js) 管理,并序列化到聊天元数据中。 + +### 节点类型 + +默认 Schema 定义在 [`schema.js`](ST-BME/schema.js)。当前内置节点类型包括: + +| 类型 | 用途 | 当前状态 | +| ------------ | -------------------- | ------------------------------ | +| `event` | 事件、动作、剧情推进 | 已实现 | +| `character` | 角色状态快照 | 已实现 | +| `location` | 地点与环境状态 | 已实现 | +| `rule` | 世界规则、约束与设定 | 已实现 | +| `thread` | 主线与阶段性进度 | 已实现 | +| `synopsis` | 全局前情提要 | 已实现 | +| `reflection` | 反思与元认知记录 | 结构已定义,生成流程未完整落地 | + +### 关系类型 + +当前内置关系类型包括: + +- `related` +- `involved_in` +- `occurred_at` +- `advances` +- `updates` +- `contradicts` +- `evolves` +- `temporal_update` + +### 时序语义 + +边对象已实现以下时间字段: + +- `validAt`:关系生效时间 +- `invalidAt`:关系失效时间 +- `expiredAt`:系统标记过期时间 + +这为后续更严格的“过去事实 / 当前事实”区分提供了基础,但实际策略仍在持续打磨中。 + +## 使用流程 + +### 最小使用步骤 + +1. 在扩展面板中启用 ST-BME。 +2. 配置 Embedding API。 +3. 开启“记忆召回注入”。 +4. 开始正常聊天,让系统按设定频率自动提取记忆。 +5. 使用“查看图谱”或“查看注入”观察当前状态。 + +### 推荐使用方式 + +- 初次使用时保持默认 Schema,不要一开始就大幅改结构。 +- 长剧情场景下保留“全局概要”与“记忆进化”。 +- 图规模较小时可保留 LLM 精确召回,图规模增大后再按成本调整。 +- 若叙事强依赖角色认知差异,可逐步测试“认知边界”。 +- 对 API 成本敏感时,提高“每 N 条回复提取”的数值。 + +## 当前状态 + +### 已实现 + +- 扩展基础骨架与设置面板 +- 聊天级图谱持久化 +- 默认 Schema 与关系类型 +- 提取、召回、注入三段式主流程 +- 向量检索接入 +- 图扩散排序 +- 层级压缩 +- 记忆进化 +- 全局概要节点 +- 边的时序字段与失效处理基础逻辑 +- 导入导出入口 + +### 实验性 + +- 精确对照对重复 / 更新场景的稳定收益 +- 认知边界过滤在复杂多角色剧情中的效果 +- 交叉检索对召回质量的增益 +- 主动遗忘的阈值与副作用控制 +- 概率触发回忆的叙事收益与噪声控制 + +### 规划中 + +- 惊奇度驱动的提取触发机制 +- 反思节点自动生成闭环 +- 更完整的可视化与调试工具 +- 更系统的 benchmark、回归测试与使用文档 + +## 路线图 + +- [ ] 补充端到端测试与典型场景回归样例 +- [ ] 完善惊奇度分割与智能触发提取 +- [ ] 完成反思条目生成与注入策略 +- [ ] 提供图谱可视化界面 +- [ ] 增强 Schema 配置与编辑体验 +- [ ] 增加多聊天图谱合并能力 +- [ ] 完善导出为外部记忆格式的支持 + +## 设计来源与参考 + +本项目当前实现和设计思路参考了仓库内收录的若干研究型项目与实现,但 ST-BME 自身为面向 SillyTavern 扩展场景的工程整合。 + +| 参考项目 | 主要启发方向 | +| ---------------------------- | ------------------------- | +| [`A-MEM`](A-MEM/README.md) | 记忆进化、近邻回溯更新 | +| [`EM-LLM`](EM-LLM/README.md) | 事件边界 / 惊奇度分割思路 | +| `Graphiti` | 时序关系与图结构管理 | +| `Mem0` | 新旧记忆对照与更新决策 | +| `RoleRAG` | 认知边界过滤 | +| `AriGraph` | 沿图边扩展的交叉检索 | +| `MemoRAG` | 全局概要作为长期锚点 | +| `SleepGate` | 主动遗忘与保留价值评估 | +| `Reflexion` | 反思条目方向 | + +## 许可证 + +本项目采用 AFPL License 发布,详见 [`LICENSE`](ST-BME/LICENSE)。 diff --git a/compressor.js b/compressor.js new file mode 100644 index 0000000..05c3af6 --- /dev/null +++ b/compressor.js @@ -0,0 +1,213 @@ +// ST-BME: 层级压缩引擎 +// 超过阈值的节点被 LLM 总结为更高层级的压缩节点 + +import { createNode, addNode, getActiveNodes, getNode } from './graph.js'; +import { callLLMForJSON } from './llm.js'; +import { embedText } from './embedding.js'; + +/** + * 对指定类型执行层级压缩 + * + * @param {object} params + * @param {object} params.graph - 当前图状态 + * @param {object} params.typeDef - 要压缩的类型定义 + * @param {object} params.embeddingConfig - Embedding API 配置 + * @param {boolean} [params.force=false] - 忽略阈值强制压缩 + * @returns {Promise<{created: number, archived: number}>} + */ +export async function compressType({ graph, typeDef, embeddingConfig, force = false }) { + const compression = typeDef.compression; + if (!compression || compression.mode !== 'hierarchical') { + return { created: 0, archived: 0 }; + } + + let totalCreated = 0; + let totalArchived = 0; + + // 从最低层级开始逐层压缩 + for (let level = 0; level < compression.maxDepth; level++) { + const result = await compressLevel({ + graph, + typeDef, + level, + embeddingConfig, + force, + }); + + totalCreated += result.created; + totalArchived += result.archived; + + // 如果这一层没有压缩发生,停止 + if (result.created === 0) break; + } + + return { created: totalCreated, archived: totalArchived }; +} + +/** + * 压缩特定层级的节点 + */ +async function compressLevel({ graph, typeDef, level, embeddingConfig, force }) { + const compression = typeDef.compression; + + // 获取该层级的活跃叶子节点 + const levelNodes = getActiveNodes(graph, typeDef.id) + .filter(n => n.level === level) + .sort((a, b) => a.seq - b.seq); + + const threshold = force ? Math.max(2, compression.fanIn) : compression.threshold; + const keepRecent = force ? 0 : compression.keepRecentLeaves; + + // 不够阈值,无需压缩 + if (levelNodes.length <= threshold) { + return { created: 0, archived: 0 }; + } + + // 排除最近的节点 + const compressible = levelNodes.slice(0, levelNodes.length - keepRecent); + if (compressible.length < compression.fanIn) { + return { created: 0, archived: 0 }; + } + + let created = 0; + let archived = 0; + + // 按 fanIn 分组压缩 + for (let i = 0; i < compressible.length; i += compression.fanIn) { + const batch = compressible.slice(i, i + compression.fanIn); + if (batch.length < 2) break; // 至少 2 个才压缩 + + // 调用 LLM 总结 + const summaryResult = await summarizeBatch(batch, typeDef); + if (!summaryResult) continue; + + // 创建压缩节点 + const compressedNode = createNode({ + type: typeDef.id, + fields: summaryResult.fields, + seq: batch[batch.length - 1].seq, + seqRange: [batch[0].seqRange?.[0] ?? batch[0].seq, batch[batch.length - 1].seqRange?.[1] ?? batch[batch.length - 1].seq], + importance: Math.max(...batch.map(n => n.importance)), + }); + + compressedNode.level = level + 1; + compressedNode.childIds = batch.map(n => n.id); + + // 生成 embedding + if (embeddingConfig?.apiUrl && summaryResult.fields.summary) { + const vec = await embedText(summaryResult.fields.summary, embeddingConfig); + if (vec) compressedNode.embedding = Array.from(vec); + } + + addNode(graph, compressedNode); + created++; + + // 归档子节点 + for (const child of batch) { + child.archived = true; + child.parentId = compressedNode.id; + archived++; + } + } + + return { created, archived }; +} + +/** + * 调用 LLM 总结一批节点 + */ +async function summarizeBatch(nodes, typeDef) { + const nodeDescriptions = nodes.map((n, i) => { + const fieldsStr = Object.entries(n.fields) + .filter(([_, v]) => v) + .map(([k, v]) => `${k}: ${v}`) + .join('\n '); + return `节点 ${i + 1} [楼层 ${n.seq}]:\n ${fieldsStr}`; + }).join('\n\n'); + + const instruction = typeDef.compression.instruction || '将以下节点压缩总结为一条精炼记录。'; + + const systemPrompt = [ + '你是一个记忆压缩器。将多个同类型节点总结为一条更高层级的压缩节点。', + instruction, + '', + '输出格式为严格 JSON:', + `{"fields": {${typeDef.columns.map(c => `"${c.name}": "..."`).join(', ')}}}`, + '', + '规则:', + '- 保留关键信息:因果关系、不可逆结果、未解决伏笔', + '- 去除重复和低信息密度内容', + '- 压缩后文本应精炼,目标 150 字左右', + ].join('\n'); + + const userPrompt = `请压缩以下 ${nodes.length} 个 "${typeDef.label}" 节点:\n\n${nodeDescriptions}`; + + return await callLLMForJSON({ systemPrompt, userPrompt, maxRetries: 1 }); +} + +/** + * 对所有支持压缩的类型执行压缩 + * + * @param {object} graph + * @param {object[]} schema + * @param {object} embeddingConfig + * @param {boolean} [force=false] + * @returns {Promise<{created: number, archived: number}>} + */ +export async function compressAll(graph, schema, embeddingConfig, force = false) { + let totalCreated = 0; + let totalArchived = 0; + + for (const typeDef of schema) { + if (typeDef.compression?.mode === 'hierarchical') { + const result = await compressType({ graph, typeDef, embeddingConfig, force }); + totalCreated += result.created; + totalArchived += result.archived; + } + } + + return { created: totalCreated, archived: totalArchived }; +} + +// ==================== v2: 主动遗忘(SleepGate 启发) ==================== + +/** + * 睡眠清理周期 + * 评估每个节点的保留价值,低于阈值的归档(遗忘) + * + * @param {object} graph - 图状态 + * @param {object} settings - 包含 forgetThreshold 的设置 + * @returns {{forgotten: number}} 本次遗忘的节点数 + */ +export function sleepCycle(graph, settings) { + const threshold = settings.forgetThreshold ?? 0.5; + const nodes = getActiveNodes(graph); + const now = Date.now(); + let forgotten = 0; + + for (const node of nodes) { + // 跳过常驻类型(synopsis, rule 等重要节点不应被遗忘) + if (node.type === 'synopsis' || node.type === 'rule' || node.type === 'thread') continue; + // 跳过高重要性节点 + if (node.importance >= 8) continue; + // 跳过最近创建的节点(< 1 小时) + if (now - node.createdTime < 3600000) continue; + + // 计算保留价值 = importance × recency × (1 + accessFreq) + const ageHours = (now - node.createdTime) / 3600000; + const recency = 1 / (1 + Math.log10(1 + ageHours)); + const accessFreq = node.accessCount / Math.max(1, ageHours / 24); + const retentionValue = (node.importance / 10) * recency * (1 + accessFreq); + + if (retentionValue < threshold) { + node.archived = true; + forgotten++; + } + } + + if (forgotten > 0) { + console.log(`[ST-BME] 主动遗忘: ${forgotten} 个低价值节点已归档`); + } + + return { forgotten }; +} diff --git a/diffusion.js b/diffusion.js new file mode 100644 index 0000000..1d40495 --- /dev/null +++ b/diffusion.js @@ -0,0 +1,167 @@ +// ST-BME: JS 版 PEDSA 扩散激活引擎 +// 从 PeroCore 的 Rust CognitiveGraphEngine 移植核心算法到纯 JS +// 适配 ST 场景(<1万节点,不需要并行/SIMD) + +/** + * PEDSA 扩散激活引擎 + * + * 算法:Parallel Energy-Decay Spreading Activation + * 本质:在有向加权图上的能量传播模型 + * + * 核心公式: + * E_{t+1}(j) = Σ_{i∈N(j)} E_t(i) × W_ij × D_decay + * + * 特点(保留自 PeroCore): + * - 能量衰减:每步传播乘以衰减因子 + * - 动态剪枝:每步只保留 Top-K 活跃节点 + * - 抑制机制:特殊边类型传递负能量 + * - 能量钳位:限制在 [-2.0, 2.0] 范围 + * + * 与 PeroCore Rust 版的差异: + * - 无 Rayon 并行(JS 单线程,ST 场景不需要) + * - 无 u16 量化(直接 f64,内存不是瓶颈) + * - 无 SIMD(普通数组运算) + */ + +/** + * 抑制边类型标记 + */ +const INHIBIT_EDGE_TYPE = 255; + +/** + * 默认配置 + */ +const DEFAULT_OPTIONS = { + maxSteps: 2, // 最大扩散步数 + decayFactor: 0.6, // 每步衰减因子 + topK: 100, // 每步保留的最大活跃节点数 + minEnergy: 0.01, // 最小有效能量(低于此值视为不活跃) + maxEnergy: 2.0, // 能量上限 + minEnergy_clamp: -2.0, // 能量下限(抑制) +}; + +/** + * 执行 PEDSA 扩散激活 + * + * @param {Map>} adjacencyMap + * 邻接表:nodeId → [{targetId, strength, edgeType}] + * 可通过 graph.buildAdjacencyMap() 构建 + * + * @param {Array<{id: string, energy: number}>} seedNodes + * 初始种子节点及其能量 + * - 向量检索命中的节点:energy = vectorScore (0~1) + * - 实体锚点节点:energy = 2.0(最大值) + * + * @param {object} [options] - 配置选项 + * + * @returns {Map} 所有被激活节点的最终能量 + * nodeId → energy(正值=激活,负值=抑制) + */ +export function propagateActivation(adjacencyMap, seedNodes, options = {}) { + const opts = { ...DEFAULT_OPTIONS, ...options }; + + // Step 0: 初始化能量表 + /** @type {Map} */ + let currentEnergy = new Map(); + + for (const seed of seedNodes) { + const clamped = clampEnergy(seed.energy, opts); + if (Math.abs(clamped) >= opts.minEnergy) { + currentEnergy.set(seed.id, clamped); + } + } + + // 累积结果(所有步骤的最大能量) + /** @type {Map} */ + const result = new Map(currentEnergy); + + // Step 1~N: 逐步扩散 + for (let step = 0; step < opts.maxSteps; step++) { + /** @type {Map} */ + const nextEnergy = new Map(); + + // 对每个当前活跃节点,传播能量到邻居 + for (const [nodeId, energy] of currentEnergy) { + const neighbors = adjacencyMap.get(nodeId); + if (!neighbors) continue; + + for (const neighbor of neighbors) { + // 计算传播能量 + let propagated = energy * neighbor.strength * opts.decayFactor; + + // 抑制边:传递负能量 + if (neighbor.edgeType === INHIBIT_EDGE_TYPE) { + propagated = -Math.abs(propagated); + } + + // 累加到邻居节点 + const existing = nextEnergy.get(neighbor.targetId) || 0; + nextEnergy.set(neighbor.targetId, existing + propagated); + } + } + + // 钳位 + 过滤低能量 + for (const [nodeId, energy] of nextEnergy) { + const clamped = clampEnergy(energy, opts); + if (Math.abs(clamped) < opts.minEnergy) { + nextEnergy.delete(nodeId); + } else { + nextEnergy.set(nodeId, clamped); + } + } + + // 动态剪枝:只保留 Top-K + if (nextEnergy.size > opts.topK) { + const sorted = [...nextEnergy.entries()] + .sort((a, b) => Math.abs(b[1]) - Math.abs(a[1])); + + nextEnergy.clear(); + for (let i = 0; i < opts.topK && i < sorted.length; i++) { + nextEnergy.set(sorted[i][0], sorted[i][1]); + } + } + + // 更新累积结果(取各步骤最大绝对值能量) + for (const [nodeId, energy] of nextEnergy) { + const existing = result.get(nodeId) || 0; + if (Math.abs(energy) > Math.abs(existing)) { + result.set(nodeId, energy); + } + } + + // 准备下一步 + currentEnergy = nextEnergy; + + // 如果没有活跃节点了,提前终止 + if (currentEnergy.size === 0) break; + } + + return result; +} + +/** + * 能量钳位 + * @param {number} energy + * @param {object} opts + * @returns {number} + */ +function clampEnergy(energy, opts) { + return Math.max(opts.minEnergy_clamp, Math.min(opts.maxEnergy, energy)); +} + +/** + * 快捷方法:从种子列表创建扩散并返回按能量排序的结果 + * + * @param {Map} adjacencyMap - 邻接表 + * @param {Array<{id: string, energy: number}>} seeds - 种子节点 + * @param {object} [options] + * @returns {Array<{nodeId: string, energy: number}>} 按能量降序排列 + */ +export function diffuseAndRank(adjacencyMap, seeds, options = {}) { + const energyMap = propagateActivation(adjacencyMap, seeds, options); + + return [...energyMap.entries()] + .filter(([_, energy]) => energy > 0) // 只返回正能量(被激活的) + .map(([nodeId, energy]) => ({ nodeId, energy })) + .sort((a, b) => b.energy - a.energy); +} diff --git a/dynamics.js b/dynamics.js new file mode 100644 index 0000000..53597f0 --- /dev/null +++ b/dynamics.js @@ -0,0 +1,102 @@ +// ST-BME: 记忆动力学模块 +// 实现访问强化、时间衰减、混合评分 — 来自 PeroCore 的核心创新 + +/** + * 访问强化:节点被召回/注入时调用 + * - accessCount += 1 + * - importance += 0.1(上限 10) + * - lastAccessTime 更新 + * + * @param {object} node + */ +export function reinforceAccess(node) { + node.accessCount = (node.accessCount || 0) + 1; + node.importance = Math.min(10, (node.importance || 5) + 0.1); + node.lastAccessTime = Date.now(); +} + +/** + * 计算时间衰减因子 + * 使用对数衰减(PeroCore 方式)而非指数衰减: + * factor = 0.8 + 0.2 / (1 + ln(1 + Δt_days)) + * + * 特点:久远但重要的记忆不会快速消失 + * - Δt = 0天 → factor = 1.0 + * - Δt = 1天 → factor ≈ 0.93 + * - Δt = 7天 → factor ≈ 0.89 + * - Δt = 30天 → factor ≈ 0.85 + * - Δt = 365天 → factor ≈ 0.83 + * + * @param {number} createdTime - 创建时间戳(ms) + * @param {number} [now] - 当前时间戳(ms) + * @returns {number} 衰减因子 [0.8, 1.0] + */ +export function timeDecayFactor(createdTime, now = Date.now()) { + const deltaDays = Math.max(0, (now - createdTime) / (1000 * 60 * 60 * 24)); + return 0.8 + 0.2 / (1 + Math.log(1 + deltaDays)); +} + +/** + * 混合评分公式 + * FinalScore = (GraphScore×α + VecScore×β + ImportanceNorm×γ) × TimeDecay + * + * 默认权重:α=0.6, β=0.3, γ=0.1 + * + * @param {object} params + * @param {number} params.graphScore - 图扩散能量得分 [0, 2] + * @param {number} params.vectorScore - 向量相似度 [0, 1] + * @param {number} params.importance - 节点重要性 [0, 10] + * @param {number} params.createdTime - 节点创建时间 + * @param {object} [weights] - 权重配置 + * @returns {number} 最终得分 + */ +export function hybridScore({ + graphScore = 0, + vectorScore = 0, + importance = 5, + createdTime = Date.now(), +}, weights = {}) { + const alpha = weights.graphWeight ?? 0.6; + const beta = weights.vectorWeight ?? 0.3; + const gamma = weights.importanceWeight ?? 0.1; + + // 归一化 + const normGraph = Math.max(0, Math.min(1, graphScore / 2.0)); // PEDSA 能量范围 [-2, 2] → [0, 1] + const normVec = Math.max(0, Math.min(1, vectorScore)); + const normImportance = Math.max(0, Math.min(1, importance / 10.0)); + + const baseScore = normGraph * alpha + normVec * beta + normImportance * gamma; + const decay = timeDecayFactor(createdTime); + + return baseScore * decay; +} + +/** + * 边权衰减:长期未被激活的边降低强度 + * 只降低到最低 0.1,不会归零 + * + * @param {object[]} edges + * @param {Set} activatedEdgeIds - 最近被激活(出现在扩散路径上)的边 ID + * @param {number} [decayRate=0.02] - 每次调用的衰减量 + */ +export function decayEdgeWeights(edges, activatedEdgeIds = new Set(), decayRate = 0.02) { + for (const edge of edges) { + if (activatedEdgeIds.has(edge.id)) { + // 被激活的边轻微加强 + edge.strength = Math.min(1.0, edge.strength + decayRate * 0.5); + } else { + // 未被激活的边轻微衰减 + edge.strength = Math.max(0.1, edge.strength - decayRate); + } + } +} + +/** + * 批量对选中节点执行访问强化 + * @param {object[]} nodes - 被召回的节点列表 + */ +export function reinforceAccessBatch(nodes) { + for (const node of nodes) { + reinforceAccess(node); + } +} diff --git a/embedding.js b/embedding.js new file mode 100644 index 0000000..7fac0f1 --- /dev/null +++ b/embedding.js @@ -0,0 +1,185 @@ +// ST-BME: 外部 Embedding API 封装 + 向量检索 +// 支持 OpenAI 兼容的 /v1/embeddings 接口 + +/** + * Embedding 服务 + * 调用外部 API 获取文本向量,并提供暴力搜索 cosine 相似度 + */ + +/** + * 调用外部 Embedding API + * + * @param {string} text - 要嵌入的文本 + * @param {object} config - API 配置 + * @param {string} config.apiUrl - API 基地址(如 https://api.openai.com/v1) + * @param {string} config.apiKey - API Key + * @param {string} config.model - 模型名(如 text-embedding-3-small) + * @returns {Promise} 向量或 null + */ +export async function embedText(text, config) { + if (!text || !config.apiUrl || !config.apiKey || !config.model) { + console.warn('[ST-BME] Embedding 配置不完整,跳过'); + return null; + } + + try { + const url = `${config.apiUrl.replace(/\/+$/, '')}/embeddings`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${config.apiKey}`, + }, + body: JSON.stringify({ + model: config.model, + input: text, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error(`[ST-BME] Embedding API 错误 (${response.status}):`, errorText); + return null; + } + + const data = await response.json(); + const vector = data?.data?.[0]?.embedding; + + if (!vector || !Array.isArray(vector)) { + console.error('[ST-BME] Embedding API 返回格式异常:', data); + return null; + } + + return new Float64Array(vector); + } catch (e) { + console.error('[ST-BME] Embedding API 调用失败:', e); + return null; + } +} + +/** + * 批量嵌入文本 + * + * @param {string[]} texts + * @param {object} config + * @returns {Promise<(Float64Array|null)[]>} + */ +export async function embedBatch(texts, config) { + if (!texts.length || !config.apiUrl || !config.apiKey || !config.model) { + return texts.map(() => null); + } + + try { + const url = `${config.apiUrl.replace(/\/+$/, '')}/embeddings`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${config.apiKey}`, + }, + body: JSON.stringify({ + model: config.model, + input: texts, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error(`[ST-BME] Embedding API 批量错误 (${response.status}):`, errorText); + return texts.map(() => null); + } + + const data = await response.json(); + const embeddings = data?.data; + + if (!Array.isArray(embeddings)) { + return texts.map(() => null); + } + + // 按 index 排序(API 可能不保证顺序) + embeddings.sort((a, b) => a.index - b.index); + + return embeddings.map(item => { + if (item?.embedding && Array.isArray(item.embedding)) { + return new Float64Array(item.embedding); + } + return null; + }); + } catch (e) { + console.error('[ST-BME] Embedding API 批量调用失败:', e); + return texts.map(() => null); + } +} + +/** + * 计算两个向量的 cosine 相似度 + * + * @param {Float64Array|number[]} vecA + * @param {Float64Array|number[]} vecB + * @returns {number} 相似度 [-1, 1] + */ +export function cosineSimilarity(vecA, vecB) { + if (!vecA || !vecB || vecA.length !== vecB.length || vecA.length === 0) { + return 0; + } + + let dotProduct = 0; + let normA = 0; + let normB = 0; + + for (let i = 0; i < vecA.length; i++) { + dotProduct += vecA[i] * vecB[i]; + normA += vecA[i] * vecA[i]; + normB += vecB[i] * vecB[i]; + } + + const denominator = Math.sqrt(normA) * Math.sqrt(normB); + if (denominator === 0) return 0; + + return dotProduct / denominator; +} + +/** + * 暴力搜索:找出与查询向量最相似的 Top-K 节点 + * PeroCore 的向量引擎也是暴力搜索(<1000 节点时比 HNSW 更快) + * + * @param {Float64Array|number[]} queryVec - 查询向量 + * @param {Array<{nodeId: string, embedding: Float64Array|number[]}>} candidates - 候选节点 + * @param {number} topK - 返回数量 + * @returns {Array<{nodeId: string, score: number}>} 按相似度降序 + */ +export function searchSimilar(queryVec, candidates, topK = 20) { + if (!queryVec || candidates.length === 0) return []; + + const scored = candidates + .filter(c => c.embedding && c.embedding.length > 0) + .map(c => ({ + nodeId: c.nodeId, + score: cosineSimilarity(queryVec, c.embedding), + })) + .filter(item => item.score > 0); + + scored.sort((a, b) => b.score - a.score); + + return scored.slice(0, topK); +} + +/** + * 测试 Embedding API 连通性 + * + * @param {object} config - API 配置 + * @returns {Promise<{success: boolean, dimensions: number, error: string}>} + */ +export async function testConnection(config) { + try { + const vec = await embedText('test connection', config); + if (vec) { + return { success: true, dimensions: vec.length, error: '' }; + } + return { success: false, dimensions: 0, error: 'API 返回空结果' }; + } catch (e) { + return { success: false, dimensions: 0, error: String(e) }; + } +} diff --git a/evolution.js b/evolution.js new file mode 100644 index 0000000..3b199e9 --- /dev/null +++ b/evolution.js @@ -0,0 +1,193 @@ +// ST-BME: 记忆进化引擎(A-MEM 启发) +// 新节点写入后触发,回溯更新相关旧节点的 context/tags/links + +import { getActiveNodes, getNode, createEdge, addEdge } from './graph.js'; +import { searchSimilar } from './embedding.js'; +import { callLLMForJSON } from './llm.js'; + +/** + * 进化系统提示词 + * 参考 A-MEM process_memory() 的进化决策 Prompt + */ +const EVOLUTION_SYSTEM_PROMPT = `你是一个记忆进化分析器。当新的记忆加入知识图谱时,你需要分析它与现有记忆的关系。 + +你的任务: +1. 判断新记忆是否揭示了与旧记忆相关的新信息 +2. 如果是,决定如何更新旧记忆的描述和分类 +3. 建立新旧记忆之间的有意义连接 + +输出严格 JSON: +{ + "should_evolve": true/false, + "reason": "进化理由(简述)", + "suggested_connections": ["需要建立链接的旧记忆ID列表"], + "neighbor_updates": [ + { + "nodeId": "需更新的旧节点ID", + "newContext": "基于新信息修正后的描述(如不需修改则为null)", + "newTags": ["更新后的分类标签,如不需修改则为null"] + } + ] +} + +进化规则: +- 仅当新信息确实改变了对旧记忆的理解时才触发进化 +- 例如:揭露卧底身份 → 修正该角色之前事件中的动机描述 +- 例如:发现地点的隐藏特性 → 更新地点节点的描述 +- 不要对无关记忆强行建立联系 +- neighbor_updates 中每条必须有实际意义的修改`; + +/** + * 记忆进化主函数 + * + * @param {object} params + * @param {object} params.graph - 当前图状态 + * @param {string[]} params.newNodeIds - 本次新创建的节点 ID 列表 + * @param {object} params.embeddingConfig - Embedding API 配置 + * @param {object} [params.options] + * @returns {Promise<{evolved: number, connections: number, updates: number}>} + */ +export async function evolveMemories({ + graph, + newNodeIds, + embeddingConfig, + options = {}, +}) { + const neighborCount = options.neighborCount ?? 5; + const stats = { evolved: 0, connections: 0, updates: 0 }; + + if (!newNodeIds || newNodeIds.length === 0) return stats; + if (!embeddingConfig?.apiUrl) { + console.log('[ST-BME] 记忆进化跳过:未配置 Embedding API'); + return stats; + } + + const activeNodes = getActiveNodes(graph); + if (activeNodes.length < 2) return stats; // 至少需要 2 个节点才有进化意义 + + for (const newId of newNodeIds) { + const newNode = getNode(graph, newId); + if (!newNode || !newNode.embedding) continue; + + // 找最近邻(排除自身) + const candidates = activeNodes + .filter(n => n.id !== newId && n.embedding) + .map(n => ({ nodeId: n.id, embedding: n.embedding })); + + if (candidates.length === 0) continue; + + const neighbors = searchSimilar(newNode.embedding, candidates, neighborCount); + if (neighbors.length === 0) continue; + + // 构建 LLM 上下文 + const neighborsContext = neighbors.map(n => { + const node = getNode(graph, n.nodeId); + if (!node) return null; + const fieldsStr = Object.entries(node.fields) + .map(([k, v]) => `${k}: ${v}`) + .join(', '); + return `[${node.id}] 类型=${node.type}, ${fieldsStr}, 分类=${(node.clusters || []).join('/')}`; + }).filter(Boolean).join('\n'); + + const newNodeFieldsStr = Object.entries(newNode.fields) + .map(([k, v]) => `${k}: ${v}`) + .join(', '); + + const userPrompt = [ + '## 新加入的记忆', + `[${newNode.id}] 类型=${newNode.type}, ${newNodeFieldsStr}`, + '', + '## 最近邻的已有记忆', + neighborsContext, + '', + `共 ${neighbors.length} 条近邻记忆。请分析新记忆是否揭示了关于旧记忆的新信息。`, + ].join('\n'); + + try { + const decision = await callLLMForJSON({ + systemPrompt: EVOLUTION_SYSTEM_PROMPT, + userPrompt, + maxRetries: 1, + }); + + if (!decision || !decision.should_evolve) continue; + + stats.evolved++; + console.log(`[ST-BME] 记忆进化触发: ${decision.reason || '(无理由)'}`); + + // 1. 建立链接(strengthen) + if (decision.suggested_connections && Array.isArray(decision.suggested_connections)) { + for (const targetId of decision.suggested_connections) { + // 验证目标节点存在 + if (!getNode(graph, targetId)) continue; + const edge = createEdge({ + fromId: newId, + toId: targetId, + relation: 'related', + strength: 0.7, + }); + if (addEdge(graph, edge)) { + stats.connections++; + } + } + } + + // 2. 反向更新旧节点(update_neighbor) + if (decision.neighbor_updates && Array.isArray(decision.neighbor_updates)) { + for (const update of decision.neighbor_updates) { + if (!update.nodeId) continue; + const oldNode = getNode(graph, update.nodeId); + if (!oldNode) continue; + + let changed = false; + + // 更新 context/state 字段 + if (update.newContext && typeof update.newContext === 'string') { + // 根据节点类型选择更新哪个字段 + if (oldNode.fields.state !== undefined) { + oldNode.fields.state = update.newContext; + changed = true; + } else if (oldNode.fields.summary !== undefined) { + oldNode.fields.summary = update.newContext; + changed = true; + } else if (oldNode.fields.core_note !== undefined) { + oldNode.fields.core_note = update.newContext; + changed = true; + } + } + + // 更新分类标签 + if (update.newTags && Array.isArray(update.newTags)) { + oldNode.clusters = update.newTags; + changed = true; + } + + if (changed) { + // 标记需要重新生成 embedding + oldNode.embedding = null; + // 记录进化历史 + if (!oldNode._evolutionHistory) oldNode._evolutionHistory = []; + oldNode._evolutionHistory.push({ + triggeredBy: newId, + timestamp: Date.now(), + reason: decision.reason || '', + }); + stats.updates++; + } + } + } + + } catch (e) { + console.error(`[ST-BME] 记忆进化失败 (${newId}):`, e); + } + } + + if (stats.evolved > 0) { + console.log( + `[ST-BME] 记忆进化完成: ${stats.evolved} 次进化, ` + + `${stats.connections} 条新链接, ${stats.updates} 个节点回溯更新`, + ); + } + + return stats; +} diff --git a/extractor.js b/extractor.js new file mode 100644 index 0000000..af598db --- /dev/null +++ b/extractor.js @@ -0,0 +1,500 @@ +// ST-BME: LLM 记忆提取管线(写入路径) +// 分析对话 → 提取节点和关系 → 更新图谱 +// v2: 融合 Mem0 精确对照 + Graphiti 时序边 + MemoRAG 全局概要 + +import { createNode, addNode, updateNode, findLatestNode, createEdge, addEdge, getActiveNodes, invalidateEdge } from './graph.js'; +import { embedText, embedBatch, searchSimilar } from './embedding.js'; +import { callLLMForJSON } from './llm.js'; +import { RELATION_TYPES } from './schema.js'; + +/** + * 对未处理的对话楼层执行记忆提取 + * + * @param {object} params + * @param {object} params.graph - 当前图状态 + * @param {Array<{role: string, content: string}>} params.messages - 要处理的对话消息 + * @param {number} params.startSeq - 起始楼层号 + * @param {object[]} params.schema - 节点类型 Schema + * @param {object} params.embeddingConfig - Embedding API 配置 + * @param {string} [params.extractPrompt] - 自定义提取提示词 + * @param {object} [params.v2Options] - v2 增强选项 + * @returns {Promise<{success: boolean, newNodes: number, updatedNodes: number, newEdges: number, newNodeIds: string[]}>} + */ +export async function extractMemories({ + graph, + messages, + startSeq, + schema, + embeddingConfig, + extractPrompt, + v2Options = {}, +}) { + if (!messages || messages.length === 0) { + return { success: true, newNodes: 0, updatedNodes: 0, newEdges: 0, newNodeIds: [] }; + } + + const enablePreciseConflict = v2Options.enablePreciseConflict ?? true; + const conflictThreshold = v2Options.conflictThreshold ?? 0.85; + + console.log(`[ST-BME] 提取开始: 楼层 ${startSeq}, ${messages.length} 条消息`); + + // 构建对话文本 + const dialogueText = messages + .map(m => `[${m.role}]: ${m.content}`) + .join('\n\n'); + + // 构建当前图概览(让 LLM 知道已有哪些节点,避免重复) + const graphOverview = buildGraphOverview(graph, schema); + + // 构建 Schema 描述 + const schemaDescription = buildSchemaDescription(schema); + + // 系统提示词 + const systemPrompt = extractPrompt || buildDefaultExtractPrompt(schema); + + // 用户提示词 + const userPrompt = [ + '## 当前对话内容(需提取记忆)', + dialogueText, + '', + '## 当前图谱状态', + graphOverview || '(空图谱,尚无节点)', + '', + '## 节点类型定义', + schemaDescription, + '', + '请分析对话,按 JSON 格式输出操作列表。', + ].join('\n'); + + // 调用 LLM + const result = await callLLMForJSON({ systemPrompt, userPrompt, maxRetries: 2 }); + + if (!result || !result.operations) { + console.warn('[ST-BME] 提取 LLM 未返回有效操作'); + return { success: false, newNodes: 0, updatedNodes: 0, newEdges: 0, newNodeIds: [] }; + } + + // ========== v2: Mem0 精确对照阶段 ========== + if (enablePreciseConflict && embeddingConfig?.apiUrl) { + await mem0ConflictCheck(graph, result.operations, embeddingConfig, conflictThreshold); + } + + // 执行操作 + const stats = { newNodes: 0, updatedNodes: 0, newEdges: 0 }; + const newNodeIds = []; // v2: 收集新建节点 ID(用于进化引擎) + const refMap = new Map(); + + for (const op of result.operations) { + try { + switch (op.action) { + case 'create': { + const createdId = handleCreate(graph, op, startSeq, schema, refMap, stats); + if (createdId) newNodeIds.push(createdId); + break; + } + case 'update': + handleUpdate(graph, op, stats); + break; + case 'delete': + handleDelete(graph, op, stats); + break; + case '_skip': + // Mem0 对照判定为重复,跳过 + break; + default: + console.warn(`[ST-BME] 未知操作类型: ${op.action}`); + } + } catch (e) { + console.error(`[ST-BME] 操作执行失败:`, op, e); + } + } + + // 为新建节点生成 embedding + await generateNodeEmbeddings(graph, embeddingConfig); + + // 更新处理进度 + graph.lastProcessedSeq = startSeq + messages.filter(m => m.role === 'assistant').length; + + console.log(`[ST-BME] 提取完成: 新建 ${stats.newNodes}, 更新 ${stats.updatedNodes}, 新边 ${stats.newEdges}`); + + return { success: true, ...stats, newNodeIds }; +} + +/** + * 处理 create 操作 + */ +function handleCreate(graph, op, seq, schema, refMap, stats) { + const typeDef = schema.find(s => s.id === op.type); + if (!typeDef) { + console.warn(`[ST-BME] 未知节点类型: ${op.type}`); + return null; + } + + // latestOnly 类型:检查是否已存在同名节点 + if (typeDef.latestOnly && op.fields?.name) { + const existing = findLatestNode(graph, op.type, op.fields.name); + if (existing) { + // 转为更新操作 + updateNode(graph, existing.id, { fields: op.fields, seq }); + stats.updatedNodes++; + + if (op.ref) refMap.set(op.ref, existing.id); + + // 处理关联边 + if (op.links) { + handleLinks(graph, existing.id, op.links, refMap, stats); + } + return null; + } + } + + // 创建新节点 + const node = createNode({ + type: op.type, + fields: op.fields || {}, + seq, + importance: op.importance ?? 5.0, + clusters: op.clusters || [], + }); + + addNode(graph, node); + stats.newNodes++; + + // 保存 ref 用于同批次引用 + if (op.ref) { + refMap.set(op.ref, node.id); + } + + // 处理关联边 + if (op.links) { + handleLinks(graph, node.id, op.links, refMap, stats); + } + + return node.id; +} + +/** + * 处理 update 操作 + */ +function handleUpdate(graph, op, stats) { + if (!op.nodeId) { + console.warn('[ST-BME] update 操作缺少 nodeId'); + return; + } + + const updated = updateNode(graph, op.nodeId, { + fields: op.fields || {}, + }); + + if (updated) { + stats.updatedNodes++; + const node = graph.nodes.find(n => n.id === op.nodeId); + if (node) node.embedding = null; + + // v2 Graphiti: 标记旧的 updates/temporal_update 边为失效 + const oldEdges = graph.edges.filter(e => + (e.fromId === op.nodeId || e.toId === op.nodeId) && + (e.relation === 'updates' || e.relation === 'temporal_update') && + !e.invalidAt + ); + for (const e of oldEdges) { + invalidateEdge(e); + } + } +} + +/** + * 处理 delete 操作 + */ +function handleDelete(graph, op, stats) { + if (!op.nodeId) return; + const node = graph.nodes.find(n => n.id === op.nodeId); + if (node) { + node.archived = true; // 软删除 + } +} + +/** + * 处理关联边 + */ +function handleLinks(graph, sourceId, links, refMap, stats) { + for (const link of links) { + let targetId = link.targetNodeId || null; + + // 通过 ref 解析目标节点 + if (!targetId && link.targetRef) { + targetId = refMap.get(link.targetRef); + } + + if (!targetId) continue; + + // 验证关系类型 + const relation = RELATION_TYPES.includes(link.relation) + ? link.relation + : 'related'; + + const edgeType = relation === 'contradicts' ? 255 : 0; + + const edge = createEdge({ + fromId: sourceId, + toId: targetId, + relation, + strength: link.strength ?? 0.8, + edgeType, + }); + + if (addEdge(graph, edge)) { + stats.newEdges++; + } + } +} + +/** + * 为缺少 embedding 的节点生成向量 + */ +async function generateNodeEmbeddings(graph, embeddingConfig) { + if (!embeddingConfig?.apiUrl) return; + + const needsEmbedding = graph.nodes.filter(n => !n.embedding && !n.archived); + + if (needsEmbedding.length === 0) return; + + const texts = needsEmbedding.map(n => { + // 用主要字段拼文本 + const parts = []; + if (n.fields.summary) parts.push(n.fields.summary); + if (n.fields.name) parts.push(n.fields.name); + if (n.fields.title) parts.push(n.fields.title); + if (n.fields.traits) parts.push(n.fields.traits); + if (n.fields.state) parts.push(n.fields.state); + if (n.fields.constraint) parts.push(n.fields.constraint); + return parts.join(' | ') || n.type; + }); + + console.log(`[ST-BME] 为 ${texts.length} 个节点生成 embedding`); + + const embeddings = await embedBatch(texts, embeddingConfig); + + for (let i = 0; i < needsEmbedding.length; i++) { + if (embeddings[i]) { + needsEmbedding[i].embedding = Array.from(embeddings[i]); + } + } +} + +/** + * 构建图谱概览文本(给 LLM 看) + */ +function buildGraphOverview(graph, schema) { + const activeNodes = graph.nodes.filter(n => !n.archived); + if (activeNodes.length === 0) return ''; + + const lines = []; + for (const typeDef of schema) { + const nodesOfType = activeNodes.filter(n => n.type === typeDef.id); + if (nodesOfType.length === 0) continue; + + lines.push(`### ${typeDef.label} (${nodesOfType.length} 个节点)`); + for (const node of nodesOfType.slice(-10)) { // 只展示最近 10 个 + const summary = node.fields.summary || node.fields.name || node.fields.title || '(无)'; + lines.push(` - [${node.id}] ${summary}`); + } + } + + return lines.join('\n'); +} + +/** + * 构建 Schema 描述文本 + */ +function buildSchemaDescription(schema) { + return schema.map(t => { + const cols = t.columns.map(c => `${c.name}${c.required ? '(必填)' : ''}: ${c.hint}`).join('\n '); + return `类型 "${t.id}" (${t.label}):\n ${cols}`; + }).join('\n\n'); +} + +/** + * 构建默认提取提示词 + */ +function buildDefaultExtractPrompt(schema) { + const typeNames = schema.map(s => `${s.id}(${s.label})`).join(', '); + + return [ + '你是一个记忆提取分析器。从对话中提取结构化记忆节点并存入知识图谱。', + '', + `支持的节点类型:${typeNames}`, + '', + '输出格式为严格 JSON:', + '{', + ' "thought": "你对本段对话的分析(事件/角色变化/新信息)",', + ' "operations": [', + ' {', + ' "action": "create",', + ' "type": "event",', + ' "fields": {"summary": "...", "participants": "...", "status": "ongoing"},', + ' "importance": 6,', + ' "ref": "evt1",', + ' "links": [', + ' {"targetNodeId": "existing-id", "relation": "involved_in", "strength": 0.9},', + ' {"targetRef": "char1", "relation": "occurred_at", "strength": 0.8}', + ' ]', + ' },', + ' {', + ' "action": "update",', + ' "nodeId": "existing-node-id",', + ' "fields": {"state": "新的状态"}', + ' }', + ' ]', + '}', + '', + '规则:', + '- 每批对话最多创建 1 个事件节点,多个子事件合并为一条', + '- 角色/地点节点:如果图中已有同名节点,用 update 而非 create', + `- 关系类型限定:${RELATION_TYPES.join(', ')}`, + '- contradicts 关系用于矛盾/冲突信息', + '- evolves 关系用于新信息揭示旧记忆需修正的情况', + '- temporal_update 关系用于实体状态的时序变化', + '- 不要虚构内容,只提取对话中有证据支持的信息', + '- importance 范围 1-10,普通事件 5,关键转折 8+', + '- summary 应该是摘要抽象,不要复制原文', + ].join('\n'); +} + +// ==================== v2 增强功能 ==================== + +/** + * Mem0 启发的精确对照 + * 对每条 create 操作搜索近邻,高相似度时让 LLM 判断 add/update/skip + */ +async function mem0ConflictCheck(graph, operations, embeddingConfig, threshold) { + const activeNodes = getActiveNodes(graph).filter(n => n.embedding); + if (activeNodes.length === 0) return; + + for (const op of operations) { + if (op.action !== 'create') continue; + + const factText = op.fields?.summary || op.fields?.name || op.fields?.title || ''; + if (!factText) continue; + + try { + const factVec = await embedText(factText, embeddingConfig); + if (!factVec) continue; + + const candidates = activeNodes.map(n => ({ nodeId: n.id, embedding: n.embedding })); + const similar = searchSimilar(factVec, candidates, 3); + + if (similar.length > 0 && similar[0].score > threshold) { + const topMatch = graph.nodes.find(n => n.id === similar[0].nodeId); + if (!topMatch) continue; + + const topFields = Object.entries(topMatch.fields) + .map(([k, v]) => `${k}: ${v}`) + .join(', '); + + const decision = await callLLMForJSON({ + systemPrompt: [ + '判断新信息与已有记忆的关系。输出严格 JSON:', + '{"action": "add"|"update"|"skip", "targetId": "旧节点ID", "mergedFields": {}}', + '- add: 新信息完全不同,应新建', + '- update: 新信息是对旧记忆的修正/补充', + '- skip: 与旧记忆完全重复', + ].join('\n'), + userPrompt: [ + `新信息: [${op.type}] ${factText}`, + `最相似旧记忆: [${topMatch.id}] 类型=${topMatch.type}, ${topFields}`, + `相似度: ${similar[0].score.toFixed(3)}`, + ].join('\n'), + maxRetries: 1, + }); + + if (decision?.action === 'update' && decision.targetId) { + console.log(`[ST-BME] Mem0对照: create->update (${decision.targetId})`); + op.action = 'update'; + op.nodeId = decision.targetId; + if (decision.mergedFields) { + op.fields = { ...op.fields, ...decision.mergedFields }; + } + } else if (decision?.action === 'skip') { + console.log('[ST-BME] Mem0对照: create->skip (重复)'); + op.action = '_skip'; + } + } + } catch (e) { + console.warn('[ST-BME] Mem0对照失败,保持原操作:', e.message); + } + } +} + +/** + * 全局故事概要生成(MemoRAG 启发) + * 基于图中事件/角色/主线自动生成/更新 synopsis 节点 + * + * @param {object} params + * @param {object} params.graph + * @param {object[]} params.schema + * @param {number} params.currentSeq + * @returns {Promise} + */ +export async function generateSynopsis({ graph, schema, currentSeq }) { + const eventNodes = getActiveNodes(graph, 'event') + .sort((a, b) => a.seq - b.seq); + + if (eventNodes.length < 3) return; + + const eventSummaries = eventNodes.map(n => + `[楼${n.seq}] ${n.fields.summary || '(无)'}`, + ).join('\n'); + + const characterNodes = getActiveNodes(graph, 'character'); + const charSummary = characterNodes.map(n => + `${n.fields.name}: ${n.fields.state || '(无状态)'}`, + ).join('; '); + + const threadNodes = getActiveNodes(graph, 'thread'); + const threadSummary = threadNodes.map(n => + `${n.fields.title}: ${n.fields.status || 'active'}`, + ).join('; '); + + const result = await callLLMForJSON({ + systemPrompt: [ + '你是故事概要生成器。根据事件线、角色和主线生成简洁的前情提要。', + '输出 JSON:{"summary": "前情提要文本(200字以内)"}', + '要求:涵盖核心冲突、关键转折、主要角色当前状态。', + ].join('\n'), + userPrompt: [ + '## 事件时间线', + eventSummaries, + '', + '## 角色状态', + charSummary || '(无)', + '', + '## 活跃主线', + threadSummary || '(无)', + ].join('\n'), + maxRetries: 1, + }); + + if (!result?.summary) return; + + const existingSynopsis = graph.nodes.find( + n => n.type === 'synopsis' && !n.archived, + ); + + if (existingSynopsis) { + updateNode(graph, existingSynopsis.id, { + fields: { summary: result.summary, scope: `楼 1 ~ ${currentSeq}` }, + }); + existingSynopsis.embedding = null; + console.log('[ST-BME] 全局概要已更新'); + } else { + const node = createNode({ + type: 'synopsis', + fields: { summary: result.summary, scope: `楼 1 ~ ${currentSeq}` }, + seq: currentSeq, + importance: 9.0, + }); + addNode(graph, node); + console.log('[ST-BME] 全局概要已创建'); + } +} diff --git a/graph.js b/graph.js new file mode 100644 index 0000000..81e80f9 --- /dev/null +++ b/graph.js @@ -0,0 +1,462 @@ +// ST-BME: 图数据模型 +// 管理节点、边的 CRUD 操作,以及序列化到 chat_metadata + +/** + * 图状态版本号 + */ +const GRAPH_VERSION = 2; + +/** + * 生成 UUID v4 + */ +function uuid() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +/** + * 创建空的图状态 + * @returns {GraphState} + */ +export function createEmptyGraph() { + return { + version: GRAPH_VERSION, + lastProcessedSeq: 0, + nodes: [], + edges: [], + lastRecallResult: null, + }; +} + +// ==================== 节点操作 ==================== + +/** + * 创建新节点 + * @param {object} params + * @returns {object} 新节点 + */ +export function createNode({ + type, + fields = {}, + seq = 0, + seqRange = null, + importance = 5.0, + clusters = [], +}) { + const now = Date.now(); + return { + id: uuid(), + type, + level: 0, + parentId: null, + childIds: [], + seq, + seqRange: seqRange || [seq, seq], + archived: false, + fields, + embedding: null, + importance: Math.max(0, Math.min(10, importance)), + accessCount: 0, + lastAccessTime: now, + createdTime: now, + prevId: null, + nextId: null, + clusters, + }; +} + +/** + * 在图中添加节点 + * @param {GraphState} graph + * @param {object} node + * @returns {object} 添加的节点 + */ +export function addNode(graph, node) { + // 同类型节点的时间链表:连接到最后一个同类型节点 + const sameTypeNodes = graph.nodes + .filter(n => n.type === node.type && !n.archived && n.level === 0) + .sort((a, b) => a.seq - b.seq); + + if (sameTypeNodes.length > 0) { + const lastNode = sameTypeNodes[sameTypeNodes.length - 1]; + lastNode.nextId = node.id; + node.prevId = lastNode.id; + } + + graph.nodes.push(node); + return node; +} + +/** + * 根据 ID 获取节点 + * @param {GraphState} graph + * @param {string} nodeId + * @returns {object|null} + */ +export function getNode(graph, nodeId) { + return graph.nodes.find(n => n.id === nodeId) || null; +} + +/** + * 更新节点字段(部分更新) + * @param {GraphState} graph + * @param {string} nodeId + * @param {object} updates - 要更新的字段 + * @returns {boolean} 是否找到并更新 + */ +export function updateNode(graph, nodeId, updates) { + const node = getNode(graph, nodeId); + if (!node) return false; + + if (updates.fields) { + node.fields = { ...node.fields, ...updates.fields }; + delete updates.fields; + } + + Object.assign(node, updates); + return true; +} + +/** + * 删除节点及其相关边 + * @param {GraphState} graph + * @param {string} nodeId + * @returns {boolean} + */ +export function removeNode(graph, nodeId) { + const node = getNode(graph, nodeId); + if (!node) return false; + + // 修复时间链表 + if (node.prevId) { + const prev = getNode(graph, node.prevId); + if (prev) prev.nextId = node.nextId; + } + if (node.nextId) { + const next = getNode(graph, node.nextId); + if (next) next.prevId = node.prevId; + } + + // 递归删除子节点 + for (const childId of node.childIds) { + removeNode(graph, childId); + } + + // 从父节点中移除引用 + if (node.parentId) { + const parent = getNode(graph, node.parentId); + if (parent) { + parent.childIds = parent.childIds.filter(id => id !== nodeId); + } + } + + // 删除相关边 + graph.edges = graph.edges.filter(e => e.fromId !== nodeId && e.toId !== nodeId); + + // 删除节点本身 + graph.nodes = graph.nodes.filter(n => n.id !== nodeId); + + return true; +} + +/** + * 获取所有未归档的节点 + * @param {GraphState} graph + * @param {string} [typeFilter] - 可选类型过滤 + * @returns {object[]} + */ +export function getActiveNodes(graph, typeFilter = null) { + let nodes = graph.nodes.filter(n => !n.archived); + if (typeFilter) { + nodes = nodes.filter(n => n.type === typeFilter); + } + return nodes; +} + +/** + * 按类型查找最新版本的节点(用于 latestOnly 类型) + * @param {GraphState} graph + * @param {string} type + * @param {string} primaryKeyValue - 主键值(如角色名) + * @param {string} primaryKeyField - 主键字段名(默认 'name') + * @returns {object|null} + */ +export function findLatestNode(graph, type, primaryKeyValue, primaryKeyField = 'name') { + const candidates = graph.nodes.filter( + n => n.type === type && !n.archived && n.fields[primaryKeyField] === primaryKeyValue, + ); + if (candidates.length === 0) return null; + return candidates.sort((a, b) => b.seq - a.seq)[0]; +} + +// ==================== 边操作 ==================== + +/** + * 创建边 + * @param {object} params + * @returns {object} 新边 + */ +export function createEdge({ fromId, toId, relation = 'related', strength = 0.8, edgeType = 0 }) { + return { + id: uuid(), + fromId, + toId, + relation, + strength: Math.max(0, Math.min(1, strength)), + edgeType, + createdTime: Date.now(), + // Graphiti 启发的时序字段 + validAt: Date.now(), // 关系生效时间 + invalidAt: null, // 关系失效时间(null = 当前有效) + expiredAt: null, // 系统标记过期时间 + }; +} + +/** + * 在图中添加边(检查节点存在性) + * @param {GraphState} graph + * @param {object} edge + * @returns {object|null} 添加的边或 null + */ +export function addEdge(graph, edge) { + const from = getNode(graph, edge.fromId); + const to = getNode(graph, edge.toId); + if (!from || !to) return null; + if (edge.fromId === edge.toId) return null; + + // 检查重复边 + const existing = graph.edges.find( + e => e.fromId === edge.fromId && e.toId === edge.toId && e.relation === edge.relation, + ); + if (existing) { + // 更新已有边的强度 + existing.strength = Math.max(existing.strength, edge.strength); + return existing; + } + + graph.edges.push(edge); + return edge; +} + +/** + * 移除边 + * @param {GraphState} graph + * @param {string} edgeId + * @returns {boolean} + */ +export function removeEdge(graph, edgeId) { + const idx = graph.edges.findIndex(e => e.id === edgeId); + if (idx === -1) return false; + graph.edges.splice(idx, 1); + return true; +} + +/** + * 获取节点的所有出边 + * @param {GraphState} graph + * @param {string} nodeId + * @returns {object[]} + */ +export function getOutEdges(graph, nodeId) { + return graph.edges.filter(e => e.fromId === nodeId); +} + +/** + * 获取节点的所有入边 + * @param {GraphState} graph + * @param {string} nodeId + * @returns {object[]} + */ +export function getInEdges(graph, nodeId) { + return graph.edges.filter(e => e.toId === nodeId); +} + +/** + * 获取连接到节点的所有边(入+出) + * @param {GraphState} graph + * @param {string} nodeId + * @returns {object[]} + */ +export function getNodeEdges(graph, nodeId) { + return graph.edges.filter(e => e.fromId === nodeId || e.toId === nodeId); +} + +// ==================== 查询辅助 ==================== + +/** + * 构建邻接表(用于扩散引擎) + * @param {GraphState} graph + * @returns {Map>} + */ +export function buildAdjacencyMap(graph) { + const adj = new Map(); + + for (const edge of graph.edges) { + // 正向 + if (!adj.has(edge.fromId)) adj.set(edge.fromId, []); + adj.get(edge.fromId).push({ + targetId: edge.toId, + strength: edge.strength, + edgeType: edge.edgeType, + }); + + // 反向(图扩散是双向的) + if (!adj.has(edge.toId)) adj.set(edge.toId, []); + adj.get(edge.toId).push({ + targetId: edge.fromId, + strength: edge.strength, + edgeType: edge.edgeType, + }); + } + + return adj; +} + +/** + * 构建时序感知邻接表(过滤失效边) + * Graphiti 启发:只纳入 "当前有效" 的边 + * @param {GraphState} graph + * @returns {Map} + */ +export function buildTemporalAdjacencyMap(graph) { + const adj = new Map(); + const now = Date.now(); + + for (const edge of graph.edges) { + // 跳过已失效的边 + if (edge.invalidAt && edge.invalidAt <= now) continue; + if (edge.expiredAt) continue; + + // 正向 + if (!adj.has(edge.fromId)) adj.set(edge.fromId, []); + adj.get(edge.fromId).push({ + targetId: edge.toId, + strength: edge.strength, + edgeType: edge.edgeType, + }); + + // 反向 + if (!adj.has(edge.toId)) adj.set(edge.toId, []); + adj.get(edge.toId).push({ + targetId: edge.fromId, + strength: edge.strength, + edgeType: edge.edgeType, + }); + } + + return adj; +} + +/** + * 将边标记为失效(不删除,保留历史) + * @param {object} edge + */ +export function invalidateEdge(edge) { + edge.invalidAt = Date.now(); +} + +/** + * 获取图的统计信息 + * @param {GraphState} graph + * @returns {object} + */ +export function getGraphStats(graph) { + const activeNodes = graph.nodes.filter(n => !n.archived); + const archivedNodes = graph.nodes.filter(n => n.archived); + const typeCounts = {}; + for (const node of activeNodes) { + typeCounts[node.type] = (typeCounts[node.type] || 0) + 1; + } + + return { + totalNodes: graph.nodes.length, + activeNodes: activeNodes.length, + archivedNodes: archivedNodes.length, + totalEdges: graph.edges.length, + lastProcessedSeq: graph.lastProcessedSeq, + typeCounts, + }; +} + +// ==================== 序列化 ==================== + +/** + * 序列化图状态为 JSON 字符串 + * @param {GraphState} graph + * @returns {string} + */ +export function serializeGraph(graph) { + return JSON.stringify(graph); +} + +/** + * 从 JSON 反序列化图状态 + * @param {string} json + * @returns {GraphState} + */ +export function deserializeGraph(json) { + try { + const data = typeof json === 'string' ? JSON.parse(json) : json; + + if (!data || data.version === undefined) { + return createEmptyGraph(); + } + + // 版本迁移 + if (data.version < GRAPH_VERSION) { + console.log(`[ST-BME] 图版本迁移 v${data.version} → v${GRAPH_VERSION}`); + + // v1→v2 迁移:给旧边补充时序字段 + if (data.version < 2 && data.edges) { + for (const edge of data.edges) { + if (edge.validAt === undefined) edge.validAt = edge.createdTime || Date.now(); + if (edge.invalidAt === undefined) edge.invalidAt = null; + if (edge.expiredAt === undefined) edge.expiredAt = null; + } + } + + data.version = GRAPH_VERSION; + } + + // 确保字段完整 + data.nodes = data.nodes || []; + data.edges = data.edges || []; + data.lastProcessedSeq = data.lastProcessedSeq || 0; + data.lastRecallResult = data.lastRecallResult || null; + + return data; + } catch (e) { + console.error('[ST-BME] 图反序列化失败:', e); + return createEmptyGraph(); + } +} + +/** + * 导出图数据(不含 embedding 以减小体积) + * @param {GraphState} graph + * @returns {string} JSON 字符串 + */ +export function exportGraph(graph) { + const exportData = { + ...graph, + nodes: graph.nodes.map(n => ({ ...n, embedding: null })), + }; + return JSON.stringify(exportData, null, 2); +} + +/** + * 导入图数据 + * @param {string} json + * @returns {GraphState} + */ +export function importGraph(json) { + const graph = deserializeGraph(json); + // 导入的节点需要重新生成 embedding + for (const node of graph.nodes) { + node.embedding = null; + } + return graph; +} diff --git a/index.js b/index.js new file mode 100644 index 0000000..37b3af4 --- /dev/null +++ b/index.js @@ -0,0 +1,655 @@ +// ST-BME: 主入口 +// 事件钩子、设置管理、流程调度 + +import { extension_settings, getContext, saveMetadataDebounced } from '../../extensions.js'; +import { eventSource, event_types, saveSettingsDebounced } from '../../../script.js'; +import { renderExtensionTemplateAsync } from '../../extensions.js'; + +import { createEmptyGraph, deserializeGraph, serializeGraph, exportGraph, importGraph, getGraphStats } from './graph.js'; +import { DEFAULT_NODE_SCHEMA, validateSchema } from './schema.js'; +import { retrieve } from './retriever.js'; +import { extractMemories, generateSynopsis } from './extractor.js'; +import { compressAll, sleepCycle } from './compressor.js'; +import { formatInjection, estimateTokens } from './injector.js'; +import { testConnection as testEmbeddingConnection } from './embedding.js'; +import { evolveMemories } from './evolution.js'; + +const MODULE_NAME = 'st_bme'; +const GRAPH_METADATA_KEY = 'st_bme_graph'; + +// ==================== 默认设置 ==================== + +const defaultSettings = { + enabled: false, + + // 提取设置 + extractEvery: 1, // 每 N 条 assistant 回复提取一次 + extractContextTurns: 2, // 提取时包含的上下文楼层数 + + // 召回设置 + recallEnabled: true, + recallTopK: 15, // 混合评分 Top-K + recallMaxNodes: 8, // LLM 召回最大节点数 + recallEnableLLM: true, // 是否启用 LLM 精确召回 + + // 注入设置 + injectPosition: 'atDepth', // 注入位置 + injectDepth: 4, // 注入深度(atDepth 模式) + injectRole: 0, // 0=system, 1=user, 2=assistant + + // 混合评分权重 + graphWeight: 0.6, + vectorWeight: 0.3, + importanceWeight: 0.1, + + // Embedding API 配置 + embeddingApiUrl: '', + embeddingApiKey: '', + embeddingModel: 'text-embedding-3-small', + + // Schema + nodeTypeSchema: null, // null 表示使用默认 + + // 自定义提示词 + extractPrompt: '', + + // ====== v2 增强设置 ====== + + // ③ A-MEM 记忆进化 + enableEvolution: true, // 启用记忆进化 + evoNeighborCount: 5, // 近邻搜索数量 + evoConsolidateEvery: 50, // 每 N 次进化后整理 + + // ② Mem0 精确对照 + enablePreciseConflict: true, // 启用精确对照 + conflictThreshold: 0.85, // 相似度阈值 + + // ⑨ 全局故事概要 + enableSynopsis: true, // 启用全局概要 + synopsisEveryN: 5, // 每 N 次提取后更新概要 + + // ⑥ 认知边界过滤(P1) + enableVisibility: false, // 启用认知边界 + // ⑦ 双记忆交叉检索(P1) + enableCrossRecall: false, // 启用交叉检索 + + // ① 惊奇度分割(P2) + enableSmartTrigger: false, // 启用惊奇度分割 + triggerPatterns: '', // 自定义触发正则 + + // ⑤ 主动遗忘(P2) + enableSleepCycle: false, // 启用主动遗忘 + forgetThreshold: 0.5, // 保留价值阈值 + sleepEveryN: 10, // 每 N 次提取后执行 + + // ⑧ 概率触发回忆(P2) + enableProbRecall: false, // 启用概率触发 + probRecallChance: 0.15, // 触发概率 + + // ⑩ 反思条目(P2) + enableReflection: false, // 启用反思 + reflectEveryN: 10, // 每 N 次提取后反思 +}; + +// ==================== 状态 ==================== + +let currentGraph = null; +let isExtracting = false; +let isRecalling = false; +let lastInjectionContent = ''; +let extractionCount = 0; // v2: 提取次数计数器(定期触发概要/遗忘/反思) + +// ==================== 设置管理 ==================== + +function getSettings() { + if (!extension_settings[MODULE_NAME]) { + extension_settings[MODULE_NAME] = { ...defaultSettings }; + } + return extension_settings[MODULE_NAME]; +} + +function getSchema() { + const settings = getSettings(); + return settings.nodeTypeSchema || DEFAULT_NODE_SCHEMA; +} + +function getEmbeddingConfig() { + const settings = getSettings(); + return { + apiUrl: settings.embeddingApiUrl, + apiKey: settings.embeddingApiKey, + model: settings.embeddingModel, + }; +} + +// ==================== 图状态持久化 ==================== + +function loadGraphFromChat() { + const context = getContext(); + if (!context.chatMetadata) { + currentGraph = createEmptyGraph(); + return; + } + + const savedData = context.chatMetadata[GRAPH_METADATA_KEY]; + if (savedData) { + currentGraph = deserializeGraph(savedData); + console.log('[ST-BME] 从聊天数据加载图谱:', getGraphStats(currentGraph)); + } else { + currentGraph = createEmptyGraph(); + } +} + +function saveGraphToChat() { + const context = getContext(); + if (!context.chatMetadata || !currentGraph) return; + + context.chatMetadata[GRAPH_METADATA_KEY] = currentGraph; + saveMetadataDebounced(); +} + +// ==================== 核心流程 ==================== + +/** + * 提取管线:处理未提取的对话楼层 + */ +async function runExtraction() { + if (isExtracting || !currentGraph) return; + + const settings = getSettings(); + if (!settings.enabled) return; + + const context = getContext(); + const chat = context.chat; + if (!chat || chat.length === 0) return; + + // 找出 assistant 楼层序号 + const assistantTurns = []; + for (let i = 0; i < chat.length; i++) { + if (chat[i].is_user === false && !chat[i].is_system) { + assistantTurns.push(i); + } + } + + const lastProcessed = currentGraph.lastProcessedSeq; + const unprocessedStarts = assistantTurns.filter(i => i > lastProcessed); + + if (unprocessedStarts.length === 0) return; + + // 按 extractEvery 批次处理 + if (unprocessedStarts.length < settings.extractEvery) return; + + isExtracting = true; + + try { + // 收集要处理的消息 + const startIdx = unprocessedStarts[0]; + const endIdx = unprocessedStarts[unprocessedStarts.length - 1]; + + // 包含上下文 + const contextStart = Math.max(0, startIdx - settings.extractContextTurns * 2); + const messages = []; + for (let i = contextStart; i <= endIdx && i < chat.length; i++) { + const msg = chat[i]; + if (msg.is_system) continue; + messages.push({ + role: msg.is_user ? 'user' : 'assistant', + content: msg.mes || '', + }); + } + + console.log(`[ST-BME] 开始提取: 楼层 ${startIdx}-${endIdx}`); + + const result = await extractMemories({ + graph: currentGraph, + messages, + startSeq: endIdx, + schema: getSchema(), + embeddingConfig: getEmbeddingConfig(), + extractPrompt: settings.extractPrompt || undefined, + v2Options: { + enablePreciseConflict: settings.enablePreciseConflict, + conflictThreshold: settings.conflictThreshold, + }, + }); + + if (result.success) { + extractionCount++; + + // v2: A-MEM 记忆进化 + if (settings.enableEvolution && result.newNodeIds?.length > 0) { + try { + await evolveMemories({ + graph: currentGraph, + newNodeIds: result.newNodeIds, + embeddingConfig: getEmbeddingConfig(), + options: { neighborCount: settings.evoNeighborCount }, + }); + } catch (e) { + console.error('[ST-BME] 记忆进化失败:', e); + } + } + + // v2: 全局故事概要(每 N 次提取更新一次) + if (settings.enableSynopsis && extractionCount % settings.synopsisEveryN === 0) { + try { + await generateSynopsis({ + graph: currentGraph, + schema: getSchema(), + currentSeq: endIdx, + }); + } catch (e) { + console.error('[ST-BME] 概要生成失败:', e); + } + } + + // v2: 主动遗忘(每 N 次提取执行) + if (settings.enableSleepCycle && extractionCount % settings.sleepEveryN === 0) { + try { + sleepCycle(currentGraph, settings); + } catch (e) { + console.error('[ST-BME] 主动遗忘失败:', e); + } + } + + // 压缩检查 + await compressAll(currentGraph, getSchema(), getEmbeddingConfig()); + saveGraphToChat(); + } + } catch (e) { + console.error('[ST-BME] 提取失败:', e); + } finally { + isExtracting = false; + } +} + +/** + * 召回管线:检索并注入记忆 + */ +async function runRecall() { + if (isRecalling || !currentGraph) return; + + const settings = getSettings(); + if (!settings.enabled || !settings.recallEnabled) return; + + const context = getContext(); + const chat = context.chat; + if (!chat || chat.length === 0) return; + + isRecalling = true; + + try { + // 获取最新用户消息 + let userMessage = ''; + const recentMessages = []; + + for (let i = chat.length - 1; i >= 0 && recentMessages.length < 4; i--) { + const msg = chat[i]; + if (msg.is_system) continue; + + if (msg.is_user && !userMessage) { + userMessage = msg.mes || ''; + } + recentMessages.unshift(`[${msg.is_user ? 'user' : 'assistant'}]: ${msg.mes || ''}`); + } + + if (!userMessage) return; + + console.log('[ST-BME] 开始召回'); + + const result = await retrieve({ + graph: currentGraph, + userMessage, + recentMessages, + embeddingConfig: getEmbeddingConfig(), + schema: getSchema(), + options: { + topK: settings.recallTopK, + maxRecallNodes: settings.recallMaxNodes, + enableLLMRecall: settings.recallEnableLLM, + weights: { + graphWeight: settings.graphWeight, + vectorWeight: settings.vectorWeight, + importanceWeight: settings.importanceWeight, + }, + // v2 options + enableVisibility: settings.enableVisibility ?? false, + visibilityFilter: context.name2 || null, + enableCrossRecall: settings.enableCrossRecall ?? false, + enableProbRecall: settings.enableProbRecall ?? false, + probRecallChance: settings.probRecallChance ?? 0.15, + }, + }); + + // 格式化注入文本 + const injectionText = formatInjection(result, getSchema()); + lastInjectionContent = injectionText; + + if (injectionText) { + const tokens = estimateTokens(injectionText); + console.log(`[ST-BME] 注入 ${tokens} 估算 tokens, Core=${result.stats.coreCount}, Recall=${result.stats.recallCount}`); + + // 使用 ST 的 extension prompt API 注入 + context.setExtensionPrompt( + MODULE_NAME, + injectionText, + 1, // extension_prompt_types.IN_PROMPT + settings.injectDepth, + ); + } + + // 保存召回结果和访问强化 + currentGraph.lastRecallResult = result.selectedNodeIds; + saveGraphToChat(); + } catch (e) { + console.error('[ST-BME] 召回失败:', e); + } finally { + isRecalling = false; + } +} + +// ==================== 事件钩子 ==================== + +function onChatChanged() { + loadGraphFromChat(); + lastInjectionContent = ''; +} + +async function onGenerationAfterCommands() { + await runExtraction(); +} + +async function onBeforeCombinePrompts() { + await runRecall(); +} + +function onMessageReceived() { + // 新消息到达,图状态可能需要更新 + if (currentGraph) { + saveGraphToChat(); + } +} + +// ==================== UI 操作 ==================== + +async function onViewGraph() { + if (!currentGraph) { + toastr.warning('当前没有加载的图谱'); + return; + } + + const stats = getGraphStats(currentGraph); + const statsText = [ + `节点: ${stats.activeNodes} 活跃 / ${stats.archivedNodes} 归档`, + `边: ${stats.totalEdges}`, + `最后处理楼层: ${stats.lastProcessedSeq}`, + `类型分布: ${Object.entries(stats.typeCounts).map(([k, v]) => `${k}=${v}`).join(', ') || '(空)'}`, + ].join('\n'); + + toastr.info(statsText, 'ST-BME 图谱状态', { timeOut: 10000 }); +} + +async function onRebuild() { + if (!confirm('确定要从当前聊天重建图谱?这将清除现有图谱数据。')) return; + + currentGraph = createEmptyGraph(); + saveGraphToChat(); + + toastr.info('图谱已重置,将在下次生成时重新提取'); +} + +async function onManualCompress() { + if (!currentGraph) return; + + const result = await compressAll(currentGraph, getSchema(), getEmbeddingConfig(), false); + saveGraphToChat(); + + toastr.info(`压缩完成: 新建 ${result.created}, 归档 ${result.archived}`); +} + +async function onExportGraph() { + if (!currentGraph) return; + + const json = exportGraph(currentGraph); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `st-bme-graph-${Date.now()}.json`; + a.click(); + URL.revokeObjectURL(url); + + toastr.success('图谱已导出'); +} + +async function onImportGraph() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + input.onchange = async (e) => { + const file = e.target.files[0]; + if (!file) return; + + try { + const text = await file.text(); + currentGraph = importGraph(text); + saveGraphToChat(); + toastr.success('图谱已导入'); + } catch (err) { + toastr.error(`导入失败: ${err.message}`); + } + }; + input.click(); +} + +async function onViewLastInjection() { + if (!lastInjectionContent) { + toastr.info('暂无注入内容'); + return; + } + + // 简单弹窗显示 + const popup = document.createElement('div'); + popup.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#1a1a2e;color:#eee;padding:24px;border-radius:12px;max-width:80vw;max-height:80vh;overflow:auto;z-index:99999;white-space:pre-wrap;font-size:13px;box-shadow:0 8px 32px rgba(0,0,0,0.5);'; + popup.textContent = lastInjectionContent; + + const close = document.createElement('button'); + close.textContent = '关闭'; + close.style.cssText = 'position:absolute;top:8px;right:12px;background:#e94560;color:white;border:none;padding:4px 12px;border-radius:4px;cursor:pointer;'; + close.onclick = () => popup.remove(); + popup.appendChild(close); + + document.body.appendChild(popup); +} + +async function onTestEmbedding() { + const config = getEmbeddingConfig(); + if (!config.apiUrl || !config.apiKey) { + toastr.warning('请先配置 Embedding API 地址和 Key'); + return; + } + + toastr.info('正在测试 Embedding API 连通性...'); + const result = await testEmbeddingConnection(config); + + if (result.success) { + toastr.success(`连接成功!向量维度: ${result.dimensions}`); + } else { + toastr.error(`连接失败: ${result.error}`); + } +} + +// ==================== 设置 UI ==================== + +function bindSettingsUI() { + const settings = getSettings(); + + // 开关 + $('#st_bme_enabled').prop('checked', settings.enabled).on('change', function () { + settings.enabled = $(this).prop('checked'); + saveSettingsDebounced(); + }); + + // 提取频率 + $('#st_bme_extract_every').val(settings.extractEvery).on('input', function () { + settings.extractEvery = Math.max(1, parseInt($(this).val()) || 1); + saveSettingsDebounced(); + }); + + // 召回开关 + $('#st_bme_recall_enabled').prop('checked', settings.recallEnabled).on('change', function () { + settings.recallEnabled = $(this).prop('checked'); + saveSettingsDebounced(); + }); + + // LLM 精确召回 + $('#st_bme_recall_llm').prop('checked', settings.recallEnableLLM).on('change', function () { + settings.recallEnableLLM = $(this).prop('checked'); + saveSettingsDebounced(); + }); + + // 注入深度 + $('#st_bme_inject_depth').val(settings.injectDepth).on('input', function () { + settings.injectDepth = Math.max(0, parseInt($(this).val()) || 4); + saveSettingsDebounced(); + }); + + // 评分权重 + $('#st_bme_graph_weight').val(settings.graphWeight).on('input', function () { + settings.graphWeight = parseFloat($(this).val()) || 0.6; + saveSettingsDebounced(); + }); + $('#st_bme_vector_weight').val(settings.vectorWeight).on('input', function () { + settings.vectorWeight = parseFloat($(this).val()) || 0.3; + saveSettingsDebounced(); + }); + $('#st_bme_importance_weight').val(settings.importanceWeight).on('input', function () { + settings.importanceWeight = parseFloat($(this).val()) || 0.1; + saveSettingsDebounced(); + }); + + // Embedding API + $('#st_bme_embed_url').val(settings.embeddingApiUrl).on('input', function () { + settings.embeddingApiUrl = $(this).val().trim(); + saveSettingsDebounced(); + }); + $('#st_bme_embed_key').val(settings.embeddingApiKey).on('input', function () { + settings.embeddingApiKey = $(this).val().trim(); + saveSettingsDebounced(); + }); + $('#st_bme_embed_model').val(settings.embeddingModel).on('input', function () { + settings.embeddingModel = $(this).val().trim(); + saveSettingsDebounced(); + }); + + // 操作按钮 + $('#st_bme_btn_view_graph').on('click', onViewGraph); + $('#st_bme_btn_rebuild').on('click', onRebuild); + $('#st_bme_btn_compress').on('click', onManualCompress); + $('#st_bme_btn_export').on('click', onExportGraph); + $('#st_bme_btn_import').on('click', onImportGraph); + $('#st_bme_btn_view_injection').on('click', onViewLastInjection); + $('#st_bme_btn_test_embed').on('click', onTestEmbedding); + + // ====== v2 增强设置 UI 绑定 ====== + + // P0: 记忆进化 + $('#st_bme_evolution').prop('checked', settings.enableEvolution).on('change', function () { + settings.enableEvolution = $(this).prop('checked'); + saveSettingsDebounced(); + }); + $('#st_bme_evo_neighbors').val(settings.evoNeighborCount).on('input', function () { + settings.evoNeighborCount = Math.max(1, parseInt($(this).val()) || 5); + saveSettingsDebounced(); + }); + + // P0: 精确对照 + $('#st_bme_precise_conflict').prop('checked', settings.enablePreciseConflict).on('change', function () { + settings.enablePreciseConflict = $(this).prop('checked'); + saveSettingsDebounced(); + }); + $('#st_bme_conflict_threshold').val(settings.conflictThreshold).on('input', function () { + settings.conflictThreshold = parseFloat($(this).val()) || 0.85; + saveSettingsDebounced(); + }); + + // P0: 全局概要 + $('#st_bme_synopsis').prop('checked', settings.enableSynopsis).on('change', function () { + settings.enableSynopsis = $(this).prop('checked'); + saveSettingsDebounced(); + }); + $('#st_bme_synopsis_every').val(settings.synopsisEveryN).on('input', function () { + settings.synopsisEveryN = Math.max(1, parseInt($(this).val()) || 5); + saveSettingsDebounced(); + }); + + // P1: 认知边界 + $('#st_bme_visibility').prop('checked', settings.enableVisibility ?? false).on('change', function () { + settings.enableVisibility = $(this).prop('checked'); + saveSettingsDebounced(); + }); + + // P1: 交叉检索 + $('#st_bme_cross_recall').prop('checked', settings.enableCrossRecall ?? false).on('change', function () { + settings.enableCrossRecall = $(this).prop('checked'); + saveSettingsDebounced(); + }); + + // P2: 惊奇度分割 + $('#st_bme_smart_trigger').prop('checked', settings.enableSmartTrigger).on('change', function () { + settings.enableSmartTrigger = $(this).prop('checked'); + saveSettingsDebounced(); + }); + + // P2: 主动遗忘 + $('#st_bme_sleep_cycle').prop('checked', settings.enableSleepCycle).on('change', function () { + settings.enableSleepCycle = $(this).prop('checked'); + saveSettingsDebounced(); + }); + $('#st_bme_forget_threshold').val(settings.forgetThreshold).on('input', function () { + settings.forgetThreshold = parseFloat($(this).val()) || 0.5; + saveSettingsDebounced(); + }); + + // P2: 概率触发 + $('#st_bme_prob_recall').prop('checked', settings.enableProbRecall).on('change', function () { + settings.enableProbRecall = $(this).prop('checked'); + saveSettingsDebounced(); + }); + $('#st_bme_prob_chance').val(settings.probRecallChance).on('input', function () { + settings.probRecallChance = parseFloat($(this).val()) || 0.15; + saveSettingsDebounced(); + }); + + // P2: 反思条目 + $('#st_bme_reflection').prop('checked', settings.enableReflection).on('change', function () { + settings.enableReflection = $(this).prop('checked'); + saveSettingsDebounced(); + }); + $('#st_bme_reflect_every').val(settings.reflectEveryN).on('input', function () { + settings.reflectEveryN = Math.max(3, parseInt($(this).val()) || 10); + saveSettingsDebounced(); + }); +} + +// ==================== 初始化 ==================== + +(async function init() { + // 加载设置面板 HTML + const settingsHtml = await renderExtensionTemplateAsync('third-party/st-bme', 'settings'); + $('#extensions_settings2').append(settingsHtml); + + // 绑定 UI + bindSettingsUI(); + + // 注册事件钩子 + eventSource.on(event_types.CHAT_CHANGED, onChatChanged); + eventSource.on(event_types.GENERATION_AFTER_COMMANDS, onGenerationAfterCommands); + eventSource.on(event_types.GENERATE_BEFORE_COMBINE_PROMPTS, onBeforeCombinePrompts); + eventSource.on(event_types.MESSAGE_RECEIVED, onMessageReceived); + + // 加载当前聊天的图谱 + loadGraphFromChat(); + + console.log('[ST-BME] 初始化完成'); +})(); diff --git a/injector.js b/injector.js new file mode 100644 index 0000000..e8b2ae5 --- /dev/null +++ b/injector.js @@ -0,0 +1,107 @@ +// ST-BME: Prompt 注入模块 +// 将检索结果格式化为表格注入到 LLM 上下文中 + +import { getSchemaType } from './schema.js'; + +/** + * 将检索结果转换为注入文本 + * + * @param {object} retrievalResult - retriever.retrieve() 的返回值 + * @param {object[]} schema - 节点类型 Schema + * @returns {string} 注入文本 + */ +export function formatInjection(retrievalResult, schema) { + const { coreNodes, recallNodes } = retrievalResult; + const parts = []; + + // ========== Core 常驻注入 ========== + if (coreNodes.length > 0) { + parts.push('[Memory - Core]'); + + // 按类型分组 + const grouped = groupByType(coreNodes); + + for (const [typeId, nodes] of grouped) { + const typeDef = getSchemaType(schema, typeId); + if (!typeDef) continue; + + const table = formatTable(nodes, typeDef); + if (table) parts.push(table); + } + } + + // ========== Recall 召回注入 ========== + if (recallNodes.length > 0) { + parts.push(''); + parts.push('[Memory - Recalled]'); + + const grouped = groupByType(recallNodes); + + for (const [typeId, nodes] of grouped) { + const typeDef = getSchemaType(schema, typeId); + if (!typeDef) continue; + + const table = formatTable(nodes, typeDef); + if (table) parts.push(table); + } + } + + return parts.join('\n'); +} + +/** + * 按类型分组节点 + */ +function groupByType(nodes) { + const map = new Map(); + for (const node of nodes) { + if (!map.has(node.type)) map.set(node.type, []); + map.get(node.type).push(node); + } + return map; +} + +/** + * 将同类型节点格式化为 Markdown 表格 + */ +function formatTable(nodes, typeDef) { + if (nodes.length === 0) return ''; + + // 确定要展示的列(有实际数据的列) + const activeCols = typeDef.columns.filter(col => + nodes.some(n => n.fields[col.name]), + ); + + if (activeCols.length === 0) return ''; + + // 表头 + const header = `| ${activeCols.map(c => c.name).join(' | ')} |`; + const separator = `| ${activeCols.map(() => '---').join(' | ')} |`; + + // 数据行 + const rows = nodes.map(node => { + const cells = activeCols.map(col => { + const val = node.fields[col.name] || ''; + // 转义管道符,限制单元格长度 + return String(val).replace(/\|/g, '\\|').replace(/\n/g, ' ').slice(0, 200); + }); + return `| ${cells.join(' | ')} |`; + }); + + return `${typeDef.tableName}:\n${header}\n${separator}\n${rows.join('\n')}`; +} + +/** + * 获取注入提示词的总 token 估算 + * 粗略估算:1 个 token ≈ 2 个中文字符 或 4 个英文字符 + * + * @param {string} injectionText + * @returns {number} 估算 token 数 + */ +export function estimateTokens(injectionText) { + if (!injectionText) return 0; + // 简单估算:中文 2 字符/token,英文 4 字符/token + const cnChars = (injectionText.match(/[\u4e00-\u9fff]/g) || []).length; + const otherChars = injectionText.length - cnChars; + return Math.ceil(cnChars / 2 + otherChars / 4); +} diff --git a/llm.js b/llm.js new file mode 100644 index 0000000..8287cc3 --- /dev/null +++ b/llm.js @@ -0,0 +1,125 @@ +// ST-BME: LLM 调用封装 +// 包装 ST 的 sendOpenAIRequest,提供结构化 JSON 输出和重试机制 + +import { sendOpenAIRequest } from '../../openai.js'; + +/** + * 调用 LLM 并期望返回结构化 JSON + * + * @param {object} params + * @param {string} params.systemPrompt - 系统提示词 + * @param {string} params.userPrompt - 用户提示词 + * @param {number} [params.maxRetries=2] - JSON 解析失败时的重试次数 + * @param {string} [params.model] - 指定模型(留空使用当前配置) + * @returns {Promise} 解析后的 JSON 对象,或 null + */ +export async function callLLMForJSON({ systemPrompt, userPrompt, maxRetries = 2 }) { + const messages = [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ]; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const response = await sendOpenAIRequest('quiet', messages); + + if (!response || typeof response !== 'string') { + console.warn(`[ST-BME] LLM 返回空响应 (尝试 ${attempt + 1})`); + continue; + } + + // 尝试解析 JSON + const parsed = extractJSON(response); + if (parsed !== null) { + return parsed; + } + + console.warn(`[ST-BME] LLM 响应无法解析为 JSON (尝试 ${attempt + 1}):`, response.slice(0, 200)); + + // 重试时在 user prompt 中追加提示 + if (attempt < maxRetries) { + messages.push({ role: 'assistant', content: response }); + messages.push({ role: 'user', content: '你的上一次输出无法被解析为有效 JSON。请严格按照要求的 JSON 格式重新输出,不要包含 markdown 代码块标记或其他非 JSON 文本。' }); + } + } catch (e) { + console.error(`[ST-BME] LLM 调用失败 (尝试 ${attempt + 1}):`, e); + } + } + + return null; +} + +/** + * 调用 LLM(不要求 JSON 输出) + * + * @param {string} systemPrompt + * @param {string} userPrompt + * @returns {Promise} + */ +export async function callLLM(systemPrompt, userPrompt) { + const messages = [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ]; + + try { + const response = await sendOpenAIRequest('quiet', messages); + return response || null; + } catch (e) { + console.error('[ST-BME] LLM 调用失败:', e); + return null; + } +} + +/** + * 从 LLM 响应文本中提取 JSON 对象 + * 处理各种常见格式:纯 JSON、markdown 代码块、混合文本等 + * + * @param {string} text + * @returns {object|null} + */ +function extractJSON(text) { + if (!text || typeof text !== 'string') return null; + + const trimmed = text.trim(); + + // 1. 直接尝试解析 + try { + return JSON.parse(trimmed); + } catch { /* continue */ } + + // 2. 尝试提取 markdown 代码块中的 JSON + const codeBlockMatch = trimmed.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/); + if (codeBlockMatch) { + try { + return JSON.parse(codeBlockMatch[1].trim()); + } catch { /* continue */ } + } + + // 3. 尝试找到第一个 { 或 [ 开始的 JSON + const firstBrace = trimmed.indexOf('{'); + const firstBracket = trimmed.indexOf('['); + + let startIdx = -1; + let endChar = ''; + + if (firstBrace >= 0 && (firstBracket < 0 || firstBrace < firstBracket)) { + startIdx = firstBrace; + endChar = '}'; + } else if (firstBracket >= 0) { + startIdx = firstBracket; + endChar = ']'; + } + + if (startIdx >= 0) { + // 从后往前找匹配的结束字符 + const lastEnd = trimmed.lastIndexOf(endChar); + if (lastEnd > startIdx) { + try { + return JSON.parse(trimmed.slice(startIdx, lastEnd + 1)); + } catch { /* continue */ } + } + } + + return null; +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..bb9ff3e --- /dev/null +++ b/manifest.json @@ -0,0 +1,11 @@ +{ + "display_name": "ST-BME Memory Graph", + "loading_order": 150, + "requires": [], + "optional": [], + "js": "index.js", + "css": "style.css", + "author": "ST-BME", + "version": "0.1.0", + "homePage": "" +} diff --git a/retriever.js b/retriever.js new file mode 100644 index 0000000..9d94e4c --- /dev/null +++ b/retriever.js @@ -0,0 +1,386 @@ +// ST-BME: 三层混合检索编排 +// 融合向量预筛(PeroCore)+ 图扩散(PeroCore PEDSA)+ 可选 LLM 精确召回 +// v2: + 认知边界过滤(RoleRAG) + 双记忆交叉检索(AriGraph) + 概率触发 + +import { getActiveNodes, buildAdjacencyMap, buildTemporalAdjacencyMap, getNode, getNodeEdges } from './graph.js'; +import { propagateActivation, diffuseAndRank } from './diffusion.js'; +import { embedText, searchSimilar } from './embedding.js'; +import { hybridScore, reinforceAccessBatch } from './dynamics.js'; +import { callLLMForJSON } from './llm.js'; + +/** + * 自适应阈值 + */ +const STRATEGY_THRESHOLDS = { + SMALL: 20, // < 20 节点:跳过向量,全图 + LLM + MEDIUM: 200, // 20-200 节点:向量 + 图扩散 + 评分(不调 LLM) + // > 200 节点:三层全开 +}; + +/** + * 三层混合检索管线 + * + * @param {object} params + * @param {object} params.graph - 当前图状态 + * @param {string} params.userMessage - 用户输入 + * @param {string[]} params.recentMessages - 最近几轮对话内容 + * @param {object} params.embeddingConfig - Embedding API 配置 + * @param {object[]} params.schema - 节点类型 Schema + * @param {object} [params.options] - 检索选项 + * @returns {Promise} + */ +export async function retrieve({ + graph, + userMessage, + recentMessages = [], + embeddingConfig, + schema, + options = {}, +}) { + const topK = options.topK ?? 15; + const maxRecallNodes = options.maxRecallNodes ?? 8; + const enableLLMRecall = options.enableLLMRecall ?? true; + const weights = options.weights ?? {}; + + // v2 options + const enableVisibility = options.enableVisibility ?? false; + const visibilityFilter = options.visibilityFilter ?? null; + const enableCrossRecall = options.enableCrossRecall ?? false; + const enableProbRecall = options.enableProbRecall ?? false; + const probRecallChance = options.probRecallChance ?? 0.15; + + let activeNodes = getActiveNodes(graph); + + // v2 ⑦: 认知边界过滤(RoleRAG 启发) + if (enableVisibility && visibilityFilter) { + activeNodes = filterByVisibility(activeNodes, visibilityFilter); + } + + const nodeCount = activeNodes.length; + console.log(`[ST-BME] 检索开始: ${nodeCount} 个活跃节点${enableVisibility ? ' (认知边界已启用)' : ''}`); + + let vectorResults = []; + let diffusionResults = []; + let useLLM = false; + + if (nodeCount === 0) { + return buildResult(graph, [], schema); + } + + // ========== 第 1 层:向量预筛 ========== + if (nodeCount >= STRATEGY_THRESHOLDS.SMALL && embeddingConfig?.apiUrl) { + console.log('[ST-BME] 第1层: 向量预筛'); + vectorResults = await vectorPreFilter(userMessage, activeNodes, embeddingConfig, topK); + } + + // ========== 第 2 层:图扩散 ========== + if (nodeCount >= STRATEGY_THRESHOLDS.SMALL) { + console.log('[ST-BME] 第2层: PEDSA 图扩散'); + const entityAnchors = extractEntityAnchors(userMessage, activeNodes); + + const seeds = [ + ...vectorResults.map(v => ({ id: v.nodeId, energy: v.score })), + ...entityAnchors.map(a => ({ id: a.nodeId, energy: 2.0 })), + ]; + + // v2 ⑧: 双记忆交叉检索(AriGraph 启发) + // 实体锚点命中后,沿边展开关联的情景节点作为额外种子 + if (enableCrossRecall && entityAnchors.length > 0) { + for (const anchor of entityAnchors) { + const connectedEdges = getNodeEdges(graph, anchor.nodeId); + for (const edge of connectedEdges) { + if (edge.invalidAt) continue; + const neighborId = edge.fromId === anchor.nodeId ? edge.toId : edge.fromId; + const neighbor = getNode(graph, neighborId); + if (neighbor && !neighbor.archived && neighbor.type === 'event') { + seeds.push({ id: neighborId, energy: 1.5 * edge.strength }); + } + } + } + } + + // 去重种子 + const seedMap = new Map(); + for (const s of seeds) { + const existing = seedMap.get(s.id) || 0; + if (s.energy > existing) seedMap.set(s.id, s.energy); + } + const uniqueSeeds = [...seedMap.entries()].map(([id, energy]) => ({ id, energy })); + + if (uniqueSeeds.length > 0) { + const adjacencyMap = buildTemporalAdjacencyMap(graph); + diffusionResults = diffuseAndRank(adjacencyMap, uniqueSeeds, { + maxSteps: 2, + decayFactor: 0.6, + topK: 100, + }); + } + } + + // ========== 第 3 层:混合评分 + 可选 LLM 精确 ========== + console.log('[ST-BME] 第3层: 混合评分'); + + // 构建评分表 + const scoreMap = new Map(); + + // 添加向量得分 + for (const v of vectorResults) { + const entry = scoreMap.get(v.nodeId) || { graphScore: 0, vectorScore: 0 }; + entry.vectorScore = v.score; + scoreMap.set(v.nodeId, entry); + } + + // 添加图扩散得分 + for (const d of diffusionResults) { + const entry = scoreMap.get(d.nodeId) || { graphScore: 0, vectorScore: 0 }; + entry.graphScore = d.energy; + scoreMap.set(d.nodeId, entry); + } + + // 小图模式:所有节点都参与评分 + if (nodeCount < STRATEGY_THRESHOLDS.SMALL) { + for (const node of activeNodes) { + if (!scoreMap.has(node.id)) { + scoreMap.set(node.id, { graphScore: 0, vectorScore: 0 }); + } + } + } + + // 计算混合得分 + const scoredNodes = []; + for (const [nodeId, scores] of scoreMap) { + const node = getNode(graph, nodeId); + if (!node || node.archived) continue; + + const finalScore = hybridScore({ + graphScore: scores.graphScore, + vectorScore: scores.vectorScore, + importance: node.importance, + createdTime: node.createdTime, + }, weights); + + scoredNodes.push({ nodeId, node, finalScore, ...scores }); + } + + scoredNodes.sort((a, b) => b.finalScore - a.finalScore); + + // 决定是否使用 LLM 精确召回 + useLLM = enableLLMRecall && ( + nodeCount < STRATEGY_THRESHOLDS.SMALL || // 小图:直接 LLM + nodeCount > STRATEGY_THRESHOLDS.MEDIUM // 大图:LLM 精确 + ); + + let selectedNodeIds; + + if (useLLM && nodeCount > 0) { + console.log('[ST-BME] LLM 精确召回'); + const candidateNodes = scoredNodes.slice(0, Math.min(30, scoredNodes.length)); + selectedNodeIds = await llmRecall( + userMessage, + recentMessages, + candidateNodes, + graph, + schema, + maxRecallNodes, + ); + } else { + // 中等图:直接取 Top-N + selectedNodeIds = scoredNodes + .slice(0, topK) + .map(s => s.nodeId); + } + + // 访问强化 + const selectedNodes = selectedNodeIds + .map(id => getNode(graph, id)) + .filter(Boolean); + + reinforceAccessBatch(selectedNodes); + + console.log(`[ST-BME] 检索完成: 选中 ${selectedNodeIds.length} 个节点`); + + // v2 ⑧: 概率触发回忆 + // 未被选中的高重要性节点有概率随机激活 + if (enableProbRecall && probRecallChance > 0) { + const selectedSet = new Set(selectedNodeIds); + const candidates = activeNodes.filter(n => + !selectedSet.has(n.id) && + n.importance >= 6 && + n.type !== 'synopsis' && + n.type !== 'rule', + ); + for (const c of candidates) { + if (Math.random() < probRecallChance) { + selectedNodeIds.push(c.id); + console.log(`[ST-BME] 概率触发: ${c.fields?.name || c.fields?.summary || c.id}`); + } + } + } + + return buildResult(graph, selectedNodeIds, schema); +} + +/** + * 向量预筛选 + */ +async function vectorPreFilter(userMessage, activeNodes, embeddingConfig, topK) { + try { + const queryVec = await embedText(userMessage, embeddingConfig); + if (!queryVec) return []; + + const candidates = activeNodes + .filter(n => n.embedding) + .map(n => ({ nodeId: n.id, embedding: n.embedding })); + + return searchSimilar(queryVec, candidates, topK); + } catch (e) { + console.error('[ST-BME] 向量预筛失败:', e); + return []; + } +} + +/** + * 实体锚点提取 + * 从用户消息中提取名词/实体,匹配图中的节点名称 + */ +function extractEntityAnchors(userMessage, activeNodes) { + const anchors = []; + + for (const node of activeNodes) { + // 检查 name 字段 + const name = node.fields?.name; + if (name && userMessage.includes(name)) { + anchors.push({ nodeId: node.id, entity: name }); + continue; + } + + // 检查 title 字段 + const title = node.fields?.title; + if (title && userMessage.includes(title)) { + anchors.push({ nodeId: node.id, entity: title }); + } + } + + return anchors; +} + +/** + * LLM 精确召回 + */ +async function llmRecall(userMessage, recentMessages, candidates, graph, schema, maxNodes) { + const contextStr = recentMessages.join('\n---\n'); + const candidateDescriptions = candidates.map(c => { + const node = c.node; + const typeDef = schema.find(s => s.id === node.type); + const typeLabel = typeDef?.label || node.type; + const fieldsStr = Object.entries(node.fields) + .map(([k, v]) => `${k}: ${v}`) + .join(', '); + return `[${node.id}] 类型=${typeLabel}, ${fieldsStr} (评分=${c.finalScore.toFixed(3)})`; + }).join('\n'); + + const systemPrompt = [ + '你是一个记忆召回分析器。', + '根据用户最新输入和对话上下文,从候选记忆节点中选择最相关的节点。', + '优先选择:(1) 直接相关的当前场景节点, (2) 因果关系连续性节点, (3) 有潜在影响的背景节点。', + `最多选择 ${maxNodes} 个节点。`, + '输出严格的 JSON 格式:', + '{"selected_ids": ["id1", "id2", ...], "reason": "简要说明选择理由"}', + ].join('\n'); + + const userPrompt = [ + '## 最近对话上下文', + contextStr || '(无)', + '', + '## 用户最新输入', + userMessage, + '', + '## 候选记忆节点', + candidateDescriptions, + '', + '请选择最相关的节点并输出 JSON。', + ].join('\n'); + + const result = await callLLMForJSON({ systemPrompt, userPrompt, maxRetries: 1 }); + + if (result?.selected_ids && Array.isArray(result.selected_ids)) { + // 校验 ID 有效性 + const validIds = result.selected_ids.filter( + id => candidates.some(c => c.nodeId === id), + ); + return validIds; + } + + // LLM 失败时回退到纯评分排序 + return candidates.slice(0, maxNodes).map(c => c.nodeId); +} + +// ==================== v2 辅助函数 ==================== + +/** + * ⑥ 认知边界过滤(RoleRAG 启发) + * 过滤掉设置了 visibility 但不包含当前角色的节点 + * @param {object[]} nodes + * @param {string} characterName - 当前视角角色名 + * @returns {object[]} + */ +function filterByVisibility(nodes, characterName) { + return nodes.filter(node => { + // 没有 visibility 字段 → 对所有人可见 + if (!node.fields?.visibility) return true; + // visibility 是数组 → 检查当前角色是否在列表中 + if (Array.isArray(node.fields.visibility)) { + return node.fields.visibility.includes(characterName); + } + // visibility 是字符串(逗号分隔)→ 解析后检查 + if (typeof node.fields.visibility === 'string') { + const visibleTo = node.fields.visibility.split(',').map(s => s.trim()); + return visibleTo.includes(characterName) || visibleTo.includes('*'); + } + return true; + }); +} + +/** + * 构建最终检索结果 + * 分离常驻注入(Core)和召回注入(Recall) + */ +function buildResult(graph, selectedNodeIds, schema) { + const coreNodes = []; // 常驻注入 + const recallNodes = []; // 召回注入 + + // 常驻注入节点(alwaysInject=true 的类型) + const alwaysInjectTypes = new Set( + schema.filter(s => s.alwaysInject).map(s => s.id), + ); + + const activeNodes = getActiveNodes(graph); + + for (const node of activeNodes) { + if (alwaysInjectTypes.has(node.type)) { + coreNodes.push(node); + } + } + + // 召回注入节点 + const selectedSet = new Set(selectedNodeIds); + for (const nodeId of selectedNodeIds) { + const node = getNode(graph, nodeId); + if (!node) continue; + // 已在 Core 中的不重复添加 + if (!alwaysInjectTypes.has(node.type)) { + recallNodes.push(node); + } + } + + return { + coreNodes, + recallNodes, + selectedNodeIds: [...selectedNodeIds], + stats: { + totalActive: activeNodes.length, + coreCount: coreNodes.length, + recallCount: recallNodes.length, + }, + }; +} diff --git a/schema.js b/schema.js new file mode 100644 index 0000000..a3c2ff9 --- /dev/null +++ b/schema.js @@ -0,0 +1,249 @@ +// ST-BME: 节点类型 Schema 定义 +// 定义图谱中支持的节点类型、字段、注入策略和压缩配置 + +/** + * 压缩模式 + */ +export const COMPRESSION_MODE = { + NONE: 'none', + HIERARCHICAL: 'hierarchical', +}; + +/** + * 默认节点类型 Schema + * 每种类型定义了: + * - id: 唯一标识 + * - label: 显示名称 + * - tableName: 注入时的表名 + * - columns: 字段列表 [{name, hint, required}] + * - alwaysInject: 是否常驻注入(true=Core, false=需要召回) + * - latestOnly: 是否只保留最新版本(用于角色/地点等随时间更新的实体) + * - forceUpdate: 每次提取是否必须产出此类型节点 + * - compression: 压缩配置 + */ +export const DEFAULT_NODE_SCHEMA = [ + { + id: 'event', + label: '事件', + tableName: 'event_table', + columns: [ + { name: 'summary', hint: '事件摘要,包含因果关系和结果', required: true }, + { name: 'participants', hint: '参与角色名,逗号分隔', required: false }, + { name: 'status', hint: '事件状态:ongoing/resolved/blocked', required: false }, + ], + alwaysInject: true, + latestOnly: false, + forceUpdate: true, + compression: { + mode: COMPRESSION_MODE.HIERARCHICAL, + threshold: 9, + fanIn: 3, + maxDepth: 10, + keepRecentLeaves: 6, + instruction: '将事件节点压缩为高价值的剧情里程碑摘要。保留因果关系、不可逆结果和未解决的伏笔。', + }, + }, + { + id: 'character', + label: '角色', + tableName: 'character_table', + columns: [ + { name: 'name', hint: '角色名(仅规范名称)', required: true }, + { name: 'traits', hint: '稳定的性格特征和外貌标记', required: false }, + { name: 'state', hint: '当前状态或处境', required: false }, + { name: 'goal', hint: '当前目标或动机', required: false }, + { name: 'inventory', hint: '携带或拥有的关键物品', required: false }, + { name: 'core_note', hint: '值得长期记住的关键备注', required: false }, + ], + alwaysInject: false, + latestOnly: true, + forceUpdate: false, + compression: { + mode: COMPRESSION_MODE.NONE, + threshold: 0, + fanIn: 0, + maxDepth: 0, + keepRecentLeaves: 0, + instruction: '', + }, + }, + { + id: 'location', + label: '地点', + tableName: 'location_table', + columns: [ + { name: 'name', hint: '地点名称(仅规范名称)', required: true }, + { name: 'state', hint: '当前状态或环境条件', required: false }, + { name: 'features', hint: '重要特征、资源或服务', required: false }, + { name: 'danger', hint: '危险等级或威胁', required: false }, + ], + alwaysInject: false, + latestOnly: true, + forceUpdate: false, + compression: { + mode: COMPRESSION_MODE.NONE, + threshold: 0, + fanIn: 0, + maxDepth: 0, + keepRecentLeaves: 0, + instruction: '', + }, + }, + { + id: 'rule', + label: '规则', + tableName: 'rule_table', + columns: [ + { name: 'title', hint: '简短规则名', required: true }, + { name: 'constraint', hint: '不可违反的规则内容', required: true }, + { name: 'scope', hint: '适用范围/场景', required: false }, + { name: 'status', hint: '当前有效性:active/suspended/revoked', required: false }, + ], + alwaysInject: true, + latestOnly: false, + forceUpdate: false, + compression: { + mode: COMPRESSION_MODE.NONE, + threshold: 0, + fanIn: 0, + maxDepth: 0, + keepRecentLeaves: 0, + instruction: '', + }, + }, + { + id: 'thread', + label: '主线', + tableName: 'thread_table', + columns: [ + { name: 'title', hint: '主线名称', required: true }, + { name: 'summary', hint: '当前进展摘要', required: false }, + { name: 'status', hint: '状态:active/completed/abandoned', required: false }, + ], + alwaysInject: true, + latestOnly: false, + forceUpdate: false, + compression: { + mode: COMPRESSION_MODE.HIERARCHICAL, + threshold: 6, + fanIn: 3, + maxDepth: 5, + keepRecentLeaves: 3, + instruction: '将主线节点压缩为阶段性进展摘要。保留关键转折和当前目标。', + }, + }, + // ====== v2 新增节点类型 ====== + { + id: 'synopsis', + label: '全局概要', + tableName: 'synopsis_table', + columns: [ + { name: 'summary', hint: '当前故事的全局概要(前情提要)', required: true }, + { name: 'scope', hint: '概要覆盖的楼层范围', required: false }, + ], + alwaysInject: true, // 常驻注入(MemoRAG 启发) + latestOnly: true, // 只保留最新版本 + forceUpdate: false, + compression: { + mode: COMPRESSION_MODE.NONE, + threshold: 0, + fanIn: 0, + maxDepth: 0, + keepRecentLeaves: 0, + instruction: '', + }, + }, + { + id: 'reflection', + label: '反思', + tableName: 'reflection_table', + columns: [ + { name: 'insight', hint: '对角色行为或情节的元认知反思', required: true }, + { name: 'trigger', hint: '触发反思的事件/矛盾', required: false }, + { name: 'suggestion', hint: '对后续叙事的建议', required: false }, + ], + alwaysInject: false, // 需要被召回 + latestOnly: false, + forceUpdate: false, + compression: { + mode: COMPRESSION_MODE.HIERARCHICAL, + threshold: 6, + fanIn: 3, + maxDepth: 3, + keepRecentLeaves: 3, + instruction: '将反思条目合并为高层次的叙事指导原则。', + }, + }, +]; + +/** + * 规范化的关系类型 + */ +export const RELATION_TYPES = [ + 'related', // 一般关联 + 'involved_in', // 参与事件 + 'occurred_at', // 发生于地点 + 'advances', // 推进主线 + 'updates', // 更新实体状态 + 'contradicts', // 矛盾/冲突(用于抑制边) + 'evolves', // A-MEM 进化链接(新→旧) + 'temporal_update', // 时序更新(Graphiti:新状态替代旧状态) +]; + +/** + * 验证 Schema 配置的合法性 + * @param {Array} schema + * @returns {{valid: boolean, errors: string[]}} + */ +export function validateSchema(schema) { + const errors = []; + + if (!Array.isArray(schema) || schema.length === 0) { + errors.push('Schema 必须是非空数组'); + return { valid: false, errors }; + } + + const ids = new Set(); + const tableNames = new Set(); + + for (const type of schema) { + if (!type.id || typeof type.id !== 'string') { + errors.push('每种类型必须有 id'); + continue; + } + + if (ids.has(type.id)) { + errors.push(`类型 ID 重复:${type.id}`); + } + ids.add(type.id); + + if (!type.tableName || typeof type.tableName !== 'string') { + errors.push(`类型 ${type.id}:缺少 tableName`); + } else if (tableNames.has(type.tableName)) { + errors.push(`表名重复:${type.tableName}`); + } else { + tableNames.add(type.tableName); + } + + if (!Array.isArray(type.columns) || type.columns.length === 0) { + errors.push(`类型 ${type.id}:至少需要一个列`); + } + + const hasRequired = type.columns?.some(c => c.required); + if (!hasRequired) { + errors.push(`类型 ${type.id}:至少需要一个 required 列`); + } + } + + return { valid: errors.length === 0, errors }; +} + +/** + * 获取 Schema 中某个类型的定义 + * @param {Array} schema + * @param {string} typeId + * @returns {object|null} + */ +export function getSchemaType(schema, typeId) { + return schema.find(t => t.id === typeId) || null; +} diff --git a/settings.html b/settings.html new file mode 100644 index 0000000..9fed2db --- /dev/null +++ b/settings.html @@ -0,0 +1,247 @@ +
+
+
+ ST-BME 图谱记忆 +
+
+
+ + +
+
+ +
+ +
+ + +
+
+ +
+ + +
+

召回设置

+ +
+ +
+ +
+ +
+ +
+ + +
+
+ +
+ + +
+

混合评分权重

+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+

+ v2 增强功能 +

+ + +
+ P0 + +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+
+ + +
+ P1 + +
+ +
+ +
+ +
+
+ + +
+ P2 + +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+
+
+ +
+ + +
+

Embedding API

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ +
+ + +
+

操作

+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+
diff --git a/style.css b/style.css new file mode 100644 index 0000000..a98a324 --- /dev/null +++ b/style.css @@ -0,0 +1,126 @@ +/* ST-BME 样式 */ + +.st-bme-settings { + --bme-accent: #e94560; + --bme-accent-dim: rgba(233, 69, 96, 0.15); + --bme-surface: rgba(255, 255, 255, 0.03); + --bme-border: rgba(255, 255, 255, 0.08); +} + +.st-bme-section { + padding: 8px 0; +} + +.st-bme-section-title { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--bme-accent); + margin: 0 0 8px 0; + font-weight: 600; +} + +.st-bme-section-title i { + margin-right: 4px; +} + +.st-bme-hr { + border: none; + border-top: 1px solid var(--bme-border); + margin: 4px 0; +} + +.st-bme-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 4px 0; +} + +.st-bme-row label { + font-size: 12px; + flex-shrink: 0; + min-width: 100px; +} + +.st-bme-row .text_pole { + max-width: 180px; +} + +.st-bme-row .checkbox_label { + justify-content: flex-start; + gap: 8px; +} + +.st-bme-row.st-bme-indent { + padding-left: 24px; +} + +.st-bme-btn-group { + display: flex; + gap: 6px; + margin: 4px 0; +} + +.st-bme-btn-group .menu_button { + flex: 1; + font-size: 12px; + padding: 6px 10px; + border-radius: 6px; + border: 1px solid var(--bme-border); + transition: all 0.2s ease; +} + +.st-bme-btn-group .menu_button:hover { + border-color: var(--bme-accent); + background: var(--bme-accent-dim); +} + +.st-bme-btn-group .menu_button i { + margin-right: 4px; +} + +/* v2: 子分区 */ +.st-bme-subsection { + position: relative; + padding: 6px 0 6px 4px; + margin: 4px 0; + border-left: 2px solid var(--bme-border); +} + +/* v2: 优先级 badge */ +.st-bme-badge { + display: inline-block; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.5px; + padding: 1px 6px; + border-radius: 3px; + margin-bottom: 4px; +} + +.st-bme-badge-p0 { + background: rgba(233, 69, 96, 0.25); + color: #e94560; + border: 1px solid rgba(233, 69, 96, 0.4); +} + +.st-bme-badge-p1 { + background: rgba(255, 193, 7, 0.2); + color: #ffc107; + border: 1px solid rgba(255, 193, 7, 0.35); +} + +.st-bme-badge-p2 { + background: rgba(76, 175, 80, 0.15); + color: #66bb6a; + border: 1px solid rgba(76, 175, 80, 0.3); +} + +/* v2: 技术来源提示 */ +.st-bme-hint { + font-size: 10px; + color: rgba(255, 255, 255, 0.35); + font-style: italic; +}