mcp-rag-memory 2.4.0 → 2.7.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/.env.example +3 -1
- package/LICENSE +21 -0
- package/README.md +161 -25
- package/dist/cli.js +102 -34
- package/dist/db/database.d.ts +10 -2
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/database.js +119 -18
- package/dist/db/database.js.map +1 -1
- package/dist/env.js +32 -12
- package/dist/mcp/rag-server.js +140 -39
- package/dist/mcp/rag-server.js.map +1 -1
- package/dist/memory/memory.d.ts +5 -1
- package/dist/memory/memory.d.ts.map +1 -1
- package/dist/memory/memory.js +245 -101
- package/dist/memory/memory.js.map +1 -1
- package/dist/rag/chunker.d.ts +7 -0
- package/dist/rag/chunker.d.ts.map +1 -1
- package/dist/rag/chunker.js +119 -11
- package/dist/rag/chunker.js.map +1 -1
- package/dist/rag/embeddings.d.ts +25 -0
- package/dist/rag/embeddings.d.ts.map +1 -1
- package/dist/rag/embeddings.js +56 -4
- package/dist/rag/embeddings.js.map +1 -1
- package/dist/rag/pipeline.d.ts +19 -2
- package/dist/rag/pipeline.d.ts.map +1 -1
- package/dist/rag/pipeline.js +212 -39
- package/dist/rag/pipeline.js.map +1 -1
- package/dist/rag/reindex.d.ts +27 -0
- package/dist/rag/reindex.d.ts.map +1 -0
- package/dist/rag/reindex.js +138 -0
- package/dist/rag/reindex.js.map +1 -0
- package/dist/rag/vector-search.d.ts +26 -1
- package/dist/rag/vector-search.d.ts.map +1 -1
- package/dist/rag/vector-search.js +207 -65
- package/dist/rag/vector-search.js.map +1 -1
- package/dist/session/transcript.d.ts +15 -5
- package/dist/session/transcript.d.ts.map +1 -1
- package/dist/session/transcript.js +152 -25
- package/dist/session/transcript.js.map +1 -1
- package/package.json +6 -3
package/.env.example
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# Copy to `.env` and adjust. Picked up automatically by `npx mcp-rag-memory`,
|
|
2
2
|
# `npm run dev`, and `npm run start` (see src/env.ts). Host environment
|
|
3
|
-
# variables always override these values
|
|
3
|
+
# variables always override these values, and only RAG_*/OPENCODE_*/EMBEDDING_*/
|
|
4
|
+
# SEARCH_MODE entries in this file are loaded — anything else is ignored.
|
|
4
5
|
|
|
5
6
|
# Where the SQLite store lives (default: <project>/.rag-data)
|
|
6
7
|
RAG_DB_DIR=.rag-data
|
|
@@ -24,6 +25,7 @@ RAG_AUTOSYNC=1
|
|
|
24
25
|
RAG_SESSION_CONDENSE=1
|
|
25
26
|
RAG_SESSION_CONDENSE_MIN_CHARS=120
|
|
26
27
|
RAG_SESSION_CONDENSE_RATIO=0.35
|
|
28
|
+
# RAG_SESSION_MAX_MSGS=500
|
|
27
29
|
|
|
28
30
|
# Optional pruning during consolidation
|
|
29
31
|
RAG_PRUNE=0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 mcp-rag-memory contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -18,16 +18,20 @@ context **across sessions**.
|
|
|
18
18
|
- **Context Management** — `remember` facts/decisions/preferences, `recall` them later (score-decayed by staleness), rate by importance, consolidate duplicates, filter by type/tag.
|
|
19
19
|
- **Zero external APIs by default** — local hashing-embedder (1024-dim), built-in `node:sqlite`. Works fully offline. Optional `EMBEDDING_PROVIDER=transformers` for a higher-quality ONNX model.
|
|
20
20
|
- **Safe ingestion** — content-hash deduplication, file size guard (`RAG_MAX_FILE_MB`) and an opt-in path allowlist (`RAG_ALLOWED_DIRS`).
|
|
21
|
-
- **MCP server** — runs on stdio,
|
|
21
|
+
- **MCP server** — runs on stdio, 20 tools.
|
|
22
22
|
|
|
23
23
|
## Quick start
|
|
24
24
|
|
|
25
|
+
Requires **Node.js ≥ 22.12** (for `node:sqlite` read-only handles and
|
|
26
|
+
`busy_timeout`).
|
|
27
|
+
|
|
25
28
|
```bash
|
|
26
29
|
npm install
|
|
27
|
-
npm test #
|
|
30
|
+
npm test # 160 checks: unit + MCP round-trip via SDK client
|
|
28
31
|
npm run lint # oxlint
|
|
32
|
+
npm run typecheck # src + scripts + .opencode/plugin
|
|
29
33
|
npm run build # compile to dist/
|
|
30
|
-
npm run cli --
|
|
34
|
+
npm run cli -- docs # CLI playground
|
|
31
35
|
```
|
|
32
36
|
|
|
33
37
|
### Run from npm (npx)
|
|
@@ -133,6 +137,21 @@ The MCP server is registered here so it works in **every project**, not just thi
|
|
|
133
137
|
> from this project's `node_modules` and stores data in the same DB
|
|
134
138
|
> regardless of which project opencode is opened from.
|
|
135
139
|
|
|
140
|
+
**Re-assert this global config with one command.** opencode updates have
|
|
141
|
+
been observed to reset/wipe the global config (the MCP server, plugin,
|
|
142
|
+
LSP and instructions disappear from new sessions). `scripts/setup-opencode.*`
|
|
143
|
+
rewrite both `opencode.json` and `opencode.jsonc` (PowerShell on Windows,
|
|
144
|
+
portable bash elsewhere; dispatcher `npm run setup-opencode`), **merging**
|
|
145
|
+
with what is already there — user keys are never clobbered, arrays
|
|
146
|
+
(`instructions`, `plugin`) are unioned, broken files are backed up to
|
|
147
|
+
`.bak-<ts>`, and runs are idempotent. Re-run it (and restart opencode)
|
|
148
|
+
after any opencode update:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
npm run setup-opencode # merge-in our defaults
|
|
152
|
+
npm run setup-opencode -- --check # exit 0 = up to date, 1 = changes pending
|
|
153
|
+
```
|
|
154
|
+
|
|
136
155
|
### Project config (`opencode.json` in this repo)
|
|
137
156
|
|
|
138
157
|
Keeps project-specific settings (model, LSP, permissions, instructions) — **no `mcp` block here**:
|
|
@@ -171,24 +190,25 @@ the global config to apply to every project.
|
|
|
171
190
|
|
|
172
191
|
Restart opencode after any config change.
|
|
173
192
|
|
|
174
|
-
## MCP tools (
|
|
193
|
+
## MCP tools (20)
|
|
175
194
|
|
|
176
195
|
| Group | Tool | Purpose |
|
|
177
196
|
|-------|------|---------|
|
|
178
|
-
| System | `system_stats` | DB dir, embedding backend, doc/memory counts |
|
|
197
|
+
| System | `system_stats` | DB dir, embedding backend (incl. what the vector leg measures), store state, doc/memory counts |
|
|
198
|
+
| Maintenance | `rag_reindex` | Re-embed every stored vector for the current model, migrating the store in place |
|
|
179
199
|
| RAG ingest | `rag_ingest_text` | Store text as a knowledge document (dedup-aware) |
|
|
180
200
|
| | `rag_ingest_file` | Store a file (`content_type` auto-detected) |
|
|
181
201
|
| | `rag_ingest_dir` | Recursively ingest source files |
|
|
182
|
-
| RAG query | `rag_search` | Hybrid (BM25 + vector) search, ranked chunks + scores |
|
|
202
|
+
| RAG query | `rag_search` | Hybrid (BM25 + vector) search, ranked chunks + scores; `explain: true` adds per-leg scores |
|
|
183
203
|
| | `rag_retrieve` | Ready-to-inject context block with token count |
|
|
184
|
-
| RAG docs | `rag_list_documents`, `rag_document_stats` | Inventory |
|
|
204
|
+
| RAG docs | `rag_list_documents`, `rag_document_stats` | Inventory (`rag_list_documents` is paginated: `limit` 1–1000, `offset`) |
|
|
185
205
|
| | `rag_delete_document` | Remove a document + chunks + FTS rows |
|
|
186
206
|
| Session sync | `rag_sync_session` | Ingest an opencode session's user inputs (or the latest in a workspace) |
|
|
187
207
|
| Memory | `memory_remember` | Save long-term memory (type/importance/tags) |
|
|
188
208
|
| | `memory_recall` | Semantic memory search (decay + recall count) |
|
|
189
209
|
| | `memory_context` | Compact context block from memories for prompts |
|
|
190
210
|
| | `memory_list`, `memory_get`, `memory_update`, `memory_forget` | CRUD |
|
|
191
|
-
| | `memory_consolidate` | Dedupe near-identical + promote hot memories |
|
|
211
|
+
| | `memory_consolidate` | Dedupe near-identical (cosine **and** word overlap) + promote hot memories |
|
|
192
212
|
| | `memory_stats` | Counts, tokens, avg importance, by type |
|
|
193
213
|
|
|
194
214
|
## Run in other AI assistants
|
|
@@ -250,9 +270,45 @@ first winning: the MCP client's `env` block → your shell environment → a
|
|
|
250
270
|
`.env` and edit, or point at any file via `RAG_ENV_FILE`. The store defaults to
|
|
251
271
|
`.rag-data/` in the working directory.
|
|
252
272
|
|
|
273
|
+
## Changing the embedding model
|
|
274
|
+
|
|
275
|
+
The store refuses to mix vector spaces: if `EMBEDDING_MODEL` changes, searching
|
|
276
|
+
with new vectors against old ones would silently blend two spaces and return
|
|
277
|
+
noise. `ensureDim` catches the mismatch and tells you to point `RAG_DB_DIR` at a
|
|
278
|
+
fresh directory and re-ingest — correct, but for a memory store holding months of
|
|
279
|
+
history that is a manual rebuild rather than a migration.
|
|
280
|
+
|
|
281
|
+
`reindex` does the migration in place. It re-embeds every chunk and memory for the
|
|
282
|
+
currently configured provider/model, then rewrites the store metadata.
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
# 1. install the optional provider
|
|
286
|
+
npm i @huggingface/transformers
|
|
287
|
+
|
|
288
|
+
# 2. migrate the existing store (documents' text is the source of truth)
|
|
289
|
+
EMBEDDING_PROVIDER=transformers \
|
|
290
|
+
EMBEDDING_MODEL=Xenova/all-MiniLM-L6-v2 \
|
|
291
|
+
npm run cli -- reindex --batch 32
|
|
292
|
+
|
|
293
|
+
# 3. bake the provider into .env so the server uses it from now on
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Or over MCP: the `rag_reindex` tool takes the same `batch_size`.
|
|
297
|
+
|
|
298
|
+
**How interruption behaves.** A half-migrated store mixes old and new vectors, so
|
|
299
|
+
`reindex` marks the store `reindexing` for its whole duration and every search,
|
|
300
|
+
recall and ingest refuses to run while that flag is set. This is deliberate: the
|
|
301
|
+
failure mode of a partial migration is confident nonsense, and nothing about the
|
|
302
|
+
results would reveal it. If the process dies mid-run, just run `reindex` again —
|
|
303
|
+
the whole store is rewritten from text, so resuming is identical to starting over.
|
|
304
|
+
`system_stats` reports `storeState` and the remediation if you hit this.
|
|
305
|
+
|
|
306
|
+
Re-running is always safe: embedding is deterministic per model, so `reindex` is
|
|
307
|
+
idempotent. On an empty store it is a no-op that just records the model.
|
|
308
|
+
|
|
253
309
|
## Custom slash commands
|
|
254
310
|
|
|
255
|
-
The
|
|
311
|
+
The 20 MCP tools are called by the AI automatically — but you can also
|
|
256
312
|
trigger them directly with custom commands. Sources (same name, project
|
|
257
313
|
wins):
|
|
258
314
|
|
|
@@ -279,10 +335,13 @@ npm run cli -- recall "deployment schedule"
|
|
|
279
335
|
npm run cli -- ingest-dir ./src/rag
|
|
280
336
|
npm run cli -- search "vector similarity"
|
|
281
337
|
npm run cli -- search "auth bug" --source opencode-db # filter by source or doc-id
|
|
338
|
+
npm run cli -- search "auth bug" --explain # per-leg vector/keyword/RRF scores
|
|
339
|
+
npm run cli -- docs --limit 20 --offset 20 # paginated inventory
|
|
282
340
|
npm run cli -- mem-context "database"
|
|
283
341
|
npm run cli -- sessions # list recent opencode sessions
|
|
284
342
|
npm run cli -- sync-session ses_123 # ingest one session's inputs
|
|
285
343
|
npm run cli -- sync-latest # ingest the most recent session
|
|
344
|
+
npm run cli -- reindex --batch 32 # re-embed all vectors for the current model
|
|
286
345
|
npm run clean-db -- --force # wipe ALL documents + memories (destructive)
|
|
287
346
|
```
|
|
288
347
|
|
|
@@ -301,16 +360,16 @@ from your curated long-term memories. Three layers:
|
|
|
301
360
|
|
|
302
361
|
1. **Plugin (real-time, debounced, cross-workspace).**
|
|
303
362
|
`.opencode/plugin/session-logger.ts` watches message events and, at most
|
|
304
|
-
once a minute, runs the portable CLI — `npx -y mcp-rag-memory
|
|
305
|
-
sync-latest --dir <workspace>` — so the session you are
|
|
306
|
-
the store. Because it invokes the published
|
|
307
|
-
it works in **any** project that adds the plugin and a
|
|
308
|
-
its store. Disable with `RAG_AUTOSYNC=0`.
|
|
363
|
+
once a minute, runs the portable CLI — `npx -y -p mcp-rag-memory
|
|
364
|
+
mcp-rag-memory-cli sync-latest --dir <workspace>` — so the session you are
|
|
365
|
+
typing in lands in the store. Because it invokes the published package (not
|
|
366
|
+
this repo's scripts), it works in **any** project that adds the plugin and a
|
|
367
|
+
`.env` pointing at its store. Disable with `RAG_AUTOSYNC=0`.
|
|
309
368
|
2. **CLI on demand.**
|
|
310
369
|
```bash
|
|
311
|
-
npx -y mcp-rag-memory-cli sync-latest --dir "D:/Project/my-app" # latest session in a workspace
|
|
312
|
-
npx -y mcp-rag-memory-cli sync-session ses_abc123 # a specific session
|
|
313
|
-
npx -y mcp-rag-memory-cli sync-logs # .session-logs/*.jsonl
|
|
370
|
+
npx -y -p mcp-rag-memory mcp-rag-memory-cli sync-latest --dir "D:/Project/my-app" # latest session in a workspace
|
|
371
|
+
npx -y -p mcp-rag-memory mcp-rag-memory-cli sync-session ses_abc123 # a specific session
|
|
372
|
+
npx -y -p mcp-rag-memory mcp-rag-memory-cli sync-logs # .session-logs/*.jsonl
|
|
314
373
|
```
|
|
315
374
|
3. **MCP tool.** `rag_sync_session` exposes the same logic over MCP:
|
|
316
375
|
`{ "session_id": "..." }` or `{ "directory": "..." }` for the latest.
|
|
@@ -318,7 +377,8 @@ from your curated long-term memories. Three layers:
|
|
|
318
377
|
> Honest scope note: the **plugin** only ships with this repo out of the box,
|
|
319
378
|
> but it is a plain node script — copy it into any workspace (and give that
|
|
320
379
|
> project an `.env`) and auto-save just works, since it shells out to the
|
|
321
|
-
> published `mcp-rag-memory-cli
|
|
380
|
+
> published CLI bin (`mcp-rag-memory-cli`, inside the `mcp-rag-memory` package)
|
|
381
|
+
> via npx. Without a plugin, auto-save is
|
|
322
382
|
> "manual": run layer 2 or call layer 3 whenever you want a session ingested.
|
|
323
383
|
|
|
324
384
|
How it works: sessions are read from opencode's own DB
|
|
@@ -338,14 +398,74 @@ The plugin needs an opencode restart to take effect.
|
|
|
338
398
|
## Performance & limits
|
|
339
399
|
|
|
340
400
|
- **Search is brute-force cosine / FTS5 over every chunk**: `O(N)` per query
|
|
341
|
-
|
|
342
|
-
|
|
401
|
+
(exact, deterministic — good for a personal store of thousands of chunks).
|
|
402
|
+
ANN / HNSW indexing is planned scope (v3) for 100k+ chunks.
|
|
403
|
+
- **The vector cache holds every vector in RAM**, uncapped and complete. Truncating
|
|
404
|
+
it would silently change which documents are findable, so the cache stays whole
|
|
405
|
+
and the server warns instead: 8 KB per row at the default 1024 dims, so 100k
|
|
406
|
+
chunks is roughly 800 MB. Tune the warning threshold with
|
|
407
|
+
`RAG_VECTOR_CACHE_WARN_MB` (default 256). Beyond a few hundred thousand chunks,
|
|
408
|
+
expect to prune rather than scale.
|
|
343
409
|
- Keyword leg uses the FTS5 **trigram** tokenizer, so CJK text and
|
|
344
410
|
Indonesian-style substring/inflection matching work without extra config.
|
|
345
411
|
Consequence: keyword terms of **≤ 2 characters are skipped** (trigram needs
|
|
346
412
|
3); the vector leg still covers them.
|
|
347
413
|
- Vector embeddings are local hash-based (no external APIs, ~0 cost, offline).
|
|
348
|
-
|
|
414
|
+
They are tuned for short-phrase similarity, not full-document semantics.
|
|
415
|
+
|
|
416
|
+
### What the vector leg actually measures
|
|
417
|
+
|
|
418
|
+
This matters more than it sounds, so the server states it explicitly in
|
|
419
|
+
`system_stats` (`embedding.vectorLeg`) rather than calling everything "semantic".
|
|
420
|
+
|
|
421
|
+
| Provider | `vectorLeg` | Meaning |
|
|
422
|
+
|---|---|---|
|
|
423
|
+
| `local` (default) | `lexical` | Hashes character 1/2/3-grams into 1024 buckets. Scores **lexical and sub-word overlap**, not meaning. |
|
|
424
|
+
| `transformers` | `semantic` | Real sentence embeddings from `EMBEDDING_MODEL`. Captures meaning. |
|
|
425
|
+
|
|
426
|
+
Consequence for the default: `"cara fix login"` and `"masalah autentikasi"`
|
|
427
|
+
share almost no n-grams, so the **vector** leg scores near zero even though they
|
|
428
|
+
mean the same thing. Retrieval still works because the **FTS5 trigram** leg
|
|
429
|
+
carries the query, and because the local embedder does respond to shared substrings
|
|
430
|
+
and character shapes. What you should not expect from the default is
|
|
431
|
+
paraphrase-level recall — restating a concept with different vocabulary is the
|
|
432
|
+
known blind spot.
|
|
433
|
+
|
|
434
|
+
To get true semantic recall, install the optional dependency and migrate in place:
|
|
435
|
+
|
|
436
|
+
```bash
|
|
437
|
+
npm i @huggingface/transformers
|
|
438
|
+
EMBEDDING_PROVIDER=transformers EMBEDDING_MODEL=Xenova/all-MiniLM-L6-v2 npm run cli -- reindex
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
`reindex` rewrites every stored vector for the new model. See
|
|
442
|
+
[Changing the embedding model](#changing-the-embedding-model).
|
|
443
|
+
|
|
444
|
+
### Debugging a bad search
|
|
445
|
+
|
|
446
|
+
`rag_search` accepts `explain: true` (CLI: `--explain`), which attaches the
|
|
447
|
+
per-leg diagnostics to every hit:
|
|
448
|
+
|
|
449
|
+
```jsonc
|
|
450
|
+
{
|
|
451
|
+
"score": 0.71, // final relevance, 0..1 — what min_score filters on
|
|
452
|
+
"legs": ["vector", "keyword"],
|
|
453
|
+
"explain": {
|
|
454
|
+
"vectorScore": 0.63, // cosine similarity, -1..1
|
|
455
|
+
"keywordScore": 1.0, // BM25 normalised against the best hit, 0..1
|
|
456
|
+
"rrfScore": 0.0246, // fused contribution that decided the order
|
|
457
|
+
"vectorRank": 0,
|
|
458
|
+
"keywordRank": 2
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
| Symptom | Likely cause |
|
|
464
|
+
|---|---|
|
|
465
|
+
| `legs: ["keyword"]`, no `vectorScore` | Only the trigram leg matched. Expected for paraphrase queries under the `local` provider. |
|
|
466
|
+
| `legs: ["vector"]` with a low `vectorScore` | Lexical overlap only. Rephrase, or switch provider. |
|
|
467
|
+
| `keywordScore` near 0 for every hit | The query terms are absent from the corpus, or under 3 characters. |
|
|
468
|
+
| No hits at all | Lower `min_score`, or set `SEARCH_MODE=keyword` to isolate which leg is failing. |
|
|
349
469
|
|
|
350
470
|
## Architecture
|
|
351
471
|
|
|
@@ -360,7 +480,7 @@ src/
|
|
|
360
480
|
│ └── pipeline.ts async ingest/search/retrieve, dedup, guards, doc mgmt
|
|
361
481
|
├── session/transcript.ts read opencode sessions (global+local DB), ingest inputs
|
|
362
482
|
├── memory/memory.ts remember / recall / consolidate + decay + prune
|
|
363
|
-
├── mcp/rag-server.ts MCP server (
|
|
483
|
+
├── mcp/rag-server.ts MCP server (20 tools, stdio, npm bin)
|
|
364
484
|
├── cli.ts CLI playground (incl. sync-session / sync-latest / sync-logs)
|
|
365
485
|
└── test/test-all.ts full test suite (unit + MCP round-trip)
|
|
366
486
|
```
|
|
@@ -378,14 +498,25 @@ Data lives in `.rag-data/rag.sqlite` (git-ignored).
|
|
|
378
498
|
| `RAG_MAX_FILE_MB` | `10` | Reject files larger than this |
|
|
379
499
|
| `RAG_ALLOWED_DIRS` | *(unset = anywhere)* | Semicolon/pipe/comma-separated allowed ingest roots |
|
|
380
500
|
| `RAG_MEMORY_HALF_LIFE_DAYS` | `14` | Recall score decay half-life |
|
|
501
|
+
| `RAG_DEDUP_MAX_VECTORS` | `500` | Above this many memories `consolidate` skips near-duplicate detection and reports `skippedDedup`. Raise it on a dedicated server (the pass is O(n²)) |
|
|
502
|
+
| `RAG_VECTOR_CACHE_WARN_MB` | `256` | Warn when the in-memory vector cache exceeds this size |
|
|
381
503
|
| `RAG_PRUNE` | `0` | Set `1` to allow `consolidate` to delete non-essential memories |
|
|
382
504
|
| `RAG_PRUNE_IMPORTANCE` | `0.2` | Delete memories below this importance |
|
|
383
505
|
| `RAG_PRUNE_AGE_DAYS` | `90` | ...and older than this (never-recalled only) |
|
|
384
506
|
| `RAG_AUTOSYNC` | `1` | Set `0` to disable the session-logger plugin's auto-sync |
|
|
385
|
-
| `OPENCODE_DB` / `OPENCODE_DB_LOCAL` | `~/.local/share/opencode/*.db` | Where session transcripts are read from |
|
|
507
|
+
| `OPENCODE_DB` / `OPENCODE_DB_LOCAL` | `~/.local/share/opencode/*.db` | Where session transcripts are read from (re-read on every call, so it can be set late) |
|
|
508
|
+
| `RAG_SESSION_MAX_MSGS` | `500` | Cap on user messages read from one session |
|
|
386
509
|
| `RAG_SESSION_CONDENSE` | `1` | Set `0` to store session inputs verbatim |
|
|
387
510
|
| `RAG_SESSION_CONDENSE_MIN_CHARS` | `120` | Inputs at/below this length are saved whole |
|
|
388
511
|
| `RAG_SESSION_CONDENSE_RATIO` | `0.35` | Fraction of long-input length to keep as key points |
|
|
512
|
+
| `RAG_ENV_FILE` | `<cwd>/.env` | Env file to load at boot |
|
|
513
|
+
|
|
514
|
+
A `.env` file can only set this server's own variables (`RAG_*`,
|
|
515
|
+
`OPENCODE_*`, `EMBEDDING_*`, `SEARCH_MODE`); anything else in it is
|
|
516
|
+
ignored with a warning on stderr, and a real environment variable always
|
|
517
|
+
wins over the file. The loader runs from `dist/index.js` and
|
|
518
|
+
`dist/cli.js` only — if you invoke `src/mcp/rag-server.ts` directly
|
|
519
|
+
(TSX/`npx tsx`), import `src/env.js` yourself.
|
|
389
520
|
|
|
390
521
|
## Testing
|
|
391
522
|
|
|
@@ -488,8 +619,13 @@ instant.
|
|
|
488
619
|
### Opencode feels sluggish / UI lag after adding plugins
|
|
489
620
|
|
|
490
621
|
Plugins that run synchronous I/O or write to stderr on every tool call
|
|
491
|
-
can slow down the TUI. This
|
|
492
|
-
|
|
622
|
+
can slow down the TUI. This repo ships exactly one plugin
|
|
623
|
+
(`.opencode/plugin/session-logger.ts`) and it is debounced to at most one
|
|
624
|
+
`npx … sync-latest` per 60s, spawned detached with `stdio: "ignore"` and
|
|
625
|
+
`shell: false`. If it still costs too much, set `RAG_AUTOSYNC=0` (keeps the
|
|
626
|
+
plugin, disables the auto-sync) or drop the `plugin` entry from
|
|
627
|
+
`opencode.json` — the MCP server and its 20 tools keep working, you just have
|
|
628
|
+
to call `rag_sync_session` yourself.
|
|
493
629
|
|
|
494
630
|
---
|
|
495
631
|
|
package/dist/cli.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* memory-stats
|
|
25
25
|
* mem-context <topic>
|
|
26
26
|
* stats Everything
|
|
27
|
+
* reindex [--batch N] Re-embed all vectors for the current model
|
|
27
28
|
* sessions List opencode sessions (recent first)
|
|
28
29
|
* sync-session <sessionId> [--limit N] Ingest a session's user inputs to RAG
|
|
29
30
|
* sync-latest [--limit N] [--dir PATH] Ingest the most recent session (workspace-aware)
|
|
@@ -34,6 +35,7 @@ import "./env.js";
|
|
|
34
35
|
import { ingestText, ingestFile, ingestDirectory, searchDocs, retrieve, listDocuments, deleteDocument, documentStats } from "./rag/pipeline.js";
|
|
35
36
|
import { remember, recall, listMemories, getMemory, updateMemory, forget, consolidate, memoryStats, contextPrompt, MEMORY_TYPES } from "./memory/memory.js";
|
|
36
37
|
import { ingestSession, ingestJsonlFile, ingestLogDir, ingestLatest, listOpenCodeSessions } from "./session/transcript.js";
|
|
38
|
+
import { reindex } from "./rag/reindex.js";
|
|
37
39
|
import { closeDB, STORAGE_DIR } from "./db/database.js";
|
|
38
40
|
import * as fs from "fs";
|
|
39
41
|
async function main() {
|
|
@@ -47,100 +49,125 @@ async function main() {
|
|
|
47
49
|
closeDB();
|
|
48
50
|
}
|
|
49
51
|
function helpText() {
|
|
50
|
-
return `Commands: ingest-text|ingest-file|ingest-dir|search|retrieve|docs|doc-stats|rm-doc|remember|recall|memory-list|memory-get|memory-update|forget|consolidate|memory-stats|mem-context|stats|sessions|sync-session|sync-latest|sync-logs|ingest-jsonl`;
|
|
52
|
+
return `Commands: ingest-text|ingest-file|ingest-dir|search|retrieve|docs|doc-stats|rm-doc|remember|recall|memory-list|memory-get|memory-update|forget|consolidate|memory-stats|mem-context|stats|reindex|sessions|sync-session|sync-latest|sync-logs|ingest-jsonl`;
|
|
51
53
|
}
|
|
52
54
|
function assertMemoryType(type) {
|
|
53
55
|
if (!MEMORY_TYPES.includes(type)) {
|
|
54
56
|
throw new Error(`Invalid memory type "${type}". Valid: ${MEMORY_TYPES.join(", ")}`);
|
|
55
57
|
}
|
|
56
58
|
}
|
|
59
|
+
function requireArg(value, usage) {
|
|
60
|
+
if (!value || value.startsWith("--"))
|
|
61
|
+
throw new Error(`Missing argument — usage: ${usage}`);
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
57
64
|
async function run(cmd, args) {
|
|
58
65
|
let out;
|
|
59
66
|
try {
|
|
60
67
|
switch (cmd) {
|
|
61
68
|
case "ingest-text": {
|
|
62
|
-
const
|
|
69
|
+
const parsed = parseOpts(args);
|
|
70
|
+
const [title, fileOrDash] = parsed.positional;
|
|
71
|
+
if (!title || !fileOrDash)
|
|
72
|
+
throw new Error("Missing argument — usage: ingest-text <title> <file|->");
|
|
63
73
|
const content = fileOrDash === "-" ? fs.readFileSync(0, "utf-8") : fs.readFileSync(fileOrDash, "utf-8");
|
|
64
|
-
out = await ingestText(content, title
|
|
74
|
+
out = await ingestText(content, title);
|
|
65
75
|
break;
|
|
66
76
|
}
|
|
67
77
|
case "ingest-file":
|
|
68
|
-
out = await ingestFile(args[0]);
|
|
78
|
+
out = await ingestFile(requireArg(args[0], "ingest-file <path>"));
|
|
69
79
|
break;
|
|
70
80
|
case "ingest-dir":
|
|
71
|
-
out = await ingestDirectory(args[0], { recursive: true });
|
|
81
|
+
out = await ingestDirectory(requireArg(args[0], "ingest-dir <dir>"), { recursive: true });
|
|
72
82
|
break;
|
|
73
83
|
case "search": {
|
|
74
84
|
const parsed = parseOpts(args);
|
|
75
|
-
out = await searchDocs(parsed.positional.
|
|
85
|
+
out = await searchDocs(queryArg(parsed.positional), toNum(parsed.opts["top-k"], 10), toNum(parsed.opts["min-score"], 0.08), {
|
|
76
86
|
docId: parsed.opts["doc-id"],
|
|
77
87
|
source: parsed.opts.source,
|
|
78
|
-
});
|
|
88
|
+
}, parsed.opts.explain !== undefined);
|
|
79
89
|
break;
|
|
80
90
|
}
|
|
81
91
|
case "retrieve": {
|
|
82
92
|
const parsed = parseOpts(args);
|
|
83
|
-
out = await retrieve(parsed.positional.
|
|
93
|
+
out = await retrieve(queryArg(parsed.positional), toNum(parsed.opts["top-k"], 6), toNum(parsed.opts["min-score"], 0.08), {
|
|
84
94
|
docId: parsed.opts["doc-id"],
|
|
85
95
|
source: parsed.opts.source,
|
|
86
96
|
});
|
|
87
97
|
break;
|
|
88
98
|
}
|
|
89
|
-
case "docs":
|
|
90
|
-
|
|
99
|
+
case "docs": {
|
|
100
|
+
const parsed = parseOpts(args);
|
|
101
|
+
out = listDocuments({ limit: toNum(parsed.opts.limit, undefined), offset: toNum(parsed.opts.offset, undefined) });
|
|
91
102
|
break;
|
|
103
|
+
}
|
|
92
104
|
case "doc-stats":
|
|
93
105
|
out = documentStats();
|
|
94
106
|
break;
|
|
95
107
|
case "rm-doc":
|
|
96
|
-
out = deleteDocument(args[0]);
|
|
108
|
+
out = deleteDocument(requireArg(args[0], "rm-doc <doc_id>"));
|
|
97
109
|
break;
|
|
98
110
|
case "remember": {
|
|
99
111
|
const parsed = parseOpts(args);
|
|
112
|
+
const content = parsed.positional.join(" ");
|
|
113
|
+
if (!content)
|
|
114
|
+
throw new Error("Missing argument — usage: remember <content> [--type fact] [--importance 0.5] [--tag x]");
|
|
100
115
|
const type = parsed.opts.type ?? "fact";
|
|
101
116
|
assertMemoryType(type);
|
|
102
117
|
out = await remember({
|
|
103
|
-
content
|
|
118
|
+
content,
|
|
104
119
|
type,
|
|
105
|
-
importance:
|
|
120
|
+
importance: toNum(parsed.opts.importance, 0.5),
|
|
106
121
|
tags: parsed.opts.tag ? [parsed.opts.tag] : undefined,
|
|
107
122
|
});
|
|
108
123
|
break;
|
|
109
124
|
}
|
|
110
|
-
case "recall":
|
|
111
|
-
|
|
125
|
+
case "recall": {
|
|
126
|
+
const parsed = parseOpts(args);
|
|
127
|
+
out = await recall(queryArg(parsed.positional), toNum(parsed.opts["top-k"], 8), toNum(parsed.opts["min-score"], 0.1));
|
|
112
128
|
break;
|
|
129
|
+
}
|
|
113
130
|
case "memory-list": {
|
|
114
131
|
const parsed = parseOpts(args);
|
|
132
|
+
const type = parsed.opts.type;
|
|
133
|
+
if (type !== undefined)
|
|
134
|
+
assertMemoryType(type);
|
|
115
135
|
out = listMemories({
|
|
116
|
-
type
|
|
136
|
+
type,
|
|
117
137
|
tag: parsed.opts.tag,
|
|
118
|
-
minImportance: parsed.opts["min-importance"]
|
|
138
|
+
minImportance: toNum(parsed.opts["min-importance"], undefined),
|
|
139
|
+
limit: toNum(parsed.opts.limit, undefined),
|
|
119
140
|
});
|
|
120
141
|
break;
|
|
121
142
|
}
|
|
122
143
|
case "memory-get":
|
|
123
|
-
out = getMemory(args[0]);
|
|
144
|
+
out = getMemory(requireArg(args[0], "memory-get <id>"));
|
|
124
145
|
break;
|
|
125
146
|
case "memory-update": {
|
|
126
147
|
const [id, ...restArgs] = args;
|
|
127
148
|
const parsed = parseOpts(restArgs);
|
|
128
|
-
|
|
129
|
-
|
|
149
|
+
if (!id)
|
|
150
|
+
throw new Error("Missing argument — usage: memory-update <id> [--content ...] [--importance 0.8] [--type fact] [--tag x]");
|
|
151
|
+
const type = parsed.opts.type;
|
|
152
|
+
if (type !== undefined)
|
|
153
|
+
assertMemoryType(type);
|
|
154
|
+
// --content=... (or --content <text>) sets the body; leftover positional
|
|
155
|
+
// words are the body too. The old `!== "true"` check silently discarded
|
|
156
|
+
// any content that was literally the word "true".
|
|
157
|
+
const content = parsed.opts.content ?? (parsed.positional.length ? parsed.positional.join(" ") : undefined);
|
|
158
|
+
if (content === undefined && type === undefined && parsed.opts.importance === undefined && parsed.opts.tag === undefined) {
|
|
159
|
+
throw new Error("Nothing to update — pass --content, --type, --importance and/or --tag");
|
|
160
|
+
}
|
|
130
161
|
out = await updateMemory(id, {
|
|
131
|
-
content
|
|
132
|
-
|
|
133
|
-
: parsed.positional.length
|
|
134
|
-
? parsed.positional.join(" ")
|
|
135
|
-
: undefined,
|
|
136
|
-
importance: parsed.opts.importance !== undefined ? Number(parsed.opts.importance) : undefined,
|
|
162
|
+
content,
|
|
163
|
+
importance: toNum(parsed.opts.importance, undefined),
|
|
137
164
|
type,
|
|
138
165
|
tags: parsed.opts.tag ? [parsed.opts.tag] : undefined,
|
|
139
166
|
});
|
|
140
167
|
break;
|
|
141
168
|
}
|
|
142
169
|
case "forget":
|
|
143
|
-
out = forget(args[0]);
|
|
170
|
+
out = forget(requireArg(args[0], "forget <id>"));
|
|
144
171
|
break;
|
|
145
172
|
case "consolidate":
|
|
146
173
|
out = await consolidate();
|
|
@@ -148,15 +175,30 @@ async function run(cmd, args) {
|
|
|
148
175
|
case "memory-stats":
|
|
149
176
|
out = memoryStats();
|
|
150
177
|
break;
|
|
151
|
-
case "mem-context":
|
|
152
|
-
|
|
178
|
+
case "mem-context": {
|
|
179
|
+
const parsed = parseOpts(args);
|
|
180
|
+
out = await contextPrompt(queryArg(parsed.positional), toNum(parsed.opts["top-k"], 6));
|
|
153
181
|
break;
|
|
182
|
+
}
|
|
154
183
|
case "stats":
|
|
155
184
|
out = { documents: documentStats(), memories: memoryStats(), dbDir: STORAGE_DIR };
|
|
156
185
|
break;
|
|
186
|
+
case "reindex": {
|
|
187
|
+
const parsed = parseOpts(args);
|
|
188
|
+
out = await reindex({
|
|
189
|
+
batchSize: toNum(parsed.opts.batch, undefined),
|
|
190
|
+
onProgress: (p) => {
|
|
191
|
+
if (p.total > 0) {
|
|
192
|
+
process.stderr.write(`reindex ${p.phase}: ${p.done}/${p.total}\r`);
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
process.stderr.write("\n");
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
157
199
|
case "sessions": {
|
|
158
200
|
const parsed = parseOpts(args);
|
|
159
|
-
out = listOpenCodeSessions(parsed.opts.limit
|
|
201
|
+
out = listOpenCodeSessions(toNum(parsed.opts.limit, 15) ?? 15, { directory: parsed.opts.dir });
|
|
160
202
|
break;
|
|
161
203
|
}
|
|
162
204
|
case "sync-session": {
|
|
@@ -164,12 +206,15 @@ async function run(cmd, args) {
|
|
|
164
206
|
const parsed = parseOpts(restArgs);
|
|
165
207
|
if (!id)
|
|
166
208
|
throw new Error("sync-session <sessionId> — run 'cli sessions' to list ids");
|
|
167
|
-
out = await ingestSession(id, { limit: parsed.opts.limit
|
|
209
|
+
out = await ingestSession(id, { limit: toNum(parsed.opts.limit, 0) ?? 0 });
|
|
168
210
|
break;
|
|
169
211
|
}
|
|
170
212
|
case "sync-latest": {
|
|
171
213
|
const parsed = parseOpts(args);
|
|
172
|
-
out = await ingestLatest({
|
|
214
|
+
out = await ingestLatest({
|
|
215
|
+
limit: toNum(parsed.opts.limit, 0) ?? 0,
|
|
216
|
+
directory: parsed.opts.dir ?? parsed.positional[0],
|
|
217
|
+
});
|
|
173
218
|
break;
|
|
174
219
|
}
|
|
175
220
|
case "sync-logs": {
|
|
@@ -178,7 +223,7 @@ async function run(cmd, args) {
|
|
|
178
223
|
break;
|
|
179
224
|
}
|
|
180
225
|
case "ingest-jsonl":
|
|
181
|
-
out = await ingestJsonlFile(args[0]);
|
|
226
|
+
out = await ingestJsonlFile(requireArg(args[0], "ingest-jsonl <file>"));
|
|
182
227
|
break;
|
|
183
228
|
default:
|
|
184
229
|
console.error(`Unknown command: ${cmd}`);
|
|
@@ -188,23 +233,39 @@ async function run(cmd, args) {
|
|
|
188
233
|
}
|
|
189
234
|
catch (e) {
|
|
190
235
|
console.error(`Error: ${e.message}`);
|
|
236
|
+
closeDB();
|
|
191
237
|
process.exit(1);
|
|
192
238
|
}
|
|
193
239
|
console.log(JSON.stringify(out, null, 2));
|
|
194
240
|
}
|
|
241
|
+
/** Query text from positional words — empty input is an error, not a silent []. */
|
|
242
|
+
function queryArg(positional) {
|
|
243
|
+
const q = positional.join(" ").trim();
|
|
244
|
+
if (!q)
|
|
245
|
+
throw new Error("Missing query — usage: <command> <query>");
|
|
246
|
+
return q;
|
|
247
|
+
}
|
|
195
248
|
function parseOpts(args) {
|
|
196
249
|
const positional = [];
|
|
197
250
|
const opts = {};
|
|
198
251
|
for (let i = 0; i < args.length; i++) {
|
|
199
252
|
const a = args[i];
|
|
200
253
|
if (a.startsWith("--")) {
|
|
254
|
+
const eq = a.indexOf("=");
|
|
255
|
+
if (eq > 0) {
|
|
256
|
+
// --key=value: the value may legitimately start with "--".
|
|
257
|
+
opts[a.slice(2, eq)] = a.slice(eq + 1);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
201
260
|
const key = a.slice(2);
|
|
202
261
|
const next = args[i + 1];
|
|
203
|
-
if (next && !next.startsWith("--")) {
|
|
262
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
204
263
|
opts[key] = next;
|
|
205
264
|
i++;
|
|
206
265
|
}
|
|
207
266
|
else {
|
|
267
|
+
// Boolean flag. Previously the literal "true" was stored as the value,
|
|
268
|
+
// which then leaked into --content and --dir.
|
|
208
269
|
opts[key] = "true";
|
|
209
270
|
}
|
|
210
271
|
}
|
|
@@ -214,6 +275,13 @@ function parseOpts(args) {
|
|
|
214
275
|
}
|
|
215
276
|
return { positional, opts };
|
|
216
277
|
}
|
|
278
|
+
/** Parse a numeric CLI option; falls back to `fallback` for missing/garbage input. */
|
|
279
|
+
function toNum(raw, fallback) {
|
|
280
|
+
if (raw === undefined)
|
|
281
|
+
return fallback;
|
|
282
|
+
const n = Number(raw);
|
|
283
|
+
return Number.isFinite(n) ? n : fallback;
|
|
284
|
+
}
|
|
217
285
|
main().catch((e) => {
|
|
218
286
|
console.error(`Error: ${e.message}`);
|
|
219
287
|
process.exit(1);
|
package/dist/db/database.d.ts
CHANGED
|
@@ -5,9 +5,9 @@ export declare function getDB(): DatabaseSync;
|
|
|
5
5
|
/** SHA-256 hex of raw content — used for document dedup. */
|
|
6
6
|
export declare function contentHash(content: string): string;
|
|
7
7
|
export declare function newId(): string;
|
|
8
|
-
/** Serialize a Float64Array embedding to a SQLite BLOB. */
|
|
8
|
+
/** Serialize a Float64Array embedding to a SQLite BLOB (view-safe). */
|
|
9
9
|
export declare function packVector(v: Float64Array): Uint8Array;
|
|
10
|
-
/** Deserialize a BLOB back into a Float64Array. */
|
|
10
|
+
/** Deserialize a BLOB back into a Float64Array. Returns null on absent/partial data. */
|
|
11
11
|
export declare function unpackVector(blob: Uint8Array | null): Float64Array | null;
|
|
12
12
|
export declare function nowMs(): number;
|
|
13
13
|
export declare function closeDB(): void;
|
|
@@ -19,4 +19,12 @@ export declare function setEmbedMeta(dim: number, model: string): void;
|
|
|
19
19
|
*/
|
|
20
20
|
export declare function ensureDim(vec: Float64Array, model: string): void;
|
|
21
21
|
export declare function getEmbedModel(): string;
|
|
22
|
+
/**
|
|
23
|
+
* `embed_state` is '' (or 'ready') for a coherent store and 'reindexing' while a
|
|
24
|
+
* vector-space migration is in flight.
|
|
25
|
+
*/
|
|
26
|
+
export declare function getEmbedState(): string;
|
|
27
|
+
export declare function setEmbedState(state: string): void;
|
|
28
|
+
/** Throws when the store is mid-reindex. Called from ensureDim, the shared gate. */
|
|
29
|
+
export declare function assertEmbedStateReady(): void;
|
|
22
30
|
//# sourceMappingURL=database.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"database.d.ts","sourceRoot":"","sources":["../../src/db/database.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAK3C,eAAO,MAAM,WAAW,QAEoB,CAAC;AAE7C,eAAO,MAAM,OAAO,QAAuC,CAAC;AAU5D,wBAAgB,KAAK,IAAI,YAAY,
|
|
1
|
+
{"version":3,"file":"database.d.ts","sourceRoot":"","sources":["../../src/db/database.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAK3C,eAAO,MAAM,WAAW,QAEoB,CAAC;AAE7C,eAAO,MAAM,OAAO,QAAuC,CAAC;AAU5D,wBAAgB,KAAK,IAAI,YAAY,CA0BpC;AA+GD,4DAA4D;AAC5D,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED,wBAAgB,KAAK,IAAI,MAAM,CAE9B;AAED,uEAAuE;AACvE,wBAAgB,UAAU,CAAC,CAAC,EAAE,YAAY,GAAG,UAAU,CAEtD;AAED,wFAAwF;AACxF,wBAAgB,YAAY,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI,GAAG,YAAY,GAAG,IAAI,CAGzE;AAED,wBAAgB,KAAK,IAAI,MAAM,CAE9B;AAED,wBAAgB,OAAO,SAItB;AASD,wBAAgB,WAAW,IAAI,MAAM,CAWpC;AAED,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,QAWtD;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,YAAY,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CA6BhE;AAED,wBAAgB,aAAa,IAAI,MAAM,CAKtC;AAED;;;GAGG;AACH,wBAAgB,aAAa,IAAI,MAAM,CAKtC;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAIjD;AAED,oFAAoF;AACpF,wBAAgB,qBAAqB,IAAI,IAAI,CAS5C"}
|