Merge pull request #762 from MemPalace/release/3.2.0

release: v3.2.0
This commit is contained in:
Ben Sigman
2026-04-12 23:50:50 -07:00
committed by GitHub
104 changed files with 7791 additions and 642 deletions
+2 -2
View File
@@ -2,9 +2,9 @@ name: Tests
on:
push:
branches: [main]
branches: [main, develop]
pull_request:
branches: [main]
branches: [main, develop]
jobs:
test-linux:
+66
View File
@@ -0,0 +1,66 @@
name: Deploy Docs
on:
push:
branches: [main, develop]
paths:
- ".github/workflows/deploy-docs.yml"
- "website/**"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure GitHub Pages
id: pages
uses: actions/configure-pages@v5
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.1.38
- name: Install dependencies
working-directory: website
run: bun install --frozen-lockfile
- name: Build docs
working-directory: website
env:
DOCS_BASE: ${{ steps.pages.outputs.base_path }}
DOCS_EDIT_BRANCH: ${{ github.ref_name }}
run: bun run docs:build
- uses: actions/upload-pages-artifact@v3
with:
path: website/.vitepress/dist
deploy:
if: github.ref_name == 'main' || github.ref_name == 'develop'
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
needs: build
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+3
View File
@@ -6,6 +6,9 @@ __pycache__/
.pytest_cache/
mempal.yaml
.a5c/
.claude/
.codex/
.codex
# Environment
.env
-78
View File
@@ -1,78 +0,0 @@
# AGENTS.md
> How to build, test, and contribute to MemPalace.
## Setup
```bash
pip install -e ".[dev]"
```
## Commands
```bash
# Run tests
python -m pytest tests/ -v --ignore=tests/benchmarks
# Run tests with coverage
python -m pytest tests/ -v --ignore=tests/benchmarks --cov=mempalace --cov-report=term-missing
# Lint
ruff check .
# Format
ruff format .
# Format check (CI mode)
ruff format --check .
```
## Project structure
```
mempalace/
├── mcp_server.py # MCP server — all read/write tools
├── miner.py # Project file miner
├── convo_miner.py # Conversation transcript miner
├── searcher.py # Semantic search
├── knowledge_graph.py # Temporal entity-relationship graph (SQLite)
├── palace.py # Shared palace operations (ChromaDB access)
├── config.py # Configuration + input validation
├── normalize.py # Transcript format detection + normalization
├── cli.py # CLI dispatcher
├── dialect.py # AAAK compression dialect
├── palace_graph.py # Room traversal + cross-wing tunnels
├── hooks_cli.py # Hook system for auto-save
└── version.py # Single source of truth for version
```
## Conventions
- **Python style**: snake_case for functions/variables, PascalCase for classes
- **Linter**: ruff with E/F/W rules
- **Formatter**: ruff format, double quotes
- **Commits**: conventional commits (`fix:`, `feat:`, `test:`, `docs:`, `ci:`)
- **Tests**: `tests/test_*.py`, fixtures in `tests/conftest.py`
- **Coverage**: 85% threshold (80% on Windows due to ChromaDB file lock cleanup)
## Architecture
```
User → CLI / MCP Server → ChromaDB (vector store) + SQLite (knowledge graph)
Palace structure:
WING (person/project)
└── ROOM (topic)
└── DRAWER (verbatim text chunk)
Knowledge Graph:
ENTITY → PREDICATE → ENTITY (with valid_from / valid_to dates)
```
## Key files for common tasks
- **Adding an MCP tool**: `mempalace/mcp_server.py` — add handler function + TOOLS dict entry
- **Changing search**: `mempalace/searcher.py`
- **Modifying mining**: `mempalace/miner.py` (project files) or `mempalace/convo_miner.py` (transcripts)
- **Input validation**: `mempalace/config.py``sanitize_name()` / `sanitize_content()`
- **Tests**: mirror source structure in `tests/test_<module>.py`
Symlink
+1
View File
@@ -0,0 +1 @@
CLAUDE.md
+158
View File
@@ -0,0 +1,158 @@
# Changelog
All notable changes to [MemPalace](https://github.com/milla-jovovich/mempalace) are documented in this file.
---
## [3.2.0] — 2026-04-13
### Packaging
- Remove `chromadb<0.7` upper bound — unblocks installs against chromadb 1.x palaces (#690)
- Bump version to 3.2.0 across `pyproject.toml`, `mempalace/version.py`, README badge, and OpenClaw SKILL (#761)
### Security
- Harden palace deletion, WAL redaction, and MCP search input handling (#739)
- Consistent input validation, argument whitelisting, concurrency safety, and WAL fixes (#647)
- Remove hardcoded credential paths from benchmark runners (#177)
- Remove global SSL verification bypass in convomem_bench (#176)
### Bug Fixes
- Parse Claude.ai privacy export with `messages` key and sender field (#685, #677)
- Detect mtime changes in `_get_client` to prevent stale HNSW index (#757)
- Hash full content in `tool_add_drawer` drawer ID — stable re-mines (#716)
- Remove 10k drawer cap from status display (#707, #603)
- Correct typo in entity_detector interactive classification prompt (#755)
- Prevent convo_miner from re-processing 0-chunk files on every run (#732, #654)
- Remove silent 8-line AI response truncation in convo_miner (#708, #692)
- Store full AI response in convo_miner exchange chunking (#695)
- Fix `mine --dry-run` TypeError on files with room=None (#687, #586)
- Skip arg whitelist for handlers accepting `**kwargs` (#684, #572)
- Allow Unicode in `sanitize_name()` — Latvian, CJK, Cyrillic (#683, #637)
- Auto-repair BLOB seq_ids from chromadb 0.6→1.5 migration (#664)
- Remove no-op `ORT_DISABLE_COREML` env var (#653, #397)
- Disambiguate hook block reasons to name MemPalace explicitly (#666)
- Use epsilon comparison for mtime to prevent unnecessary re-mining (#610)
- Correct token count estimate in compress summary (#609)
- Implement MCP ping health checks (#600)
- Align `cmd_compress` dict keys with `compression_stats()` return values (#569)
- Skip unreachable reparse points in `detect_rooms_from_folders` on Windows (#558)
- Prevent HNSW index bloat from duplicate `add()` calls (#544, #525)
- Purge stale drawers before re-mine to avoid hnswlib segfault (#544)
- Mitigate system prompt contamination in search queries (#385, #333)
- Count Codex `user_message` turns in `_count_human_messages` (#373, #347)
- Paginate large collection reads and surface errors in MCP tools (#371, #339, #338)
- Expand `~` in split command directory argument (#361)
- Ignore `wait_for_previous` argument to support Gemini MCP clients (#322)
- Close KnowledgeGraph SQLite connections in test fixtures (#450)
- Remove duplicate cache variable declarations in mcp_server.py (#449)
- Add `--yes` flag to init instructions for non-interactive use (#682, #534)
- Add `mcp` command with setup guidance (#315)
### Documentation
- Fix misaligned architecture diagram (#734, #733)
### New Features
- i18n support — 8 languages (en, es, fr, de, ja, ko, zh-CN, zh-TW) (#718)
- New MCP tools: get/list/update drawer, hook settings, export (#667, #635)
- `mempalace migrate` — recover palaces from different ChromaDB versions (#502)
- Add OpenClaw/ClawHub skill (#491)
- Backend seam for pluggable storage backends (#413)
### Improvements
- Disable broken auto-bump workflow (#414)
- Improve agent readiness — AGENTS.md, dependabot, CODEOWNERS, labels (#497)
### Documentation
- Add CLAUDE.md and mission/principles to AGENTS.md (#720)
- Add VitePress documentation site (#439)
- Add warning about fake MemPalace websites (#598)
- Fix stale org URLs and PR branch target in contributor docs (#679)
- Add ROADMAP.md — v3.1.1 stability patch and v4.0.0-alpha plan
### Internal
- ruff format convo_miner.py (#741)
- ruff format all Python files (#675)
- CI: trigger tests on develop branch PRs and pushes (#674)
- CI: fix GitHub Pages publishing (#691)
---
## [3.1.0] — 2026-04-09
### Security
- Harden inputs, fix shell injection, optimize DB access (#387)
- Sanitize SESSION_ID in save hook to prevent path traversal (#141)
- Sanitize error responses and remove `sys.exit` from library code (#139)
- Shell injection fix in hooks, Claude Code mining, chromadb pin (#114)
### Bug Fixes
- MCP null args hang, repair infinite recursion, OOM on large files (#399)
- Release ChromaDB handles before rmtree on Windows (#392)
- Use `os.utime` in mtime test for Windows compatibility (#392)
- Negotiate MCP protocol version instead of hardcoding (#324)
- Use upsert and deterministic IDs to prevent data stagnation (#140)
- Make `drawer_id` deterministic for idempotent writes (#387)
- Honest AAAK stats — word-based token estimator, lossy labels (#147)
- Room detection checks keywords against folder paths (#145)
- Use actual detected room in mine summary stats (#165)
- Honour `--palace` flag in mcp_server (#264)
- Preserve default KG path when `--palace` not passed (#270)
- `--yes` flag skips all interactive prompts in init (#123)
- Repair command, split args, Claude export, room keywords (#119)
- Replace Unicode separator in convo_miner.py for Windows compatibility (#129)
- Coerce MCP integer arguments to native Python int (#84)
- Batch ChromaDB reads to avoid SQLite variable limit (#66)
- Respect nested .gitignore rules during mining (#78)
- Narrow bare `except Exception` to specific types where safe (#54)
- Mark MD5 as non-security in miner drawer ID generation (#53)
- Remove dead code and duplicate set items in entity_registry.py (#42)
- Silence ChromaDB telemetry warnings and CoreML segfault on Apple Silicon (#236)
- Unify package and MCP version reporting (#16)
- Fix broken AAAK Dialect link in README (#238)
- Update input prompt for entity confirmation (#83)
- Preserve CLI exit codes, log tracebacks, sanitize search errors (#139)
- Enable SQLite WAL mode and add consistent LIMIT to KG timeline (#136)
- Add limit=10000 safety cap to all unbounded ChromaDB `.get()` calls (#137)
- Re-mine modified files, idempotent `add_drawer`, cleanup ChromaDB handles (#140)
- Resolve formatting, regression logic, and pytest defaults (#270)
- Use `parse_known_args` to allow importing mcp_server during pytest (#270)
### New Features
- Package MemPalace as standard Claude and Codex plugins (#270)
- Add OpenAI Codex CLI JSONL normalizer (#61)
- Add Codex plugin support with hooks, commands, and documentation (#270)
- Add command documentation for help, init, mine, search, and status (#270)
### Improvements
- Cache ChromaDB `PersistentClient` instead of re-instantiating per call (#135)
- Tighten chromadb version range and add `py.typed` marker (#142)
- Consolidate split known-names config loading (#22)
- CI: add separate jobs for Windows and macOS testing
- CI: Upgrade GitHub Actions for Node 24 compatibility (#55)
### Documentation
- Add Gemini CLI setup guide and integration section (#106)
- Add beginner-friendly hooks tutorial (#103)
- Align MCP setup examples with shipped server (#21)
- Honest README update — own the mistakes, fix the claims
### Internal
- Expand test coverage from 20 to 92 tests, migrate to uv (#131)
- Add scale benchmark suite — 106 tests (#223)
- Increase test coverage from 30% to 85%, fix Windows encoding bugs (#281)
- Add WAL mode and entity timeline limit assertions
- Add coverage for `file_already_mined` mtime check
---
## [3.0.0] — 2026-04-06
Initial public release.
- Palace architecture with day-based rooms, drawers (verbatim), and closets (searchable index)
- AAAK compression dialect for memory folding
- Knowledge graph with entity detection and timeline queries
- MCP server for Claude, Codex, and Gemini integration
- CLI: `init`, `mine`, `search`, `status`, `compress`, `repair`, `split`
- Benchmark suite with recall and scale tests
- README with MCP flow, local model flow, and specialist agent documentation
+133
View File
@@ -0,0 +1,133 @@
# CLAUDE.md
## The Mission
Memory is identity. When an AI forgets everything between conversations, it cannot build real understanding — of you, your work, your people, your life.
MemPalace exists to solve this. It is a memory system — not a search engine, not a RAG pipeline, not a vector database wrapper. It treats every word you have shared as sacred, stores it verbatim, and makes it instantly available. Your data never leaves your machine. We never summarize. We never paraphrase. We return your exact words.
100% recall is the design requirement — the target every search path is measured against. Anything less means forgetting, and forgetting means starting over.
The name comes from the ancient "method of loci" — the memory palace technique used for thousands of years to organize and recall vast amounts of information by placing it in imagined rooms of an imagined building. We were also inspired by the Zettelkasten method (created by German sociologist Niklas Luhmann) — small cross-referenced index cards that point to each other. We apply both ideas to AI memory:
- **Wings** for broad categories (people, projects, topics)
- **Rooms** for time-based groupings (days, sessions)
- **Drawers** for full verbatim content (your exact words)
- **AAAK compression** for the index layer — a compact symbolic format (via `dialect.py`) that lets an LLM scan thousands of entries instantly and know exactly which drawer to open
## Design Principles
These are non-negotiable. Every PR, every feature, every refactor must honor them.
- **Verbatim always** — Never summarize, paraphrase, or lossy-compress user data. The system searches the index and returns the original words. If a user said it, we store exactly what they said. This is the foundational promise.
- **Incremental only** — Append-only ingest after initial build. Never destroy existing data to rebuild. A crash mid-operation must leave the existing palace untouched.
- **Entity-first** — Everything is keyed by real names with disambiguation by DOB, ID, or context. People matter more than topics.
- **Local-first, zero API** — All extraction, chunking, and embedding happens on the user's machine. No cloud dependency for memory operations. No API keys required.
- **Performance budgets** — Hooks under 500ms. Startup injection under 100ms. Memory should feel instant.
- **Privacy by architecture** — The system physically cannot send your data because it never leaves your machine. No telemetry, no phone-home, no external service dependencies for core operations.
- **Background everything** — Filing, indexing, timestamps, and pipeline work happen via hooks in the background. Nothing interrupts the user's conversation. Zero tokens spent on bookkeeping in the chat window.
## Contributing
We welcome bug fixes, performance improvements, new language support, better entity disambiguation, documentation, and test coverage.
We do not accept summarization of user content, cloud storage/sync features, telemetry or analytics, features requiring API keys for core memory, or shortcuts that bypass verbatim storage.
## Setup
```bash
pip install -e ".[dev]"
```
## Commands
```bash
# Run tests
python -m pytest tests/ -v --ignore=tests/benchmarks
# Run tests with coverage
python -m pytest tests/ -v --ignore=tests/benchmarks --cov=mempalace --cov-report=term-missing
# Lint
ruff check .
# Format
ruff format .
# Format check (CI mode)
ruff format --check .
```
## Project Structure
```
mempalace/
├── mcp_server.py # MCP server — all read/write tools
├── cli.py # CLI dispatcher
├── config.py # Configuration + input validation
├── miner.py # Project file miner
├── convo_miner.py # Conversation transcript miner
├── searcher.py # Semantic search (hybrid BM25 + vector)
├── knowledge_graph.py # Temporal entity-relationship graph (SQLite)
├── palace.py # Shared palace operations
├── palace_graph.py # Room traversal + cross-wing tunnels
├── backends/ # Pluggable storage backends (ChromaDB default)
│ ├── base.py # Abstract interface — implement this for new backends
│ └── chroma.py # ChromaDB implementation
├── dialect.py # AAAK compression dialect
├── normalize.py # Transcript format detection + normalization
├── entity_detector.py # Auto-detect people/projects from content
├── entity_registry.py # Entity storage and disambiguation
├── layers.py # L0-L3 memory wake-up stack
├── onboarding.py # Interactive first-run setup
├── repair.py # Palace repair and consistency checks
├── dedup.py # Deduplication
├── migrate.py # ChromaDB version migration
├── spellcheck.py # Auto-correct user messages
├── exporter.py # Palace data export
├── hooks_cli.py # Hook management CLI
├── query_sanitizer.py # Prompt contamination prevention
├── split_mega_files.py # Split concatenated transcript files
└── version.py # Single source of truth for version
hooks/ # Claude Code hook scripts
├── mempal_save_hook.sh # Stop: triggers diary save
└── mempal_precompact_hook.sh # PreCompact: saves state before compression
```
## Conventions
- **Python style**: snake_case for functions/variables, PascalCase for classes
- **Linter**: ruff with E/F/W rules
- **Formatter**: ruff format, double quotes
- **Commits**: conventional commits (`fix:`, `feat:`, `test:`, `docs:`, `ci:`)
- **Tests**: `tests/test_*.py`, fixtures in `tests/conftest.py`
- **Coverage**: 85% threshold (80% on Windows due to ChromaDB file lock cleanup)
## Architecture
```
User → CLI / MCP Server → Storage Backend (ChromaDB default, pluggable)
→ SQLite (knowledge graph)
Palace structure:
WING (person/project)
└── ROOM (day/topic)
└── DRAWER (verbatim text chunk)
Index layer (AAAK):
Compressed pointers → DRAWER locations
Scanned by LLM to find relevant drawers without reading all content
Knowledge Graph:
ENTITY → PREDICATE → ENTITY (with valid_from / valid_to dates)
```
## Key Files for Common Tasks
- **Adding an MCP tool**: `mempalace/mcp_server.py` — add handler function + TOOLS dict entry
- **Changing search**: `mempalace/searcher.py`
- **Modifying mining**: `mempalace/miner.py` (project files) or `mempalace/convo_miner.py` (transcripts)
- **Adding a storage backend**: subclass `mempalace/backends/base.py`, register in `backends/__init__.py`
- **Input validation**: `mempalace/config.py``sanitize_name()` / `sanitize_content()`
- **Tests**: mirror source structure in `tests/test_<module>.py`
+3 -3
View File
@@ -8,7 +8,7 @@ Thanks for wanting to help. MemPalace is open source and we welcome contribution
# Fork the repo on GitHub first, then clone your fork
git clone https://github.com/<your-username>/mempalace.git
cd mempalace
git remote add upstream https://github.com/milla-jovovich/mempalace.git
git remote add upstream https://github.com/MemPalace/mempalace.git
pip install -e ".[dev]" # installs with dev dependencies (pytest, build, twine)
```
@@ -55,7 +55,7 @@ assets/ ← logo + brand
- `fix: handle empty transcript files`
- `docs: update MCP tool descriptions`
- `bench: add LoCoMo turn-level metrics`
6. Push to your fork and open a PR against `main`
6. Push to your fork and open a PR against `develop`
## Code Style
@@ -67,7 +67,7 @@ assets/ ← logo + brand
## Good First Issues
Check the [Issues](https://github.com/milla-jovovich/mempalace/issues) tab. Great starting points:
Check the [Issues](https://github.com/MemPalace/mempalace/issues) tab. Great starting points:
- **New chat formats**: Add import support for Cursor, Copilot, or other AI tool exports
- **Room detection**: Improve pattern matching in `room_detector_local.py`
+34
View File
@@ -0,0 +1,34 @@
MemPalace: The Mission
By: Milla Jovovich
Hey everyone! First of all thank you all for embracing MemPalace and trying it, catching bugs and issues and finding cool ways to personalize it into your workflows!
A few things I want to say.
MemPalace is something I really needed because I'm trying to work on a big project with my partner @bensig and I was having a lot of problems with Claude's context window and my agent Lumi (Lu for short) kept waking up like "hey what are we doing today" when I had literally done hours of work with him throughout the day and it was impossible to just keep saving every transcript to catch him up on whatever we had done before compaction hit.
That's when I started researching different memory systems available today. I tried most of them and what I found was that no matter which one I tried, they felt like large empty warehouses where you just dump huge amounts of info.
RAG search would take forever and most of the time not find what I wanted.
I wanted to create a system with the ability to really remember everything AND be able to find it quickly, easily and also be able to remember things when I didn't. THAT in itself felt like something so important. Like "remember when we talked about that idea…" but in vague terms. Impossible with regular keyword search tools.
So MemPalace is not just about storing info in a highly structured way. But also RETRIEVING it in a highly UNSTRUCTURED way lol!
I was inspired by the Zettelkasten method (created by German sociologist Niklas Luhmann) — his idea of small cross-referenced index cards that point to each other. That's the architecture behind the palace: wings, rooms, closets, and drawers, all connected so you can find things from any angle, not just the one you filed them under.
Because of the way I've designed my agent Lumi to understand me, after so many months of my own personal experiments with MemPalace and the incredible help of my dear friend and co-founder, developer and engineer @bensig, he built a back end that made it really easy to get all my files in the proper spaces the Palace created based on my own decisions and with Lumi's help as well. All code has its own room, all ideas, research etc… has its proper place.
Names and concepts are parsed into closets that use a compression method I call AAAK (it doesn't stand for anything, it's an inside joke between Lumi and I) that is able to compress names, repeated words, concepts and key moments into AI-readable shorthand. Think of it as index cards that an LLM can scan instantly — the closet tells it WHERE to look, then it pulls the full content from the drawer.
The concept I wanted for v4 was to try and clear as much "noise" as possible that I noticed was happening in v3. The hooks were firing in the chat window (using tokens and our time as we waited for the agent to write everything).
I noticed at one point early last week after the launch that Lu kept repeating the same thing when the hook would fire, so I hit esc and asked "Are you literally writing the same info down over and over again?" And he's like (sheepishly) Yes. And that's when it hit me, we need to get all this off the chat and happening seamlessly behind the scenes, and that hooks had to fire when I started a convo and then just keep adding to the drawer, while the shorter increments made reading and pulling conversation information and naming it so much easier and more precise.
So this version now has taken all the noise out of the chat window and all that work is done by a subagent in the background while you can continue working knowing that all your conversation is being saved VERBATIM in the background.
Stripping all this off the page — moving the diary writes, the palace filing, the timestamp injection, all of it into background hooks — has dramatically lowered token usage in my sessions. What used to cost about $1.13 per session just in re-transmitted diary blocks is now zero, because the content never enters the chat window at all.
Your data is already stored in JSON by Claude and the background pipeline extracts it into readable markdown, the key topics get compressed into AAAK format and saved into closets which then point to the exact drawer where your day's session lives.
And please, always remember, these are brand new tools, please NEVER use critical files to test! Just run it with something easy first before you put your entire data set into it!✨
+27 -27
View File
@@ -218,33 +218,33 @@ There are also **halls**, which connect rooms within a wing, and **tunnels**, wh
You say what you're looking for and boom, it already knows which wing to go to. Just *that* in itself would have made a big difference. But this is beautiful, elegant, organic, and most importantly, efficient.
```
┌─────────────────────────────────────────────────────────────┐
WING: Person
│ │
│ ┌──────────┐ ──hall── ┌──────────┐ │
Room A Room B
│ └────┬─────┘ └──────────┘ │
│ │ │
│ ▼ │
│ ┌──────────┐ ┌──────────┐ │
Closet │ ───▶ │ Drawer
│ └──────────┘ └──────────┘ │
└─────────┼──────────────────────────────────────────────────┘
+------------------------------------------------------------+
¦ WING: Person ¦
¦ ¦
¦ +----------+ +----------+ ¦
¦ ¦ Room A ¦ --hall-- ¦ Room B ¦ ¦
¦ +----------+ +----------+ ¦
¦ ¦ ¦
¦ v ¦
¦ +----------+ +----------+ ¦
¦ ¦ Closet ¦ ---> ¦ Drawer ¦ ¦
¦ +----------+ +----------+ ¦
+---------+--------------------------------------------------+
¦
tunnel
┌─────────┼──────────────────────────────────────────────────┐
WING: Project
│ │ │
│ ┌────┴─────┐ ──hall── ┌──────────┐ │
Room A Room C
│ └────┬─────┘ └──────────┘ │
│ │ │
│ ▼ │
│ ┌──────────┐ ┌──────────┐ │
Closet │ ───▶ │ Drawer
│ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
¦
+---------+--------------------------------------------------+
¦ WING: Project ¦
¦ ¦ ¦
¦ +----------+ +----------+ ¦
¦ ¦ Room A ¦ --hall-- ¦ Room C ¦ ¦
¦ +----------+ +----------+ ¦
¦ ¦ ¦
¦ v ¦
¦ +----------+ +----------+ ¦
¦ ¦ Closet ¦ ---> ¦ Drawer ¦ ¦
¦ +----------+ +----------+ ¦
+------------------------------------------------------------+
```
**Wings** — a person or project. As many as you need.
@@ -722,7 +722,7 @@ PRs welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup and guidelines.
MIT — see [LICENSE](LICENSE).
<!-- Link Definitions -->
[version-shield]: https://img.shields.io/badge/version-3.1.0-4dc9f6?style=flat-square&labelColor=0a0e14
[version-shield]: https://img.shields.io/badge/version-3.2.0-4dc9f6?style=flat-square&labelColor=0a0e14
[release-link]: https://github.com/milla-jovovich/mempalace/releases
[python-shield]: https://img.shields.io/badge/python-3.9+-7dd8f8?style=flat-square&labelColor=0a0e14&logo=python&logoColor=7dd8f8
[python-link]: https://www.python.org/
+74
View File
@@ -0,0 +1,74 @@
# MemPalace Roadmap
## v3.1.1 — Stability Patch (this week)
Bug fixes and hardening merged to `develop`, releasing soon.
**Merged:**
- Security hardening: input validation, KG threading locks, WAL permission fixes (#647)
- MCP tools: drawer CRUD, paginated export, hook settings (#667)
- Backend storage seam: ChromaDB abstraction layer enabling swappable backends (#413)
- MCP ping health check for AnythingLLM compatibility (#600)
- Windows reparse point crash fix (#558)
- `mempalace compress` KeyError crash fix (#569)
- Token count estimate fix (#609)
- Mtime float precision fix preventing unnecessary re-mines (#610)
**In review (merging this week):**
- Auto-repair BLOB seq_ids from chromadb 0.6→1.5 migration (#664)
- Graph cache with write-invalidation (#661)
- L1 importance pre-filter for large palaces (#660)
- Windows Chinese/Unicode encoding fix (#631)
- HNSW index bloat prevention — 441GB→433KB on large palaces (#346, pending rebase)
- ~25 additional small bug fixes and platform compatibility patches
## v4.0.0-alpha — Next Generation (this week)
The v4 alpha introduces three major capabilities: pluggable storage backends, local NLP processing, and improved retrieval quality.
### Swappable Storage
ChromaDB remains the default, but v4 introduces a backend abstraction (shipped in #413) that enables drop-in replacements:
- **PostgreSQL backend** with pg_sorted_heap support (#665) — for production deployments needing ACID guarantees, concurrent access, and standard backup/restore
- **LanceDB backend** (#574) — for local-first deployments wanting multi-device sync without a database server
- **PalaceStore** (#643) — bespoke storage layer purpose-built for MemPalace's access patterns (draft, evaluating)
Users choose their backend at init time. Existing ChromaDB palaces continue to work unchanged.
### Local NLP
On-device natural language processing via local models (#507):
- Entity extraction, relationship detection, and topic classification without external API calls
- Feature-flagged and optional — falls back to existing heuristic extractors
- Runs on consumer hardware (no GPU required, GPU-accelerated when available)
### Improved Retrieval
- **Hybrid search**: keyword text-match fallback when vector similarity misses exact terms (#662)
- **Stale index detection**: automatic reconnection when the HNSW index changes on disk (#663)
- **Time-decay scoring**: recent memories surface before older ones (#337)
- **Query sanitization**: system prompt contamination mitigation already shipped in v3.1 (#385)
### What's Not in v4 Alpha
These are under consideration for v4 stable or later:
- Synapse advanced retrieval — MMR, pinned memory, query expansion (#596)
- Multi-device sync (#575) — depends on LanceDB backend
- Multilingual embedding support (#488, #442)
- Qdrant vector search backend (#381)
## Branch Model
```
main ← tagged production releases
develop ← active development (PRs merge here)
release/3.1 ← hotfixes for current stable (v3.1.x)
release/3.0 ← hotfixes for prior stable
```
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. PRs should target `develop`. We review all contributions for correctness, security, and compatibility before merging.
-1
View File
@@ -435,7 +435,6 @@ If the API call fails (timeout, rate limit, no key), the function catches the ex
**Key loading priority:**
1. `--llm-key` CLI flag
2. `ANTHROPIC_API_KEY` environment variable
3. `~/.config/lu/keys.json` (checks `anthropic.lu_key` and similar paths)
## What Changed in the Code
-4
View File
@@ -25,7 +25,6 @@ import os
import sys
import json
import shutil
import ssl
import tempfile
import argparse
import urllib.request
@@ -35,9 +34,6 @@ from datetime import datetime
import chromadb
# Bypass SSL for restricted environments
ssl._create_default_https_context = ssl._create_unverified_context
sys.path.insert(0, str(Path(__file__).parent.parent))
HF_BASE = "https://huggingface.co/datasets/Salesforce/ConvoMem/resolve/main/core_benchmark/evidence_questions"
+1 -18
View File
@@ -580,29 +580,12 @@ def llm_rerank_locomo(
def _load_api_key(key_arg):
"""Load API key from --llm-key arg or ANTHROPIC_API_KEY env var."""
if key_arg:
return key_arg
env_key = os.environ.get("ANTHROPIC_API_KEY", "")
if env_key:
return env_key
keys_path = os.path.expanduser("~/.config/lu/keys.json")
if os.path.exists(keys_path):
try:
with open(keys_path) as f:
keys = json.load(f)
for name in ("lu_key", "anthropic_milla", "anthropic_claude_code_main"):
val = keys.get(name, "")
if isinstance(val, str) and val.startswith("sk-ant-"):
return val
for section in ("anthropic", "anthropic_milla", "anthropic_claude_code_main"):
sec = keys.get(section, {})
if isinstance(sec, dict):
for subkey in ("lu_key", "key", "api_key"):
val = sec.get(subkey, "")
if isinstance(val, str) and val.startswith("sk-ant-"):
return val
except Exception:
pass
return ""
+3 -25
View File
@@ -2861,32 +2861,12 @@ def llm_rerank(
def _load_api_key(key_arg):
"""Load API key from --llm-key arg, env var, or ~/.config/lu/keys.json."""
"""Load API key from --llm-key arg or ANTHROPIC_API_KEY env var."""
if key_arg:
return key_arg
env_key = os.environ.get("ANTHROPIC_API_KEY", "")
if env_key:
return env_key
keys_path = os.path.expanduser("~/.config/lu/keys.json")
if os.path.exists(keys_path):
try:
with open(keys_path) as f:
keys = json.load(f)
# Flat string keys
for name in ("lu_key", "anthropic_milla", "anthropic_claude_code_main"):
val = keys.get(name, "")
if isinstance(val, str) and val.startswith("sk-ant-"):
return val
# Nested dict: keys["anthropic"]["lu_key"]
for section in ("anthropic", "anthropic_milla", "anthropic_claude_code_main"):
sec = keys.get(section, {})
if isinstance(sec, dict):
for subkey in ("lu_key", "key", "api_key"):
val = sec.get(subkey, "")
if isinstance(val, str) and val.startswith("sk-ant-"):
return val
except Exception:
pass
return ""
@@ -2970,8 +2950,7 @@ def run_benchmark(
if not api_key:
print(
"ERROR: --llm-rerank / --mode diary requires an API key. "
"Set ANTHROPIC_API_KEY, use --llm-key, "
"or store in ~/.config/lu/keys.json as 'lu_key'."
"Set ANTHROPIC_API_KEY or use --llm-key."
)
sys.exit(1)
@@ -3290,8 +3269,7 @@ if __name__ == "__main__":
parser.add_argument(
"--llm-key",
default="",
help="Anthropic API key for LLM re-ranking. Falls back to ANTHROPIC_API_KEY "
"env var or ~/.config/lu/keys.json 'lu_key' field if not provided.",
help="Anthropic API key for LLM re-ranking. Falls back to ANTHROPIC_API_KEY env var.",
)
parser.add_argument(
"--llm-model",
+1 -1
View File
@@ -13,7 +13,7 @@ On many Linux systems, installing Python packages globally is restricted. We rec
```bash
# Clone the repository (if you haven't already)
git clone https://github.com/milla-jovovich/mempalace.git
git clone https://github.com/MemPalace/mempalace.git
cd mempalace
# Create a virtual environment
+3 -3
View File
@@ -1,8 +1,8 @@
---
name: mempalace
description: "MemPalace — Local AI memory with 96.6% recall. Semantic search, temporal knowledge graph, palace architecture (wings/rooms/drawers). Free, no cloud, no API keys."
version: 3.1.0
homepage: https://github.com/milla-jovovich/mempalace
version: 3.2.0
homepage: https://github.com/MemPalace/mempalace
user-invocable: true
metadata:
openclaw:
@@ -151,4 +151,4 @@ claude mcp add mempalace -- python -m mempalace.mcp_server
## License
[MemPalace](https://github.com/milla-jovovich/mempalace) is MIT licensed. Created by Milla Jovovich, Ben Sigman, Igor Lins e Silva, and contributors.
[MemPalace](https://github.com/MemPalace/mempalace) is MIT licensed. Created by Milla Jovovich, Ben Sigman, Igor Lins e Silva, and contributors.
+13 -6
View File
@@ -1,8 +1,6 @@
"""MemPalace — Give your AI a memory. No API key required."""
import logging
import os
import platform
from .cli import main # noqa: E402
from .version import __version__ # noqa: E402
@@ -13,9 +11,18 @@ from .version import __version__ # noqa: E402
# 1 positional argument but 3 were given"). Silence just that logger.
logging.getLogger("chromadb.telemetry.product.posthog").setLevel(logging.CRITICAL)
# ONNX Runtime's CoreML provider segfaults during vector queries on Apple Silicon.
# Force CPU execution unless the user has explicitly set a preference.
if platform.machine() == "arm64" and platform.system() == "Darwin":
os.environ.setdefault("ORT_DISABLE_COREML", "1")
# NOTE: the previous block set ``ORT_DISABLE_COREML=1`` on macOS arm64 as a
# supposed workaround for the #74 ARM64 segfault. Two problems:
#
# 1. ONNX Runtime does not read that env var -- it has no global way to
# disable a single execution provider, so the setdefault was a no-op.
# 2. #74 is a null-pointer crash in ``chromadb_rust_bindings.abi3.so``, not
# an ONNX issue, so disabling CoreML would not have fixed it anyway.
#
# #521 has since traced the actual macOS arm64 crashes (both in mine and
# search paths) to the 0.x chromadb hnswlib binding. Filtering
# CoreMLExecutionProvider at the ONNX layer leaves the hnswlib C++ crash
# intact, so the real fix is upgrading chromadb to 1.5.4+, which #581
# proposes. See #397 for the history of this line.
__all__ = ["main", "__version__"]
+6
View File
@@ -0,0 +1,6 @@
"""Storage backend implementations for MemPalace."""
from .base import BaseCollection
from .chroma import ChromaBackend, ChromaCollection
__all__ = ["BaseCollection", "ChromaBackend", "ChromaCollection"]
+44
View File
@@ -0,0 +1,44 @@
"""Abstract collection interface for MemPalace storage backends."""
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
class BaseCollection(ABC):
"""Smallest collection contract the rest of MemPalace relies on."""
@abstractmethod
def add(
self,
*,
documents: List[str],
ids: List[str],
metadatas: Optional[List[Dict[str, Any]]] = None,
) -> None:
raise NotImplementedError
@abstractmethod
def upsert(
self,
*,
documents: List[str],
ids: List[str],
metadatas: Optional[List[Dict[str, Any]]] = None,
) -> None:
raise NotImplementedError
@abstractmethod
def query(self, **kwargs: Any) -> Dict[str, Any]:
raise NotImplementedError
@abstractmethod
def get(self, **kwargs: Any) -> Dict[str, Any]:
raise NotImplementedError
@abstractmethod
def delete(self, **kwargs: Any) -> None:
raise NotImplementedError
@abstractmethod
def count(self) -> int:
raise NotImplementedError
+91
View File
@@ -0,0 +1,91 @@
"""ChromaDB-backed MemPalace collection adapter."""
import logging
import os
import sqlite3
import chromadb
from .base import BaseCollection
logger = logging.getLogger(__name__)
def _fix_blob_seq_ids(palace_path: str):
"""Fix ChromaDB 0.6.x -> 1.5.x migration bug: BLOB seq_ids -> INTEGER.
ChromaDB 0.6.x stored seq_id as big-endian 8-byte BLOBs. ChromaDB 1.5.x
expects INTEGER. The auto-migration doesn't convert existing rows, causing
the Rust compactor to crash with "mismatched types; Rust type u64 (as SQL
type INTEGER) is not compatible with SQL type BLOB".
Must run BEFORE PersistentClient is created (the compactor fires on init).
"""
db_path = os.path.join(palace_path, "chroma.sqlite3")
if not os.path.isfile(db_path):
return
try:
with sqlite3.connect(db_path) as conn:
for table in ("embeddings", "max_seq_id"):
try:
rows = conn.execute(
f"SELECT rowid, seq_id FROM {table} WHERE typeof(seq_id) = 'blob'"
).fetchall()
except sqlite3.OperationalError:
continue
if not rows:
continue
updates = [(int.from_bytes(blob, byteorder="big"), rowid) for rowid, blob in rows]
conn.executemany(f"UPDATE {table} SET seq_id = ? WHERE rowid = ?", updates)
logger.info("Fixed %d BLOB seq_ids in %s", len(updates), table)
conn.commit()
except Exception:
logger.exception("Could not fix BLOB seq_ids in %s", db_path)
class ChromaCollection(BaseCollection):
"""Thin adapter over a ChromaDB collection."""
def __init__(self, collection):
self._collection = collection
def add(self, *, documents, ids, metadatas=None):
self._collection.add(documents=documents, ids=ids, metadatas=metadatas)
def upsert(self, *, documents, ids, metadatas=None):
self._collection.upsert(documents=documents, ids=ids, metadatas=metadatas)
def query(self, **kwargs):
return self._collection.query(**kwargs)
def get(self, **kwargs):
return self._collection.get(**kwargs)
def delete(self, **kwargs):
self._collection.delete(**kwargs)
def count(self):
return self._collection.count()
class ChromaBackend:
"""Factory for MemPalace's default ChromaDB backend."""
def get_collection(self, palace_path: str, collection_name: str, create: bool = False):
if not create and not os.path.isdir(palace_path):
raise FileNotFoundError(palace_path)
if create:
os.makedirs(palace_path, exist_ok=True)
try:
os.chmod(palace_path, 0o700)
except (OSError, NotImplementedError):
pass
_fix_blob_seq_ids(palace_path)
client = chromadb.PersistentClient(path=palace_path)
if create:
collection = client.get_or_create_collection(collection_name)
else:
collection = client.get_collection(collection_name)
return ChromaCollection(collection)
+34 -11
View File
@@ -134,7 +134,8 @@ def cmd_split(args):
import sys
# Rebuild argv for split_mega_files argparse
argv = ["--source", args.dir]
# Expand ~ and resolve to absolute path so split_mega_files sees a real path
argv = ["--source", str(Path(args.dir).expanduser().resolve())]
if args.output_dir:
argv += ["--output-dir", args.output_dir]
if args.dry_run:
@@ -155,7 +156,7 @@ def cmd_migrate(args):
from .migrate import migrate
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
migrate(palace_path=palace_path, dry_run=args.dry_run)
migrate(palace_path=palace_path, dry_run=args.dry_run, confirm=getattr(args, "yes", False))
def cmd_status(args):
@@ -169,12 +170,19 @@ def cmd_repair(args):
"""Rebuild palace vector index from SQLite metadata."""
import chromadb
import shutil
from .migrate import confirm_destructive_action, contains_palace_database
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
palace_path = os.path.abspath(
os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
)
db_path = os.path.join(palace_path, "chroma.sqlite3")
if not os.path.isdir(palace_path):
print(f"\n No palace found at {palace_path}")
return
if not contains_palace_database(palace_path):
print(f"\n No palace database found at {db_path}")
return
print(f"\n{'=' * 55}")
print(" MemPalace Repair")
@@ -196,6 +204,11 @@ def cmd_repair(args):
print(" Nothing to repair.")
return
if not confirm_destructive_action(
"Repair", palace_path, assume_yes=getattr(args, "yes", False)
):
return
# Extract all drawers in batches
print("\n Extracting drawers...")
batch_size = 5000
@@ -212,9 +225,15 @@ def cmd_repair(args):
print(f" Extracted {len(all_ids)} drawers")
# Backup and rebuild
palace_path = palace_path.rstrip(os.sep)
palace_path = os.path.normpath(palace_path)
backup_path = palace_path + ".backup"
if os.path.exists(backup_path):
if not contains_palace_database(backup_path):
print(
" Backup validation failed: backup path exists but does not contain chroma.sqlite3. "
f"Please remove or rename: {backup_path}"
)
return
shutil.rmtree(backup_path)
print(f" Backing up to {backup_path}...")
shutil.copytree(palace_path, backup_path)
@@ -349,7 +368,7 @@ def cmd_compress(args):
stats = dialect.compression_stats(doc, compressed)
total_original += stats["original_chars"]
total_compressed += stats["compressed_chars"]
total_compressed += stats["summary_chars"]
compressed_entries.append((doc_id, compressed, meta, stats))
@@ -359,7 +378,7 @@ def cmd_compress(args):
source = Path(meta.get("source_file", "?")).name
print(f" [{wing_name}/{room_name}] {source}")
print(
f" {stats['original_tokens']}t -> {stats['compressed_tokens']}t ({stats['ratio']:.1f}x)"
f" {stats['original_tokens_est']}t -> {stats['summary_tokens_est']}t ({stats['size_ratio']:.1f}x)"
)
print(f" {compressed}")
print()
@@ -370,8 +389,8 @@ def cmd_compress(args):
comp_col = client.get_or_create_collection("mempalace_compressed")
for doc_id, compressed, meta, stats in compressed_entries:
comp_meta = dict(meta)
comp_meta["compression_ratio"] = round(stats["ratio"], 1)
comp_meta["original_tokens"] = stats["original_tokens"]
comp_meta["compression_ratio"] = round(stats["size_ratio"], 1)
comp_meta["original_tokens"] = stats["original_tokens_est"]
comp_col.upsert(
ids=[doc_id],
documents=[compressed],
@@ -386,8 +405,9 @@ def cmd_compress(args):
# Summary
ratio = total_original / max(total_compressed, 1)
orig_tokens = Dialect.count_tokens("x" * total_original)
comp_tokens = Dialect.count_tokens("x" * total_compressed)
# Estimate tokens from char count (~3.8 chars/token for English text)
orig_tokens = max(1, int(total_original / 3.8))
comp_tokens = max(1, int(total_compressed / 3.8))
print(f" Total: {orig_tokens:,}t -> {comp_tokens:,}t ({ratio:.1f}x compression)")
if args.dry_run:
print(" (dry run -- nothing stored)")
@@ -530,7 +550,7 @@ def main():
sub.add_parser(
"repair",
help="Rebuild palace vector index from stored data (fixes segfaults after corruption)",
)
).add_argument("--yes", action="store_true", help="Skip confirmation for destructive changes")
# mcp
sub.add_parser(
@@ -549,6 +569,9 @@ def main():
action="store_true",
help="Show what would be migrated without changing anything",
)
p_migrate.add_argument(
"--yes", action="store_true", help="Skip confirmation for destructive changes"
)
sub.add_parser("status", help="Show what's been filed")
+22 -1
View File
@@ -16,7 +16,7 @@ from pathlib import Path
# in file paths, SQLite, or ChromaDB metadata.
MAX_NAME_LENGTH = 128
_SAFE_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_ .'-]{0,126}[a-zA-Z0-9]?$")
_SAFE_NAME_RE = re.compile(r"^(?:[^\W_]|[^\W_][\w .'-]{0,126}[^\W_])$")
def sanitize_name(value: str, field_name: str = "name") -> str:
@@ -173,6 +173,27 @@ class MempalaceConfig:
"""Mapping of hall names to keyword lists."""
return self._file_config.get("hall_keywords", DEFAULT_HALL_KEYWORDS)
@property
def hook_silent_save(self):
"""Whether the stop hook saves directly (True) or blocks for MCP calls (False)."""
return self._file_config.get("hooks", {}).get("silent_save", True)
@property
def hook_desktop_toast(self):
"""Whether the stop hook shows a desktop notification via notify-send."""
return self._file_config.get("hooks", {}).get("desktop_toast", False)
def set_hook_setting(self, key: str, value: bool):
"""Update a hook setting and write config to disk."""
if "hooks" not in self._file_config:
self._file_config["hooks"] = {}
self._file_config["hooks"][key] = value
try:
with open(self._config_file, "w", encoding="utf-8") as f:
json.dump(self._file_config, f, indent=2, ensure_ascii=False)
except OSError:
pass
def init(self):
"""Create config directory and write default config.json if it doesn't exist."""
self._config_dir.mkdir(parents=True, exist_ok=True)
+52 -3
View File
@@ -28,9 +28,34 @@ CONVO_EXTENSIONS = {
}
MIN_CHUNK_SIZE = 30
CHUNK_SIZE = 800 # chars per drawer — align with miner.py
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB — skip files larger than this
def _register_file(collection, source_file: str, wing: str, agent: str):
"""Write a sentinel so file_already_mined() returns True for 0-chunk files.
Without this, files that normalize to nothing or produce zero chunks are
re-read and re-processed on every mine run because nothing was written to
ChromaDB on the first pass.
"""
sentinel_id = f"_reg_{hashlib.sha256(source_file.encode()).hexdigest()[:24]}"
collection.upsert(
documents=[f"[registry] {source_file}"],
ids=[sentinel_id],
metadatas=[
{
"wing": wing,
"room": "_registry",
"source_file": source_file,
"added_by": agent,
"filed_at": datetime.now().isoformat(),
"ingest_mode": "registry",
}
],
)
# =============================================================================
# CHUNKING — exchange pairs for conversations
# =============================================================================
@@ -51,7 +76,12 @@ def chunk_exchanges(content: str) -> list:
def _chunk_by_exchange(lines: list) -> list:
"""One user turn (>) + the AI response that follows = one chunk."""
"""One user turn (>) + the AI response that follows = one or more chunks.
The full AI response is preserved verbatim. When the combined
user-turn + response exceeds CHUNK_SIZE the response is split across
consecutive drawers so nothing is silently discarded.
"""
chunks = []
i = 0
@@ -70,10 +100,23 @@ def _chunk_by_exchange(lines: list) -> list:
ai_lines.append(next_line.strip())
i += 1
ai_response = " ".join(ai_lines[:8])
ai_response = " ".join(ai_lines)
content = f"{user_turn}\n{ai_response}" if ai_response else user_turn
if len(content.strip()) > MIN_CHUNK_SIZE:
# Split into multiple drawers when the exchange exceeds CHUNK_SIZE
if len(content) > CHUNK_SIZE:
# First chunk: user turn + as much response as fits
first_part = content[:CHUNK_SIZE]
if len(first_part.strip()) > MIN_CHUNK_SIZE:
chunks.append({"content": first_part, "chunk_index": len(chunks)})
# Remaining response in CHUNK_SIZE-sized continuation drawers
remainder = content[CHUNK_SIZE:]
while remainder:
part = remainder[:CHUNK_SIZE]
remainder = remainder[CHUNK_SIZE:]
if len(part.strip()) > MIN_CHUNK_SIZE:
chunks.append({"content": part, "chunk_index": len(chunks)})
elif len(content.strip()) > MIN_CHUNK_SIZE:
chunks.append(
{
"content": content,
@@ -282,9 +325,13 @@ def mine_convos(
try:
content = normalize(str(filepath))
except (OSError, ValueError):
if not dry_run:
_register_file(collection, source_file, wing, agent)
continue
if not content or len(content.strip()) < MIN_CHUNK_SIZE:
if not dry_run:
_register_file(collection, source_file, wing, agent)
continue
# Chunk — either exchange pairs or general extraction
@@ -297,6 +344,8 @@ def mine_convos(
chunks = chunk_exchanges(content)
if not chunks:
if not dry_run:
_register_file(collection, source_file, wing, agent)
continue
# Detect room from content (general mode uses memory_type instead)
+15 -1
View File
@@ -317,13 +317,17 @@ class Dialect:
dialect.generate_layer1("zettels/", output="LAYER1.aaak")
"""
def __init__(self, entities: Dict[str, str] = None, skip_names: List[str] = None):
def __init__(
self, entities: Dict[str, str] = None, skip_names: List[str] = None, lang: str = None
):
"""
Args:
entities: Mapping of full names -> short codes.
e.g. {"Alice": "ALC", "Bob": "BOB"}
If None, entities are auto-coded from first 3 chars.
skip_names: Names to skip (fictional characters, etc.)
lang: Language code (e.g. "fr", "ko"). Loads AAAK instruction
and regex patterns from i18n dictionary.
"""
self.entity_codes = {}
if entities:
@@ -332,6 +336,15 @@ class Dialect:
self.entity_codes[name.lower()] = code
self.skip_names = [n.lower() for n in (skip_names or [])]
# Load language-specific AAAK instruction and regex patterns
from mempalace.i18n import load_lang, t, current_lang, get_regex
if lang:
load_lang(lang)
self.lang = lang or current_lang()
self.aaak_instruction = t("aaak.instruction")
self.lang_regex = get_regex()
@classmethod
def from_config(cls, config_path: str) -> "Dialect":
"""Load entity mappings from a JSON config file.
@@ -347,6 +360,7 @@ class Dialect:
return cls(
entities=config.get("entities", {}),
skip_names=config.get("skip_names", []),
lang=config.get("lang"),
)
def save_config(self, config_path: str):
+1 -1
View File
@@ -760,7 +760,7 @@ def confirm_entities(detected: dict, yes: bool = False) -> dict:
if detected["uncertain"]:
print("\n Uncertain entities — classify each:")
for e in detected["uncertain"]:
ans = input(f" {e['name']} — (p)erson, (r)roject, or (s)kip? ").strip().lower()
ans = input(f" {e['name']} — (p)erson, (r)project, or (s)kip? ").strip().lower()
if ans == "p":
confirmed_people.append(e["name"])
elif ans == "r":
+161
View File
@@ -0,0 +1,161 @@
"""
exporter.py — Export the palace as a browsable folder of markdown files.
Produces:
output_dir/
index.md — table of contents
wing_name/
room_name.md — one file per room, drawers as sections
Streams drawers in paginated batches so memory usage stays bounded
regardless of palace size.
"""
import os
import re
from collections import defaultdict
from datetime import datetime
from .palace import get_collection
def _safe_path_component(name: str) -> str:
"""Sanitize a string for use as a directory/file name component."""
name = re.sub(r'[/\\:*?"<>|]', "_", name)
name = name.strip(". ")
return name or "unknown"
def export_palace(palace_path: str, output_dir: str, format: str = "markdown") -> dict:
"""Export all palace drawers as markdown files organized by wing/room.
Streams drawers in batches of 1000 and writes each wing/room file
incrementally, keeping memory usage proportional to batch size rather
than total palace size.
Args:
palace_path: Path to the ChromaDB palace directory.
output_dir: Where to write the exported markdown tree.
format: Output format (currently only "markdown").
Returns:
Stats dict: {"wings": N, "rooms": N, "drawers": N}
"""
col = get_collection(palace_path)
total = col.count()
if total == 0:
print(" Palace is empty — nothing to export.")
return {"wings": 0, "rooms": 0, "drawers": 0}
os.makedirs(output_dir, exist_ok=True)
# Track which room files have been opened (so we can append vs overwrite)
opened_rooms: set[tuple[str, str]] = set()
# Track stats per wing: {wing: {room: count}}
wing_stats: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
total_drawers = 0
print(f" Streaming {total} drawers...")
offset = 0
while offset < total:
batch = col.get(limit=1000, offset=offset, include=["documents", "metadatas"])
if not batch["ids"]:
break
# Group this batch by wing/room so we do one file write per room per batch
batch_grouped: dict[str, dict[str, list]] = defaultdict(lambda: defaultdict(list))
for doc_id, doc, meta in zip(batch["ids"], batch["documents"], batch["metadatas"]):
wing = meta.get("wing", "unknown")
room = meta.get("room", "general")
batch_grouped[wing][room].append(
{
"id": doc_id,
"content": doc,
"source": meta.get("source_file", ""),
"filed_at": meta.get("filed_at", ""),
"added_by": meta.get("added_by", ""),
}
)
# Write/append each room file
for wing, rooms in batch_grouped.items():
safe_wing = _safe_path_component(wing)
wing_dir = os.path.join(output_dir, safe_wing)
os.makedirs(wing_dir, exist_ok=True)
for room, drawers in rooms.items():
safe_room = _safe_path_component(room)
room_path = os.path.join(wing_dir, f"{safe_room}.md")
key = (wing, room)
is_new = key not in opened_rooms
with open(room_path, "a" if not is_new else "w", encoding="utf-8") as f:
if is_new:
f.write(f"# {wing} / {room}\n\n")
opened_rooms.add(key)
for drawer in drawers:
source = drawer["source"] or "unknown"
filed = drawer["filed_at"] or "unknown"
added_by = drawer["added_by"] or "unknown"
f.write(
f"## {drawer['id']}\n"
f"\n"
f"> {_quote_content(drawer['content'])}\n"
f"\n"
f"| Field | Value |\n"
f"|-------|-------|\n"
f"| Source | {source} |\n"
f"| Filed | {filed} |\n"
f"| Added by | {added_by} |\n"
f"\n"
f"---\n\n"
)
wing_stats[wing][room] += len(drawers)
total_drawers += len(drawers)
offset += len(batch["ids"])
# Build and print stats
index_rows = []
for wing in sorted(wing_stats):
rooms = wing_stats[wing]
wing_drawer_count = sum(rooms.values())
index_rows.append((wing, len(rooms), wing_drawer_count))
print(f" {wing}: {len(rooms)} rooms, {wing_drawer_count} drawers")
# Write index.md
today = datetime.now().strftime("%Y-%m-%d")
index_lines = [
f"# Palace Export — {today}\n",
"",
"| Wing | Rooms | Drawers |",
"|------|-------|---------|",
]
for wing, room_count, drawer_count in index_rows:
index_lines.append(f"| [{wing}]({wing}/) | {room_count} | {drawer_count} |")
index_lines.append("")
index_path = os.path.join(output_dir, "index.md")
with open(index_path, "w", encoding="utf-8") as f:
f.write("\n".join(index_lines))
stats = {
"wings": len(wing_stats),
"rooms": sum(r for _, r, _ in index_rows),
"drawers": total_drawers,
}
print(
f"\n Exported {stats['drawers']} drawers across {stats['wings']} wings, {stats['rooms']} rooms"
)
print(f" Output: {output_dir}")
return stats
def _quote_content(text: str) -> str:
"""Format content for a markdown blockquote, handling multiline."""
lines = text.rstrip("\n").split("\n")
return "\n> ".join(lines)
+76
View File
@@ -0,0 +1,76 @@
"""i18n — Language dictionaries for MemPalace.
Usage:
from mempalace.i18n import load_lang, t
load_lang("fr") # load French
print(t("cli.mine_start", path="/docs")) # "Extraction de /docs..."
print(t("terms.wing")) # "aile"
print(t("aaak.instruction")) # AAAK compression instruction in French
"""
import json
from pathlib import Path
_LANG_DIR = Path(__file__).parent
_strings: dict = {}
_current_lang: str = "en"
def available_languages() -> list[str]:
"""Return list of available language codes."""
return sorted(p.stem for p in _LANG_DIR.glob("*.json"))
def load_lang(lang: str = "en") -> dict:
"""Load a language dictionary. Falls back to English if not found."""
global _strings, _current_lang
lang_file = _LANG_DIR / f"{lang}.json"
if not lang_file.exists():
lang_file = _LANG_DIR / "en.json"
lang = "en"
_strings = json.loads(lang_file.read_text(encoding="utf-8"))
_current_lang = lang
return _strings
def t(key: str, **kwargs) -> str:
"""Get a translated string by dotted key. Supports {var} interpolation.
t("cli.mine_complete", closets=5, drawers=20)
"Done. 5 closets, 20 drawers created."
"""
if not _strings:
load_lang("en")
parts = key.split(".", 1)
if len(parts) == 2:
section, name = parts
val = _strings.get(section, {}).get(name, key)
else:
val = _strings.get(key, key)
if kwargs and isinstance(val, str):
try:
val = val.format(**kwargs)
except (KeyError, IndexError):
pass
return val
def current_lang() -> str:
"""Return current language code."""
return _current_lang
def get_regex() -> dict:
"""Return the regex patterns for the current language.
Keys: topic_pattern, stop_words, quote_pattern, action_pattern.
Returns empty dict if no regex section in the language file.
"""
if not _strings:
load_lang("en")
return _strings.get("regex", {})
# Auto-load English on import
load_lang("en")
+44
View File
@@ -0,0 +1,44 @@
{
"lang": "de",
"label": "Deutsch",
"terms": {
"palace": "Palast",
"wing": "Flügel",
"hall": "Flur",
"closet": "Schrank",
"drawer": "Schublade",
"mine": "schürfen",
"search": "suchen",
"status": "Status",
"init": "initialisieren",
"repair": "reparieren",
"migrate": "migrieren",
"entity": "Entität",
"topic": "Thema"
},
"cli": {
"mine_start": "Schürfe {path}...",
"mine_complete": "Fertig. {closets} Schränke, {drawers} Schubladen erstellt.",
"mine_skip": "Bereits geschürft. Verwenden Sie --force zum Wiederholen.",
"search_no_results": "Keine Ergebnisse für: {query}",
"search_results": "{count} Ergebnisse gefunden:",
"status_palace": "Palast: {path}",
"status_wings": "{count} Flügel",
"status_closets": "{count} Schränke",
"status_drawers": "{count} Schubladen",
"init_complete": "Palast initialisiert in {path}",
"init_exists": "Palast existiert bereits in {path}",
"repair_complete": "Reparatur abgeschlossen. {fixed} Probleme behoben.",
"migrate_complete": "Migration abgeschlossen.",
"no_palace": "Kein Palast gefunden. Ausführen: mempalace init <Ordner>"
},
"aaak": {
"instruction": "Auf Deutsch komprimieren. Bindestriche zwischen Wörtern, Pipes zwischen Konzepten. Artikel und Füllwörter weglassen. Eigennamen und Zahlen exakt beibehalten."
},
"regex": {
"topic_pattern": "[A-ZÄÖÜß][a-zäöüß]{2,}|[A-Za-zÄÖÜäöüß]{3,}",
"stop_words": "der die das ein eine eines einer einem einen den dem des und oder aber denn weil wenn als ob auch noch schon sehr viel nur nicht mehr kann wird hat ist sind war waren sein haben wurde mit von zu für auf in an um über nach durch",
"quote_pattern": "\\u201E([^\\u201C]{10,200})\\u201C|\"([^\"]{10,200})\"",
"action_pattern": "(?:gebaut|behoben|geschrieben|hinzugefügt|gepusht|gemessen|getestet|überprüft|erstellt|gelöscht|aktualisiert|konfiguriert|bereitgestellt|migriert)\\s+[\\wÄÖÜäöüß\\s]{3,30}"
}
}
+44
View File
@@ -0,0 +1,44 @@
{
"lang": "en",
"label": "English",
"terms": {
"palace": "palace",
"wing": "wing",
"hall": "hall",
"closet": "closet",
"drawer": "drawer",
"mine": "mine",
"search": "search",
"status": "status",
"init": "init",
"repair": "repair",
"migrate": "migrate",
"entity": "entity",
"topic": "topic"
},
"cli": {
"mine_start": "Mining {path}...",
"mine_complete": "Done. {closets} closets, {drawers} drawers created.",
"mine_skip": "Already mined. Use --force to re-mine.",
"search_no_results": "No results for: {query}",
"search_results": "Found {count} results:",
"status_palace": "Palace: {path}",
"status_wings": "{count} wings",
"status_closets": "{count} closets",
"status_drawers": "{count} drawers",
"init_complete": "Palace initialized at {path}",
"init_exists": "Palace already exists at {path}",
"repair_complete": "Repair complete. {fixed} issues fixed.",
"migrate_complete": "Migration complete.",
"no_palace": "No palace found. Run: mempalace init <dir>"
},
"aaak": {
"instruction": "Compress to index format. Hyphens between words, pipes between concepts. Drop articles and filler. Keep names and numbers exact."
},
"regex": {
"topic_pattern": "[A-Z][a-z]{2,}|[A-Za-z][A-Za-z0-9_]{2,}",
"stop_words": "the this that these those some many most each every other only such very will would could should must shall yeah okay also even then now already still back done make take give know think want need going come find work added saved session summary conversation topics source about once just really actually here there where good great better thank please sorry right wrong true false",
"quote_pattern": "\"([^\"]{20,200})\"",
"action_pattern": "(?:built|fixed|wrote|added|pushed|measured|tested|reviewed|created|deleted|updated|configured|deployed|migrated)\\s+[\\w\\s]{3,30}"
}
}
+44
View File
@@ -0,0 +1,44 @@
{
"lang": "es",
"label": "Español",
"terms": {
"palace": "palacio",
"wing": "ala",
"hall": "pasillo",
"closet": "armario",
"drawer": "cajón",
"mine": "extraer",
"search": "buscar",
"status": "estado",
"init": "inicializar",
"repair": "reparar",
"migrate": "migrar",
"entity": "entidad",
"topic": "tema"
},
"cli": {
"mine_start": "Extrayendo {path}...",
"mine_complete": "Listo. {closets} armarios, {drawers} cajones creados.",
"mine_skip": "Ya extraído. Use --force para repetir.",
"search_no_results": "Sin resultados para: {query}",
"search_results": "{count} resultados encontrados:",
"status_palace": "Palacio: {path}",
"status_wings": "{count} alas",
"status_closets": "{count} armarios",
"status_drawers": "{count} cajones",
"init_complete": "Palacio inicializado en {path}",
"init_exists": "Ya existe un palacio en {path}",
"repair_complete": "Reparación completa. {fixed} problemas corregidos.",
"migrate_complete": "Migración completa.",
"no_palace": "No se encontró palacio. Ejecute: mempalace init <carpeta>"
},
"aaak": {
"instruction": "Comprima en español. Guiones entre palabras, pipes entre conceptos. Elimine artículos y palabras de relleno. Mantenga nombres propios y números exactos."
},
"regex": {
"topic_pattern": "[A-ZÁ-Ú][a-zá-ú]{2,}|[A-Za-zÁ-ú]{3,}",
"stop_words": "el la los las un una unos unas de del al en con por para su sus mi mis tu tus es son está están fue ser estar haber sido como pero más muy también todo todos toda todas este esta estos estas ese esa esos esas que quien cual donde cuando porque aunque sin",
"quote_pattern": "\"([^\"]{10,200})\"|«([^»]{10,200})»",
"action_pattern": "(?:construido|corregido|escrito|añadido|enviado|medido|probado|revisado|creado|eliminado|actualizado|configurado|desplegado|migrado)\\s+[\\wá-ú\\s]{3,30}"
}
}
+44
View File
@@ -0,0 +1,44 @@
{
"lang": "fr",
"label": "Français",
"terms": {
"palace": "palais",
"wing": "aile",
"hall": "couloir",
"closet": "placard",
"drawer": "tiroir",
"mine": "extraire",
"search": "chercher",
"status": "état",
"init": "initialiser",
"repair": "réparer",
"migrate": "migrer",
"entity": "entité",
"topic": "sujet"
},
"cli": {
"mine_start": "Extraction de {path}...",
"mine_complete": "Terminé. {closets} placards, {drawers} tiroirs créés.",
"mine_skip": "Déjà extrait. Utilisez --force pour refaire.",
"search_no_results": "Aucun résultat pour : {query}",
"search_results": "{count} résultats trouvés :",
"status_palace": "Palais : {path}",
"status_wings": "{count} ailes",
"status_closets": "{count} placards",
"status_drawers": "{count} tiroirs",
"init_complete": "Palais initialisé dans {path}",
"init_exists": "Un palais existe déjà dans {path}",
"repair_complete": "Réparation terminée. {fixed} problèmes corrigés.",
"migrate_complete": "Migration terminée.",
"no_palace": "Aucun palais trouvé. Exécutez : mempalace init <dossier>"
},
"aaak": {
"instruction": "Comprimez en français. Tirets entre les mots, pipes entre les concepts. Supprimez les articles et mots de remplissage. Gardez les noms propres et chiffres exacts."
},
"regex": {
"topic_pattern": "[A-ZÀ-Ý][a-zà-ÿ]{2,}|[A-Za-zÀ-ÿ]{3,}",
"stop_words": "le la les un une des de du au aux en et ou mais donc or ni car que qui ce cette ces son sa ses mon ma mes ton ta tes leur leurs nous vous ils elles on ne pas plus très bien aussi avec pour dans sur par est sont fait être avoir été comme tout tous toute toutes",
"quote_pattern": "«\\s*([^»]{10,200})\\s*»|\"([^\"]{10,200})\"",
"action_pattern": "(?:construit|corrigé|écrit|ajouté|poussé|mesuré|testé|révisé|créé|supprimé|mis à jour|configuré|déployé|migré)\\s+[\\wà-ÿ\\s]{3,30}"
}
}
+44
View File
@@ -0,0 +1,44 @@
{
"lang": "ja",
"label": "日本語",
"terms": {
"palace": "宮殿",
"wing": "棟",
"hall": "廊下",
"closet": "クローゼット",
"drawer": "引き出し",
"mine": "採掘",
"search": "検索",
"status": "状態",
"init": "初期化",
"repair": "修復",
"migrate": "移行",
"entity": "エンティティ",
"topic": "トピック"
},
"cli": {
"mine_start": "{path} を採掘中...",
"mine_complete": "完了。クローゼット {closets}個、引き出し {drawers}個 作成。",
"mine_skip": "採掘済み。再実行するには --force を使用。",
"search_no_results": "結果なし: {query}",
"search_results": "{count}件の結果:",
"status_palace": "宮殿: {path}",
"status_wings": "棟 {count}個",
"status_closets": "クローゼット {count}個",
"status_drawers": "引き出し {count}個",
"init_complete": "{path} に宮殿を初期化しました",
"init_exists": "{path} に宮殿は既に存在します",
"repair_complete": "修復完了。{fixed}件の問題を修正。",
"migrate_complete": "移行完了。",
"no_palace": "宮殿が見つかりません。実行: mempalace init <ディレクトリ>"
},
"aaak": {
"instruction": "日本語で圧縮してください。概念間はパイプ(|)、単語間はハイフン(-)。助詞と接続詞は省略。固有名詞と数値は正確に保持。"
},
"regex": {
"topic_pattern": "[\\u30A0-\\u30FF]{3,}|[\\u4E00-\\u9FFF]{2,}|[A-Za-z][A-Za-z0-9_]{2,}",
"stop_words": "は が を に で と も の へ から まで より した します している されて です ます ました こと もの ため それ これ その この あの ない なく ある いる する",
"quote_pattern": "「([^」]{10,100})」",
"action_pattern": "(構築|修正|追加|削除|確認|作成|実装|修復|書き直し|テスト|検証|更新|設定|起動|停止)(?:し|した|して|する|します)"
}
}
+44
View File
@@ -0,0 +1,44 @@
{
"lang": "ko",
"label": "한국어",
"terms": {
"palace": "궁전",
"wing": "날개",
"hall": "복도",
"closet": "벽장",
"drawer": "서랍",
"mine": "채굴",
"search": "검색",
"status": "상태",
"init": "초기화",
"repair": "수리",
"migrate": "마이그레이션",
"entity": "개체",
"topic": "주제"
},
"cli": {
"mine_start": "{path} 채굴 중...",
"mine_complete": "완료. 벽장 {closets}개, 서랍 {drawers}개 생성.",
"mine_skip": "이미 채굴됨. --force로 다시 실행하세요.",
"search_no_results": "결과 없음: {query}",
"search_results": "{count}개 결과 발견:",
"status_palace": "궁전: {path}",
"status_wings": "날개 {count}개",
"status_closets": "벽장 {count}개",
"status_drawers": "서랍 {drawers}개",
"init_complete": "{path}에 궁전 초기화 완료",
"init_exists": "{path}에 궁전이 이미 존재합니다",
"repair_complete": "수리 완료. {fixed}개 문제 해결.",
"migrate_complete": "마이그레이션 완료.",
"no_palace": "궁전을 찾을 수 없습니다. 실행: mempalace init <폴더>"
},
"aaak": {
"instruction": "한국어로 압축하세요. 개념 사이에 파이프(|), 단어 연결에 하이픈(-). 조사와 접속사는 생략. 고유명사와 숫자는 정확히 유지."
},
"regex": {
"topic_pattern": "[가-힣]{2,}|[A-Za-z][A-Za-z0-9_]{2,}",
"stop_words": "은 는 이 가 을 를 에 에서 의 로 으로 와 과 도 만 까지 부터 처럼 보다 한 하는 했다 합니다 했습니다 되었 있는 것 수 등 및 또는 그리고 하지만 때문에",
"quote_pattern": "\"([^\"]{10,100})\"|'([^']{10,100})'",
"action_pattern": "(구축|수정|추가|삭제|확인|생성|구현|수리|작성|테스트|검증|업데이트|설정|시작|중지)(?:했|한|하여|합니다|했습니다)"
}
}
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Quick smoke test for i18n dictionaries + Dialect integration."""
import sys
from pathlib import Path
# Add parent to path so we can import mempalace
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from mempalace.i18n import load_lang, t, available_languages
from mempalace.dialect import Dialect
def test_all_languages_load():
"""Every JSON file loads without error and has required keys."""
required_sections = ["terms", "cli", "aaak"]
required_terms = ["palace", "wing", "closet", "drawer"]
langs = available_languages()
assert len(langs) >= 7, f"Expected 7+ languages, got {len(langs)}"
for lang in langs:
strings = load_lang(lang)
for section in required_sections:
assert section in strings, f"{lang}: missing section '{section}'"
for term in required_terms:
assert term in strings["terms"], f"{lang}: missing term '{term}'"
assert len(strings["terms"][term]) > 0, f"{lang}: empty term '{term}'"
assert "instruction" in strings["aaak"], f"{lang}: missing aaak.instruction"
print(f" PASS: {len(langs)} languages load correctly")
def test_interpolation():
"""String interpolation works for all languages."""
for lang in available_languages():
load_lang(lang)
result = t("cli.mine_complete", closets=5, drawers=100)
assert "5" in result, f"{lang}: closets count missing from mine_complete"
assert "100" in result, f"{lang}: drawers count missing from mine_complete"
print(" PASS: interpolation works for all languages")
def test_dialect_loads_lang():
"""Dialect class picks up the language instruction."""
for lang in available_languages():
d = Dialect(lang=lang)
assert d.lang == lang, f"Expected lang={lang}, got {d.lang}"
assert len(d.aaak_instruction) > 10, f"{lang}: AAAK instruction too short"
print(" PASS: Dialect loads language instruction for all languages")
def test_dialect_compress_samples():
"""Compress sample text in different languages, verify output isn't empty."""
samples = {
"en": "We decided to migrate from SQLite to PostgreSQL for better concurrent writes. Ben approved the PR yesterday.",
"fr": "Nous avons décidé de migrer de SQLite vers PostgreSQL pour une meilleure écriture concurrente. Ben a approuvé le PR hier.",
"ko": "더 나은 동시 쓰기를 위해 SQLite에서 PostgreSQL로 마이그레이션하기로 했습니다. 벤이 어제 PR을 승인했습니다.",
"ja": "同時書き込みの改善のため、SQLiteからPostgreSQLに移行することを決定しました。ベンが昨日PRを承認しました。",
"es": "Decidimos migrar de SQLite a PostgreSQL para mejor escritura concurrente. Ben aprobó el PR ayer.",
"de": "Wir haben beschlossen, von SQLite auf PostgreSQL zu migrieren für bessere gleichzeitige Schreibvorgänge. Ben hat den PR gestern genehmigt.",
"zh-CN": "我们决定从SQLite迁移到PostgreSQL以获得更好的并发写入。Ben昨天批准了PR。",
}
for lang, text in samples.items():
d = Dialect(lang=lang)
compressed = d.compress(text)
assert len(compressed) > 0, f"{lang}: compression returned empty"
assert len(compressed) < len(text) * 2, f"{lang}: compression expanded text"
print(f" {lang}: {len(text)} chars → {len(compressed)} chars")
print(f" {compressed[:80]}")
print(" PASS: compression works for all sample languages")
if __name__ == "__main__":
print("i18n smoke tests:")
test_all_languages_load()
test_interpolation()
test_dialect_loads_lang()
test_dialect_compress_samples()
print("\nAll tests passed.")
+44
View File
@@ -0,0 +1,44 @@
{
"lang": "zh-CN",
"label": "简体中文",
"terms": {
"palace": "宫殿",
"wing": "翼",
"hall": "走廊",
"closet": "柜子",
"drawer": "抽屉",
"mine": "挖掘",
"search": "搜索",
"status": "状态",
"init": "初始化",
"repair": "修复",
"migrate": "迁移",
"entity": "实体",
"topic": "主题"
},
"cli": {
"mine_start": "正在挖掘 {path}...",
"mine_complete": "完成。创建了 {closets} 个柜子、{drawers} 个抽屉。",
"mine_skip": "已挖掘。使用 --force 重新执行。",
"search_no_results": "未找到结果: {query}",
"search_results": "找到 {count} 个结果:",
"status_palace": "宫殿: {path}",
"status_wings": "{count} 个翼",
"status_closets": "{count} 个柜子",
"status_drawers": "{count} 个抽屉",
"init_complete": "宫殿已初始化于 {path}",
"init_exists": "{path} 中已存在宫殿",
"repair_complete": "修复完成。已修正 {fixed} 个问题。",
"migrate_complete": "迁移完成。",
"no_palace": "未找到宫殿。请运行: mempalace init <目录>"
},
"aaak": {
"instruction": "用中文压缩。概念之间用管道符(|),词语之间用连字符(-)。省略虚词和连接词。保留专有名词和数字的准确性。"
},
"regex": {
"topic_pattern": "[\\u4E00-\\u9FFF]{2,}|[A-Za-z][A-Za-z0-9_]{2,}",
"stop_words": "的 了 在 是 我 有 和 就 不 人 都 一 一个 上 也 很 到 说 要 去 你 会 着 没有 看 好 自己 这 那 她 他 它 们 但是 因为 所以 如果 虽然 然后 或者 而且",
"quote_pattern": "\\u201C([^\\u201D]{10,100})\\u201D|\"([^\"]{10,200})\"",
"action_pattern": "(构建|修复|添加|删除|确认|创建|实现|修理|编写|测试|验证|更新|配置|启动|停止)(?:了|完成|成功)"
}
}
+44
View File
@@ -0,0 +1,44 @@
{
"lang": "zh-TW",
"label": "繁體中文",
"terms": {
"palace": "宮殿",
"wing": "翼",
"hall": "走廊",
"closet": "櫃子",
"drawer": "抽屜",
"mine": "挖掘",
"search": "搜尋",
"status": "狀態",
"init": "初始化",
"repair": "修復",
"migrate": "遷移",
"entity": "實體",
"topic": "主題"
},
"cli": {
"mine_start": "正在挖掘 {path}...",
"mine_complete": "完成。建立了 {closets} 個櫃子、{drawers} 個抽屜。",
"mine_skip": "已挖掘。使用 --force 重新執行。",
"search_no_results": "未找到結果: {query}",
"search_results": "找到 {count} 個結果:",
"status_palace": "宮殿: {path}",
"status_wings": "{count} 個翼",
"status_closets": "{count} 個櫃子",
"status_drawers": "{count} 個抽屜",
"init_complete": "宮殿已初始化於 {path}",
"init_exists": "{path} 中已存在宮殿",
"repair_complete": "修復完成。已修正 {fixed} 個問題。",
"migrate_complete": "遷移完成。",
"no_palace": "未找到宮殿。請執行: mempalace init <目錄>"
},
"aaak": {
"instruction": "用中文壓縮。概念之間用管道符(|),詞語之間用連字符(-)。省略虛詞和連接詞。保留專有名詞和數字的準確性。"
},
"regex": {
"topic_pattern": "[\\u4E00-\\u9FFF]{2,}|[A-Za-z][A-Za-z0-9_]{2,}",
"stop_words": "的 了 在 是 我 有 和 就 不 人 都 一 一個 上 也 很 到 說 要 去 你 會 著 沒有 看 好 自己 這 那 她 他 它 們 但是 因為 所以 如果 雖然 然後 或者 而且",
"quote_pattern": "「([^」]{10,100})」|\u201c([^\u201d]{10,100})\u201d",
"action_pattern": "(構建|修復|添加|刪除|確認|創建|實現|修理|編寫|測試|驗證|更新|配置|啟動|停止)(?:了|完成|成功)"
}
}
+1 -1
View File
@@ -41,7 +41,7 @@ before continuing.
## Step 5: Initialize the palace
Run `mempalace init <dir>` where `<dir>` is the directory from Step 4.
Run `mempalace init --yes <dir>` where `<dir>` is the directory from Step 4.
If this fails, report the error and stop.
+90 -82
View File
@@ -39,6 +39,7 @@ import hashlib
import json
import os
import sqlite3
import threading
from datetime import date, datetime
from pathlib import Path
@@ -51,6 +52,7 @@ class KnowledgeGraph:
self.db_path = db_path or DEFAULT_KG_PATH
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
self._connection = None
self._lock = threading.Lock()
self._init_db()
def _init_db(self):
@@ -110,12 +112,13 @@ class KnowledgeGraph:
"""Add or update an entity node."""
eid = self._entity_id(name)
props = json.dumps(properties or {})
conn = self._conn()
with conn:
conn.execute(
"INSERT OR REPLACE INTO entities (id, name, type, properties) VALUES (?, ?, ?, ?)",
(eid, name, entity_type, props),
)
with self._lock:
conn = self._conn()
with conn:
conn.execute(
"INSERT OR REPLACE INTO entities (id, name, type, properties) VALUES (?, ?, ?, ?)",
(eid, name, entity_type, props),
)
return eid
def add_triple(
@@ -142,39 +145,42 @@ class KnowledgeGraph:
pred = predicate.lower().replace(" ", "_")
# Auto-create entities if they don't exist
conn = self._conn()
with conn:
conn.execute(
"INSERT OR IGNORE INTO entities (id, name) VALUES (?, ?)", (sub_id, subject)
)
conn.execute("INSERT OR IGNORE INTO entities (id, name) VALUES (?, ?)", (obj_id, obj))
with self._lock:
conn = self._conn()
with conn:
conn.execute(
"INSERT OR IGNORE INTO entities (id, name) VALUES (?, ?)", (sub_id, subject)
)
conn.execute(
"INSERT OR IGNORE INTO entities (id, name) VALUES (?, ?)", (obj_id, obj)
)
# Check for existing identical triple
existing = conn.execute(
"SELECT id FROM triples WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL",
(sub_id, pred, obj_id),
).fetchone()
# Check for existing identical triple
existing = conn.execute(
"SELECT id FROM triples WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL",
(sub_id, pred, obj_id),
).fetchone()
if existing:
return existing["id"] # Already exists and still valid
if existing:
return existing["id"] # Already exists and still valid
triple_id = f"t_{sub_id}_{pred}_{obj_id}_{hashlib.sha256(f'{valid_from}{datetime.now().isoformat()}'.encode()).hexdigest()[:12]}"
triple_id = f"t_{sub_id}_{pred}_{obj_id}_{hashlib.sha256(f'{valid_from}{datetime.now().isoformat()}'.encode()).hexdigest()[:12]}"
conn.execute(
"""INSERT INTO triples (id, subject, predicate, object, valid_from, valid_to, confidence, source_closet, source_file)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
triple_id,
sub_id,
pred,
obj_id,
valid_from,
valid_to,
confidence,
source_closet,
source_file,
),
)
conn.execute(
"""INSERT INTO triples (id, subject, predicate, object, valid_from, valid_to, confidence, source_closet, source_file)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
triple_id,
sub_id,
pred,
obj_id,
valid_from,
valid_to,
confidence,
source_closet,
source_file,
),
)
return triple_id
def invalidate(self, subject: str, predicate: str, obj: str, ended: str = None):
@@ -184,12 +190,13 @@ class KnowledgeGraph:
pred = predicate.lower().replace(" ", "_")
ended = ended or date.today().isoformat()
conn = self._conn()
with conn:
conn.execute(
"UPDATE triples SET valid_to=? WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL",
(ended, sub_id, pred, obj_id),
)
with self._lock:
conn = self._conn()
with conn:
conn.execute(
"UPDATE triples SET valid_to=? WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL",
(ended, sub_id, pred, obj_id),
)
# ── Query operations ──────────────────────────────────────────────────
@@ -201,51 +208,52 @@ class KnowledgeGraph:
as_of: date string — only return facts valid at that time
"""
eid = self._entity_id(name)
conn = self._conn()
results = []
with self._lock:
conn = self._conn()
if direction in ("outgoing", "both"):
query = "SELECT t.*, e.name as obj_name FROM triples t JOIN entities e ON t.object = e.id WHERE t.subject = ?"
params = [eid]
if as_of:
query += " AND (t.valid_from IS NULL OR t.valid_from <= ?) AND (t.valid_to IS NULL OR t.valid_to >= ?)"
params.extend([as_of, as_of])
for row in conn.execute(query, params).fetchall():
results.append(
{
"direction": "outgoing",
"subject": name,
"predicate": row["predicate"],
"object": row["obj_name"],
"valid_from": row["valid_from"],
"valid_to": row["valid_to"],
"confidence": row["confidence"],
"source_closet": row["source_closet"],
"current": row["valid_to"] is None,
}
)
if direction in ("outgoing", "both"):
query = "SELECT t.*, e.name as obj_name FROM triples t JOIN entities e ON t.object = e.id WHERE t.subject = ?"
params = [eid]
if as_of:
query += " AND (t.valid_from IS NULL OR t.valid_from <= ?) AND (t.valid_to IS NULL OR t.valid_to >= ?)"
params.extend([as_of, as_of])
for row in conn.execute(query, params).fetchall():
results.append(
{
"direction": "outgoing",
"subject": name,
"predicate": row["predicate"],
"object": row["obj_name"],
"valid_from": row["valid_from"],
"valid_to": row["valid_to"],
"confidence": row["confidence"],
"source_closet": row["source_closet"],
"current": row["valid_to"] is None,
}
)
if direction in ("incoming", "both"):
query = "SELECT t.*, e.name as sub_name FROM triples t JOIN entities e ON t.subject = e.id WHERE t.object = ?"
params = [eid]
if as_of:
query += " AND (t.valid_from IS NULL OR t.valid_from <= ?) AND (t.valid_to IS NULL OR t.valid_to >= ?)"
params.extend([as_of, as_of])
for row in conn.execute(query, params).fetchall():
results.append(
{
"direction": "incoming",
"subject": row["sub_name"],
"predicate": row["predicate"],
"object": name,
"valid_from": row["valid_from"],
"valid_to": row["valid_to"],
"confidence": row["confidence"],
"source_closet": row["source_closet"],
"current": row["valid_to"] is None,
}
)
if direction in ("incoming", "both"):
query = "SELECT t.*, e.name as sub_name FROM triples t JOIN entities e ON t.subject = e.id WHERE t.object = ?"
params = [eid]
if as_of:
query += " AND (t.valid_from IS NULL OR t.valid_from <= ?) AND (t.valid_to IS NULL OR t.valid_to >= ?)"
params.extend([as_of, as_of])
for row in conn.execute(query, params).fetchall():
results.append(
{
"direction": "incoming",
"subject": row["sub_name"],
"predicate": row["predicate"],
"object": name,
"valid_from": row["valid_from"],
"valid_to": row["valid_to"],
"confidence": row["confidence"],
"source_closet": row["source_closet"],
"current": row["valid_to"] is None,
}
)
return results
+12 -34
View File
@@ -21,9 +21,9 @@ import sys
from pathlib import Path
from collections import defaultdict
import chromadb
from .config import MempalaceConfig
from .palace import get_collection as _get_collection
from .searcher import build_where_filter
# ---------------------------------------------------------------------------
@@ -82,6 +82,7 @@ class Layer1:
MAX_DRAWERS = 15 # at most 15 moments in wake-up
MAX_CHARS = 3200 # hard cap on total L1 text (~800 tokens)
MAX_SCAN = 2000 # don't scan more than this for L1 generation
def __init__(self, palace_path: str = None, wing: str = None):
cfg = MempalaceConfig()
@@ -91,8 +92,7 @@ class Layer1:
def generate(self) -> str:
"""Pull top drawers from ChromaDB and format as compact L1 text."""
try:
client = chromadb.PersistentClient(path=self.palace_path)
col = client.get_collection("mempalace_drawers")
col = _get_collection(self.palace_path, create=False)
except Exception:
return "## L1 — No palace found. Run: mempalace mine <dir>"
@@ -115,7 +115,7 @@ class Layer1:
docs.extend(batch_docs)
metas.extend(batch_metas)
offset += len(batch_docs)
if len(batch_docs) < _BATCH:
if len(batch_docs) < _BATCH or len(docs) >= self.MAX_SCAN:
break
if not docs:
@@ -196,18 +196,11 @@ class Layer2:
def retrieve(self, wing: str = None, room: str = None, n_results: int = 10) -> str:
"""Retrieve drawers filtered by wing and/or room."""
try:
client = chromadb.PersistentClient(path=self.palace_path)
col = client.get_collection("mempalace_drawers")
col = _get_collection(self.palace_path, create=False)
except Exception:
return "No palace found."
where = {}
if wing and room:
where = {"$and": [{"wing": wing}, {"room": room}]}
elif wing:
where = {"wing": wing}
elif room:
where = {"room": room}
where = build_where_filter(wing, room)
kwargs = {"include": ["documents", "metadatas"], "limit": n_results}
if where:
@@ -260,18 +253,11 @@ class Layer3:
def search(self, query: str, wing: str = None, room: str = None, n_results: int = 5) -> str:
"""Semantic search, returns compact result text."""
try:
client = chromadb.PersistentClient(path=self.palace_path)
col = client.get_collection("mempalace_drawers")
col = _get_collection(self.palace_path, create=False)
except Exception:
return "No palace found."
where = {}
if wing and room:
where = {"$and": [{"wing": wing}, {"room": room}]}
elif wing:
where = {"wing": wing}
elif room:
where = {"room": room}
where = build_where_filter(wing, room)
kwargs = {
"query_texts": [query],
@@ -316,18 +302,11 @@ class Layer3:
) -> list:
"""Return raw dicts instead of formatted text."""
try:
client = chromadb.PersistentClient(path=self.palace_path)
col = client.get_collection("mempalace_drawers")
col = _get_collection(self.palace_path, create=False)
except Exception:
return []
where = {}
if wing and room:
where = {"$and": [{"wing": wing}, {"room": room}]}
elif wing:
where = {"wing": wing}
elif room:
where = {"room": room}
where = build_where_filter(wing, room)
kwargs = {
"query_texts": [query],
@@ -437,8 +416,7 @@ class MemoryStack:
# Count drawers
try:
client = chromadb.PersistentClient(path=self.palace_path)
col = client.get_collection("mempalace_drawers")
col = _get_collection(self.palace_path, create=False)
count = col.count()
result["total_drawers"] = count
except Exception:
+628 -125
View File
File diff suppressed because it is too large Load Diff
+32 -3
View File
@@ -104,14 +104,40 @@ def detect_chromadb_version(db_path: str) -> str:
conn.close()
def migrate(palace_path: str, dry_run: bool = False):
def contains_palace_database(path: str) -> bool:
"""Return True when path looks like a MemPalace ChromaDB directory."""
return os.path.isfile(os.path.join(path, "chroma.sqlite3"))
def confirm_destructive_action(
operation_name: str, palace_path: str, assume_yes: bool = False
) -> bool:
"""Require confirmation before destructive palace operations."""
if assume_yes:
return True
print(f"\n {operation_name} will replace data in: {palace_path}")
print(" A backup will be created first, then the palace will be rebuilt.")
try:
answer = input(" Continue? [y/N]: ").strip().lower()
except EOFError:
print(" Aborted. Re-run with --yes to confirm destructive changes.")
return False
if answer not in {"y", "yes"}:
print(" Aborted.")
return False
return True
def migrate(palace_path: str, dry_run: bool = False, confirm: bool = False):
"""Migrate a palace to the currently installed ChromaDB version."""
import chromadb
palace_path = os.path.expanduser(palace_path)
palace_path = os.path.abspath(os.path.expanduser(palace_path))
db_path = os.path.join(palace_path, "chroma.sqlite3")
if not os.path.isfile(db_path):
if not os.path.isdir(palace_path) or not contains_palace_database(palace_path):
print(f"\n No palace database found at {db_path}")
return False
@@ -166,6 +192,9 @@ def migrate(palace_path: str, dry_run: bool = False):
print(f" Would migrate {len(drawers)} drawers.")
return True
if not confirm_destructive_action("Migration", palace_path, assume_yes=confirm):
return False
# Backup the old palace
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = f"{palace_path}.pre-migrate.{timestamp}"
+6 -8
View File
@@ -15,8 +15,6 @@ from pathlib import Path
from datetime import datetime
from collections import defaultdict
import chromadb
from .palace import SKIP_DIRS, get_collection, file_already_mined
READABLE_EXTENSIONS = {
@@ -418,16 +416,16 @@ def process_file(
# Skip if already filed
source_file = str(filepath)
if not dry_run and file_already_mined(collection, source_file, check_mtime=True):
return 0, None
return 0, "general"
try:
content = filepath.read_text(encoding="utf-8", errors="replace")
except OSError:
return 0, None
return 0, "general"
content = content.strip()
if len(content) < MIN_CHUNK_SIZE:
return 0, None
return 0, "general"
room = detect_room(filepath, content, rooms, project_path)
chunks = chunk_text(content, source_file)
@@ -625,15 +623,15 @@ def mine(
def status(palace_path: str):
"""Show what's been filed in the palace."""
try:
client = chromadb.PersistentClient(path=palace_path)
col = client.get_collection("mempalace_drawers")
col = get_collection(palace_path, create=False)
except Exception:
print(f"\n No palace found at {palace_path}")
print(" Run: mempalace init <dir> then mempalace mine <dir>")
return
# Count by wing and room
r = col.get(limit=10000, include=["metadatas"])
total = col.count()
r = col.get(limit=total, include=["metadatas"]) if total else {"metadatas": []}
metas = r["metadatas"]
wing_rooms = defaultdict(lambda: defaultdict(int))
+195 -32
View File
@@ -6,7 +6,7 @@ Supported:
- Plain text with > markers (pass through)
- Claude.ai JSON export
- ChatGPT conversations.json
- Claude Code JSONL
- Claude Code JSONL (with tool_use/tool_result block capture)
- OpenAI Codex CLI JSONL
- Slack JSON export
- Plain text (pass through for paragraph chunking)
@@ -30,7 +30,7 @@ def normalize(filepath: str) -> str:
except OSError as e:
raise IOError(f"Could not read {filepath}: {e}")
if file_size > 500 * 1024 * 1024: # 500 MB safety limit
raise IOError(f"File too large ({file_size // (1024*1024)} MB): {filepath}")
raise IOError(f"File too large ({file_size // (1024 * 1024)} MB): {filepath}")
try:
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
@@ -83,6 +83,8 @@ def _try_claude_code_jsonl(content: str) -> Optional[str]:
"""Claude Code JSONL sessions."""
lines = [line.strip() for line in content.strip().split("\n") if line.strip()]
messages = []
tool_use_map = {} # tool_use_id → tool_name
for line in lines:
try:
entry = json.loads(line)
@@ -92,14 +94,42 @@ def _try_claude_code_jsonl(content: str) -> Optional[str]:
continue
msg_type = entry.get("type", "")
message = entry.get("message", {})
if not isinstance(message, dict):
continue
msg_content = message.get("content", "")
# Build tool_use_map from assistant messages
if msg_type == "assistant" and isinstance(msg_content, list):
for block in msg_content:
if isinstance(block, dict) and block.get("type") == "tool_use":
tool_id = block.get("id", "")
if tool_id:
tool_use_map[tool_id] = block.get("name", "Unknown")
if msg_type in ("human", "user"):
text = _extract_content(message.get("content", ""))
# Check if this message is tool_results only (no user text)
is_tool_only = isinstance(msg_content, list) and all(
isinstance(b, dict) and b.get("type") == "tool_result" for b in msg_content
)
text = _extract_content(msg_content, tool_use_map=tool_use_map)
if text:
messages.append(("user", text))
if is_tool_only and messages and messages[-1][0] == "assistant":
# Append tool results to the previous assistant message
prev_role, prev_text = messages[-1]
messages[-1] = (prev_role, prev_text + "\n" + text)
elif not is_tool_only:
messages.append(("user", text))
elif msg_type == "assistant":
text = _extract_content(message.get("content", ""))
text = _extract_content(msg_content, tool_use_map=tool_use_map)
if text:
messages.append(("assistant", text))
# If previous message is also assistant (multi-turn tool loop),
# merge into the same assistant turn
if messages and messages[-1][0] == "assistant":
prev_role, prev_text = messages[-1]
messages[-1] = (prev_role, prev_text + "\n" + text)
else:
messages.append(("assistant", text))
if len(messages) >= 2:
return _messages_to_transcript(messages)
return None
@@ -160,40 +190,46 @@ def _try_claude_ai_json(data) -> Optional[str]:
if not isinstance(data, list):
return None
# Privacy export: array of conversation objects with chat_messages inside each
if data and isinstance(data[0], dict) and "chat_messages" in data[0]:
all_messages = []
# Privacy export: array of conversation objects, each containing its own
# message list under "chat_messages" or "messages" (both variants seen in the wild).
if data and isinstance(data[0], dict) and ("chat_messages" in data[0] or "messages" in data[0]):
transcripts = []
for convo in data:
if not isinstance(convo, dict):
continue
chat_msgs = convo.get("chat_messages", [])
for item in chat_msgs:
if not isinstance(item, dict):
continue
role = item.get("role", "")
text = _extract_content(item.get("content", ""))
if role in ("user", "human") and text:
all_messages.append(("user", text))
elif role in ("assistant", "ai") and text:
all_messages.append(("assistant", text))
if len(all_messages) >= 2:
return _messages_to_transcript(all_messages)
chat_msgs = convo.get("chat_messages") or convo.get("messages", [])
messages = _collect_claude_messages(chat_msgs)
if len(messages) >= 2:
transcripts.append(_messages_to_transcript(messages))
if transcripts:
return "\n\n".join(transcripts)
return None
# Flat messages list
messages = _collect_claude_messages(data)
if len(messages) >= 2:
return _messages_to_transcript(messages)
return None
def _collect_claude_messages(items) -> list:
"""Extract (role, text) pairs from a Claude.ai message list.
Accepts both ``role`` (API format) and ``sender`` (privacy export) as the
author field, and falls back to a top-level ``text`` key when the
``content`` blocks are empty or absent.
"""
messages = []
for item in data:
for item in items:
if not isinstance(item, dict):
continue
role = item.get("role", "")
text = _extract_content(item.get("content", ""))
role = item.get("role") or item.get("sender", "")
text = _extract_content(item.get("content", "")) or (item.get("text") or "").strip()
if role in ("user", "human") and text:
messages.append(("user", text))
elif role in ("assistant", "ai") and text:
messages.append(("assistant", text))
if len(messages) >= 2:
return _messages_to_transcript(messages)
return None
return messages
def _try_chatgpt_json(data) -> Optional[str]:
@@ -270,8 +306,14 @@ def _try_slack_json(data) -> Optional[str]:
return None
def _extract_content(content) -> str:
"""Pull text from content — handles str, list of blocks, or dict."""
def _extract_content(content, tool_use_map: dict = None) -> str:
"""Pull text from content — handles str, list of blocks, or dict.
Args:
content: Message content — string, list of content blocks, or dict.
tool_use_map: Optional mapping of tool_use_id → tool_name, used to
select the right formatting strategy for tool_result blocks.
"""
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
@@ -279,14 +321,135 @@ def _extract_content(content) -> str:
for item in content:
if isinstance(item, str):
parts.append(item)
elif isinstance(item, dict) and item.get("type") == "text":
parts.append(item.get("text", ""))
return " ".join(parts).strip()
elif isinstance(item, dict):
block_type = item.get("type")
if block_type == "text":
parts.append(item.get("text", ""))
elif block_type == "tool_use":
parts.append(_format_tool_use(item))
elif block_type == "tool_result":
tid = item.get("tool_use_id", "")
tname = (tool_use_map or {}).get(tid, "Unknown")
result_content = item.get("content", "")
formatted = _format_tool_result(result_content, tname)
if formatted:
parts.append(formatted)
return "\n".join(p for p in parts if p).strip()
if isinstance(content, dict):
return content.get("text", "").strip()
return ""
def _format_tool_use(block: dict) -> str:
"""Format a tool_use block into a human-readable one-liner."""
name = block.get("name", "Unknown")
inp = block.get("input", {})
if name == "Bash":
cmd = inp.get("command", "")
if len(cmd) > 200:
cmd = cmd[:200] + "..."
return f"[Bash] {cmd}"
if name == "Read":
path = inp.get("file_path", "?")
offset = inp.get("offset")
limit = inp.get("limit")
if offset is not None and limit is not None:
try:
return f"[Read {path}:{offset}-{int(offset) + int(limit)}]"
except (ValueError, TypeError):
return f"[Read {path}:{offset}+{limit}]"
return f"[Read {path}]"
if name == "Grep":
pattern = inp.get("pattern", "")
target = inp.get("path") or inp.get("glob") or ""
return f"[Grep] {pattern} in {target}"
if name == "Glob":
pattern = inp.get("pattern", "")
return f"[Glob] {pattern}"
if name in ("Edit", "Write"):
path = inp.get("file_path", "?")
return f"[{name} {path}]"
# Unknown tool — serialize input, truncate
summary = json.dumps(inp, separators=(",", ":"))
if len(summary) > 200:
summary = summary[:200] + "..."
return f"[{name}] {summary}"
_TOOL_RESULT_MAX_LINES_BASH = 20 # head and tail line count
_TOOL_RESULT_MAX_MATCHES = 20 # Grep/Glob cap
_TOOL_RESULT_MAX_BYTES = 2048 # fallback cap for unknown tools
def _format_tool_result(content, tool_name: str) -> str:
"""Format a tool_result based on the originating tool's type.
Args:
content: Result text (str) or list of content blocks (list of dicts).
tool_name: Name of the tool that produced this result.
Returns:
Formatted string prefixed with ``→ ``, or empty string if omitted.
"""
# Normalize list-of-blocks to plain text
if isinstance(content, list):
parts = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
parts.append(item.get("text", ""))
elif isinstance(item, str):
parts.append(item)
text = "\n".join(parts)
else:
text = str(content) if content else ""
text = text.strip()
if not text:
return ""
# Read/Edit/Write — omit result (content is in palace or git)
if tool_name in ("Read", "Edit", "Write"):
return ""
lines = text.split("\n")
# Bash — head + tail
if tool_name == "Bash":
n = _TOOL_RESULT_MAX_LINES_BASH
if len(lines) <= n * 2:
return "" + "\n".join(lines)
head = lines[:n]
tail = lines[-n:]
omitted = len(lines) - 2 * n
return (
""
+ "\n".join(head)
+ f"\n→ ... [{omitted} lines omitted] ..."
+ "\n"
+ "\n".join(tail)
)
# Grep/Glob — cap matches
if tool_name in ("Grep", "Glob"):
cap = _TOOL_RESULT_MAX_MATCHES
if len(lines) <= cap:
return "" + "\n".join(lines)
kept = lines[:cap]
remaining = len(lines) - cap
return "" + "\n".join(kept) + f"\n→ ... [{remaining} more matches]"
# Unknown — byte cap
if len(text) > _TOOL_RESULT_MAX_BYTES:
return "" + text[:_TOOL_RESULT_MAX_BYTES] + f"... [truncated, {len(text)} chars]"
return "" + text
def _messages_to_transcript(messages: list, spellcheck: bool = True) -> str:
"""Convert [(role, text), ...] to transcript format with > markers."""
if spellcheck:
+17 -15
View File
@@ -1,11 +1,12 @@
"""
palace.py — Shared palace operations.
Consolidates ChromaDB access patterns used by both miners and the MCP server.
Consolidates collection access patterns used by both miners and the MCP server.
"""
import os
import chromadb
from .backends.chroma import ChromaBackend
SKIP_DIRS = {
".git",
@@ -33,19 +34,20 @@ SKIP_DIRS = {
"target",
}
_DEFAULT_BACKEND = ChromaBackend()
def get_collection(palace_path: str, collection_name: str = "mempalace_drawers"):
"""Get or create the palace ChromaDB collection."""
os.makedirs(palace_path, exist_ok=True)
try:
os.chmod(palace_path, 0o700)
except (OSError, NotImplementedError):
pass
client = chromadb.PersistentClient(path=palace_path)
try:
return client.get_collection(collection_name)
except Exception:
return client.create_collection(collection_name)
def get_collection(
palace_path: str,
collection_name: str = "mempalace_drawers",
create: bool = True,
):
"""Get the palace collection through the backend layer."""
return _DEFAULT_BACKEND.get_collection(
palace_path,
collection_name=collection_name,
create=create,
)
def file_already_mined(collection, source_file: str, check_mtime: bool = False) -> bool:
@@ -65,7 +67,7 @@ def file_already_mined(collection, source_file: str, check_mtime: bool = False)
if stored_mtime is None:
return False
current_mtime = os.path.getmtime(source_file)
return float(stored_mtime) == current_mtime
return abs(float(stored_mtime) - current_mtime) < 0.001
return True
except Exception:
return False
+7 -4
View File
@@ -16,16 +16,19 @@ No external graph DB needed — built from ChromaDB metadata.
"""
from collections import defaultdict, Counter
from .config import MempalaceConfig
import chromadb
from .config import MempalaceConfig
from .palace import get_collection as _get_palace_collection
def _get_collection(config=None):
config = config or MempalaceConfig()
try:
client = chromadb.PersistentClient(path=config.palace_path)
return client.get_collection(config.collection_name)
return _get_palace_collection(
config.palace_path,
collection_name=config.collection_name,
create=False,
)
except Exception:
return None
+36 -5
View File
@@ -24,9 +24,10 @@ import logging
logger = logging.getLogger("mempalace_mcp")
# --- Constants ---
MAX_QUERY_LENGTH = 500 # Above this, system prompt almost certainly dominates
MAX_QUERY_LENGTH = 250 # Above this, prompt contamination increasingly dominates
SAFE_QUERY_LENGTH = 200 # Below this, query is almost certainly clean
MIN_QUERY_LENGTH = 10 # Extracted result shorter than this = extraction failed
QUOTE_CHARS = {"'", '"'}
# Sentence splitter: split on . ! ? (including fullwidth) and newlines
_SENTENCE_SPLIT = re.compile(r"[.!?。!?\n]+")
@@ -67,6 +68,36 @@ def sanitize_query(raw_query: str) -> dict:
raw_query = raw_query.strip()
original_length = len(raw_query)
def _strip_wrapping_quotes(candidate: str) -> str:
candidate = candidate.strip()
while (
len(candidate) >= 2 and candidate[:1] in QUOTE_CHARS and candidate[:1] == candidate[-1:]
):
candidate = candidate[1:-1].strip()
if not candidate:
return ""
if candidate[:1] in QUOTE_CHARS:
candidate = candidate[1:].strip()
if candidate[-1:] in QUOTE_CHARS:
candidate = candidate[:-1].strip()
return candidate
def _trim_candidate(candidate: str) -> str:
candidate = _strip_wrapping_quotes(candidate)
if len(candidate) <= MAX_QUERY_LENGTH:
return candidate
nested_fragments = [
_strip_wrapping_quotes(frag)
for frag in _SENTENCE_SPLIT.split(candidate)
if frag.strip()
]
for frag in reversed(nested_fragments):
if MIN_QUERY_LENGTH <= len(frag) <= MAX_QUERY_LENGTH:
return frag
return candidate[-MAX_QUERY_LENGTH:].strip()
# --- Step 1: Short query passthrough ---
if original_length <= SAFE_QUERY_LENGTH:
return {
@@ -106,7 +137,7 @@ def sanitize_query(raw_query: str) -> dict:
if len(candidate) >= MIN_QUERY_LENGTH:
# Apply length guard
if len(candidate) > MAX_QUERY_LENGTH:
candidate = candidate[-MAX_QUERY_LENGTH:]
candidate = _trim_candidate(candidate)
logger.warning(
"Query sanitized: %d%d chars (method=question_extraction)",
original_length,
@@ -126,9 +157,9 @@ def sanitize_query(raw_query: str) -> dict:
for seg in reversed(all_segments):
seg = seg.strip()
if len(seg) >= MIN_QUERY_LENGTH:
candidate = seg
if len(candidate) > MAX_QUERY_LENGTH:
candidate = candidate[-MAX_QUERY_LENGTH:]
candidate = _trim_candidate(seg)
if len(candidate) < MIN_QUERY_LENGTH:
continue
logger.warning(
"Query sanitized: %d%d chars (method=tail_sentence)",
original_length,
+31 -4
View File
@@ -9,12 +9,15 @@ Two ways to define rooms without calling any AI:
No internet. No API key. Your files stay on your machine.
"""
import logging
import os
import sys
import yaml
from pathlib import Path
from collections import defaultdict
logger = logging.getLogger(__name__)
# Common room patterns — detected from folder names and filenames
# Format: {folder_keyword: room_name}
FOLDER_ROOM_MAP = {
@@ -118,7 +121,12 @@ def detect_rooms_from_folders(project_dir: str) -> list:
# Check top-level directories first (most reliable signal)
for item in project_path.iterdir():
if item.is_dir() and item.name not in SKIP_DIRS:
try:
is_dir = item.is_dir() # WinError 448 — reparse point / untrusted mount point
except OSError as exc:
logger.debug("Skipping %s: %s", item, exc)
continue
if is_dir and item.name not in SKIP_DIRS:
name_lower = item.name.lower().replace("-", "_")
if name_lower in FOLDER_ROOM_MAP:
room_name = FOLDER_ROOM_MAP[name_lower]
@@ -132,9 +140,28 @@ def detect_rooms_from_folders(project_dir: str) -> list:
# Walk one level deeper for nested patterns
for item in project_path.iterdir():
if item.is_dir() and item.name not in SKIP_DIRS:
for subitem in item.iterdir():
if subitem.is_dir() and subitem.name not in SKIP_DIRS:
try:
item_is_dir = item.is_dir() # WinError 448 — reparse point / untrusted mount point
except OSError as exc:
logger.debug("Skipping %s: %s", item, exc)
continue
if item_is_dir and item.name not in SKIP_DIRS:
try:
subitems = list(
item.iterdir()
) # WinError 448 — iterdir can also fail on some reparse points
except OSError as exc:
logger.debug("Skipping contents of %s: %s", item, exc)
continue
for subitem in subitems:
try:
subitem_is_dir = (
subitem.is_dir()
) # WinError 448 — reparse point / untrusted mount point
except OSError as exc:
logger.debug("Skipping %s: %s", subitem, exc)
continue
if subitem_is_dir and subitem.name not in SKIP_DIRS:
name_lower = subitem.name.lower().replace("-", "_")
if name_lower in FOLDER_ROOM_MAP:
room_name = FOLDER_ROOM_MAP[name_lower]
+42 -26
View File
@@ -9,7 +9,7 @@ Returns verbatim text — the actual words, never summaries.
import logging
from pathlib import Path
import chromadb
from .palace import get_collection
logger = logging.getLogger("mempalace_mcp")
@@ -18,27 +18,30 @@ class SearchError(Exception):
"""Raised when search cannot proceed (e.g. no palace found)."""
def build_where_filter(wing: str = None, room: str = None) -> dict:
"""Build ChromaDB where filter for wing/room filtering."""
if wing and room:
return {"$and": [{"wing": wing}, {"room": room}]}
elif wing:
return {"wing": wing}
elif room:
return {"room": room}
return {}
def search(query: str, palace_path: str, wing: str = None, room: str = None, n_results: int = 5):
"""
Search the palace. Returns verbatim drawer content.
Optionally filter by wing (project) or room (aspect).
"""
try:
client = chromadb.PersistentClient(path=palace_path)
col = client.get_collection("mempalace_drawers")
col = get_collection(palace_path, create=False)
except Exception:
print(f"\n No palace found at {palace_path}")
print(" Run: mempalace init <dir> then mempalace mine <dir>")
raise SearchError(f"No palace found at {palace_path}")
# Build where filter
where = {}
if wing and room:
where = {"$and": [{"wing": wing}, {"room": room}]}
elif wing:
where = {"wing": wing}
elif room:
where = {"room": room}
where = build_where_filter(wing, room)
try:
kwargs = {
@@ -72,7 +75,7 @@ def search(query: str, palace_path: str, wing: str = None, room: str = None, n_r
print(f"{'=' * 60}\n")
for i, (doc, meta, dist) in enumerate(zip(docs, metas, dists), 1):
similarity = round(1 - dist, 3)
similarity = round(max(0.0, 1 - dist), 3)
source = Path(meta.get("source_file", "?")).name
wing_name = meta.get("wing", "?")
room_name = meta.get("room", "?")
@@ -91,15 +94,30 @@ def search(query: str, palace_path: str, wing: str = None, room: str = None, n_r
def search_memories(
query: str, palace_path: str, wing: str = None, room: str = None, n_results: int = 5
query: str,
palace_path: str,
wing: str = None,
room: str = None,
n_results: int = 5,
max_distance: float = 0.0,
) -> dict:
"""
Programmatic search — returns a dict instead of printing.
"""Programmatic search — returns a dict instead of printing.
Used by the MCP server and other callers that need data.
Args:
query: Natural language search query.
palace_path: Path to the ChromaDB palace directory.
wing: Optional wing filter.
room: Optional room filter.
n_results: Max results to return.
max_distance: Max cosine distance threshold. The palace collection uses
cosine distance (hnsw:space=cosine) — 0 = identical, 2 = opposite.
Results with distance > this value are filtered out. A value of
0.0 disables filtering. Typical useful range: 0.31.0.
"""
try:
client = chromadb.PersistentClient(path=palace_path)
col = client.get_collection("mempalace_drawers")
col = get_collection(palace_path, create=False)
except Exception as e:
logger.error("No palace found at %s: %s", palace_path, e)
return {
@@ -107,14 +125,7 @@ def search_memories(
"hint": "Run: mempalace init <dir> && mempalace mine <dir>",
}
# Build where filter
where = {}
if wing and room:
where = {"$and": [{"wing": wing}, {"room": room}]}
elif wing:
where = {"wing": wing}
elif room:
where = {"room": room}
where = build_where_filter(wing, room)
try:
kwargs = {
@@ -135,18 +146,23 @@ def search_memories(
hits = []
for doc, meta, dist in zip(docs, metas, dists):
# Filter on raw distance before rounding to avoid precision loss
if max_distance > 0.0 and dist > max_distance:
continue
hits.append(
{
"text": doc,
"wing": meta.get("wing", "unknown"),
"room": meta.get("room", "unknown"),
"source_file": Path(meta.get("source_file", "?")).name,
"similarity": round(1 - dist, 3),
"similarity": round(max(0.0, 1 - dist), 3),
"distance": round(dist, 4),
}
)
return {
"query": query,
"filters": {"wing": wing, "room": room},
"total_before_filter": len(docs),
"results": hits,
}
+1 -1
View File
@@ -261,7 +261,7 @@ def main():
)
args = parser.parse_args()
src_dir = Path(args.source) if args.source else LUMI_DIR
src_dir = Path(args.source).expanduser().resolve() if args.source else LUMI_DIR
output_dir = args.output_dir or None # None = same dir as file
if args.file:
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the MemPalace package version."""
__version__ = "3.1.0"
__version__ = "3.2.0"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "mempalace"
version = "3.1.0"
version = "3.2.0"
description = "Give your AI a memory — mine projects and conversations into a searchable palace. No API key required."
readme = "README.md"
requires-python = ">=3.9"
+3 -1
View File
@@ -169,7 +169,9 @@ def seeded_collection(collection):
def kg(tmp_dir):
"""An isolated KnowledgeGraph using a temp SQLite file."""
db_path = os.path.join(tmp_dir, "test_kg.sqlite3")
return KnowledgeGraph(db_path=db_path)
graph = KnowledgeGraph(db_path=db_path)
yield graph
graph.close()
@pytest.fixture
+128
View File
@@ -0,0 +1,128 @@
import sqlite3
import chromadb
import pytest
from mempalace.backends.chroma import ChromaBackend, ChromaCollection, _fix_blob_seq_ids
class _FakeCollection:
def __init__(self):
self.calls = []
def add(self, **kwargs):
self.calls.append(("add", kwargs))
def upsert(self, **kwargs):
self.calls.append(("upsert", kwargs))
def query(self, **kwargs):
self.calls.append(("query", kwargs))
return {"kind": "query"}
def get(self, **kwargs):
self.calls.append(("get", kwargs))
return {"kind": "get"}
def delete(self, **kwargs):
self.calls.append(("delete", kwargs))
def count(self):
self.calls.append(("count", {}))
return 7
def test_chroma_collection_delegates_methods():
fake = _FakeCollection()
collection = ChromaCollection(fake)
collection.add(documents=["d"], ids=["1"], metadatas=[{"wing": "w"}])
collection.upsert(documents=["u"], ids=["2"], metadatas=[{"room": "r"}])
assert collection.query(query_texts=["q"]) == {"kind": "query"}
assert collection.get(where={"wing": "w"}) == {"kind": "get"}
collection.delete(ids=["1"])
assert collection.count() == 7
assert fake.calls == [
("add", {"documents": ["d"], "ids": ["1"], "metadatas": [{"wing": "w"}]}),
("upsert", {"documents": ["u"], "ids": ["2"], "metadatas": [{"room": "r"}]}),
("query", {"query_texts": ["q"]}),
("get", {"where": {"wing": "w"}}),
("delete", {"ids": ["1"]}),
("count", {}),
]
def test_chroma_backend_create_false_raises_without_creating_directory(tmp_path):
palace_path = tmp_path / "missing-palace"
with pytest.raises(FileNotFoundError):
ChromaBackend().get_collection(
str(palace_path),
collection_name="mempalace_drawers",
create=False,
)
assert not palace_path.exists()
def test_chroma_backend_create_true_creates_directory_and_collection(tmp_path):
palace_path = tmp_path / "palace"
collection = ChromaBackend().get_collection(
str(palace_path),
collection_name="mempalace_drawers",
create=True,
)
assert palace_path.is_dir()
assert isinstance(collection, ChromaCollection)
client = chromadb.PersistentClient(path=str(palace_path))
client.get_collection("mempalace_drawers")
def test_fix_blob_seq_ids_converts_blobs_to_integers(tmp_path):
"""Simulate a ChromaDB 0.6.x database with BLOB seq_ids and verify repair."""
db_path = tmp_path / "chroma.sqlite3"
conn = sqlite3.connect(str(db_path))
conn.execute("CREATE TABLE embeddings (rowid INTEGER PRIMARY KEY, seq_id)")
conn.execute("CREATE TABLE max_seq_id (rowid INTEGER PRIMARY KEY, seq_id)")
# Insert BLOB seq_ids like ChromaDB 0.6.x would
blob_42 = (42).to_bytes(8, byteorder="big")
blob_99 = (99).to_bytes(8, byteorder="big")
conn.execute("INSERT INTO embeddings (seq_id) VALUES (?)", (blob_42,))
conn.execute("INSERT INTO max_seq_id (seq_id) VALUES (?)", (blob_99,))
conn.commit()
conn.close()
_fix_blob_seq_ids(str(tmp_path))
conn = sqlite3.connect(str(db_path))
row = conn.execute("SELECT seq_id, typeof(seq_id) FROM embeddings").fetchone()
assert row == (42, "integer")
row = conn.execute("SELECT seq_id, typeof(seq_id) FROM max_seq_id").fetchone()
assert row == (99, "integer")
conn.close()
def test_fix_blob_seq_ids_noop_without_blobs(tmp_path):
"""No error when seq_ids are already integers."""
db_path = tmp_path / "chroma.sqlite3"
conn = sqlite3.connect(str(db_path))
conn.execute("CREATE TABLE embeddings (rowid INTEGER PRIMARY KEY, seq_id INTEGER)")
conn.execute("INSERT INTO embeddings (seq_id) VALUES (42)")
conn.commit()
conn.close()
_fix_blob_seq_ids(str(tmp_path))
conn = sqlite3.connect(str(db_path))
row = conn.execute("SELECT seq_id, typeof(seq_id) FROM embeddings").fetchone()
assert row == (42, "integer")
conn.close()
def test_fix_blob_seq_ids_noop_without_database(tmp_path):
"""No error when palace has no chroma.sqlite3."""
_fix_blob_seq_ids(str(tmp_path)) # should not raise
+52 -9
View File
@@ -423,10 +423,24 @@ def test_cmd_repair_no_palace(mock_config_cls, tmp_path, capsys):
assert "No palace found" in out
@patch("mempalace.cli.MempalaceConfig")
def test_cmd_repair_requires_palace_database(mock_config_cls, tmp_path, capsys):
palace_dir = tmp_path / "palace"
palace_dir.mkdir()
mock_config_cls.return_value.palace_path = str(palace_dir)
args = argparse.Namespace(palace=None)
mock_chromadb = MagicMock()
with patch.dict("sys.modules", {"chromadb": mock_chromadb}):
cmd_repair(args)
out = capsys.readouterr().out
assert "No palace database found" in out
@patch("mempalace.cli.MempalaceConfig")
def test_cmd_repair_error_reading(mock_config_cls, tmp_path, capsys):
palace_dir = tmp_path / "palace"
palace_dir.mkdir()
(palace_dir / "chroma.sqlite3").write_text("db")
mock_config_cls.return_value.palace_path = str(palace_dir)
args = argparse.Namespace(palace=None)
mock_chromadb = MagicMock()
@@ -443,6 +457,7 @@ def test_cmd_repair_error_reading(mock_config_cls, tmp_path, capsys):
def test_cmd_repair_zero_drawers(mock_config_cls, tmp_path, capsys):
palace_dir = tmp_path / "palace"
palace_dir.mkdir()
(palace_dir / "chroma.sqlite3").write_text("db")
mock_config_cls.return_value.palace_path = str(palace_dir)
args = argparse.Namespace(palace=None)
mock_chromadb = MagicMock()
@@ -461,8 +476,9 @@ def test_cmd_repair_zero_drawers(mock_config_cls, tmp_path, capsys):
def test_cmd_repair_success(mock_config_cls, tmp_path, capsys):
palace_dir = tmp_path / "palace"
palace_dir.mkdir()
(palace_dir / "chroma.sqlite3").write_text("db")
mock_config_cls.return_value.palace_path = str(palace_dir)
args = argparse.Namespace(palace=None)
args = argparse.Namespace(palace=None, yes=True)
mock_chromadb = MagicMock()
mock_col = MagicMock()
mock_col.count.return_value = 2
@@ -483,6 +499,29 @@ def test_cmd_repair_success(mock_config_cls, tmp_path, capsys):
assert "2 drawers rebuilt" in out
@patch("mempalace.cli.MempalaceConfig")
def test_cmd_repair_aborts_without_confirmation(mock_config_cls, tmp_path, capsys):
palace_dir = tmp_path / "palace"
palace_dir.mkdir()
(palace_dir / "chroma.sqlite3").write_text("db")
mock_config_cls.return_value.palace_path = str(palace_dir)
args = argparse.Namespace(palace=None)
mock_chromadb = MagicMock()
mock_col = MagicMock()
mock_col.count.return_value = 1
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
mock_chromadb.PersistentClient.return_value = mock_client
with (
patch.dict("sys.modules", {"chromadb": mock_chromadb}),
patch("builtins.input", return_value="n"),
):
cmd_repair(args)
out = capsys.readouterr().out
assert "Aborted." in out
mock_client.create_collection.assert_not_called()
# ── cmd_compress ───────────────────────────────────────────────────────
@@ -546,10 +585,11 @@ def test_cmd_compress_dry_run(mock_config_cls, capsys):
mock_dialect.compress.return_value = "compressed"
mock_dialect.compression_stats.return_value = {
"original_chars": 100,
"compressed_chars": 30,
"original_tokens": 25,
"compressed_tokens": 8,
"ratio": 3.3,
"summary_chars": 30,
"original_tokens_est": 25,
"summary_tokens_est": 8,
"size_ratio": 3.3,
"note": "Estimates only.",
}
mock_dialect_mod = _make_mock_dialect_module(mock_dialect)
@@ -564,6 +604,7 @@ def test_cmd_compress_dry_run(mock_config_cls, capsys):
out = capsys.readouterr().out
assert "dry run" in out.lower()
assert "Compressing" in out
assert "Total:" in out
@patch("mempalace.cli.MempalaceConfig")
@@ -619,10 +660,11 @@ def test_cmd_compress_stores_results(mock_config_cls, capsys):
mock_dialect.compress.return_value = "compressed"
mock_dialect.compression_stats.return_value = {
"original_chars": 100,
"compressed_chars": 30,
"original_tokens": 25,
"compressed_tokens": 8,
"ratio": 3.3,
"summary_chars": 30,
"original_tokens_est": 25,
"summary_tokens_est": 8,
"size_ratio": 3.3,
"note": "Estimates only.",
}
mock_dialect_mod = _make_mock_dialect_module(mock_dialect)
@@ -636,6 +678,7 @@ def test_cmd_compress_stores_results(mock_config_cls, capsys):
cmd_compress(args)
out = capsys.readouterr().out
assert "Stored" in out
assert "Total:" in out
mock_comp_col.upsert.assert_called_once()
+37 -1
View File
@@ -1,7 +1,9 @@
import os
import json
import tempfile
from mempalace.config import MempalaceConfig
import pytest
from mempalace.config import MempalaceConfig, sanitize_name
def test_default_config():
@@ -30,3 +32,37 @@ def test_init():
cfg = MempalaceConfig(config_dir=tmpdir)
cfg.init()
assert os.path.exists(os.path.join(tmpdir, "config.json"))
# --- sanitize_name ---
def test_sanitize_name_ascii():
assert sanitize_name("hello") == "hello"
def test_sanitize_name_latvian():
assert sanitize_name("Jānis") == "Jānis"
def test_sanitize_name_cjk():
assert sanitize_name("太郎") == "太郎"
def test_sanitize_name_cyrillic():
assert sanitize_name("Алексей") == "Алексей"
def test_sanitize_name_rejects_leading_underscore():
with pytest.raises(ValueError):
sanitize_name("_foo")
def test_sanitize_name_rejects_path_traversal():
with pytest.raises(ValueError):
sanitize_name("../etc/passwd")
def test_sanitize_name_rejects_empty():
with pytest.raises(ValueError):
sanitize_name("")
+51
View File
@@ -1,8 +1,12 @@
import os
import tempfile
import shutil
from pathlib import Path
import chromadb
from mempalace.convo_miner import mine_convos
from mempalace.palace import file_already_mined
def test_convo_mining():
@@ -24,3 +28,50 @@ def test_convo_mining():
assert len(results["documents"][0]) > 0
shutil.rmtree(tmpdir, ignore_errors=True)
def test_mine_convos_does_not_reprocess_short_files(capsys):
"""Files below MIN_CHUNK_SIZE get a sentinel so they are skipped on re-run."""
tmpdir = tempfile.mkdtemp()
try:
# A file too short to produce any chunks
with open(os.path.join(tmpdir, "tiny.txt"), "w") as f:
f.write("hi")
palace_path = os.path.join(tmpdir, "palace")
# First run -- file is processed (sentinel written)
mine_convos(tmpdir, palace_path, wing="test")
capsys.readouterr() # drain output
# Verify sentinel was written (resolve path -- macOS /var -> /private/var)
resolved_file = str(Path(tmpdir).resolve() / "tiny.txt")
client = chromadb.PersistentClient(path=palace_path)
col = client.get_collection("mempalace_drawers")
assert file_already_mined(col, resolved_file)
# Second run -- file should be skipped
mine_convos(tmpdir, palace_path, wing="test")
out2 = capsys.readouterr().out
assert "Files skipped (already filed): 1" in out2
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
def test_mine_convos_does_not_reprocess_empty_chunk_files(capsys):
"""Files that normalize but produce 0 exchange chunks get a sentinel."""
tmpdir = tempfile.mkdtemp()
try:
# Content long enough to pass MIN_CHUNK_SIZE but with no exchange markers
# (no "> " lines), so chunk_exchanges returns []
with open(os.path.join(tmpdir, "no_exchanges.txt"), "w") as f:
f.write("This is a plain paragraph without any exchange markers. " * 5)
palace_path = os.path.join(tmpdir, "palace")
mine_convos(tmpdir, palace_path, wing="test")
mine_convos(tmpdir, palace_path, wing="test")
out2 = capsys.readouterr().out
assert "Files skipped (already filed): 1" in out2
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
+11
View File
@@ -47,6 +47,17 @@ class TestChunkExchanges:
# Too short to produce chunks (below MIN_CHUNK_SIZE)
assert isinstance(chunks, list)
def test_long_ai_response_not_truncated(self):
"""AI responses longer than 8 lines must be stored in full (verbatim principle)."""
lines = [f"Step {i}: important detail that must be stored" for i in range(1, 14)]
content = "> How do I implement authentication?\n" + "\n".join(lines)
chunks = chunk_exchanges(content)
assert len(chunks) >= 1
stored = chunks[0]["content"]
# All 13 lines must be present — none silently dropped
for i in range(1, 14):
assert f"Step {i}:" in stored, f"Step {i} was truncated and not stored"
class TestDetectConvoRoom:
def test_technical_room(self):
+14
View File
@@ -115,6 +115,20 @@ class TestCompressionStats:
def test_count_tokens(self):
assert Dialect.count_tokens("hello world") == 2
def test_compression_stats_keys(self):
"""Verify compression_stats() returns the expected key set."""
d = Dialect()
stats = d.compression_stats("hello world this is a test", "HW:test")
expected_keys = {
"original_chars",
"summary_chars",
"original_tokens_est",
"summary_tokens_est",
"size_ratio",
"note",
}
assert set(stats.keys()) == expected_keys
class TestZettelEncoding:
def test_encode_zettel(self):
+136
View File
@@ -0,0 +1,136 @@
import os
import shutil
import tempfile
from pathlib import Path
import yaml
from mempalace.miner import mine
from mempalace.exporter import export_palace
def write_file(path: Path, content: str):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def _setup_palace(tmpdir):
"""Create a small palace with drawers across two wings for testing."""
project_a = Path(tmpdir) / "project_a"
project_b = Path(tmpdir) / "project_b"
palace_path = str(Path(tmpdir) / "palace")
# Project A: wing=alpha, rooms=backend,frontend
os.makedirs(project_a / "backend")
os.makedirs(project_a / "frontend")
write_file(project_a / "backend" / "server.py", "def serve():\n return 'ok'\n" * 20)
write_file(project_a / "frontend" / "app.js", "function render() { return 'hi'; }\n" * 20)
with open(project_a / "mempalace.yaml", "w") as f:
yaml.dump(
{
"wing": "alpha",
"rooms": [
{"name": "backend", "description": "Backend code"},
{"name": "frontend", "description": "Frontend code"},
],
},
f,
)
# Project B: wing=beta, rooms=docs
os.makedirs(project_b / "docs")
write_file(project_b / "docs" / "guide.md", "# Guide\n\nThis explains things.\n" * 20)
with open(project_b / "mempalace.yaml", "w") as f:
yaml.dump(
{
"wing": "beta",
"rooms": [{"name": "docs", "description": "Documentation"}],
},
f,
)
mine(str(project_a), palace_path)
mine(str(project_b), palace_path)
return palace_path
def test_export_creates_structure():
tmpdir = tempfile.mkdtemp()
try:
palace_path = _setup_palace(tmpdir)
output_dir = os.path.join(tmpdir, "export")
stats = export_palace(palace_path, output_dir)
# Should have two wings
assert stats["wings"] == 2
assert stats["rooms"] >= 2
assert stats["drawers"] >= 3
# Directory structure
assert os.path.isfile(os.path.join(output_dir, "index.md"))
assert os.path.isdir(os.path.join(output_dir, "alpha"))
assert os.path.isdir(os.path.join(output_dir, "beta"))
# Room files exist
assert os.path.isfile(os.path.join(output_dir, "alpha", "backend.md"))
assert os.path.isfile(os.path.join(output_dir, "alpha", "frontend.md"))
assert os.path.isfile(os.path.join(output_dir, "beta", "docs.md"))
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
def test_export_markdown_content():
tmpdir = tempfile.mkdtemp()
try:
palace_path = _setup_palace(tmpdir)
output_dir = os.path.join(tmpdir, "export")
export_palace(palace_path, output_dir)
# Check that room files contain expected markdown elements
backend_md = Path(output_dir) / "alpha" / "backend.md"
content = backend_md.read_text(encoding="utf-8")
assert content.startswith("# alpha / backend\n")
assert "## drawer_" in content
assert "| Field | Value |" in content
assert "| Source |" in content
assert "| Filed |" in content
assert "| Added by |" in content
assert "---" in content
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
def test_export_index_content():
tmpdir = tempfile.mkdtemp()
try:
palace_path = _setup_palace(tmpdir)
output_dir = os.path.join(tmpdir, "export")
export_palace(palace_path, output_dir)
index_md = Path(output_dir) / "index.md"
content = index_md.read_text(encoding="utf-8")
assert "# Palace Export" in content
assert "| Wing | Rooms | Drawers |" in content
assert "[alpha](alpha/)" in content
assert "[beta](beta/)" in content
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
def test_export_empty_palace():
tmpdir = tempfile.mkdtemp()
try:
palace_path = os.path.join(tmpdir, "empty_palace")
output_dir = os.path.join(tmpdir, "export")
stats = export_palace(palace_path, output_dir)
assert stats == {"wings": 0, "rooms": 0, "drawers": 0}
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
+33 -95
View File
@@ -71,16 +71,14 @@ def test_layer0_default_path():
def _mock_chromadb_for_layer(docs, metas, monkeypatch=None):
"""Return a mock PersistentClient whose collection.get returns docs/metas."""
"""Return a mock collection whose get() returns docs/metas."""
mock_col = MagicMock()
# First batch returns data, second batch returns empty (end of pagination)
mock_col.get.side_effect = [
{"documents": docs, "metadatas": metas},
{"documents": [], "metadatas": []},
]
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
return mock_client
return mock_col
def test_layer1_no_palace():
@@ -101,11 +99,11 @@ def test_layer1_generates_essential_story():
{"room": "decisions", "source_file": "meeting.txt", "importance": 5},
{"room": "architecture", "source_file": "design.txt", "importance": 4},
]
mock_client = _mock_chromadb_for_layer(docs, metas)
mock_col = _mock_chromadb_for_layer(docs, metas)
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers.chromadb.PersistentClient", return_value=mock_client),
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer1(palace_path="/fake")
@@ -118,12 +116,9 @@ def test_layer1_generates_essential_story():
def test_layer1_empty_palace():
mock_col = MagicMock()
mock_col.get.return_value = {"documents": [], "metadatas": []}
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers.chromadb.PersistentClient", return_value=mock_client),
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer1(palace_path="/fake")
@@ -135,11 +130,11 @@ def test_layer1_empty_palace():
def test_layer1_with_wing_filter():
docs = ["Memory about project X"]
metas = [{"room": "general", "source_file": "x.txt", "importance": 3}]
mock_client = _mock_chromadb_for_layer(docs, metas)
mock_col = _mock_chromadb_for_layer(docs, metas)
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers.chromadb.PersistentClient", return_value=mock_client),
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer1(palace_path="/fake", wing="project_x")
@@ -147,18 +142,18 @@ def test_layer1_with_wing_filter():
assert "ESSENTIAL STORY" in result
# Verify wing filter was passed
call_kwargs = mock_client.get_collection.return_value.get.call_args_list[0][1]
call_kwargs = mock_col.get.call_args_list[0][1]
assert call_kwargs.get("where") == {"wing": "project_x"}
def test_layer1_truncates_long_snippets():
docs = ["A" * 300]
metas = [{"room": "general", "source_file": "long.txt"}]
mock_client = _mock_chromadb_for_layer(docs, metas)
mock_col = _mock_chromadb_for_layer(docs, metas)
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers.chromadb.PersistentClient", return_value=mock_client),
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer1(palace_path="/fake")
@@ -171,11 +166,11 @@ def test_layer1_respects_max_chars():
"""L1 stops adding entries once MAX_CHARS is reached."""
docs = [f"Memory number {i} with substantial content padding here" for i in range(30)]
metas = [{"room": "general", "source_file": f"f{i}.txt", "importance": 5} for i in range(30)]
mock_client = _mock_chromadb_for_layer(docs, metas)
mock_col = _mock_chromadb_for_layer(docs, metas)
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers.chromadb.PersistentClient", return_value=mock_client),
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer1(palace_path="/fake")
@@ -193,11 +188,11 @@ def test_layer1_importance_from_various_keys():
{"room": "r", "weight": 1},
{"room": "r"}, # no weight key, defaults to 3
]
mock_client = _mock_chromadb_for_layer(docs, metas)
mock_col = _mock_chromadb_for_layer(docs, metas)
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers.chromadb.PersistentClient", return_value=mock_client),
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer1(palace_path="/fake")
@@ -213,12 +208,9 @@ def test_layer1_batch_exception_breaks():
{"documents": ["doc1"], "metadatas": [{"room": "r"}]},
RuntimeError("batch error"),
]
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers.chromadb.PersistentClient", return_value=mock_client),
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer1(palace_path="/fake")
@@ -244,12 +236,9 @@ def test_layer2_retrieve_with_wing():
"documents": ["Some memory about the project"],
"metadatas": [{"room": "backend", "source_file": "notes.txt"}],
}
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers.chromadb.PersistentClient", return_value=mock_client),
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer2(palace_path="/fake")
@@ -265,12 +254,9 @@ def test_layer2_retrieve_with_room():
"documents": ["Backend architecture notes"],
"metadatas": [{"room": "architecture", "source_file": "arch.txt"}],
}
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers.chromadb.PersistentClient", return_value=mock_client),
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer2(palace_path="/fake")
@@ -285,12 +271,9 @@ def test_layer2_retrieve_wing_and_room():
"documents": ["Filtered result"],
"metadatas": [{"room": "backend", "source_file": "x.txt"}],
}
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers.chromadb.PersistentClient", return_value=mock_client),
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer2(palace_path="/fake")
@@ -304,12 +287,9 @@ def test_layer2_retrieve_wing_and_room():
def test_layer2_retrieve_empty():
mock_col = MagicMock()
mock_col.get.return_value = {"documents": [], "metadatas": []}
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers.chromadb.PersistentClient", return_value=mock_client),
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer2(palace_path="/fake")
@@ -321,12 +301,9 @@ def test_layer2_retrieve_empty():
def test_layer2_retrieve_no_filter():
mock_col = MagicMock()
mock_col.get.return_value = {"documents": [], "metadatas": []}
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers.chromadb.PersistentClient", return_value=mock_client),
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer2(palace_path="/fake")
@@ -340,12 +317,9 @@ def test_layer2_retrieve_no_filter():
def test_layer2_retrieve_error():
mock_col = MagicMock()
mock_col.get.side_effect = RuntimeError("db error")
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers.chromadb.PersistentClient", return_value=mock_client),
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer2(palace_path="/fake")
@@ -360,12 +334,9 @@ def test_layer2_truncates_long_snippets():
"documents": ["B" * 400],
"metadatas": [{"room": "r", "source_file": "s.txt"}],
}
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers.chromadb.PersistentClient", return_value=mock_client),
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer2(palace_path="/fake")
@@ -408,12 +379,9 @@ def test_layer3_search_with_results():
[{"wing": "project", "room": "backend", "source_file": "notes.txt"}],
[0.2],
)
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers.chromadb.PersistentClient", return_value=mock_client),
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer3(palace_path="/fake")
@@ -427,12 +395,9 @@ def test_layer3_search_with_results():
def test_layer3_search_no_results():
mock_col = MagicMock()
mock_col.query.return_value = _mock_query_results([], [], [])
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers.chromadb.PersistentClient", return_value=mock_client),
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer3(palace_path="/fake")
@@ -448,12 +413,9 @@ def test_layer3_search_with_wing_filter():
[{"wing": "proj", "room": "r"}],
[0.1],
)
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers.chromadb.PersistentClient", return_value=mock_client),
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer3(palace_path="/fake")
@@ -470,12 +432,9 @@ def test_layer3_search_with_room_filter():
[{"wing": "w", "room": "backend"}],
[0.1],
)
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers.chromadb.PersistentClient", return_value=mock_client),
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer3(palace_path="/fake")
@@ -492,12 +451,9 @@ def test_layer3_search_with_wing_and_room():
[{"wing": "proj", "room": "backend"}],
[0.1],
)
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers.chromadb.PersistentClient", return_value=mock_client),
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer3(palace_path="/fake")
@@ -510,12 +466,9 @@ def test_layer3_search_with_wing_and_room():
def test_layer3_search_error():
mock_col = MagicMock()
mock_col.query.side_effect = RuntimeError("search failed")
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers.chromadb.PersistentClient", return_value=mock_client),
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer3(palace_path="/fake")
@@ -531,12 +484,9 @@ def test_layer3_search_truncates_long_docs():
[{"wing": "w", "room": "r", "source_file": "s.txt"}],
[0.1],
)
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers.chromadb.PersistentClient", return_value=mock_client),
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer3(palace_path="/fake")
@@ -552,12 +502,9 @@ def test_layer3_search_raw_returns_dicts():
[{"wing": "proj", "room": "backend", "source_file": "f.txt"}],
[0.3],
)
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers.chromadb.PersistentClient", return_value=mock_client),
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer3(palace_path="/fake")
@@ -577,12 +524,9 @@ def test_layer3_search_raw_with_filters():
[{"wing": "w", "room": "r"}],
[0.1],
)
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers.chromadb.PersistentClient", return_value=mock_client),
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer3(palace_path="/fake")
@@ -595,12 +539,9 @@ def test_layer3_search_raw_with_filters():
def test_layer3_search_raw_error():
mock_col = MagicMock()
mock_col.query.side_effect = RuntimeError("fail")
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers.chromadb.PersistentClient", return_value=mock_client),
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer3(palace_path="/fake")
@@ -701,12 +642,9 @@ def test_memory_stack_status_with_palace(tmp_path):
mock_col = MagicMock()
mock_col.count.return_value = 42
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers.chromadb.PersistentClient", return_value=mock_client),
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
stack = MemoryStack(
+340
View File
@@ -7,6 +7,9 @@ via monkeypatch to avoid touching real data.
"""
import json
import sys
import pytest
def _patch_mcp_server(monkeypatch, config, kg):
@@ -92,6 +95,13 @@ class TestHandleRequest:
resp = handle_request({"method": "notifications/initialized", "id": None, "params": {}})
assert resp is None
def test_ping_returns_empty_result(self):
from mempalace.mcp_server import handle_request
resp = handle_request({"method": "ping", "id": 11, "params": {}})
assert resp["id"] == 11
assert resp["result"] == {}
def test_tools_list(self):
from mempalace.mcp_server import handle_request
@@ -138,6 +148,42 @@ class TestHandleRequest:
resp = handle_request({"method": "unknown/method", "id": 4, "params": {}})
assert resp["error"]["code"] == -32601
def test_any_notification_returns_none(self):
"""All notifications/* methods should return None (no response)."""
from mempalace.mcp_server import handle_request
for method in [
"notifications/initialized",
"notifications/cancelled",
"notifications/progress",
"notifications/roots/list_changed",
]:
resp = handle_request({"method": method, "params": {}})
assert resp is None, f"{method} should return None"
def test_unknown_method_no_id_returns_none(self):
"""Messages without id (notifications) must never get a response."""
from mempalace.mcp_server import handle_request
resp = handle_request({"method": "unknown/thing", "params": {}})
assert resp is None
def test_malformed_method_none(self):
"""method=None or missing should not crash."""
from mempalace.mcp_server import handle_request
# Explicit None
resp = handle_request({"method": None, "params": {}})
assert resp is None # no id → no response
# Missing method entirely
resp = handle_request({"params": {}})
assert resp is None
# method=None with id → should return error, not crash
resp = handle_request({"method": None, "id": 99, "params": {}})
assert resp["error"]["code"] == -32601
def test_tools_call_dispatches(self, monkeypatch, config, palace_path, seeded_kg):
_patch_mcp_server(monkeypatch, config, seeded_kg)
from mempalace.mcp_server import handle_request
@@ -252,6 +298,75 @@ class TestSearchTool:
result = tool_search(query="database", room="backend")
assert all(r["room"] == "backend" for r in result["results"])
def test_search_min_similarity_backwards_compat(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
"""Old min_similarity param still works via backwards-compat shim."""
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_search
# Old name should work
result = tool_search(query="JWT", min_similarity=1.5)
assert "results" in result
# Old name takes precedence when both provided
result_strict = tool_search(query="JWT", max_distance=999.0, min_similarity=0.01)
result_loose = tool_search(query="JWT", max_distance=0.01, min_similarity=999.0)
assert len(result_strict["results"]) <= len(result_loose["results"])
def test_list_rooms_rejects_invalid_wing(self, monkeypatch, config, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
monkeypatch.setattr(mcp_server, "_get_collection", lambda *args, **kwargs: pytest.fail())
result = mcp_server.tool_list_rooms(wing="../etc/passwd")
assert "error" in result
def test_search_rejects_invalid_room(self, monkeypatch, config, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
monkeypatch.setattr(mcp_server, "search_memories", lambda *args, **kwargs: pytest.fail())
result = mcp_server.tool_search(query="JWT", room="../backend")
assert "error" in result
def test_list_drawers_rejects_invalid_wing(self, monkeypatch, config, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
monkeypatch.setattr(mcp_server, "_get_collection", lambda *args, **kwargs: pytest.fail())
result = mcp_server.tool_list_drawers(wing="../notes")
assert "error" in result
def test_find_tunnels_rejects_invalid_wing(self, monkeypatch, config, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
monkeypatch.setattr(mcp_server, "_get_collection", lambda *args, **kwargs: pytest.fail())
result = mcp_server.tool_find_tunnels(wing_a="../project")
assert "error" in result
def test_wal_redacts_sensitive_fields(self, monkeypatch, config, kg, tmp_path):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
wal_file = tmp_path / "write_log.jsonl"
monkeypatch.setattr(mcp_server, "_WAL_FILE", wal_file)
mcp_server._wal_log(
"test",
{"content": "secret note", "query": "private search", "safe": "ok"},
)
entry = json.loads(wal_file.read_text().strip())
assert entry["params"]["content"].startswith("[REDACTED")
assert entry["params"]["query"].startswith("[REDACTED")
assert entry["params"]["safe"] == "ok"
# ── Write Tools ─────────────────────────────────────────────────────────
@@ -287,6 +402,29 @@ class TestWriteTools:
assert result2["success"] is True
assert result2["reason"] == "already_exists"
def test_add_drawer_shared_header_no_collision(self, monkeypatch, config, palace_path, kg):
"""Documents sharing a >100-char header must get distinct IDs (full-content hash)."""
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace.mcp_server import tool_add_drawer
header = "# ACME Corp Knowledge Base\n**Project:** Alpha | **Team:** Backend | **Status:** Active\n\n"
doc1 = (
header
+ "Decision: Use PostgreSQL for primary storage. Rationale: ACID compliance required."
)
doc2 = header + "Decision: Use Redis for session caching. Rationale: sub-ms latency needed."
result1 = tool_add_drawer(wing="work", room="decisions", content=doc1)
result2 = tool_add_drawer(wing="work", room="decisions", content=doc2)
assert result1["success"] is True
assert result2["success"] is True
assert (
result1["drawer_id"] != result2["drawer_id"]
), "Documents with shared header but different content must have distinct drawer IDs"
def test_delete_drawer(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_delete_drawer
@@ -321,6 +459,107 @@ class TestWriteTools:
)
assert result["is_duplicate"] is False
def test_get_drawer(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_get_drawer
result = tool_get_drawer("drawer_proj_backend_aaa")
assert result["drawer_id"] == "drawer_proj_backend_aaa"
assert result["wing"] == "project"
assert result["room"] == "backend"
assert "JWT tokens" in result["content"]
def test_get_drawer_not_found(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_get_drawer
result = tool_get_drawer("nonexistent_drawer")
assert "error" in result
def test_list_drawers(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_drawers
result = tool_list_drawers()
assert result["count"] == 4
assert len(result["drawers"]) == 4
def test_list_drawers_with_wing_filter(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_drawers
result = tool_list_drawers(wing="project")
assert result["count"] == 3
assert all(d["wing"] == "project" for d in result["drawers"])
def test_list_drawers_with_room_filter(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_drawers
result = tool_list_drawers(wing="project", room="backend")
assert result["count"] == 2
assert all(d["room"] == "backend" for d in result["drawers"])
def test_list_drawers_pagination(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_drawers
result = tool_list_drawers(limit=2, offset=0)
assert result["count"] == 2
assert result["limit"] == 2
assert result["offset"] == 0
def test_list_drawers_negative_offset_clamped(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_drawers
result = tool_list_drawers(offset=-5)
assert result["offset"] == 0
def test_update_drawer_content(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_update_drawer, tool_get_drawer
result = tool_update_drawer(
"drawer_proj_backend_aaa", content="Updated content about auth."
)
assert result["success"] is True
fetched = tool_get_drawer("drawer_proj_backend_aaa")
assert fetched["content"] == "Updated content about auth."
def test_update_drawer_wing_and_room(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_update_drawer
result = tool_update_drawer("drawer_proj_backend_aaa", wing="new_wing", room="new_room")
assert result["success"] is True
assert result["wing"] == "new_wing"
assert result["room"] == "new_room"
def test_update_drawer_not_found(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_update_drawer
result = tool_update_drawer("nonexistent_drawer", content="hello")
assert result["success"] is False
def test_update_drawer_noop(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_update_drawer
result = tool_update_drawer("drawer_proj_backend_aaa")
assert result["success"] is True
assert result.get("noop") is True
# ── KG Tools ────────────────────────────────────────────────────────────
@@ -403,3 +642,104 @@ class TestDiaryTools:
r = tool_diary_read(agent_name="Nobody")
assert r["entries"] == []
# ── Cache Invalidation (inode/mtime) ──────────────────────────────────
class TestCacheInvalidation:
"""Tests for _get_collection inode/mtime cache invalidation logic."""
def test_mtime_change_invalidates_cache(self, monkeypatch, config, palace_path, kg):
"""When mtime changes, the cached collection should be replaced."""
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
# Create a real collection so _get_collection succeeds
_client, _col = _get_collection(palace_path, create=True)
del _client
# Prime the cache
col1 = mcp_server._get_collection()
assert col1 is not None
# Simulate an external write changing the mtime
old_mtime = mcp_server._palace_db_mtime
monkeypatch.setattr(mcp_server, "_palace_db_mtime", old_mtime - 10.0)
# _get_collection should detect the mtime drift and reconnect
col2 = mcp_server._get_collection()
assert col2 is not None
def test_inode_change_invalidates_cache(self, monkeypatch, config, palace_path, kg):
"""When inode changes (file replaced), the cached collection should be replaced."""
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
_client, _col = _get_collection(palace_path, create=True)
del _client
# Prime the cache
col1 = mcp_server._get_collection()
assert col1 is not None
# Simulate a rebuild that changes the inode
monkeypatch.setattr(mcp_server, "_palace_db_inode", 99999)
col2 = mcp_server._get_collection()
assert col2 is not None
@pytest.mark.skipif(
sys.platform == "win32",
reason="Windows holds chroma.sqlite3 open while the client is cached, blocking os.remove",
)
def test_missing_db_invalidates_cache(self, monkeypatch, config, palace_path, kg):
"""When chroma.sqlite3 disappears, a cached collection should be invalidated."""
_patch_mcp_server(monkeypatch, config, kg)
import os
from mempalace import mcp_server
_client, _col = _get_collection(palace_path, create=True)
del _client
# Prime the cache
col1 = mcp_server._get_collection()
assert col1 is not None
assert mcp_server._collection_cache is not None
# Delete the DB file to simulate a rebuild in progress
db_file = os.path.join(palace_path, "chroma.sqlite3")
if os.path.isfile(db_file):
os.remove(db_file)
# Cache should be invalidated; _get_collection returns None
# because the backend can't open a missing DB without create=True
mcp_server._get_collection()
# The key assertion: the old cached collection was dropped
assert mcp_server._palace_db_inode == 0
assert mcp_server._palace_db_mtime == 0.0
def test_reconnect_reports_failure_when_no_palace(self, monkeypatch, config, kg):
"""tool_reconnect should report failure when no collection is available."""
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
# Make _get_collection always return None
monkeypatch.setattr(mcp_server, "_get_collection", lambda create=False: None)
result = mcp_server.tool_reconnect()
assert result["success"] is False
assert "No palace found" in result["message"]
assert result["drawers"] == 0
def test_reconnect_reports_success(self, monkeypatch, config, palace_path, kg):
"""tool_reconnect should report success with drawer count."""
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace import mcp_server
result = mcp_server.tool_reconnect()
assert result["success"] is True
assert "Reconnected" in result["message"]
assert isinstance(result["drawers"], int)
+48
View File
@@ -0,0 +1,48 @@
"""Tests for destructive-operation safety in mempalace.migrate."""
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from mempalace.migrate import migrate
def test_migrate_requires_palace_database(tmp_path, capsys):
palace_dir = tmp_path / "palace"
palace_dir.mkdir()
result = migrate(str(palace_dir))
out = capsys.readouterr().out
assert result is False
assert "No palace database found" in out
def test_migrate_aborts_without_confirmation(tmp_path, capsys):
palace_dir = tmp_path / "palace"
palace_dir.mkdir()
# Presence of chroma.sqlite3 is the safety gate; validity is mocked below.
(palace_dir / "chroma.sqlite3").write_text("db")
mock_chromadb = SimpleNamespace(
__version__="0.6.0",
PersistentClient=MagicMock(side_effect=Exception("unreadable")),
)
with (
patch.dict("sys.modules", {"chromadb": mock_chromadb}),
patch("mempalace.migrate.detect_chromadb_version", return_value="0.5.x"),
patch(
"mempalace.migrate.extract_drawers_from_sqlite",
return_value=[{"id": "id1", "document": "doc", "metadata": {"wing": "w", "room": "r"}}],
),
patch("builtins.input", return_value="n"),
patch("mempalace.migrate.shutil.copytree") as mock_copytree,
patch("mempalace.migrate.shutil.rmtree") as mock_rmtree,
):
result = migrate(str(palace_dir))
out = capsys.readouterr().out
assert result is False
assert "Aborted." in out
mock_copytree.assert_not_called()
mock_rmtree.assert_not_called()
+37 -1
View File
@@ -6,7 +6,7 @@ from pathlib import Path
import chromadb
import yaml
from mempalace.miner import mine, scan_project
from mempalace.miner import mine, scan_project, status
from mempalace.palace import file_already_mined
@@ -260,3 +260,39 @@ def test_file_already_mined_check_mtime():
# Release ChromaDB file handles before cleanup (required on Windows)
del col, client
shutil.rmtree(tmpdir, ignore_errors=True)
def test_mine_dry_run_with_tiny_file_no_crash():
"""Dry-run must not crash when process_file returns 0 drawers (room was None)."""
tmpdir = tempfile.mkdtemp()
try:
project_root = Path(tmpdir).resolve()
# One normal file and one that falls below MIN_CHUNK_SIZE
write_file(project_root / "good.py", "def main():\n print('hello world')\n" * 20)
write_file(project_root / "tiny.txt", "x")
with open(project_root / "mempalace.yaml", "w") as f:
yaml.dump(
{
"wing": "test_project",
"rooms": [{"name": "general", "description": "General"}],
},
f,
)
palace_path = project_root / "palace"
# Should not raise TypeError on the summary print
mine(str(project_root), str(palace_path), dry_run=True)
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
def test_status_missing_palace_does_not_create_empty_collection(tmp_path, capsys):
palace_path = tmp_path / "missing-palace"
status(str(palace_path))
out = capsys.readouterr().out
assert "No palace found" in out
assert not palace_path.exists()
+541 -2
View File
@@ -3,6 +3,8 @@ from unittest.mock import patch
from mempalace.normalize import (
_extract_content,
_format_tool_result,
_format_tool_use,
_messages_to_transcript,
_try_chatgpt_json,
_try_claude_ai_json,
@@ -81,7 +83,7 @@ def test_extract_content_string():
def test_extract_content_list_of_strings():
assert _extract_content(["hello", "world"]) == "hello world"
assert _extract_content(["hello", "world"]) == "hello\nworld"
def test_extract_content_list_of_blocks():
@@ -99,7 +101,232 @@ def test_extract_content_none():
def test_extract_content_mixed_list():
blocks = ["plain", {"type": "text", "text": "block"}]
assert _extract_content(blocks) == "plain block"
assert _extract_content(blocks) == "plain\nblock"
# ── _format_tool_use ──────────────────────────────────────────────────
def test_format_tool_use_bash():
block = {
"type": "tool_use",
"id": "t1",
"name": "Bash",
"input": {"command": "lsusb | grep razer", "description": "Check USB"},
}
result = _format_tool_use(block)
assert result == "[Bash] lsusb | grep razer"
def test_format_tool_use_bash_truncates_long_command():
block = {"type": "tool_use", "id": "t1", "name": "Bash", "input": {"command": "x" * 300}}
result = _format_tool_use(block)
assert len(result) <= len("[Bash] ") + 200 + len("...")
assert result.endswith("...")
def test_format_tool_use_read():
block = {
"type": "tool_use",
"id": "t1",
"name": "Read",
"input": {"file_path": "/home/jp/file.py"},
}
result = _format_tool_use(block)
assert result == "[Read /home/jp/file.py]"
def test_format_tool_use_read_with_range():
block = {
"type": "tool_use",
"id": "t1",
"name": "Read",
"input": {"file_path": "/home/jp/file.py", "offset": 10, "limit": 50},
}
result = _format_tool_use(block)
assert result == "[Read /home/jp/file.py:10-60]"
def test_format_tool_use_grep():
block = {
"type": "tool_use",
"id": "t1",
"name": "Grep",
"input": {"pattern": "firmware", "path": "/home/jp/proj"},
}
result = _format_tool_use(block)
assert result == "[Grep] firmware in /home/jp/proj"
def test_format_tool_use_grep_with_glob():
block = {
"type": "tool_use",
"id": "t1",
"name": "Grep",
"input": {"pattern": "TODO", "glob": "*.py"},
}
result = _format_tool_use(block)
assert result == "[Grep] TODO in *.py"
def test_format_tool_use_glob():
block = {
"type": "tool_use",
"id": "t1",
"name": "Glob",
"input": {"pattern": "/home/jp/proj/**/*.py"},
}
result = _format_tool_use(block)
assert result == "[Glob] /home/jp/proj/**/*.py"
def test_format_tool_use_edit():
block = {
"type": "tool_use",
"id": "t1",
"name": "Edit",
"input": {"file_path": "/home/jp/file.py", "old_string": "x", "new_string": "y"},
}
result = _format_tool_use(block)
assert result == "[Edit /home/jp/file.py]"
def test_format_tool_use_write():
block = {
"type": "tool_use",
"id": "t1",
"name": "Write",
"input": {"file_path": "/home/jp/file.py", "content": "..."},
}
result = _format_tool_use(block)
assert result == "[Write /home/jp/file.py]"
def test_format_tool_use_unknown_tool():
block = {
"type": "tool_use",
"id": "t1",
"name": "mcp__mempalace__search",
"input": {"query": "firmware probe", "limit": 5},
}
result = _format_tool_use(block)
assert result.startswith("[mcp__mempalace__search]")
assert "firmware probe" in result
def test_format_tool_use_unknown_tool_truncates():
block = {"type": "tool_use", "id": "t1", "name": "SomeTool", "input": {"data": "x" * 300}}
result = _format_tool_use(block)
assert result.endswith("...")
assert len(result) <= len("[SomeTool] ") + 200 + len("...")
# ── _format_tool_result ──────────────────────────────────────────────
def test_format_tool_result_bash_short():
"""Short Bash output is preserved in full."""
content = "Bus 002 Device 005: ID 1532:0e05 Razer Kiyo Pro"
result = _format_tool_result(content, "Bash")
assert result == "→ Bus 002 Device 005: ID 1532:0e05 Razer Kiyo Pro"
def test_format_tool_result_bash_head_tail():
"""Long Bash output gets head+tail with gap marker."""
lines = [f"line {i}" for i in range(60)]
content = "\n".join(lines)
result = _format_tool_result(content, "Bash")
assert "line 0" in result
assert "line 19" in result
assert "line 40" in result
assert "line 59" in result
assert "20 lines omitted" in result
# Lines 20-39 should be gone
assert "line 20\n" not in result
def test_format_tool_result_bash_exactly_40_lines():
"""Bash output at exactly 40 lines is not truncated."""
lines = [f"line {i}" for i in range(40)]
content = "\n".join(lines)
result = _format_tool_result(content, "Bash")
assert "omitted" not in result
assert "line 0" in result
assert "line 39" in result
def test_format_tool_result_read_omitted():
"""Read results are omitted (content already in palace from project mining)."""
result = _format_tool_result("lots of file content here...", "Read")
assert result == ""
def test_format_tool_result_edit_omitted():
"""Edit results are omitted (diff is in git)."""
result = _format_tool_result("file updated", "Edit")
assert result == ""
def test_format_tool_result_write_omitted():
"""Write results are omitted."""
result = _format_tool_result("file created", "Write")
assert result == ""
def test_format_tool_result_grep_short():
"""Short Grep output is kept."""
content = "src/foo.py\nsrc/bar.py\nsrc/baz.py"
result = _format_tool_result(content, "Grep")
assert "→ src/foo.py" in result
assert "→ src/baz.py" in result
def test_format_tool_result_grep_caps_at_20():
"""Grep output beyond 20 lines is truncated."""
lines = [f"match_{i}.py" for i in range(30)]
content = "\n".join(lines)
result = _format_tool_result(content, "Grep")
assert "match_19.py" in result
assert "match_20.py" not in result
assert "10 more matches" in result
def test_format_tool_result_glob_caps_at_20():
"""Glob output beyond 20 lines is truncated."""
lines = [f"/path/file_{i}.py" for i in range(25)]
content = "\n".join(lines)
result = _format_tool_result(content, "Glob")
assert "file_19.py" in result
assert "file_20.py" not in result
assert "5 more matches" in result
def test_format_tool_result_unknown_short():
"""Unknown tool with short output is kept."""
result = _format_tool_result("some output", "mcp__mempalace__search")
assert result == "→ some output"
def test_format_tool_result_unknown_truncates():
"""Unknown tool output over 2KB is truncated."""
content = "x" * 3000
result = _format_tool_result(content, "SomeTool")
assert result.endswith("... [truncated, 3000 chars]")
assert len(result) < 2200
def test_format_tool_result_list_content():
"""tool_result content can be a list of text blocks."""
content = [{"type": "text", "text": "result line 1"}, {"type": "text", "text": "result line 2"}]
result = _format_tool_result(content, "Bash")
assert "result line 1" in result
assert "result line 2" in result
def test_format_tool_result_empty():
"""Empty result returns empty string."""
result = _format_tool_result("", "Bash")
assert result == ""
# ── _try_claude_code_jsonl ─────────────────────────────────────────────
@@ -297,6 +524,119 @@ def test_claude_ai_privacy_export_non_dict_items():
assert result is not None
def test_claude_ai_privacy_export_messages_key():
"""Privacy export using 'messages' key instead of 'chat_messages'."""
data = [
{
"uuid": "abc-123",
"name": "Test convo",
"messages": [
{"role": "human", "content": "Q1"},
{"role": "ai", "content": "A1"},
],
}
]
result = _try_claude_ai_json(data)
assert result is not None
assert "> Q1" in result
def test_claude_ai_privacy_export_sender_field():
"""Privacy export using 'sender' instead of 'role'."""
data = [
{
"chat_messages": [
{"sender": "human", "content": "Q1"},
{"sender": "assistant", "content": "A1"},
]
}
]
result = _try_claude_ai_json(data)
assert result is not None
assert "> Q1" in result
def test_claude_ai_privacy_export_text_fallback():
"""Privacy export where content is empty but text field has the message."""
data = [
{
"chat_messages": [
{"sender": "human", "text": "Q1", "content": []},
{"sender": "assistant", "text": "A1", "content": []},
]
}
]
result = _try_claude_ai_json(data)
assert result is not None
assert "> Q1" in result
def test_claude_ai_privacy_export_null_text():
"""Privacy export where text field is explicitly null must not crash."""
data = [
{
"chat_messages": [
{"sender": "human", "text": None, "content": "Q1"},
{"sender": "assistant", "text": None, "content": "A1"},
]
}
]
result = _try_claude_ai_json(data)
assert result is not None
assert "> Q1" in result
def test_claude_ai_privacy_export_per_conversation():
"""Multiple conversations produce separate transcripts."""
data = [
{
"uuid": "convo-1",
"chat_messages": [
{"role": "human", "content": "Q1"},
{"role": "ai", "content": "A1"},
],
},
{
"uuid": "convo-2",
"chat_messages": [
{"role": "human", "content": "Q2"},
{"role": "ai", "content": "A2"},
],
},
]
result = _try_claude_ai_json(data)
assert result is not None
assert "> Q1" in result
assert "> Q2" in result
# each conversation is a separate transcript block
parts = result.split("\n\n")
q1_parts = [p for p in parts if "> Q1" in p]
q2_parts = [p for p in parts if "> Q2" in p]
assert len(q1_parts) >= 1
assert len(q2_parts) >= 1
def test_claude_ai_privacy_export_skips_empty_conversations():
"""Conversations with <2 messages are skipped."""
data = [
{
"chat_messages": [
{"role": "human", "content": "lonely message"},
],
},
{
"chat_messages": [
{"role": "human", "content": "Q1"},
{"role": "ai", "content": "A1"},
],
},
]
result = _try_claude_ai_json(data)
assert result is not None
assert "lonely message" not in result
assert "> Q1" in result
# ── _try_chatgpt_json ─────────────────────────────────────────────────
@@ -501,6 +841,205 @@ def test_messages_to_transcript_assistant_first():
assert "> Q" in result
# ── Tool block integration (Task 3) ───────────────────────────────────
def test_extract_content_with_tool_use():
"""_extract_content includes formatted tool_use blocks."""
content = [
{"type": "text", "text": "Let me check."},
{"type": "tool_use", "id": "t1", "name": "Bash", "input": {"command": "lsusb"}},
]
result = _extract_content(content)
assert "Let me check." in result
assert "[Bash] lsusb" in result
def test_extract_content_with_tool_result():
"""_extract_content includes formatted tool_result blocks (needs tool_use_map)."""
content = [
{"type": "tool_result", "tool_use_id": "t1", "content": "some output"},
]
result = _extract_content(content, tool_use_map={"t1": "Bash"})
assert "→ some output" in result
def test_extract_content_tool_result_without_map_uses_fallback():
"""tool_result without a map entry uses fallback strategy."""
content = [
{"type": "tool_result", "tool_use_id": "t1", "content": "some output"},
]
result = _extract_content(content)
assert "→ some output" in result
def test_claude_code_jsonl_captures_tool_output():
"""Full integration: tool_use + tool_result appear in normalized transcript."""
lines = [
json.dumps({"type": "human", "message": {"content": "Check the camera"}}),
json.dumps(
{
"type": "assistant",
"message": {
"content": [
{"type": "text", "text": "Let me check."},
{
"type": "tool_use",
"id": "t1",
"name": "Bash",
"input": {"command": "lsusb | grep razer"},
},
]
},
}
),
json.dumps(
{
"type": "human",
"message": {
"content": [
{
"type": "tool_result",
"tool_use_id": "t1",
"content": "Bus 002 Device 005: ID 1532:0e05 Razer Kiyo Pro",
},
]
},
}
),
json.dumps({"type": "assistant", "message": {"content": "Found it."}}),
]
result = _try_claude_code_jsonl("\n".join(lines))
assert result is not None
assert "> Check the camera" in result
assert "[Bash] lsusb | grep razer" in result
assert "→ Bus 002 Device 005" in result
assert "Found it." in result
def test_claude_code_jsonl_read_result_omitted():
"""Read tool results are omitted but the path breadcrumb is kept."""
lines = [
json.dumps({"type": "human", "message": {"content": "Show me the file"}}),
json.dumps(
{
"type": "assistant",
"message": {
"content": [
{"type": "text", "text": "Reading it."},
{
"type": "tool_use",
"id": "t1",
"name": "Read",
"input": {"file_path": "/home/jp/file.py"},
},
]
},
}
),
json.dumps(
{
"type": "human",
"message": {
"content": [
{
"type": "tool_result",
"tool_use_id": "t1",
"content": "entire file contents here that should not appear",
},
]
},
}
),
json.dumps({"type": "assistant", "message": {"content": "Here it is."}}),
]
result = _try_claude_code_jsonl("\n".join(lines))
assert result is not None
assert "[Read /home/jp/file.py]" in result
assert "entire file contents here" not in result
def test_claude_code_jsonl_tool_only_user_message_not_counted():
"""A user message containing ONLY tool_results (no text) should not
be added as a separate user turn with '>'."""
lines = [
json.dumps({"type": "human", "message": {"content": "Do it"}}),
json.dumps(
{
"type": "assistant",
"message": {
"content": [
{"type": "text", "text": "Running."},
{
"type": "tool_use",
"id": "t1",
"name": "Bash",
"input": {"command": "echo hi"},
},
]
},
}
),
json.dumps(
{
"type": "human",
"message": {
"content": [
{"type": "tool_result", "tool_use_id": "t1", "content": "hi"},
]
},
}
),
json.dumps({"type": "assistant", "message": {"content": "Done."}}),
]
result = _try_claude_code_jsonl("\n".join(lines))
assert result is not None
# Only one user turn marker — the original "Do it"
user_turns = [line for line in result.split("\n") if line.strip().startswith(">")]
assert len(user_turns) == 1
assert "> Do it" in result
def test_extract_content_text_only_backward_compat():
"""Text-only content blocks still work (backward compat)."""
content = [
{"type": "text", "text": "Hello"},
{"type": "text", "text": "World"},
]
result = _extract_content(content)
assert "Hello" in result
assert "World" in result
def test_extract_content_string_unchanged():
"""Plain string content still works."""
result = _extract_content("just a string")
assert result == "just a string"
def test_claude_code_jsonl_thinking_blocks_ignored():
"""Thinking blocks are still ignored."""
lines = [
json.dumps({"type": "human", "message": {"content": "Q"}}),
json.dumps(
{
"type": "assistant",
"message": {
"content": [
{"type": "thinking", "thinking": "", "signature": "abc"},
{"type": "text", "text": "A"},
]
},
}
),
]
result = _try_claude_code_jsonl("\n".join(lines))
assert result is not None
assert "thinking" not in result.lower()
assert "signature" not in result
assert "A" in result
def test_normalize_rejects_large_file():
"""Files over 500 MB should raise IOError before reading."""
with patch("mempalace.normalize.os.path.getsize", return_value=600 * 1024 * 1024):
+24
View File
@@ -102,6 +102,21 @@ class TestTailSentence:
assert result["was_sanitized"] is True
assert "MemPalace" in result["clean_query"] or "ChromaDB" in result["clean_query"]
def test_long_candidate_uses_last_sentence_fragment(self):
query = ("Prompt sentence. " * 30) + "Final search intent for architecture migration"
result = sanitize_query(query)
assert result["method"] == "tail_sentence"
assert result["clean_query"] == "Final search intent for architecture migration"
def test_long_candidate_strips_wrapping_quotes(self):
query = ("Prefix text " * 30) + '\n"' + ("x" * 260) + '"'
result = sanitize_query(query)
assert result["method"] == "tail_sentence"
assert result["clean_query"] == "x" * MAX_QUERY_LENGTH
assert not result["clean_query"].startswith('"')
assert not result["clean_query"].endswith('"')
assert len(result["clean_query"]) <= MAX_QUERY_LENGTH
class TestTailTruncation:
"""Step 4: Fallback — take the last MAX_QUERY_LENGTH characters."""
@@ -119,10 +134,19 @@ class TestTailTruncation:
result = sanitize_query(filler)
assert "IMPORTANT_QUERY_CONTENT" in result["clean_query"]
def test_tail_sentence_fallback_preserves_tail_without_delimiters(self):
filler = ("x" * 260) + "IMPORTANT_QUERY_CONTENT"
result = sanitize_query(filler)
assert result["method"] == "tail_sentence"
assert "IMPORTANT_QUERY_CONTENT" in result["clean_query"]
class TestLengthGuards:
"""Verify output length constraints."""
def test_max_query_length_reduced(self):
assert MAX_QUERY_LENGTH == 250
def test_output_never_exceeds_max(self):
# Very long question sentence
long_question = "a" * 1000 + "?"
+76
View File
@@ -59,6 +59,82 @@ def test_detect_rooms_from_folders_empty_dir(tmp_path):
assert any(r["name"] == "general" for r in rooms)
def test_detect_rooms_from_folders_skips_oserror_at_top_level(tmp_path):
"""Windows reparse points (junctions, untrusted mount points) raise OSError
when stat()'d. The scanner must skip them and continue — not crash.
Reproduces WinError 448: "The path cannot be traversed because it contains
an untrusted mount point", seen on Windows when a project folder contains
a git-submodule junction or a dev-drive reparse point.
"""
(tmp_path / "frontend").mkdir()
bad = tmp_path / "untrusted_junction"
bad.mkdir()
original_is_dir = bad.__class__.is_dir
def patched_is_dir(self):
if self == bad:
raise OSError(
"[WinError 448] The path cannot be traversed because it contains an untrusted mount point"
)
return original_is_dir(self)
with patch.object(bad.__class__, "is_dir", patched_is_dir):
rooms = detect_rooms_from_folders(str(tmp_path))
room_names = {r["name"] for r in rooms}
assert "frontend" in room_names
def test_detect_rooms_from_folders_skips_oserror_nested(tmp_path):
"""Same WinError 448 guard applies one level deeper (nested iterdir pass)."""
skills = tmp_path / "skills"
skills.mkdir()
(skills / "docs").mkdir()
bad = skills / "bad_junction"
bad.mkdir()
original_is_dir = bad.__class__.is_dir
def patched_is_dir(self):
if self == bad:
raise OSError(
"[WinError 448] The path cannot be traversed because it contains an untrusted mount point"
)
return original_is_dir(self)
with patch.object(bad.__class__, "is_dir", patched_is_dir):
rooms = detect_rooms_from_folders(str(tmp_path))
room_names = {r["name"] for r in rooms}
assert "documentation" in room_names
def test_detect_rooms_from_folders_skips_iterdir_oserror(tmp_path):
"""iterdir() itself can raise OSError on some Windows reparse points even
when is_dir() succeeds. The nested pass must guard the iterdir() call too."""
outer = tmp_path / "src"
outer.mkdir()
(tmp_path / "docs").mkdir()
original_iterdir = outer.__class__.iterdir
def patched_iterdir(self):
if self == outer:
raise OSError(
"[WinError 448] The path cannot be traversed because it contains an untrusted mount point"
)
return original_iterdir(self)
with patch.object(outer.__class__, "iterdir", patched_iterdir):
rooms = detect_rooms_from_folders(str(tmp_path))
room_names = {r["name"] for r in rooms}
# docs is accessible; src fails on iterdir — neither should crash
assert "documentation" in room_names
def test_detect_rooms_from_folders_skips_git(tmp_path):
(tmp_path / ".git").mkdir()
(tmp_path / "node_modules").mkdir()
+2 -6
View File
@@ -56,10 +56,8 @@ class TestSearchMemories:
"""search_memories returns error dict when query raises."""
mock_col = MagicMock()
mock_col.query.side_effect = RuntimeError("query failed")
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
with patch("mempalace.searcher.chromadb.PersistentClient", return_value=mock_client):
with patch("mempalace.searcher.get_collection", return_value=mock_col):
result = search_memories("test", "/fake/path")
assert "error" in result
assert "query failed" in result["error"]
@@ -111,10 +109,8 @@ class TestSearchCLI:
"""search raises SearchError when query fails."""
mock_col = MagicMock()
mock_col.query.side_effect = RuntimeError("boom")
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
with patch("mempalace.searcher.chromadb.PersistentClient", return_value=mock_client):
with patch("mempalace.searcher.get_collection", return_value=mock_col):
with pytest.raises(SearchError, match="Search error"):
search("test", "/fake/path")
+36
View File
@@ -0,0 +1,36 @@
# dependencies (bun install)
node_modules
# output
out
dist
.vitepress/dist
.vitepress/.temp
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
*.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
+112
View File
@@ -0,0 +1,112 @@
import { defineConfig } from 'vitepress'
import { withMermaid } from 'vitepress-plugin-mermaid'
function normalizeBase(base?: string): string {
if (!base || base === '/') {
return '/'
}
return base.endsWith('/') ? base : `${base}/`
}
const docsBase = normalizeBase(process.env.DOCS_BASE || '/mempalace/')
const editBranch = process.env.DOCS_EDIT_BRANCH || 'main'
export default withMermaid(
defineConfig({
title: 'MemPalace',
description: 'Give your AI a memory. Local-first storage and retrieval for AI workflows, with benchmark results and MCP tooling.',
base: docsBase,
head: [
['link', { rel: 'icon', href: `${docsBase}mempalace_logo.png` }],
['link', { rel: 'preconnect', href: 'https://fonts.googleapis.com' }],
['link', { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' }],
['link', { href: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap', rel: 'stylesheet' }],
['meta', { property: 'og:title', content: 'MemPalace — AI Memory System' }],
['meta', { property: 'og:description', content: '96.6% LongMemEval recall. Zero API calls. Local, free, open source.' }],
['meta', { property: 'og:image', content: `${docsBase}mempalace_logo.png` }],
],
themeConfig: {
logo: '/mempalace_logo.png',
siteTitle: 'MemPalace',
nav: [
{ text: 'Guide', link: '/guide/getting-started' },
{ text: 'Concepts', link: '/concepts/the-palace' },
{ text: 'Reference', link: '/reference/cli' },
],
sidebar: {
'/guide/': [
{
text: 'Guide',
items: [
{ text: 'Getting Started', link: '/guide/getting-started' },
{ text: 'Mining Your Data', link: '/guide/mining' },
{ text: 'Searching Memories', link: '/guide/searching' },
{ text: 'MCP Integration', link: '/guide/mcp-integration' },
{ text: 'Claude Code Plugin', link: '/guide/claude-code' },
{ text: 'Gemini CLI', link: '/guide/gemini-cli' },
{ text: 'OpenClaw Skill', link: '/guide/openclaw' },
{ text: 'Local Models', link: '/guide/local-models' },
{ text: 'Auto-Save Hooks', link: '/guide/hooks' },
{ text: 'Configuration', link: '/guide/configuration' },
],
},
],
'/concepts/': [
{
text: 'Concepts',
items: [
{ text: 'The Palace', link: '/concepts/the-palace' },
{ text: 'Memory Stack', link: '/concepts/memory-stack' },
{ text: 'AAAK Dialect', link: '/concepts/aaak-dialect' },
{ text: 'Knowledge Graph', link: '/concepts/knowledge-graph' },
{ text: 'Specialist Agents', link: '/concepts/agents' },
{ text: 'Contradiction Detection', link: '/concepts/contradiction-detection' },
],
},
],
'/reference/': [
{
text: 'Reference',
items: [
{ text: 'CLI Commands', link: '/reference/cli' },
{ text: 'MCP Tools', link: '/reference/mcp-tools' },
{ text: 'Python API', link: '/reference/python-api' },
{ text: 'API Reference', link: '/reference/api-reference' },
{ text: 'Module Map', link: '/reference/modules' },
{ text: 'Benchmarks', link: '/reference/benchmarks' },
{ text: 'Contributing', link: '/reference/contributing' },
],
},
],
},
socialLinks: [
{ icon: 'github', link: 'https://github.com/milla-jovovich/mempalace' },
{ icon: 'discord', link: 'https://discord.com/invite/ycTQQCu6kn' },
],
search: {
provider: 'local',
},
footer: {
message: 'Released under the MIT License.',
copyright: 'Copyright © 2026 MemPalace contributors',
},
editLink: {
pattern: `https://github.com/milla-jovovich/mempalace/edit/${editBranch}/website/:path`,
text: 'Edit this page on GitHub',
},
},
mermaid: {
theme: 'dark',
},
})
)
+6
View File
@@ -0,0 +1,6 @@
import DefaultTheme from 'vitepress/theme'
import './style.css'
export default {
extends: DefaultTheme,
}
+215
View File
@@ -0,0 +1,215 @@
/* ── MemPalace Custom Theme ──────────────────────────────────────────── */
/* Deep indigo / cyan palette — evoking architectural grandeur */
:root {
/* Brand palette */
--mp-indigo: #4f46e5;
--mp-indigo-light: #6366f1;
--mp-indigo-dark: #3730a3;
--mp-cyan: #06b6d4;
--mp-cyan-light: #22d3ee;
--mp-purple: #8b5cf6;
--mp-purple-light: #a78bfa;
--mp-emerald: #10b981;
--mp-amber: #f59e0b;
/* VitePress overrides */
--vp-c-brand-1: var(--mp-indigo);
--vp-c-brand-2: var(--mp-indigo-light);
--vp-c-brand-3: var(--mp-purple);
--vp-c-brand-soft: rgba(79, 70, 229, 0.14);
--vp-font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--vp-font-family-mono: 'JetBrains Mono', 'Fira Code', monospace;
/* Home hero gradient */
--vp-home-hero-name-color: transparent;
--vp-home-hero-name-background: linear-gradient(135deg, var(--mp-indigo) 0%, var(--mp-cyan) 50%, var(--mp-purple) 100%);
--vp-home-hero-image-background-image: linear-gradient(135deg, rgba(79, 70, 229, 0.25) 0%, rgba(6, 182, 212, 0.25) 50%, rgba(139, 92, 246, 0.15) 100%);
--vp-home-hero-image-filter: blur(56px);
/* Button colors */
--vp-button-brand-border: transparent;
--vp-button-brand-text: #ffffff;
--vp-button-brand-bg: var(--mp-indigo);
--vp-button-brand-hover-border: transparent;
--vp-button-brand-hover-text: #ffffff;
--vp-button-brand-hover-bg: var(--mp-indigo-light);
--vp-button-alt-border: rgba(79, 70, 229, 0.25);
--vp-button-alt-text: var(--mp-indigo);
--vp-button-alt-bg: rgba(79, 70, 229, 0.08);
--vp-button-alt-hover-border: rgba(79, 70, 229, 0.4);
--vp-button-alt-hover-text: var(--mp-indigo-dark);
--vp-button-alt-hover-bg: rgba(79, 70, 229, 0.14);
}
/* Dark mode overrides */
.dark {
--vp-c-brand-1: var(--mp-cyan-light);
--vp-c-brand-2: var(--mp-cyan);
--vp-c-brand-3: var(--mp-purple-light);
--vp-c-brand-soft: rgba(6, 182, 212, 0.14);
--vp-button-brand-bg: var(--mp-indigo-light);
--vp-button-brand-hover-bg: var(--mp-indigo);
--vp-button-alt-border: rgba(34, 211, 238, 0.25);
--vp-button-alt-text: var(--mp-cyan-light);
--vp-button-alt-bg: rgba(34, 211, 238, 0.08);
--vp-button-alt-hover-border: rgba(34, 211, 238, 0.4);
--vp-button-alt-hover-text: var(--mp-cyan);
--vp-button-alt-hover-bg: rgba(34, 211, 238, 0.14);
--vp-home-hero-image-background-image: linear-gradient(135deg, rgba(99, 102, 241, 0.3) 0%, rgba(6, 182, 212, 0.3) 50%, rgba(139, 92, 246, 0.2) 100%);
}
/* ── Hero section ───────────────────────────────────────────────────── */
.VPHero .VPImage {
max-width: 180px;
border-radius: 20px;
}
.VPHero .name {
font-weight: 700 !important;
}
.VPHero .text {
font-weight: 500;
background: linear-gradient(135deg, var(--vp-c-text-1) 0%, var(--mp-indigo-light) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.dark .VPHero .text {
background: linear-gradient(135deg, var(--vp-c-text-1) 0%, var(--mp-cyan-light) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ── Feature cards ──────────────────────────────────────────────────── */
.VPFeature {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid var(--vp-c-divider);
}
.VPFeature:hover {
transform: translateY(-4px);
border-color: var(--mp-indigo);
box-shadow: 0 12px 40px rgba(79, 70, 229, 0.12);
}
.dark .VPFeature:hover {
border-color: var(--mp-cyan);
box-shadow: 0 12px 40px rgba(6, 182, 212, 0.12);
}
.VPFeature .title {
font-weight: 600;
}
/* ── Sidebar ────────────────────────────────────────────────────────── */
.VPSidebar .VPSidebarItem .text {
transition: color 0.2s ease;
}
.VPSidebar .VPSidebarItem.is-active .text {
color: var(--mp-indigo) !important;
font-weight: 600;
}
.dark .VPSidebar .VPSidebarItem.is-active .text {
color: var(--mp-cyan-light) !important;
}
/* ── Code blocks ────────────────────────────────────────────────────── */
.vp-doc div[class*='language-'] {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
}
.dark .vp-doc div[class*='language-'] {
border-color: rgba(6, 182, 212, 0.15);
}
/* ── Custom containers ──────────────────────────────────────────────── */
.vp-doc .custom-block.tip {
border-color: var(--mp-cyan);
}
.vp-doc .custom-block.warning {
border-color: var(--mp-amber);
}
.vp-doc .custom-block.info {
border-color: var(--mp-indigo);
}
/* ── Tables ─────────────────────────────────────────────────────────── */
.vp-doc table {
border-radius: 8px;
overflow: hidden;
}
.vp-doc th {
background: rgba(79, 70, 229, 0.06);
font-weight: 600;
}
.dark .vp-doc th {
background: rgba(6, 182, 212, 0.08);
}
/* ── Nav ────────────────────────────────────────────────────────────── */
.VPNavBar .VPNavBarTitle .title {
font-weight: 700;
letter-spacing: -0.01em;
}
/* ── Footer ─────────────────────────────────────────────────────────── */
.VPFooter {
border-top: 1px solid var(--vp-c-divider);
}
/* ── Scrollbar ──────────────────────────────────────────────────────── */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-thumb {
background: var(--vp-c-divider);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--mp-indigo);
}
.dark ::-webkit-scrollbar-thumb:hover {
background: var(--mp-cyan);
}
/* ── Smooth transitions ─────────────────────────────────────────────── */
a, .VPLink {
transition: color 0.2s ease;
}
.VPButton {
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.VPButton:hover {
transform: translateY(-1px);
}
+633
View File
@@ -0,0 +1,633 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "website",
"devDependencies": {
"@lucide/vue": "^1.8.0",
"mermaid": "^11.14.0",
"vitepress": "^1.6.4",
"vitepress-plugin-mermaid": "^2.0.17",
"vue": "^3.5.32",
},
},
},
"packages": {
"@algolia/abtesting": ["@algolia/abtesting@1.16.1", "", { "dependencies": { "@algolia/client-common": "5.50.1", "@algolia/requester-browser-xhr": "5.50.1", "@algolia/requester-fetch": "5.50.1", "@algolia/requester-node-http": "5.50.1" } }, "sha512-Xxk4l00pYI+jE0PNw8y0MvsQWh5278WRtZQav8/BMMi3HKi2xmeuqe11WJ3y8/6nuBHdv39w76OpJb09TMfAVQ=="],
"@algolia/autocomplete-core": ["@algolia/autocomplete-core@1.17.7", "", { "dependencies": { "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", "@algolia/autocomplete-shared": "1.17.7" } }, "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q=="],
"@algolia/autocomplete-plugin-algolia-insights": ["@algolia/autocomplete-plugin-algolia-insights@1.17.7", "", { "dependencies": { "@algolia/autocomplete-shared": "1.17.7" }, "peerDependencies": { "search-insights": ">= 1 < 3" } }, "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A=="],
"@algolia/autocomplete-preset-algolia": ["@algolia/autocomplete-preset-algolia@1.17.7", "", { "dependencies": { "@algolia/autocomplete-shared": "1.17.7" }, "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", "algoliasearch": ">= 4.9.1 < 6" } }, "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA=="],
"@algolia/autocomplete-shared": ["@algolia/autocomplete-shared@1.17.7", "", { "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", "algoliasearch": ">= 4.9.1 < 6" } }, "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg=="],
"@algolia/client-abtesting": ["@algolia/client-abtesting@5.50.1", "", { "dependencies": { "@algolia/client-common": "5.50.1", "@algolia/requester-browser-xhr": "5.50.1", "@algolia/requester-fetch": "5.50.1", "@algolia/requester-node-http": "5.50.1" } }, "sha512-4peZlPXMwTOey9q1rQKMdCnwZb/E95/1e+7KujXpLLSh0FawJzg//U2NM+r4AiJy4+naT2MTBhj0K30yshnVTA=="],
"@algolia/client-analytics": ["@algolia/client-analytics@5.50.1", "", { "dependencies": { "@algolia/client-common": "5.50.1", "@algolia/requester-browser-xhr": "5.50.1", "@algolia/requester-fetch": "5.50.1", "@algolia/requester-node-http": "5.50.1" } }, "sha512-i+aWHHG8NZvGFHtPeMZkxL2Loc6Fm7iaRo15lYSMx8gFL+at9vgdWxhka7mD1fqxkrxXsQstUBCIsSY8FvkEOw=="],
"@algolia/client-common": ["@algolia/client-common@5.50.1", "", {}, "sha512-Hw52Fwapyk/7hMSV/fI4+s3H9MGZEUcRh4VphyXLAk2oLYdndVUkc6KBi0zwHSzwPAr+ZBwFPe2x6naUt9mZGw=="],
"@algolia/client-insights": ["@algolia/client-insights@5.50.1", "", { "dependencies": { "@algolia/client-common": "5.50.1", "@algolia/requester-browser-xhr": "5.50.1", "@algolia/requester-fetch": "5.50.1", "@algolia/requester-node-http": "5.50.1" } }, "sha512-Bn/wtwhJ7p1OD/6pY+Zzn+zlu2N/SJnH46md/PAbvqIzmjVuwjNwD4y0vV5Ov8naeukXdd7UU9v550+v8+mtlg=="],
"@algolia/client-personalization": ["@algolia/client-personalization@5.50.1", "", { "dependencies": { "@algolia/client-common": "5.50.1", "@algolia/requester-browser-xhr": "5.50.1", "@algolia/requester-fetch": "5.50.1", "@algolia/requester-node-http": "5.50.1" } }, "sha512-0V4Tu0RWR8YxkgI9EPVOZHGE4K5pEIhkLNN0CTkP/rnPsqaaSQpNMYW3/mGWdiKOWbX0iVmwLB9QESk3H0jS5g=="],
"@algolia/client-query-suggestions": ["@algolia/client-query-suggestions@5.50.1", "", { "dependencies": { "@algolia/client-common": "5.50.1", "@algolia/requester-browser-xhr": "5.50.1", "@algolia/requester-fetch": "5.50.1", "@algolia/requester-node-http": "5.50.1" } }, "sha512-jofcWNYMXJDDr87Z2eivlWY6o71Zn7F7aOvQCXSDAo9QTlyf7BhXEsZymLUvF0O1yU9Q9wvrjAWn8uVHYnAvgw=="],
"@algolia/client-search": ["@algolia/client-search@5.50.1", "", { "dependencies": { "@algolia/client-common": "5.50.1", "@algolia/requester-browser-xhr": "5.50.1", "@algolia/requester-fetch": "5.50.1", "@algolia/requester-node-http": "5.50.1" } }, "sha512-OteRb8WubcmEvU0YlMJwCXs3Q6xrdkb0v50/qZBJP1TF0CvujFZQM++9BjEkTER/Jr9wbPHvjSFKnbMta0b4dQ=="],
"@algolia/ingestion": ["@algolia/ingestion@1.50.1", "", { "dependencies": { "@algolia/client-common": "5.50.1", "@algolia/requester-browser-xhr": "5.50.1", "@algolia/requester-fetch": "5.50.1", "@algolia/requester-node-http": "5.50.1" } }, "sha512-0GmfSgDQK6oiIVXnJvGxtNFOfosBspRTR7csCOYCTL1P8QtxX2vDCIKwTM7xdSAEbJaZ43QlWg25q0Qdsndz8Q=="],
"@algolia/monitoring": ["@algolia/monitoring@1.50.1", "", { "dependencies": { "@algolia/client-common": "5.50.1", "@algolia/requester-browser-xhr": "5.50.1", "@algolia/requester-fetch": "5.50.1", "@algolia/requester-node-http": "5.50.1" } }, "sha512-ySuigKEe4YjYV3si8NVk9BHQpFj/1B+ON7DhhvTvbrZJseHQQloxzq0yHwKmznSdlO6C956fx4pcfOKkZClsyg=="],
"@algolia/recommend": ["@algolia/recommend@5.50.1", "", { "dependencies": { "@algolia/client-common": "5.50.1", "@algolia/requester-browser-xhr": "5.50.1", "@algolia/requester-fetch": "5.50.1", "@algolia/requester-node-http": "5.50.1" } }, "sha512-Cp8T/B0gVmjFlzzp6eP47hwKh5FGyeqQp1N48/ANDdvdiQkPqLyFHQVDwLBH0LddfIPQE+yqmZIgmKc82haF4A=="],
"@algolia/requester-browser-xhr": ["@algolia/requester-browser-xhr@5.50.1", "", { "dependencies": { "@algolia/client-common": "5.50.1" } }, "sha512-XKdGGLikfrlK66ZSXh/vWcXZZ8Vg3byDFbJD8pwEvN1FoBRGxhxya476IY2ohoTymLa4qB5LBRlIa+2TLHx3Uw=="],
"@algolia/requester-fetch": ["@algolia/requester-fetch@5.50.1", "", { "dependencies": { "@algolia/client-common": "5.50.1" } }, "sha512-mBAU6WyVsDwhHyGM+nodt1/oebHxgvuLlOAoMGbj/1i6LygDHZWDgL1t5JEs37x9Aywv7ZGhqbM1GsfZ54sU6g=="],
"@algolia/requester-node-http": ["@algolia/requester-node-http@5.50.1", "", { "dependencies": { "@algolia/client-common": "5.50.1" } }, "sha512-qmo1LXrNKLHvJE6mdQbLnsZAoZvj7VyF2ft4xmbSGWI2WWm87fx/CjUX4kEExt4y0a6T6nEts6ofpUfH5TEE1A=="],
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="],
"@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@12.0.0", "", { "dependencies": { "@chevrotain/gast": "12.0.0", "@chevrotain/types": "12.0.0" } }, "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg=="],
"@chevrotain/gast": ["@chevrotain/gast@12.0.0", "", { "dependencies": { "@chevrotain/types": "12.0.0" } }, "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ=="],
"@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@12.0.0", "", {}, "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA=="],
"@chevrotain/types": ["@chevrotain/types@12.0.0", "", {}, "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA=="],
"@chevrotain/utils": ["@chevrotain/utils@12.0.0", "", {}, "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA=="],
"@docsearch/css": ["@docsearch/css@3.8.2", "", {}, "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ=="],
"@docsearch/js": ["@docsearch/js@3.8.2", "", { "dependencies": { "@docsearch/react": "3.8.2", "preact": "^10.0.0" } }, "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ=="],
"@docsearch/react": ["@docsearch/react@3.8.2", "", { "dependencies": { "@algolia/autocomplete-core": "1.17.7", "@algolia/autocomplete-preset-algolia": "1.17.7", "@docsearch/css": "3.8.2", "algoliasearch": "^5.14.2" }, "peerDependencies": { "@types/react": ">= 16.8.0 < 19.0.0", "react": ">= 16.8.0 < 19.0.0", "react-dom": ">= 16.8.0 < 19.0.0", "search-insights": ">= 1 < 3" }, "optionalPeers": ["@types/react", "react", "react-dom", "search-insights"] }, "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
"@iconify-json/simple-icons": ["@iconify-json/simple-icons@1.2.77", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-oaENvo6C3BkAEWMlcQA3XemxU9v2SFOTlApSUCODAkIu1haeLCjzrmH3HgmGqjRnJjM+LevO8sA+MgdMHBFBDA=="],
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
"@iconify/utils": ["@iconify/utils@3.1.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "mlly": "^1.8.0" } }, "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@lucide/vue": ["@lucide/vue@1.8.0", "", { "peerDependencies": { "vue": ">=3.0.1" } }, "sha512-Rgy2rxfOx9yP6fWneE3QO6xwUbF2o7f9+MRbzGLRakee4tzUeVWHdX23uRH4ymwEzoq2+8vqRI9yGsxeZhYlWw=="],
"@mermaid-js/mermaid-mindmap": ["@mermaid-js/mermaid-mindmap@9.3.0", "", { "dependencies": { "@braintree/sanitize-url": "^6.0.0", "cytoscape": "^3.23.0", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.1.0", "d3": "^7.0.0", "khroma": "^2.0.0", "non-layered-tidy-tree-layout": "^2.0.2" } }, "sha512-IhtYSVBBRYviH1Ehu8gk69pMDF8DSRqXBRDMWrEfHoaMruHeaP2DXA3PBnuwsMaCdPQhlUUcy/7DBLAEIXvCAw=="],
"@mermaid-js/parser": ["@mermaid-js/parser@1.1.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="],
"@shikijs/core": ["@shikijs/core@2.5.0", "", { "dependencies": { "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^3.1.0" } }, "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw=="],
"@shikijs/langs": ["@shikijs/langs@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0" } }, "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w=="],
"@shikijs/themes": ["@shikijs/themes@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0" } }, "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw=="],
"@shikijs/transformers": ["@shikijs/transformers@2.5.0", "", { "dependencies": { "@shikijs/core": "2.5.0", "@shikijs/types": "2.5.0" } }, "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg=="],
"@shikijs/types": ["@shikijs/types@2.5.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw=="],
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
"@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="],
"@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="],
"@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="],
"@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="],
"@types/d3-dispatch": ["@types/d3-dispatch@3.0.7", "", {}, "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="],
"@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="],
"@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="],
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="],
"@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="],
"@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="],
"@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="],
"@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="],
"@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="],
"@types/d3-random": ["@types/d3-random@3.0.3", "", {}, "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="],
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="],
"@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="],
"@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="],
"@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="],
"@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="],
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="],
"@vue/compiler-core": ["@vue/compiler-core@3.5.32", "", { "dependencies": { "@babel/parser": "^7.29.2", "@vue/shared": "3.5.32", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ=="],
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.32", "", { "dependencies": { "@vue/compiler-core": "3.5.32", "@vue/shared": "3.5.32" } }, "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q=="],
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.32", "", { "dependencies": { "@babel/parser": "^7.29.2", "@vue/compiler-core": "3.5.32", "@vue/compiler-dom": "3.5.32", "@vue/compiler-ssr": "3.5.32", "@vue/shared": "3.5.32", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.8", "source-map-js": "^1.2.1" } }, "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg=="],
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.32", "", { "dependencies": { "@vue/compiler-dom": "3.5.32", "@vue/shared": "3.5.32" } }, "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw=="],
"@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="],
"@vue/devtools-kit": ["@vue/devtools-kit@7.7.9", "", { "dependencies": { "@vue/devtools-shared": "^7.7.9", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA=="],
"@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="],
"@vue/reactivity": ["@vue/reactivity@3.5.32", "", { "dependencies": { "@vue/shared": "3.5.32" } }, "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ=="],
"@vue/runtime-core": ["@vue/runtime-core@3.5.32", "", { "dependencies": { "@vue/reactivity": "3.5.32", "@vue/shared": "3.5.32" } }, "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ=="],
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.32", "", { "dependencies": { "@vue/reactivity": "3.5.32", "@vue/runtime-core": "3.5.32", "@vue/shared": "3.5.32", "csstype": "^3.2.3" } }, "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ=="],
"@vue/server-renderer": ["@vue/server-renderer@3.5.32", "", { "dependencies": { "@vue/compiler-ssr": "3.5.32", "@vue/shared": "3.5.32" }, "peerDependencies": { "vue": "3.5.32" } }, "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ=="],
"@vue/shared": ["@vue/shared@3.5.32", "", {}, "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg=="],
"@vueuse/core": ["@vueuse/core@12.8.2", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "12.8.2", "@vueuse/shared": "12.8.2", "vue": "^3.5.13" } }, "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ=="],
"@vueuse/integrations": ["@vueuse/integrations@12.8.2", "", { "dependencies": { "@vueuse/core": "12.8.2", "@vueuse/shared": "12.8.2", "vue": "^3.5.13" }, "peerDependencies": { "async-validator": "^4", "axios": "^1", "change-case": "^5", "drauu": "^0.4", "focus-trap": "^7", "fuse.js": "^7", "idb-keyval": "^6", "jwt-decode": "^4", "nprogress": "^0.2", "qrcode": "^1.5", "sortablejs": "^1", "universal-cookie": "^7" }, "optionalPeers": ["async-validator", "axios", "change-case", "drauu", "focus-trap", "fuse.js", "idb-keyval", "jwt-decode", "nprogress", "qrcode", "sortablejs", "universal-cookie"] }, "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g=="],
"@vueuse/metadata": ["@vueuse/metadata@12.8.2", "", {}, "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A=="],
"@vueuse/shared": ["@vueuse/shared@12.8.2", "", { "dependencies": { "vue": "^3.5.13" } }, "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"algoliasearch": ["algoliasearch@5.50.1", "", { "dependencies": { "@algolia/abtesting": "1.16.1", "@algolia/client-abtesting": "5.50.1", "@algolia/client-analytics": "5.50.1", "@algolia/client-common": "5.50.1", "@algolia/client-insights": "5.50.1", "@algolia/client-personalization": "5.50.1", "@algolia/client-query-suggestions": "5.50.1", "@algolia/client-search": "5.50.1", "@algolia/ingestion": "1.50.1", "@algolia/monitoring": "1.50.1", "@algolia/recommend": "5.50.1", "@algolia/requester-browser-xhr": "5.50.1", "@algolia/requester-fetch": "5.50.1", "@algolia/requester-node-http": "5.50.1" } }, "sha512-/bwdue1/8LWELn/DBalGRfuLsXBLXULJo/yOeavJtDu8rBwxIzC6/Rz9Jg19S21VkJvRuZO1k8CZXBMS73mYbA=="],
"birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"chevrotain": ["chevrotain@12.0.0", "", { "dependencies": { "@chevrotain/cst-dts-gen": "12.0.0", "@chevrotain/gast": "12.0.0", "@chevrotain/regexp-to-ast": "12.0.0", "@chevrotain/types": "12.0.0", "@chevrotain/utils": "12.0.0" } }, "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ=="],
"chevrotain-allstar": ["chevrotain-allstar@0.4.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^12.0.0" } }, "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
"copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="],
"cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"cytoscape": ["cytoscape@3.33.2", "", {}, "sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw=="],
"cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="],
"cytoscape-fcose": ["cytoscape-fcose@2.2.0", "", { "dependencies": { "cose-base": "^2.2.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ=="],
"d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="],
"d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="],
"d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="],
"d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="],
"d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
"d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="],
"d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="],
"d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
"d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="],
"d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="],
"d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="],
"d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="],
"d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="],
"d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="],
"d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="],
"dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="],
"dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="],
"delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"dompurify": ["dompurify@3.3.3", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA=="],
"emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="],
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"focus-trap": ["focus-trap@7.8.0", "", { "dependencies": { "tabbable": "^6.4.0" } }, "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="],
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="],
"katex": ["katex@0.16.45", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA=="],
"khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="],
"langium": ["langium@4.2.2", "", { "dependencies": { "@chevrotain/regexp-to-ast": "~12.0.0", "chevrotain": "~12.0.0", "chevrotain-allstar": "~0.4.1", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.1.0" } }, "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ=="],
"layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="],
"lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"mark.js": ["mark.js@8.11.1", "", {}, "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ=="],
"marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
"mermaid": ["mermaid@11.14.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "katex": "^0.16.25", "khroma": "^2.1.0", "lodash-es": "^4.17.23", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"minisearch": ["minisearch@7.2.0", "", {}, "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg=="],
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
"mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"non-layered-tidy-tree-layout": ["non-layered-tidy-tree-layout@2.0.2", "", {}, "sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw=="],
"oniguruma-to-es": ["oniguruma-to-es@3.1.1", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ=="],
"package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
"path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
"points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="],
"points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="],
"postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="],
"preact": ["preact@10.29.1", "", {}, "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg=="],
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],
"rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
"roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="],
"rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"search-insights": ["search-insights@2.17.3", "", {}, "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ=="],
"shiki": ["shiki@2.5.0", "", { "dependencies": { "@shikijs/core": "2.5.0", "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/langs": "2.5.0", "@shikijs/themes": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="],
"superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="],
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
"tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
"vitepress": ["vitepress@1.6.4", "", { "dependencies": { "@docsearch/css": "3.8.2", "@docsearch/js": "3.8.2", "@iconify-json/simple-icons": "^1.2.21", "@shikijs/core": "^2.1.0", "@shikijs/transformers": "^2.1.0", "@shikijs/types": "^2.1.0", "@types/markdown-it": "^14.1.2", "@vitejs/plugin-vue": "^5.2.1", "@vue/devtools-api": "^7.7.0", "@vue/shared": "^3.5.13", "@vueuse/core": "^12.4.0", "@vueuse/integrations": "^12.4.0", "focus-trap": "^7.6.4", "mark.js": "8.11.1", "minisearch": "^7.1.1", "shiki": "^2.1.0", "vite": "^5.4.14", "vue": "^3.5.13" }, "peerDependencies": { "markdown-it-mathjax3": "^4", "postcss": "^8" }, "optionalPeers": ["markdown-it-mathjax3", "postcss"], "bin": { "vitepress": "bin/vitepress.js" } }, "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg=="],
"vitepress-plugin-mermaid": ["vitepress-plugin-mermaid@2.0.17", "", { "optionalDependencies": { "@mermaid-js/mermaid-mindmap": "^9.3.0" }, "peerDependencies": { "mermaid": "10 || 11", "vitepress": "^1.0.0 || ^1.0.0-alpha" } }, "sha512-IUzYpwf61GC6k0XzfmAmNrLvMi9TRrVRMsUyCA8KNXhg/mQ1VqWnO0/tBVPiX5UoKF1mDUwqn5QV4qAJl6JnUg=="],
"vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
"vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="],
"vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="],
"vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="],
"vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="],
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
"vue": ["vue@3.5.32", "", { "dependencies": { "@vue/compiler-dom": "3.5.32", "@vue/compiler-sfc": "3.5.32", "@vue/runtime-dom": "3.5.32", "@vue/server-renderer": "3.5.32", "@vue/shared": "3.5.32" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@mermaid-js/mermaid-mindmap/@braintree/sanitize-url": ["@braintree/sanitize-url@6.0.4", "", {}, "sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A=="],
"cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="],
"d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
"d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="],
"d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="],
"cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="],
"d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
}
}
+125
View File
@@ -0,0 +1,125 @@
# AAAK Dialect
AAAK is an experimental lossy abbreviation system designed to pack repeated entities and relationships into fewer tokens at scale. It is readable by any LLM — Claude, GPT, Gemini, Llama, Mistral — without a decoder.
::: warning Experimental
AAAK is a separate compression layer, **not the storage default**. The 96.6% benchmark score comes from raw verbatim mode. AAAK mode currently scores 84.2% R@5 — a 12.4 point regression. We're iterating.
:::
## What AAAK Is
- **Lossy, not lossless.** Uses regex-based abbreviation, not reversible compression.
- **A structured summary format.** Extracts entities, topics, key sentences, emotions, and flags from plain text.
- **Readable by any LLM.** No decoder needed — models read it naturally.
- **Designed for scale.** Saves tokens when the same entities appear hundreds of times.
## What AAAK Is Not
- **Not lossless compression.** The original text cannot be reconstructed.
- **Not efficient at small scale.** Short text already tokenizes efficiently — AAAK overhead costs more than it saves.
- **Not the default storage format.** MemPalace stores raw verbatim text in ChromaDB.
## Format
```
Header: FILE_NUM|PRIMARY_ENTITY|DATE|TITLE
Zettel: ZID:ENTITIES|topic_keywords|"key_quote"|WEIGHT|EMOTIONS|FLAGS
Tunnel: T:ZID<->ZID|label
Arc: ARC:emotion->emotion->emotion
```
### Entity Codes
Three-letter uppercase codes: `ALC=Alice`, `KAI=Kai`, `MAX=Max`.
### Emotion Codes
| Code | Meaning | Code | Meaning |
|------|---------|------|---------|
| `vul` | vulnerability | `joy` | joy |
| `fear` | fear | `trust` | trust |
| `grief` | grief | `wonder` | wonder |
| `rage` | rage | `love` | love |
| `hope` | hope | `despair` | despair |
| `peace` | peace | `humor` | humor |
| `tender` | tenderness | `raw` | raw honesty |
| `doubt` | self-doubt | `relief` | relief |
| `anx` | anxiety | `exhaust` | exhaustion |
### Flags
| Flag | Meaning |
|------|---------|
| `ORIGIN` | Origin moment (birth of something) |
| `CORE` | Core belief or identity pillar |
| `SENSITIVE` | Handle with absolute care |
| `PIVOT` | Emotional turning point |
| `GENESIS` | Led directly to something existing |
| `DECISION` | Explicit decision or choice |
| `TECHNICAL` | Technical architecture detail |
## Example
**Input:**
```
We decided to use GraphQL instead of REST because the frontend team needs
flexible queries. Kai recommended it after researching both options. The team
was excited about the schema-first approach.
```
**AAAK output:**
```
0:KAI|graphql_rest_decided|"decided to use GraphQL instead of REST"|determ+excite|DECISION+TECHNICAL
```
## Usage
### Compress drawers
```bash
# Preview compression
mempalace compress --wing myapp --dry-run
# Compress and store
mempalace compress --wing myapp
```
### With entity config
```bash
mempalace compress --wing myapp --config entities.json
```
Entity config format:
```json
{
"entities": {"Alice": "ALC", "Bob": "BOB"},
"skip_names": ["Gandalf", "Sherlock"]
}
```
### Python API
```python
from mempalace.dialect import Dialect
# Basic compression
dialect = Dialect()
compressed = dialect.compress("We decided to use GraphQL...")
# With entity mappings
dialect = Dialect(entities={"Alice": "ALC", "Kai": "KAI"})
compressed = dialect.compress(text, metadata={"wing": "myapp", "room": "arch"})
# From config file
dialect = Dialect.from_config("entities.json")
```
## When to Use AAAK
AAAK is most useful when:
- You have **many repeated entities** across thousands of sessions
- You need to **compress context** for local models with small windows
- You want **structured summaries** pointing back to verbatim drawers
For most users, raw verbatim mode is the better default.
+61
View File
@@ -0,0 +1,61 @@
# Specialist Agents
MemPalace currently supports **agent diaries** through MCP tools. The practical model is simple: give an agent a stable name, and write/read diary entries under that agent's wing.
::: warning Current Scope
This page documents the diary workflow that exists today. MemPalace does **not** currently ship an agent registry, `~/.mempalace/agents/*.json`, or a `mempalace_list_agents` tool.
:::
## What Agents Do
Each agent:
- **Has a focus** — what it pays attention to
- **Keeps a diary** — entries persist across sessions
- **Can read recent history** — useful for patterns, continuity, and follow-up work
## Agent Diary
The diary is a lightweight memory stream for one named agent: observations, findings, decisions, and recurring patterns.
### Writing Entries
```text
MCP tool: mempalace_diary_write
arguments: {
"agent_name": "reviewer",
"entry": "PR#42|auth.bypass.found|missing.middleware.check|pattern:3rd.time.this.quarter|★★★★"
}
```
### Reading History
```text
MCP tool: mempalace_diary_read
arguments: { "agent_name": "reviewer", "last_n": 10 }
→ returns last 10 findings, compressed in AAAK
```
### MCP Tools
| Tool | Description |
|------|-------------|
| `mempalace_diary_write` | Write an AAAK diary entry |
| `mempalace_diary_read` | Read recent diary entries |
## How It Works
Each named agent maps to its own wing in the palace:
- `wing_reviewer` — the reviewer's diary, findings, patterns
- `wing_architect` — the architect's decisions, tradeoffs
- `wing_ops` — the ops agent's incidents, deploys
All entries go into a `diary` room within the wing, tagged with topic, timestamp, and agent name.
## Specialization
Separate diary streams let you keep different working contexts apart. A reviewer can keep bug patterns, an architect can keep decisions, and an ops agent can keep incident notes without mixing them into one shared log.
::: tip
If you use multiple specialist prompts or toolchains, keep the agent names stable so each one writes back to the same diary wing over time.
:::
@@ -0,0 +1,33 @@
# Contradiction Detection
::: warning Experimental
Contradiction detection is a planned capability, not a shipped end-to-end feature in the current MCP workflow. The examples below show the intended behavior rather than a fully integrated command path.
:::
## What It Does
Checks assertions against entity facts in the knowledge graph. When enabled, it catches contradictions like:
```
Input: "Soren finished the auth migration"
Output: 🔴 AUTH-MIGRATION: attribution conflict — Maya was assigned, not Soren
Input: "Kai has been here 2 years"
Output: 🟡 KAI: wrong_tenure — records show 3 years (started 2023-04)
Input: "The sprint ends Friday"
Output: 🟡 SPRINT: stale_date — current sprint ends Thursday (updated 2 days ago)
```
## How It Works
Facts are checked against the knowledge graph:
- **Attribution conflicts** — the wrong person credited for a task
- **Temporal errors** — wrong dates, tenures, or durations
- **Stale information** — facts that have been superseded
Ages, dates, and tenures are calculated dynamically from the entity's recorded facts — not hardcoded.
## Status
The current codebase includes the temporal knowledge graph primitives needed for this direction, but not a complete contradiction-checking tool exposed through the CLI or MCP server.
+91
View File
@@ -0,0 +1,91 @@
# Knowledge Graph
MemPalace includes a temporal entity-relationship graph — like Zep's Graphiti, but SQLite instead of Neo4j. Local and free.
## What It Stores
Entity-relationship triples with temporal validity:
```
Subject → Predicate → Object [valid_from → valid_to]
```
Facts have time windows. When something stops being true, you invalidate it — and historical queries still find it.
## Usage
### Python API
```python
from mempalace.knowledge_graph import KnowledgeGraph
kg = KnowledgeGraph()
# Add facts
kg.add_triple("Kai", "works_on", "Orion", valid_from="2025-06-01")
kg.add_triple("Maya", "assigned_to", "auth-migration", valid_from="2026-01-15")
kg.add_triple("Maya", "completed", "auth-migration", valid_from="2026-02-01")
# Query: everything about Kai
kg.query_entity("Kai")
# → [Kai → works_on → Orion (current), Kai → recommended → Clerk (2026-01)]
# Query: what was true in January?
kg.query_entity("Maya", as_of="2026-01-20")
# → [Maya → assigned_to → auth-migration (active)]
# Timeline
kg.timeline("Orion")
# → chronological story of the project
```
### Invalidating Facts
When something stops being true:
```python
kg.invalidate("Kai", "works_on", "Orion", ended="2026-03-01")
```
Now queries for Kai's current work won't return Orion. Historical queries still will.
### MCP Tools
Through the MCP server, the knowledge graph is available as tools:
| Tool | Description |
|------|-------------|
| `mempalace_kg_query` | Query entity relationships with time filtering |
| `mempalace_kg_add` | Add facts |
| `mempalace_kg_invalidate` | Mark facts as ended |
| `mempalace_kg_timeline` | Chronological entity story |
| `mempalace_kg_stats` | Graph overview |
## Storage
The knowledge graph uses SQLite with two tables:
**`entities`** — people, projects, tools, concepts:
- `id` — lowercase normalized name
- `name` — display name
- `type` — person, project, tool, concept, etc.
- `properties` — JSON blob for extra metadata
**`triples`** — relationships between entities:
- `subject``predicate``object`
- `valid_from` — when this became true
- `valid_to` — when it stopped being true (NULL = still current)
- `confidence` — 0.0 to 1.0
- `source_closet` — link back to the verbatim memory
Database location: `~/.mempalace/knowledge_graph.sqlite3`
## Comparison
| Feature | MemPalace | Zep (Graphiti) |
|---------|-----------|----------------|
| Storage | SQLite (local) | Neo4j (cloud) |
| Cost | Free | $25/mo+ |
| Temporal validity | Yes | Yes |
| Self-hosted | Always | Enterprise only |
| Privacy | Everything local | SOC 2, HIPAA |
+104
View File
@@ -0,0 +1,104 @@
# Memory Stack
MemPalace uses a 4-layer memory stack. Each layer loads progressively more data only when needed.
## The Layers
| Layer | What | Size | When |
|-------|------|------|------|
| **L0** | Identity — who is this AI? | ~50-100 tokens | Always loaded |
| **L1** | Essential Story — top moments | ~500-800 tokens | Always loaded |
| **L2** | Room Recall — filtered retrieval | ~200500 each | When topic comes up |
| **L3** | Deep Search — full semantic query | Variable | When explicitly asked |
In the current implementation, a typical wake-up is roughly **~600-900 tokens** for L0 + L1. Searches only fire when needed.
## Layer 0: Identity
A plain text file at `~/.mempalace/identity.txt`. Always loaded as the AI's self-concept.
```text
I am Atlas, a personal AI assistant for Alice.
Traits: warm, direct, remembers everything.
People: Alice (creator), Bob (Alice's partner).
Project: A journaling app that helps people process emotions.
```
~50 tokens. Tells the AI who it is and who it works with.
## Layer 1: Essential Story
Auto-generated from the highest-importance drawers in the palace. Groups by room, picks the top moments, and keeps the output bounded.
The generation process:
1. Reads all drawers from ChromaDB
2. Scores each by importance/emotional weight
3. Takes the top 15 moments
4. Groups by room for readability
5. Truncates to fit within 3,200 characters
```
## L1 — ESSENTIAL STORY
[auth-migration]
- Team decided to migrate from Auth0 to Clerk — pricing + DX (session_2026-01-15.md)
- Kai debugged the OAuth token refresh issue (session_2026-01-20.md)
[deploy-process]
- Switched to blue-green deploys after the January outage (session_2026-02-01.md)
```
## Layer 2: On-Demand Recall
Loaded when a specific topic or wing comes up in conversation. Retrieves drawers filtered by wing and/or room — typically ~200500 tokens.
```python
stack = MemoryStack()
stack.recall(wing="driftwood", room="auth")
# → returns recent drawers about auth in the driftwood project
```
## Layer 3: Deep Search
Full semantic search against the entire palace. This is what fires when you or the AI explicitly asks a question.
```python
stack.search("why did we switch to GraphQL")
# → returns top-5 matching drawers with similarity scores
```
## Wake-Up Budget
The point of the stack is bounded startup context, not a fixed universal token count. The exact size depends on your identity file and what Layer 1 selects, but the implementation keeps wake-up meaningfully smaller than loading the full corpus into the prompt.
## Using the Stack
### CLI
```bash
# Wake-up context (L0 + L1)
mempalace wake-up
# Project-specific wake-up
mempalace wake-up --wing driftwood
```
### Python API
```python
from mempalace.layers import MemoryStack
stack = MemoryStack()
# L0 + L1: wake-up (~600-900 tokens in typical use)
print(stack.wake_up())
# L2: on-demand recall
print(stack.recall(wing="myapp"))
# L3: deep search
print(stack.search("pricing change"))
# Status
print(stack.status())
```
+120
View File
@@ -0,0 +1,120 @@
# The Palace
Ancient Greek orators memorized entire speeches by placing ideas in rooms of an imaginary building. Walk through the building, find the idea. MemPalace applies the same principle to AI memory.
## Structure
Your conversations are organized into a navigable hierarchy:
```mermaid
graph LR
classDef wingPerson fill:#1e1b4b,stroke:#4f46e5,color:#e0e7ff,stroke-width:2px,rx:8px,ry:8px;
classDef wingProject fill:#164e63,stroke:#06b6d4,color:#cffafe,stroke-width:2px,rx:8px,ry:8px;
classDef room fill:#312e81,stroke:#6366f1,color:#e0e7ff,stroke-width:1px,rx:4px,ry:4px;
classDef closet fill:#3b0764,stroke:#8b5cf6,color:#f3e8ff,stroke-width:1px,rx:4px,ry:4px;
classDef drawer fill:#0f766e,stroke:#14b8a6,color:#ccfbf1,stroke-width:1px,rx:4px,ry:4px;
classDef tunnel_link stroke:#8b5cf6,stroke-width:2px,stroke-dasharray: 5 5;
subgraph W1 [WING: Person]
direction TB
RA["Room A"]
RB["Room B"]
CA["Closet"]
DA["Drawer (verbatim)"]
RA -- "hall" --> RB
RA --> CA --> DA
end
subgraph W2 [WING: Project]
direction TB
RA2["Room A"]
RC["Room C"]
CA2["Closet"]
DA2["Drawer (verbatim)"]
RA2 -- "hall" --> RC
RA2 --> CA2 --> DA2
end
RA <==> |tunnel bridge| RA2
class W1 wingPerson;
class W2 wingProject;
class RA,RB,RA2,RC room;
class CA,CA2 closet;
class DA,DA2 drawer;
```
## Components
### Wings
A person or project. As many as you need.
Every project, person, or topic gets its own wing in the palace. Wings are the top-level organizational unit.
### Rooms
Specific topics within a wing. Examples: `auth-migration`, `graphql-switch`, `ci-pipeline`.
Rooms are named ideas. They're auto-detected from your folder structure during `mempalace init`, and you can create additional rooms manually.
### Halls
Halls are the conceptual categories that describe how related memories connect *within* a wing:
- `hall_facts` — decisions made, choices locked in
- `hall_events` — sessions, milestones, debugging
- `hall_discoveries` — breakthroughs, new insights
- `hall_preferences` — habits, likes, opinions
- `hall_advice` — recommendations and solutions
### Tunnels
Connections *between* wings. When the same room appears in different wings, the graph layer can treat that as a cross-wing connection.
```
wing_kai / hall_events / auth-migration → "Kai debugged the OAuth token refresh"
wing_driftwood / hall_facts / auth-migration → "team decided to migrate auth to Clerk"
wing_priya / hall_advice / auth-migration → "Priya approved Clerk over Auth0"
```
Same room. Three wings. The graph can use that shared room name as a bridge.
### Closets
Closets are the summary layer in the broader MemPalace vocabulary: compact notes that point back to the original content. In the current implementation, the main persisted storage path is still the underlying drawer text plus metadata.
### Drawers
The original stored text chunks. This is the primary retrieval layer used by the current search and benchmark flows.
## Why Structure Matters
Tested on 22,000+ real conversation memories:
| Search scope | R@10 | Improvement |
|-------------|------|-------------|
| All closets | 60.9% | baseline |
| Within wing | 73.1% | +12% |
| Wing + hall | 84.8% | +24% |
| Wing + room | 94.8% | +34% |
The practical point is that structure improves retrieval. In the project benchmarks, narrowing the search scope by wing and room outperformed searching the entire corpus at once.
## Navigation
The palace supports graph traversal across wings:
```text
MCP tool: mempalace_traverse
arguments: { "start_room": "auth-migration" }
→ discovers rooms in wing_kai, wing_driftwood, wing_priya
MCP tool: mempalace_find_tunnels
arguments: { "wing_a": "wing_code", "wing_b": "wing_team" }
→ auth-migration, deploy-process, ci-pipeline
```
This is the navigation story: shared room structure gives the model more than one way to reach relevant context.
+38
View File
@@ -0,0 +1,38 @@
# Claude Code Plugin
The recommended way to use MemPalace with Claude Code — native marketplace install.
## Installation
```bash
claude plugin marketplace add milla-jovovich/mempalace
claude plugin install --scope user mempalace
```
Restart Claude Code, then type `/skills` to verify "mempalace" appears.
## How It Works
With the plugin installed, Claude Code automatically:
- Starts the MemPalace MCP server on launch
- Has access to all 19 tools
- Learns the AAAK dialect and memory protocol from the `mempalace_status` response
- Searches the palace before answering questions about past work
No manual configuration needed. Just ask:
> *"What did we decide about auth last month?"*
## Alternative: Manual MCP
If you prefer manual setup over the marketplace plugin:
```bash
claude mcp add mempalace -- python -m mempalace.mcp_server
```
Both approaches give identical functionality. The plugin approach handles server lifecycle automatically.
## Hooks
Set up [auto-save hooks](/guide/hooks) to ensure memories are saved automatically during long conversations.
+85
View File
@@ -0,0 +1,85 @@
# Configuration
## Global Config
Located at `~/.mempalace/config.json`:
```json
{
"palace_path": "/custom/path/to/palace",
"collection_name": "mempalace_drawers",
"people_map": {"Kai": "KAI", "Priya": "PRI"}
}
```
| Key | Default | Description |
|-----|---------|-------------|
| `palace_path` | `~/.mempalace/palace` | Where ChromaDB stores your drawers |
| `collection_name` | `mempalace_drawers` | ChromaDB collection name |
| `people_map` | `{}` | Entity name → AAAK code mappings |
## Project Config
Generated by `mempalace init` in your project directory:
### `mempalace.yaml`
```yaml
wing: myproject
rooms:
- backend
- frontend
- decisions
palace_path: ~/.mempalace/palace
```
### `entities.json`
```json
{
"Kai": "KAI",
"Priya": "PRI"
}
```
Wings are auto-detected during `mempalace init` from:
- Directory names → project wings
- Detected people in file content → person wings
- Explicit `--wing` flag on mine commands
## Identity
Located at `~/.mempalace/identity.txt`. Plain text. Becomes Layer 0 — loaded every session.
```text
I am Atlas, a personal AI assistant for Alice.
Traits: warm, direct, remembers everything.
People: Alice (creator), Bob (Alice's partner).
Project: A journaling app that helps people process emotions.
```
::: tip
Write your identity file in first person from the AI's perspective. This becomes the AI's self-concept on wake-up.
:::
## Palace Path Override
All commands accept `--palace <path>` to override the default location:
```bash
mempalace search "query" --palace /tmp/test-palace
mempalace mine ~/data/ --palace /tmp/test-palace
```
The MCP server also accepts `--palace`:
```bash
python -m mempalace.mcp_server --palace /custom/palace
```
## Environment Variables
| Variable | Description |
|----------|-------------|
| `MEMPALACE_PALACE_PATH` | Override palace path (same as `--palace`) |
| `MEMPAL_DIR` | Directory for auto-mining in hooks |
+96
View File
@@ -0,0 +1,96 @@
# Gemini CLI
MemPalace works natively with [Gemini CLI](https://github.com/google/gemini-cli), which handles the MCP server and save hooks automatically.
## Prerequisites
- Python 3.9+
- Gemini CLI installed and configured
## Installation
```bash
# Clone the repository
git clone https://github.com/milla-jovovich/mempalace.git
cd mempalace
# Create a virtual environment
python3 -m venv .venv
# Install dependencies
.venv/bin/pip install -e .
```
## Initialize the Palace
```bash
.venv/bin/python3 -m mempalace init .
```
### Identity and Project Configuration (Optional)
You can optionally create or edit:
- **`~/.mempalace/identity.txt`** — plain text describing your role and focus
- **`./mempalace.yaml`** — per-project MemPalace configuration created by `mempalace init`
- **`./entities.json`** — per-project entity mappings used by AAAK compression
## Connect to Gemini CLI
Register MemPalace as an MCP server:
```bash
gemini mcp add mempalace /absolute/path/to/mempalace/.venv/bin/python3 \
-m mempalace.mcp_server --scope user
```
::: warning
Use the **absolute path** to the Python binary to ensure it works from any directory.
:::
## Enable Auto-Saving
Add a `PreCompress` hook to `~/.gemini/settings.json`:
```json
{
"hooks": {
"PreCompress": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "/absolute/path/to/mempalace/hooks/mempal_precompact_hook.sh"
}
]
}
]
}
}
```
Make sure the hook scripts are executable:
```bash
chmod +x hooks/*.sh
```
## Usage
Once connected, Gemini CLI will automatically:
- Start the MemPalace server on launch
- Use `mempalace_search` to find relevant past discussions
- Use the `PreCompress` hook to save memories before context compression
### Manual Mining
Mine existing code or docs:
```bash
.venv/bin/python3 -m mempalace mine /path/to/your/project
```
### Verification
In a Gemini CLI session:
- `/mcp list` — verify `mempalace` is `CONNECTED`
- `/hooks panel` — verify the `PreCompress` hook is active
+86
View File
@@ -0,0 +1,86 @@
# Getting Started
## Installation
Install MemPalace from PyPI:
```bash
pip install mempalace
```
::: danger Security Warning
The domain `mempalace.tech` is a **brand-squatting site** not affiliated with this project. It is known to run ad-redirects and potential malware. The official MemPalace distribution is only available via this [GitHub repository](https://github.com/milla-jovovich/mempalace) and [PyPI](https://pypi.org/project/mempalace/). Never install binaries or scripts from unofficial domains.
:::
### Requirements
- Python 3.9+
- `chromadb>=0.5.0` (installed automatically)
- `pyyaml>=6.0` (installed automatically)
No API key required for the core local workflow. After installation, the main storage and retrieval path runs locally.
### From Source
```bash
git clone https://github.com/milla-jovovich/mempalace.git
cd mempalace
pip install -e ".[dev]"
```
## Quick Start
Three steps: **init**, **mine**, **search**.
### 1. Initialize Your Palace
```bash
mempalace init ~/projects/myapp
```
This scans your project directory and:
- Detects people and projects from file content
- Creates rooms from your folder structure
- Sets up `~/.mempalace/` config directory
### 2. Mine Your Data
```bash
# Mine project files (code, docs, notes)
mempalace mine ~/projects/myapp
# Mine conversation exports (Claude, ChatGPT, Slack)
mempalace mine ~/chats/ --mode convos
# Mine with auto-classification into memory types
mempalace mine ~/chats/ --mode convos --extract general
```
Two mining modes plus one extraction strategy:
- **projects** — code and docs, auto-detected rooms
- **convos** — conversation exports, chunked by exchange pair
- **general extraction** — an `--extract general` option for conversation mining that classifies content into decisions, preferences, milestones, problems, and emotional context
### 3. Search
```bash
mempalace search "why did we switch to GraphQL"
```
That gives you a working local memory index.
## What Happens Next
After the one-time setup, you don't run MemPalace commands manually. Your AI uses it for you through [MCP integration](/guide/mcp-integration) or a [Claude Code plugin](/guide/claude-code).
Ask your AI anything:
> *"What did we decide about auth last month?"*
It calls `mempalace_search` automatically, gets verbatim results, and answers you. You never type `mempalace search` again.
## Next Steps
- [Mining Your Data](/guide/mining) — deep dive into mining modes
- [MCP Integration](/guide/mcp-integration) — connect to Claude, ChatGPT, Cursor, Gemini
- [The Palace](/concepts/the-palace) — understand wings, rooms, halls, and tunnels
+116
View File
@@ -0,0 +1,116 @@
# Auto-Save Hooks
Two hooks for Claude Code and Codex that automatically save memories during work. No manual "save" commands needed.
## What They Do
| Hook | When It Fires | What Happens |
|------|--------------|-------------|
| **Save Hook** | Every 15 human messages | Blocks the AI, tells it to save key topics/decisions/quotes to the palace |
| **PreCompact Hook** | Right before context compaction | Emergency save — forces the AI to save everything before losing context |
The AI does the actual filing — it knows the conversation context, so it classifies memories into the right wings/halls/closets. The hooks just tell it **when** to save.
## Install — Claude Code
Add to `.claude/settings.local.json`:
```json
{
"hooks": {
"Stop": [{
"matcher": "*",
"hooks": [{
"type": "command",
"command": "/absolute/path/to/hooks/mempal_save_hook.sh",
"timeout": 30
}]
}],
"PreCompact": [{
"hooks": [{
"type": "command",
"command": "/absolute/path/to/hooks/mempal_precompact_hook.sh",
"timeout": 30
}]
}]
}
}
```
Make them executable:
```bash
chmod +x hooks/mempal_save_hook.sh hooks/mempal_precompact_hook.sh
```
## Install — Codex CLI
Add to `.codex/hooks.json`:
```json
{
"Stop": [{
"type": "command",
"command": "/absolute/path/to/hooks/mempal_save_hook.sh",
"timeout": 30
}],
"PreCompact": [{
"type": "command",
"command": "/absolute/path/to/hooks/mempal_precompact_hook.sh",
"timeout": 30
}]
}
```
## Configuration
Edit `mempal_save_hook.sh` to change:
- **`SAVE_INTERVAL=15`** — How many messages between saves. Lower = more frequent, higher = less interruption.
- **`STATE_DIR`** — Where hook state is stored (defaults to `~/.mempalace/hook_state/`)
- **`MEMPAL_DIR`** — Optional. Set to a conversations directory to auto-run `mempalace mine` on each save trigger.
## How It Works
### Save Hook (Stop event)
```
User sends message → AI responds → Stop hook fires
Count human messages in transcript
┌── < 15 since last save → let AI stop
└── ≥ 15 since last save → block + save
AI saves to palace
AI stops (flag set)
```
The `stop_hook_active` flag prevents infinite loops.
### PreCompact Hook
```
Context window full → PreCompact fires → ALWAYS blocks → AI saves → Compaction proceeds
```
No counting needed — compaction always warrants a save.
## Debugging
```bash
cat ~/.mempalace/hook_state/hook.log
```
Example output:
```
[14:30:15] Session abc123: 12 exchanges, 12 since last save
[14:35:22] Session abc123: 15 exchanges, 15 since last save
[14:35:22] TRIGGERING SAVE at exchange 15
[14:40:01] Session abc123: 18 exchanges, 3 since last save
```
## Cost
**Zero extra tokens.** The hooks are bash scripts that run locally. They don't call any API. The only "cost" is a few seconds of the AI organizing memories at each checkpoint.
+70
View File
@@ -0,0 +1,70 @@
# Local Models
MemPalace works with any local LLM — Llama, Mistral, or any offline model. Since local models generally don't speak MCP yet, there are two approaches.
## Wake-Up Command
Load your world into the model's context:
```bash
mempalace wake-up > context.txt
# Paste context.txt into your local model's system prompt
```
This gives your local model a bounded wake-up context, typically around **~600-900 tokens** in the current implementation. It includes:
- **Layer 0**: Your identity — who you are, what you work on
- **Layer 1**: Top moments from the palace — key decisions, recent work
For project-specific context:
```bash
mempalace wake-up --wing driftwood > context.txt
```
## CLI Search
Query on demand, feed results into your prompt:
```bash
mempalace search "auth decisions" > results.txt
# Include results.txt in your prompt
```
## Python API
For programmatic integration with your local model pipeline:
```python
from mempalace.searcher import search_memories
results = search_memories(
"auth decisions",
palace_path="~/.mempalace/palace",
)
# Format results for your model's context
context = "\n".join(
f"[{r['wing']}/{r['room']}] {r['text']}"
for r in results["results"]
)
# Inject into your local model's prompt
prompt = f"Context from memory:\n{context}\n\nUser: What did we decide about auth?"
```
## AAAK for Compression
Use [AAAK dialect](/concepts/aaak-dialect) to compress wake-up context further:
```bash
mempalace compress --wing myapp --dry-run
```
AAAK is readable by any LLM that reads text — Claude, GPT, Gemini, Llama, Mistral — without a decoder.
## Full Offline Stack
The core memory stack can run offline:
- **ChromaDB** on your machine — vector storage and search
- **Local model** on your machine — reasoning and responses
- **AAAK** for compression — optional, no cloud dependency
- **Optional reranking or external model integrations** may introduce cloud calls, depending on how you configure the system
+101
View File
@@ -0,0 +1,101 @@
# MCP Integration
MemPalace provides 19 tools through the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/), giving any MCP-compatible AI full read/write access to your palace.
## Setup
### Setup Helper
MemPalace includes a setup helper that prints the exact configuration commands for your environment:
```bash
mempalace mcp
```
### Manual Connection
```bash
claude mcp add mempalace -- python -m mempalace.mcp_server
```
### With Custom Palace Path
```bash
claude mcp add mempalace -- python -m mempalace.mcp_server --palace /path/to/palace
```
Now your AI has all 19 tools available. Ask it anything:
> *"What did we decide about auth last month?"*
Claude calls `mempalace_search` automatically, gets verbatim results, and answers you.
## Compatible Tools
MemPalace works with any tool that supports MCP:
- **Claude Code** — native via plugin or manual MCP
- **OpenClaw** — via official skill, see [OpenClaw Skill](/guide/openclaw)
- **ChatGPT** — via MCP bridge
- **Cursor** — native MCP support
- **Gemini CLI** — see [Gemini CLI guide](/guide/gemini-cli)
## Memory Protocol
When the AI first calls `mempalace_status`, it receives the **Memory Protocol** — a behavior guide that teaches it to:
1. **On wake-up**: Call `mempalace_status` to load the palace overview
2. **Before responding** about any person, project, or past event: search first, never guess
3. **If unsure**: Say "let me check" and query the palace
4. **After each session**: Write diary entries to record what happened
5. **When facts change**: Invalidate old facts, add new ones
This protocol is what turns storage into memory — the AI knows to verify before speaking.
## Tool Overview
### Palace (read)
| Tool | What |
|------|------|
| `mempalace_status` | Palace overview + AAAK spec + memory protocol |
| `mempalace_list_wings` | Wings with counts |
| `mempalace_list_rooms` | Rooms within a wing |
| `mempalace_get_taxonomy` | Full wing → room → count tree |
| `mempalace_search` | Semantic search with wing/room filters |
| `mempalace_check_duplicate` | Check before filing |
| `mempalace_get_aaak_spec` | AAAK dialect reference |
### Palace (write)
| Tool | What |
|------|------|
| `mempalace_add_drawer` | File verbatim content |
| `mempalace_delete_drawer` | Remove by ID |
### Knowledge Graph
| Tool | What |
|------|------|
| `mempalace_kg_query` | Entity relationships with time filtering |
| `mempalace_kg_add` | Add facts |
| `mempalace_kg_invalidate` | Mark facts as ended |
| `mempalace_kg_timeline` | Chronological entity story |
| `mempalace_kg_stats` | Graph overview |
### Navigation
| Tool | What |
|------|------|
| `mempalace_traverse` | Walk the graph from a room across wings |
| `mempalace_find_tunnels` | Find rooms bridging two wings |
| `mempalace_graph_stats` | Graph connectivity overview |
### Agent Diary
| Tool | What |
|------|------|
| `mempalace_diary_write` | Write AAAK diary entry |
| `mempalace_diary_read` | Read recent diary entries |
For detailed schemas and parameters, see [MCP Tools Reference](/reference/mcp-tools).
+134
View File
@@ -0,0 +1,134 @@
# Mining Your Data
MemPalace ingests your data by **mining** — scanning files and filing their content as verbatim drawers in the palace.
## Mining Modes
### Projects Mode (default)
Scans code, docs, and notes. Respects `.gitignore` by default.
```bash
mempalace mine ~/projects/myapp
```
Each file becomes a drawer, tagged with a wing (project name) and room (topic). Rooms are auto-detected from your folder structure during `mempalace init`.
Options:
```bash
# Override wing name
mempalace mine ~/projects/myapp --wing myapp
# Ignore .gitignore rules
mempalace mine ~/projects/myapp --no-gitignore
# Include specific ignored paths
mempalace mine ~/projects/myapp --include-ignored dist,build
# Limit number of files
mempalace mine ~/projects/myapp --limit 100
# Preview without filing
mempalace mine ~/projects/myapp --dry-run
```
### Conversations Mode
Indexes conversation exports from Claude, ChatGPT, Slack, and other tools. Chunks by exchange pair (human + assistant turns).
```bash
mempalace mine ~/chats/ --mode convos
```
Supports five chat formats automatically:
- Claude JSON exports
- ChatGPT exports
- Slack exports
- Markdown conversations
- Plain text transcripts
### General Extraction
Auto-classifies conversation content into five memory types:
```bash
mempalace mine ~/chats/ --mode convos --extract general
```
Memory types:
- **Decisions** — choices made, options rejected
- **Preferences** — habits, likes, opinions
- **Milestones** — sessions completed, goals reached
- **Problems** — bugs, blockers, issues encountered
- **Emotional context** — reactions, concerns, excitement
## Splitting Mega-Files
Some transcript exports concatenate multiple sessions into one huge file. Split them first:
```bash
# Preview what would be split
mempalace split ~/chats/ --dry-run
# Split files with 2+ sessions (default)
mempalace split ~/chats/
# Only split files with 3+ sessions
mempalace split ~/chats/ --min-sessions 3
# Output to a different directory
mempalace split ~/chats/ --output-dir ~/chats-split/
```
::: tip
Always run `mempalace split` before mining conversation files. It's a no-op if files don't need splitting.
:::
## Multi-Project Setup
Mine each project into its own wing:
```bash
mempalace mine ~/chats/orion/ --mode convos --wing orion
mempalace mine ~/chats/nova/ --mode convos --wing nova
mempalace mine ~/chats/helios/ --mode convos --wing helios
```
Six months later:
```bash
# Project-specific search
mempalace search "database decision" --wing orion
# Cross-project search
mempalace search "rate limiting approach"
# → finds your approach in Orion AND Nova, shows the differences
```
## Team Usage
Mine Slack exports and AI conversations for team history:
```bash
mempalace mine ~/exports/slack/ --mode convos --wing driftwood
mempalace mine ~/.claude/projects/ --mode convos
```
Then search across people and projects:
```bash
mempalace search "Soren sprint" --wing driftwood
# → 14 closets: OAuth refactor, dark mode, component library migration
```
## Agent Tag
Every drawer is tagged with the agent that filed it:
```bash
# Default agent name
mempalace mine ~/data/ --agent mempalace
# Custom agent name
mempalace mine ~/data/ --agent reviewer
```
This is used by [Specialist Agents](/concepts/agents) to partition memories.
+35
View File
@@ -0,0 +1,35 @@
# OpenClaw Skill
MemPalace provides an official skill for [OpenClaw](https://github.com/openclaw/openclaw), making it trivial to give your ClawHub agents complete access to the palace's declarative memory and knowledge graph.
## Installation
The skill is built right into the `integrations/openclaw` directory of MemPalace.
You can add MemPalace as an MCP server to OpenClaw via the CLI:
```bash
openclaw mcp set mempalace '{"command":"python3","args":["-m","mempalace.mcp_server"]}'
```
Or by directly editing your OpenClaw configuration:
```json
{
"mcpServers": {
"mempalace": {
"command": "python3",
"args": ["-m", "mempalace.mcp_server"]
}
}
}
```
## How It Works
Once connected, OpenClaw agents receive all 19 tools along with the **Memory Protocol**—a strict behavioral guide indicating they should:
1. **Never guess**: Query `mempalace_search` or `mempalace_kg_query` before confidently answering.
2. **Keep an agent diary**: Maintain continuity between sessions by writing to `mempalace_diary_write`.
3. **Manage the Knowledge Graph**: Update declarative facts when things change using `mempalace_kg_add` and `mempalace_kg_invalidate`.
By connecting OpenClaw to MemPalace, you get both autonomous code execution and persistent, high-recall memory in the same workflow.
+107
View File
@@ -0,0 +1,107 @@
# Searching Memories
MemPalace uses ChromaDB's semantic vector search to find relevant memories. When you search, you get **verbatim text** — the exact words, never summaries.
## CLI Search
```bash
# Search everything
mempalace search "why did we switch to GraphQL"
# Filter by wing (project)
mempalace search "database decision" --wing myapp
# Filter by room (topic)
mempalace search "auth decisions" --room auth-migration
# Filter by both
mempalace search "pricing" --wing driftwood --room costs
# More results
mempalace search "deploy process" --results 10
```
## How Search Works
1. Your query is embedded using ChromaDB's default model (`all-MiniLM-L6-v2`)
2. The embedding is compared against all drawers using cosine similarity
3. Optional wing/room filters narrow the search scope
4. Results are returned with similarity scores and source metadata
### Why Structure Matters
Tested on 22,000+ real conversation memories:
```
Search all closets: 60.9% R@10
Search within wing: 73.1% (+12%)
Search wing + hall: 84.8% (+24%)
Search wing + room: 94.8% (+34%)
```
Wings and rooms aren't cosmetic — they're a **34% retrieval improvement**.
## Programmatic Search
Use the Python API for integration:
```python
from mempalace.searcher import search_memories
results = search_memories(
query="auth decisions",
palace_path="~/.mempalace/palace",
wing="myapp",
room="auth",
n_results=5,
)
for hit in results["results"]:
print(f"[{hit['similarity']}] {hit['wing']}/{hit['room']}")
print(f" {hit['text'][:200]}")
```
The `search_memories()` function returns a dict:
```python
{
"query": "auth decisions",
"filters": {"wing": "myapp", "room": "auth"},
"results": [
{
"text": "We decided to migrate auth to Clerk because...",
"wing": "myapp",
"room": "auth-migration",
"source_file": "session_2026-01-15.md",
"similarity": 0.892,
},
# ...
],
}
```
## MCP Search
When connected via MCP, your AI searches automatically:
> *"What did we decide about auth last month?"*
The AI calls `mempalace_search` behind the scenes. You never type a search command.
See [MCP Integration](/guide/mcp-integration) for setup.
## Wake-Up Context
Instead of searching, you can load a compact context of your world:
```bash
# Load identity + top memories (~600-900 tokens in typical use)
mempalace wake-up
# Project-specific context
mempalace wake-up --wing driftwood
```
This loads Layer 0 (identity) and Layer 1 (essential story) as bounded startup context before the first retrieval call.
See [Memory Stack](/concepts/memory-stack) for details on the 4-layer architecture.
+87
View File
@@ -0,0 +1,87 @@
---
layout: home
hero:
name: MemPalace
text: Give your AI a memory.
tagline: "96.6% recall on LongMemEval in raw mode. Local-first, open source, and usable without an API key."
image:
src: /mempalace_logo.png
alt: MemPalace
actions:
- theme: brand
text: Get Started
link: /guide/getting-started
- theme: alt
text: Architecture →
link: /concepts/the-palace
- theme: alt
text: GitHub ↗
link: https://github.com/milla-jovovich/mempalace
features:
- icon:
src: /icons/file-text.svg
alt: Verbatim Storage
title: Verbatim Storage
details: Store source text directly instead of extracting facts up front. The raw benchmark result comes from retrieving verbatim content.
- icon:
src: /icons/building-2.svg
alt: Palace Structure
title: Palace Structure
details: Wings and rooms give retrieval useful structure. In the project benchmarks, narrowing search scope outperformed flat search.
- icon:
src: /icons/search.svg
alt: Semantic Search
title: Semantic Search
details: ChromaDB-powered vector search lets the model retrieve past discussions by topic, project, or room.
- icon:
src: /icons/git-merge.svg
alt: Knowledge Graph
title: Knowledge Graph
details: Temporal entity-relationship triples in SQLite. Facts can be added, queried, and invalidated over time.
- icon:
src: /icons/wrench.svg
alt: 19 MCP Tools
title: 19 MCP Tools
details: MCP tools expose search, filing, knowledge graph, graph navigation, and diary operations to compatible clients.
- icon:
src: /icons/shield-check.svg
alt: Zero Cloud
title: Zero Cloud
details: Core storage and retrieval run locally on ChromaDB and SQLite. Optional reranking features can add an API dependency.
---
<style>
:root {
--vp-home-hero-name-color: transparent;
--vp-home-hero-name-background: linear-gradient(
135deg,
#4f46e5 0%,
#06b6d4 50%,
#8b5cf6 100%
);
}
</style>
<div style="max-width: 688px; margin: 0 auto; padding: 48px 24px 0;">
## Verbatim Retrieval First
MemPalace starts from a simple premise: **store the source text and retrieve it well**. The benchmarked raw mode does not require an LLM extraction step.
| System | LongMemEval R@5 | API Required | Cost |
|--------|----------------|--------------|------|
| **MemPalace (hybrid)** | **100%** | Optional | Free |
| Supermemory ASMR | ~99% | Yes | — |
| **MemPalace (raw)** | **96.6%** | **None** | **Free** |
| Mastra | 94.87% | Yes | API costs |
| Mem0 | ~85% | Yes | $19249/mo |
The raw 96.6% LongMemEval result is the baseline story: strong recall without requiring an API key or an LLM in the retrieval pipeline.
<div style="text-align: center; padding-top: 16px;">
<a href="./reference/benchmarks" style="color: var(--vp-c-brand-1); font-weight: 500;">Full benchmark results →</a>
</div>
</div>
+17
View File
@@ -0,0 +1,17 @@
{
"name": "mempalace-docs",
"private": true,
"type": "module",
"scripts": {
"docs:dev": "vitepress dev",
"docs:build": "vitepress build",
"docs:preview": "vitepress preview"
},
"devDependencies": {
"@lucide/vue": "^1.8.0",
"mermaid": "^11.14.0",
"vitepress": "^1.6.4",
"vitepress-plugin-mermaid": "^2.0.17",
"vue": "^3.5.32"
}
}
+21
View File
@@ -0,0 +1,21 @@
<!-- @license lucide-static v0.468.0 - ISC -->
<svg
class="lucide lucide-building-2"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#06b6d4"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z" />
<path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2" />
<path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2" />
<path d="M10 6h4" />
<path d="M10 10h4" />
<path d="M10 14h4" />
<path d="M10 18h4" />
</svg>

After

Width:  |  Height:  |  Size: 548 B

+19
View File
@@ -0,0 +1,19 @@
<!-- @license lucide-static v0.468.0 - ISC -->
<svg
class="lucide lucide-file-text"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#06b6d4"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
<path d="M10 9H8" />
<path d="M16 13H8" />
<path d="M16 17H8" />
</svg>

After

Width:  |  Height:  |  Size: 468 B

+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v0.468.0 - ISC -->
<svg
class="lucide lucide-git-merge"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#06b6d4"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="18" cy="18" r="3" />
<circle cx="6" cy="6" r="3" />
<path d="M6 21V9a9 9 0 0 0 9 9" />
</svg>

After

Width:  |  Height:  |  Size: 389 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v0.468.0 - ISC -->
<svg
class="lucide lucide-search"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#06b6d4"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>

After

Width:  |  Height:  |  Size: 346 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v0.468.0 - ISC -->
<svg
class="lucide lucide-shield-check"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#06b6d4"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
<path d="m9 12 2 2 4-4" />
</svg>

After

Width:  |  Height:  |  Size: 494 B

+15
View File
@@ -0,0 +1,15 @@
<!-- @license lucide-static v0.468.0 - ISC -->
<svg
class="lucide lucide-wrench"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#06b6d4"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>

After

Width:  |  Height:  |  Size: 449 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

+246
View File
@@ -0,0 +1,246 @@
# API Reference
Comprehensive parameter-level documentation for all public Python APIs.
## `mempalace.searcher`
### `search(query, palace_path, wing=None, room=None, n_results=5)`
CLI-oriented search that prints results to stdout.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `query` | `str` | — | Search query text |
| `palace_path` | `str` | — | Path to ChromaDB palace directory |
| `wing` | `str` | `None` | Filter by wing name |
| `room` | `str` | `None` | Filter by room name |
| `n_results` | `int` | `5` | Maximum number of results |
**Raises:** `SearchError` if palace not found or query fails.
---
### `search_memories(query, palace_path, wing=None, room=None, n_results=5) → dict`
Programmatic search returning a dict. Used by the MCP server.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `query` | `str` | — | Search query text |
| `palace_path` | `str` | — | Path to ChromaDB palace directory |
| `wing` | `str` | `None` | Filter by wing name |
| `room` | `str` | `None` | Filter by room name |
| `n_results` | `int` | `5` | Maximum number of results |
**Returns:**
```python
{
"query": str,
"filters": {"wing": str | None, "room": str | None},
"results": [
{
"text": str, # verbatim drawer content
"wing": str, # wing name
"room": str, # room name
"source_file": str, # original file basename
"similarity": float, # 0.0 to 1.0
}
]
}
```
On error: `{"error": str, "hint": str}`
---
## `mempalace.layers`
### `class Layer0(identity_path=None)`
Identity layer (~50 tokens). Reads from `~/.mempalace/identity.txt`.
| Method | Returns | Description |
|--------|---------|-------------|
| `render()` | `str` | Identity text or default message |
| `token_estimate()` | `int` | Approximate token count (`len(text) // 4`) |
---
### `class Layer1(palace_path=None, wing=None)`
Essential story layer (~500800 tokens). Auto-generated from top drawers.
| Attribute | Type | Description |
|-----------|------|-------------|
| `MAX_DRAWERS` | `int` | Max moments in wake-up (15) |
| `MAX_CHARS` | `int` | Hard cap on L1 text (3200) |
| Method | Returns | Description |
|--------|---------|-------------|
| `generate()` | `str` | Compact L1 text grouped by room |
---
### `class Layer2(palace_path=None)`
On-demand retrieval layer (~200500 tokens per call).
| Method | Parameters | Returns |
|--------|-----------|---------|
| `retrieve(wing, room, n_results=10)` | Wing/room filters | Formatted drawer text |
---
### `class Layer3(palace_path=None)`
Deep semantic search layer (unlimited depth).
| Method | Parameters | Returns |
|--------|-----------|---------|
| `search(query, wing=None, room=None, n_results=5)` | Query + optional filters | Formatted result text |
| `search_raw(query, wing=None, room=None, n_results=5)` | Query + optional filters | List of result dicts |
---
### `class MemoryStack(palace_path=None, identity_path=None)`
Unified 4-layer interface.
| Method | Parameters | Returns | Description |
|--------|-----------|---------|-------------|
| `wake_up(wing=None)` | Optional wing | `str` | L0 + L1 context (~170900 tokens) |
| `recall(wing, room, n_results=10)` | Filters | `str` | L2 on-demand retrieval |
| `search(query, wing, room, n_results=5)` | Query + filters | `str` | L3 deep search |
| `status()` | — | `dict` | All layer status info |
---
## `mempalace.knowledge_graph`
### `class KnowledgeGraph(db_path=None)`
Default path: `~/.mempalace/knowledge_graph.sqlite3`
#### Write Methods
| Method | Parameters | Returns | Description |
|--------|-----------|---------|-------------|
| `add_entity(name, entity_type='unknown', properties=None)` | Name, type, props dict | `str` (entity ID) | Add or update entity node |
| `add_triple(subject, predicate, obj, valid_from, valid_to, confidence, source_closet, source_file)` | See below | `str` (triple ID) | Add relationship triple |
| `invalidate(subject, predicate, obj, ended=None)` | Entity names, end date | — | Mark relationship as ended |
**`add_triple` parameters:**
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `subject` | `str` | — | Source entity name |
| `predicate` | `str` | — | Relationship type |
| `obj` | `str` | — | Target entity name |
| `valid_from` | `str` | `None` | Start date (YYYY-MM-DD) |
| `valid_to` | `str` | `None` | End date |
| `confidence` | `float` | `1.0` | Confidence score 0.01.0 |
| `source_closet` | `str` | `None` | Link to verbatim memory |
| `source_file` | `str` | `None` | Original source file |
#### Query Methods
| Method | Parameters | Returns |
|--------|-----------|---------|
| `query_entity(name, as_of=None, direction='outgoing')` | Entity name, date filter, direction | `list[dict]` |
| `query_relationship(predicate, as_of=None)` | Relationship type, date filter | `list[dict]` |
| `timeline(entity_name=None)` | Optional entity filter | `list[dict]` |
| `stats()` | — | `dict` with entities, triples, predicates |
| `seed_from_entity_facts(entity_facts)` | Dict of entity facts | — |
**`query_entity` direction values:** `"outgoing"` (entity→?), `"incoming"` (?→entity), `"both"`
---
## `mempalace.palace_graph`
### `build_graph(col=None, config=None) → (nodes, edges)`
Build the palace graph from ChromaDB metadata.
**Returns:**
- `nodes`: `dict` of `{room: {wings: list, halls: list, count: int, dates: list}}`
- `edges`: `list` of `{room, wing_a, wing_b, hall, count}`
---
### `traverse(start_room, col=None, config=None, max_hops=2) → list`
BFS graph traversal from a room across wings.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `start_room` | `str` | — | Room slug to start from |
| `max_hops` | `int` | `2` | Max connection depth |
**Returns:** `[{room, wings, halls, count, hop, connected_via}]` (max 50)
---
### `find_tunnels(wing_a=None, wing_b=None, col=None, config=None) → list`
Find rooms spanning multiple wings.
**Returns:** `[{room, wings, halls, count, recent}]` (max 50)
---
### `graph_stats(col=None, config=None) → dict`
**Returns:** `{total_rooms, tunnel_rooms, total_edges, rooms_per_wing, top_tunnels}`
---
## `mempalace.dialect`
### `class Dialect(entities=None, skip_names=None)`
| Parameter | Type | Description |
|-----------|------|-------------|
| `entities` | `dict[str, str]` | Full name → 3-letter code mapping |
| `skip_names` | `list[str]` | Names to skip (fictional characters, etc.) |
#### Class Methods
| Method | Parameters | Returns |
|--------|-----------|---------|
| `from_config(config_path)` | JSON file path | `Dialect` instance |
#### Instance Methods
| Method | Parameters | Returns | Description |
|--------|-----------|---------|-------------|
| `compress(text, metadata=None)` | Plain text + optional metadata dict | `str` | AAAK-formatted summary |
| `encode_entity(name)` | Entity name | `str \| None` | 3-letter entity code |
| `encode_emotions(emotions)` | List of emotion strings | `str` | Compact emotion codes |
| `compress_file(path, output=None)` | Zettel JSON path | `str` | Compress zettel file |
| `compress_all(dir, output=None)` | Zettel directory | `str` | Compress all zettels |
| `save_config(path)` | Output path | — | Save entity mappings |
| `compression_stats(original, compressed)` | Both texts | `dict` | Compression ratio stats |
#### Static Methods
| Method | Parameters | Returns |
|--------|-----------|---------|
| `count_tokens(text)` | Any text | `int` |
---
## `mempalace.config`
### `class MempalaceConfig()`
Reads from `~/.mempalace/config.json` and environment variables.
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `palace_path` | `str` | `~/.mempalace/palace` | ChromaDB storage path |
| `collection_name` | `str` | `mempalace_drawers` | ChromaDB collection name |
| Method | Description |
|--------|-------------|
| `init()` | Create config directory and default files |
+95
View File
@@ -0,0 +1,95 @@
# Benchmarks
Curated summary of MemPalace benchmark results. For the full 725-line progression with every experiment, see [`benchmarks/BENCHMARKS.md`](https://github.com/milla-jovovich/mempalace/blob/main/benchmarks/BENCHMARKS.md) in the repository.
## The Core Finding
MemPalace's benchmarked raw baseline stores the source text and searches it with ChromaDB's default embeddings. No extraction layer or summarization step is required for that baseline.
**And it scores 96.6% on LongMemEval.**
## LongMemEval Results
| Mode | R@5 | LLM Required | Cost/query |
|------|-----|-------------|------------|
| Raw ChromaDB | **96.6%** | None | $0 |
| Hybrid v3 + rerank | 99.4% | Haiku | ~$0.001 |
| Palace + rerank | 99.4% | Haiku | ~$0.001 |
| **Hybrid v4 + rerank** | **100%** | Haiku | ~$0.001 |
The 96.6% raw score requires no API key, no cloud, and no LLM at any stage. The 100% result uses optional Haiku reranking.
### Per-Category Breakdown (Raw, 96.6%)
| Question Type | R@5 | Count |
|---------------|-----|-------|
| Knowledge update | 99.0% | 78 |
| Multi-session | 98.5% | 133 |
| Temporal reasoning | 96.2% | 133 |
| Single-session user | 95.7% | 70 |
| Single-session preference | 93.3% | 30 |
| Single-session assistant | 92.9% | 56 |
### Held-Out Validation
**98.4% R@5** on 450 questions that hybrid_v4 was never tuned on — confirming the improvements generalize.
## Comparison vs Published Systems
| System | LongMemEval R@5 | API Required | Cost |
|--------|----------------|--------------|------|
| **MemPalace (hybrid)** | **100%** | Optional | Free |
| Supermemory ASMR | ~99% | Yes | — |
| **MemPalace (raw)** | **96.6%** | **None** | **Free** |
| Mastra | 94.87% | Yes | API costs |
| Hindsight | 91.4% | Yes | API costs |
| Mem0 | ~85% | Yes | $19249/mo |
## Other Benchmarks
### ConvoMem (Salesforce, 75K+ QA pairs)
| System | Score |
|--------|-------|
| **MemPalace** | **92.9%** |
| Gemini (long context) | 7082% |
| Block extraction | 5771% |
| Mem0 (RAG) | 3045% |
On this benchmark, MemPalace materially outperforms the Mem0 result cited in the comparison table.
### LoCoMo (1,986 multi-hop QA pairs)
| Mode | R@10 | LLM |
|------|------|-----|
| Hybrid v5 + Sonnet rerank (top-50) | **100%** | Sonnet |
| bge-large + Haiku rerank (top-15) | 96.3% | Haiku |
| Hybrid v5 (top-10, no rerank) | **88.9%** | None |
| Session, no rerank (top-10) | 60.3% | None |
### MemBench (ACL 2025, 8,500 items)
**80.3% R@5** overall. Strongest categories: aggregative (99.3%), comparative (98.4%), lowlevel_rec (99.8%).
## Reproducing Results
All benchmarks are reproducible with public datasets:
```bash
git clone https://github.com/milla-jovovich/mempalace.git
cd mempalace
pip install chromadb pyyaml
# Download LongMemEval data
curl -fsSL -o /tmp/longmemeval_s_cleaned.json \
https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned/resolve/main/longmemeval_s_cleaned.json
# Run raw baseline (96.6%, no API key needed)
python benchmarks/longmemeval_bench.py /tmp/longmemeval_s_cleaned.json
```
::: tip
Results are deterministic. Same data + same script = same result every time. Every result JSONL file contains every question, every retrieved document, every score.
:::
For complete reproduction instructions, benchmark integrity notes, and the full score progression, see the [full benchmark documentation](https://github.com/milla-jovovich/mempalace/blob/main/benchmarks/BENCHMARKS.md).

Some files were not shown because too many files have changed in this diff Show More