@@ -2,9 +2,9 @@ name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
test-linux:
|
||||
|
||||
@@ -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
|
||||
@@ -6,6 +6,9 @@ __pycache__/
|
||||
.pytest_cache/
|
||||
mempal.yaml
|
||||
.a5c/
|
||||
.claude/
|
||||
.codex/
|
||||
.codex
|
||||
|
||||
# Environment
|
||||
.env
|
||||
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
|
||||
@@ -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!✨
|
||||
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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__"]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Storage backend implementations for MemPalace."""
|
||||
|
||||
from .base import BaseCollection
|
||||
from .chroma import ChromaBackend, ChromaCollection
|
||||
|
||||
__all__ = ["BaseCollection", "ChromaBackend", "ChromaCollection"]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
@@ -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": "(構築|修正|追加|削除|確認|作成|実装|修復|書き直し|テスト|検証|更新|設定|起動|停止)(?:し|した|して|する|します)"
|
||||
}
|
||||
}
|
||||
@@ -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": "(구축|수정|추가|삭제|확인|생성|구현|수리|작성|테스트|검증|업데이트|설정|시작|중지)(?:했|한|하여|합니다|했습니다)"
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
@@ -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": "(构建|修复|添加|删除|确认|创建|实现|修理|编写|测试|验证|更新|配置|启动|停止)(?:了|完成|成功)"
|
||||
}
|
||||
}
|
||||
@@ -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": "(構建|修復|添加|刪除|確認|創建|實現|修理|編寫|測試|驗證|更新|配置|啟動|停止)(?:了|完成|成功)"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.3–1.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,
|
||||
}
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
"""Single source of truth for the MemPalace package version."""
|
||||
|
||||
__version__ = "3.1.0"
|
||||
__version__ = "3.2.0"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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("")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 + "?"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
import DefaultTheme from 'vitepress/theme'
|
||||
import './style.css'
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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 | ~200–500 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 ~200–500 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())
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 | $19–249/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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 680 KiB |
@@ -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 (~500–800 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 (~200–500 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 (~170–900 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.0–1.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 |
|
||||
@@ -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 | $19–249/mo |
|
||||
|
||||
## Other Benchmarks
|
||||
|
||||
### ConvoMem (Salesforce, 75K+ QA pairs)
|
||||
|
||||
| System | Score |
|
||||
|--------|-------|
|
||||
| **MemPalace** | **92.9%** |
|
||||
| Gemini (long context) | 70–82% |
|
||||
| Block extraction | 57–71% |
|
||||
| Mem0 (RAG) | 30–45% |
|
||||
|
||||
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).
|
||||