@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
package/src/store.ts
CHANGED
|
@@ -12,9 +12,12 @@
|
|
|
12
12
|
import Database from 'better-sqlite3'
|
|
13
13
|
import type { Statement } from 'better-sqlite3'
|
|
14
14
|
import { mkdirSync } from 'node:fs'
|
|
15
|
-
import { dirname } from 'node:path'
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
16
|
import { SCHEMA } from './schema.js'
|
|
17
|
-
import type { Fact, FactCategory } from './types.js'
|
|
17
|
+
import type { Fact, FactCategory, DreamReport } from './types.js'
|
|
18
|
+
import { loadConfig } from './config.js'
|
|
19
|
+
import { LLMClient } from './llm-client.js'
|
|
20
|
+
import { DreamEngine } from './dream-engine.js'
|
|
18
21
|
|
|
19
22
|
// 信任评分常量
|
|
20
23
|
const HELPFUL_DELTA = 0.05
|
|
@@ -136,10 +139,10 @@ export class MemoryStore {
|
|
|
136
139
|
DROP TRIGGER IF EXISTS facts_ad;
|
|
137
140
|
DROP TRIGGER IF EXISTS facts_au;
|
|
138
141
|
`)
|
|
139
|
-
// 重建 FTS5 虚拟表(含 summary
|
|
142
|
+
// 重建 FTS5 虚拟表(含 summary,使用 trigram tokenizer 支持中文子串)
|
|
140
143
|
this.db.exec(`
|
|
141
144
|
CREATE VIRTUAL TABLE facts_fts
|
|
142
|
-
USING fts5(content, tags, summary, content=facts, content_rowid=fact_id);
|
|
145
|
+
USING fts5(content, tags, summary, content=facts, content_rowid=fact_id, tokenize='trigram');
|
|
143
146
|
`)
|
|
144
147
|
// 重填充
|
|
145
148
|
this.db.exec(`
|
|
@@ -743,7 +746,9 @@ export class MemoryStore {
|
|
|
743
746
|
|
|
744
747
|
// Rate-based adjustment (需要 30+ 次检索)
|
|
745
748
|
if (row.retrieval_count > 30) {
|
|
746
|
-
|
|
749
|
+
// 高频检索保护:检索 >100 次说明被持续需要,feedback 少不代表无用
|
|
750
|
+
const highFrequencyProtected = row.retrieval_count > 100
|
|
751
|
+
if (rate < 0.05 && !highFrequencyProtected) {
|
|
747
752
|
const newTrust = clampTrust(row.trust_score * 0.9)
|
|
748
753
|
this.db.prepare('UPDATE facts SET trust_score = ? WHERE fact_id = ?').run(newTrust, row.fact_id)
|
|
749
754
|
demoted++
|
|
@@ -839,6 +844,194 @@ export class MemoryStore {
|
|
|
839
844
|
}
|
|
840
845
|
}
|
|
841
846
|
|
|
847
|
+
/** Dream 前备份数据库(使用 better-sqlite3 内置备份,安全处理 WAL 模式) */
|
|
848
|
+
async backupDatabase(): Promise<string> {
|
|
849
|
+
const dbDir = dirname(this.db.name)
|
|
850
|
+
const backupDir = join(dbDir, 'backup')
|
|
851
|
+
mkdirSync(backupDir, { recursive: true })
|
|
852
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
|
853
|
+
const backupPath = join(backupDir, `dream-${timestamp}.db`)
|
|
854
|
+
await this.db.backup(backupPath)
|
|
855
|
+
return backupPath
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** 合并同 category 内 Jaccard > 0.6 的重叠 fact */
|
|
859
|
+
mergeOverlappingFacts(): { merged: number; details: Array<{ kept: number; removed: number; similarity: number }> } {
|
|
860
|
+
return this.db.transaction(() => {
|
|
861
|
+
const categories: FactCategory[] = ['identity', 'coding_style', 'tool_pref', 'workflow', 'general']
|
|
862
|
+
let merged = 0
|
|
863
|
+
const details: Array<{ kept: number; removed: number; similarity: number }> = []
|
|
864
|
+
|
|
865
|
+
for (const cat of categories) {
|
|
866
|
+
const rows = this.db.prepare(
|
|
867
|
+
'SELECT fact_id, content, retrieval_count FROM facts WHERE category = ? ORDER BY trust_score DESC'
|
|
868
|
+
).all(cat) as Array<{ fact_id: number; content: string; retrieval_count: number }>
|
|
869
|
+
|
|
870
|
+
const removed = new Set<number>()
|
|
871
|
+
|
|
872
|
+
for (let i = 0; i < rows.length; i++) {
|
|
873
|
+
if (removed.has(rows[i].fact_id)) continue
|
|
874
|
+
const tokensA = this.tokenizeForDedup(rows[i].content)
|
|
875
|
+
|
|
876
|
+
for (let j = i + 1; j < rows.length; j++) {
|
|
877
|
+
if (removed.has(rows[j].fact_id)) continue
|
|
878
|
+
const tokensB = this.tokenizeForDedup(rows[j].content)
|
|
879
|
+
const sim = this.jaccardSimilarity(tokensA, tokensB)
|
|
880
|
+
|
|
881
|
+
if (sim > 0.4) {
|
|
882
|
+
const aHighFreq = rows[i].retrieval_count > 100
|
|
883
|
+
const bHighFreq = rows[j].retrieval_count > 100
|
|
884
|
+
|
|
885
|
+
if (aHighFreq && bHighFreq) continue
|
|
886
|
+
|
|
887
|
+
let keptId: number, removedId: number
|
|
888
|
+
if (bHighFreq) {
|
|
889
|
+
keptId = rows[j].fact_id
|
|
890
|
+
removedId = rows[i].fact_id
|
|
891
|
+
} else {
|
|
892
|
+
keptId = rows[i].fact_id
|
|
893
|
+
removedId = rows[j].fact_id
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
this.removeFact(removedId)
|
|
897
|
+
removed.add(removedId)
|
|
898
|
+
details.push({ kept: keptId, removed: removedId, similarity: Math.round(sim * 100) / 100 })
|
|
899
|
+
merged++
|
|
900
|
+
// 外层 fact 被删除时停止内层循环,避免幽灵操作
|
|
901
|
+
if (removedId === rows[i].fact_id) break
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
return { merged, details }
|
|
907
|
+
})()
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** 分类修正:按关键词规则表将误分类的 fact 挪到正确 category */
|
|
911
|
+
reclassifyFacts(): number {
|
|
912
|
+
return this.db.transaction(() => {
|
|
913
|
+
const rules: Array<{ keywords: string[]; target: FactCategory }> = [
|
|
914
|
+
{ keywords: ['角色设定', '暖暖', '身份', '编程女朋友', '暖宝宝'], target: 'identity' },
|
|
915
|
+
{ keywords: ['编码规范', '代码风格', 'pytest', '文件不超过', '方法不超过'], target: 'coding_style' },
|
|
916
|
+
{ keywords: ['工作流', 'OpenSpec', 'writing-plans', 'subagent'], target: 'workflow' },
|
|
917
|
+
{ keywords: ['偏好', 'VS Code', '编辑器', 'IDE', '快捷键'], target: 'tool_pref' },
|
|
918
|
+
]
|
|
919
|
+
|
|
920
|
+
// 只从 general 分类移动,避免已分类 fact 被反复震荡
|
|
921
|
+
const rows = this.db.prepare(
|
|
922
|
+
"SELECT fact_id, content, category FROM facts WHERE category = 'general'"
|
|
923
|
+
).all() as Array<{ fact_id: number; content: string; category: string }>
|
|
924
|
+
|
|
925
|
+
let reclassified = 0
|
|
926
|
+
for (const row of rows) {
|
|
927
|
+
for (const rule of rules) {
|
|
928
|
+
if (rule.keywords.some(kw => row.content.includes(kw))) {
|
|
929
|
+
this.db.prepare("UPDATE facts SET category = ?, updated_at = datetime('now', 'localtime') WHERE fact_id = ?").run(rule.target, row.fact_id)
|
|
930
|
+
reclassified++
|
|
931
|
+
break
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
return reclassified
|
|
936
|
+
})()
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** 执行完整 dream cycle:备份 → LLM 整理 → 降级规则引擎 → 报告 */
|
|
940
|
+
async runDream(options?: { skipBackup?: boolean }): Promise<DreamReport> {
|
|
941
|
+
if (!options?.skipBackup) {
|
|
942
|
+
await this.backupDatabase()
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// 尝试 LLM 驱动的 dream
|
|
946
|
+
const config = loadConfig()
|
|
947
|
+
const llmClient = new LLMClient(config)
|
|
948
|
+
|
|
949
|
+
const available = await llmClient.isAvailable()
|
|
950
|
+
if (available) {
|
|
951
|
+
try {
|
|
952
|
+
const engine = new DreamEngine(llmClient, this)
|
|
953
|
+
const mergeResult = await engine.semanticMerge()
|
|
954
|
+
const compressed = await engine.smartCompress()
|
|
955
|
+
const reclassified = await engine.smartReclassify()
|
|
956
|
+
|
|
957
|
+
return this.buildDreamReport(mergeResult.merged, compressed, reclassified, mergeResult.details.map(d => ({ kept: d.kept, removed: d.removed, similarity: 0 })), false)
|
|
958
|
+
} catch {
|
|
959
|
+
// LLM 执行失败,降级到规则引擎
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// 降级到规则引擎
|
|
964
|
+
const compressed = this.compressLongFacts()
|
|
965
|
+
const mergeResult = this.mergeOverlappingFacts()
|
|
966
|
+
const reclassified = this.reclassifyFacts()
|
|
967
|
+
|
|
968
|
+
return this.buildDreamReport(mergeResult.merged, compressed, reclassified, mergeResult.details, true, 'LLM unavailable')
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
private buildDreamReport(
|
|
972
|
+
merged: number, compressed: number, reclassified: number,
|
|
973
|
+
mergeDetails: Array<{ kept: number; removed: number; similarity: number }>,
|
|
974
|
+
fallback: boolean, fallbackReason?: string,
|
|
975
|
+
): DreamReport {
|
|
976
|
+
const stats = this.db.prepare(`
|
|
977
|
+
SELECT COUNT(*) as total,
|
|
978
|
+
AVG(trust_score) as avg_trust,
|
|
979
|
+
AVG(length(content)) as avg_length
|
|
980
|
+
FROM facts
|
|
981
|
+
`).get() as { total: number; avg_trust: number; avg_length: number }
|
|
982
|
+
|
|
983
|
+
const categories: FactCategory[] = ['identity', 'coding_style', 'tool_pref', 'workflow', 'general']
|
|
984
|
+
const coverage: Record<string, number> = {}
|
|
985
|
+
for (const cat of categories) {
|
|
986
|
+
const row = this.db.prepare('SELECT COUNT(*) as c FROM facts WHERE category = ?').get(cat) as { c: number }
|
|
987
|
+
coverage[cat] = row.c
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
return {
|
|
991
|
+
merged,
|
|
992
|
+
compressed,
|
|
993
|
+
reclassified,
|
|
994
|
+
deleted: merged,
|
|
995
|
+
mergeDetails,
|
|
996
|
+
fallback,
|
|
997
|
+
fallbackReason,
|
|
998
|
+
health: {
|
|
999
|
+
total: stats.total,
|
|
1000
|
+
avg_trust: Math.round((stats.avg_trust ?? 0) * 100) / 100,
|
|
1001
|
+
avg_length: Math.round(stats.avg_length ?? 0),
|
|
1002
|
+
coverage: coverage as Record<FactCategory, number>,
|
|
1003
|
+
},
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/** 压缩长 fact:content > 200 字且无 summary 的,自动提取前 2 句作为 summary */
|
|
1008
|
+
compressLongFacts(): number {
|
|
1009
|
+
const rows = this.db.prepare(
|
|
1010
|
+
"SELECT fact_id, content FROM facts WHERE length(content) > 200 AND (summary IS NULL OR summary = '')"
|
|
1011
|
+
).all() as Array<{ fact_id: number; content: string }>
|
|
1012
|
+
|
|
1013
|
+
let compressed = 0
|
|
1014
|
+
for (const row of rows) {
|
|
1015
|
+
const summary = this.extractSummary(row.content)
|
|
1016
|
+
if (summary) {
|
|
1017
|
+
this.db.prepare('UPDATE facts SET summary = ? WHERE fact_id = ?').run(summary, row.fact_id)
|
|
1018
|
+
compressed++
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
return compressed
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/** 从 content 提取前 2 个完整句子(总长 ≤ 150 字) */
|
|
1025
|
+
private extractSummary(content: string): string | null {
|
|
1026
|
+
const sentences = content.split(/[。\n.]/).map(s => s.trim()).filter(s => s.length > 0)
|
|
1027
|
+
if (sentences.length === 0) return null
|
|
1028
|
+
let summary = sentences[0]
|
|
1029
|
+
if (sentences.length > 1 && summary.length + sentences[1].length <= 148) {
|
|
1030
|
+
summary += '。' + sentences[1]
|
|
1031
|
+
}
|
|
1032
|
+
return summary.length <= 150 ? summary : summary.slice(0, 147) + '...'
|
|
1033
|
+
}
|
|
1034
|
+
|
|
842
1035
|
/** 获取数据库连接(供 FactRetriever 直接使用) */
|
|
843
1036
|
get connection(): Database.Database {
|
|
844
1037
|
return this.db
|
package/src/types.ts
CHANGED
|
@@ -55,7 +55,7 @@ export interface RetrieverOptions {
|
|
|
55
55
|
|
|
56
56
|
/** fact_store 工具调用参数 */
|
|
57
57
|
export interface FactStoreArgs {
|
|
58
|
-
action: 'add' | 'search' | 'probe' | 'related' | 'reason' | 'contradict' | 'update' | 'remove' | 'list' | 'learn' | 'audit'
|
|
58
|
+
action: 'add' | 'search' | 'probe' | 'related' | 'reason' | 'contradict' | 'update' | 'remove' | 'list' | 'learn' | 'audit' | 'dream'
|
|
59
59
|
content?: string | string[]
|
|
60
60
|
query?: string
|
|
61
61
|
entity?: string
|
|
@@ -82,3 +82,43 @@ export interface SecurityScanResult {
|
|
|
82
82
|
hasPii: boolean
|
|
83
83
|
injectionAttempts: string[]
|
|
84
84
|
}
|
|
85
|
+
|
|
86
|
+
/** Dream 整理报告 */
|
|
87
|
+
export interface DreamReport {
|
|
88
|
+
merged: number
|
|
89
|
+
compressed: number
|
|
90
|
+
reclassified: number
|
|
91
|
+
deleted: number
|
|
92
|
+
mergeDetails: Array<{ kept: number; removed: number; similarity: number }>
|
|
93
|
+
fallback?: boolean
|
|
94
|
+
fallbackReason?: string
|
|
95
|
+
health: {
|
|
96
|
+
total: number
|
|
97
|
+
avg_trust: number
|
|
98
|
+
avg_length: number
|
|
99
|
+
coverage: Record<FactCategory, number>
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 精简搜索结果 */
|
|
104
|
+
export interface CompactFactResult {
|
|
105
|
+
factId: number
|
|
106
|
+
display: string
|
|
107
|
+
category: FactCategory
|
|
108
|
+
trustScore: number
|
|
109
|
+
score: number
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** LLM 配置 */
|
|
113
|
+
export interface LLMConfig {
|
|
114
|
+
baseUrl: string
|
|
115
|
+
model: string
|
|
116
|
+
apiKey?: string
|
|
117
|
+
temperature: number
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** LLM 聊天消息 */
|
|
121
|
+
export interface LLMMessage {
|
|
122
|
+
role: 'system' | 'user' | 'assistant'
|
|
123
|
+
content: string
|
|
124
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { DreamEngine } from '../src/dream-engine.js'
|
|
3
|
+
import { LLMClient } from '../src/llm-client.js'
|
|
4
|
+
import { MemoryStore } from '../src/store.js'
|
|
5
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { tmpdir } from 'node:os'
|
|
8
|
+
|
|
9
|
+
let store: MemoryStore
|
|
10
|
+
let tmpDir: string
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'mnemo-dream-'))
|
|
14
|
+
store = new MemoryStore(join(tmpDir, 'test.db'))
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
store.close()
|
|
19
|
+
rmSync(tmpDir, { recursive: true, force: true })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('DreamEngine - semanticMerge', () => {
|
|
23
|
+
it('merges semantically duplicate facts in same category', async () => {
|
|
24
|
+
store.addFact('用户喜欢使用 VS Code 编辑器写代码', 'tool_pref')
|
|
25
|
+
store.addFact('用户偏好 Visual Studio Code 作为开发工具', 'tool_pref')
|
|
26
|
+
|
|
27
|
+
const mockChatJSON = vi.fn().mockResolvedValueOnce({
|
|
28
|
+
merges: [{ kept: 1, removed: 2, reason: '都描述偏好VS Code编辑器' }],
|
|
29
|
+
})
|
|
30
|
+
const mockClient = { chatJSON: mockChatJSON, isAvailable: vi.fn().mockResolvedValue(true) } as unknown as LLMClient
|
|
31
|
+
const eng = new DreamEngine(mockClient, store)
|
|
32
|
+
const result = await eng.semanticMerge()
|
|
33
|
+
|
|
34
|
+
expect(result.merged).toBe(1)
|
|
35
|
+
expect(result.details.length).toBe(1)
|
|
36
|
+
expect(result.details[0].reason).toBe('都描述偏好VS Code编辑器')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('skips batch when LLM returns invalid JSON', async () => {
|
|
40
|
+
store.addFact('一些事实内容', 'general')
|
|
41
|
+
|
|
42
|
+
const mockClient = {
|
|
43
|
+
chatJSON: vi.fn().mockRejectedValue(new Error('invalid json')),
|
|
44
|
+
isAvailable: vi.fn().mockResolvedValue(true),
|
|
45
|
+
} as unknown as LLMClient
|
|
46
|
+
const eng = new DreamEngine(mockClient, store)
|
|
47
|
+
const result = await eng.semanticMerge()
|
|
48
|
+
|
|
49
|
+
expect(result.merged).toBe(0)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('protects high-trust facts from deletion', async () => {
|
|
53
|
+
const id1 = store.addFact('高信任事实内容', 'general')
|
|
54
|
+
store.recordFeedback(id1, true)
|
|
55
|
+
store.recordFeedback(id1, true)
|
|
56
|
+
store.recordFeedback(id1, true)
|
|
57
|
+
store.recordFeedback(id1, true)
|
|
58
|
+
store.recordFeedback(id1, true)
|
|
59
|
+
store.recordFeedback(id1, true)
|
|
60
|
+
store.recordFeedback(id1, true)
|
|
61
|
+
store.recordFeedback(id1, true)
|
|
62
|
+
store.recordFeedback(id1, true)
|
|
63
|
+
store.recordFeedback(id1, true)
|
|
64
|
+
|
|
65
|
+
const id2 = store.addFact('另一个事实内容', 'general')
|
|
66
|
+
|
|
67
|
+
const mockChatJSON = vi.fn().mockResolvedValueOnce({
|
|
68
|
+
merges: [{ kept: id2, removed: id1, reason: '重复' }],
|
|
69
|
+
})
|
|
70
|
+
const mockClient = { chatJSON: mockChatJSON, isAvailable: vi.fn().mockResolvedValue(true) } as unknown as LLMClient
|
|
71
|
+
const eng = new DreamEngine(mockClient, store)
|
|
72
|
+
const result = await eng.semanticMerge()
|
|
73
|
+
|
|
74
|
+
expect(result.merged).toBe(0)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('DreamEngine - smartCompress', () => {
|
|
79
|
+
it('generates summary for long facts without summary', async () => {
|
|
80
|
+
const longContent = '这是一段很长的记忆内容。'.repeat(20) // >200 chars
|
|
81
|
+
store.addFact(longContent, 'general')
|
|
82
|
+
|
|
83
|
+
const summary = '这是一段关于长内容的简洁摘要'
|
|
84
|
+
const mockChatJSON = vi.fn().mockResolvedValueOnce({ summaries: [{ fact_id: 1, summary }] })
|
|
85
|
+
const mockClient = { chatJSON: mockChatJSON, isAvailable: vi.fn().mockResolvedValue(true) } as unknown as LLMClient
|
|
86
|
+
const eng = new DreamEngine(mockClient, store)
|
|
87
|
+
const result = await eng.smartCompress()
|
|
88
|
+
|
|
89
|
+
expect(result).toBe(1)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('skips facts that already have summary', async () => {
|
|
93
|
+
const longContent = '这是一段很长的记忆内容。'.repeat(20)
|
|
94
|
+
const id = store.addFact(longContent, 'general')
|
|
95
|
+
store.connection.prepare('UPDATE facts SET summary = ? WHERE fact_id = ?').run('已有摘要', id)
|
|
96
|
+
|
|
97
|
+
const mockChatJSON = vi.fn()
|
|
98
|
+
const mockClient = { chatJSON: mockChatJSON, isAvailable: vi.fn().mockResolvedValue(true) } as unknown as LLMClient
|
|
99
|
+
const eng = new DreamEngine(mockClient, store)
|
|
100
|
+
const result = await eng.smartCompress()
|
|
101
|
+
|
|
102
|
+
expect(result).toBe(0)
|
|
103
|
+
expect(mockChatJSON).not.toHaveBeenCalled()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('truncates summary longer than 150 chars', async () => {
|
|
107
|
+
const longContent = '这是一段很长的记忆内容。'.repeat(20)
|
|
108
|
+
store.addFact(longContent, 'general')
|
|
109
|
+
|
|
110
|
+
const tooLongSummary = 'a'.repeat(200)
|
|
111
|
+
const mockChatJSON = vi.fn().mockResolvedValueOnce({ summaries: [{ fact_id: 1, summary: tooLongSummary }] })
|
|
112
|
+
const mockClient = { chatJSON: mockChatJSON, isAvailable: vi.fn().mockResolvedValue(true) } as unknown as LLMClient
|
|
113
|
+
const eng = new DreamEngine(mockClient, store)
|
|
114
|
+
await eng.smartCompress()
|
|
115
|
+
|
|
116
|
+
const fact = store.listFacts('general', 0, 10)[0]
|
|
117
|
+
expect(fact.summary!.length).toBeLessThanOrEqual(150)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('DreamEngine - smartReclassify', () => {
|
|
122
|
+
it('moves general facts to correct category via LLM', async () => {
|
|
123
|
+
store.addFact('用户编码规范要求文件不超过500行', 'general')
|
|
124
|
+
|
|
125
|
+
const mockChatJSON = vi.fn().mockResolvedValueOnce({
|
|
126
|
+
reclassify: [{ fact_id: 1, to: 'coding_style' }],
|
|
127
|
+
})
|
|
128
|
+
const mockClient = { chatJSON: mockChatJSON, isAvailable: vi.fn().mockResolvedValue(true) } as unknown as LLMClient
|
|
129
|
+
const eng = new DreamEngine(mockClient, store)
|
|
130
|
+
const result = await eng.smartReclassify()
|
|
131
|
+
|
|
132
|
+
expect(result).toBe(1)
|
|
133
|
+
const fact = store.listFacts('coding_style', 0, 10)[0]
|
|
134
|
+
expect(fact).toBeDefined()
|
|
135
|
+
expect(fact.content).toContain('编码规范')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('ignores invalid category from LLM', async () => {
|
|
139
|
+
store.addFact('一些内容', 'general')
|
|
140
|
+
|
|
141
|
+
const mockChatJSON = vi.fn().mockResolvedValueOnce({
|
|
142
|
+
reclassify: [{ fact_id: 1, to: 'invalid_category' }],
|
|
143
|
+
})
|
|
144
|
+
const mockClient = { chatJSON: mockChatJSON, isAvailable: vi.fn().mockResolvedValue(true) } as unknown as LLMClient
|
|
145
|
+
const eng = new DreamEngine(mockClient, store)
|
|
146
|
+
const result = await eng.smartReclassify()
|
|
147
|
+
|
|
148
|
+
expect(result).toBe(0)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('skips when LLM says keep general', async () => {
|
|
152
|
+
store.addFact('一些杂项内容', 'general')
|
|
153
|
+
|
|
154
|
+
const mockChatJSON = vi.fn().mockResolvedValueOnce({
|
|
155
|
+
reclassify: [{ fact_id: 1, to: 'general' }],
|
|
156
|
+
})
|
|
157
|
+
const mockClient = { chatJSON: mockChatJSON, isAvailable: vi.fn().mockResolvedValue(true) } as unknown as LLMClient
|
|
158
|
+
const eng = new DreamEngine(mockClient, store)
|
|
159
|
+
const result = await eng.smartReclassify()
|
|
160
|
+
|
|
161
|
+
expect(result).toBe(0)
|
|
162
|
+
})
|
|
163
|
+
})
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { LLMClient } from '../src/llm-client.js'
|
|
3
|
+
import type { LLMConfig, LLMMessage } from '../src/types.js'
|
|
4
|
+
|
|
5
|
+
const mockConfig: LLMConfig = {
|
|
6
|
+
baseUrl: 'http://localhost:11434/v1',
|
|
7
|
+
model: 'test-model',
|
|
8
|
+
temperature: 0.1,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function mockFetchResponse(body: unknown, ok = true, status = 200) {
|
|
12
|
+
return vi.fn().mockResolvedValue({
|
|
13
|
+
ok,
|
|
14
|
+
status,
|
|
15
|
+
json: () => Promise.resolve(body),
|
|
16
|
+
text: () => Promise.resolve(JSON.stringify(body)),
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('LLMClient', () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.restoreAllMocks()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('chat', () => {
|
|
26
|
+
it('sends request and returns text content', async () => {
|
|
27
|
+
const mockResp = {
|
|
28
|
+
choices: [{ message: { content: 'Hello from LLM' } }],
|
|
29
|
+
}
|
|
30
|
+
globalThis.fetch = mockFetchResponse(mockResp)
|
|
31
|
+
|
|
32
|
+
const client = new LLMClient(mockConfig)
|
|
33
|
+
const messages: LLMMessage[] = [{ role: 'user', content: 'Hi' }]
|
|
34
|
+
const result = await client.chat(messages)
|
|
35
|
+
|
|
36
|
+
expect(result).toBe('Hello from LLM')
|
|
37
|
+
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
38
|
+
'http://localhost:11434/v1/chat/completions',
|
|
39
|
+
expect.objectContaining({
|
|
40
|
+
method: 'POST',
|
|
41
|
+
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
|
|
42
|
+
}),
|
|
43
|
+
)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('includes Authorization header when apiKey is set', async () => {
|
|
47
|
+
const configWithKey = { ...mockConfig, apiKey: 'sk-test-key' }
|
|
48
|
+
globalThis.fetch = mockFetchResponse({
|
|
49
|
+
choices: [{ message: { content: 'ok' } }],
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const client = new LLMClient(configWithKey)
|
|
53
|
+
await client.chat([{ role: 'user', content: 'test' }])
|
|
54
|
+
|
|
55
|
+
const callArgs = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1] as RequestInit
|
|
56
|
+
expect(callArgs.headers).toHaveProperty('Authorization', 'Bearer sk-test-key')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('throws on connection failure', async () => {
|
|
60
|
+
globalThis.fetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'))
|
|
61
|
+
const client = new LLMClient(mockConfig)
|
|
62
|
+
await expect(client.chat([{ role: 'user', content: 'test' }])).rejects.toThrow('ECONNREFUSED')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('throws on non-ok HTTP status', async () => {
|
|
66
|
+
globalThis.fetch = mockFetchResponse({ error: 'bad request' }, false, 400)
|
|
67
|
+
const client = new LLMClient(mockConfig)
|
|
68
|
+
await expect(client.chat([{ role: 'user', content: 'test' }])).rejects.toThrow('400')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('extracts JSON from markdown code fence', async () => {
|
|
72
|
+
const jsonBody = { result: [1, 2] }
|
|
73
|
+
const fenced = '```json\n' + JSON.stringify(jsonBody) + '\n```'
|
|
74
|
+
globalThis.fetch = mockFetchResponse({
|
|
75
|
+
choices: [{ message: { content: fenced } }],
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const client = new LLMClient(mockConfig)
|
|
79
|
+
const result = await client.chatJSON([{ role: 'user', content: 'test' }])
|
|
80
|
+
expect(result).toEqual(jsonBody)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('throws on invalid JSON response in chatJSON', async () => {
|
|
84
|
+
globalThis.fetch = mockFetchResponse({
|
|
85
|
+
choices: [{ message: { content: 'not json at all' } }],
|
|
86
|
+
})
|
|
87
|
+
const client = new LLMClient(mockConfig)
|
|
88
|
+
await expect(client.chatJSON([{ role: 'user', content: 'test' }])).rejects.toThrow()
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('isAvailable', () => {
|
|
93
|
+
it('returns true when service is reachable', async () => {
|
|
94
|
+
globalThis.fetch = mockFetchResponse({ data: [] })
|
|
95
|
+
const client = new LLMClient(mockConfig)
|
|
96
|
+
expect(await client.isAvailable()).toBe(true)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('returns false when service is unreachable', async () => {
|
|
100
|
+
globalThis.fetch = vi.fn().mockRejectedValue(new Error('fail'))
|
|
101
|
+
const client = new LLMClient(mockConfig)
|
|
102
|
+
expect(await client.isAvailable()).toBe(false)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
})
|
package/tests/resource.test.ts
CHANGED
|
@@ -21,42 +21,44 @@ afterEach(() => {
|
|
|
21
21
|
})
|
|
22
22
|
|
|
23
23
|
describe('ResourceManager', () => {
|
|
24
|
-
it('returns empty
|
|
25
|
-
const
|
|
26
|
-
expect(
|
|
24
|
+
it('returns empty markdown for empty category', () => {
|
|
25
|
+
const text = readResource('identity')
|
|
26
|
+
expect(text).toContain('身份与行为设定')
|
|
27
|
+
expect(text).not.toContain('## 你的身份')
|
|
27
28
|
})
|
|
28
29
|
|
|
29
|
-
it('
|
|
30
|
+
it('formats identity facts as instructions', () => {
|
|
31
|
+
store.addFact('AI角色设定:大名暖暖,身份是用户的编程助手', 'identity')
|
|
32
|
+
const text = readResource('identity')
|
|
33
|
+
expect(text).toContain('你的身份')
|
|
34
|
+
expect(text).toContain('暖暖')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('formats non-identity facts as reference', () => {
|
|
30
38
|
store.addFact('用户偏好深色主题', 'tool_pref')
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
expect(
|
|
34
|
-
// listFacts returns trust_score DESC, first fact added gets default trust
|
|
35
|
-
expect(facts.length).toBeGreaterThan(0)
|
|
39
|
+
const text = readResource('tool_pref')
|
|
40
|
+
expect(text).toContain('工具偏好')
|
|
41
|
+
expect(text).toContain('深色主题')
|
|
36
42
|
})
|
|
37
43
|
|
|
38
44
|
it('caches results', () => {
|
|
39
45
|
store.addFact('测试事实', 'general')
|
|
40
|
-
|
|
46
|
+
readResource('general')
|
|
47
|
+
expect(manager.cacheSize()).toBe(1)
|
|
48
|
+
readResource('general')
|
|
41
49
|
expect(manager.cacheSize()).toBe(1)
|
|
42
|
-
// Second call should hit cache
|
|
43
|
-
const facts2 = manager.getFacts('general')
|
|
44
|
-
expect(facts2.length).toBe(1)
|
|
45
50
|
})
|
|
46
51
|
|
|
47
|
-
it('invalidates cache
|
|
52
|
+
it('invalidates cache', () => {
|
|
48
53
|
store.addFact('测试事实', 'general')
|
|
49
|
-
|
|
54
|
+
readResource('general')
|
|
50
55
|
expect(manager.cacheSize()).toBe(1)
|
|
51
56
|
manager.invalidate()
|
|
52
57
|
expect(manager.cacheSize()).toBe(0)
|
|
53
58
|
})
|
|
54
|
-
|
|
55
|
-
it('limits to top 10 facts', () => {
|
|
56
|
-
for (let i = 0; i < 15; i++) {
|
|
57
|
-
store.addFact(`事实 ${i}`, 'general')
|
|
58
|
-
}
|
|
59
|
-
const facts = manager.getFacts('general')
|
|
60
|
-
expect(facts.length).toBe(10)
|
|
61
|
-
})
|
|
62
59
|
})
|
|
60
|
+
|
|
61
|
+
function readResource(category: string): string {
|
|
62
|
+
const result = (manager as any).readCategory(category)
|
|
63
|
+
return result.contents[0].text
|
|
64
|
+
}
|