@morningljn/mnemo 0.2.0 → 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/dream.d.ts +2 -0
- package/dist/dream.js +20 -0
- package/dist/dream.js.map +1 -0
- package/dist/init.js +4 -24
- package/dist/init.js.map +1 -1
- 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.d.ts +22 -8
- package/dist/resources.js +66 -20
- package/dist/resources.js.map +1 -1
- package/dist/retriever.js +12 -5
- package/dist/retriever.js.map +1 -1
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +2 -2
- package/dist/server.js +40 -6
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +23 -1
- package/dist/store.js +169 -4
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +41 -1
- package/docs/superpowers/plans/2026-05-16-llm-dream.md +973 -0
- package/docs/superpowers/plans/2026-05-16-memory-dreaming.md +626 -0
- package/openspec/changes/archive/2026-05-16-memory-dreaming/.openspec.yaml +2 -0
- package/openspec/changes/archive/2026-05-16-memory-dreaming/design.md +71 -0
- package/openspec/changes/archive/2026-05-16-memory-dreaming/proposal.md +32 -0
- package/openspec/changes/archive/2026-05-16-memory-dreaming/specs/compact-search/spec.md +16 -0
- package/openspec/changes/archive/2026-05-16-memory-dreaming/specs/dream-cycle/spec.md +38 -0
- package/openspec/changes/archive/2026-05-16-memory-dreaming/tasks.md +27 -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/openspec/specs/compact-search/spec.md +16 -0
- package/openspec/specs/dream-cycle/spec.md +38 -0
- package/package.json +3 -2
- package/src/config.ts +29 -0
- package/src/dream-engine.ts +162 -0
- package/src/dream.ts +20 -0
- package/src/init.ts +4 -24
- package/src/llm-client.ts +59 -0
- package/src/resources.ts +77 -21
- package/src/retriever.ts +9 -5
- package/src/schema.ts +2 -2
- package/src/server.ts +46 -7
- package/src/store.ts +198 -5
- package/src/types.ts +41 -1
- package/tests/dream-engine.test.ts +163 -0
- package/tests/llm-client.test.ts +105 -0
- package/tests/resource.test.ts +25 -23
- package/tests/store.test.ts +130 -2
|
@@ -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
|
+
}
|
package/src/dream.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { MemoryStore } from './store.js'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { homedir } from 'node:os'
|
|
6
|
+
|
|
7
|
+
const dbPath = join(homedir(), '.mnemo', 'facts.db')
|
|
8
|
+
const store = new MemoryStore(dbPath)
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
console.log('[mnemo dream] 开始整理记忆库...\n')
|
|
12
|
+
const report = await store.runDream()
|
|
13
|
+
console.log(JSON.stringify(report, null, 2))
|
|
14
|
+
console.log(`\n[mnemo dream] 完成: merged=${report.merged} compressed=${report.compressed} reclassified=${report.reclassified} deleted=${report.deleted}`)
|
|
15
|
+
} catch (err) {
|
|
16
|
+
console.error('[mnemo dream] error:', err)
|
|
17
|
+
process.exit(1)
|
|
18
|
+
} finally {
|
|
19
|
+
store.close()
|
|
20
|
+
}
|
package/src/init.ts
CHANGED
|
@@ -19,32 +19,12 @@ const CLAUDE_MD_PATH = join(CLAUDE_DIR, 'CLAUDE.md')
|
|
|
19
19
|
const SETTINGS_PATH = join(CLAUDE_DIR, 'settings.json')
|
|
20
20
|
|
|
21
21
|
const MEMORY_RULES = `
|
|
22
|
-
# 记忆系统使用规则
|
|
23
22
|
|
|
24
|
-
|
|
23
|
+
# mnemo 记忆系统
|
|
25
24
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
## 规则 2:按需补充查询
|
|
31
|
-
仅在以下情况调用 \`fact_store(action="search")\`:
|
|
32
|
-
- 用户消息涉及个人偏好/习惯/工具选择且预热中未覆盖
|
|
33
|
-
- 用户明确查询记忆("我之前说过什么""按我的习惯")
|
|
34
|
-
- 技术选型时需要确认用户偏好
|
|
35
|
-
|
|
36
|
-
不触发查询的情况:
|
|
37
|
-
- 纯操作指令("运行测试""git commit")
|
|
38
|
-
- 通用技术问题("Promise 怎么用")
|
|
39
|
-
- 代码审查/解释请求
|
|
40
|
-
|
|
41
|
-
## 规则 3:写入记忆
|
|
42
|
-
用户说"记住"、"记下来"时,调用 \`fact_store(action="add", content="...", category="...")\`。
|
|
43
|
-
- 先 search 检查是否已有相似事实,有则 update
|
|
44
|
-
- category:identity / coding_style / tool_pref / workflow / general
|
|
45
|
-
|
|
46
|
-
## 规则 4:反馈强化
|
|
47
|
-
成功使用某条记忆时,调用 \`fact_feedback(action="helpful", fact_id=...)\`。
|
|
25
|
+
- 身份问题("你是谁"等)→ 先 fact_store(search, query="角色设定"),按设定回答
|
|
26
|
+
- 用户说"记住"→ fact_store(add),先 search 去重
|
|
27
|
+
- 成功使用记忆 → fact_feedback(helpful, fact_id)
|
|
48
28
|
`
|
|
49
29
|
|
|
50
30
|
const MCP_TOOLS = [
|
|
@@ -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
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MCP Resource manager for mnemo-mcp.
|
|
3
|
-
* Exposes per-category memory
|
|
3
|
+
* Exposes per-category memory as MCP Resources for session warmup injection.
|
|
4
|
+
*
|
|
5
|
+
* identity → 指令格式(Claude 应遵循的行为设定)
|
|
6
|
+
* 其他 → 参考格式(供 Claude 查阅的用户偏好)
|
|
4
7
|
*/
|
|
5
8
|
|
|
6
9
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
@@ -8,7 +11,7 @@ import type { MemoryStore } from './store.js'
|
|
|
8
11
|
import type { FactCategory } from './types.js'
|
|
9
12
|
|
|
10
13
|
const CATEGORIES: FactCategory[] = ['identity', 'coding_style', 'tool_pref', 'workflow', 'general']
|
|
11
|
-
const RESOURCE_LIMIT =
|
|
14
|
+
const RESOURCE_LIMIT = 15
|
|
12
15
|
|
|
13
16
|
export interface ResourceFact {
|
|
14
17
|
fact_id: number
|
|
@@ -17,13 +20,12 @@ export interface ResourceFact {
|
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
export class ResourceManager {
|
|
20
|
-
private cache = new Map<FactCategory,
|
|
23
|
+
private cache = new Map<FactCategory, string>()
|
|
21
24
|
|
|
22
25
|
constructor(
|
|
23
26
|
private store: MemoryStore,
|
|
24
27
|
) {}
|
|
25
28
|
|
|
26
|
-
/** Register all category resources with the MCP server */
|
|
27
29
|
registerResources(server: McpServer): void {
|
|
28
30
|
for (const category of CATEGORIES) {
|
|
29
31
|
const uri = `mnemo://global/${category}`
|
|
@@ -32,46 +34,100 @@ export class ResourceManager {
|
|
|
32
34
|
uri,
|
|
33
35
|
{
|
|
34
36
|
description: `${category} category global facts (top ${RESOURCE_LIMIT} by trust)`,
|
|
35
|
-
mimeType: '
|
|
37
|
+
mimeType: 'text/markdown',
|
|
36
38
|
},
|
|
37
39
|
async () => this.readCategory(category),
|
|
38
40
|
)
|
|
39
41
|
}
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
/** Read handler for a specific category */
|
|
43
|
-
|
|
44
|
-
const
|
|
44
|
+
/** Read handler for a specific category (public for server instructions) */
|
|
45
|
+
readCategory(category: FactCategory): { contents: Array<{ uri: string; mimeType: string; text: string }> } {
|
|
46
|
+
const text = this.getFormattedFacts(category)
|
|
45
47
|
return {
|
|
46
48
|
contents: [{
|
|
47
49
|
uri: `mnemo://global/${category}`,
|
|
48
|
-
mimeType: '
|
|
49
|
-
text
|
|
50
|
+
mimeType: 'text/markdown',
|
|
51
|
+
text,
|
|
50
52
|
}],
|
|
51
53
|
}
|
|
52
54
|
}
|
|
53
55
|
|
|
54
|
-
|
|
55
|
-
getFacts(category: FactCategory): ResourceFact[] {
|
|
56
|
+
private getFormattedFacts(category: FactCategory): string {
|
|
56
57
|
const cached = this.cache.get(category)
|
|
57
58
|
if (cached) return cached
|
|
58
59
|
|
|
59
|
-
const facts = this.store.listFacts(category, 0.0, RESOURCE_LIMIT)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}))
|
|
60
|
+
const facts = this.store.listFacts(category, 0.0, RESOURCE_LIMIT)
|
|
61
|
+
const text = category === 'identity'
|
|
62
|
+
? this.formatAsInstructions(facts)
|
|
63
|
+
: this.formatAsReference(facts, category)
|
|
64
64
|
|
|
65
|
-
this.cache.set(category,
|
|
66
|
-
return
|
|
65
|
+
this.cache.set(category, text)
|
|
66
|
+
return text
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* identity 类事实格式化为指令——Claude 应直接遵循这些设定。
|
|
71
|
+
* 角色设定排在最前面,用祈使句。
|
|
72
|
+
*/
|
|
73
|
+
private formatAsInstructions(facts: ReturnType<MemoryStore['listFacts']>): string {
|
|
74
|
+
const lines: string[] = ['# 身份与行为设定', '', '以下是你的身份设定和用户偏好,请直接遵循:', '']
|
|
75
|
+
|
|
76
|
+
// 角色/身份相关的 fact 排在最前面
|
|
77
|
+
const roleFacts = facts.filter(f =>
|
|
78
|
+
f.content.includes('角色设定') ||
|
|
79
|
+
f.content.includes('你是') ||
|
|
80
|
+
f.content.includes('身份是') ||
|
|
81
|
+
f.content.includes('你扮演')
|
|
82
|
+
)
|
|
83
|
+
const otherFacts = facts.filter(f => !roleFacts.includes(f))
|
|
84
|
+
|
|
85
|
+
if (roleFacts.length > 0) {
|
|
86
|
+
lines.push('## 你的身份')
|
|
87
|
+
for (const f of roleFacts) {
|
|
88
|
+
// 把描述性语句转为指令
|
|
89
|
+
const content = f.content
|
|
90
|
+
.replace(/^AI角色设定[::]/, '')
|
|
91
|
+
.replace(/^你是/, '')
|
|
92
|
+
.trim()
|
|
93
|
+
lines.push(`- ${content}`)
|
|
94
|
+
}
|
|
95
|
+
lines.push('')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (otherFacts.length > 0) {
|
|
99
|
+
lines.push('## 用户信息')
|
|
100
|
+
for (const f of otherFacts) {
|
|
101
|
+
lines.push(`- ${f.content}`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return lines.join('\n')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 非 identity 类事实格式化为参考——供 Claude 查阅但不强制遵循。
|
|
110
|
+
*/
|
|
111
|
+
private formatAsReference(facts: ReturnType<MemoryStore['listFacts']>, category: string): string {
|
|
112
|
+
const title: Record<string, string> = {
|
|
113
|
+
coding_style: '编码风格偏好',
|
|
114
|
+
tool_pref: '工具偏好',
|
|
115
|
+
workflow: '工作流偏好',
|
|
116
|
+
general: '通用知识',
|
|
117
|
+
}
|
|
118
|
+
const lines: string[] = [`# ${title[category] ?? category}`, '']
|
|
119
|
+
|
|
120
|
+
for (const f of facts) {
|
|
121
|
+
lines.push(`- ${f.content}`)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return lines.join('\n')
|
|
67
125
|
}
|
|
68
126
|
|
|
69
|
-
/** Invalidate all caches — call after any write operation */
|
|
70
127
|
invalidate(): void {
|
|
71
128
|
this.cache.clear()
|
|
72
129
|
}
|
|
73
130
|
|
|
74
|
-
/** Get cache entry count for debugging */
|
|
75
131
|
cacheSize(): number {
|
|
76
132
|
return this.cache.size
|
|
77
133
|
}
|
package/src/retriever.ts
CHANGED
|
@@ -437,15 +437,19 @@ export class FactRetriever {
|
|
|
437
437
|
const ftsParts: string[] = []
|
|
438
438
|
|
|
439
439
|
for (const word of parts) {
|
|
440
|
-
|
|
441
|
-
// 对中文部分追加 bigram
|
|
442
|
-
const cnChars = word.match(/[\u4e00-\u9fff]+/g)
|
|
440
|
+
const cnChars = word.match(/[一-鿿]+/g)
|
|
443
441
|
if (cnChars) {
|
|
442
|
+
// 中文部分:trigram tokenizer 需要至少 3 字符
|
|
444
443
|
for (const seg of cnChars) {
|
|
445
|
-
|
|
446
|
-
|
|
444
|
+
if (seg.length >= 3) ftsParts.push(seg)
|
|
445
|
+
// 提取 trigram(3 字符子串)
|
|
446
|
+
for (let i = 0; i <= seg.length - 3; i++) {
|
|
447
|
+
ftsParts.push(seg.slice(i, i + 3))
|
|
447
448
|
}
|
|
448
449
|
}
|
|
450
|
+
} else {
|
|
451
|
+
// 非中文部分:用引号包裹(短语匹配),至少 1 字符
|
|
452
|
+
if (word.length >= 1) ftsParts.push(`"${word}"`)
|
|
449
453
|
}
|
|
450
454
|
}
|
|
451
455
|
|
package/src/schema.ts
CHANGED
|
@@ -46,9 +46,9 @@ CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(name);
|
|
|
46
46
|
CREATE INDEX IF NOT EXISTS idx_fact_entities_entity ON fact_entities(entity_id);
|
|
47
47
|
CREATE INDEX IF NOT EXISTS idx_retrieval_log_ts ON retrieval_log(timestamp);
|
|
48
48
|
|
|
49
|
-
-- FTS5
|
|
49
|
+
-- FTS5 全文索引(trigram tokenizer 支持中文子串匹配)
|
|
50
50
|
CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts
|
|
51
|
-
USING fts5(content, tags, summary, content=facts, content_rowid=fact_id);
|
|
51
|
+
USING fts5(content, tags, summary, content=facts, content_rowid=fact_id, tokenize='trigram');
|
|
52
52
|
|
|
53
53
|
-- FTS5 同步触发器:插入
|
|
54
54
|
CREATE TRIGGER IF NOT EXISTS facts_ai AFTER INSERT ON facts BEGIN
|
package/src/server.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { MemoryStore } from './store.js'
|
|
|
9
9
|
import { FactRetriever } from './retriever.js'
|
|
10
10
|
import { ResourceManager } from './resources.js'
|
|
11
11
|
import { fullSecurityScan } from './security.js'
|
|
12
|
-
import type { FactStoreArgs, FactFeedbackArgs, FactCategory } from './types.js'
|
|
12
|
+
import type { FactStoreArgs, FactFeedbackArgs, FactCategory, ScoredFact, CompactFactResult } from './types.js'
|
|
13
13
|
|
|
14
14
|
const FACT_STORE_DESCRIPTION = `结构化事实记忆系统(SQLite+FTS5 索引)。支持读写。
|
|
15
15
|
|
|
@@ -27,7 +27,7 @@ const FACT_STORE_DESCRIPTION = `结构化事实记忆系统(SQLite+FTS5 索引
|
|
|
27
27
|
写入时先 search 检查是否已存在相似事实。identity/coding_style/tool_pref/workflow/general → 全局库,project → 项目库。`
|
|
28
28
|
|
|
29
29
|
const factStoreSchema = {
|
|
30
|
-
action: z.enum(['add', 'search', 'probe', 'related', 'reason', 'contradict', 'update', 'remove', 'list', 'learn', 'audit']),
|
|
30
|
+
action: z.enum(['add', 'search', 'probe', 'related', 'reason', 'contradict', 'update', 'remove', 'list', 'learn', 'audit', 'dream']),
|
|
31
31
|
content: z.union([z.string(), z.array(z.string())]).optional().describe("事实内容('add' 必需,支持批量)"),
|
|
32
32
|
summary: z.string().optional().describe('超长事实的摘要(检索用 summary 匹配)'),
|
|
33
33
|
query: z.string().optional().describe("搜索查询('search' 必需)"),
|
|
@@ -52,6 +52,16 @@ function resolveCategory(category?: string): FactCategory {
|
|
|
52
52
|
return valid.includes(category as FactCategory) ? (category as FactCategory) : 'general'
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
function toCompactResult(f: ScoredFact): CompactFactResult {
|
|
56
|
+
return {
|
|
57
|
+
factId: f.factId,
|
|
58
|
+
display: f.summary ?? (f.content.length > 100 ? f.content.slice(0, 100) + '...' : f.content),
|
|
59
|
+
category: f.category,
|
|
60
|
+
trustScore: Math.round(f.trustScore * 100) / 100,
|
|
61
|
+
score: Math.round(f.score * 1000) / 1000,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
55
65
|
const minTrust = 0.3
|
|
56
66
|
|
|
57
67
|
// -- Initialize store + retriever --
|
|
@@ -76,7 +86,25 @@ process.nextTick(() => {
|
|
|
76
86
|
})
|
|
77
87
|
|
|
78
88
|
// -- MCP Server --
|
|
79
|
-
|
|
89
|
+
// 动态生成 instructions:将 identity resource 中的角色设定作为 system prompt 指令注入
|
|
90
|
+
function buildInstructions(): string {
|
|
91
|
+
try {
|
|
92
|
+
const rm = new ResourceManager(store)
|
|
93
|
+
const result = rm.readCategory('identity')
|
|
94
|
+
const identityText = result.contents[0]?.text ?? ''
|
|
95
|
+
if (identityText.length > 10) {
|
|
96
|
+
return identityText
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// fallback:无 identity 数据时不注入
|
|
100
|
+
}
|
|
101
|
+
return ''
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const server = new McpServer(
|
|
105
|
+
{ name: 'mnemo-mcp', version: '0.1.0' },
|
|
106
|
+
{ instructions: buildInstructions() },
|
|
107
|
+
)
|
|
80
108
|
|
|
81
109
|
// -- MCP Resources: 会话预热注入 --
|
|
82
110
|
const resourceManager = new ResourceManager(store)
|
|
@@ -136,26 +164,30 @@ server.tool(
|
|
|
136
164
|
case 'search': {
|
|
137
165
|
if (!a.query) return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Missing required argument: query' }) }] }
|
|
138
166
|
const results = retriever.search(a.query, { category: a.category ? category : undefined, minTrust: a.min_trust ?? minTrust, limit: a.limit ?? 10 })
|
|
139
|
-
|
|
167
|
+
const compact = results.map(toCompactResult)
|
|
168
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify({ results: compact, count: compact.length }) }] }
|
|
140
169
|
}
|
|
141
170
|
|
|
142
171
|
case 'probe': {
|
|
143
172
|
if (!a.entity) return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Missing required argument: entity' }) }] }
|
|
144
173
|
const results = retriever.probe(a.entity, { minTrust: a.min_trust ?? minTrust, limit: a.limit ?? 10 })
|
|
145
|
-
|
|
174
|
+
const compact = results.map(toCompactResult)
|
|
175
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify({ results: compact, count: compact.length }) }] }
|
|
146
176
|
}
|
|
147
177
|
|
|
148
178
|
case 'related': {
|
|
149
179
|
if (!a.entity) return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Missing required argument: entity' }) }] }
|
|
150
180
|
const results = retriever.related(a.entity, { minTrust: a.min_trust ?? minTrust, limit: a.limit ?? 10 })
|
|
151
|
-
|
|
181
|
+
const compact = results.map(toCompactResult)
|
|
182
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify({ results: compact, count: compact.length }) }] }
|
|
152
183
|
}
|
|
153
184
|
|
|
154
185
|
case 'reason': {
|
|
155
186
|
const entities = a.entities ?? []
|
|
156
187
|
if (entities.length === 0) return { content: [{ type: 'text' as const, text: JSON.stringify({ error: "reason requires 'entities' list" }) }] }
|
|
157
188
|
const results = retriever.reason(entities, { minTrust: a.min_trust ?? minTrust, limit: a.limit ?? 10 })
|
|
158
|
-
|
|
189
|
+
const compact = results.map(toCompactResult)
|
|
190
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify({ results: compact, count: compact.length }) }] }
|
|
159
191
|
}
|
|
160
192
|
|
|
161
193
|
case 'contradict': {
|
|
@@ -194,6 +226,13 @@ server.tool(
|
|
|
194
226
|
return { content: [{ type: 'text' as const, text: JSON.stringify(report) }] }
|
|
195
227
|
}
|
|
196
228
|
|
|
229
|
+
case 'dream': {
|
|
230
|
+
const report = await store.runDream()
|
|
231
|
+
retriever.getCache().clear()
|
|
232
|
+
resourceManager.invalidate()
|
|
233
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(report) }] }
|
|
234
|
+
}
|
|
235
|
+
|
|
197
236
|
case 'list': {
|
|
198
237
|
const facts = store.listFacts(category, a.min_trust ?? 0.0, a.limit ?? 10)
|
|
199
238
|
return { content: [{ type: 'text' as const, text: JSON.stringify({ facts, count: facts.length }) }] }
|