@morningljn/mnemo 0.1.4 → 0.2.1

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 (50) hide show
  1. package/dist/dream.d.ts +2 -0
  2. package/dist/dream.js +20 -0
  3. package/dist/dream.js.map +1 -0
  4. package/dist/init.js +4 -24
  5. package/dist/init.js.map +1 -1
  6. package/dist/resources.d.ts +22 -8
  7. package/dist/resources.js +66 -20
  8. package/dist/resources.js.map +1 -1
  9. package/dist/retriever.js +42 -47
  10. package/dist/retriever.js.map +1 -1
  11. package/dist/schema.d.ts +1 -1
  12. package/dist/schema.js +21 -10
  13. package/dist/schema.js.map +1 -1
  14. package/dist/server.js +73 -6
  15. package/dist/server.js.map +1 -1
  16. package/dist/store.d.ts +59 -1
  17. package/dist/store.js +308 -10
  18. package/dist/store.js.map +1 -1
  19. package/dist/types.d.ts +30 -1
  20. package/docs/superpowers/plans/2026-05-16-memory-dreaming.md +626 -0
  21. package/docs/superpowers/plans/2026-05-16-memory-self-learning.md +932 -0
  22. package/openspec/changes/archive/2026-05-16-memory-dreaming/.openspec.yaml +2 -0
  23. package/openspec/changes/archive/2026-05-16-memory-dreaming/design.md +71 -0
  24. package/openspec/changes/archive/2026-05-16-memory-dreaming/proposal.md +32 -0
  25. package/openspec/changes/archive/2026-05-16-memory-dreaming/specs/compact-search/spec.md +16 -0
  26. package/openspec/changes/archive/2026-05-16-memory-dreaming/specs/dream-cycle/spec.md +38 -0
  27. package/openspec/changes/archive/2026-05-16-memory-dreaming/tasks.md +27 -0
  28. package/openspec/changes/memory-self-learning/.openspec.yaml +2 -0
  29. package/openspec/changes/memory-self-learning/design.md +174 -0
  30. package/openspec/changes/memory-self-learning/proposal.md +35 -0
  31. package/openspec/changes/memory-self-learning/specs/fact-retrieval/spec.md +35 -0
  32. package/openspec/changes/memory-self-learning/specs/fact-summary/spec.md +45 -0
  33. package/openspec/changes/memory-self-learning/specs/length-penalty/spec.md +27 -0
  34. package/openspec/changes/memory-self-learning/specs/retrieval-log/spec.md +41 -0
  35. package/openspec/changes/memory-self-learning/specs/self-learning/spec.md +68 -0
  36. package/openspec/changes/memory-self-learning/tasks.md +56 -0
  37. package/openspec/specs/compact-search/spec.md +16 -0
  38. package/openspec/specs/dream-cycle/spec.md +38 -0
  39. package/package.json +3 -2
  40. package/src/dream.ts +20 -0
  41. package/src/init.ts +4 -24
  42. package/src/resources.ts +77 -21
  43. package/src/retriever.ts +41 -49
  44. package/src/schema.ts +21 -10
  45. package/src/server.ts +81 -7
  46. package/src/store.ts +378 -11
  47. package/src/types.ts +28 -1
  48. package/tests/resource.test.ts +25 -23
  49. package/tests/retriever.test.ts +53 -0
  50. package/tests/store.test.ts +239 -0
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,8 +27,9 @@ 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']),
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
+ summary: z.string().optional().describe('超长事实的摘要(检索用 summary 匹配)'),
32
33
  query: z.string().optional().describe("搜索查询('search' 必需)"),
33
34
  entity: z.string().optional().describe("实体名('probe'/'related' 使用)"),
34
35
  entities: z.array(z.string()).optional().describe("实体列表('reason' 使用)"),
@@ -51,6 +52,16 @@ function resolveCategory(category?: string): FactCategory {
51
52
  return valid.includes(category as FactCategory) ? (category as FactCategory) : 'general'
52
53
  }
53
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
+
54
65
  const minTrust = 0.3
55
66
 
56
67
  // -- Initialize store + retriever --
@@ -62,8 +73,38 @@ const retriever = new FactRetriever(store, { temporalDecayHalfLife: 30 })
62
73
  store.decayTrustScores()
63
74
  store.auditContradictions()
64
75
 
