@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.
- package/README.md +43 -14
- package/dist/init.js +16 -8
- package/dist/init.js.map +1 -1
- package/dist/refine.d.ts +14 -0
- package/dist/refine.js +115 -0
- package/dist/refine.js.map +1 -0
- package/dist/resources.d.ts +27 -0
- package/dist/resources.js +56 -0
- package/dist/resources.js.map +1 -0
- package/dist/retriever.d.ts +3 -1
- package/dist/retriever.js +42 -42
- package/dist/retriever.js.map +1 -1
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +21 -10
- package/dist/schema.js.map +1 -1
- package/dist/server.js +41 -1
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +37 -0
- package/dist/store.js +166 -9
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +4 -1
- package/docs/superpowers/plans/2026-05-15-mnemo-mcp.md +1154 -0
- package/docs/superpowers/plans/2026-05-16-memory-self-learning.md +932 -0
- package/docs/superpowers/plans/2026-05-16-mnemo-query-cache.md +613 -0
- package/docs/superpowers/plans/2026-05-16-retrieval-and-injection-optimization.md +770 -0
- package/openspec/changes/archive/2026-05-15-mnemo-mcp/.openspec.yaml +2 -0
- package/openspec/changes/archive/2026-05-15-mnemo-mcp/design.md +83 -0
- package/openspec/changes/archive/2026-05-15-mnemo-mcp/proposal.md +32 -0
- package/openspec/changes/archive/2026-05-15-mnemo-mcp/specs/fact-retrieval/spec.md +75 -0
- package/openspec/changes/archive/2026-05-15-mnemo-mcp/specs/fact-store/spec.md +83 -0
- package/openspec/changes/archive/2026-05-15-mnemo-mcp/specs/mcp-server/spec.md +34 -0
- package/openspec/changes/archive/2026-05-15-mnemo-mcp/specs/security/spec.md +37 -0
- package/openspec/changes/archive/2026-05-15-mnemo-mcp/tasks.md +44 -0
- package/openspec/changes/archive/2026-05-16-mnemo-query-cache/.openspec.yaml +2 -0
- package/openspec/changes/archive/2026-05-16-mnemo-query-cache/design.md +96 -0
- package/openspec/changes/archive/2026-05-16-mnemo-query-cache/proposal.md +29 -0
- package/openspec/changes/archive/2026-05-16-mnemo-query-cache/specs/batch-operations/spec.md +42 -0
- package/openspec/changes/archive/2026-05-16-mnemo-query-cache/specs/perf-metrics/spec.md +55 -0
- package/openspec/changes/archive/2026-05-16-mnemo-query-cache/specs/query-cache/spec.md +65 -0
- package/openspec/changes/archive/2026-05-16-mnemo-query-cache/tasks.md +45 -0
- package/openspec/changes/memory-self-learning/.openspec.yaml +2 -0
- package/openspec/changes/memory-self-learning/design.md +174 -0
- package/openspec/changes/memory-self-learning/proposal.md +35 -0
- package/openspec/changes/memory-self-learning/specs/fact-retrieval/spec.md +35 -0
- package/openspec/changes/memory-self-learning/specs/fact-summary/spec.md +45 -0
- package/openspec/changes/memory-self-learning/specs/length-penalty/spec.md +27 -0
- package/openspec/changes/memory-self-learning/specs/retrieval-log/spec.md +41 -0
- package/openspec/changes/memory-self-learning/specs/self-learning/spec.md +68 -0
- package/openspec/changes/memory-self-learning/tasks.md +56 -0
- package/openspec/changes/retrieval-and-injection-optimization/.openspec.yaml +2 -0
- package/openspec/changes/retrieval-and-injection-optimization/design.md +117 -0
- package/openspec/changes/retrieval-and-injection-optimization/proposal.md +30 -0
- package/openspec/changes/retrieval-and-injection-optimization/specs/adaptive-scoring/spec.md +43 -0
- package/openspec/changes/retrieval-and-injection-optimization/specs/injection-protocol/spec.md +48 -0
- package/openspec/changes/retrieval-and-injection-optimization/specs/mcp-resources/spec.md +39 -0
- package/openspec/changes/retrieval-and-injection-optimization/specs/query-refinement/spec.md +39 -0
- package/openspec/changes/retrieval-and-injection-optimization/tasks.md +33 -0
- package/openspec/config.yaml +20 -0
- package/package.json +1 -1
- package/src/init.ts +17 -9
- package/src/refine.ts +127 -0
- package/src/resources.ts +78 -0
- package/src/retriever.ts +46 -44
- package/src/schema.ts +21 -10
- package/src/server.ts +44 -1
- package/src/store.ts +215 -9
- package/src/types.ts +4 -1
- package/tests/refine.test.ts +52 -0
- package/tests/resource.test.ts +62 -0
- package/tests/retriever.test.ts +53 -0
- 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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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([...
|
|
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 =
|
|
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
|
-
//
|
|
140
|
-
const
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
}
|