lightrag 1.0.0 → 1.1.1
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 +69 -6
- package/package.json +20 -7
- package/server.js +162 -0
- package/src/lightrag.js +6 -0
- package/src/storage.js +60 -0
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# LightRAG
|
|
2
2
|
|
|
3
3
|
Lightweight Retrieval-Augmented Generation library with Knowledge Graph.
|
|
4
|
-
Runs in Node.js (CommonJS)
|
|
4
|
+
Runs in Node.js (CommonJS) and browsers (Web Worker, no bundler required).
|
|
5
5
|
|
|
6
6
|
## Install
|
|
7
7
|
|
|
@@ -11,6 +11,8 @@ npm install lightrag
|
|
|
11
11
|
|
|
12
12
|
## Usage
|
|
13
13
|
|
|
14
|
+
### Node.js
|
|
15
|
+
|
|
14
16
|
```js
|
|
15
17
|
const { pipeline } = require('@huggingface/transformers');
|
|
16
18
|
const { LightRAG, Embedder } = require('lightrag');
|
|
@@ -22,17 +24,50 @@ const { LightRAG, Embedder } = require('lightrag');
|
|
|
22
24
|
embedder: _embedder,
|
|
23
25
|
tokenizer: _pipe.tokenizer,
|
|
24
26
|
llmFunc: async (prompt, opts = {}) => {
|
|
25
|
-
|
|
27
|
+
const _resp = await fetch('https://api.deepseek.com/v1/chat/completions', {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer YOUR_KEY' },
|
|
30
|
+
body: JSON.stringify({ model: 'deepseek-v4-flash', messages: [{ role: 'user', content: prompt }] }),
|
|
31
|
+
});
|
|
32
|
+
const _json = await _resp.json();
|
|
33
|
+
return _json.choices?.[0]?.message?.content || '';
|
|
26
34
|
},
|
|
27
35
|
});
|
|
28
36
|
|
|
29
37
|
await _rag.insert('Your document text here...');
|
|
30
|
-
|
|
31
38
|
const _answer = await _rag.query('What is this about?', { mode: 'hybrid' });
|
|
32
39
|
console.log(_answer);
|
|
33
40
|
})();
|
|
34
41
|
```
|
|
35
42
|
|
|
43
|
+
### Browser (Web Worker)
|
|
44
|
+
|
|
45
|
+
Load Transformers.js from CDN in your Worker, then create a LightRAG instance. The `storage.js` module provides IndexedDB persistence for saving/restoring RAG state across page reloads.
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
import { pipeline, env } from 'https://unpkg.com/@huggingface/transformers@4.2.0/dist/transformers.js';
|
|
49
|
+
|
|
50
|
+
env.allowLocalModels = false;
|
|
51
|
+
env.useBrowserCache = true;
|
|
52
|
+
|
|
53
|
+
const _pipe = await pipeline('feature-extraction', 'Xenova/multilingual-e5-small', { dtype: 'fp32' });
|
|
54
|
+
const _embedder = new Embedder(_pipe);
|
|
55
|
+
const _rag = new LightRAG({
|
|
56
|
+
embedder: _embedder,
|
|
57
|
+
tokenizer: _pipe.tokenizer,
|
|
58
|
+
llmFunc: async (prompt) => {
|
|
59
|
+
const _resp = await fetch('https://api.deepseek.com/v1/chat/completions', { ... });
|
|
60
|
+
const _json = await _resp.json();
|
|
61
|
+
return _json.choices?.[0]?.message?.content || '';
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
await _rag.insert(documentText);
|
|
66
|
+
const _context = await _rag.buildContext(question, 'hybrid');
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
See the [BlackboardLM](https://github.com/Mirakelor/BlackboardLM) project for a complete browser integration example using Web Workers, IndexedDB persistence, and streaming LLM responses.
|
|
70
|
+
|
|
36
71
|
## API
|
|
37
72
|
|
|
38
73
|
### `new LightRAG(opts)`
|
|
@@ -53,14 +88,25 @@ Inserts document text: tokenizes → chunks → embeds each chunk into vector DB
|
|
|
53
88
|
|
|
54
89
|
### `rag.query(question, options)`
|
|
55
90
|
|
|
56
|
-
Query with RAG.
|
|
91
|
+
Query with RAG. Calls `llmFunc` with the question, system prompt (including retrieved context), and conversation history.
|
|
57
92
|
|
|
58
93
|
| Option | Type | Default | Description |
|
|
59
94
|
|--------|------|---------|-------------|
|
|
60
95
|
| `options.mode` | `string` | `'hybrid'` | `'naive'` \| `'local'` \| `'global'` \| `'hybrid'` \| `'mix'` |
|
|
61
96
|
| `options.systemPrompt` | `string` | `''` | System prompt prepended to LLM context |
|
|
62
97
|
| `options.history` | `array` | `[]` | Conversation history `[{role, content}]` |
|
|
63
|
-
| `options.stream` | `boolean` | `false` |
|
|
98
|
+
| `options.stream` | `boolean` | `false` | Passed through to `llmFunc` |
|
|
99
|
+
|
|
100
|
+
### `rag.buildContext(question, mode)`
|
|
101
|
+
|
|
102
|
+
Returns the retrieved context string for a given question and mode, **without** calling the LLM. Useful when you want to handle the LLM call yourself (e.g., for streaming).
|
|
103
|
+
|
|
104
|
+
| Arg | Type | Description |
|
|
105
|
+
|-----|------|-------------|
|
|
106
|
+
| `question` | `string` | The query to build context for |
|
|
107
|
+
| `mode` | `string` | Retrieval mode (`'local'` / `'global'` / `'hybrid'` / `'mix'`) |
|
|
108
|
+
|
|
109
|
+
Returns `''` for `'naive'` mode or when no documents are indexed.
|
|
64
110
|
|
|
65
111
|
### `rag.getGraphData()`
|
|
66
112
|
|
|
@@ -70,9 +116,26 @@ Returns `{ nodes, edges }` — nodes with `id, entity_type, description, degree`
|
|
|
70
116
|
|
|
71
117
|
Returns `{ total, ready, isInserting, isReady }` — insertion progress.
|
|
72
118
|
|
|
119
|
+
### `rag.vdbSize`
|
|
120
|
+
|
|
121
|
+
Getter returning the number of vectors currently stored (read-only).
|
|
122
|
+
|
|
73
123
|
### `rag.toJSON()` / `LightRAG.fromJSON(data, opts)`
|
|
74
124
|
|
|
75
|
-
Serialize/deserialize the entire RAG state (vector DB + knowledge graph).
|
|
125
|
+
Serialize/deserialize the entire RAG state (vector DB + knowledge graph). Use with `storage.js` for IndexedDB persistence in browsers, or with `fs` in Node.js.
|
|
126
|
+
|
|
127
|
+
## Storage (Browser)
|
|
128
|
+
|
|
129
|
+
`lightrag/src/storage.js` provides a simple IndexedDB wrapper for persisting RAG state:
|
|
130
|
+
|
|
131
|
+
```js
|
|
132
|
+
import { Storage } from 'lightrag/src/storage.js';
|
|
133
|
+
|
|
134
|
+
await Storage.save('rag_state', rag.toJSON());
|
|
135
|
+
const _data = await Storage.load('rag_state');
|
|
136
|
+
const _rag = LightRAG.fromJSON(_data, opts);
|
|
137
|
+
await Storage.clear();
|
|
138
|
+
```
|
|
76
139
|
|
|
77
140
|
## License
|
|
78
141
|
|
package/package.json
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lightrag",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Lightweight RAG library with knowledge graph —
|
|
3
|
+
"version": "1.1.1",
|
|
4
|
+
"description": "Lightweight RAG library with knowledge graph — runs in Node.js and browsers (Web Worker, no bundler), MIT licensed",
|
|
5
5
|
"main": "src/index.js",
|
|
6
|
+
"browser": "src/index.js",
|
|
6
7
|
"files": [
|
|
7
|
-
"src/"
|
|
8
|
+
"src/",
|
|
9
|
+
"server.js"
|
|
8
10
|
],
|
|
9
11
|
"scripts": {
|
|
10
12
|
"test": "echo \"Error: no test specified\" && exit 1"
|
|
@@ -16,16 +18,27 @@
|
|
|
16
18
|
"llm",
|
|
17
19
|
"transformers.js",
|
|
18
20
|
"lightrag",
|
|
19
|
-
"entity-extraction"
|
|
21
|
+
"entity-extraction",
|
|
22
|
+
"embeddings",
|
|
23
|
+
"retrieval",
|
|
24
|
+
"browser",
|
|
25
|
+
"web-worker",
|
|
26
|
+
"indexeddb"
|
|
20
27
|
],
|
|
21
28
|
"author": "BlackboardLM",
|
|
22
29
|
"license": "MIT",
|
|
30
|
+
"homepage": "https://github.com/Mirakelor/BlackboardLM/tree/main/lightrag#readme",
|
|
23
31
|
"repository": {
|
|
24
32
|
"type": "git",
|
|
25
|
-
"url": "https://github.com/
|
|
33
|
+
"url": "https://github.com/Mirakelor/BlackboardLM"
|
|
26
34
|
},
|
|
27
35
|
"type": "commonjs",
|
|
28
|
-
"
|
|
29
|
-
"@huggingface/transformers": "^4.
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@huggingface/transformers": "^4.0.0"
|
|
38
|
+
},
|
|
39
|
+
"peerDependenciesMeta": {
|
|
40
|
+
"@huggingface/transformers": {
|
|
41
|
+
"optional": true
|
|
42
|
+
}
|
|
30
43
|
}
|
|
31
44
|
}
|
package/server.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
const { pipeline, env } = require('@huggingface/transformers');
|
|
2
|
+
const { LightRAG, Embedder } = require('./src/index');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
env.remoteHost = process.env.HF_REMOTE_HOST || 'https://huggingface.co/';
|
|
6
|
+
const MODEL_NAME = process.env.TRANSFORMERS_JS_MODEL || 'Xenova/multilingual-e5-small';
|
|
7
|
+
const STORAGE_FILE = (process.env.LIGHTRAG_STORAGE || '/tmp/blackboardlm_rag.json');
|
|
8
|
+
|
|
9
|
+
let _rag = null;
|
|
10
|
+
let _pipe = null;
|
|
11
|
+
|
|
12
|
+
async function _llmFunc(prompt, opts = {}) {
|
|
13
|
+
const _apiKey = process.env.DEEPSEEK_API_KEY || '';
|
|
14
|
+
const _baseUrl = process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com';
|
|
15
|
+
const _model = process.env.LLM_MODEL || 'deepseek-v4-flash';
|
|
16
|
+
const _messages = [];
|
|
17
|
+
if (opts.system_prompt) _messages.push({ role: 'system', content: opts.system_prompt });
|
|
18
|
+
if (opts.history) _messages.push(...opts.history);
|
|
19
|
+
_messages.push({ role: 'user', content: prompt });
|
|
20
|
+
const _resp = await fetch(`${_baseUrl}/v1/chat/completions`, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${_apiKey}` },
|
|
23
|
+
body: JSON.stringify({
|
|
24
|
+
model: _model,
|
|
25
|
+
messages: _messages,
|
|
26
|
+
temperature: 0.7,
|
|
27
|
+
max_tokens: parseInt(process.env.LLM_MAX_TOKENS || '16384'),
|
|
28
|
+
stream: !!opts.stream,
|
|
29
|
+
extra_body: {
|
|
30
|
+
thinking: { type: process.env.LLM_THINKING || 'disabled' },
|
|
31
|
+
reasoning_effort: process.env.LLM_REASONING_EFFORT || 'max',
|
|
32
|
+
},
|
|
33
|
+
}),
|
|
34
|
+
});
|
|
35
|
+
if (!_resp.ok) throw new Error(`LLM API error: ${_resp.status}`);
|
|
36
|
+
if (opts.stream) {
|
|
37
|
+
let _text = '';
|
|
38
|
+
const _body = _resp.body;
|
|
39
|
+
const _reader = _body.getReader();
|
|
40
|
+
const _decoder = new TextDecoder();
|
|
41
|
+
while (true) {
|
|
42
|
+
const { done, value } = await _reader.read();
|
|
43
|
+
if (done) break;
|
|
44
|
+
const _chunk = _decoder.decode(value, { stream: true });
|
|
45
|
+
const _lines = _chunk.split('\n').filter(_l => _l.startsWith('data: '));
|
|
46
|
+
for (const _line of _lines) {
|
|
47
|
+
const _data = _line.slice(6);
|
|
48
|
+
if (_data === '[DONE]') continue;
|
|
49
|
+
try {
|
|
50
|
+
const _json = JSON.parse(_data);
|
|
51
|
+
const _content = _json.choices?.[0]?.delta?.content || '';
|
|
52
|
+
_text += _content;
|
|
53
|
+
} catch (_e) {}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return _text;
|
|
57
|
+
}
|
|
58
|
+
const _json = await _resp.json();
|
|
59
|
+
return _json.choices?.[0]?.message?.content || '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function _init() {
|
|
63
|
+
const _start = Date.now();
|
|
64
|
+
_pipe = await pipeline('feature-extraction', MODEL_NAME, {
|
|
65
|
+
dtype: 'fp32',
|
|
66
|
+
progress_callback: (_info) => {
|
|
67
|
+
if (_info.status === 'progress' && _info.total) {
|
|
68
|
+
process.stderr.write(JSON.stringify({ status: 'progress', loaded: _info.loaded, total: _info.total, file: _info.file }) + '\n');
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
const _embedder = new Embedder(_pipe);
|
|
73
|
+
if (fs.existsSync(STORAGE_FILE)) {
|
|
74
|
+
try {
|
|
75
|
+
const _json = JSON.parse(fs.readFileSync(STORAGE_FILE, 'utf-8'));
|
|
76
|
+
_rag = LightRAG.fromJSON(_json, { embedder: _embedder, llmFunc: _llmFunc, tokenizer: _pipe.tokenizer });
|
|
77
|
+
} catch (_e) {
|
|
78
|
+
_rag = new LightRAG({ embedder: _embedder, llmFunc: _llmFunc, tokenizer: _pipe.tokenizer });
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
_rag = new LightRAG({ embedder: _embedder, llmFunc: _llmFunc, tokenizer: _pipe.tokenizer });
|
|
82
|
+
}
|
|
83
|
+
const _elapsed = Date.now() - _start;
|
|
84
|
+
process.stderr.write(JSON.stringify({ status: 'ready', model: MODEL_NAME, init_ms: _elapsed, loaded: _rag._vdb.size > 0 }) + '\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function _save() {
|
|
88
|
+
fs.writeFileSync(STORAGE_FILE, JSON.stringify(_rag.toJSON()), 'utf-8');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function _clearStorage() {
|
|
92
|
+
if (fs.existsSync(STORAGE_FILE)) fs.unlinkSync(STORAGE_FILE);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function _handle(req) {
|
|
96
|
+
console.error(`[Server] Request: action=${req.action}`);
|
|
97
|
+
switch (req.action) {
|
|
98
|
+
case 'insert':
|
|
99
|
+
console.error(`[Server] Inserting text (${(req.text || '').length} chars)`);
|
|
100
|
+
await _rag.insert(req.text);
|
|
101
|
+
_save();
|
|
102
|
+
console.error(`[Server] Insert done, saved to ${STORAGE_FILE}`);
|
|
103
|
+
return { ok: true, progress: _rag.getProgress() };
|
|
104
|
+
case 'query': {
|
|
105
|
+
console.error(`[Server] Query: mode=${req.mode || 'hybrid'}, history_len=${(req.history || []).length}`);
|
|
106
|
+
const _text = await _rag.query(req.question, {
|
|
107
|
+
mode: req.mode || 'hybrid',
|
|
108
|
+
systemPrompt: req.systemPrompt || '',
|
|
109
|
+
history: req.history || [],
|
|
110
|
+
});
|
|
111
|
+
return { ok: true, text: _text };
|
|
112
|
+
}
|
|
113
|
+
case 'graph':
|
|
114
|
+
return { ok: true, ..._rag.getGraphData() };
|
|
115
|
+
case 'progress':
|
|
116
|
+
return { ok: true, ..._rag.getProgress() };
|
|
117
|
+
case 'reset':
|
|
118
|
+
const { LightRAG: _LR, Embedder: _E } = require('./src/index');
|
|
119
|
+
_rag = new _LR({ embedder: new _E(_pipe), llmFunc: _llmFunc, tokenizer: _pipe.tokenizer });
|
|
120
|
+
_clearStorage();
|
|
121
|
+
return { ok: true };
|
|
122
|
+
case 'save':
|
|
123
|
+
_save();
|
|
124
|
+
return { ok: true };
|
|
125
|
+
case 'load':
|
|
126
|
+
if (fs.existsSync(STORAGE_FILE)) {
|
|
127
|
+
const _json = JSON.parse(fs.readFileSync(STORAGE_FILE, 'utf-8'));
|
|
128
|
+
const { LightRAG: _LR2, Embedder: _E2 } = require('./src/index');
|
|
129
|
+
_rag = LightRAG.fromJSON(_json, { embedder: new _E2(_pipe), llmFunc: _llmFunc, tokenizer: _pipe.tokenizer });
|
|
130
|
+
return { ok: true, loaded: _rag._vdb.size > 0 };
|
|
131
|
+
}
|
|
132
|
+
return { ok: true, loaded: false };
|
|
133
|
+
default:
|
|
134
|
+
return { error: `Unknown action: ${req.action}` };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function main() {
|
|
139
|
+
await _init();
|
|
140
|
+
let _buf = '';
|
|
141
|
+
process.stdin.setEncoding('utf-8');
|
|
142
|
+
for await (const _chunk of process.stdin) {
|
|
143
|
+
_buf += _chunk;
|
|
144
|
+
const _parts = _buf.split('\n');
|
|
145
|
+
_buf = _parts.pop();
|
|
146
|
+
for (const _line of _parts) {
|
|
147
|
+
if (!_line.trim()) continue;
|
|
148
|
+
try {
|
|
149
|
+
const _req = JSON.parse(_line);
|
|
150
|
+
const _resp = await _handle(_req);
|
|
151
|
+
if (_resp !== null) process.stdout.write(JSON.stringify(_resp) + '\n');
|
|
152
|
+
} catch (_e) {
|
|
153
|
+
process.stdout.write(JSON.stringify({ error: _e.message }) + '\n');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
main().catch(_e => {
|
|
160
|
+
process.stderr.write(JSON.stringify({ error: _e.message }) + '\n');
|
|
161
|
+
process.exit(1);
|
|
162
|
+
});
|
package/src/lightrag.js
CHANGED
|
@@ -89,6 +89,12 @@ class LightRAG {
|
|
|
89
89
|
return await this._llmFunc(question, { system_prompt: _sysPrompt, history: options.history || [], stream: options.stream });
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
async buildContext(question, mode) {
|
|
93
|
+
if (mode === 'naive' || this._vdb.size === 0) return '';
|
|
94
|
+
const _qVec = await this._embedder.embedQuery(question);
|
|
95
|
+
return await this._buildContext(new Float32Array(_qVec), mode);
|
|
96
|
+
}
|
|
97
|
+
|
|
92
98
|
async _buildContext(_qVec, mode) {
|
|
93
99
|
let _parts = [];
|
|
94
100
|
if (mode === 'local' || mode === 'hybrid' || mode === 'mix') {
|
package/src/storage.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const _DB_NAME = 'blackboardlm_rag';
|
|
2
|
+
const _DB_VERSION = 1;
|
|
3
|
+
const _STORE_NAME = 'rag_state';
|
|
4
|
+
|
|
5
|
+
function _open() {
|
|
6
|
+
return new Promise((_resolve, _reject) => {
|
|
7
|
+
const _req = indexedDB.open(_DB_NAME, _DB_VERSION);
|
|
8
|
+
_req.onupgradeneeded = (_e) => {
|
|
9
|
+
const _db = _e.target.result;
|
|
10
|
+
if (!_db.objectStoreNames.contains(_STORE_NAME)) {
|
|
11
|
+
_db.createObjectStore(_STORE_NAME);
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
_req.onsuccess = (_e) => _resolve(_e.target.result);
|
|
15
|
+
_req.onerror = (_e) => _reject(_e.target.error);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function _save(key, data) {
|
|
20
|
+
const _db = await _open();
|
|
21
|
+
return new Promise((_resolve, _reject) => {
|
|
22
|
+
const _tx = _db.transaction(_STORE_NAME, 'readwrite');
|
|
23
|
+
const _store = _tx.objectStore(_STORE_NAME);
|
|
24
|
+
_store.put(data, key);
|
|
25
|
+
_tx.oncomplete = () => _resolve();
|
|
26
|
+
_tx.onerror = (_e) => _reject(_e.target.error);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function _load(key) {
|
|
31
|
+
const _db = await _open();
|
|
32
|
+
return new Promise((_resolve, _reject) => {
|
|
33
|
+
const _tx = _db.transaction(_STORE_NAME, 'readonly');
|
|
34
|
+
const _store = _tx.objectStore(_STORE_NAME);
|
|
35
|
+
const _req = _store.get(key);
|
|
36
|
+
_req.onsuccess = (_e) => _resolve(_e.target.result);
|
|
37
|
+
_req.onerror = (_e) => _reject(_e.target.error);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function _clear() {
|
|
42
|
+
const _db = await _open();
|
|
43
|
+
return new Promise((_resolve, _reject) => {
|
|
44
|
+
const _tx = _db.transaction(_STORE_NAME, 'readwrite');
|
|
45
|
+
const _store = _tx.objectStore(_STORE_NAME);
|
|
46
|
+
_store.clear();
|
|
47
|
+
_tx.oncomplete = () => _resolve();
|
|
48
|
+
_tx.onerror = (_e) => _reject(_e.target.error);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const Storage = {
|
|
53
|
+
save: (key, data) => _save(key, data),
|
|
54
|
+
load: (key) => _load(key),
|
|
55
|
+
clear: () => _clear(),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
59
|
+
module.exports = { Storage };
|
|
60
|
+
}
|