@morningljn/mnemo 0.1.3 → 0.1.4
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 +38 -26
- package/dist/retriever.js.map +1 -1
- package/dist/server.js +7 -0
- package/dist/server.js.map +1 -1
- package/docs/superpowers/plans/2026-05-15-mnemo-mcp.md +1154 -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/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 +40 -26
- package/src/server.ts +8 -0
- package/tests/refine.test.ts +52 -0
- package/tests/resource.test.ts +62 -0
package/src/refine.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query refinement: strip noise tokens from user messages before memory search.
|
|
3
|
+
* Pure function — no side effects, no DB access.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { FactCategory } from './types.js'
|
|
7
|
+
|
|
8
|
+
// Action words / helper phrases to strip (Chinese)
|
|
9
|
+
const ACTION_WORDS = [
|
|
10
|
+
'帮我看看', '能不能帮我', '给我看看',
|
|
11
|
+
'帮我', '看看', '看一下', '做一下', '能不能', '为什么', '怎么',
|
|
12
|
+
'是什么', '如何', '请', '麻烦', '可以', '给我',
|
|
13
|
+
'给我做', '给我写', '给我查', '给我找', '给我说', '给我讲',
|
|
14
|
+
'告诉我', '跟我说', '跟我讲', '给我解释', '给我说明', '给我介绍',
|
|
15
|
+
'运行', '执行', '启动', '停止', '创建', '删除', '修改', '更新', '查看',
|
|
16
|
+
'检查', '测试', '提交', '推送', '拉取', '合并', '切换', '重置', '重构',
|
|
17
|
+
'运行测试', '创建文件',
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
// Common CLI commands / low-signal English tokens to filter
|
|
21
|
+
const NOISE_WORDS = new Set([
|
|
22
|
+
'git', 'npm', 'npx', 'yarn', 'pnpm', 'status', 'log', 'diff', 'add',
|
|
23
|
+
'commit', 'push', 'pull', 'merge', 'checkout', 'branch', 'stash',
|
|
24
|
+
'install', 'build', 'run', 'start', 'stop', 'test', 'lint', 'format',
|
|
25
|
+
])
|
|
26
|
+
// Sort by length descending so longer phrases match first during replacement
|
|
27
|
+
const ACTION_WORDS_SORTED = [...ACTION_WORDS].sort((a, b) => b.length - a.length)
|
|
28
|
+
const ACTION_WORDS_SET = new Set(ACTION_WORDS)
|
|
29
|
+
|
|
30
|
+
// Reuse existing stop words from retriever
|
|
31
|
+
const CN_STOP_WORDS = new Set([
|
|
32
|
+
'的', '了', '是', '在', '有', '和', '就', '不', '人', '都',
|
|
33
|
+
'一', '个', '上', '也', '很', '到', '说', '要', '去', '你',
|
|
34
|
+
'会', '着', '没', '看', '好', '自', '这', '他', '她', '它',
|
|
35
|
+
'那', '些', '用', '对', '下', '为', '从', '被', '把', '能',
|
|
36
|
+
'可', '以', '所', '而', '又', '与', '但', '或', '等', '中',
|
|
37
|
+
'大', '小', '多', '少', '其', '之', '做', '让', '给', '已',
|
|
38
|
+
'还', '来', '地', '得', '过', '时', '里', '后', '前', '当',
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
export interface RefineResult {
|
|
42
|
+
query: string | null
|
|
43
|
+
tokens: string[]
|
|
44
|
+
entityTokens: string[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Refine a raw user message into memory-searchable keywords.
|
|
49
|
+
* Returns null if the message is a pure operation command with no memory relevance.
|
|
50
|
+
*/
|
|
51
|
+
export function refineQuery(raw: string): RefineResult | null {
|
|
52
|
+
const trimmed = raw.trim()
|
|
53
|
+
if (!trimmed) return null
|
|
54
|
+
|
|
55
|
+
// Extract high-signal tokens first: quoted content, book titles, capitalized phrases
|
|
56
|
+
const entityTokens: string[] = []
|
|
57
|
+
|
|
58
|
+
// Chinese quotes: 「深色主题」 or "深色主题" or '深色主题'
|
|
59
|
+
for (const m of trimmed.matchAll(/[「""'']([^「""''」]{2,20})[」""'']/g)) {
|
|
60
|
+
entityTokens.push(m[1])
|
|
61
|
+
}
|
|
62
|
+
// Book titles: 《记忆系统》
|
|
63
|
+
for (const m of trimmed.matchAll(/《([^》]+)》/g)) {
|
|
64
|
+
entityTokens.push(m[1])
|
|
65
|
+
}
|
|
66
|
+
// Capitalized English phrases: "TypeScript", "Visual Studio Code"
|
|
67
|
+
for (const m of trimmed.matchAll(/\b([A-Z][a-zA-Z]*(?:\s+[A-Z][a-zA-Z]*)+)\b/g)) {
|
|
68
|
+
entityTokens.push(m[1])
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Tokenize: split by spaces and Chinese character boundaries
|
|
72
|
+
const tokens: string[] = []
|
|
73
|
+
const parts = trimmed.split(/\s+/)
|
|
74
|
+
for (const part of parts) {
|
|
75
|
+
// English words
|
|
76
|
+
for (const word of part.match(/[a-zA-Z0-9_\-.]+/g) ?? []) {
|
|
77
|
+
if (word.length >= 2) tokens.push(word)
|
|
78
|
+
}
|
|
79
|
+
// For Chinese: strip action words first, then extract remaining chars
|
|
80
|
+
let cnText = part.replace(/[\u4e00-\u9fff]+/g, (seg) => {
|
|
81
|
+
let result = seg
|
|
82
|
+
for (const aw of ACTION_WORDS_SORTED) {
|
|
83
|
+
result = result.replaceAll(aw, '')
|
|
84
|
+
}
|
|
85
|
+
return result
|
|
86
|
+
})
|
|
87
|
+
const cnChars = cnText.match(/[\u4e00-\u9fff]/g) ?? []
|
|
88
|
+
for (const c of cnChars) {
|
|
89
|
+
if (!CN_STOP_WORDS.has(c)) tokens.push(c)
|
|
90
|
+
}
|
|
91
|
+
// Chinese 2-grams for better matching
|
|
92
|
+
for (let i = 0; i < cnChars.length - 1; i++) {
|
|
93
|
+
const bigram = cnChars[i] + cnChars[i + 1]
|
|
94
|
+
tokens.push(bigram)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Filter stop words, noise, and short tokens
|
|
99
|
+
const filtered = tokens.filter(t => {
|
|
100
|
+
if (ACTION_WORDS_SET.has(t)) return false
|
|
101
|
+
if (CN_STOP_WORDS.has(t)) return false
|
|
102
|
+
if (NOISE_WORDS.has(t.toLowerCase())) return false
|
|
103
|
+
if (t.length < 2) return false
|
|
104
|
+
return true
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// Deduplicate while preserving order
|
|
108
|
+
const seen = new Set<string>()
|
|
109
|
+
const deduped: string[] = []
|
|
110
|
+
for (const t of filtered) {
|
|
111
|
+
if (!seen.has(t)) {
|
|
112
|
+
seen.add(t)
|
|
113
|
+
deduped.push(t)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// If nothing left after filtering, check if we have entity tokens
|
|
118
|
+
if (deduped.length === 0 && entityTokens.length === 0) {
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Combine: entity tokens first (higher signal), then deduped tokens
|
|
123
|
+
const allTokens = [...entityTokens, ...deduped.filter(t => !entityTokens.includes(t))]
|
|
124
|
+
const query = allTokens.join(' ')
|
|
125
|
+
|
|
126
|
+
return { query, tokens: deduped, entityTokens }
|
|
127
|
+
}
|
package/src/resources.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Resource manager for mnemo-mcp.
|
|
3
|
+
* Exposes per-category memory summaries as MCP Resources for session warmup injection.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
7
|
+
import type { MemoryStore } from './store.js'
|
|
8
|
+
import type { FactCategory } from './types.js'
|
|
9
|
+
|
|
10
|
+
const CATEGORIES: FactCategory[] = ['identity', 'coding_style', 'tool_pref', 'workflow', 'general']
|
|
11
|
+
const RESOURCE_LIMIT = 10
|
|
12
|
+
|
|
13
|
+
export interface ResourceFact {
|
|
14
|
+
fact_id: number
|
|
15
|
+
content: string
|
|
16
|
+
trust_score: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class ResourceManager {
|
|
20
|
+
private cache = new Map<FactCategory, ResourceFact[]>()
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
private store: MemoryStore,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
/** Register all category resources with the MCP server */
|
|
27
|
+
registerResources(server: McpServer): void {
|
|
28
|
+
for (const category of CATEGORIES) {
|
|
29
|
+
const uri = `mnemo://global/${category}`
|
|
30
|
+
server.registerResource(
|
|
31
|
+
`mnemo-global-${category}`,
|
|
32
|
+
uri,
|
|
33
|
+
{
|
|
34
|
+
description: `${category} category global facts (top ${RESOURCE_LIMIT} by trust)`,
|
|
35
|
+
mimeType: 'application/json',
|
|
36
|
+
},
|
|
37
|
+
async () => this.readCategory(category),
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Read handler for a specific category */
|
|
43
|
+
private readCategory(category: FactCategory): { contents: Array<{ uri: string; mimeType: string; text: string }> } {
|
|
44
|
+
const facts = this.getFacts(category)
|
|
45
|
+
return {
|
|
46
|
+
contents: [{
|
|
47
|
+
uri: `mnemo://global/${category}`,
|
|
48
|
+
mimeType: 'application/json',
|
|
49
|
+
text: JSON.stringify(facts, null, 2),
|
|
50
|
+
}],
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Get facts for a category — with caching */
|
|
55
|
+
getFacts(category: FactCategory): ResourceFact[] {
|
|
56
|
+
const cached = this.cache.get(category)
|
|
57
|
+
if (cached) return cached
|
|
58
|
+
|
|
59
|
+
const facts = this.store.listFacts(category, 0.0, RESOURCE_LIMIT).map(f => ({
|
|
60
|
+
fact_id: f.factId,
|
|
61
|
+
content: f.content,
|
|
62
|
+
trust_score: f.trustScore,
|
|
63
|
+
}))
|
|
64
|
+
|
|
65
|
+
this.cache.set(category, facts)
|
|
66
|
+
return facts
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Invalidate all caches — call after any write operation */
|
|
70
|
+
invalidate(): void {
|
|
71
|
+
this.cache.clear()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Get cache entry count for debugging */
|
|
75
|
+
cacheSize(): number {
|
|
76
|
+
return this.cache.size
|
|
77
|
+
}
|
|
78
|
+
}
|
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,18 +103,22 @@ 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
109
|
// 个人/身份相关的短查询触发 trust fallback
|
|
100
|
-
if (this.isPersonalQuery(
|
|
110
|
+
if (this.isPersonalQuery(searchQuery)) {
|
|
101
111
|
return this.trustFallback(category, minTrust, limit)
|
|
102
112
|
}
|
|
103
113
|
return []
|
|
104
114
|
}
|
|
105
115
|
|
|
106
116
|
// Stage 2-4: Jaccard 重排序 + 信任评分 + 时间衰减
|
|
107
|
-
|
|
117
|
+
// 动态权重:短查询偏 FTS,长查询偏 Jaccard
|
|
118
|
+
const queryTokens = this.tokenize(searchQuery)
|
|
119
|
+
const tokenCount = queryTokens.size
|
|
120
|
+
const ftsWeight = tokenCount <= 3 ? 0.7 : 0.3
|
|
121
|
+
const jaccardWeight = tokenCount <= 3 ? 0.3 : 0.7
|
|
108
122
|
|
|
109
123
|
const scored: ScoredFact[] = []
|
|
110
124
|
|
|
@@ -122,7 +136,7 @@ export class FactRetriever {
|
|
|
122
136
|
const ftsScore = fact.ftsRank
|
|
123
137
|
|
|
124
138
|
// 综合评分
|
|
125
|
-
const relevance =
|
|
139
|
+
const relevance = ftsWeight * ftsScore + jaccardWeight * similarity
|
|
126
140
|
|
|
127
141
|
let score = relevance * fact.trustScore
|
|
128
142
|
|
|
@@ -136,29 +150,29 @@ export class FactRetriever {
|
|
|
136
150
|
|
|
137
151
|
scored.sort((a, b) => b.score - a.score)
|
|
138
152
|
|
|
139
|
-
//
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
diverse.push(s)
|
|
155
|
-
if (diverse.length >= limit) break
|
|
153
|
+
// 相关性门控:过滤低相关性结果
|
|
154
|
+
const RELEVANCE_THRESHOLD = 0.15
|
|
155
|
+
const gated = scored.filter(s => s.score >= RELEVANCE_THRESHOLD)
|
|
156
|
+
const pool = gated.length > 0 ? gated : scored
|
|
157
|
+
|
|
158
|
+
// 内容去重:Jaccard > 0.7 的只保留高分
|
|
159
|
+
const results: ScoredFact[] = []
|
|
160
|
+
for (const candidate of pool) {
|
|
161
|
+
let isDuplicate = false
|
|
162
|
+
const candidateTokens = this.tokenize(candidate.content)
|
|
163
|
+
for (const kept of results) {
|
|
164
|
+
const keptTokens = this.tokenize(kept.content)
|
|
165
|
+
if (this.jaccardSimilarity(candidateTokens, keptTokens) > 0.7) {
|
|
166
|
+
isDuplicate = true
|
|
167
|
+
break
|
|
156
168
|
}
|
|
157
169
|
}
|
|
170
|
+
if (!isDuplicate) {
|
|
171
|
+
results.push(candidate)
|
|
172
|
+
if (results.length >= limit) break
|
|
173
|
+
}
|
|
158
174
|
}
|
|
159
175
|
|
|
160
|
-
const results = diverse
|
|
161
|
-
|
|
162
176
|
// 检索追踪:递增 retrieval_count + top3 信任刷新
|
|
163
177
|
if (results.length > 0) {
|
|
164
178
|
this.trackRetrieval(results)
|
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
|
|
|
@@ -64,6 +65,10 @@ store.auditContradictions()
|
|
|
64
65
|
// -- MCP Server --
|
|
65
66
|
const server = new McpServer({ name: 'mnemo-mcp', version: '0.1.0' })
|
|
66
67
|
|
|
68
|
+
// -- MCP Resources: 会话预热注入 --
|
|
69
|
+
const resourceManager = new ResourceManager(store)
|
|
70
|
+
resourceManager.registerResources(server)
|
|
71
|
+
|
|
67
72
|
server.tool(
|
|
68
73
|
'fact_store',
|
|
69
74
|
FACT_STORE_DESCRIPTION,
|
|
@@ -101,6 +106,7 @@ server.tool(
|
|
|
101
106
|
}
|
|
102
107
|
|
|
103
108
|
retriever.getCache().clear()
|
|
109
|
+
resourceManager.invalidate()
|
|
104
110
|
const response = Array.isArray(a.content) ? results : results[0]
|
|
105
111
|
return { content: [{ type: 'text' as const, text: JSON.stringify(response) }] }
|
|
106
112
|
}
|
|
@@ -139,6 +145,7 @@ server.tool(
|
|
|
139
145
|
if (!a.fact_id) return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Missing required argument: fact_id' }) }] }
|
|
140
146
|
const updated = store.updateFact(a.fact_id as number, { content: a.content as string | undefined, tags: a.tags, category, trustDelta: a.trust_delta })
|
|
141
147
|
retriever.getCache().clear()
|
|
148
|
+
resourceManager.invalidate()
|
|
142
149
|
return { content: [{ type: 'text' as const, text: JSON.stringify({ updated }) }] }
|
|
143
150
|
}
|
|
144
151
|
|
|
@@ -147,6 +154,7 @@ server.tool(
|
|
|
147
154
|
const ids = Array.isArray(a.fact_id) ? a.fact_id : [a.fact_id]
|
|
148
155
|
const results = ids.map(id => ({ fact_id: id, removed: store.removeFact(id) }))
|
|
149
156
|
retriever.getCache().clear()
|
|
157
|
+
resourceManager.invalidate()
|
|
150
158
|
const response = Array.isArray(a.fact_id) ? results : results[0]
|
|
151
159
|
return { content: [{ type: 'text' as const, text: JSON.stringify(response) }] }
|
|
152
160
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { refineQuery } from '../src/refine.js'
|
|
3
|
+
|
|
4
|
+
describe('refineQuery', () => {
|
|
5
|
+
it('filters action words from Chinese query', () => {
|
|
6
|
+
const result = refineQuery('帮我用 TypeScript 重构 auth 模块')
|
|
7
|
+
expect(result).not.toBeNull()
|
|
8
|
+
expect(result!.query).toContain('TypeScript')
|
|
9
|
+
expect(result!.query).toContain('auth')
|
|
10
|
+
expect(result!.query).not.toContain('帮我')
|
|
11
|
+
expect(result!.query).not.toContain('重构')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('returns null for pure operation commands', () => {
|
|
15
|
+
expect(refineQuery('运行测试')).toBeNull()
|
|
16
|
+
expect(refineQuery('git status')).toBeNull()
|
|
17
|
+
expect(refineQuery('创建文件')).toBeNull()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('extracts quoted Chinese entities', () => {
|
|
21
|
+
const result = refineQuery('我喜欢「深色主题」')
|
|
22
|
+
expect(result).not.toBeNull()
|
|
23
|
+
expect(result!.entityTokens).toContain('深色主题')
|
|
24
|
+
expect(result!.query).toContain('深色主题')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('extracts book title entities', () => {
|
|
28
|
+
const result = refineQuery('读了《设计模式》这本书')
|
|
29
|
+
expect(result).not.toBeNull()
|
|
30
|
+
expect(result!.entityTokens).toContain('设计模式')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('extracts capitalized English phrases', () => {
|
|
34
|
+
const result = refineQuery('使用 Visual Studio Code 编辑器')
|
|
35
|
+
expect(result).not.toBeNull()
|
|
36
|
+
expect(result!.entityTokens).toContain('Visual Studio Code')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('returns null for empty string', () => {
|
|
40
|
+
expect(refineQuery('')).toBeNull()
|
|
41
|
+
expect(refineQuery(' ')).toBeNull()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('preserves meaningful Chinese tokens', () => {
|
|
45
|
+
const result = refineQuery('用户偏好深色主题')
|
|
46
|
+
expect(result).not.toBeNull()
|
|
47
|
+
expect(result!.query).toContain('用户')
|
|
48
|
+
expect(result!.query).toContain('偏好')
|
|
49
|
+
expect(result!.query).toContain('深色')
|
|
50
|
+
expect(result!.query).toContain('主题')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { MemoryStore } from '../src/store.js'
|
|
3
|
+
import { ResourceManager } from '../src/resources.js'
|
|
4
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
|
|
8
|
+
let store: MemoryStore
|
|
9
|
+
let manager: ResourceManager
|
|
10
|
+
let tmpDir: string
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'mnemo-test-'))
|
|
14
|
+
store = new MemoryStore(join(tmpDir, 'test.db'))
|
|
15
|
+
manager = new ResourceManager(store)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
store.close()
|
|
20
|
+
rmSync(tmpDir, { recursive: true, force: true })
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('ResourceManager', () => {
|
|
24
|
+
it('returns empty array for empty category', () => {
|
|
25
|
+
const facts = manager.getFacts('identity')
|
|
26
|
+
expect(facts).toEqual([])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('returns facts ordered by trust score', () => {
|
|
30
|
+
store.addFact('用户偏好深色主题', 'tool_pref')
|
|
31
|
+
store.addFact('用户喜欢 VS Code', 'tool_pref')
|
|
32
|
+
const facts = manager.getFacts('tool_pref')
|
|
33
|
+
expect(facts.length).toBe(2)
|
|
34
|
+
// listFacts returns trust_score DESC, first fact added gets default trust
|
|
35
|
+
expect(facts.length).toBeGreaterThan(0)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('caches results', () => {
|
|
39
|
+
store.addFact('测试事实', 'general')
|
|
40
|
+
manager.getFacts('general')
|
|
41
|
+
expect(manager.cacheSize()).toBe(1)
|
|
42
|
+
// Second call should hit cache
|
|
43
|
+
const facts2 = manager.getFacts('general')
|
|
44
|
+
expect(facts2.length).toBe(1)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('invalidates cache on write', () => {
|
|
48
|
+
store.addFact('测试事实', 'general')
|
|
49
|
+
manager.getFacts('general')
|
|
50
|
+
expect(manager.cacheSize()).toBe(1)
|
|
51
|
+
manager.invalidate()
|
|
52
|
+
expect(manager.cacheSize()).toBe(0)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('limits to top 10 facts', () => {
|
|
56
|
+
for (let i = 0; i < 15; i++) {
|
|
57
|
+
store.addFact(`事实 ${i}`, 'general')
|
|
58
|
+
}
|
|
59
|
+
const facts = manager.getFacts('general')
|
|
60
|
+
expect(facts.length).toBe(10)
|
|
61
|
+
})
|
|
62
|
+
})
|