@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/src/store.ts CHANGED
@@ -15,6 +15,9 @@ import { mkdirSync } from 'node:fs'
15
15
  import { dirname, join } from 'node:path'
16
16
  import { SCHEMA } from './schema.js'
17
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
@@ -875,7 +878,7 @@ export class MemoryStore {
875
878
  const tokensB = this.tokenizeForDedup(rows[j].content)
876
879
  const sim = this.jaccardSimilarity(tokensA, tokensB)
877
880
 
878
- if (sim > 0.6) {
881
+ if (sim > 0.4) {
879
882
  const aHighFreq = rows[i].retrieval_count > 100
880
883
  const bHighFreq = rows[j].retrieval_count > 100
881
884
 
@@ -914,14 +917,14 @@ export class MemoryStore {
914
917
  { keywords: ['偏好', 'VS Code', '编辑器', 'IDE', '快捷键'], target: 'tool_pref' },
915
918
  ]
916
919
 
920
+ // 只从 general 分类移动,避免已分类 fact 被反复震荡
917
921
  const rows = this.db.prepare(
918
- 'SELECT fact_id, content, category FROM facts'
922
+ "SELECT fact_id, content, category FROM facts WHERE category = 'general'"
919
923
  ).all() as Array<{ fact_id: number; content: string; category: string }>
920
924
 
921
925
  let reclassified = 0
922
926
  for (const row of rows) {
923
927
  for (const rule of rules) {
924
- if (rule.target === row.category) continue
925
928
  if (rule.keywords.some(kw => row.content.includes(kw))) {
926
929
  this.db.prepare("UPDATE facts SET category = ?, updated_at = datetime('now', 'localtime') WHERE fact_id = ?").run(rule.target, row.fact_id)
927
930
  reclassified++
@@ -933,16 +936,43 @@ export class MemoryStore {
933
936
  })()
934
937
  }
935
938
 
936
- /** 执行完整 dream cycle:备份 → 压缩 合并 重分类 → 报告 */
939
+ /** 执行完整 dream cycle:备份 → LLM 整理降级规则引擎 → 报告 */
937
940
  async runDream(options?: { skipBackup?: boolean }): Promise<DreamReport> {
938
941
  if (!options?.skipBackup) {
939
942
  await this.backupDatabase()
940
943
  }
941
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
+ // 降级到规则引擎
942
964
  const compressed = this.compressLongFacts()
943
965
  const mergeResult = this.mergeOverlappingFacts()
944
966
  const reclassified = this.reclassifyFacts()
945
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 {
946
976
  const stats = this.db.prepare(`
947
977
  SELECT COUNT(*) as total,
948
978
  AVG(trust_score) as avg_trust,
@@ -958,11 +988,13 @@ export class MemoryStore {
958
988
  }
959
989
 
960
990
  return {
961
- merged: mergeResult.merged,
991
+ merged,
962
992
  compressed,
963
993
  reclassified,
964
- deleted: mergeResult.merged,
965
- mergeDetails: mergeResult.details,
994
+ deleted: merged,
995
+ mergeDetails,
996
+ fallback,
997
+ fallbackReason,
966
998
  health: {
967
999
  total: stats.total,
968
1000
  avg_trust: Math.round((stats.avg_trust ?? 0) * 100) / 100,
package/src/types.ts CHANGED
@@ -90,6 +90,8 @@ export interface DreamReport {
90
90
  reclassified: number
91
91
  deleted: number
92
92
  mergeDetails: Array<{ kept: number; removed: number; similarity: number }>
93
+ fallback?: boolean
94
+ fallbackReason?: string
93
95
  health: {
94
96
  total: number
95
97
  avg_trust: number
@@ -106,3 +108,17 @@ export interface CompactFactResult {
106
108
  trustScore: number
107
109
  score: number
108
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
+ })
@@ -309,16 +309,16 @@ describe('dream - merge', () => {
309
309
  })
310
310
 
311
311
  describe('dream - reclassify', () => {
312
- it('moves miscategorized facts by keywords', () => {
313
- const id = store.addFact('编码规范:文件不超过 500 行', 'identity')
312
+ it('moves miscategorized facts from general to correct category', () => {
313
+ const id = store.addFact('编码规范:文件不超过 500 行', 'general')
314
314
  const result = store.reclassifyFacts()
315
315
  expect(result).toBeGreaterThanOrEqual(1)
316
316
  const row = store.connection.prepare('SELECT category FROM facts WHERE fact_id = ?').get(id) as any
317
317
  expect(row.category).toBe('coding_style')
318
318
  })
319
319
 
320
- it('skips correctly categorized facts', () => {
321
- store.addFact('用户偏好使用 VS Code 编辑器', 'tool_pref')
320
+ it('skips already categorized facts', () => {
321
+ store.addFact('编码规范:文件不超过 500 ', 'coding_style')
322
322
  const result = store.reclassifyFacts()
323
323
  expect(result).toBe(0)
324
324
  })
@@ -331,7 +331,7 @@ describe('dream - runDream', () => {
331
331
  // 重叠 fact(触发合并)
332
332
  store.addFact('用户偏好使用 TypeScript 开发前端代码', 'coding_style')
333
333
  // 分类错误 fact(触发重分类)
334
- store.addFact('编码规范:文件不超过 500 行', 'identity')
334
+ store.addFact('编码规范:文件不超过 500 行', 'general')
335
335
 
336
336
  const report = await store.runDream({ skipBackup: true })
337
337
  expect(report.compressed).toBeGreaterThanOrEqual(0)
@@ -339,5 +339,6 @@ describe('dream - runDream', () => {
339
339
  expect(report.reclassified).toBeGreaterThanOrEqual(0)
340
340
  expect(report.health.total).toBeGreaterThanOrEqual(1)
341
341
  expect(report.health.coverage).toBeTruthy()
342
+ expect(report.fallback).toBe(true) // 测试环境无 Ollama,应降级到规则引擎
342
343
  })
343
344
  })