@morningljn/mnemo 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.d.ts +2 -0
- package/dist/config.js +28 -0
- package/dist/config.js.map +1 -0
- package/dist/dream-engine.d.ts +17 -0
- package/dist/dream-engine.js +144 -0
- package/dist/dream-engine.js.map +1 -0
- package/dist/llm-client.d.ts +10 -0
- package/dist/llm-client.js +55 -0
- package/dist/llm-client.js.map +1 -0
- package/dist/resources.js +1 -1
- package/dist/store.d.ts +2 -1
- package/dist/store.js +32 -8
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +14 -0
- package/docs/superpowers/plans/2026-05-16-llm-dream.md +973 -0
- package/openspec/changes/llm-dream/.openspec.yaml +2 -0
- package/openspec/changes/llm-dream/design.md +84 -0
- package/openspec/changes/llm-dream/proposal.md +36 -0
- package/openspec/changes/llm-dream/specs/dream-cycle/spec.md +42 -0
- package/openspec/changes/llm-dream/specs/llm-client/spec.md +57 -0
- package/openspec/changes/llm-dream/specs/llm-dream-engine/spec.md +72 -0
- package/openspec/changes/llm-dream/tasks.md +32 -0
- package/package.json +1 -1
- package/src/config.ts +29 -0
- package/src/dream-engine.ts +162 -0
- package/src/llm-client.ts +59 -0
- package/src/resources.ts +1 -1
- package/src/store.ts +39 -7
- package/src/types.ts +16 -0
- package/tests/dream-engine.test.ts +163 -0
- package/tests/llm-client.test.ts +105 -0
- package/tests/store.test.ts +6 -5
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
## Context
|
|
2
|
+
|
|
3
|
+
mnemo-mcp 是一个 SQLite 驱动的记忆管理 MCP 服务器,当前 dream cycle 通过硬编码规则(Jaccard 词频、关键词匹配、截断压缩)整理记忆。用户有 71 条事实,规则引擎几乎不产生效果(merge=0, compress=0, reclassified=反复震荡)。
|
|
4
|
+
|
|
5
|
+
用户本地有 Ollama(HomeUbuntu 上运行 qwen3:8b 等),也有云端 API(智谱/DeepSeek/Kimi)。触发方式保持手动(CLI `mnemo-dream` 或 MCP `dream` action)。
|
|
6
|
+
|
|
7
|
+
## Goals / Non-Goals
|
|
8
|
+
|
|
9
|
+
**Goals:**
|
|
10
|
+
- LLM 理解语义后做合并、摘要、分类,产生实际可感知的整理效果
|
|
11
|
+
- 本地优先:默认 Ollama,零成本
|
|
12
|
+
- 云端可插拔:支持 OpenAI 兼容 API(智谱/DeepSeek/Kimi 等)
|
|
13
|
+
- 安全:硬编码安全层保护高信任/高频 fact
|
|
14
|
+
- 降级:LLM 不可用时回退到规则引擎
|
|
15
|
+
|
|
16
|
+
**Non-Goals:**
|
|
17
|
+
- 自动定时触发(保持手动触发)
|
|
18
|
+
- 流式输出 / 进度回调
|
|
19
|
+
- 多轮对话式确认(全自动,触发即执行)
|
|
20
|
+
- 训练/微调模型
|
|
21
|
+
|
|
22
|
+
## Decisions
|
|
23
|
+
|
|
24
|
+
### Decision 1: 统一使用 OpenAI 兼容 `/v1/chat/completions` 接口
|
|
25
|
+
|
|
26
|
+
**选择**: 只实现一个 OpenAI 兼容客户端。Ollama 本地(localhost:11434/v1)和 Ollama 云端(ollama.com/v1)以及所有国产模型 API(智谱/DeepSeek/Kimi)都走 `/v1/chat/completions`
|
|
27
|
+
|
|
28
|
+
**备选**: 分别实现 Ollama 原生 API(/api/chat)和 OpenAI API 两个客户端
|
|
29
|
+
|
|
30
|
+
**理由**:
|
|
31
|
+
- Ollama 已原生支持 OpenAI 兼容接口(`/v1/chat/completions`)
|
|
32
|
+
- 用户可配置 baseUrl 指向:本地 Ollama / ollama.com / 智谱 / DeepSeek / 任意 OpenAI 兼容 API
|
|
33
|
+
- 只需一个客户端实现,零 SDK 依赖,用 Node.js 原生 `fetch()`
|
|
34
|
+
- 配置示例:`{ baseUrl: "http://localhost:11434/v1" }` 或 `{ baseUrl: "https://ollama.com/v1", apiKey: "..." }`
|
|
35
|
+
|
|
36
|
+
### Decision 2: 批量处理,每批 20 条 fact 送 LLM
|
|
37
|
+
|
|
38
|
+
**选择**: 同 category 的 facts 按每批 20 条分组,一次性送 LLM 分析
|
|
39
|
+
|
|
40
|
+
**备选**: 逐条送 LLM / 全部一次性送
|
|
41
|
+
|
|
42
|
+
**理由**:
|
|
43
|
+
- 逐条:token 浪费(每次都传 system prompt),延迟高
|
|
44
|
+
- 全部一次:71 条 fact 的 content 总长约 40K 字,超出小模型上下文
|
|
45
|
+
- 每批 20 条:约 5-10K 字 input,适合 8B 模型(如 qwen3:8b 的 32K 上下文)
|
|
46
|
+
|
|
47
|
+
### Decision 3: 配置文件可选,无配置时用默认值
|
|
48
|
+
|
|
49
|
+
**选择**: `~/.mnemo/config.json` 为可选文件。不存在时默认 `ollama/localhost:11434/qwen3:8b`
|
|
50
|
+
|
|
51
|
+
**备选**: 必须配置才能使用 LLM dream
|
|
52
|
+
|
|
53
|
+
**理由**:
|
|
54
|
+
- 用户 HomeUbuntu 上已有 Ollama 运行,开箱即用
|
|
55
|
+
- 零配置降低使用门槛
|
|
56
|
+
- macOS 本地无 Ollama 时自动降级到规则引擎
|
|
57
|
+
|
|
58
|
+
### Decision 4: 安全层在 LLM 输出之后、数据库操作之前执行
|
|
59
|
+
|
|
60
|
+
**选择**: LLM 返回操作建议 → 安全层校验(信任度/数量/格式) → 执行数据库操作
|
|
61
|
+
|
|
62
|
+
**备选**: 在 LLM prompt 中加入安全约束
|
|
63
|
+
|
|
64
|
+
**理由**:
|
|
65
|
+
- LLM 可能不遵守 prompt 约束(尤其是小模型)
|
|
66
|
+
- 安全长用硬编码 TypeScript 保证不会误删
|
|
67
|
+
- 职责分离:LLM 负责理解语义,代码负责安全边界
|
|
68
|
+
|
|
69
|
+
### Decision 5: Prompt 用中文,适配中文记忆内容
|
|
70
|
+
|
|
71
|
+
**选择**: 所有 LLM prompt 使用中文指令
|
|
72
|
+
|
|
73
|
+
**理由**:
|
|
74
|
+
- mnemo 的 fact 内容主要是中文
|
|
75
|
+
- 中文 prompt 对中文内容的理解更准确
|
|
76
|
+
- qwen3 对中文支持好
|
|
77
|
+
|
|
78
|
+
## Risks / Trade-offs
|
|
79
|
+
|
|
80
|
+
- **[小模型幻觉风险]** qwen3:8b 可能输出格式错误的 JSON → 用 try-catch 解析,解析失败丢弃该批结果,降级到规则引擎处理该批
|
|
81
|
+
- **[Ollama 连接失败]** 用户本地未启动 Ollama → 自动降级到规则引擎,dream report 中标记 `fallback: true`
|
|
82
|
+
- **[Token 成本]** 使用云端 API 时每次 dream 消耗 token → 配置中可设 provider,默认 Ollama 零成本
|
|
83
|
+
- **[删除误伤]** LLM 可能错误判断"语义重复" → 安全层限制:单次最多删除 10% fact,信任度 > 0.8 禁止删除
|
|
84
|
+
- **[处理速度]** 71 条 fact 批量送 LLM,每批 20 条约 3 批,总耗时 10-30 秒(取决于模型速度) → 可接受,dream 本就不频繁
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
## Why
|
|
2
|
+
|
|
3
|
+
当前 dream cycle 使用硬编码规则(Jaccard 词频合并、关键词重分类、截断压缩),无法理解语义。导致:
|
|
4
|
+
- 两条用词不同但语义相同的 fact(如"喜欢VS Code" vs "偏好Visual Studio Code")永远无法合并
|
|
5
|
+
- 分类只靠关键词匹配,容易震荡(同一条 fact 反复换分类)
|
|
6
|
+
- 摘要只是截取前两句,不是真正的信息提炼
|
|
7
|
+
|
|
8
|
+
需要引入 LLM 做语义级别的记忆整理,让 dream 产生实际效果。
|
|
9
|
+
|
|
10
|
+
## What Changes
|
|
11
|
+
|
|
12
|
+
- 新增 LLM 客户端抽象层,支持 Ollama(默认)和 OpenAI 兼容 API
|
|
13
|
+
- 新增配置系统(`~/.mnemo/config.json`),支持 LLM provider/model/参数配置
|
|
14
|
+
- 改造 dream cycle 三个核心任务为 LLM 驱动:
|
|
15
|
+
- **语义合并**:LLM 判断同 category facts 是否语义重复,输出合并建议
|
|
16
|
+
- **智能摘要**:LLM 提取长 fact 的核心信息作为 summary
|
|
17
|
+
- **智能分类**:LLM 判断 general 分类的 fact 应归属哪个 category
|
|
18
|
+
- 新增安全验证层(硬编码规则,不经过 LLM):信任度保护、删除数量上限、备份
|
|
19
|
+
- Ollama 不可用时自动降级到当前规则引擎
|
|
20
|
+
|
|
21
|
+
## Capabilities
|
|
22
|
+
|
|
23
|
+
### New Capabilities
|
|
24
|
+
- `llm-client`: LLM 客户端抽象层,支持 Ollama 和 OpenAI 兼容 API,包含配置加载和健康检查
|
|
25
|
+
- `llm-dream-engine`: LLM 驱动的 dream engine,包含语义合并、智能摘要、智能分类三个任务,安全验证层,降级策略
|
|
26
|
+
|
|
27
|
+
### Modified Capabilities
|
|
28
|
+
- `dream-cycle`: runDream() 从纯规则引擎改为调用 LLM dream engine,保留规则引擎作为降级方案
|
|
29
|
+
|
|
30
|
+
## Impact
|
|
31
|
+
|
|
32
|
+
- **新增文件**:`src/llm-client.ts`(LLM 客户端)、`src/dream-engine.ts`(LLM dream engine)
|
|
33
|
+
- **修改文件**:`src/store.ts`(runDream 集成 dream engine)、`src/types.ts`(新增配置类型)、`src/server.ts`(dream action 传递配置)
|
|
34
|
+
- **新增依赖**:无(使用 Node.js 原生 fetch 调用 Ollama/OpenAI API)
|
|
35
|
+
- **配置文件**:新增 `~/.mnemo/config.json` 支持可选配置
|
|
36
|
+
- **向后兼容**:Ollama 不可用时自动降级到规则引擎,不影响现有用户
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
## MODIFIED Requirements
|
|
2
|
+
|
|
3
|
+
### Requirement: Dream action 整理记忆库
|
|
4
|
+
系统 SHALL 提供 `fact_store(action="dream")` 操作,优先使用 LLM 做语义级整理(合并、摘要、分类),LLM 不可用时降级到规则引擎。整理前自动备份数据库。
|
|
5
|
+
|
|
6
|
+
#### Scenario: LLM 驱动的语义合并
|
|
7
|
+
- **WHEN** 同 category 内存在语义重复的 fact(由 LLM 判断)
|
|
8
|
+
- **THEN** 系统合并重复 fact,保留内容更完整的,在 dream report 中记录合并对和原因
|
|
9
|
+
|
|
10
|
+
#### Scenario: LLM 驱动的智能摘要
|
|
11
|
+
- **WHEN** fact 的 content 长度 > 200 字且 summary 为 NULL
|
|
12
|
+
- **THEN** 系统由 LLM 生成精准摘要(≤ 150 字)写入 summary 字段
|
|
13
|
+
|
|
14
|
+
#### Scenario: LLM 驱动的智能分类
|
|
15
|
+
- **WHEN** fact 的 category 为 "general" 但内容属于其他 category
|
|
16
|
+
- **THEN** 系统由 LLM 判断正确分类并更新
|
|
17
|
+
|
|
18
|
+
#### Scenario: 降级到规则引擎
|
|
19
|
+
- **WHEN** LLM 服务不可用(连接失败/超时)
|
|
20
|
+
- **THEN** 系统自动降级到规则引擎(Jaccard 合并、截取摘要、关键词分类),report 中标记 fallback: true
|
|
21
|
+
|
|
22
|
+
#### Scenario: Dream 前备份
|
|
23
|
+
- **WHEN** dream action 被触发
|
|
24
|
+
- **THEN** 系统在执行任何修改前,自动将数据库备份到备份目录
|
|
25
|
+
|
|
26
|
+
#### Scenario: 输出 dream report
|
|
27
|
+
- **WHEN** dream 整理完成
|
|
28
|
+
- **THEN** 系统返回 JSON 报告,包含 merged、compressed、reclassified、deleted 计数、health 统计、fallback 标记
|
|
29
|
+
|
|
30
|
+
### Requirement: CLI dream 命令
|
|
31
|
+
系统 SHALL 提供 `mnemo-dream` CLI 命令,手动触发 LLM 驱动的 dream 整理。
|
|
32
|
+
|
|
33
|
+
#### Scenario: 手动执行 dream
|
|
34
|
+
- **WHEN** 用户运行 `mnemo-dream`
|
|
35
|
+
- **THEN** 系统执行 LLM 驱动的 dream cycle 并输出 report 到 stdout
|
|
36
|
+
|
|
37
|
+
### Requirement: 高频 fact 保护
|
|
38
|
+
Dream 整理 SHALL 保护检索次数 > 100 或信任度 > 0.8 的 fact 不被删除,无论 LLM 是否建议删除。
|
|
39
|
+
|
|
40
|
+
#### Scenario: 高频/高信任 fact 不被合并删除
|
|
41
|
+
- **WHEN** LLM 建议删除 retrieval_count > 100 或 trust_score > 0.8 的 fact
|
|
42
|
+
- **THEN** 系统拒绝该删除操作
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
## ADDED Requirements
|
|
2
|
+
|
|
3
|
+
### Requirement: OpenAI 兼容 LLM 客户端
|
|
4
|
+
系统 SHALL 提供统一 LLM 客户端,使用 OpenAI 兼容的 `/v1/chat/completions` 接口。通过配置 baseUrl 支持 Ollama 本地、Ollama 云端(ollama.com/v1)、智谱、DeepSeek 等任何 OpenAI 兼容 API。
|
|
5
|
+
|
|
6
|
+
#### Scenario: 连接 Ollama 本地
|
|
7
|
+
- **WHEN** config.baseUrl 为 "http://localhost:11434/v1"
|
|
8
|
+
- **THEN** 系统向 `localhost:11434/v1/chat/completions` 发送请求,无需 apiKey
|
|
9
|
+
|
|
10
|
+
#### Scenario: 连接 Ollama 云端
|
|
11
|
+
- **WHEN** config.baseUrl 为 "https://ollama.com/v1" 且提供 apiKey
|
|
12
|
+
- **THEN** 系统向 `ollama.com/v1/chat/completions` 发送请求,附带 Authorization header
|
|
13
|
+
|
|
14
|
+
#### Scenario: 连接第三方 OpenAI 兼容 API
|
|
15
|
+
- **WHEN** config.baseUrl 为 "https://open.bigmodel.cn/api/paas/v4" 且提供 apiKey
|
|
16
|
+
- **THEN** 系统向对应 `/chat/completions` 端点发送请求
|
|
17
|
+
|
|
18
|
+
### Requirement: LLM 聊天接口
|
|
19
|
+
系统 SHALL 提供 `chat(messages, options)` 方法,返回 LLM 文本响应。
|
|
20
|
+
|
|
21
|
+
#### Scenario: 成功调用返回文本
|
|
22
|
+
- **WHEN** 调用 chat([{ role: "user", content: "..." }], { temperature: 0.1 })
|
|
23
|
+
- **THEN** 系统返回 LLM 生成的文本内容
|
|
24
|
+
|
|
25
|
+
#### Scenario: 连接失败抛出错误
|
|
26
|
+
- **WHEN** LLM 服务不可用(连接拒绝/超时)
|
|
27
|
+
- **THEN** 系统抛出 LLMConnectionError,包含原始错误信息
|
|
28
|
+
|
|
29
|
+
#### Scenario: JSON 响应解析
|
|
30
|
+
- **WHEN** LLM 响应内容可解析为 JSON
|
|
31
|
+
- **THEN** 系统返回解析后的 JSON 对象
|
|
32
|
+
|
|
33
|
+
#### Scenario: JSON 解析失败
|
|
34
|
+
- **WHEN** LLM 响应不是有效 JSON
|
|
35
|
+
- **THEN** 系统抛出 LLMResponseError,包含原始响应文本
|
|
36
|
+
|
|
37
|
+
### Requirement: LLM 健康检查
|
|
38
|
+
系统 SHALL 提供 `isAvailable()` 方法,检测 LLM 服务是否可达。
|
|
39
|
+
|
|
40
|
+
#### Scenario: 服务可用
|
|
41
|
+
- **WHEN** 调用 isAvailable()
|
|
42
|
+
- **THEN** 系统向 baseUrl/models 端点发送 GET 请求,成功返回 true
|
|
43
|
+
|
|
44
|
+
#### Scenario: 服务不可用
|
|
45
|
+
- **WHEN** 调用 isAvailable()
|
|
46
|
+
- **THEN** 连接失败返回 false,不抛出错误
|
|
47
|
+
|
|
48
|
+
### Requirement: 配置加载
|
|
49
|
+
系统 SHALL 从 `~/.mnemo/config.json` 加载 LLM 配置。文件不存在时使用默认配置。
|
|
50
|
+
|
|
51
|
+
#### Scenario: 配置文件存在
|
|
52
|
+
- **WHEN** `~/.mnemo/config.json` 存在且包含 llm 字段
|
|
53
|
+
- **THEN** 系统使用配置文件中的 baseUrl/model/apiKey/temperature
|
|
54
|
+
|
|
55
|
+
#### Scenario: 配置文件不存在
|
|
56
|
+
- **WHEN** `~/.mnemo/config.json` 不存在
|
|
57
|
+
- **THEN** 系统使用默认配置:`{ baseUrl: "http://localhost:11434/v1", model: "qwen3:8b", temperature: 0.1 }`
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
## ADDED Requirements
|
|
2
|
+
|
|
3
|
+
### Requirement: LLM 语义合并
|
|
4
|
+
系统 SHALL 将同 category 的 facts 按每批 20 条送 LLM,由 LLM 判断语义重复并输出合并建议。
|
|
5
|
+
|
|
6
|
+
#### Scenario: LLM 识别语义重复
|
|
7
|
+
- **WHEN** 同 category 内存在两条用词不同但语义相同的 fact(如"喜欢VS Code"和"偏好Visual Studio Code")
|
|
8
|
+
- **THEN** LLM 返回 merge 建议 `{"kept": factId, "removed": factId, "reason": "..."}`,系统执行合并
|
|
9
|
+
|
|
10
|
+
#### Scenario: LLM 判断不重复
|
|
11
|
+
- **WHEN** 同 category 内两条 fact 语义不同
|
|
12
|
+
- **THEN** LLM 不输出合并建议,系统不操作
|
|
13
|
+
|
|
14
|
+
#### Scenario: LLM 输出格式错误
|
|
15
|
+
- **WHEN** LLM 返回的 JSON 无法解析或缺少必需字段(kept/removed)
|
|
16
|
+
- **THEN** 系统丢弃该批合并建议,不执行任何操作
|
|
17
|
+
|
|
18
|
+
### Requirement: LLM 智能摘要
|
|
19
|
+
系统 SHALL 将 content > 200 字且 summary 为 NULL 的 fact 送 LLM 生成精准摘要。
|
|
20
|
+
|
|
21
|
+
#### Scenario: LLM 生成摘要
|
|
22
|
+
- **WHEN** fact 的 content 长度 > 200 且 summary 为空
|
|
23
|
+
- **THEN** LLM 返回 `{"summary": "核心信息..."}`,系统写入 summary 字段,摘要长度 SHALL ≤ 150 字
|
|
24
|
+
|
|
25
|
+
#### Scenario: 已有摘要跳过
|
|
26
|
+
- **WHEN** fact 已有 summary
|
|
27
|
+
- **THEN** 系统不发送给 LLM,跳过该 fact
|
|
28
|
+
|
|
29
|
+
#### Scenario: LLM 摘要超长
|
|
30
|
+
- **WHEN** LLM 返回的 summary 长度 > 150 字
|
|
31
|
+
- **THEN** 系统截断到 150 字
|
|
32
|
+
|
|
33
|
+
### Requirement: LLM 智能分类
|
|
34
|
+
系统 SHALL 将 category 为 "general" 的 facts 送 LLM 判断正确分类。
|
|
35
|
+
|
|
36
|
+
#### Scenario: LLM 正确分类
|
|
37
|
+
- **WHEN** general 分类中的 fact 内容属于 identity/coding_style/tool_pref/workflow
|
|
38
|
+
- **THEN** LLM 返回 `{"fact_id": id, "to": "target_category"}`,系统更新 category
|
|
39
|
+
|
|
40
|
+
#### Scenario: LLM 判断应保持 general
|
|
41
|
+
- **WHEN** fact 内容不属于其他四个 category
|
|
42
|
+
- **THEN** LLM 返回 `{"fact_id": id, "to": "general"}`,系统不操作
|
|
43
|
+
|
|
44
|
+
#### Scenario: LLM 返回无效 category
|
|
45
|
+
- **WHEN** LLM 返回的 target 不在 [identity, coding_style, tool_pref, workflow, general] 中
|
|
46
|
+
- **THEN** 系统丢弃该分类建议
|
|
47
|
+
|
|
48
|
+
### Requirement: 安全验证层
|
|
49
|
+
系统 SHALL 在执行 LLM 建议的数据库操作前,进行硬编码安全校验。
|
|
50
|
+
|
|
51
|
+
#### Scenario: 高信任 fact 禁止删除
|
|
52
|
+
- **WHEN** LLM 建议删除 trust_score > 0.8 的 fact
|
|
53
|
+
- **THEN** 系统拒绝该删除操作
|
|
54
|
+
|
|
55
|
+
#### Scenario: 单次删除数量限制
|
|
56
|
+
- **WHEN** LLM 建议删除的 fact 数量超过总量的 10%
|
|
57
|
+
- **THEN** 系统只执行前 10% 的删除,丢弃多余建议
|
|
58
|
+
|
|
59
|
+
#### Scenario: 高频 fact 保护
|
|
60
|
+
- **WHEN** LLM 建议删除 retrieval_count > 100 的 fact
|
|
61
|
+
- **THEN** 系统拒绝该删除操作
|
|
62
|
+
|
|
63
|
+
### Requirement: 降级策略
|
|
64
|
+
系统 SHALL 在 LLM 不可用时自动降级到规则引擎。
|
|
65
|
+
|
|
66
|
+
#### Scenario: Ollama 不可用自动降级
|
|
67
|
+
- **WHEN** Ollama 连接失败或超时
|
|
68
|
+
- **THEN** 系统自动使用当前硬编码规则引擎执行 dream,report 中标记 `fallback: true`
|
|
69
|
+
|
|
70
|
+
#### Scenario: 降级后 report 包含 fallback 标记
|
|
71
|
+
- **WHEN** dream 执行了降级路径
|
|
72
|
+
- **THEN** DreamReport 中 `fallback` 字段为 true,`fallbackReason` 字段记录原因
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
## 1. 类型定义与配置
|
|
2
|
+
|
|
3
|
+
- [ ] 1.1 在 `src/types.ts` 新增 LLM 相关类型:LLMConfig(baseUrl/model/apiKey/temperature)、LLMMessage(role/content)、DreamReport 新增 fallback/fallbackReason 字段
|
|
4
|
+
- [ ] 1.2 新增 `src/config.ts`:loadConfig() 函数,从 `~/.mnemo/config.json` 读取配置,文件不存在时返回默认值(localhost:11434/v1, qwen3:8b, temperature 0.1)
|
|
5
|
+
|
|
6
|
+
## 2. LLM 客户端
|
|
7
|
+
|
|
8
|
+
- [ ] 2.1 新增 `src/llm-client.ts`:实现 OpenAI 兼容 `/v1/chat/completions` 客户端,包含 chat(messages, options) 和 isAvailable() 方法
|
|
9
|
+
- [ ] 2.2 chat() 方法:POST 请求到 baseUrl/chat/completions,解析 choices[0].message.content,支持 JSON 响应提取
|
|
10
|
+
- [ ] 2.3 isAvailable() 方法:GET baseUrl/models,成功返回 true,失败返回 false(不抛错)
|
|
11
|
+
- [ ] 2.4 错误处理:LLMConnectionError(连接失败)、LLMResponseError(响应解析失败)
|
|
12
|
+
|
|
13
|
+
## 3. LLM Dream Engine
|
|
14
|
+
|
|
15
|
+
- [ ] 3.1 新增 `src/dream-engine.ts`:DreamEngine 类,接收 LLMClient 和 MemoryStore 实例
|
|
16
|
+
- [ ] 3.2 实现 llmSemanticMerge(category, facts):将同 category 的 facts 按每批 20 条送 LLM,prompt 要求输出 JSON 格式的合并建议,解析后返回操作列表
|
|
17
|
+
- [ ] 3.3 实现 llmSmartCompress(facts):将长 fact(content > 200, summary 为空)送 LLM 生成摘要,返回摘要操作列表
|
|
18
|
+
- [ ] 3.4 实现 llmSmartReclassify(facts):将 general 分类的 facts 送 LLM 判断正确分类,返回分类操作列表
|
|
19
|
+
- [ ] 3.5 实现安全验证层 validateOperations(operations, totalFacts):过滤掉 trust_score > 0.8、retrieval_count > 100 的删除操作,限制删除总数 ≤ 10%
|
|
20
|
+
- [ ] 3.6 实现降级逻辑:LLM 不可用时(isAvailable() 返回 false 或 chat 抛错),调用现有规则引擎方法
|
|
21
|
+
|
|
22
|
+
## 4. 集成到 Dream Cycle
|
|
23
|
+
|
|
24
|
+
- [ ] 4.1 修改 `src/store.ts` 的 runDream():加载配置 → 创建 LLMClient → 创建 DreamEngine → 优先 LLM 整理 → 降级规则引擎
|
|
25
|
+
- [ ] 4.2 修改 `src/server.ts` 的 dream action:传递配置,处理 fallback 标记
|
|
26
|
+
- [ ] 4.3 修改 `src/dream.ts` CLI:确保 CLI 也使用新的 dream engine
|
|
27
|
+
|
|
28
|
+
## 5. 测试
|
|
29
|
+
|
|
30
|
+
- [ ] 5.1 新增 `tests/llm-client.test.ts`:测试 chat() 成功/失败/JSON 解析、isAvailable() 可用/不可用
|
|
31
|
+
- [ ] 5.2 新增 `tests/dream-engine.test.ts`:测试语义合并/智能摘要/智能分类(mock LLM 响应)、安全验证层、降级策略
|
|
32
|
+
- [ ] 5.3 更新 `tests/store.test.ts`:dream 测试用例适配新的 DreamReport 字段(fallback/fallbackReason)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@morningljn/mnemo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Structured fact memory MCP server — SQLite + FTS5, trust scoring, entity graph, bilingual retrieval for Claude Code & Codex",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/server.js",
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import type { LLMConfig } from './types.js'
|
|
5
|
+
|
|
6
|
+
const DEFAULT_CONFIG: LLMConfig = {
|
|
7
|
+
baseUrl: 'http://localhost:11434/v1',
|
|
8
|
+
model: 'qwen3:8b',
|
|
9
|
+
temperature: 0.1,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function loadConfig(): LLMConfig {
|
|
13
|
+
const configPath = join(homedir(), '.mnemo', 'config.json')
|
|
14
|
+
if (!existsSync(configPath)) return { ...DEFAULT_CONFIG }
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const raw = readFileSync(configPath, 'utf-8')
|
|
18
|
+
const parsed = JSON.parse(raw)
|
|
19
|
+
const llm = parsed.llm ?? {}
|
|
20
|
+
return {
|
|
21
|
+
baseUrl: llm.baseUrl ?? DEFAULT_CONFIG.baseUrl,
|
|
22
|
+
model: llm.model ?? DEFAULT_CONFIG.model,
|
|
23
|
+
apiKey: llm.apiKey,
|
|
24
|
+
temperature: llm.temperature ?? DEFAULT_CONFIG.temperature,
|
|
25
|
+
}
|
|
26
|
+
} catch {
|
|
27
|
+
return { ...DEFAULT_CONFIG }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { FactCategory, LLMMessage } from './types.js'
|
|
2
|
+
import type { LLMClient } from './llm-client.js'
|
|
3
|
+
import type { MemoryStore } from './store.js'
|
|
4
|
+
|
|
5
|
+
const BATCH_SIZE = 20
|
|
6
|
+
const MAX_DELETE_RATIO = 0.1
|
|
7
|
+
const TRUST_DELETE_LIMIT = 0.8
|
|
8
|
+
const RETRIEVAL_DELETE_LIMIT = 100
|
|
9
|
+
|
|
10
|
+
export class DreamEngine {
|
|
11
|
+
constructor(private llm: LLMClient, private store: MemoryStore) {}
|
|
12
|
+
|
|
13
|
+
async semanticMerge(): Promise<{
|
|
14
|
+
merged: number
|
|
15
|
+
details: Array<{ kept: number; removed: number; reason: string }>
|
|
16
|
+
}> {
|
|
17
|
+
const categories: FactCategory[] = ['identity', 'coding_style', 'tool_pref', 'workflow', 'general']
|
|
18
|
+
let merged = 0
|
|
19
|
+
const details: Array<{ kept: number; removed: number; reason: string }> = []
|
|
20
|
+
|
|
21
|
+
const totalFacts = this.store.getTotalCount()
|
|
22
|
+
const maxDeletes = Math.max(1, Math.floor(totalFacts * MAX_DELETE_RATIO))
|
|
23
|
+
|
|
24
|
+
for (const cat of categories) {
|
|
25
|
+
const facts = this.store.listFacts(cat, 0, 200)
|
|
26
|
+
if (facts.length < 2) continue
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < facts.length; i += BATCH_SIZE) {
|
|
29
|
+
const batch = facts.slice(i, i + BATCH_SIZE)
|
|
30
|
+
const factList = batch.map(f => `[${f.factId}] ${f.content}`).join('\n')
|
|
31
|
+
|
|
32
|
+
const messages: LLMMessage[] = [
|
|
33
|
+
{
|
|
34
|
+
role: 'system',
|
|
35
|
+
content: `你是一个记忆整理助手。分析以下同一分类(${cat})的记忆条目,找出语义重复的条目对。
|
|
36
|
+
只输出JSON,格式:{"merges": [{"kept": 保留的fact_id, "removed": 删除的fact_id, "reason": "原因"}]}
|
|
37
|
+
如果没有语义重复的条目,输出:{"merges": []}
|
|
38
|
+
规则:
|
|
39
|
+
- 保留内容更完整、信息量更大的条目
|
|
40
|
+
- 用词不同但意思相同的条目应合并(如"喜欢VS Code"和"偏好Visual Studio Code")
|
|
41
|
+
- 不要合并只是主题相关但内容不同的条目`,
|
|
42
|
+
},
|
|
43
|
+
{ role: 'user', content: factList },
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const result = await this.llm.chatJSON<{ merges: Array<{ kept: number; removed: number; reason: string }> }>(messages)
|
|
48
|
+
if (!result?.merges || !Array.isArray(result.merges)) continue
|
|
49
|
+
|
|
50
|
+
for (const merge of result.merges) {
|
|
51
|
+
if (merged >= maxDeletes) break
|
|
52
|
+
if (!merge.kept || !merge.removed) continue
|
|
53
|
+
|
|
54
|
+
const toRemove = this.store.listFacts(cat, 0, 200).find(f => f.factId === merge.removed)
|
|
55
|
+
if (!toRemove) continue
|
|
56
|
+
if (toRemove.trustScore > TRUST_DELETE_LIMIT) continue
|
|
57
|
+
if (toRemove.retrievalCount > RETRIEVAL_DELETE_LIMIT) continue
|
|
58
|
+
|
|
59
|
+
const toKeep = this.store.listFacts(cat, 0, 200).find(f => f.factId === merge.kept)
|
|
60
|
+
if (!toKeep) continue
|
|
61
|
+
|
|
62
|
+
this.store.removeFact(merge.removed)
|
|
63
|
+
details.push({ kept: merge.kept, removed: merge.removed, reason: merge.reason })
|
|
64
|
+
merged++
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { merged, details }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async smartCompress(): Promise<number> {
|
|
76
|
+
const rows = this.store.connection.prepare(
|
|
77
|
+
"SELECT fact_id, content FROM facts WHERE length(content) > 200 AND (summary IS NULL OR summary = '')"
|
|
78
|
+
).all() as Array<{ fact_id: number; content: string }>
|
|
79
|
+
|
|
80
|
+
if (rows.length === 0) return 0
|
|
81
|
+
|
|
82
|
+
let compressed = 0
|
|
83
|
+
|
|
84
|
+
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
|
|
85
|
+
const batch = rows.slice(i, i + BATCH_SIZE)
|
|
86
|
+
const factList = batch.map(f => `[${f.fact_id}] ${f.content}`).join('\n\n---\n\n')
|
|
87
|
+
|
|
88
|
+
const messages: LLMMessage[] = [
|
|
89
|
+
{
|
|
90
|
+
role: 'system',
|
|
91
|
+
content: `你是一个记忆摘要助手。为每条记忆生成简洁的摘要(≤150字)。
|
|
92
|
+
摘要应保留核心信息:谁/什么/关键决策/关键数据。去除示例、过程描述、冗余细节。
|
|
93
|
+
输出JSON:{"summaries": [{"fact_id": 数字, "summary": "摘要内容"}]}`,
|
|
94
|
+
},
|
|
95
|
+
{ role: 'user', content: factList },
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const result = await this.llm.chatJSON<{ summaries: Array<{ fact_id: number; summary: string }> }>(messages)
|
|
100
|
+
if (!result?.summaries || !Array.isArray(result.summaries)) continue
|
|
101
|
+
|
|
102
|
+
for (const item of result.summaries) {
|
|
103
|
+
if (!item.fact_id || !item.summary) continue
|
|
104
|
+
const truncated = item.summary.length > 150 ? item.summary.slice(0, 147) + '...' : item.summary
|
|
105
|
+
this.store.connection.prepare('UPDATE facts SET summary = ? WHERE fact_id = ?').run(truncated, item.fact_id)
|
|
106
|
+
compressed++
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return compressed
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async smartReclassify(): Promise<number> {
|
|
117
|
+
const rows = this.store.connection.prepare(
|
|
118
|
+
"SELECT fact_id, content FROM facts WHERE category = 'general'"
|
|
119
|
+
).all() as Array<{ fact_id: number; content: string }>
|
|
120
|
+
|
|
121
|
+
if (rows.length === 0) return 0
|
|
122
|
+
|
|
123
|
+
const validCategories = ['identity', 'coding_style', 'tool_pref', 'workflow']
|
|
124
|
+
let reclassified = 0
|
|
125
|
+
|
|
126
|
+
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
|
|
127
|
+
const batch = rows.slice(i, i + BATCH_SIZE)
|
|
128
|
+
const factList = batch.map(f => `[${f.fact_id}] ${f.content}`).join('\n')
|
|
129
|
+
|
|
130
|
+
const messages: LLMMessage[] = [
|
|
131
|
+
{
|
|
132
|
+
role: 'system',
|
|
133
|
+
content: `你是一个记忆分类助手。分析以下记忆条目,判断它们应该属于哪个分类。
|
|
134
|
+
可选分类:identity(身份/角色)、coding_style(编码规范)、tool_pref(工具偏好)、workflow(工作流)
|
|
135
|
+
如果记忆不属于以上任何分类,保持 general。
|
|
136
|
+
输出JSON:{"reclassify": [{"fact_id": 数字, "to": "分类名"}]}
|
|
137
|
+
不需要重新分类的条目不要输出。`,
|
|
138
|
+
},
|
|
139
|
+
{ role: 'user', content: factList },
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const result = await this.llm.chatJSON<{ reclassify: Array<{ fact_id: number; to: string }> }>(messages)
|
|
144
|
+
if (!result?.reclassify || !Array.isArray(result.reclassify)) continue
|
|
145
|
+
|
|
146
|
+
for (const item of result.reclassify) {
|
|
147
|
+
if (!item.fact_id || !item.to) continue
|
|
148
|
+
if (!validCategories.includes(item.to)) continue
|
|
149
|
+
|
|
150
|
+
this.store.connection.prepare(
|
|
151
|
+
"UPDATE facts SET category = ?, updated_at = datetime('now', 'localtime') WHERE fact_id = ?"
|
|
152
|
+
).run(item.to, item.fact_id)
|
|
153
|
+
reclassified++
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return reclassified
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { LLMConfig, LLMMessage } from './types.js'
|
|
2
|
+
|
|
3
|
+
export class LLMClient {
|
|
4
|
+
constructor(private config: LLMConfig) {}
|
|
5
|
+
|
|
6
|
+
async chat(messages: LLMMessage[], options?: { temperature?: number }): Promise<string> {
|
|
7
|
+
const url = `${this.config.baseUrl}/chat/completions`
|
|
8
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
9
|
+
if (this.config.apiKey) {
|
|
10
|
+
headers['Authorization'] = `Bearer ${this.config.apiKey}`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const resp = await fetch(url, {
|
|
14
|
+
method: 'POST',
|
|
15
|
+
headers,
|
|
16
|
+
body: JSON.stringify({
|
|
17
|
+
model: this.config.model,
|
|
18
|
+
messages,
|
|
19
|
+
temperature: options?.temperature ?? this.config.temperature,
|
|
20
|
+
stream: false,
|
|
21
|
+
}),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
if (!resp.ok) {
|
|
25
|
+
throw new Error(`LLM request failed: ${resp.status} ${await resp.text()}`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const data = (await resp.json()) as {
|
|
29
|
+
choices: Array<{ message: { content: string } }>
|
|
30
|
+
}
|
|
31
|
+
return data.choices[0]?.message?.content ?? ''
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async chatJSON<T = unknown>(messages: LLMMessage[]): Promise<T> {
|
|
35
|
+
const text = await this.chat(messages)
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(text)
|
|
38
|
+
} catch {
|
|
39
|
+
const match = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/)
|
|
40
|
+
if (match) {
|
|
41
|
+
return JSON.parse(match[1].trim())
|
|
42
|
+
}
|
|
43
|
+
throw new Error(`LLM response is not valid JSON: ${text.slice(0, 200)}`)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async isAvailable(): Promise<boolean> {
|
|
48
|
+
try {
|
|
49
|
+
const url = `${this.config.baseUrl}/models`
|
|
50
|
+
const resp = await fetch(url, {
|
|
51
|
+
method: 'GET',
|
|
52
|
+
signal: AbortSignal.timeout(3000),
|
|
53
|
+
})
|
|
54
|
+
return resp.ok
|
|
55
|
+
} catch {
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/resources.ts
CHANGED