@vheins/local-memory-mcp 0.1.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/DASHBOARD.md +129 -0
- package/HYBRID_SEARCH.md +204 -0
- package/IMPLEMENTATION.md +159 -0
- package/README.md +175 -0
- package/dist/capabilities.d.ts +22 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +23 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/dashboard/dashboard.test.d.ts +2 -0
- package/dist/dashboard/dashboard.test.d.ts.map +1 -0
- package/dist/dashboard/dashboard.test.js +362 -0
- package/dist/dashboard/dashboard.test.js.map +1 -0
- package/dist/dashboard/public/app.js +1187 -0
- package/dist/dashboard/public/chart.js +0 -0
- package/dist/dashboard/public/index.html +967 -0
- package/dist/dashboard/server.d.ts +3 -0
- package/dist/dashboard/server.d.ts.map +1 -0
- package/dist/dashboard/server.js +297 -0
- package/dist/dashboard/server.js.map +1 -0
- package/dist/mcp/client.d.ts +34 -0
- package/dist/mcp/client.d.ts.map +1 -0
- package/dist/mcp/client.js +181 -0
- package/dist/mcp/client.js.map +1 -0
- package/dist/mcp/client.test.d.ts +2 -0
- package/dist/mcp/client.test.d.ts.map +1 -0
- package/dist/mcp/client.test.js +130 -0
- package/dist/mcp/client.test.js.map +1 -0
- package/dist/prompts/registry.d.ts +39 -0
- package/dist/prompts/registry.d.ts.map +1 -0
- package/dist/prompts/registry.js +90 -0
- package/dist/prompts/registry.js.map +1 -0
- package/dist/resources/index.d.ts +17 -0
- package/dist/resources/index.d.ts.map +1 -0
- package/dist/resources/index.js +100 -0
- package/dist/resources/index.js.map +1 -0
- package/dist/resources/index.test.d.ts +2 -0
- package/dist/resources/index.test.d.ts.map +1 -0
- package/dist/resources/index.test.js +96 -0
- package/dist/resources/index.test.js.map +1 -0
- package/dist/router.d.ts +4 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +60 -0
- package/dist/router.js.map +1 -0
- package/dist/router.test.d.ts +2 -0
- package/dist/router.test.d.ts.map +1 -0
- package/dist/router.test.js +113 -0
- package/dist/router.test.js.map +1 -0
- package/dist/search_memory_example.d.ts +3 -0
- package/dist/search_memory_example.d.ts.map +1 -0
- package/dist/search_memory_example.js +56 -0
- package/dist/search_memory_example.js.map +1 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +91 -0
- package/dist/server.js.map +1 -0
- package/dist/storage/sqlite.d.ts +95 -0
- package/dist/storage/sqlite.d.ts.map +1 -0
- package/dist/storage/sqlite.js +537 -0
- package/dist/storage/sqlite.js.map +1 -0
- package/dist/storage/sqlite.test.d.ts +2 -0
- package/dist/storage/sqlite.test.d.ts.map +1 -0
- package/dist/storage/sqlite.test.js +358 -0
- package/dist/storage/sqlite.test.js.map +1 -0
- package/dist/storage/vectors.stub.d.ts +12 -0
- package/dist/storage/vectors.stub.d.ts.map +1 -0
- package/dist/storage/vectors.stub.js +88 -0
- package/dist/storage/vectors.stub.js.map +1 -0
- package/dist/store_memory_example.d.ts +3 -0
- package/dist/store_memory_example.d.ts.map +1 -0
- package/dist/store_memory_example.js +69 -0
- package/dist/store_memory_example.js.map +1 -0
- package/dist/test_quotes_client.d.ts +3 -0
- package/dist/test_quotes_client.d.ts.map +1 -0
- package/dist/test_quotes_client.js +72 -0
- package/dist/test_quotes_client.js.map +1 -0
- package/dist/tools/memory.delete.d.ts +9 -0
- package/dist/tools/memory.delete.d.ts.map +1 -0
- package/dist/tools/memory.delete.js +22 -0
- package/dist/tools/memory.delete.js.map +1 -0
- package/dist/tools/memory.recap.d.ts +4 -0
- package/dist/tools/memory.recap.d.ts.map +1 -0
- package/dist/tools/memory.recap.js +42 -0
- package/dist/tools/memory.recap.js.map +1 -0
- package/dist/tools/memory.search.d.ts +5 -0
- package/dist/tools/memory.search.d.ts.map +1 -0
- package/dist/tools/memory.search.js +192 -0
- package/dist/tools/memory.search.js.map +1 -0
- package/dist/tools/memory.search.test.d.ts +2 -0
- package/dist/tools/memory.search.test.d.ts.map +1 -0
- package/dist/tools/memory.search.test.js +181 -0
- package/dist/tools/memory.search.test.js.map +1 -0
- package/dist/tools/memory.store.d.ts +5 -0
- package/dist/tools/memory.store.d.ts.map +1 -0
- package/dist/tools/memory.store.js +41 -0
- package/dist/tools/memory.store.js.map +1 -0
- package/dist/tools/memory.summarize.d.ts +4 -0
- package/dist/tools/memory.summarize.d.ts.map +1 -0
- package/dist/tools/memory.summarize.js +13 -0
- package/dist/tools/memory.summarize.js.map +1 -0
- package/dist/tools/memory.update.d.ts +5 -0
- package/dist/tools/memory.update.d.ts.map +1 -0
- package/dist/tools/memory.update.js +31 -0
- package/dist/tools/memory.update.js.map +1 -0
- package/dist/tools/schemas.d.ts +334 -0
- package/dist/tools/schemas.d.ts.map +1 -0
- package/dist/tools/schemas.js +251 -0
- package/dist/tools/schemas.js.map +1 -0
- package/dist/types.d.ts +31 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/git-scope.d.ts +8 -0
- package/dist/utils/git-scope.d.ts.map +1 -0
- package/dist/utils/git-scope.js +38 -0
- package/dist/utils/git-scope.js.map +1 -0
- package/dist/utils/logger.d.ts +7 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +40 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/logger.test.d.ts +2 -0
- package/dist/utils/logger.test.d.ts.map +1 -0
- package/dist/utils/logger.test.js +84 -0
- package/dist/utils/logger.test.js.map +1 -0
- package/dist/utils/mcp-response.d.ts +44 -0
- package/dist/utils/mcp-response.d.ts.map +1 -0
- package/dist/utils/mcp-response.js +81 -0
- package/dist/utils/mcp-response.js.map +1 -0
- package/dist/utils/normalize.d.ts +4 -0
- package/dist/utils/normalize.d.ts.map +1 -0
- package/dist/utils/normalize.js +51 -0
- package/dist/utils/normalize.js.map +1 -0
- package/dist/utils/normalize.test.d.ts +2 -0
- package/dist/utils/normalize.test.d.ts.map +1 -0
- package/dist/utils/normalize.test.js +159 -0
- package/dist/utils/normalize.test.js.map +1 -0
- package/dist/utils/query-expander.d.ts +2 -0
- package/dist/utils/query-expander.d.ts.map +1 -0
- package/dist/utils/query-expander.js +50 -0
- package/dist/utils/query-expander.js.map +1 -0
- package/dist/utils/query-expander.test.d.ts +2 -0
- package/dist/utils/query-expander.test.d.ts.map +1 -0
- package/dist/utils/query-expander.test.js +35 -0
- package/dist/utils/query-expander.test.js.map +1 -0
- package/docs/PRD.md +199 -0
- package/docs/PROMPT-agent.md +139 -0
- package/docs/SPEC-git-scope.md +172 -0
- package/docs/SPEC-heuristics.md +199 -0
- package/docs/SPEC-server.md +243 -0
- package/docs/SPEC-skeleton.md +255 -0
- package/docs/SPEC-sqlite-schema.md +183 -0
- package/docs/SPEC-tool-schema.md +201 -0
- package/docs/SPEC-vector-search.md +198 -0
- package/docs/TEST-scenarios.md +179 -0
- package/package.json +43 -0
- package/scripts/update-null-titles-ai.mjs +272 -0
- package/scripts/update-titles-batch.mjs +71 -0
- package/scripts/update-titles.mjs +66 -0
- package/seed-data.mjs +151 -0
- package/src/capabilities.ts +22 -0
- package/src/dashboard/dashboard.test.ts +546 -0
- package/src/dashboard/public/app.js +1187 -0
- package/src/dashboard/public/chart.js +0 -0
- package/src/dashboard/public/index.html +967 -0
- package/src/dashboard/server.ts +347 -0
- package/src/mcp/client.test.ts +164 -0
- package/src/mcp/client.ts +212 -0
- package/src/prompts/registry.ts +89 -0
- package/src/resources/index.test.ts +132 -0
- package/src/resources/index.ts +113 -0
- package/src/router.test.ts +145 -0
- package/src/router.ts +80 -0
- package/src/server.ts +99 -0
- package/src/storage/sqlite.test.ts +504 -0
- package/src/storage/sqlite.ts +688 -0
- package/src/storage/vectors.stub.ts +101 -0
- package/src/tools/memory.delete.ts +37 -0
- package/src/tools/memory.recap.ts +61 -0
- package/src/tools/memory.search.test.ts +276 -0
- package/src/tools/memory.search.ts +244 -0
- package/src/tools/memory.store.ts +56 -0
- package/src/tools/memory.summarize.ts +23 -0
- package/src/tools/memory.update.ts +46 -0
- package/src/tools/schemas.ts +261 -0
- package/src/types.ts +36 -0
- package/src/utils/git-scope.ts +42 -0
- package/src/utils/logger.test.ts +125 -0
- package/src/utils/logger.ts +53 -0
- package/src/utils/mcp-response.ts +116 -0
- package/src/utils/normalize.test.ts +203 -0
- package/src/utils/normalize.ts +53 -0
- package/src/utils/query-expander.test.ts +40 -0
- package/src/utils/query-expander.ts +60 -0
- package/storage/.gitkeep +5 -0
- package/test.sh +48 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import express from "express";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
import { MCPClient } from "../mcp/client.js";
|
|
7
|
+
import { SQLiteStore } from "../storage/sqlite.js";
|
|
8
|
+
import { logger } from "../utils/logger.js";
|
|
9
|
+
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const app = express();
|
|
12
|
+
const PORT = process.env.PORT || 3456;
|
|
13
|
+
const startTime = Date.now();
|
|
14
|
+
|
|
15
|
+
const db = new SQLiteStore();
|
|
16
|
+
|
|
17
|
+
type RecentAction = {
|
|
18
|
+
action: string;
|
|
19
|
+
query?: string;
|
|
20
|
+
memory_id?: string;
|
|
21
|
+
result_count?: number;
|
|
22
|
+
created_at: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type CondensedRecentAction = RecentAction & {
|
|
26
|
+
burstCount: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
app.use(express.json());
|
|
30
|
+
|
|
31
|
+
// Timing middleware - must be before routes
|
|
32
|
+
app.use((req, res, next) => {
|
|
33
|
+
const start = Date.now();
|
|
34
|
+
res.on("finish", () => {
|
|
35
|
+
const duration = Date.now() - start;
|
|
36
|
+
if (duration > 1000) {
|
|
37
|
+
logger.warn("Slow request", { method: req.method, path: req.path, duration });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
next();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Serve static assets. Prefer built `public` next to this file (dist/dashboard/public),
|
|
44
|
+
// but fall back to the source `src/dashboard/public` when running from the repo.
|
|
45
|
+
const builtPublic = path.join(__dirname, "public");
|
|
46
|
+
const srcPublic = path.join(process.cwd(), "src", "dashboard", "public");
|
|
47
|
+
const staticRoot = fs.existsSync(builtPublic) ? builtPublic : srcPublic;
|
|
48
|
+
|
|
49
|
+
if (!fs.existsSync(staticRoot)) {
|
|
50
|
+
logger.warn("Dashboard static directory not found", { builtPublic, srcPublic });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
app.use(express.static(staticRoot));
|
|
54
|
+
|
|
55
|
+
const mcpClient = new MCPClient();
|
|
56
|
+
|
|
57
|
+
// Start MCP client
|
|
58
|
+
mcpClient.start().catch((err) => {
|
|
59
|
+
logger.error("Failed to start MCP client", { error: err.message });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Health check endpoint
|
|
63
|
+
app.get("/api/health", (req, res) => {
|
|
64
|
+
const stats = db.getStats();
|
|
65
|
+
res.json({
|
|
66
|
+
connected: mcpClient.isConnected(),
|
|
67
|
+
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
68
|
+
memoryCount: stats.total,
|
|
69
|
+
pendingRequests: mcpClient.getPendingCount()
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// List all repositories
|
|
74
|
+
app.get("/api/repos", async (req, res) => {
|
|
75
|
+
try {
|
|
76
|
+
const repos = db.listRepoNavigation();
|
|
77
|
+
res.json({ repos });
|
|
78
|
+
} catch (err: unknown) {
|
|
79
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
80
|
+
logger.error("Error getting repos", { error: message });
|
|
81
|
+
res.status(500).json({ error: message, repos: [] });
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Get recent actions
|
|
86
|
+
app.get("/api/recent-actions", async (req, res) => {
|
|
87
|
+
try {
|
|
88
|
+
const repo = req.query.repo as string | undefined;
|
|
89
|
+
const limit = parseInt(req.query.limit as string) || 20;
|
|
90
|
+
const rawActions = db.getRecentActions(repo, Math.max(limit * 4, 50));
|
|
91
|
+
const actions = condenseRecentActions(rawActions, limit);
|
|
92
|
+
res.json({ actions });
|
|
93
|
+
} catch (err: unknown) {
|
|
94
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
95
|
+
logger.error("Error getting recent actions", { error: message });
|
|
96
|
+
res.status(500).json({ error: message, actions: [] });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Get statistics
|
|
101
|
+
app.get("/api/stats", async (req, res) => {
|
|
102
|
+
try {
|
|
103
|
+
const repo = req.query.repo as string | undefined;
|
|
104
|
+
|
|
105
|
+
const stats = db.getStats(repo);
|
|
106
|
+
|
|
107
|
+
const allMemories = db.getAllMemoriesWithStats(repo);
|
|
108
|
+
const topMemories = allMemories
|
|
109
|
+
.sort((a, b) => {
|
|
110
|
+
if (a.importance !== b.importance) {
|
|
111
|
+
return b.importance - a.importance;
|
|
112
|
+
}
|
|
113
|
+
return b.hit_count - a.hit_count;
|
|
114
|
+
})
|
|
115
|
+
.slice(0, 10);
|
|
116
|
+
|
|
117
|
+
const totalHitCount = allMemories.reduce((sum, m) => sum + (m.hit_count || 0), 0);
|
|
118
|
+
|
|
119
|
+
const avgImportance = allMemories.length > 0
|
|
120
|
+
? allMemories.reduce((sum, m) => sum + m.importance, 0) / allMemories.length
|
|
121
|
+
: 0;
|
|
122
|
+
|
|
123
|
+
const now = new Date();
|
|
124
|
+
const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
|
125
|
+
const expiringSoon = allMemories.filter(m =>
|
|
126
|
+
m.expires_at && new Date(m.expires_at) <= sevenDaysFromNow
|
|
127
|
+
).length;
|
|
128
|
+
|
|
129
|
+
let mostActiveRepo: string | null = null;
|
|
130
|
+
if (!repo) {
|
|
131
|
+
const repoCounts: Record<string, number> = {};
|
|
132
|
+
for (const memory of allMemories) {
|
|
133
|
+
const r = memory.scope.repo;
|
|
134
|
+
repoCounts[r] = (repoCounts[r] || 0) + (memory.hit_count || 0);
|
|
135
|
+
}
|
|
136
|
+
let maxHits = 0;
|
|
137
|
+
for (const [r, count] of Object.entries(repoCounts)) {
|
|
138
|
+
if (count > maxHits) {
|
|
139
|
+
maxHits = count;
|
|
140
|
+
mostActiveRepo = r;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Importance distribution (histogram 1-5)
|
|
146
|
+
const importanceDist: number[] = [0, 0, 0, 0, 0];
|
|
147
|
+
for (const m of allMemories) {
|
|
148
|
+
if (m.importance >= 1 && m.importance <= 5) {
|
|
149
|
+
importanceDist[m.importance - 1]++;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Time series - memories per day for last 30 days
|
|
154
|
+
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
155
|
+
const timeSeries: Record<string, number> = {};
|
|
156
|
+
for (let i = 0; i < 30; i++) {
|
|
157
|
+
const date = new Date(thirtyDaysAgo.getTime() + i * 24 * 60 * 60 * 1000);
|
|
158
|
+
const key = date.toISOString().split('T')[0];
|
|
159
|
+
timeSeries[key] = 0;
|
|
160
|
+
}
|
|
161
|
+
for (const m of allMemories) {
|
|
162
|
+
const created = new Date(m.created_at);
|
|
163
|
+
if (created >= thirtyDaysAgo) {
|
|
164
|
+
const key = created.toISOString().split('T')[0];
|
|
165
|
+
if (timeSeries[key] !== undefined) {
|
|
166
|
+
timeSeries[key]++;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Scatter data: importance vs hit_count
|
|
172
|
+
const scatterData = allMemories.map(m => ({
|
|
173
|
+
x: m.importance,
|
|
174
|
+
y: m.hit_count || 0
|
|
175
|
+
}));
|
|
176
|
+
|
|
177
|
+
res.json({
|
|
178
|
+
...stats,
|
|
179
|
+
topMemories,
|
|
180
|
+
totalHitCount: Math.round(totalHitCount),
|
|
181
|
+
avgImportance: Math.round(avgImportance * 10) / 10,
|
|
182
|
+
mostActiveRepo,
|
|
183
|
+
expiringSoon,
|
|
184
|
+
importanceDist,
|
|
185
|
+
timeSeries,
|
|
186
|
+
scatterData
|
|
187
|
+
});
|
|
188
|
+
} catch (err: unknown) {
|
|
189
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
190
|
+
logger.error("Error getting stats", { error: message });
|
|
191
|
+
res.status(500).json({ error: message });
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// List memories with optional filtering
|
|
196
|
+
app.get("/api/memories", async (req, res) => {
|
|
197
|
+
try {
|
|
198
|
+
const repo = req.query.repo as string;
|
|
199
|
+
const type = req.query.type as string | undefined;
|
|
200
|
+
const search = req.query.search as string | undefined;
|
|
201
|
+
const minImportance = req.query.minImportance ? parseInt(req.query.minImportance as string, 10) : undefined;
|
|
202
|
+
const maxImportance = req.query.maxImportance ? parseInt(req.query.maxImportance as string, 10) : undefined;
|
|
203
|
+
const sortBy = req.query.sortBy as string || "hit_count";
|
|
204
|
+
const sortOrder = req.query.sortOrder as string || "desc";
|
|
205
|
+
const page = Math.max(1, parseInt(req.query.page as string || "1", 10));
|
|
206
|
+
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string || "25", 10)));
|
|
207
|
+
|
|
208
|
+
if (!repo) {
|
|
209
|
+
return res.status(400).json({ error: "repo parameter is required" });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const offset = (page - 1) * pageSize;
|
|
213
|
+
const result = db.listMemoriesForDashboard({
|
|
214
|
+
repo,
|
|
215
|
+
type,
|
|
216
|
+
search,
|
|
217
|
+
minImportance,
|
|
218
|
+
maxImportance,
|
|
219
|
+
sortBy,
|
|
220
|
+
sortOrder: sortOrder === "asc" ? "asc" : "desc",
|
|
221
|
+
limit: pageSize,
|
|
222
|
+
offset,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
res.json({
|
|
226
|
+
memories: result.items,
|
|
227
|
+
pagination: {
|
|
228
|
+
page,
|
|
229
|
+
pageSize,
|
|
230
|
+
totalItems: result.total,
|
|
231
|
+
totalPages: Math.max(1, Math.ceil(result.total / pageSize)),
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
} catch (err: any) {
|
|
235
|
+
logger.error("Error listing memories", { error: err.message });
|
|
236
|
+
res.status(500).json({ error: err.message });
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Get memory by ID
|
|
241
|
+
app.get("/api/memories/:id", async (req, res) => {
|
|
242
|
+
try {
|
|
243
|
+
const memory = db.getByIdWithStats(req.params.id);
|
|
244
|
+
if (!memory) {
|
|
245
|
+
throw new Error("Memory not found");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
db.logAction("read", memory.scope.repo, { memoryId: memory.id, resultCount: 1 });
|
|
249
|
+
res.json(memory);
|
|
250
|
+
} catch (err: unknown) {
|
|
251
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
252
|
+
res.status(404).json({ error: message });
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Update memory
|
|
257
|
+
app.put("/api/memories/:id", async (req, res) => {
|
|
258
|
+
try {
|
|
259
|
+
const { title, content, importance } = req.body;
|
|
260
|
+
|
|
261
|
+
const result = await mcpClient.callTool("memory.update", {
|
|
262
|
+
id: req.params.id,
|
|
263
|
+
title,
|
|
264
|
+
content,
|
|
265
|
+
importance
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
res.json(result);
|
|
269
|
+
} catch (err: any) {
|
|
270
|
+
res.status(400).json({ error: err.message });
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// Delete memory
|
|
275
|
+
app.delete("/api/memories/:id", async (req, res) => {
|
|
276
|
+
try {
|
|
277
|
+
const result = await mcpClient.callTool("memory.delete", {
|
|
278
|
+
id: req.params.id
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
res.json(result);
|
|
282
|
+
} catch (err: any) {
|
|
283
|
+
res.status(400).json({ error: err.message });
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Archive expired memories
|
|
288
|
+
app.post("/api/memories/archive", async (req, res) => {
|
|
289
|
+
try {
|
|
290
|
+
const archived = db.archiveExpiredMemories();
|
|
291
|
+
res.json({ archived });
|
|
292
|
+
} catch (err: any) {
|
|
293
|
+
logger.error("Error archiving memories", { error: err.message });
|
|
294
|
+
res.status(500).json({ error: err.message });
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Serve the dashboard HTML
|
|
299
|
+
app.get("/", (req, res) => {
|
|
300
|
+
res.sendFile(path.join(__dirname, "public", "index.html"));
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
app.listen(PORT, () => {
|
|
304
|
+
logger.info("MCP Memory Dashboard started", { port: PORT });
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
function condenseRecentActions(actions: RecentAction[], limit: number): CondensedRecentAction[] {
|
|
308
|
+
const condensed: CondensedRecentAction[] = [];
|
|
309
|
+
|
|
310
|
+
for (const action of actions) {
|
|
311
|
+
const previous = condensed[condensed.length - 1];
|
|
312
|
+
const sameKind = previous
|
|
313
|
+
&& previous.action === action.action
|
|
314
|
+
&& previous.query === action.query
|
|
315
|
+
&& previous.memory_id === action.memory_id;
|
|
316
|
+
|
|
317
|
+
const currentTime = new Date(action.created_at).getTime();
|
|
318
|
+
const previousTime = previous ? new Date(previous.created_at).getTime() : 0;
|
|
319
|
+
const withinBurstWindow = previous && Math.abs(previousTime - currentTime) <= 10 * 60 * 1000;
|
|
320
|
+
|
|
321
|
+
if (sameKind && withinBurstWindow) {
|
|
322
|
+
previous.burstCount += 1;
|
|
323
|
+
previous.created_at = action.created_at;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
condensed.push({
|
|
328
|
+
...action,
|
|
329
|
+
burstCount: 1,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return condensed.slice(0, limit);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Cleanup on exit
|
|
337
|
+
process.on("SIGINT", () => {
|
|
338
|
+
mcpClient.stop();
|
|
339
|
+
db.close();
|
|
340
|
+
process.exit(0);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
process.on("SIGTERM", () => {
|
|
344
|
+
mcpClient.stop();
|
|
345
|
+
db.close();
|
|
346
|
+
process.exit(0);
|
|
347
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Feature: memory-mcp-optimization, Property 16: MCPClient cleanup pending requests
|
|
2
|
+
// Feature: memory-mcp-optimization, Property 17: MCPClient retry count
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
5
|
+
import * as fc from "fast-check";
|
|
6
|
+
|
|
7
|
+
import { MCPClient } from "./client.js";
|
|
8
|
+
|
|
9
|
+
class TestableMCPClient extends MCPClient {
|
|
10
|
+
get pending(): Map<number, { resolve: (v: unknown) => void; reject: (r: unknown) => void }> {
|
|
11
|
+
return (this as unknown as { pendingRequests: Map<number, { resolve: (v: unknown) => void; reject: (r: unknown) => void }> }).pendingRequests;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
injectPending(n: number): Promise<unknown>[] {
|
|
15
|
+
const promises: Promise<unknown>[] = [];
|
|
16
|
+
for (let i = 0; i < n; i++) {
|
|
17
|
+
const id = 1000 + i;
|
|
18
|
+
const p = new Promise((resolve, reject) => {
|
|
19
|
+
this.pending.set(id, {
|
|
20
|
+
resolve,
|
|
21
|
+
reject,
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
promises.push(p);
|
|
25
|
+
}
|
|
26
|
+
return promises;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("Property 16: MCPClient cleanup pending requests saat stop atau timeout", () => {
|
|
31
|
+
it("stop() clears the pending map synchronously", () => {
|
|
32
|
+
const client = new TestableMCPClient();
|
|
33
|
+
const n = 5;
|
|
34
|
+
const promises = client.injectPending(n);
|
|
35
|
+
const rejections = promises.map((p) => p.catch(() => undefined));
|
|
36
|
+
client.stop();
|
|
37
|
+
expect(client.getPendingCount()).toBe(0);
|
|
38
|
+
void Promise.all(rejections);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("stop() rejects all pending requests with 'Client stopped'", async () => {
|
|
42
|
+
const client = new TestableMCPClient();
|
|
43
|
+
const n = 5;
|
|
44
|
+
const promises = client.injectPending(n);
|
|
45
|
+
client.stop();
|
|
46
|
+
const results = await Promise.allSettled(promises);
|
|
47
|
+
for (const result of results) {
|
|
48
|
+
expect(result.status).toBe("rejected");
|
|
49
|
+
if (result.status === "rejected") {
|
|
50
|
+
expect((result.reason as Error).message).toBe("Client stopped");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("getPendingCount() returns correct count before and after stop()", () => {
|
|
56
|
+
const client = new TestableMCPClient();
|
|
57
|
+
const n = 10;
|
|
58
|
+
const promises = client.injectPending(n);
|
|
59
|
+
const rejections = promises.map((p) => p.catch(() => undefined));
|
|
60
|
+
expect(client.getPendingCount()).toBe(n);
|
|
61
|
+
client.stop();
|
|
62
|
+
expect(client.getPendingCount()).toBe(0);
|
|
63
|
+
void Promise.all(rejections);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("property: for any N >= 0, after stop() pendingCount === 0", () => {
|
|
67
|
+
fc.assert(
|
|
68
|
+
fc.property(fc.integer({ min: 0, max: 20 }), (n: number) => {
|
|
69
|
+
const client = new TestableMCPClient();
|
|
70
|
+
const promises = client.injectPending(n);
|
|
71
|
+
promises.forEach((p) => p.catch(() => undefined));
|
|
72
|
+
client.stop();
|
|
73
|
+
return client.getPendingCount() === 0;
|
|
74
|
+
})
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("Property 17: MCPClient retry maksimal 3 kali dengan exponential backoff", () => {
|
|
80
|
+
it("retries exactly 3 times (4 total attempts) on timeout before rejecting", async () => {
|
|
81
|
+
vi.useFakeTimers();
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
let callOnceCount = 0;
|
|
85
|
+
|
|
86
|
+
const client = new TestableMCPClient() as unknown as {
|
|
87
|
+
callOnce: (method: string, params: unknown) => Promise<unknown>;
|
|
88
|
+
callWithRetry: (method: string, params: unknown) => Promise<unknown>;
|
|
89
|
+
process: unknown;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
client.callOnce = async (_method: string, _params: unknown): Promise<unknown> => {
|
|
93
|
+
callOnceCount++;
|
|
94
|
+
throw new Error("Request timeout");
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
client.process = { stdin: { write: () => true } };
|
|
98
|
+
|
|
99
|
+
const retryPromise = client.callWithRetry("test/method", {});
|
|
100
|
+
retryPromise.catch(() => undefined);
|
|
101
|
+
|
|
102
|
+
await vi.runAllTimersAsync();
|
|
103
|
+
const result = await retryPromise.catch((e: Error) => e);
|
|
104
|
+
|
|
105
|
+
expect(result).toBeInstanceOf(Error);
|
|
106
|
+
expect((result as Error).message).toBe("Request timeout");
|
|
107
|
+
expect(callOnceCount).toBe(4);
|
|
108
|
+
} finally {
|
|
109
|
+
vi.useRealTimers();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("does not retry on non-timeout errors", async () => {
|
|
114
|
+
vi.useFakeTimers();
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
let callOnceCount = 0;
|
|
118
|
+
|
|
119
|
+
const client = new TestableMCPClient() as unknown as {
|
|
120
|
+
callOnce: (method: string, params: unknown) => Promise<unknown>;
|
|
121
|
+
callWithRetry: (method: string, params: unknown) => Promise<unknown>;
|
|
122
|
+
process: unknown;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
client.callOnce = async (_method: string, _params: unknown): Promise<unknown> => {
|
|
126
|
+
callOnceCount++;
|
|
127
|
+
throw new Error("Some other error");
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
client.process = { stdin: { write: () => true } };
|
|
131
|
+
|
|
132
|
+
const retryPromise = client.callWithRetry("test/method", {});
|
|
133
|
+
retryPromise.catch(() => undefined);
|
|
134
|
+
|
|
135
|
+
await vi.runAllTimersAsync();
|
|
136
|
+
const result = await retryPromise.catch((e: Error) => e);
|
|
137
|
+
|
|
138
|
+
expect(result).toBeInstanceOf(Error);
|
|
139
|
+
expect((result as Error).message).toBe("Some other error");
|
|
140
|
+
expect(callOnceCount).toBe(1);
|
|
141
|
+
} finally {
|
|
142
|
+
vi.useRealTimers();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("property: retry delays follow exponential backoff pattern", () => {
|
|
147
|
+
vi.useFakeTimers();
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
fc.assert(
|
|
151
|
+
fc.property(fc.constant(null), () => {
|
|
152
|
+
const expectedDelays = [1000, 2000, 4000];
|
|
153
|
+
expect(expectedDelays).toHaveLength(3);
|
|
154
|
+
for (let i = 1; i < expectedDelays.length; i++) {
|
|
155
|
+
expect(expectedDelays[i]).toBe(expectedDelays[i - 1] * 2);
|
|
156
|
+
}
|
|
157
|
+
return true;
|
|
158
|
+
})
|
|
159
|
+
);
|
|
160
|
+
} finally {
|
|
161
|
+
vi.useRealTimers();
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
});
|