@morningljn/mnemo 0.1.3 → 0.2.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 (71) hide show
  1. package/README.md +43 -14
  2. package/dist/init.js +16 -8
  3. package/dist/init.js.map +1 -1
  4. package/dist/refine.d.ts +14 -0
  5. package/dist/refine.js +115 -0
  6. package/dist/refine.js.map +1 -0
  7. package/dist/resources.d.ts +27 -0
  8. package/dist/resources.js +56 -0
  9. package/dist/resources.js.map +1 -0
  10. package/dist/retriever.d.ts +3 -1
  11. package/dist/retriever.js +42 -42
  12. package/dist/retriever.js.map +1 -1
  13. package/dist/schema.d.ts +1 -1
  14. package/dist/schema.js +21 -10
  15. package/dist/schema.js.map +1 -1
  16. package/dist/server.js +41 -1
  17. package/dist/server.js.map +1 -1
  18. package/dist/store.d.ts +37 -0
  19. package/dist/store.js +166 -9
  20. package/dist/store.js.map +1 -1
  21. package/dist/types.d.ts +4 -1
  22. package/docs/superpowers/plans/2026-05-15-mnemo-mcp.md +1154 -0
  23. package/docs/superpowers/plans/2026-05-16-memory-self-learning.md +932 -0
  24. package/docs/superpowers/plans/2026-05-16-mnemo-query-cache.md +613 -0
  25. package/docs/superpowers/plans/2026-05-16-retrieval-and-injection-optimization.md +770 -0
  26. package/openspec/changes/archive/2026-05-15-mnemo-mcp/.openspec.yaml +2 -0
  27. package/openspec/changes/archive/2026-05-15-mnemo-mcp/design.md +83 -0
  28. package/openspec/changes/archive/2026-05-15-mnemo-mcp/proposal.md +32 -0
  29. package/openspec/changes/archive/2026-05-15-mnemo-mcp/specs/fact-retrieval/spec.md +75 -0
  30. package/openspec/changes/archive/2026-05-15-mnemo-mcp/specs/fact-store/spec.md +83 -0
  31. package/openspec/changes/archive/2026-05-15-mnemo-mcp/specs/mcp-server/spec.md +34 -0
  32. package/openspec/changes/archive/2026-05-15-mnemo-mcp/specs/security/spec.md +37 -0
  33. package/openspec/changes/archive/2026-05-15-mnemo-mcp/tasks.md +44 -0
  34. package/openspec/changes/archive/2026-05-16-mnemo-query-cache/.openspec.yaml +2 -0
  35. package/openspec/changes/archive/2026-05-16-mnemo-query-cache/design.md +96 -0
  36. package/openspec/changes/archive/2026-05-16-mnemo-query-cache/proposal.md +29 -0
  37. package/openspec/changes/archive/2026-05-16-mnemo-query-cache/specs/batch-operations/spec.md +42 -0
  38. package/openspec/changes/archive/2026-05-16-mnemo-query-cache/specs/perf-metrics/spec.md +55 -0
  39. package/openspec/changes/archive/2026-05-16-mnemo-query-cache/specs/query-cache/spec.md +65 -0
  40. package/openspec/changes/archive/2026-05-16-mnemo-query-cache/tasks.md +45 -0
  41. package/openspec/changes/memory-self-learning/.openspec.yaml +2 -0
  42. package/openspec/changes/memory-self-learning/design.md +174 -0
  43. package/openspec/changes/memory-self-learning/proposal.md +35 -0
  44. package/openspec/changes/memory-self-learning/specs/fact-retrieval/spec.md +35 -0
  45. package/openspec/changes/memory-self-learning/specs/fact-summary/spec.md +45 -0
  46. package/openspec/changes/memory-self-learning/specs/length-penalty/spec.md +27 -0
  47. package/openspec/changes/memory-self-learning/specs/retrieval-log/spec.md +41 -0
  48. package/openspec/changes/memory-self-learning/specs/self-learning/spec.md +68 -0
  49. package/openspec/changes/memory-self-learning/tasks.md +56 -0
  50. package/openspec/changes/retrieval-and-injection-optimization/.openspec.yaml +2 -0
  51. package/openspec/changes/retrieval-and-injection-optimization/design.md +117 -0
  52. package/openspec/changes/retrieval-and-injection-optimization/proposal.md +30 -0
  53. package/openspec/changes/retrieval-and-injection-optimization/specs/adaptive-scoring/spec.md +43 -0
  54. package/openspec/changes/retrieval-and-injection-optimization/specs/injection-protocol/spec.md +48 -0
  55. package/openspec/changes/retrieval-and-injection-optimization/specs/mcp-resources/spec.md +39 -0
  56. package/openspec/changes/retrieval-and-injection-optimization/specs/query-refinement/spec.md +39 -0
  57. package/openspec/changes/retrieval-and-injection-optimization/tasks.md +33 -0
  58. package/openspec/config.yaml +20 -0
  59. package/package.json +1 -1
  60. package/src/init.ts +17 -9
  61. package/src/refine.ts +127 -0
  62. package/src/resources.ts +78 -0
  63. package/src/retriever.ts +46 -44
  64. package/src/schema.ts +21 -10
  65. package/src/server.ts +44 -1
  66. package/src/store.ts +215 -9
  67. package/src/types.ts +4 -1
  68. package/tests/refine.test.ts +52 -0
  69. package/tests/resource.test.ts +62 -0
  70. package/tests/retriever.test.ts +53 -0
  71. package/tests/store.test.ts +112 -0