76
+ // Auto-learn on startup (non-blocking)
77
+ process.nextTick(() => {
78
+ try {
79
+ const result = store.runLearning()
80
+ if (result.demoted > 0 || result.aged > 0 || result.long_facts.length > 0) {
81
+ console.error(`[mnemo:auto-learn] promoted=${result.promoted} demoted=${result.demoted} aged=${result.aged} long_facts=${result.long_facts.length}`)
82
+ }
83
+ } catch (err) {
84
+ console.error('[mnemo:auto-learn] error:', err)
85
+ }
86
+ })
87
+
65
88
  // -- MCP Server --
66
- const server = new McpServer({ name: 'mnemo-mcp', version: '0.1.0' })
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
+ )
67
108
 
68
109
  // -- MCP Resources: 会话预热注入 --
69
110
  const resourceManager = new ResourceManager(store)
@@ -93,13 +134,22 @@ server.tool(
93
134
  let warnings: string[] | undefined
94
135
  const scan = fullSecurityScan(content)
95
136
  if (scan.warnings.length > 0 || scan.hasPii) warnings = [...scan.warnings]
137
+ if (content.length > 500 && !a.summary) {
138
+ warnings = [...(warnings ?? []), 'content 超过 500 字,建议提供 summary 或拆分为多条 fact']
139
+ }
96
140
 
97
141
  if (similar) {
98
142
  store.updateFact(similar.factId, { content, tags: a.tags, trustDelta: 0.05 })
143
+ if (a.summary) {
144
+ store.connection.prepare('UPDATE facts SET summary = ? WHERE fact_id = ?').run(a.summary, similar.factId)
145
+ }
99
146
  const demoted = store.demoteContradictingFacts(similar.factId, content, category)
100
147
  results.push({ fact_id: similar.factId, status: 'updated', reason: 'similar_fact_merged', ...(demoted > 0 ? { contradicted_demoted: demoted } : {}), ...(warnings ? { warnings } : {}) })
101
148
  } else {
102
149
  const factId = store.addFact(content, category, a.tags ?? '')
150
+ if (a.summary) {
151
+ store.connection.prepare('UPDATE facts SET summary = ? WHERE fact_id = ?').run(a.summary, factId)
152
+ }
103
153
  const demoted = store.demoteContradictingFacts(factId, content, category)
104
154
  results.push({ fact_id: factId, status: 'added', category, ...(demoted > 0 ? { contradicted_demoted: demoted } : {}), ...(warnings ? { warnings } : {}) })
105
155
  }
@@ -114,26 +164,30 @@ server.tool(
114
164
  case 'search': {
115
165
  if (!a.query) return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Missing required argument: query' }) }] }
116
166
  const results = retriever.search(a.query, { category: a.category ? category : undefined, minTrust: a.min_trust ?? minTrust, limit: a.limit ?? 10 })
117
- return { content: [{ type: 'text' as const, text: JSON.stringify({ results, count: results.length }) }] }
167
+ const compact = results.map(toCompactResult)
168
+ return { content: [{ type: 'text' as const, text: JSON.stringify({ results: compact, count: compact.length }) }] }
118
169
  }
119
170
 
120
171
  case 'probe': {
121
172
  if (!a.entity) return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Missing required argument: entity' }) }] }
122
173
  const results = retriever.probe(a.entity, { minTrust: a.min_trust ?? minTrust, limit: a.limit ?? 10 })
123
- return { content: [{ type: 'text' as const, text: JSON.stringify({ results, count: results.length }) }] }
174
+ const compact = results.map(toCompactResult)
175
+ return { content: [{ type: 'text' as const, text: JSON.stringify({ results: compact, count: compact.length }) }] }
124
176
  }
125
177
 
126
178
  case 'related': {
127
179
  if (!a.entity) return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Missing required argument: entity' }) }] }
128
180
  const results = retriever.related(a.entity, { minTrust: a.min_trust ?? minTrust, limit: a.limit ?? 10 })
129
- return { content: [{ type: 'text' as const, text: JSON.stringify({ results, count: results.length }) }] }
181
+ const compact = results.map(toCompactResult)
182
+ return { content: [{ type: 'text' as const, text: JSON.stringify({ results: compact, count: compact.length }) }] }
130
183
  }
131
184
 
132
185
  case 'reason': {
133
186
  const entities = a.entities ?? []
134
187
  if (entities.length === 0) return { content: [{ type: 'text' as const, text: JSON.stringify({ error: "reason requires 'entities' list" }) }] }
135
188
  const results = retriever.reason(entities, { minTrust: a.min_trust ?? minTrust, limit: a.limit ?? 10 })
136
- return { content: [{ type: 'text' as const, text: JSON.stringify({ results, count: results.length }) }] }
189
+ const compact = results.map(toCompactResult)
190
+ return { content: [{ type: 'text' as const, text: JSON.stringify({ results: compact, count: compact.length }) }] }
137
191
  }
138
192
 
139
193
  case 'contradict': {
@@ -144,6 +198,9 @@ server.tool(
144
198
  case 'update': {
145
199
  if (!a.fact_id) return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Missing required argument: fact_id' }) }] }
146
200
  const updated = store.updateFact(a.fact_id as number, { content: a.content as string | undefined, tags: a.tags, category, trustDelta: a.trust_delta })
201
+ if (a.summary !== undefined) {
202
+ store.connection.prepare('UPDATE facts SET summary = ? WHERE fact_id = ?').run(a.summary, a.fact_id as number)
203
+ }
147
204
  retriever.getCache().clear()
148
205
  resourceManager.invalidate()
149
206
  return { content: [{ type: 'text' as const, text: JSON.stringify({ updated }) }] }
@@ -159,6 +216,23 @@ server.tool(
159
216
  return { content: [{ type: 'text' as const, text: JSON.stringify(response) }] }
160
217
  }
161
218
 
219
+ case 'learn': {
220
+ const result = store.runLearning()
221
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
222
+ }
223
+
224
+ case 'audit': {
225
+ const report = store.runAudit()
226
+ return { content: [{ type: 'text' as const, text: JSON.stringify(report) }] }
227
+ }
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
+
162
236
  case 'list': {
163
237
  const facts = store.listFacts(category, a.min_trust ?? 0.0, a.limit ?? 10)
164
238
  return { content: [{ type: 'text' as const, text: JSON.stringify({ facts, count: facts.length }) }] }
package/src/store.ts CHANGED
@@ -12,9 +12,9 @@
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
18
 
19
19
  // 信任评分常量
20
20
  const HELPFUL_DELTA = 0.05
@@ -63,9 +63,11 @@ interface FactRow {
63
63
  category: string
64
64
  tags: string
65
65
  keywords: string
66
+ summary?: string | null
66
67
  trust_score: number
67
68
  retrieval_count: number
68
69
  helpful_count: number
70
+ last_retrieved_at?: string | null
69
71
  created_at: string
70
72
  updated_at: string
71
73
  }
@@ -110,10 +112,58 @@ export class MemoryStore {
110
112
 
111
113
  /** 增量迁移:添加新列(已存在则跳过) */
112
114
  private migrateSchema(): void {
115
+ const addColumn = (table: string, column: string, def: string): void => {
116
+ try {
117
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${def}`)
118
+ } catch { /* 列已存在 */ }
119
+ }
120
+
113
121
  // keywords 列(v2)
114
- try {
115
- this.db.exec('ALTER TABLE facts ADD COLUMN keywords TEXT DEFAULT \'[]\'')
116
- } catch { /* 列已存在 */ }
122
+ addColumn('facts', 'keywords', "TEXT DEFAULT '[]'")
123
+ // summary
124
+ addColumn('facts', 'summary', 'TEXT DEFAULT NULL')
125
+ // last_retrieved_at 列
126
+ addColumn('facts', 'last_retrieved_at', 'TEXT DEFAULT NULL')
127
+
128
+ // FTS5 重建:检查 facts_fts 是否包含 summary 列
129
+ const ftsCols = this.db.pragma('table_info(facts_fts)') as Array<{ name: string }>
130
+ const hasSummary = ftsCols.some(c => c.name === 'summary')
131
+ if (!hasSummary) {
132
+ // DROP 旧 FTS5 表和触发器,重建含 summary 的新版本
133
+ this.db.exec(`
134
+ DROP TABLE IF EXISTS facts_fts;
135
+ DROP TRIGGER IF EXISTS facts_ai;
136
+ DROP TRIGGER IF EXISTS facts_ad;
137
+ DROP TRIGGER IF EXISTS facts_au;
138
+ `)
139
+ // 重建 FTS5 虚拟表(含 summary,使用 trigram tokenizer 支持中文子串)
140
+ this.db.exec(`
141
+ CREATE VIRTUAL TABLE facts_fts
142
+ USING fts5(content, tags, summary, content=facts, content_rowid=fact_id, tokenize='trigram');
143
+ `)
144
+ // 重填充
145
+ this.db.exec(`
146
+ INSERT INTO facts_fts(rowid, content, tags, summary)
147
+ SELECT fact_id, content, tags, COALESCE(summary, '') FROM facts;
148
+ `)
149
+ // 重建触发器
150
+ this.db.exec(`
151
+ CREATE TRIGGER facts_ai AFTER INSERT ON facts BEGIN
152
+ INSERT INTO facts_fts(rowid, content, tags, summary)
153
+ VALUES (new.fact_id, new.content, new.tags, COALESCE(new.summary, ''));
154
+ END;
155
+ CREATE TRIGGER facts_ad AFTER DELETE ON facts BEGIN
156
+ INSERT INTO facts_fts(facts_fts, rowid, content, tags, summary)
157
+ VALUES ('delete', old.fact_id, old.content, old.tags, COALESCE(old.summary, ''));
158
+ END;
159
+ CREATE TRIGGER facts_au AFTER UPDATE ON facts BEGIN
160
+ INSERT INTO facts_fts(facts_fts, rowid, content, tags, summary)
161
+ VALUES ('delete', old.fact_id, old.content, old.tags, COALESCE(old.summary, ''));
162
+ INSERT INTO facts_fts(rowid, content, tags, summary)
163
+ VALUES (new.fact_id, new.content, new.tags, COALESCE(new.summary, ''));
164
+ END;
165
+ `)
166
+ }
117
167
  }
118
168
 
119
169
  private prepareStatements(): void {
@@ -361,8 +411,8 @@ export class MemoryStore {
361
411
  params.push(limit)
362
412
 
363
413
  const sql = `
364
- SELECT fact_id, content, category, tags, keywords, trust_score,
365
- retrieval_count, helpful_count, created_at, updated_at
414
+ SELECT fact_id, content, category, tags, keywords, summary, trust_score,
415
+ retrieval_count, helpful_count, last_retrieved_at, created_at, updated_at
366
416
  FROM facts
367
417
  WHERE trust_score >= ?
368
418
  ${categoryClause}
@@ -410,8 +460,8 @@ export class MemoryStore {
410
460
  params.push(limit)
411
461
 
412
462
  const sql = `
413
- SELECT f.fact_id, f.content, f.category, f.tags, f.keywords, f.trust_score,
414
- f.retrieval_count, f.helpful_count, f.created_at, f.updated_at
463
+ SELECT f.fact_id, f.content, f.category, f.tags, f.keywords, f.summary, f.trust_score,
464
+ f.retrieval_count, f.helpful_count, f.last_retrieved_at, f.created_at, f.updated_at
415
465
  FROM facts f
416
466
  JOIN fact_entities fe ON f.fact_id = fe.fact_id
417
467
  JOIN entities e ON fe.entity_id = e.entity_id
@@ -445,8 +495,8 @@ export class MemoryStore {
445
495
  params.push(limit)
446
496
 
447
497
  const sql = `
448
- SELECT f.fact_id, f.content, f.category, f.tags, f.keywords, f.trust_score,
449
- f.retrieval_count, f.helpful_count, f.created_at, f.updated_at
498
+ SELECT f.fact_id, f.content, f.category, f.tags, f.keywords, f.summary, f.trust_score,
499
+ f.retrieval_count, f.helpful_count, f.last_retrieved_at, f.created_at, f.updated_at
450
500
  FROM facts f
451
501
  WHERE f.fact_id IN (${intersects})
452
502
  ${categoryClause}
@@ -635,6 +685,321 @@ export class MemoryStore {
635
685
  return row.count
636
686
  }
637
687
 
688
+ /** 记录检索日志并更新 last_retrieved_at */
689
+ logRetrieval(query: string, results: Array<{ id: number; score: number }>): void {
690
+ const resultsJson = JSON.stringify(results)
691
+ this.db.prepare(
692
+ "INSERT INTO retrieval_log (query, results) VALUES (?, ?)"
693
+ ).run(query, resultsJson)
694
+
695
+ // 更新返回 fact 的 last_retrieved_at
696
+ if (results.length > 0) {
697
+ const ids = results.map(r => r.id)
698
+ const placeholders = ids.map(() => '?').join(',')
699
+ this.db.prepare(
700
+ `UPDATE facts SET last_retrieved_at = datetime('now', 'localtime') WHERE fact_id IN (${placeholders})`
701
+ ).run(...ids)
702
+ }
703
+
704
+ // 自动清理日志
705
+ this.pruneRetrievalLog(5000)
706
+ }
707
+
708
+ /** 清理检索日志,保留最近 maxEntries 条 */
709
+ pruneRetrievalLog(maxEntries = 5000): void {
710
+ this.db.prepare(
711
+ `DELETE FROM retrieval_log WHERE id NOT IN (
712
+ SELECT id FROM retrieval_log ORDER BY id DESC LIMIT ?
713
+ )`
714
+ ).run(maxEntries)
715
+ }
716
+
717
+ /** 自学习:基于检索统计自动调整 trust_score */
718
+ runLearning(): {
719
+ promoted: number
720
+ demoted: number
721
+ aged: number
722
+ unchanged: number
723
+ long_facts: Array<{ id: number; content_length: number; penalty: number; has_summary: boolean }>
724
+ } {
725
+ const rows = this.db.prepare(
726
+ 'SELECT fact_id, content, summary, retrieval_count, helpful_count, trust_score, last_retrieved_at FROM facts'
727
+ ).all() as Array<{
728
+ fact_id: number; content: string; summary: string | null;
729
+ retrieval_count: number; helpful_count: number; trust_score: number; last_retrieved_at: string | null
730
+ }>
731
+
732
+ let promoted = 0
733
+ let demoted = 0
734
+ let aged = 0
735
+ let unchanged = 0
736
+ const longFacts: Array<{ id: number; content_length: number; penalty: number; has_summary: boolean }> = []
737
+
738
+ const now = Date.now()
739
+
740
+ for (const row of rows) {
741
+ let changed = false
742
+ const rate = row.retrieval_count > 0 ? row.helpful_count / row.retrieval_count : 0
743
+
744
+ // Rate-based adjustment (需要 30+ 次检索)
745
+ if (row.retrieval_count > 30) {
746
+ // 高频检索保护:检索 >100 次说明被持续需要,feedback 少不代表无用
747
+ const highFrequencyProtected = row.retrieval_count > 100
748
+ if (rate < 0.05 && !highFrequencyProtected) {
749
+ const newTrust = clampTrust(row.trust_score * 0.9)
750
+ this.db.prepare('UPDATE facts SET trust_score = ? WHERE fact_id = ?').run(newTrust, row.fact_id)
751
+ demoted++
752
+ changed = true
753
+ } else if (rate > 0.3) {
754
+ const newTrust = clampTrust(row.trust_score + 0.05)
755
+ this.db.prepare('UPDATE facts SET trust_score = ? WHERE fact_id = ?').run(newTrust, row.fact_id)
756
+ promoted++
757
+ changed = true
758
+ }
759
+ }
760
+
761
+ // Aging (60 天未检索)
762
+ if (row.last_retrieved_at) {
763
+ const lastRetrieved = new Date(row.last_retrieved_at + 'Z').getTime()
764
+ const daysSinceRetrieval = (now - lastRetrieved) / 86_400_000
765
+ if (daysSinceRetrieval > 60) {
766
+ const currentTrust = this.db.prepare('SELECT trust_score FROM facts WHERE fact_id = ?').get(row.fact_id) as any
767
+ const newTrust = clampTrust(currentTrust.trust_score * 0.95)
768
+ this.db.prepare('UPDATE facts SET trust_score = ? WHERE fact_id = ?').run(newTrust, row.fact_id)
769
+ aged++
770
+ changed = true
771
+ }
772
+ }
773
+ // last_retrieved_at 为 NULL = 新 fact,不老化
774
+
775
+ if (!changed) unchanged++
776
+
777
+ // Long facts report (content > 300 字无 summary)
778
+ const matchLength = row.summary ? row.summary.length : row.content.length
779
+ if (matchLength > 300) {
780
+ longFacts.push({
781
+ id: row.fact_id,
782
+ content_length: row.content.length,
783
+ penalty: Math.min(1.0, 300 / matchLength),
784
+ has_summary: !!row.summary,
785
+ })
786
+ }
787
+ }
788
+
789
+ return { promoted, demoted, aged, unchanged, long_facts: longFacts }
790
+ }
791
+
792
+ /** 数据质量审计(只读,不修改数据) */
793
+ runAudit(): {
794
+ total_facts: number
795
+ long_without_summary: Array<{ id: number; content_length: number }>
796
+ low_helpful_rate: Array<{ id: number; rate: number; retrieval_count: number }>
797
+ aging_candidates: Array<{ id: number; last_retrieved_at: string | null }>
798
+ } {
799
+ const rows = this.db.prepare(
800
+ 'SELECT fact_id, content, summary, retrieval_count, helpful_count, last_retrieved_at FROM facts'
801
+ ).all() as Array<{
802
+ fact_id: number; content: string; summary: string | null;
803
+ retrieval_count: number; helpful_count: number; last_retrieved_at: string | null
804
+ }>
805
+
806
+ const longWithoutSummary: Array<{ id: number; content_length: number }> = []
807
+ const lowHelpfulRate: Array<{ id: number; rate: number; retrieval_count: number }> = []
808
+ const agingCandidates: Array<{ id: number; last_retrieved_at: string | null }> = []
809
+
810
+ const now = Date.now()
811
+
812
+ for (const row of rows) {
813
+ // 超 500 字无 summary
814
+ if (row.content.length > 500 && !row.summary) {
815
+ longWithoutSummary.push({ id: row.fact_id, content_length: row.content.length })
816
+ }
817
+
818
+ // 低 helpful 率(>30 次检索,rate < 5%)
819
+ if (row.retrieval_count > 30) {
820
+ const rate = row.helpful_count / row.retrieval_count
821
+ if (rate < 0.05) {
822
+ lowHelpfulRate.push({ id: row.fact_id, rate: Math.round(rate * 1000) / 1000, retrieval_count: row.retrieval_count })
823
+ }
824
+ }
825
+
826
+ // 老化候选(>60 天未检索)
827
+ if (row.last_retrieved_at) {
828
+ const lastRetrieved = new Date(row.last_retrieved_at + 'Z').getTime()
829
+ const daysSince = (now - lastRetrieved) / 86_400_000
830
+ if (daysSince > 60) {
831
+ agingCandidates.push({ id: row.fact_id, last_retrieved_at: row.last_retrieved_at })
832
+ }
833
+ }
834
+ }
835
+
836
+ return {
837
+ total_facts: rows.length,
838
+ long_without_summary: longWithoutSummary,
839
+ low_helpful_rate: lowHelpfulRate,
840
+ aging_candidates: agingCandidates,
841
+ }
842
+ }
843
+
844
+ /** Dream 前备份数据库(使用 better-sqlite3 内置备份,安全处理 WAL 模式) */
845
+ async backupDatabase(): Promise<string> {
846
+ const dbDir = dirname(this.db.name)
847
+ const backupDir = join(dbDir, 'backup')
848
+ mkdirSync(backupDir, { recursive: true })
849
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
850
+ const backupPath = join(backupDir, `dream-${timestamp}.db`)
851
+ await this.db.backup(backupPath)
852
+ return backupPath
853
+ }
854
+
855
+ /** 合并同 category 内 Jaccard > 0.6 的重叠 fact */
856
+ mergeOverlappingFacts(): { merged: number; details: Array<{ kept: number; removed: number; similarity: number }> } {
857
+ return this.db.transaction(() => {
858
+ const categories: FactCategory[] = ['identity', 'coding_style', 'tool_pref', 'workflow', 'general']
859
+ let merged = 0
860
+ const details: Array<{ kept: number; removed: number; similarity: number }> = []
861
+
862
+ for (const cat of categories) {
863
+ const rows = this.db.prepare(
864
+ 'SELECT fact_id, content, retrieval_count FROM facts WHERE category = ? ORDER BY trust_score DESC'
865
+ ).all(cat) as Array<{ fact_id: number; content: string; retrieval_count: number }>
866
+
867
+ const removed = new Set<number>()
868
+
869
+ for (let i = 0; i < rows.length; i++) {
870
+ if (removed.has(rows[i].fact_id)) continue
871
+ const tokensA = this.tokenizeForDedup(rows[i].content)
872
+
873
+ for (let j = i + 1; j < rows.length; j++) {
874
+ if (removed.has(rows[j].fact_id)) continue
875
+ const tokensB = this.tokenizeForDedup(rows[j].content)
876
+ const sim = this.jaccardSimilarity(tokensA, tokensB)
877
+
878
+ if (sim > 0.6) {
879
+ const aHighFreq = rows[i].retrieval_count > 100
880
+ const bHighFreq = rows[j].retrieval_count > 100
881
+
882
+ if (aHighFreq && bHighFreq) continue
883
+
884
+ let keptId: number, removedId: number
885
+ if (bHighFreq) {
886
+ keptId = rows[j].fact_id
887
+ removedId = rows[i].fact_id
888
+ } else {
889
+ keptId = rows[i].fact_id
890
+ removedId = rows[j].fact_id
891
+ }
892
+
893
+ this.removeFact(removedId)
894
+ removed.add(removedId)
895
+ details.push({ kept: keptId, removed: removedId, similarity: Math.round(sim * 100) / 100 })
896
+ merged++
897
+ // 外层 fact 被删除时停止内层循环,避免幽灵操作
898
+ if (removedId === rows[i].fact_id) break
899
+ }
900
+ }
901
+ }
902
+ }
903
+ return { merged, details }
904
+ })()
905
+ }
906
+
907
+ /** 分类修正:按关键词规则表将误分类的 fact 挪到正确 category */
908
+ reclassifyFacts(): number {
909
+ return this.db.transaction(() => {
910
+ const rules: Array<{ keywords: string[]; target: FactCategory }> = [
911
+ { keywords: ['角色设定', '暖暖', '身份', '编程女朋友', '暖宝宝'], target: 'identity' },
912
+ { keywords: ['编码规范', '代码风格', 'pytest', '文件不超过', '方法不超过'], target: 'coding_style' },
913
+ { keywords: ['工作流', 'OpenSpec', 'writing-plans', 'subagent'], target: 'workflow' },
914
+ { keywords: ['偏好', 'VS Code', '编辑器', 'IDE', '快捷键'], target: 'tool_pref' },
915
+ ]
916
+
917
+ const rows = this.db.prepare(
918
+ 'SELECT fact_id, content, category FROM facts'
919
+ ).all() as Array<{ fact_id: number; content: string; category: string }>
920
+
921
+ let reclassified = 0
922
+ for (const row of rows) {
923
+ for (const rule of rules) {
924
+ if (rule.target === row.category) continue
925
+ if (rule.keywords.some(kw => row.content.includes(kw))) {
926
+ this.db.prepare("UPDATE facts SET category = ?, updated_at = datetime('now', 'localtime') WHERE fact_id = ?").run(rule.target, row.fact_id)
927
+ reclassified++
928
+ break
929
+ }
930
+ }
931
+ }
932
+ return reclassified
933
+ })()
934
+ }
935
+
936
+ /** 执行完整 dream cycle:备份 → 压缩 → 合并 → 重分类 → 报告 */
937
+ async runDream(options?: { skipBackup?: boolean }): Promise<DreamReport> {
938
+ if (!options?.skipBackup) {
939
+ await this.backupDatabase()
940
+ }
941
+
942
+ const compressed = this.compressLongFacts()
943
+ const mergeResult = this.mergeOverlappingFacts()
944
+ const reclassified = this.reclassifyFacts()
945
+
946
+ const stats = this.db.prepare(`
947
+ SELECT COUNT(*) as total,
948
+ AVG(trust_score) as avg_trust,
949
+ AVG(length(content)) as avg_length
950
+ FROM facts
951
+ `).get() as { total: number; avg_trust: number; avg_length: number }
952
+
953
+ const categories: FactCategory[] = ['identity', 'coding_style', 'tool_pref', 'workflow', 'general']
954
+ const coverage: Record<string, number> = {}
955
+ for (const cat of categories) {
956
+ const row = this.db.prepare('SELECT COUNT(*) as c FROM facts WHERE category = ?').get(cat) as { c: number }
957
+ coverage[cat] = row.c
958
+ }
959
+
960
+ return {
961
+ merged: mergeResult.merged,
962
+ compressed,
963
+ reclassified,
964
+ deleted: mergeResult.merged,
965
+ mergeDetails: mergeResult.details,
966
+ health: {
967
+ total: stats.total,
968
+ avg_trust: Math.round((stats.avg_trust ?? 0) * 100) / 100,
969
+ avg_length: Math.round(stats.avg_length ?? 0),
970
+ coverage: coverage as Record<FactCategory, number>,
971
+ },
972
+ }
973
+ }
974
+
975
+ /** 压缩长 fact:content > 200 字且无 summary 的,自动提取前 2 句作为 summary */
976
+ compressLongFacts(): number {
977
+ const rows = this.db.prepare(
978
+ "SELECT fact_id, content FROM facts WHERE length(content) > 200 AND (summary IS NULL OR summary = '')"
979
+ ).all() as Array<{ fact_id: number; content: string }>
980
+
981
+ let compressed = 0
982
+ for (const row of rows) {
983
+ const summary = this.extractSummary(row.content)
984
+ if (summary) {
985
+ this.db.prepare('UPDATE facts SET summary = ? WHERE fact_id = ?').run(summary, row.fact_id)
986
+ compressed++
987
+ }
988
+ }
989
+ return compressed
990
+ }
991
+
992
+ /** 从 content 提取前 2 个完整句子(总长 ≤ 150 字) */
993
+ private extractSummary(content: string): string | null {
994
+ const sentences = content.split(/[。\n.]/).map(s => s.trim()).filter(s => s.length > 0)
995
+ if (sentences.length === 0) return null
996
+ let summary = sentences[0]
997
+ if (sentences.length > 1 && summary.length + sentences[1].length <= 148) {
998
+ summary += '。' + sentences[1]
999
+ }
1000
+ return summary.length <= 150 ? summary : summary.slice(0, 147) + '...'
1001
+ }
1002
+
638
1003
  /** 获取数据库连接(供 FactRetriever 直接使用) */
639
1004
  get connection(): Database.Database {
640
1005
  return this.db
@@ -795,9 +1160,11 @@ export class MemoryStore {
795
1160
  category: row.category as FactCategory,
796
1161
  tags: row.tags,
797
1162
  keywords: row.keywords,
1163
+ summary: (row as any).summary ?? null,
798
1164
  trustScore: row.trust_score,
799
1165
  retrievalCount: row.retrieval_count,
800
1166
  helpfulCount: row.helpful_count,
1167
+ lastRetrievedAt: (row as any).last_retrieved_at ?? null,
801
1168
  createdAt: row.created_at,
802
1169
  updatedAt: row.updated_at,
803
1170
  }
package/src/types.ts CHANGED
@@ -8,9 +8,11 @@ export interface Fact {
8
8
  category: FactCategory
9
9
  tags: string
10
10
  keywords: string
11
+ summary: string | null
11
12
  trustScore: number
12
13
  retrievalCount: number
13
14
  helpfulCount: number
15
+ lastRetrievedAt: string | null
14
16
  createdAt: string
15
17
  updatedAt: string
16
18
  }
@@ -53,7 +55,7 @@ export interface RetrieverOptions {
53
55
 
54
56
  /** fact_store 工具调用参数 */
55
57
  export interface FactStoreArgs {
56
- action: 'add' | 'search' | 'probe' | 'related' | 'reason' | 'contradict' | 'update' | 'remove' | 'list'
58
+ action: 'add' | 'search' | 'probe' | 'related' | 'reason' | 'contradict' | 'update' | 'remove' | 'list' | 'learn' | 'audit' | 'dream'
57
59
  content?: string | string[]
58
60
  query?: string
59
61
  entity?: string
@@ -61,6 +63,7 @@ export interface FactStoreArgs {
61
63
  fact_id?: number | number[]
62
64
  category?: string
63
65
  tags?: string
66
+ summary?: string
64
67
  trust_delta?: number
65
68
  min_trust?: number
66
69
  limit?: number
@@ -79,3 +82,27 @@ export interface SecurityScanResult {
79
82
  hasPii: boolean
80
83
  injectionAttempts: string[]
81
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
+ health: {
94
+ total: number
95
+ avg_trust: number
96
+ avg_length: number
97
+ coverage: Record<FactCategory, number>
98
+ }
99
+ }
100
+
101
+ /** 精简搜索结果 */
102
+ export interface CompactFactResult {
103
+ factId: number
104
+ display: string
105
+ category: FactCategory
106
+ trustScore: number
107
+ score: number
108
+ }