@cchez/memory-mcp 1.0.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/DESIGN.md +188 -0
- package/README.md +484 -0
- package/db/.env.example +16 -0
- package/db/docker-compose.yml +33 -0
- package/dist/embedding.js +54 -0
- package/dist/index.js +60 -0
- package/dist/qdrant.js +349 -0
- package/dist/server.js +67 -0
- package/dist/tools/correct.js +57 -0
- package/dist/tools/delete.js +12 -0
- package/dist/tools/episode.js +65 -0
- package/dist/tools/list.js +37 -0
- package/dist/tools/search.js +98 -0
- package/dist/tools/store.js +71 -0
- package/package.json +66 -0
- package/skills/memory-correct/SKILL.md +83 -0
- package/skills/memory-save/SKILL.md +209 -0
- package/skills/memory-search/SKILL.md +156 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
version: "3.8"
|
|
2
|
+
|
|
3
|
+
services:
|
|
4
|
+
qdrant:
|
|
5
|
+
image: qdrant/qdrant:latest
|
|
6
|
+
ports:
|
|
7
|
+
- "6333:6333"
|
|
8
|
+
- "6334:6334"
|
|
9
|
+
volumes:
|
|
10
|
+
- ./qdrant_data:/qdrant/storage
|
|
11
|
+
restart: always
|
|
12
|
+
environment:
|
|
13
|
+
- QDRANT__SERVICE__GRPC_PORT=6334
|
|
14
|
+
|
|
15
|
+
ollama:
|
|
16
|
+
image: ollama/ollama:latest
|
|
17
|
+
ports:
|
|
18
|
+
- "11434:11434"
|
|
19
|
+
volumes:
|
|
20
|
+
- ./ollama_data:/root/.ollama
|
|
21
|
+
restart: always
|
|
22
|
+
# Pull bge-m3 on first start if not already cached
|
|
23
|
+
entrypoint: >
|
|
24
|
+
sh -c "ollama serve &
|
|
25
|
+
sleep 5 &&
|
|
26
|
+
ollama pull bge-m3 &&
|
|
27
|
+
wait"
|
|
28
|
+
healthcheck:
|
|
29
|
+
test: ["CMD", "curl", "-sf", "http://localhost:11434/api/tags"]
|
|
30
|
+
interval: 10s
|
|
31
|
+
timeout: 5s
|
|
32
|
+
retries: 12
|
|
33
|
+
start_period: 60s
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
export class OpenAIEmbeddingProvider {
|
|
3
|
+
client;
|
|
4
|
+
model;
|
|
5
|
+
constructor(apiKey = process.env.OPENAI_API_KEY, model = "text-embedding-3-small") {
|
|
6
|
+
if (!apiKey)
|
|
7
|
+
throw new Error("OPENAI_API_KEY is required");
|
|
8
|
+
this.client = new OpenAI({ apiKey });
|
|
9
|
+
this.model = model;
|
|
10
|
+
}
|
|
11
|
+
async embed(text) {
|
|
12
|
+
const response = await this.client.embeddings.create({
|
|
13
|
+
model: this.model,
|
|
14
|
+
input: text,
|
|
15
|
+
});
|
|
16
|
+
return response.data[0].embedding;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export class OllamaEmbeddingProvider {
|
|
20
|
+
baseUrl;
|
|
21
|
+
model;
|
|
22
|
+
constructor(baseUrl = process.env.OLLAMA_BASE_URL ?? "http://localhost:11434", model = process.env.OLLAMA_MODEL ?? "nomic-embed-text") {
|
|
23
|
+
this.baseUrl = baseUrl;
|
|
24
|
+
this.model = model;
|
|
25
|
+
}
|
|
26
|
+
async embed(text) {
|
|
27
|
+
const response = await fetch(`${this.baseUrl}/api/embeddings`, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: { "Content-Type": "application/json" },
|
|
30
|
+
body: JSON.stringify({ model: this.model, prompt: text }),
|
|
31
|
+
});
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
const body = await response.text();
|
|
34
|
+
throw new Error(`Ollama embedding failed: ${response.status} — ${body}`);
|
|
35
|
+
}
|
|
36
|
+
const data = (await response.json());
|
|
37
|
+
return data.embedding;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
let _provider = null;
|
|
41
|
+
export function getEmbeddingProvider() {
|
|
42
|
+
if (!_provider) {
|
|
43
|
+
const providerName = process.env.EMBEDDING_PROVIDER ?? "openai";
|
|
44
|
+
if (providerName === "ollama") {
|
|
45
|
+
_provider = new OllamaEmbeddingProvider();
|
|
46
|
+
console.error(`[embedding] Using Ollama: ${process.env.OLLAMA_MODEL ?? "nomic-embed-text"}`);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
_provider = new OpenAIEmbeddingProvider();
|
|
50
|
+
console.error("[embedding] Using OpenAI");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return _provider;
|
|
54
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { storeMemorySchema, storeMemory } from "./tools/store.js";
|
|
5
|
+
import { searchMemorySchema, searchMemoryTool } from "./tools/search.js";
|
|
6
|
+
import { deleteMemorySchema, deleteMemoryTool } from "./tools/delete.js";
|
|
7
|
+
import { listMemoriesSchema, listMemoriesTool } from "./tools/list.js";
|
|
8
|
+
import { correctMemorySchema, correctMemoryTool } from "./tools/correct.js";
|
|
9
|
+
import { captureEpisodeSchema, captureEpisodeTool } from "./tools/episode.js";
|
|
10
|
+
import { initCollections } from "./qdrant.js";
|
|
11
|
+
async function main() {
|
|
12
|
+
await initCollections();
|
|
13
|
+
const server = new McpServer({
|
|
14
|
+
name: "knowledge-base",
|
|
15
|
+
version: "1.0.0",
|
|
16
|
+
});
|
|
17
|
+
server.tool("store_memory", "将知识内容向量化并存入知识库。根据 memory_type 自动路由到对应 collection:rule/decision/preference → coding,fact/summary → workspace。内容相同时自动去重。返回 id 和 collection。", storeMemorySchema.shape, async (args) => {
|
|
18
|
+
const result = await storeMemory(args);
|
|
19
|
+
return {
|
|
20
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
server.tool("search_memory", "混合搜索知识库。默认同时搜 coding + workspace 两个 collection,并融合向量相似度、关键词匹配、recency 和 MMR 去重。支持 source/tags/memory_type/status 过滤和 score_threshold 阈值。", searchMemorySchema.shape, async (args) => {
|
|
24
|
+
const results = await searchMemoryTool(args);
|
|
25
|
+
return {
|
|
26
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
server.tool("delete_memory", "按 ID 删除指定记忆条目。需同时提供 collection 名称(coding 或 workspace)。", deleteMemorySchema.shape, async (args) => {
|
|
30
|
+
const result = await deleteMemoryTool(args);
|
|
31
|
+
return {
|
|
32
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
server.tool("list_memories", "列出指定 collection 中的 active 记忆条目,支持分页和 source/memory_type 过滤。可设置 include_inactive 审计 superseded/deprecated 条目。", listMemoriesSchema.shape, async (args) => {
|
|
36
|
+
const results = await listMemoriesTool(args);
|
|
37
|
+
return {
|
|
38
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
server.tool("correct_memory", "根据用户纠正或新事实修订一条记忆:写入新 active 记忆,并把旧记忆标记为 superseded,保留 supersedes/superseded_by 修订链。", correctMemorySchema.shape, async (args) => {
|
|
42
|
+
const result = await correctMemoryTool(args);
|
|
43
|
+
return {
|
|
44
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
server.tool("capture_episode", "Opt-in 捕获任务/调试 episode 的结构化总结和可复用观察。只保存调用方提供的摘要和 observations,不自动保存 raw conversation/tool trace。", captureEpisodeSchema.shape, async (args) => {
|
|
48
|
+
const result = await captureEpisodeTool(args);
|
|
49
|
+
return {
|
|
50
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
const transport = new StdioServerTransport();
|
|
54
|
+
await server.connect(transport);
|
|
55
|
+
console.error("[knowledge-base] MCP server started (stdio)");
|
|
56
|
+
}
|
|
57
|
+
main().catch((err) => {
|
|
58
|
+
console.error("[knowledge-base] Fatal error:", err);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
});
|
package/dist/qdrant.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { QdrantClient } from "@qdrant/js-client-rest";
|
|
2
|
+
const QDRANT_URL = process.env.QDRANT_URL ?? "http://localhost:6333";
|
|
3
|
+
const VECTOR_SIZE = parseInt(process.env.EMBEDDING_DIMENSIONS ?? "1536", 10);
|
|
4
|
+
// coding: rules, architecture decisions, technical constraints, tool preferences
|
|
5
|
+
// workspace: slack summaries, confluence pages, meeting notes, team facts
|
|
6
|
+
export const COLLECTIONS = {
|
|
7
|
+
coding: process.env.CODING_COLLECTION ?? "coding",
|
|
8
|
+
workspace: process.env.WORKSPACE_COLLECTION ?? "workspace",
|
|
9
|
+
};
|
|
10
|
+
// Auto-routing: memory_type → collection
|
|
11
|
+
const MEMORY_TYPE_COLLECTION = {
|
|
12
|
+
rule: "coding",
|
|
13
|
+
decision: "coding",
|
|
14
|
+
preference: "coding",
|
|
15
|
+
fact: "workspace",
|
|
16
|
+
summary: "workspace",
|
|
17
|
+
};
|
|
18
|
+
export function collectionForType(memoryType) {
|
|
19
|
+
return MEMORY_TYPE_COLLECTION[memoryType];
|
|
20
|
+
}
|
|
21
|
+
const client = new QdrantClient({ url: QDRANT_URL });
|
|
22
|
+
export async function initCollections() {
|
|
23
|
+
const existing = await client.getCollections();
|
|
24
|
+
const existingNames = new Set(existing.collections.map((c) => c.name));
|
|
25
|
+
for (const [key, name] of Object.entries(COLLECTIONS)) {
|
|
26
|
+
if (!existingNames.has(name)) {
|
|
27
|
+
await client.createCollection(name, {
|
|
28
|
+
vectors: { size: VECTOR_SIZE, distance: "Cosine" },
|
|
29
|
+
});
|
|
30
|
+
console.error(`[qdrant] Created collection: ${name} (${key})`);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
console.error(`[qdrant] Collection exists: ${name} (${key})`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export async function upsertMemory(id, vector, payload) {
|
|
38
|
+
const col = COLLECTIONS[collectionForType(payload.memory_type)];
|
|
39
|
+
const now = new Date().toISOString();
|
|
40
|
+
await client.upsert(col, {
|
|
41
|
+
points: [
|
|
42
|
+
{
|
|
43
|
+
id,
|
|
44
|
+
vector,
|
|
45
|
+
payload: {
|
|
46
|
+
...payload,
|
|
47
|
+
status: payload.status ?? "active",
|
|
48
|
+
updated_at: payload.updated_at ?? now,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
export async function deleteMemory(id, collection) {
|
|
55
|
+
await client.delete(COLLECTIONS[collection], { points: [id] });
|
|
56
|
+
}
|
|
57
|
+
function buildFilter(sourceFilter, tagsFilter, memoryTypeFilter, includeInactive = false) {
|
|
58
|
+
const mustClauses = [];
|
|
59
|
+
const mustNotClauses = [];
|
|
60
|
+
if (sourceFilter !== undefined) {
|
|
61
|
+
mustClauses.push({ key: "source", match: { value: sourceFilter } });
|
|
62
|
+
}
|
|
63
|
+
if (tagsFilter && tagsFilter.length > 0) {
|
|
64
|
+
for (const tag of tagsFilter) {
|
|
65
|
+
mustClauses.push({ key: "tags", match: { value: tag } });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (memoryTypeFilter !== undefined) {
|
|
69
|
+
mustClauses.push({ key: "memory_type", match: { value: memoryTypeFilter } });
|
|
70
|
+
}
|
|
71
|
+
if (!includeInactive) {
|
|
72
|
+
mustNotClauses.push({ key: "status", match: { value: "superseded" } });
|
|
73
|
+
mustNotClauses.push({ key: "status", match: { value: "deprecated" } });
|
|
74
|
+
}
|
|
75
|
+
if (mustClauses.length === 0 && mustNotClauses.length === 0) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
...(mustClauses.length > 0 ? { must: mustClauses } : {}),
|
|
80
|
+
...(mustNotClauses.length > 0 ? { must_not: mustNotClauses } : {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function payloadToSearchResult(id, payload, collection, score, vectorScore, keywordScore, recencyScore) {
|
|
84
|
+
return {
|
|
85
|
+
id,
|
|
86
|
+
content: payload.content,
|
|
87
|
+
source: payload.source,
|
|
88
|
+
memory_type: payload.memory_type,
|
|
89
|
+
collection,
|
|
90
|
+
tags: payload.tags,
|
|
91
|
+
score,
|
|
92
|
+
vector_score: vectorScore,
|
|
93
|
+
keyword_score: keywordScore,
|
|
94
|
+
recency_score: recencyScore,
|
|
95
|
+
status: payload.status ?? "active",
|
|
96
|
+
created_at: payload.created_at,
|
|
97
|
+
updated_at: payload.updated_at,
|
|
98
|
+
supersedes: payload.supersedes,
|
|
99
|
+
superseded_by: payload.superseded_by,
|
|
100
|
+
correction_reason: payload.correction_reason,
|
|
101
|
+
confidence: payload.confidence,
|
|
102
|
+
episode_id: payload.episode_id,
|
|
103
|
+
related_ids: payload.related_ids,
|
|
104
|
+
valid_until: payload.valid_until,
|
|
105
|
+
last_verified_at: payload.last_verified_at,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function payloadToListResult(id, payload, collection) {
|
|
109
|
+
return {
|
|
110
|
+
id,
|
|
111
|
+
content: payload.content,
|
|
112
|
+
source: payload.source,
|
|
113
|
+
memory_type: payload.memory_type,
|
|
114
|
+
collection,
|
|
115
|
+
tags: payload.tags,
|
|
116
|
+
status: payload.status ?? "active",
|
|
117
|
+
created_at: payload.created_at,
|
|
118
|
+
updated_at: payload.updated_at,
|
|
119
|
+
supersedes: payload.supersedes,
|
|
120
|
+
superseded_by: payload.superseded_by,
|
|
121
|
+
correction_reason: payload.correction_reason,
|
|
122
|
+
confidence: payload.confidence,
|
|
123
|
+
episode_id: payload.episode_id,
|
|
124
|
+
related_ids: payload.related_ids,
|
|
125
|
+
valid_until: payload.valid_until,
|
|
126
|
+
last_verified_at: payload.last_verified_at,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function tokenize(text) {
|
|
130
|
+
return text
|
|
131
|
+
.toLowerCase()
|
|
132
|
+
.match(/[\p{L}\p{N}_./:#-]+/gu) ?? [];
|
|
133
|
+
}
|
|
134
|
+
function keywordScore(query, payload) {
|
|
135
|
+
const queryTerms = Array.from(new Set(tokenize(query)));
|
|
136
|
+
if (queryTerms.length === 0)
|
|
137
|
+
return 0;
|
|
138
|
+
const searchable = [
|
|
139
|
+
payload.content,
|
|
140
|
+
payload.source,
|
|
141
|
+
payload.memory_type,
|
|
142
|
+
...(payload.tags ?? []),
|
|
143
|
+
payload.episode_id ?? "",
|
|
144
|
+
]
|
|
145
|
+
.join(" ")
|
|
146
|
+
.toLowerCase();
|
|
147
|
+
const searchableTerms = tokenize(searchable);
|
|
148
|
+
const termCount = new Map();
|
|
149
|
+
for (const term of searchableTerms) {
|
|
150
|
+
termCount.set(term, (termCount.get(term) ?? 0) + 1);
|
|
151
|
+
}
|
|
152
|
+
let score = 0;
|
|
153
|
+
for (const term of queryTerms) {
|
|
154
|
+
const exactCount = termCount.get(term) ?? 0;
|
|
155
|
+
if (exactCount > 0) {
|
|
156
|
+
score += 1 + Math.log1p(exactCount);
|
|
157
|
+
}
|
|
158
|
+
else if (searchable.includes(term)) {
|
|
159
|
+
score += 0.35;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (searchable.includes(query.toLowerCase())) {
|
|
163
|
+
score += 1;
|
|
164
|
+
}
|
|
165
|
+
return Math.min(1, score / (queryTerms.length * 1.6));
|
|
166
|
+
}
|
|
167
|
+
function recencyScore(payload) {
|
|
168
|
+
if (payload.memory_type === "rule" || payload.memory_type === "decision") {
|
|
169
|
+
return 1;
|
|
170
|
+
}
|
|
171
|
+
const timestamp = Date.parse(payload.last_verified_at ?? payload.updated_at ?? payload.created_at);
|
|
172
|
+
if (Number.isNaN(timestamp))
|
|
173
|
+
return 0.5;
|
|
174
|
+
const ageDays = Math.max(0, (Date.now() - timestamp) / 86_400_000);
|
|
175
|
+
const halfLifeDays = payload.memory_type === "preference" ? 180 : 90;
|
|
176
|
+
return Math.pow(0.5, ageDays / halfLifeDays);
|
|
177
|
+
}
|
|
178
|
+
function combineScore(vectorScore, keywordScoreValue, recencyScoreValue, mode, useRecency) {
|
|
179
|
+
const recencyWeight = useRecency ? 0.1 : 0;
|
|
180
|
+
if (mode === "keyword") {
|
|
181
|
+
return keywordScoreValue * (1 - recencyWeight) + recencyScoreValue * recencyWeight;
|
|
182
|
+
}
|
|
183
|
+
if (mode === "vector") {
|
|
184
|
+
return vectorScore * (1 - recencyWeight) + recencyScoreValue * recencyWeight;
|
|
185
|
+
}
|
|
186
|
+
return vectorScore * 0.65 + keywordScoreValue * 0.25 + recencyScoreValue * recencyWeight;
|
|
187
|
+
}
|
|
188
|
+
function textSimilarity(a, b) {
|
|
189
|
+
const left = new Set(tokenize(`${a.content} ${(a.tags ?? []).join(" ")}`));
|
|
190
|
+
const right = new Set(tokenize(`${b.content} ${(b.tags ?? []).join(" ")}`));
|
|
191
|
+
if (left.size === 0 || right.size === 0)
|
|
192
|
+
return 0;
|
|
193
|
+
let intersection = 0;
|
|
194
|
+
for (const term of left) {
|
|
195
|
+
if (right.has(term))
|
|
196
|
+
intersection += 1;
|
|
197
|
+
}
|
|
198
|
+
return intersection / (left.size + right.size - intersection);
|
|
199
|
+
}
|
|
200
|
+
function mmrRank(results, limit) {
|
|
201
|
+
const remaining = [...results].sort((a, b) => b.score - a.score);
|
|
202
|
+
const selected = [];
|
|
203
|
+
while (selected.length < limit && remaining.length > 0) {
|
|
204
|
+
let bestIndex = 0;
|
|
205
|
+
let bestScore = Number.NEGATIVE_INFINITY;
|
|
206
|
+
for (let index = 0; index < remaining.length; index += 1) {
|
|
207
|
+
const candidate = remaining[index];
|
|
208
|
+
const maxSimilarity = selected.length === 0
|
|
209
|
+
? 0
|
|
210
|
+
: Math.max(...selected.map((item) => textSimilarity(candidate, item)));
|
|
211
|
+
const mmrScore = candidate.score * 0.8 - maxSimilarity * 0.2;
|
|
212
|
+
if (mmrScore > bestScore) {
|
|
213
|
+
bestScore = mmrScore;
|
|
214
|
+
bestIndex = index;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
selected.push(remaining.splice(bestIndex, 1)[0]);
|
|
218
|
+
}
|
|
219
|
+
return selected;
|
|
220
|
+
}
|
|
221
|
+
async function scrollKeywordCandidates(collection, filter, limit) {
|
|
222
|
+
const result = await client.scroll(COLLECTIONS[collection], {
|
|
223
|
+
limit,
|
|
224
|
+
filter,
|
|
225
|
+
with_payload: true,
|
|
226
|
+
with_vector: false,
|
|
227
|
+
});
|
|
228
|
+
return result.points.map((p) => {
|
|
229
|
+
const payload = p.payload;
|
|
230
|
+
return payloadToListResult(String(p.id), payload, collection);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
export async function searchMemory(options) {
|
|
234
|
+
const { vector, query, collections, limit = 5, sourceFilter, scoreThreshold, tagsFilter, memoryTypeFilter, includeInactive = false, mode = "hybrid", useRecency = true, useMmr = true, } = options;
|
|
235
|
+
const filter = buildFilter(sourceFilter, tagsFilter, memoryTypeFilter, includeInactive);
|
|
236
|
+
const perCollection = Math.ceil(limit / collections.length);
|
|
237
|
+
const candidates = new Map();
|
|
238
|
+
if (mode !== "keyword") {
|
|
239
|
+
if (!vector) {
|
|
240
|
+
throw new Error("vector is required for vector and hybrid search modes");
|
|
241
|
+
}
|
|
242
|
+
const vectorResults = (await Promise.all(collections.map(async (col) => {
|
|
243
|
+
const results = await client.search(COLLECTIONS[col], {
|
|
244
|
+
vector,
|
|
245
|
+
limit: mode === "hybrid" ? Math.max(perCollection * 3, 10) : perCollection,
|
|
246
|
+
filter,
|
|
247
|
+
score_threshold: mode === "vector" ? scoreThreshold : undefined,
|
|
248
|
+
with_payload: true,
|
|
249
|
+
});
|
|
250
|
+
return results.map((r) => {
|
|
251
|
+
const payload = r.payload;
|
|
252
|
+
const keywordScoreValue = keywordScore(query, payload);
|
|
253
|
+
const recencyScoreValue = recencyScore(payload);
|
|
254
|
+
const finalScore = combineScore(r.score, keywordScoreValue, recencyScoreValue, mode, useRecency);
|
|
255
|
+
return payloadToSearchResult(String(r.id), payload, col, finalScore, r.score, keywordScoreValue, recencyScoreValue);
|
|
256
|
+
});
|
|
257
|
+
}))).flat();
|
|
258
|
+
for (const result of vectorResults) {
|
|
259
|
+
candidates.set(`${result.collection}:${result.id}`, result);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (mode !== "vector") {
|
|
263
|
+
const keywordCandidateLimit = parseInt(process.env.KEYWORD_CANDIDATE_LIMIT ?? "200", 10);
|
|
264
|
+
const keywordResults = (await Promise.all(collections.map(async (col) => {
|
|
265
|
+
const results = await scrollKeywordCandidates(col, filter, keywordCandidateLimit);
|
|
266
|
+
return results
|
|
267
|
+
.map((result) => {
|
|
268
|
+
const payload = {
|
|
269
|
+
content: result.content,
|
|
270
|
+
source: result.source,
|
|
271
|
+
memory_type: result.memory_type,
|
|
272
|
+
tags: result.tags,
|
|
273
|
+
created_at: result.created_at,
|
|
274
|
+
updated_at: result.updated_at,
|
|
275
|
+
status: result.status,
|
|
276
|
+
supersedes: result.supersedes,
|
|
277
|
+
superseded_by: result.superseded_by,
|
|
278
|
+
correction_reason: result.correction_reason,
|
|
279
|
+
confidence: result.confidence,
|
|
280
|
+
episode_id: result.episode_id,
|
|
281
|
+
related_ids: result.related_ids,
|
|
282
|
+
valid_until: result.valid_until,
|
|
283
|
+
last_verified_at: result.last_verified_at,
|
|
284
|
+
};
|
|
285
|
+
const keywordScoreValue = keywordScore(query, payload);
|
|
286
|
+
const recencyScoreValue = recencyScore(payload);
|
|
287
|
+
const existing = candidates.get(`${result.collection}:${result.id}`);
|
|
288
|
+
const vectorScoreValue = existing?.vector_score ?? 0;
|
|
289
|
+
const finalScore = combineScore(vectorScoreValue, keywordScoreValue, recencyScoreValue, mode, useRecency);
|
|
290
|
+
return payloadToSearchResult(result.id, payload, result.collection, finalScore, vectorScoreValue, keywordScoreValue, recencyScoreValue);
|
|
291
|
+
})
|
|
292
|
+
.filter((result) => result.keyword_score !== undefined && result.keyword_score > 0);
|
|
293
|
+
}))).flat();
|
|
294
|
+
for (const result of keywordResults) {
|
|
295
|
+
const key = `${result.collection}:${result.id}`;
|
|
296
|
+
const existing = candidates.get(key);
|
|
297
|
+
if (!existing || result.score > existing.score) {
|
|
298
|
+
candidates.set(key, result);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const allResults = Array.from(candidates.values())
|
|
303
|
+
.filter((result) => scoreThreshold === undefined || result.score >= scoreThreshold)
|
|
304
|
+
.sort((a, b) => b.score - a.score);
|
|
305
|
+
return useMmr ? mmrRank(allResults, limit) : allResults.slice(0, limit);
|
|
306
|
+
}
|
|
307
|
+
export async function listMemories(collection, limit = 20, offset = 0, sourceFilter, memoryTypeFilter, includeInactive = false) {
|
|
308
|
+
const filter = buildFilter(sourceFilter, undefined, memoryTypeFilter, includeInactive);
|
|
309
|
+
const result = await client.scroll(COLLECTIONS[collection], {
|
|
310
|
+
limit,
|
|
311
|
+
offset,
|
|
312
|
+
filter,
|
|
313
|
+
with_payload: true,
|
|
314
|
+
with_vector: false,
|
|
315
|
+
});
|
|
316
|
+
return result.points.map((p) => {
|
|
317
|
+
const payload = p.payload;
|
|
318
|
+
return payloadToListResult(String(p.id), payload, collection);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
function extractVector(vector) {
|
|
322
|
+
return Array.isArray(vector) && vector.every((value) => typeof value === "number")
|
|
323
|
+
? vector
|
|
324
|
+
: undefined;
|
|
325
|
+
}
|
|
326
|
+
export async function getMemory(id, collection, withVector = false) {
|
|
327
|
+
const records = await client.retrieve(COLLECTIONS[collection], {
|
|
328
|
+
ids: [id],
|
|
329
|
+
with_payload: true,
|
|
330
|
+
with_vector: withVector,
|
|
331
|
+
});
|
|
332
|
+
const record = records[0];
|
|
333
|
+
if (!record?.payload)
|
|
334
|
+
return null;
|
|
335
|
+
const payload = record.payload;
|
|
336
|
+
return {
|
|
337
|
+
...payloadToListResult(String(record.id), payload, collection),
|
|
338
|
+
vector: extractVector(record.vector),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
export async function updateMemoryPayload(id, collection, payload) {
|
|
342
|
+
await client.setPayload(COLLECTIONS[collection], {
|
|
343
|
+
points: [id],
|
|
344
|
+
payload: {
|
|
345
|
+
...payload,
|
|
346
|
+
updated_at: new Date().toISOString(),
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { storeMemory, storeMemorySchema } from "./tools/store.js";
|
|
4
|
+
import { initCollections } from "./qdrant.js";
|
|
5
|
+
const app = express();
|
|
6
|
+
app.use(express.json());
|
|
7
|
+
const INGEST_API_KEY = process.env.INGEST_API_KEY;
|
|
8
|
+
const PORT = parseInt(process.env.PORT ?? "3000", 10);
|
|
9
|
+
function authMiddleware(req, res, next) {
|
|
10
|
+
if (!INGEST_API_KEY) {
|
|
11
|
+
res.status(500).json({ error: "INGEST_API_KEY not configured" });
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const auth = req.headers.authorization;
|
|
15
|
+
if (!auth || auth !== `Bearer ${INGEST_API_KEY}`) {
|
|
16
|
+
res.status(401).json({ error: "Unauthorized" });
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
next();
|
|
20
|
+
}
|
|
21
|
+
const ingestBodySchema = z.object({
|
|
22
|
+
memories: z
|
|
23
|
+
.array(storeMemorySchema)
|
|
24
|
+
.min(1),
|
|
25
|
+
});
|
|
26
|
+
app.post("/ingest", authMiddleware, async (req, res) => {
|
|
27
|
+
const parsed = ingestBodySchema.safeParse(req.body);
|
|
28
|
+
if (!parsed.success) {
|
|
29
|
+
res.status(400).json({ error: parsed.error.flatten() });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const { memories } = parsed.data;
|
|
33
|
+
const succeeded = [];
|
|
34
|
+
const failed = [];
|
|
35
|
+
await Promise.allSettled(memories.map(async (memory, index) => {
|
|
36
|
+
try {
|
|
37
|
+
const result = await storeMemory(memory);
|
|
38
|
+
succeeded.push({ index, id: result.id, collection: result.collection });
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
failed.push({
|
|
42
|
+
index,
|
|
43
|
+
error: err instanceof Error ? err.message : String(err),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}));
|
|
47
|
+
const statusCode = failed.length === 0 ? 200 : succeeded.length === 0 ? 500 : 207;
|
|
48
|
+
res.status(statusCode).json({
|
|
49
|
+
stored: succeeded.length,
|
|
50
|
+
failed: failed.length,
|
|
51
|
+
succeeded,
|
|
52
|
+
...(failed.length > 0 ? { errors: failed } : {}),
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
app.get("/health", (_req, res) => {
|
|
56
|
+
res.json({ status: "ok" });
|
|
57
|
+
});
|
|
58
|
+
async function startServer() {
|
|
59
|
+
await initCollections();
|
|
60
|
+
app.listen(PORT, () => {
|
|
61
|
+
console.log(`[knowledge-base] HTTP ingest server listening on port ${PORT}`);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
startServer().catch((err) => {
|
|
65
|
+
console.error("[knowledge-base] Server startup error:", err);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getMemory, updateMemoryPayload } from "../qdrant.js";
|
|
3
|
+
import { MEMORY_TYPES, storeMemory } from "./store.js";
|
|
4
|
+
export const correctMemorySchema = z.object({
|
|
5
|
+
id: z.string().min(1).describe("要修订的旧记忆 ID"),
|
|
6
|
+
collection: z
|
|
7
|
+
.enum(["coding", "workspace"])
|
|
8
|
+
.describe("旧记忆所在 collection:coding 或 workspace"),
|
|
9
|
+
corrected_content: z.string().min(1).describe("修订后的新记忆内容"),
|
|
10
|
+
correction_reason: z.string().min(1).describe("为什么需要修订旧记忆"),
|
|
11
|
+
source: z.string().optional().describe("新记忆来源,默认沿用旧记忆 source"),
|
|
12
|
+
memory_type: z
|
|
13
|
+
.enum(MEMORY_TYPES)
|
|
14
|
+
.optional()
|
|
15
|
+
.describe("新记忆类型,默认沿用旧记忆 memory_type"),
|
|
16
|
+
tags: z.array(z.string()).optional().describe("新记忆 tags,默认沿用旧记忆 tags"),
|
|
17
|
+
confidence: z
|
|
18
|
+
.number()
|
|
19
|
+
.min(0)
|
|
20
|
+
.max(1)
|
|
21
|
+
.optional()
|
|
22
|
+
.default(0.9)
|
|
23
|
+
.describe("修订后记忆可信度,默认 0.9"),
|
|
24
|
+
});
|
|
25
|
+
export async function correctMemoryTool(input) {
|
|
26
|
+
const oldMemory = await getMemory(input.id, input.collection);
|
|
27
|
+
if (!oldMemory) {
|
|
28
|
+
throw new Error(`Memory not found: ${input.collection}/${input.id}`);
|
|
29
|
+
}
|
|
30
|
+
if (oldMemory.content.trim() === input.corrected_content.trim()) {
|
|
31
|
+
throw new Error("corrected_content must differ from the existing memory content");
|
|
32
|
+
}
|
|
33
|
+
const result = await storeMemory({
|
|
34
|
+
content: input.corrected_content,
|
|
35
|
+
source: input.source ?? oldMemory.source,
|
|
36
|
+
memory_type: input.memory_type ?? oldMemory.memory_type,
|
|
37
|
+
tags: input.tags ?? oldMemory.tags,
|
|
38
|
+
status: "active",
|
|
39
|
+
confidence: input.confidence,
|
|
40
|
+
supersedes: input.id,
|
|
41
|
+
related_ids: [input.id, ...(oldMemory.related_ids ?? [])],
|
|
42
|
+
correction_reason: input.correction_reason,
|
|
43
|
+
last_verified_at: new Date().toISOString(),
|
|
44
|
+
});
|
|
45
|
+
await updateMemoryPayload(input.id, input.collection, {
|
|
46
|
+
status: "superseded",
|
|
47
|
+
superseded_by: result.id,
|
|
48
|
+
correction_reason: input.correction_reason,
|
|
49
|
+
});
|
|
50
|
+
return {
|
|
51
|
+
corrected: true,
|
|
52
|
+
old_id: input.id,
|
|
53
|
+
old_collection: input.collection,
|
|
54
|
+
new_id: result.id,
|
|
55
|
+
new_collection: result.collection,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { deleteMemory } from "../qdrant.js";
|
|
3
|
+
export const deleteMemorySchema = z.object({
|
|
4
|
+
id: z.string().min(1).describe("要删除的记忆 ID(由 store_memory 返回)"),
|
|
5
|
+
collection: z
|
|
6
|
+
.enum(["coding", "workspace"])
|
|
7
|
+
.describe("所在 collection:coding=技术记忆, workspace=团队记忆"),
|
|
8
|
+
});
|
|
9
|
+
export async function deleteMemoryTool(input) {
|
|
10
|
+
await deleteMemory(input.id, input.collection);
|
|
11
|
+
return { deleted: true, id: input.id, collection: input.collection };
|
|
12
|
+
}
|