package/src/retriever.ts CHANGED
@@ -12,6 +12,7 @@ import type { Fact, FactCategory, ScoredFact, Contradiction, SearchOptions, Cont
12
12
  import { MemoryStore } from './store.js'
13
13
  import { QueryCache } from './cache.js'
14
14
  import { PerfMetrics } from './metrics.js'
15
+ import { refineQuery } from './refine.js'
15
16
 
16
17
  // 中文字符级匹配的虚词集合(这些单字太常见,不参与字符交叉匹配)
17
18
  const CN_OVERLAP_STOP = new Set([
@@ -65,14 +66,23 @@ export class FactRetriever {
65
66
  }
66
67
 
67
68
  /** 主搜索:FTS5 → LIKE → 字符交叉 → 分类推断 → Jaccard → 信任评分 → 时间衰减 */
68
- search(query: string, options?: SearchOptions): ScoredFact[] {
69
+ search(query: string, options?: SearchOptions & { skipRefine?: boolean }): ScoredFact[] {
69
70
  const startTime = performance.now()
70
71
  const minTrust = options?.minTrust ?? 0.3
71
72
  const limit = options?.limit ?? 10
72
73
  const category = options?.category
73
74
 
75
+ // 查询提炼(除非显式跳过)
76
+ let searchQuery = query
77
+ if (!options?.skipRefine) {
78
+ const refined = refineQuery(query)
79
+ if (refined?.query) {
80
+ searchQuery = refined.query
81
+ }
82
+ }
83
+
74
84
  // 缓存检查
75
- const cacheKey = this.cache.makeKey({ action: 'search', query, category, minTrust, limit })
85
+ const cacheKey = this.cache.makeKey({ action: 'search', query: searchQuery, category, minTrust, limit })
76
86
  const cached = this.cache.get(cacheKey)
77
87
  if (cached) {
78
88
  this.metrics.record({ action: 'search', durationMs: performance.now() - startTime, resultCount: cached.length, cacheHit: true })
@@ -80,7 +90,7 @@ export class FactRetriever {
80
90
  }
81
91
 
82
92
  // 查询双语扩展:中文术语追加英文,英文术语追加中文
83
- const expandedQuery = this.expandQueryBilingually(query)
93
+ const expandedQuery = this.expandQueryBilingually(searchQuery)
84
94
 
85
95
  // Stage 1: FTS5 候选集,空时逐级 fallback(使用双语扩展后的查询)
86
96
  let candidates = this.ftsCandidates(expandedQuery, category, minTrust, limit * 3)
@@ -93,37 +103,35 @@ export class FactRetriever {
93
103
  if (candidates.length === 0) {
94
104
  // 分类推断 fallback(仅无 category 过滤时生效)
95
105
  if (!category) {
96
- const inferred = this.categoryInferFallback(query, minTrust, limit)
106
+ const inferred = this.categoryInferFallback(searchQuery, minTrust, limit)
97
107
  if (inferred.length > 0) return inferred
98
108
  }
99
- // 个人/身份相关的短查询触发 trust fallback
109
+ // 个人/身份相关的短查询触发 trust fallback(用原始 query,避免 refineQuery 拆词导致正则失配)
100
110
  if (this.isPersonalQuery(query)) {
101
111
  return this.trustFallback(category, minTrust, limit)
102
112
  }
103
113
  return []
104
114
  }
105
115
 
106
- // Stage 2-4: Jaccard 重排序 + 信任评分 + 时间衰减
107
- const queryTokens = this.tokenize(query)
116
+ // Stage 2-4: Jaccard 重排序 + 信任评分 + 时间衰减 + length penalty
117
+ const queryTokens = this.tokenize(searchQuery)
108
118
 
109
119
  const scored: ScoredFact[] = []
110
120
 
111
121
  for (const fact of candidates) {
112
- const contentTokens = this.tokenize(fact.content)
122
+ // summary 优先用于匹配
123
+ const matchText = fact.summary ?? fact.content
124
+ const matchTokens = this.tokenize(matchText)
113
125
  const tagTokens = this.tokenize(fact.tags)
114
- const allTokens = new Set([...contentTokens, ...tagTokens])
126
+ const allTokens = new Set([...matchTokens, ...tagTokens])
115
127
 
116
128
  const jaccard = this.jaccardSimilarity(queryTokens, allTokens)
117
- // Containment: 查询 token 在事实 token 中的覆盖率
118
129
  const qInF = this.containmentScore(queryTokens, allTokens)
119
-
120
- // 混合相似度:Jaccard + Containment(简化版,移除 keywordScore)
121
130
  const similarity = 0.3 * jaccard + 0.7 * qInF
122
131
  const ftsScore = fact.ftsRank
123
132
 
124
- // 综合评分
125
- const relevance = this.ftsWeight * ftsScore + this.jaccardWeight * similarity
126
-
133
+ // 静态权重 0.5/0.5(回退 v3 动态权重)
134
+ const relevance = 0.5 * ftsScore + 0.5 * similarity
127
135
  let score = relevance * fact.trustScore
128
136
 
129
137
  // 时间衰减
@@ -131,37 +139,22 @@ export class FactRetriever {
131
139
  score *= this.temporalDecay(fact.updatedAt || fact.createdAt)
132
140
  }
133
141
 
142
+ // Length penalty:基于 matchText 长度
143
+ score *= Math.min(1.0, 300 / matchText.length)
144
+
134
145
  scored.push({ ...fact, score })
135
146
  }
136
147
 
137
148
  scored.sort((a, b) => b.score - a.score)
138
149
 
139
- // Category 多样性:同类事实只保留评分最高的,避免 general 黑洞效应
140
- const seenCategories = new Set<FactCategory>()
141
- const diverse: ScoredFact[] = []
142
- for (const s of scored) {
143
- if (!seenCategories.has(s.category)) {
144
- seenCategories.add(s.category)
145
- diverse.push(s)
146
- }
147
- if (diverse.length >= limit) break
148
- }
149
- // 补位:如果去重后不足 limit,从原排序列表中补(允许同类多次出现)
150
- if (diverse.length < limit) {
151
- const diverseIds = new Set(diverse.map(f => f.factId))
152
- for (const s of scored) {
153
- if (!diverseIds.has(s.factId)) {
154
- diverse.push(s)
155
- if (diverse.length >= limit) break
156
- }
157
- }
158
- }
159
-
160
- const results = diverse
150
+ // limit 条(不再做 relevance gate 和 content dedup)
151
+ const results = scored.slice(0, limit)
161
152
 
162
153
  // 检索追踪:递增 retrieval_count + top3 信任刷新
163
154
  if (results.length > 0) {
164
155
  this.trackRetrieval(results)
156
+ // 记录检索日志
157
+ this.store.logRetrieval(searchQuery, results.map(r => ({ id: r.factId, score: Math.round(r.score * 1000) / 1000 })))
165
158
  }
166
159
 
167
160
  // 缓存存储 + 指标记录
@@ -281,9 +274,11 @@ export class FactRetriever {
281
274
  category: r.category as FactCategory,
282
275
  tags: r.tags,
283
276
  keywords: r.keywords ?? '[]',
277
+ summary: (r as any).summary ?? null,
284
278
  trustScore: r.trust_score,
285
279
  retrievalCount: r.retrieval_count,
286
280
  helpfulCount: r.helpful_count,
281
+ lastRetrievedAt: (r as any).last_retrieved_at ?? null,
287
282
  createdAt: r.created_at,
288
283
  updatedAt: r.updated_at,
289
284
  score: r.trust_score * (1 - i * 0.05),
@@ -395,9 +390,11 @@ export class FactRetriever {
395
390
  category: r.category as FactCategory,
396
391
  tags: r.tags,
397
392
  keywords: r.keywords ?? '[]',
393
+ summary: (r as any).summary ?? null,
398
394
  trustScore: r.trust_score,
399
395
  retrievalCount: 0,
400
396
  helpfulCount: 0,
397
+ lastRetrievedAt: (r as any).last_retrieved_at ?? null,
401
398
  createdAt: r.created_at,
402
399
  updatedAt: r.updated_at,
403
400
  })
@@ -495,9 +492,11 @@ export class FactRetriever {
495
492
  category: String(row.category) as FactCategory,
496
493
  tags: String(row.tags),
497
494
  keywords: String(row.keywords ?? '[]'),
495
+ summary: row.summary != null ? String(row.summary) : null,
498
496
  trustScore: Number(row.trust_score),
499
497
  retrievalCount: Number(row.retrieval_count),
500
498
  helpfulCount: Number(row.helpful_count),
499
+ lastRetrievedAt: row.last_retrieved_at != null ? String(row.last_retrieved_at) : null,
501
500
  createdAt: String(row.created_at),
502
501
  updatedAt: String(row.updated_at),
503
502
  ftsRank: rawRanks[i] / maxRank,
@@ -605,8 +604,8 @@ export class FactRetriever {
605
604
  const conditions: string[] = []
606
605
  const params: unknown[] = []
607
606
  for (const word of words) {
608
- conditions.push('(f.content LIKE ? OR f.tags LIKE ?)')
609
- params.push(`%${word}%`, `%${word}%`)
607
+ conditions.push('(f.content LIKE ? OR f.tags LIKE ? OR f.summary LIKE ?)')
608
+ params.push(`%${word}%`, `%${word}%`, `%${word}%`)
610
609
  }
611
610
 
612
611
  // 中文子串分解:将中文查询拆为 2~3 字滑动窗口,追加 LIKE 条件
@@ -618,14 +617,14 @@ export class FactRetriever {
618
617
  // 2-gram
619
618
  for (let i = 0; i < seg.length - 1; i++) {
620
619
  const bigram = seg.slice(i, i + 2)
621
- conditions.push('(f.content LIKE ? OR f.tags LIKE ?)')
622
- params.push(`%${bigram}%`, `%${bigram}%`)
620
+ conditions.push('(f.content LIKE ? OR f.tags LIKE ? OR f.summary LIKE ?)')
621
+ params.push(`%${bigram}%`, `%${bigram}%`, `%${bigram}%`)
623
622
  }
624
623
  // 3-gram(覆盖更长的短语匹配)
625
624
  for (let i = 0; i < seg.length - 2; i++) {
626
625
  const trigram = seg.slice(i, i + 3)
627
- conditions.push('(f.content LIKE ? OR f.tags LIKE ?)')
628
- params.push(`%${trigram}%`, `%${trigram}%`)
626
+ conditions.push('(f.content LIKE ? OR f.tags LIKE ? OR f.summary LIKE ?)')
627
+ params.push(`%${trigram}%`, `%${trigram}%`, `%${trigram}%`)
629
628
  }
630
629
  }
631
630
  }
@@ -642,7 +641,7 @@ export class FactRetriever {
642
641
 
643
642
  const sql = `
644
643
  SELECT f.fact_id, f.content, f.category, f.tags, f.keywords,
645
- f.trust_score, f.retrieval_count, f.helpful_count,
644
+ f.summary, f.trust_score, f.retrieval_count, f.helpful_count,
646
645
  f.created_at, f.updated_at
647
646
  FROM facts f
648
647
  WHERE (${conditionsSql})
@@ -654,6 +653,7 @@ export class FactRetriever {
654
653
 
655
654
  const rows = this.db.prepare(sql).all(...params) as Array<{
656
655
  fact_id: number; content: string; category: string; tags: string; keywords: string;
656
+ summary: string | null;
657
657
  trust_score: number; retrieval_count: number; helpful_count: number;
658
658
  created_at: string; updated_at: string;
659
659
  }>
@@ -665,9 +665,11 @@ export class FactRetriever {
665
665
  category: r.category as FactCategory,
666
666
  tags: r.tags,
667
667
  keywords: r.keywords ?? '[]',
668
+ summary: r.summary ?? null,
668
669
  trustScore: r.trust_score,
669
670
  retrievalCount: r.retrieval_count,
670
671
  helpfulCount: r.helpful_count,
672
+ lastRetrievedAt: (r as any).last_retrieved_at ?? null,
671
673
  createdAt: r.created_at,
672
674
  updatedAt: r.updated_at,
673
675
  ftsRank: 0.5,
package/src/schema.ts CHANGED
@@ -6,9 +6,11 @@ CREATE TABLE IF NOT EXISTS facts (
6
6
  category TEXT DEFAULT 'general',
7
7
  tags TEXT DEFAULT '',
8
8
  keywords TEXT DEFAULT '[]',
9
+ summary TEXT DEFAULT NULL,
9
10
  trust_score REAL DEFAULT 0.5,
10
11
  retrieval_count INTEGER DEFAULT 0,
11
12
  helpful_count INTEGER DEFAULT 0,
13
+ last_retrieved_at TEXT DEFAULT NULL,
12
14
  created_at TEXT DEFAULT (datetime('now', 'localtime')),
13
15
  updated_at TEXT DEFAULT (datetime('now', 'localtime'))
14
16
  );
@@ -29,33 +31,42 @@ CREATE TABLE IF NOT EXISTS fact_entities (
29
31
  PRIMARY KEY (fact_id, entity_id)
30
32
  );
31
33
 
34
+ -- 检索日志表
35
+ CREATE TABLE IF NOT EXISTS retrieval_log (
36
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ query TEXT NOT NULL,
38
+ results TEXT DEFAULT NULL,
39
+ timestamp TEXT DEFAULT (datetime('now', 'localtime'))
40
+ );
41
+
32
42
  -- 索引
33
43
  CREATE INDEX IF NOT EXISTS idx_facts_trust ON facts(trust_score DESC);
34
44
  CREATE INDEX IF NOT EXISTS idx_facts_category ON facts(category);
35
45
  CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(name);
36
46
  CREATE INDEX IF NOT EXISTS idx_fact_entities_entity ON fact_entities(entity_id);
47
+ CREATE INDEX IF NOT EXISTS idx_retrieval_log_ts ON retrieval_log(timestamp);
37
48
 
38
- -- FTS5 全文索引
49
+ -- FTS5 全文索引(含 summary 列)
39
50
  CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts
40
- USING fts5(content, tags, content=facts, content_rowid=fact_id);
51
+ USING fts5(content, tags, summary, content=facts, content_rowid=fact_id);
41
52
 
42
53
  -- FTS5 同步触发器:插入
43
54
  CREATE TRIGGER IF NOT EXISTS facts_ai AFTER INSERT ON facts BEGIN
44
- INSERT INTO facts_fts(rowid, content, tags)
45
- VALUES (new.fact_id, new.content, new.tags);
55
+ INSERT INTO facts_fts(rowid, content, tags, summary)
56
+ VALUES (new.fact_id, new.content, new.tags, COALESCE(new.summary, ''));
46
57
  END;
47
58
 
48
59
  -- FTS5 同步触发器:删除
49
60
  CREATE TRIGGER IF NOT EXISTS facts_ad AFTER DELETE ON facts BEGIN
50
- INSERT INTO facts_fts(facts_fts, rowid, content, tags)
51
- VALUES ('delete', old.fact_id, old.content, old.tags);
61
+ INSERT INTO facts_fts(facts_fts, rowid, content, tags, summary)
62
+ VALUES ('delete', old.fact_id, old.content, old.tags, COALESCE(old.summary, ''));
52
63
  END;
53
64
 
54
65
  -- FTS5 同步触发器:更新
55
66
  CREATE TRIGGER IF NOT EXISTS facts_au AFTER UPDATE ON facts BEGIN
56
- INSERT INTO facts_fts(facts_fts, rowid, content, tags)
57
- VALUES ('delete', old.fact_id, old.content, old.tags);
58
- INSERT INTO facts_fts(rowid, content, tags)
59
- VALUES (new.fact_id, new.content, new.tags);
67
+ INSERT INTO facts_fts(facts_fts, rowid, content, tags, summary)
68
+ VALUES ('delete', old.fact_id, old.content, old.tags, COALESCE(old.summary, ''));
69
+ INSERT INTO facts_fts(rowid, content, tags, summary)
70
+ VALUES (new.fact_id, new.content, new.tags, COALESCE(new.summary, ''));
60
71
  END;
61
72
  `
package/src/server.ts CHANGED
@@ -7,6 +7,7 @@ import { homedir } from 'node:os'
7
7
  import { z } from 'zod/v4'
8
8
  import { MemoryStore } from './store.js'
9
9
  import { FactRetriever } from './retriever.js'
10
+ import { ResourceManager } from './resources.js'
10
11
  import { fullSecurityScan } from './security.js'
11
12
  import type { FactStoreArgs, FactFeedbackArgs, FactCategory } from './types.js'
12
13
 
@@ -26,8 +27,9 @@ const FACT_STORE_DESCRIPTION = `结构化事实记忆系统(SQLite+FTS5 索引
26
27
  写入时先 search 检查是否已存在相似事实。identity/coding_style/tool_pref/workflow/general → 全局库,project → 项目库。`
27
28
 
28
29
  const factStoreSchema = {
29
- 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']),
30
31
  content: z.union([z.string(), z.array(z.string())]).optional().describe("事实内容('add' 必需,支持批量)"),
32
+ summary: z.string().optional().describe('超长事实的摘要(检索用 summary 匹配)'),
31
33
  query: z.string().optional().describe("搜索查询('search' 必需)"),
32
34
  entity: z.string().optional().describe("实体名('probe'/'related' 使用)"),
33
35
  entities: z.array(z.string()).optional().describe("实体列表('reason' 使用)"),
@@ -61,9 +63,25 @@ const retriever = new FactRetriever(store, { temporalDecayHalfLife: 30 })
61
63
  store.decayTrustScores()
62
64
  store.auditContradictions()
63
65
 
66
+ // Auto-learn on startup (non-blocking)
67
+ process.nextTick(() => {
68
+ try {
69
+ const result = store.runLearning()
70
+ if (result.demoted > 0 || result.aged > 0 || result.long_facts.length > 0) {
71
+ console.error(`[mnemo:auto-learn] promoted=${result.promoted} demoted=${result.demoted} aged=${result.aged} long_facts=${result.long_facts.length}`)
72
+ }
73
+ } catch (err) {
74
+ console.error('[mnemo:auto-learn] error:', err)
75
+ }
76
+ })
77
+
64
78
  // -- MCP Server --
65
79
  const server = new McpServer({ name: 'mnemo-mcp', version: '0.1.0' })
66
80
 
81
+ // -- MCP Resources: 会话预热注入 --
82
+ const resourceManager = new ResourceManager(store)
83
+ resourceManager.registerResources(server)
84
+
67
85
  server.tool(
68
86
  'fact_store',
69
87
  FACT_STORE_DESCRIPTION,
@@ -88,19 +106,29 @@ server.tool(
88
106
  let warnings: string[] | undefined
89
107
  const scan = fullSecurityScan(content)
90
108
  if (scan.warnings.length > 0 || scan.hasPii) warnings = [...scan.warnings]
109
+ if (content.length > 500 && !a.summary) {
110
+ warnings = [...(warnings ?? []), 'content 超过 500 字,建议提供 summary 或拆分为多条 fact']
111
+ }
91
112
 
92
113
  if (similar) {
93
114
  store.updateFact(similar.factId, { content, tags: a.tags, trustDelta: 0.05 })
115
+ if (a.summary) {
116
+ store.connection.prepare('UPDATE facts SET summary = ? WHERE fact_id = ?').run(a.summary, similar.factId)
117
+ }
94
118
  const demoted = store.demoteContradictingFacts(similar.factId, content, category)
95
119
  results.push({ fact_id: similar.factId, status: 'updated', reason: 'similar_fact_merged', ...(demoted > 0 ? { contradicted_demoted: demoted } : {}), ...(warnings ? { warnings } : {}) })
96
120
  } else {
97
121
  const factId = store.addFact(content, category, a.tags ?? '')
122
+ if (a.summary) {
123
+ store.connection.prepare('UPDATE facts SET summary = ? WHERE fact_id = ?').run(a.summary, factId)
124
+ }
98
125
  const demoted = store.demoteContradictingFacts(factId, content, category)
99
126
  results.push({ fact_id: factId, status: 'added', category, ...(demoted > 0 ? { contradicted_demoted: demoted } : {}), ...(warnings ? { warnings } : {}) })
100
127
  }
101
128
  }
102
129
 
103
130
  retriever.getCache().clear()
131
+ resourceManager.invalidate()
104
132
  const response = Array.isArray(a.content) ? results : results[0]
105
133
  return { content: [{ type: 'text' as const, text: JSON.stringify(response) }] }
106
134
  }
@@ -138,7 +166,11 @@ server.tool(
138
166
  case 'update': {
139
167
  if (!a.fact_id) return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Missing required argument: fact_id' }) }] }
140
168
  const updated = store.updateFact(a.fact_id as number, { content: a.content as string | undefined, tags: a.tags, category, trustDelta: a.trust_delta })
169
+ if (a.summary !== undefined) {
170
+ store.connection.prepare('UPDATE facts SET summary = ? WHERE fact_id = ?').run(a.summary, a.fact_id as number)
171
+ }
141
172
  retriever.getCache().clear()
173
+ resourceManager.invalidate()
142
174
  return { content: [{ type: 'text' as const, text: JSON.stringify({ updated }) }] }
143
175
  }
144
176
 
@@ -147,10 +179,21 @@ server.tool(
147
179
  const ids = Array.isArray(a.fact_id) ? a.fact_id : [a.fact_id]
148
180
  const results = ids.map(id => ({ fact_id: id, removed: store.removeFact(id) }))
149
181
  retriever.getCache().clear()
182
+ resourceManager.invalidate()
150
183
  const response = Array.isArray(a.fact_id) ? results : results[0]
151
184
  return { content: [{ type: 'text' as const, text: JSON.stringify(response) }] }
152
185
  }
153
186
 
187
+ case 'learn': {
188
+ const result = store.runLearning()
189
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
190
+ }
191
+
192
+ case 'audit': {
193
+ const report = store.runAudit()
194
+ return { content: [{ type: 'text' as const, text: JSON.stringify(report) }] }
195
+ }
196
+
154
197
  case 'list': {
155
198
  const facts = store.listFacts(category, a.min_trust ?? 0.0, a.limit ?? 10)
156
199
  return { content: [{ type: 'text' as const, text: JSON.stringify({ facts, count: facts.length }) }] }
package/src/store.ts CHANGED
@@ -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)
140
+ this.db.exec(`
141
+ CREATE VIRTUAL TABLE facts_fts
142
+ USING fts5(content, tags, summary, content=facts, content_rowid=fact_id);
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,160 @@ 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
+ if (rate < 0.05) {
747
+ const newTrust = clampTrust(row.trust_score * 0.9)
748
+ this.db.prepare('UPDATE facts SET trust_score = ? WHERE fact_id = ?').run(newTrust, row.fact_id)
749
+ demoted++
750
+ changed = true
751
+ } else if (rate > 0.3) {
752
+ const newTrust = clampTrust(row.trust_score + 0.05)
753
+ this.db.prepare('UPDATE facts SET trust_score = ? WHERE fact_id = ?').run(newTrust, row.fact_id)
754
+ promoted++
755
+ changed = true
756
+ }
757
+ }
758
+
759
+ // Aging (60 天未检索)
760
+ if (row.last_retrieved_at) {
761
+ const lastRetrieved = new Date(row.last_retrieved_at + 'Z').getTime()
762
+ const daysSinceRetrieval = (now - lastRetrieved) / 86_400_000
763
+ if (daysSinceRetrieval > 60) {
764
+ const currentTrust = this.db.prepare('SELECT trust_score FROM facts WHERE fact_id = ?').get(row.fact_id) as any
765
+ const newTrust = clampTrust(currentTrust.trust_score * 0.95)
766
+ this.db.prepare('UPDATE facts SET trust_score = ? WHERE fact_id = ?').run(newTrust, row.fact_id)
767
+ aged++
768
+ changed = true
769
+ }
770
+ }
771
+ // last_retrieved_at 为 NULL = 新 fact,不老化
772
+
773
+ if (!changed) unchanged++
774
+
775
+ // Long facts report (content > 300 字无 summary)
776
+ const matchLength = row.summary ? row.summary.length : row.content.length
777
+ if (matchLength > 300) {
778
+ longFacts.push({
779
+ id: row.fact_id,
780
+ content_length: row.content.length,
781
+ penalty: Math.min(1.0, 300 / matchLength),
782
+ has_summary: !!row.summary,
783
+ })
784
+ }
785
+ }
786
+
787
+ return { promoted, demoted, aged, unchanged, long_facts: longFacts }
788
+ }
789
+
790
+ /** 数据质量审计(只读,不修改数据) */
791
+ runAudit(): {
792
+ total_facts: number
793
+ long_without_summary: Array<{ id: number; content_length: number }>
794
+ low_helpful_rate: Array<{ id: number; rate: number; retrieval_count: number }>
795
+ aging_candidates: Array<{ id: number; last_retrieved_at: string | null }>
796
+ } {
797
+ const rows = this.db.prepare(
798
+ 'SELECT fact_id, content, summary, retrieval_count, helpful_count, last_retrieved_at FROM facts'
799
+ ).all() as Array<{
800
+ fact_id: number; content: string; summary: string | null;
801
+ retrieval_count: number; helpful_count: number; last_retrieved_at: string | null
802
+ }>
803
+
804
+ const longWithoutSummary: Array<{ id: number; content_length: number }> = []
805
+ const lowHelpfulRate: Array<{ id: number; rate: number; retrieval_count: number }> = []
806
+ const agingCandidates: Array<{ id: number; last_retrieved_at: string | null }> = []
807
+
808
+ const now = Date.now()
809
+
810
+ for (const row of rows) {
811
+ // 超 500 字无 summary
812
+ if (row.content.length > 500 && !row.summary) {
813
+ longWithoutSummary.push({ id: row.fact_id, content_length: row.content.length })
814
+ }
815
+
816
+ // 低 helpful 率(>30 次检索,rate < 5%)
817
+ if (row.retrieval_count > 30) {
818
+ const rate = row.helpful_count / row.retrieval_count
819
+ if (rate < 0.05) {
820
+ lowHelpfulRate.push({ id: row.fact_id, rate: Math.round(rate * 1000) / 1000, retrieval_count: row.retrieval_count })
821
+ }
822
+ }
823
+
824
+ // 老化候选(>60 天未检索)
825
+ if (row.last_retrieved_at) {
826
+ const lastRetrieved = new Date(row.last_retrieved_at + 'Z').getTime()
827
+ const daysSince = (now - lastRetrieved) / 86_400_000
828
+ if (daysSince > 60) {
829
+ agingCandidates.push({ id: row.fact_id, last_retrieved_at: row.last_retrieved_at })
830
+ }
831
+ }
832
+ }
833
+
834
+ return {
835
+ total_facts: rows.length,
836
+ long_without_summary: longWithoutSummary,
837
+ low_helpful_rate: lowHelpfulRate,
838
+ aging_candidates: agingCandidates,
839
+ }
840
+ }
841
+
638
842
  /** 获取数据库连接(供 FactRetriever 直接使用) */
639
843
  get connection(): Database.Database {
640
844
  return this.db
@@ -795,9 +999,11 @@ export class MemoryStore {
795
999
  category: row.category as FactCategory,
796
1000
  tags: row.tags,
797
1001
  keywords: row.keywords,
1002
+ summary: (row as any).summary ?? null,
798
1003
  trustScore: row.trust_score,
799
1004
  retrievalCount: row.retrieval_count,
800
1005
  helpfulCount: row.helpful_count,
1006
+ lastRetrievedAt: (row as any).last_retrieved_at ?? null,
801
1007
  createdAt: row.created_at,
802
1008
  updatedAt: row.updated_at,
803
1009
  }