@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.
Files changed (60) hide show
  1. package/dist/config.d.ts +2 -0
  2. package/dist/config.js +28 -0
  3. package/dist/config.js.map +1 -0
  4. package/dist/dream-engine.d.ts +17 -0
  5. package/dist/dream-engine.js +144 -0
  6. package/dist/dream-engine.js.map +1 -0
  7. package/dist/dream.d.ts +2 -0
  8. package/dist/dream.js +20 -0
  9. package/dist/dream.js.map +1 -0
  10. package/dist/init.js +4 -24
  11. package/dist/init.js.map +1 -1
  12. package/dist/llm-client.d.ts +10 -0
  13. package/dist/llm-client.js +55 -0
  14. package/dist/llm-client.js.map +1 -0
  15. package/dist/resources.d.ts +22 -8
  16. package/dist/resources.js +66 -20
  17. package/dist/resources.js.map +1 -1
  18. package/dist/retriever.js +12 -5
  19. package/dist/retriever.js.map +1 -1
  20. package/dist/schema.d.ts +1 -1
  21. package/dist/schema.js +2 -2
  22. package/dist/server.js +40 -6
  23. package/dist/server.js.map +1 -1
  24. package/dist/store.d.ts +23 -1
  25. package/dist/store.js +169 -4
  26. package/dist/store.js.map +1 -1
  27. package/dist/types.d.ts +41 -1
  28. package/docs/superpowers/plans/2026-05-16-llm-dream.md +973 -0
  29. package/docs/superpowers/plans/2026-05-16-memory-dreaming.md +626 -0
  30. package/openspec/changes/archive/2026-05-16-memory-dreaming/.openspec.yaml +2 -0
  31. package/openspec/changes/archive/2026-05-16-memory-dreaming/design.md +71 -0
  32. package/openspec/changes/archive/2026-05-16-memory-dreaming/proposal.md +32 -0
  33. package/openspec/changes/archive/2026-05-16-memory-dreaming/specs/compact-search/spec.md +16 -0
  34. package/openspec/changes/archive/2026-05-16-memory-dreaming/specs/dream-cycle/spec.md +38 -0
  35. package/openspec/changes/archive/2026-05-16-memory-dreaming/tasks.md +27 -0
  36. package/openspec/changes/llm-dream/.openspec.yaml +2 -0
  37. package/openspec/changes/llm-dream/design.md +84 -0
  38. package/openspec/changes/llm-dream/proposal.md +36 -0
  39. package/openspec/changes/llm-dream/specs/dream-cycle/spec.md +42 -0
  40. package/openspec/changes/llm-dream/specs/llm-client/spec.md +57 -0
  41. package/openspec/changes/llm-dream/specs/llm-dream-engine/spec.md +72 -0
  42. package/openspec/changes/llm-dream/tasks.md +32 -0
  43. package/openspec/specs/compact-search/spec.md +16 -0
  44. package/openspec/specs/dream-cycle/spec.md +38 -0
  45. package/package.json +3 -2
  46. package/src/config.ts +29 -0
  47. package/src/dream-engine.ts +162 -0
  48. package/src/dream.ts +20 -0
  49. package/src/init.ts +4 -24
  50. package/src/llm-client.ts +59 -0
  51. package/src/resources.ts +77 -21
  52. package/src/retriever.ts +9 -5
  53. package/src/schema.ts +2 -2
  54. package/src/server.ts +46 -7
  55. package/src/store.ts +198 -5
  56. package/src/types.ts +41 -1
  57. package/tests/dream-engine.test.ts +163 -0
  58. package/tests/llm-client.test.ts +105 -0
  59. package/tests/resource.test.ts +25 -23
  60. 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
- if (rate < 0.05) {
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
+ })
@@ -21,42 +21,44 @@ afterEach(() => {
21
21
  })
22
22
 
23
23
  describe('ResourceManager', () => {
24
- it('returns empty array for empty category', () => {
25
- const facts = manager.getFacts('identity')
26
- expect(facts).toEqual([])
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('returns facts ordered by trust score', () => {
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
- store.addFact('用户喜欢 VS Code', 'tool_pref')
32
- const facts = manager.getFacts('tool_pref')
33
- expect(facts.length).toBe(2)
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
- manager.getFacts('general')
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 on write', () => {
52
+ it('invalidates cache', () => {
48
53
  store.addFact('测试事实', 'general')
49
- manager.getFacts('general')
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
+ }