Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4aa7e1eebd | ||
|
|
122a5fdf35 | ||
|
|
2a5694073d | ||
|
|
7a93ede797 | ||
|
|
8b26bf2ac3 | ||
|
|
81dc977bbf | ||
|
|
6614b9b4e7 | ||
|
|
a2432a3245 | ||
|
|
e200ce2c8a | ||
|
|
39e1651af3 | ||
|
|
c17cf079ad | ||
|
|
f4226047cb | ||
|
|
3a50966766 | ||
|
|
e9a706aa4a | ||
|
|
e6d232f538 | ||
|
|
41b8601d86 | ||
|
|
c68370609d | ||
|
|
22328540e1 | ||
|
|
c383523768 | ||
|
|
b1a676fa24 | ||
|
|
976289aa5c | ||
|
|
248ecd98f1 | ||
|
|
d2d4e62543 | ||
|
|
c478dfa173 | ||
|
|
bb577bb41f | ||
|
|
5ac600b3bb | ||
|
|
3b77624f8a | ||
|
|
8efd4e53b5 | ||
|
|
6f1cf6c86e | ||
|
|
87e8bafad8 | ||
|
|
9b60c6edd7 | ||
|
|
d52d6c9622 | ||
|
|
091c2fe1c6 | ||
|
|
862a07b198 | ||
|
|
6e2ced3287 | ||
|
|
915b8b2c75 | ||
|
|
c3f9b76d9a | ||
|
|
baf3c0ab64 | ||
|
|
74e5bf6090 | ||
|
|
27d8944f5e | ||
|
|
a487d059ba | ||
|
|
f224c48e09 | ||
|
|
f56e67b516 | ||
|
|
89206107fa | ||
|
|
dc143471bc | ||
|
|
15d9ee1b51 | ||
|
|
d82bbe754c | ||
|
|
d8b2db696f | ||
|
|
007acca59a | ||
|
|
f138d4929d | ||
|
|
abc99f4154 | ||
|
|
5b67e0740e | ||
|
|
4621f85d7c | ||
|
|
53229f725a | ||
|
|
607815acbb | ||
|
|
20c8f8e57b | ||
|
|
58eca5075a | ||
|
|
bb7ed80578 | ||
|
|
ae5196bc8d | ||
|
|
154e8a78ec | ||
|
|
c4d8662de8 | ||
|
|
89c0a58271 | ||
|
|
9c4b7302cc | ||
|
|
1056018b52 | ||
|
|
ad806cf3f8 | ||
|
|
2a1ac762e6 | ||
|
|
41d9d7adb9 | ||
|
|
6117606541 | ||
|
|
410d22488c | ||
|
|
f184a86361 | ||
|
|
b03ab482ef | ||
|
|
8a0488f88a | ||
|
|
309d9b0095 | ||
|
|
b57c1603e3 | ||
|
|
a9aaa45ccf | ||
|
|
0cbbfba8ed | ||
|
|
2e8a5a7b7a | ||
|
|
91952044d6 | ||
|
|
22454073a6 | ||
|
|
5b7708398c | ||
|
|
d0c9f9b0c1 | ||
|
|
8a6e75eed8 | ||
|
|
a868e16eaa | ||
|
|
60bea83e76 | ||
|
|
afa30a9cca | ||
|
|
e30c283fd8 | ||
|
|
15c5a528ed | ||
|
|
8930b45f97 | ||
|
|
e641b80448 | ||
|
|
559e43b2e9 | ||
|
|
71e8f2d054 | ||
|
|
697f651ae2 | ||
|
|
6d70dfdada | ||
|
|
2d7d7e080f | ||
|
|
a3fec4f565 | ||
|
|
06963ddaed | ||
|
|
a036b4300d | ||
|
|
3a0f782646 | ||
|
|
46520d2154 | ||
|
|
dfb22f5345 | ||
|
|
f96300bb86 | ||
|
|
2981433535 | ||
|
|
69afba3b28 | ||
|
|
df464a991d | ||
|
|
725fa2b6f1 | ||
|
|
0126f750d7 | ||
|
|
7509a72502 | ||
|
|
e5440e31af | ||
|
|
d20c8ab992 |
@@ -11,7 +11,7 @@ A Claude Code plugin that gives your AI a persistent memory system. Mine project
|
||||
### Claude Code Marketplace
|
||||
|
||||
```bash
|
||||
claude plugin marketplace add milla-jovovich/mempalace
|
||||
claude plugin marketplace add MemPalace/mempalace
|
||||
claude plugin install --scope user mempalace
|
||||
```
|
||||
|
||||
|
||||
@@ -25,5 +25,5 @@
|
||||
"palace",
|
||||
"search"
|
||||
],
|
||||
"repository": "https://github.com/milla-jovovich/mempalace"
|
||||
"repository": "https://github.com/MemPalace/mempalace"
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ codex /init
|
||||
1. Clone the MemPalace repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/milla-jovovich/mempalace.git
|
||||
git clone https://github.com/MemPalace/mempalace.git
|
||||
cd mempalace
|
||||
```
|
||||
|
||||
@@ -71,5 +71,5 @@ Set the `MEMPAL_DIR` environment variable to a directory path to automatically r
|
||||
|
||||
## Support
|
||||
|
||||
- Repository: https://github.com/milla-jovovich/mempalace
|
||||
- Issues: https://github.com/milla-jovovich/mempalace/issues
|
||||
- Repository: https://github.com/MemPalace/mempalace
|
||||
- Issues: https://github.com/MemPalace/mempalace/issues
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"author": {
|
||||
"name": "milla-jovovich"
|
||||
},
|
||||
"homepage": "https://github.com/milla-jovovich/mempalace",
|
||||
"repository": "https://github.com/milla-jovovich/mempalace",
|
||||
"homepage": "https://github.com/MemPalace/mempalace",
|
||||
"repository": "https://github.com/MemPalace/mempalace",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"memory",
|
||||
@@ -39,9 +39,9 @@
|
||||
"Read",
|
||||
"Write"
|
||||
],
|
||||
"websiteURL": "https://github.com/milla-jovovich/mempalace",
|
||||
"privacyPolicyURL": "https://github.com/milla-jovovich/mempalace",
|
||||
"termsOfServiceURL": "https://github.com/milla-jovovich/mempalace",
|
||||
"websiteURL": "https://github.com/MemPalace/mempalace",
|
||||
"privacyPolicyURL": "https://github.com/MemPalace/mempalace",
|
||||
"termsOfServiceURL": "https://github.com/MemPalace/mempalace",
|
||||
"defaultPrompt": [
|
||||
"Search my memories for recent decisions",
|
||||
"Mine this project into my memory palace",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Default owners for everything
|
||||
* @milla-jovovich @bensig @igorls
|
||||
|
||||
# Core library
|
||||
mempalace/ @milla-jovovich @bensig
|
||||
|
||||
# CI and workflows
|
||||
.github/ @bensig
|
||||
|
||||
# Plugins and integrations
|
||||
.claude-plugin/ @bensig
|
||||
.codex-plugin/ @bensig
|
||||
integrations/ @bensig
|
||||
@@ -0,0 +1,12 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 3
|
||||
@@ -2,9 +2,9 @@ name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
test-linux:
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- run: pip install -e ".[dev]"
|
||||
- run: python -m pytest tests/ -v --ignore=tests/benchmarks --cov=mempalace --cov-report=term-missing --cov-fail-under=80
|
||||
- run: python -m pytest tests/ -v --ignore=tests/benchmarks --cov=mempalace --cov-report=term-missing --cov-fail-under=80 --durations=10
|
||||
|
||||
test-windows:
|
||||
runs-on: windows-latest
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
with:
|
||||
python-version: "3.9"
|
||||
- run: pip install -e ".[dev]"
|
||||
- run: python -m pytest tests/ -v --ignore=tests/benchmarks --cov=mempalace --cov-report=term-missing --cov-fail-under=80
|
||||
- run: python -m pytest tests/ -v --ignore=tests/benchmarks --cov=mempalace --cov-report=term-missing --cov-fail-under=80 --durations=10
|
||||
|
||||
test-macos:
|
||||
runs-on: macos-latest
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
with:
|
||||
python-version: "3.9"
|
||||
- run: pip install -e ".[dev]"
|
||||
- run: python -m pytest tests/ -v --ignore=tests/benchmarks --cov=mempalace --cov-report=term-missing --cov-fail-under=80
|
||||
- run: python -m pytest tests/ -v --ignore=tests/benchmarks --cov=mempalace --cov-report=term-missing --cov-fail-under=80 --durations=10
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -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
|
||||
+30
@@ -6,3 +6,33 @@ __pycache__/
|
||||
.pytest_cache/
|
||||
mempal.yaml
|
||||
.a5c/
|
||||
.claude/
|
||||
.codex/
|
||||
.codex
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDEs
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Coverage
|
||||
htmlcov/
|
||||
.coverage
|
||||
coverage.xml
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# ChromaDB local data
|
||||
*.sqlite3-journal
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to [MemPalace](https://github.com/MemPalace/mempalace) are documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased] — v3.3.0 (on develop)
|
||||
|
||||
### New Features
|
||||
- Closet layer — a compact searchable index of pointers to verbatim drawers, enabling fast topical lookup without reading all content (#788)
|
||||
- BM25 hybrid search — closets boost ranking, drawers remain the source of truth (#795, #829)
|
||||
- Entity metadata on every drawer for filterable search (#829)
|
||||
- Diary ingest — day-based rooms for conversation transcripts (#829)
|
||||
- Cross-wing tunnels — explicit links between rooms in different wings for multi-project agents (#829)
|
||||
- Drawer-grep — returns the best-matching chunk plus adjacent context drawers (#829)
|
||||
- Offline fact checker against the entity registry and knowledge graph (#829)
|
||||
- LLM-based closet regeneration — optional, bring-your-own endpoint, no mandatory API key (#793)
|
||||
- Hall detection — routes drawer content to `emotions` / `technical` / `family` / `memory` / `identity` / `consciousness` / `creative` halls, enabling hall-based graph connectivity within wings (#835)
|
||||
|
||||
### Bug Fixes
|
||||
- Set `hnsw:space=cosine` metadata on all collection creation sites — fixes broken similarity scoring under ChromaDB's default L2 distance (#807, #218)
|
||||
- File-level locking prevents duplicate drawers when agents mine the same file concurrently (#784, #826)
|
||||
- Hybrid closet+drawer retrieval — closets boost ranking, never gate results (#795)
|
||||
- Stop hooks from making agents write in chat — saves tokens on every turn (#786)
|
||||
- Strip system tags, hook output, and Claude UI chrome from drawers before filing (#785)
|
||||
- Verbatim-safe `strip_noise` scoped to Claude Code JSONL only (#785)
|
||||
- Prevent diary entry ID collisions via microsecond timestamp and full content hash (#819)
|
||||
- Auto-rebuild stale drawers via `NORMALIZE_VERSION` schema gate
|
||||
- Enforce atomic topics in closets and extract richer pointers
|
||||
- Sync `version.py` to match `pyproject.toml` (#820)
|
||||
- Remove unused `main` import from `mempalace/__init__.py` (#827)
|
||||
- README audit — fix 7 stale claims (tool count, version badge, wake-up token cost, `dialect.py` lossless disclaimer, `pyproject.toml` version) with 42 regression-guard tests (#835)
|
||||
|
||||
### Improvements
|
||||
- Optimize entity detection with regex caching and pre-compilation (#828)
|
||||
- Extract locked filing block into helper to keep `mine_convos` under C901 complexity
|
||||
|
||||
### Documentation
|
||||
- Add `docs/CLOSETS.md` — closet layer overview
|
||||
- Fix stale `milla-jovovich/*` org URLs in website and plugin manifests (#787)
|
||||
- Fix remaining stale org URLs in contributor docs (#808)
|
||||
|
||||
### Internal
|
||||
- Add test coverage for `mine_lock`, closets, entity metadata, BM25, and diary
|
||||
- Verify `mine_lock` via disjoint critical-section intervals
|
||||
- Serialize `mine_lock` concurrency test with multiprocessing
|
||||
- Make diary state path assertion platform-neutral
|
||||
- Add `TestTunnels` coverage for cross-wing tunnel operations
|
||||
- Ruff format with CI-pinned version (0.4.x); format `mempalace/palace.py`
|
||||
|
||||
---
|
||||
|
||||
## [3.2.0] — 2026-04-12
|
||||
|
||||
### 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)
|
||||
|
||||
### 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)
|
||||
- Fix misaligned architecture diagram (#734, #733)
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
[Unreleased]: https://github.com/MemPalace/mempalace/compare/v3.2.0...HEAD
|
||||
[3.2.0]: https://github.com/MemPalace/mempalace/compare/v3.1.0...v3.2.0
|
||||
[3.1.0]: https://github.com/MemPalace/mempalace/compare/v3.0.0...v3.1.0
|
||||
[3.0.0]: https://github.com/MemPalace/mempalace/releases/tag/v3.0.0
|
||||
@@ -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`
|
||||
+6
-3
@@ -5,8 +5,11 @@ Thanks for wanting to help. MemPalace is open source and we welcome contribution
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
git clone https://github.com/milla-jovovich/mempalace.git
|
||||
# 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/MemPalace/mempalace.git
|
||||
|
||||
pip install -e ".[dev]" # installs with dev dependencies (pytest, build, twine)
|
||||
```
|
||||
|
||||
@@ -52,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
|
||||
|
||||
@@ -64,7 +67,7 @@ assets/ ← logo + brand
|
||||
|
||||
## Good First Issues
|
||||
|
||||
Check the [Issues](https://github.com/milla-jovovich/mempalace/issues) tab. Great starting points:
|
||||
Check the [Issues](https://github.com/MemPalace/mempalace/issues) tab. Great starting points:
|
||||
|
||||
- **New chat formats**: Add import support for Cursor, Copilot, or other AI tool exports
|
||||
- **Room detection**: Improve pattern matching in `room_detector_local.py`
|
||||
|
||||
+34
@@ -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!✨
|
||||
@@ -84,6 +84,18 @@ Other memory systems try to fix this by letting AI decide what's worth rememberi
|
||||
|
||||
---
|
||||
|
||||
## An important follow up note regarding fake MemPalace websites - April 11, 2026
|
||||
|
||||
Several Community Members (#267, #326, #506) have pointed out there are fake MemPalace websites popping up, including ones with Malware.
|
||||
|
||||
To be super clear, MemPalace *has no website* (at least for now), so anything claiming to be one is false.
|
||||
|
||||
Thanks to our Community Members for letting us know about the problem.
|
||||
|
||||
Stay safe out there.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
@@ -130,7 +142,7 @@ Restart Claude Code, then type `/skills` to verify "mempalace" appears.
|
||||
claude mcp add mempalace -- python -m mempalace.mcp_server
|
||||
```
|
||||
|
||||
Now your AI has 19 tools available through MCP. Ask it anything:
|
||||
Now your AI has 29 tools available through MCP. Ask it anything:
|
||||
|
||||
> *"What did we decide about auth last month?"*
|
||||
|
||||
@@ -149,7 +161,7 @@ mempalace wake-up > context.txt
|
||||
# Paste context.txt into your local model's system prompt
|
||||
```
|
||||
|
||||
This gives your local model ~170 tokens of critical facts (in AAAK if you prefer) before you ask a single question.
|
||||
This gives your local model ~600-900 tokens of critical facts (in AAAK if you prefer) before you ask a single question.
|
||||
|
||||
**2. CLI search** — query on demand, feed results into your prompt:
|
||||
|
||||
@@ -180,10 +192,10 @@ Decisions happen in conversations now. Not in docs. Not in Jira. In conversation
|
||||
|----------|--------------|-------------|
|
||||
| Paste everything | 19.5M — doesn't fit any context window | Impossible |
|
||||
| LLM summaries | ~650K | ~$507/yr |
|
||||
| **MemPalace wake-up** | **~170 tokens** | **~$0.70/yr** |
|
||||
| **MemPalace wake-up** | **~600-900 tokens** | **~$0.70/yr** |
|
||||
| **MemPalace + 5 searches** | **~13,500 tokens** | **~$10/yr** |
|
||||
|
||||
MemPalace loads 170 tokens of critical facts on wake-up — your team, your projects, your preferences. Then searches only when needed. $10/year to remember everything vs $507/year for summaries that lose context.
|
||||
MemPalace loads ~600-900 tokens of critical facts on wake-up — your team, your projects, your preferences. Then searches only when needed. $10/year to remember everything vs $507/year for summaries that lose context.
|
||||
|
||||
---
|
||||
|
||||
@@ -206,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.
|
||||
@@ -281,7 +293,7 @@ Wings and rooms aren't cosmetic. They're a **34% retrieval improvement**. The pa
|
||||
| **L2** | Room recall — recent sessions, current project | On demand | When topic comes up |
|
||||
| **L3** | Deep search — semantic query across all closets | On demand | When explicitly asked |
|
||||
|
||||
Your AI wakes up with L0 + L1 (~170 tokens) and knows your world. Searches only fire when needed.
|
||||
Your AI wakes up with L0 + L1 (~600-900 tokens) and knows your world. Searches only fire when needed.
|
||||
|
||||
### AAAK Dialect (experimental)
|
||||
|
||||
@@ -458,7 +470,7 @@ claude plugin install --scope user mempalace
|
||||
claude mcp add mempalace -- python -m mempalace.mcp_server
|
||||
```
|
||||
|
||||
### 19 Tools
|
||||
### 29 Tools
|
||||
|
||||
**Palace (read)**
|
||||
|
||||
@@ -496,6 +508,18 @@ claude mcp add mempalace -- python -m mempalace.mcp_server
|
||||
| `mempalace_traverse` | Walk the graph from a room across wings |
|
||||
| `mempalace_find_tunnels` | Find rooms bridging two wings |
|
||||
| `mempalace_graph_stats` | Graph connectivity overview |
|
||||
| `mempalace_create_tunnel` | Create explicit cross-wing link between two rooms |
|
||||
| `mempalace_list_tunnels` | List all explicit tunnels, filter by wing |
|
||||
| `mempalace_delete_tunnel` | Remove a tunnel by ID |
|
||||
| `mempalace_follow_tunnels` | Follow tunnels from a room to connected rooms in other wings |
|
||||
|
||||
**Drawer Management**
|
||||
|
||||
| Tool | What |
|
||||
|------|------|
|
||||
| `mempalace_get_drawer` | Fetch a single drawer by ID |
|
||||
| `mempalace_list_drawers` | Paginated drawer listing |
|
||||
| `mempalace_update_drawer` | Update drawer content or metadata |
|
||||
|
||||
**Agent Diary**
|
||||
|
||||
@@ -504,6 +528,14 @@ claude mcp add mempalace -- python -m mempalace.mcp_server
|
||||
| `mempalace_diary_write` | Write AAAK diary entry |
|
||||
| `mempalace_diary_read` | Read recent diary entries |
|
||||
|
||||
**System**
|
||||
|
||||
| Tool | What |
|
||||
|------|------|
|
||||
| `mempalace_hook_settings` | Get/set hook behavior (silent save, toast) |
|
||||
| `mempalace_memories_filed_away` | Check if recent checkpoint was saved |
|
||||
| `mempalace_reconnect` | Force DB reconnect after external writes |
|
||||
|
||||
The AI learns AAAK and the memory protocol automatically from the `mempalace_status` response. No manual configuration.
|
||||
|
||||
---
|
||||
@@ -585,6 +617,9 @@ mempalace compress --wing myapp # AAAK compress
|
||||
|
||||
# Status
|
||||
mempalace status # palace overview
|
||||
|
||||
# MCP
|
||||
mempalace mcp # show MCP setup command
|
||||
```
|
||||
|
||||
All commands accept `--palace <path>` to override the default location.
|
||||
@@ -630,12 +665,12 @@ Plain text. Becomes Layer 0 — loaded every session.
|
||||
| `cli.py` | CLI entry point |
|
||||
| `config.py` | Configuration loading and defaults |
|
||||
| `normalize.py` | Converts 5 chat formats to standard transcript |
|
||||
| `mcp_server.py` | MCP server — 19 tools, AAAK auto-teach, memory protocol |
|
||||
| `mcp_server.py` | MCP server — 29 tools, AAAK auto-teach, memory protocol |
|
||||
| `miner.py` | Project file ingest |
|
||||
| `convo_miner.py` | Conversation ingest — chunks by exchange pair |
|
||||
| `searcher.py` | Semantic search via ChromaDB |
|
||||
| `layers.py` | 4-layer memory stack |
|
||||
| `dialect.py` | AAAK compression — 30x lossless |
|
||||
| `dialect.py` | AAAK index format for closet pointers |
|
||||
| `knowledge_graph.py` | Temporal entity-relationship graph (SQLite) |
|
||||
| `palace_graph.py` | Room-based navigation graph |
|
||||
| `onboarding.py` | Guided setup — generates AAAK bootstrap + wing config |
|
||||
@@ -654,7 +689,7 @@ mempalace/
|
||||
├── README.md ← you are here
|
||||
├── mempalace/ ← core package (README)
|
||||
│ ├── cli.py ← CLI entry point
|
||||
│ ├── mcp_server.py ← MCP server (19 tools)
|
||||
│ ├── mcp_server.py ← MCP server (29 tools)
|
||||
│ ├── knowledge_graph.py ← temporal entity graph
|
||||
│ ├── palace_graph.py ← room navigation graph
|
||||
│ ├── dialect.py ← AAAK compression
|
||||
@@ -679,7 +714,7 @@ mempalace/
|
||||
│ └── mcp_setup.md
|
||||
├── tests/ ← test suite (README)
|
||||
├── assets/ ← logo + brand assets
|
||||
└── pyproject.toml ← package config (v3.0.0)
|
||||
└── pyproject.toml ← package config (v3.3.0)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -707,7 +742,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.3.0-4dc9f6?style=flat-square&labelColor=0a0e14
|
||||
[release-link]: https://github.com/milla-jovovich/mempalace/releases
|
||||
[python-shield]: https://img.shields.io/badge/python-3.9+-7dd8f8?style=flat-square&labelColor=0a0e14&logo=python&logoColor=7dd8f8
|
||||
[python-link]: https://www.python.org/
|
||||
|
||||
+74
@@ -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",
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# Closets — The Searchable Index Layer
|
||||
|
||||
## What closets are
|
||||
|
||||
Drawers hold your verbatim content. Closets are the index — compact pointers that tell the searcher which drawers to open.
|
||||
|
||||
```
|
||||
CLOSET: "built auth system|Ben;Igor|→drawer_api_auth_a1b2c3"
|
||||
↑ topic ↑ entities ↑ points to this drawer
|
||||
```
|
||||
|
||||
An agent searching "who built the auth?" hits the closet first (fast scan of short text), then opens the referenced drawer to get the full verbatim content.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
### When are closets created?
|
||||
|
||||
Closets are created during `mempalace mine`. For each file mined:
|
||||
1. Content is chunked into drawers (verbatim, ~800 chars each)
|
||||
2. Topics, entities, and quotes are extracted from the content
|
||||
3. A closet is created with pointer lines to those drawers
|
||||
|
||||
### What's inside a closet?
|
||||
|
||||
Each line is one atomic topic pointer:
|
||||
```
|
||||
topic description|entity1;entity2|→drawer_id_1,drawer_id_2
|
||||
"verbatim quote from the content"|entity1|→drawer_id_3
|
||||
```
|
||||
|
||||
Topics are never split across closets. If adding a topic would exceed 1,500 characters, a new closet is created.
|
||||
|
||||
### When do closets update?
|
||||
|
||||
When a file is re-mined (content changed, or `NORMALIZE_VERSION` was bumped), the miner first deletes every closet for that source file (`purge_file_closets`) and then writes a fresh set. Stale topics from the prior mine are gone — closets are always a snapshot of the current content, never an accumulation across runs.
|
||||
|
||||
### What about stale topics?
|
||||
|
||||
There are no stale topics: each re-mine is a clean rebuild for that source file. If a file gets larger and produces fewer or more closets than last time, the leftover numbered closets from the larger run are still purged because the delete is done by `source_file`, not by ID.
|
||||
|
||||
### Do closets survive palace rebuilds?
|
||||
|
||||
Closets are stored in the `mempalace_closets` ChromaDB collection alongside `mempalace_drawers`. If you delete and rebuild the palace, closets are recreated during the next `mempalace mine`.
|
||||
|
||||
## How search uses closets
|
||||
|
||||
```
|
||||
Query → search mempalace_closets (fast, small documents)
|
||||
↓
|
||||
top closet hits → parse `→drawer_id_a,drawer_id_b` pointers
|
||||
↓
|
||||
fetch exactly those drawers from mempalace_drawers (verbatim content)
|
||||
↓
|
||||
apply max_distance filter
|
||||
↓
|
||||
return chunk-level results (same shape as direct search)
|
||||
```
|
||||
|
||||
Hits carry `matched_via: "closet"` (or `"drawer"` for the fallback path) plus a `closet_preview` field showing the line that surfaced them.
|
||||
|
||||
If no closets exist (palace created before this feature) — or all closet hits get filtered out by `max_distance` — search falls back to direct drawer search. Closets are created on next mine.
|
||||
|
||||
> **BM25 hybrid re-rank** is on the roadmap (deferred to a follow-up PR alongside generic `LLM_*` env-var support); the current closet search ranks purely by ChromaDB cosine distance against the closet text.
|
||||
|
||||
## Limits
|
||||
|
||||
| Setting | Value | Reason |
|
||||
|---------|-------|--------|
|
||||
| Max closet size | 1,500 chars (`CLOSET_CHAR_LIMIT`) | Leaves buffer under ChromaDB's working limit |
|
||||
| Source content scanned | 5,000 chars (`CLOSET_EXTRACT_WINDOW`) | Caps regex extraction cost on long files; back-of-file content is currently invisible to closet extraction (tracked for follow-up) |
|
||||
| Max topics per file | 12 | Keeps closets focused |
|
||||
| Max quotes per file | 3 | Most relevant only |
|
||||
| Max entities per pointer | 5 | Top names by frequency, after stoplist filtering |
|
||||
|
||||
## For developers
|
||||
|
||||
Closet functions live in `mempalace/palace.py`:
|
||||
- `get_closets_collection()` — get the closets ChromaDB collection
|
||||
- `build_closet_lines()` — extract topics/entities/quotes into pointer lines
|
||||
- `upsert_closet_lines()` — write lines to closets respecting the char limit (overwrites existing IDs; does not append — call `purge_file_closets` first when re-mining)
|
||||
- `purge_file_closets()` — delete every closet for a given source file before rebuild
|
||||
- `CLOSET_CHAR_LIMIT` / `CLOSET_EXTRACT_WINDOW` — size constants
|
||||
|
||||
The closet-first search path lives in `mempalace/searcher.py`:
|
||||
- `_extract_drawer_ids_from_closet()` — parse `→drawer_a,drawer_b` pointers out of a closet document
|
||||
- `_closet_first_hits()` — query closets, parse pointers, hydrate matching drawers, return chunk-level hits or `None` to fall back
|
||||
|
||||
Note: only the project miner (`miner.py::process_file`) builds closets today. Conversation-mined wings (Claude Code JSONL, ChatGPT export, etc.) will keep using direct drawer search via the searcher fallback until the convo-closet PR lands.
|
||||
@@ -0,0 +1,36 @@
|
||||
-- MemPalace Knowledge Graph Schema
|
||||
-- SQLite database at ~/.mempalace/knowledge_graph.db
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entities (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT DEFAULT 'unknown',
|
||||
properties TEXT DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS triples (
|
||||
id TEXT PRIMARY KEY,
|
||||
subject TEXT NOT NULL,
|
||||
predicate TEXT NOT NULL,
|
||||
object TEXT NOT NULL,
|
||||
valid_from TEXT,
|
||||
valid_to TEXT,
|
||||
confidence REAL DEFAULT 1.0,
|
||||
source_closet TEXT,
|
||||
source_file TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attributes (
|
||||
entity_id TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT,
|
||||
valid_from TEXT,
|
||||
valid_to TEXT,
|
||||
PRIMARY KEY (entity_id, key, valid_from)
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_triples_subject ON triples(subject);
|
||||
CREATE INDEX IF NOT EXISTS idx_triples_object ON triples(object);
|
||||
CREATE INDEX IF NOT EXISTS idx_triples_predicate ON triples(predicate);
|
||||
CREATE INDEX IF NOT EXISTS idx_triples_valid ON triples(valid_from, valid_to);
|
||||
@@ -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
|
||||
|
||||
+5
-1
@@ -133,6 +133,10 @@ Example output:
|
||||
[14:40:01] Session abc123: 18 exchanges, 3 since last save
|
||||
```
|
||||
|
||||
## Known Limitations
|
||||
|
||||
**Hooks require session restart after install.** Claude Code loads hooks from `settings.json` at session start only. If you run `mempalace init` or manually edit hook config mid-session, the hooks won't fire until you restart Claude Code. This is a Claude Code limitation.
|
||||
|
||||
## Cost
|
||||
|
||||
**Zero extra tokens.** The hooks are bash scripts that run locally. They don't call any API. The only "cost" is the AI spending a few seconds organizing memories at each checkpoint — and it's doing that with context it already has loaded.
|
||||
**Zero extra tokens.** The hooks notify the AI that saves happened in the background — the AI doesn't need to write anything in the chat. All filing is handled automatically. Previous versions asked the AI to write diary entries and drawer content in the chat window, which cost ~$1/session in retransmitted tokens.
|
||||
|
||||
@@ -68,10 +68,10 @@ if [ -n "$MEMPAL_DIR" ] && [ -d "$MEMPAL_DIR" ]; then
|
||||
python3 -m mempalace mine "$MEMPAL_DIR" >> "$STATE_DIR/hook.log" 2>&1
|
||||
fi
|
||||
|
||||
# Always block — compaction = save everything
|
||||
# Notify — compaction is about to happen but filing is handled in background
|
||||
cat << 'HOOKJSON'
|
||||
{
|
||||
"decision": "block",
|
||||
"reason": "COMPACTION IMMINENT. Save ALL topics, decisions, quotes, code, and important context from this session to your memory system. Be thorough — after compaction, detailed context will be lost. Organize into appropriate categories. Use verbatim quotes where possible. Save everything, then allow compaction to proceed."
|
||||
"decision": "allow",
|
||||
"reason": "MemPalace pre-compaction save. Your full conversation has been saved verbatim in the background — no action needed. Compaction can proceed safely."
|
||||
}
|
||||
HOOKJSON
|
||||
|
||||
@@ -140,12 +140,15 @@ if [ "$SINCE_LAST" -ge "$SAVE_INTERVAL" ] && [ "$EXCHANGE_COUNT" -gt 0 ]; then
|
||||
python3 -m mempalace mine "$MEMPAL_DIR" >> "$STATE_DIR/hook.log" 2>&1 &
|
||||
fi
|
||||
|
||||
# Block the AI and tell it to save
|
||||
# The "reason" becomes a system message the AI sees and acts on
|
||||
# Notify the AI that a checkpoint happened — but do NOT ask it to write
|
||||
# anything in chat. All filing happens in the background via the pipeline.
|
||||
# The old version asked the agent to write diary entries, add drawers, and
|
||||
# add KG triples in the chat window — that cost ~$1/session in retransmitted
|
||||
# tokens and cluttered the conversation.
|
||||
cat << 'HOOKJSON'
|
||||
{
|
||||
"decision": "block",
|
||||
"reason": "AUTO-SAVE checkpoint. Save key topics, decisions, quotes, and code from this session to your memory system. Organize into appropriate categories. Use verbatim quotes where possible. Continue conversation after saving."
|
||||
"decision": "allow",
|
||||
"reason": "MemPalace auto-save checkpoint. Your conversation is being saved verbatim in the background — no action needed from you. Continue working."
|
||||
}
|
||||
HOOKJSON
|
||||
else
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
---
|
||||
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.3.0
|
||||
homepage: https://github.com/MemPalace/mempalace
|
||||
user-invocable: true
|
||||
metadata:
|
||||
openclaw:
|
||||
emoji: "\U0001F3DB"
|
||||
os:
|
||||
- darwin
|
||||
- linux
|
||||
- win32
|
||||
requires:
|
||||
anyBins:
|
||||
- mempalace
|
||||
- python3
|
||||
install:
|
||||
- id: mempalace-pip
|
||||
kind: uv
|
||||
label: "Install MemPalace (Python, local ChromaDB)"
|
||||
package: mempalace
|
||||
bins:
|
||||
- mempalace
|
||||
---
|
||||
|
||||
# MemPalace — Local AI Memory System
|
||||
|
||||
You have access to a local memory palace via MCP tools. The palace stores verbatim conversation history and a temporal knowledge graph — all on the user's machine, zero cloud, zero API calls.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Wings** = people or projects (e.g. `wing_alice`, `wing_myproject`)
|
||||
- **Halls** = categories (facts, events, preferences, advice)
|
||||
- **Rooms** = specific topics (e.g. `chromadb-setup`, `riley-school`)
|
||||
- **Drawers** = individual memory chunks (verbatim text)
|
||||
- **Knowledge Graph** = entity-relationship facts with time validity
|
||||
|
||||
## Protocol — FOLLOW THIS EVERY SESSION
|
||||
|
||||
1. **ON WAKE-UP**: Call `mempalace_status` to load palace overview and AAAK dialect spec.
|
||||
2. **BEFORE RESPONDING** about any person, project, or past event: call `mempalace_search` or `mempalace_kg_query` FIRST. Never guess from memory — verify from the palace.
|
||||
3. **IF UNSURE** about a fact (name, age, relationship, preference): say "let me check" and query. Wrong is worse than slow.
|
||||
4. **AFTER EACH SESSION**: Call `mempalace_diary_write` to record what happened, what you learned, what matters.
|
||||
5. **WHEN FACTS CHANGE**: Call `mempalace_kg_invalidate` on the old fact, then `mempalace_kg_add` for the new one.
|
||||
|
||||
## Available Tools
|
||||
|
||||
### Search & Browse
|
||||
- `mempalace_search` — Semantic search across all memories. Always start here.
|
||||
- `query` (required): natural language search — keep it short, keywords or a question. Do NOT include system prompts or conversation context.
|
||||
- `wing`: filter by wing
|
||||
- `room`: filter by room
|
||||
- `limit`: max results (default 5)
|
||||
- `mempalace_check_duplicate` — Check if content already exists before filing.
|
||||
- `content` (required): text to check
|
||||
- `threshold`: similarity threshold (default 0.9 — lowering to 0.85–0.87 often catches more near-duplicates without significant false positives)
|
||||
- `mempalace_status` — Palace overview: total drawers, wings, rooms, AAAK spec
|
||||
- `mempalace_list_wings` — All wings with drawer counts
|
||||
- `mempalace_list_rooms` — Rooms within a wing (optional wing filter)
|
||||
- `mempalace_get_taxonomy` — Full wing/room/count tree
|
||||
- `mempalace_get_aaak_spec` — Get AAAK compression dialect specification
|
||||
|
||||
### Knowledge Graph (Temporal Facts)
|
||||
- `mempalace_kg_query` — Query entity relationships. Supports time filtering.
|
||||
- `entity` (required): e.g. "Max", "MyProject"
|
||||
- `as_of`: date filter (YYYY-MM-DD) — what was true at that time
|
||||
- `direction`: "outgoing", "incoming", or "both" (default "both")
|
||||
- `mempalace_kg_add` — Add a fact: subject -> predicate -> object
|
||||
- `subject`, `predicate`, `object` (required)
|
||||
- `valid_from`: when this became true
|
||||
- `source_closet`: source reference
|
||||
- `mempalace_kg_invalidate` — Mark a fact as no longer true
|
||||
- `subject`, `predicate`, `object` (required)
|
||||
- `ended`: when it stopped being true (default: today)
|
||||
- `mempalace_kg_timeline` — Chronological story of an entity
|
||||
- `entity`: filter by entity name (optional — all events if omitted)
|
||||
- `mempalace_kg_stats` — Graph overview: entities, triples, relationship types
|
||||
|
||||
### Palace Graph (Cross-Domain Connections)
|
||||
- `mempalace_traverse` — Walk from a room, find connected ideas across wings
|
||||
- `start_room` (required): room to start from
|
||||
- `max_hops`: connection depth (default 2)
|
||||
- `mempalace_find_tunnels` — Find rooms that bridge two wings
|
||||
- `wing_a`, `wing_b` (required)
|
||||
- `mempalace_graph_stats` — Graph connectivity overview
|
||||
|
||||
### Write
|
||||
- `mempalace_add_drawer` — Store verbatim content into a wing/room
|
||||
- `wing`, `room`, `content` (required)
|
||||
- `source_file`: optional source reference
|
||||
- Checks for duplicates automatically
|
||||
- `mempalace_delete_drawer` — Remove a drawer by ID
|
||||
- `drawer_id` (required)
|
||||
- `mempalace_diary_write` — Write a session diary entry
|
||||
- `agent_name` (required): your name/identifier
|
||||
- `entry` (required): what happened, what you learned, what matters
|
||||
- `topic`: category tag (default "general")
|
||||
- `mempalace_diary_read` — Read recent diary entries
|
||||
- `agent_name` (required)
|
||||
- `last_n`: number of entries (default 10)
|
||||
|
||||
## Setup
|
||||
|
||||
Install MemPalace and populate the palace:
|
||||
|
||||
```bash
|
||||
pip install mempalace
|
||||
mempalace init ~/my-convos
|
||||
mempalace mine ~/my-convos
|
||||
```
|
||||
|
||||
### OpenClaw MCP config
|
||||
|
||||
Add to your OpenClaw MCP configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "python3",
|
||||
"args": ["-m", "mempalace.mcp_server"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or via CLI:
|
||||
|
||||
```bash
|
||||
openclaw mcp set mempalace '{"command":"python3","args":["-m","mempalace.mcp_server"]}'
|
||||
```
|
||||
|
||||
### Other MCP hosts
|
||||
|
||||
```bash
|
||||
# Claude Code
|
||||
claude mcp add mempalace -- python -m mempalace.mcp_server
|
||||
|
||||
# Cursor — add to .cursor/mcp.json
|
||||
# Codex — add to .codex/mcp.json
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Search is semantic (meaning-based), not keyword. "What did we discuss about database performance?" works better than "database".
|
||||
- The knowledge graph stores typed relationships with time windows. Use it for facts about people and projects — it knows WHEN things were true.
|
||||
- Diary entries accumulate across sessions. Write one at the end of each conversation to build continuity.
|
||||
- Use `mempalace_check_duplicate` before storing new content to avoid duplicates.
|
||||
- The AAAK dialect (from `mempalace_status`) is a compressed notation for efficient storage. Read it naturally — expand codes mentally, treat *markers* as emotional context.
|
||||
|
||||
## License
|
||||
|
||||
[MemPalace](https://github.com/MemPalace/mempalace) is MIT licensed. Created by Milla Jovovich, Ben Sigman, Igor Lins e Silva, and contributors.
|
||||
+14
-8
@@ -1,10 +1,7 @@
|
||||
"""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
|
||||
|
||||
# ChromaDB 0.6.x ships a Posthog telemetry client whose capture() signature is
|
||||
@@ -13,9 +10,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__"]
|
||||
__all__ = ["__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,93 @@
|
||||
"""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, metadata={"hnsw:space": "cosine"}
|
||||
)
|
||||
else:
|
||||
collection = client.get_collection(collection_name)
|
||||
return ChromaCollection(collection)
|
||||
+98
-13
@@ -14,6 +14,7 @@ Commands:
|
||||
mempalace mine <dir> Mine project files (default)
|
||||
mempalace mine <dir> --mode convos Mine conversation exports
|
||||
mempalace search "query" Find anything, exact words
|
||||
mempalace mcp Show MCP setup command
|
||||
mempalace wake-up Show L0 + L1 wake-up context
|
||||
mempalace wake-up --wing my_app Wake-up for a specific project
|
||||
mempalace status Show what's been filed
|
||||
@@ -28,6 +29,7 @@ Examples:
|
||||
|
||||
import os
|
||||
import sys
|
||||
import shlex
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
@@ -132,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:
|
||||
@@ -148,6 +151,18 @@ def cmd_split(args):
|
||||
sys.argv = old_argv
|
||||
|
||||
|
||||
def cmd_migrate(args):
|
||||
"""Migrate palace from a different ChromaDB version."""
|
||||
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,
|
||||
confirm=getattr(args, "yes", False),
|
||||
)
|
||||
|
||||
|
||||
def cmd_status(args):
|
||||
from .miner import status
|
||||
|
||||
@@ -159,12 +174,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")
|
||||
@@ -186,6 +208,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
|
||||
@@ -202,16 +229,22 @@ 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)
|
||||
|
||||
print(" Rebuilding collection...")
|
||||
client.delete_collection("mempalace_drawers")
|
||||
new_col = client.create_collection("mempalace_drawers")
|
||||
new_col = client.create_collection("mempalace_drawers", metadata={"hnsw:space": "cosine"})
|
||||
|
||||
filed = 0
|
||||
for i in range(0, len(all_ids), batch_size):
|
||||
@@ -241,6 +274,27 @@ def cmd_instructions(args):
|
||||
run_instructions(name=args.name)
|
||||
|
||||
|
||||
def cmd_mcp(args):
|
||||
"""Show how to wire MemPalace into MCP-capable hosts."""
|
||||
base_server_cmd = "python -m mempalace.mcp_server"
|
||||
|
||||
if args.palace:
|
||||
resolved_palace = str(Path(args.palace).expanduser())
|
||||
server_cmd = f"{base_server_cmd} --palace {shlex.quote(resolved_palace)}"
|
||||
else:
|
||||
server_cmd = base_server_cmd
|
||||
|
||||
print("MemPalace MCP quick setup:")
|
||||
print(f" claude mcp add mempalace -- {server_cmd}")
|
||||
print("\nRun the server directly:")
|
||||
print(f" {server_cmd}")
|
||||
|
||||
if not args.palace:
|
||||
print("\nOptional custom palace:")
|
||||
print(f" claude mcp add mempalace -- {base_server_cmd} --palace /path/to/palace")
|
||||
print(f" {base_server_cmd} --palace /path/to/palace")
|
||||
|
||||
|
||||
def cmd_compress(args):
|
||||
"""Compress drawers in a wing using AAAK Dialect."""
|
||||
import chromadb
|
||||
@@ -278,7 +332,11 @@ def cmd_compress(args):
|
||||
offset = 0
|
||||
while True:
|
||||
try:
|
||||
kwargs = {"include": ["documents", "metadatas"], "limit": _BATCH, "offset": offset}
|
||||
kwargs = {
|
||||
"include": ["documents", "metadatas"],
|
||||
"limit": _BATCH,
|
||||
"offset": offset,
|
||||
}
|
||||
if where:
|
||||
kwargs["where"] = where
|
||||
batch = col.get(**kwargs)
|
||||
@@ -318,7 +376,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))
|
||||
|
||||
@@ -328,7 +386,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()
|
||||
@@ -336,11 +394,13 @@ def cmd_compress(args):
|
||||
# Store compressed versions (unless dry-run)
|
||||
if not args.dry_run:
|
||||
try:
|
||||
comp_col = client.get_or_create_collection("mempalace_compressed")
|
||||
comp_col = client.get_or_create_collection(
|
||||
"mempalace_compressed", metadata={"hnsw:space": "cosine"}
|
||||
)
|
||||
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],
|
||||
@@ -355,8 +415,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)")
|
||||
@@ -380,7 +441,9 @@ def main():
|
||||
p_init = sub.add_parser("init", help="Detect rooms from your folder structure")
|
||||
p_init.add_argument("dir", help="Project directory to set up")
|
||||
p_init.add_argument(
|
||||
"--yes", action="store_true", help="Auto-accept all detected entities (non-interactive)"
|
||||
"--yes",
|
||||
action="store_true",
|
||||
help="Auto-accept all detected entities (non-interactive)",
|
||||
)
|
||||
|
||||
# mine
|
||||
@@ -499,9 +562,29 @@ 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(
|
||||
"mcp",
|
||||
help="Show MCP setup command for connecting MemPalace to your AI client",
|
||||
)
|
||||
|
||||
# status
|
||||
# migrate
|
||||
p_migrate = sub.add_parser(
|
||||
"migrate",
|
||||
help="Migrate palace from a different ChromaDB version (fixes 3.0.0 → 3.1.0 upgrade)",
|
||||
)
|
||||
p_migrate.add_argument(
|
||||
"--dry-run",
|
||||
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")
|
||||
|
||||
args = parser.parse_args()
|
||||
@@ -532,9 +615,11 @@ def main():
|
||||
"mine": cmd_mine,
|
||||
"split": cmd_split,
|
||||
"search": cmd_search,
|
||||
"mcp": cmd_mcp,
|
||||
"compress": cmd_compress,
|
||||
"wake-up": cmd_wakeup,
|
||||
"repair": cmd_repair,
|
||||
"migrate": cmd_migrate,
|
||||
"status": cmd_status,
|
||||
}
|
||||
dispatch[args.command](args)
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
"""
|
||||
closet_llm.py — Generate closets via a user-configured LLM for richer indexing.
|
||||
|
||||
The regex-based closet extraction catches action verbs, headers, and proper
|
||||
nouns — but misses implicit topics, foreign-language content, and contextual
|
||||
references. An LLM reads everything and produces better closets.
|
||||
|
||||
This module is **OPTIONAL and opt-in**. Regex closets are always created by
|
||||
the miner; this path regenerates them afterward using whatever LLM the user
|
||||
chooses. Core memory operations remain API-free by design (see CLAUDE.md,
|
||||
"Local-first, zero API").
|
||||
|
||||
## Bring-your-own-LLM configuration
|
||||
|
||||
The endpoint is any OpenAI-compatible Chat Completions URL:
|
||||
|
||||
LLM_ENDPOINT=http://localhost:11434/v1 # Ollama
|
||||
LLM_ENDPOINT=http://localhost:8000/v1 # vLLM, llama.cpp
|
||||
LLM_ENDPOINT=https://api.openai.com/v1
|
||||
LLM_ENDPOINT=https://openrouter.ai/api/v1
|
||||
LLM_ENDPOINT=https://api.anthropic.com/v1 # when proxied through a compat layer
|
||||
|
||||
Set:
|
||||
LLM_ENDPOINT — base URL (required)
|
||||
LLM_KEY — bearer token (optional; local inference usually doesn't need it)
|
||||
LLM_MODEL — model name (required), e.g. "gpt-4o-mini", "llama3:8b", "qwen2.5:7b"
|
||||
|
||||
Or pass flags on the CLI (flags win over env):
|
||||
|
||||
python -m mempalace.closet_llm \\
|
||||
--palace ~/.mempalace/palace \\
|
||||
--endpoint http://localhost:11434/v1 \\
|
||||
--model llama3:8b
|
||||
|
||||
No vendor lock-in. No hidden dependency on any specific provider. Zero deps
|
||||
added to pyproject — uses stdlib urllib.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from .palace import (
|
||||
NORMALIZE_VERSION,
|
||||
get_closets_collection,
|
||||
get_collection,
|
||||
mine_lock,
|
||||
purge_file_closets,
|
||||
upsert_closet_lines,
|
||||
)
|
||||
|
||||
MAX_CONTENT_CHARS = 30000
|
||||
MAX_OUTPUT_TOKENS = 1500
|
||||
HTTP_TIMEOUT_S = 60
|
||||
|
||||
PROMPT_TEMPLATE = """You are reading content filed in a memory palace. Generate a
|
||||
topic-dense index that will be used to find this content later when someone searches.
|
||||
|
||||
Source: {source_file}
|
||||
Wing: {wing} | Room: {room}
|
||||
|
||||
CONTENT:
|
||||
{content}
|
||||
|
||||
---
|
||||
|
||||
Output a JSON object with EXACTLY these fields:
|
||||
|
||||
{{
|
||||
"topics": ["distinctive_word_or_phrase_1", "topic_2", ...],
|
||||
"quotes": ["[Speaker] verbatim quote", ...],
|
||||
"summary": "2-3 sentences describing what this content is about."
|
||||
}}
|
||||
|
||||
RULES:
|
||||
- Topics: 8-15 entries. Include proper nouns (names, places, projects),
|
||||
distinctive technical terms, and key concepts. NOT generic words like
|
||||
"conversation" or "discussion".
|
||||
- Quotes: 2-5 entries. EXACT verbatim from the content, not paraphrased.
|
||||
Attribute with [Speaker] prefix if speaker is identifiable.
|
||||
- Summary: mention WHO, WHAT, and WHY. No filler.
|
||||
- Write in the same language as the content.
|
||||
- Output valid JSON only. No code fences. No commentary.
|
||||
"""
|
||||
|
||||
|
||||
class LLMConfig:
|
||||
"""Resolved LLM connection config. CLI flags > env vars."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: Optional[str] = None,
|
||||
key: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
):
|
||||
self.endpoint = (endpoint or os.environ.get("LLM_ENDPOINT", "")).rstrip("/")
|
||||
self.key = key or os.environ.get("LLM_KEY", "")
|
||||
self.model = model or os.environ.get("LLM_MODEL", "")
|
||||
|
||||
def missing(self) -> list:
|
||||
missing = []
|
||||
if not self.endpoint:
|
||||
missing.append("LLM_ENDPOINT (or --endpoint)")
|
||||
if not self.model:
|
||||
missing.append("LLM_MODEL (or --model)")
|
||||
# key is optional — local inference servers (Ollama, vLLM) often don't require one
|
||||
return missing
|
||||
|
||||
|
||||
def _call_llm(cfg: LLMConfig, source_file: str, wing: str, room: str, content: str):
|
||||
"""Single LLM call via OpenAI-compatible /chat/completions.
|
||||
|
||||
Returns (parsed_json_dict_or_None, usage_dict_or_None).
|
||||
"""
|
||||
try:
|
||||
from mempalace.i18n import t
|
||||
|
||||
lang_instruction = t("aaak.instruction")
|
||||
except Exception:
|
||||
lang_instruction = ""
|
||||
|
||||
prompt = PROMPT_TEMPLATE.format(
|
||||
source_file=source_file[:100],
|
||||
wing=wing,
|
||||
room=room,
|
||||
content=content[:MAX_CONTENT_CHARS],
|
||||
)
|
||||
if lang_instruction and "english" not in lang_instruction.lower():
|
||||
prompt += f"\n\nLanguage instruction: {lang_instruction}"
|
||||
|
||||
body = json.dumps(
|
||||
{
|
||||
"model": cfg.model,
|
||||
"max_tokens": MAX_OUTPUT_TOKENS,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if cfg.key:
|
||||
headers["Authorization"] = f"Bearer {cfg.key}"
|
||||
|
||||
url = f"{cfg.endpoint}/chat/completions"
|
||||
|
||||
for attempt in range(3):
|
||||
try:
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
||||
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
payload = json.loads(raw)
|
||||
|
||||
text = payload["choices"][0]["message"]["content"].strip()
|
||||
text = re.sub(r"^```(?:json)?\s*", "", text)
|
||||
text = re.sub(r"\s*```$", "", text)
|
||||
parsed = json.loads(text)
|
||||
return parsed, payload.get("usage")
|
||||
except json.JSONDecodeError:
|
||||
return None, None
|
||||
except urllib.error.HTTPError as e:
|
||||
# 429 / 503 = retry with backoff
|
||||
if e.code in (429, 503) and attempt < 2:
|
||||
time.sleep(2**attempt)
|
||||
continue
|
||||
return None, None
|
||||
except Exception as e:
|
||||
if "rate" in str(e).lower() and attempt < 2:
|
||||
time.sleep(2**attempt)
|
||||
continue
|
||||
return None, None
|
||||
return None, None
|
||||
|
||||
|
||||
def _parsed_to_closet_lines(parsed, drawer_ids, entities_str):
|
||||
"""Convert LLM's JSON output to closet pointer lines."""
|
||||
lines = []
|
||||
drawer_ref = ",".join(drawer_ids[:3])
|
||||
|
||||
for topic in parsed.get("topics", [])[:15]:
|
||||
lines.append(f"{topic}|{entities_str}|→{drawer_ref}")
|
||||
for quote in parsed.get("quotes", [])[:5]:
|
||||
lines.append(f"{quote}|{entities_str}|→{drawer_ref}")
|
||||
summary = parsed.get("summary", "")
|
||||
if summary:
|
||||
lines.append(f"{summary[:200]}|{entities_str}|→{drawer_ref}")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def regenerate_closets(
|
||||
palace_path,
|
||||
wing=None,
|
||||
sample=0,
|
||||
dry_run=False,
|
||||
cfg: Optional[LLMConfig] = None,
|
||||
):
|
||||
"""Regenerate closets using a configured LLM for richer topic extraction.
|
||||
|
||||
Reads existing drawers, sends content to the configured endpoint,
|
||||
replaces regex closets with LLM-generated ones. Regex closets remain
|
||||
as the fallback whenever the call fails.
|
||||
"""
|
||||
if cfg is None:
|
||||
cfg = LLMConfig()
|
||||
missing = cfg.missing()
|
||||
if missing:
|
||||
print("Error: missing configuration: " + ", ".join(missing))
|
||||
print("Set env vars LLM_ENDPOINT / LLM_MODEL (and optionally LLM_KEY),")
|
||||
print("or pass --endpoint / --model / --key on the CLI.")
|
||||
return {"error": "missing-config", "missing": missing}
|
||||
|
||||
drawers_col = get_collection(palace_path, create=False)
|
||||
closets_col = get_closets_collection(palace_path)
|
||||
|
||||
total = drawers_col.count()
|
||||
if total == 0:
|
||||
print("No drawers in palace.")
|
||||
return {"processed": 0}
|
||||
|
||||
all_data = drawers_col.get(limit=total, include=["documents", "metadatas"])
|
||||
by_source = {}
|
||||
for doc_id, doc, meta in zip(all_data["ids"], all_data["documents"], all_data["metadatas"]):
|
||||
source = meta.get("source_file", "unknown")
|
||||
w = meta.get("wing", "")
|
||||
if wing and w != wing:
|
||||
continue
|
||||
if source not in by_source:
|
||||
by_source[source] = {"drawer_ids": [], "content": [], "meta": meta}
|
||||
by_source[source]["drawer_ids"].append(doc_id)
|
||||
by_source[source]["content"].append(doc)
|
||||
|
||||
sources = list(by_source.keys())
|
||||
if sample > 0:
|
||||
sources = sources[:sample]
|
||||
|
||||
print(
|
||||
f"Regenerating closets for {len(sources)} source files via {cfg.endpoint} ({cfg.model})..."
|
||||
)
|
||||
if dry_run:
|
||||
print("DRY RUN — no changes will be written")
|
||||
|
||||
processed = 0
|
||||
failed = 0
|
||||
total_input = 0
|
||||
total_output = 0
|
||||
|
||||
for i, source in enumerate(sources, 1):
|
||||
data = by_source[source]
|
||||
content = "\n\n".join(data["content"])
|
||||
meta = data["meta"]
|
||||
w = meta.get("wing", "")
|
||||
r = meta.get("room", "")
|
||||
entities = meta.get("entities", "")
|
||||
|
||||
if dry_run:
|
||||
print(f" [{i}/{len(sources)}] {os.path.basename(source)} ({len(content)} chars)")
|
||||
continue
|
||||
|
||||
parsed, usage = _call_llm(cfg, source, w, r, content)
|
||||
if not parsed:
|
||||
failed += 1
|
||||
print(f" [{i}/{len(sources)}] ✗ {os.path.basename(source)} — LLM failed")
|
||||
continue
|
||||
|
||||
if usage:
|
||||
total_input += usage.get("prompt_tokens", 0)
|
||||
total_output += usage.get("completion_tokens", 0)
|
||||
|
||||
lines = _parsed_to_closet_lines(parsed, data["drawer_ids"], entities)
|
||||
# Use os.path.basename so Windows-style paths survive unchanged;
|
||||
# the naive split('/') would leave a bare path component on Windows
|
||||
# and collide across different files under different drives.
|
||||
closet_id_base = f"closet_{w}_{r}_{os.path.basename(source)[:30]}"
|
||||
|
||||
# Serialize with concurrent mine operations on the same source —
|
||||
# otherwise a regex closet rebuild mid-regenerate races with our
|
||||
# purge+upsert cycle and leaves mixed regex/LLM lines.
|
||||
with mine_lock(source):
|
||||
purge_file_closets(closets_col, source)
|
||||
upsert_closet_lines(
|
||||
closets_col,
|
||||
closet_id_base,
|
||||
lines,
|
||||
{
|
||||
"wing": w,
|
||||
"room": r,
|
||||
"source_file": source,
|
||||
"generated_by": f"llm:{cfg.model}",
|
||||
"filed_at": datetime.now().isoformat(),
|
||||
"entities": entities,
|
||||
# Stamp so the miner's stale-drawer gate doesn't treat
|
||||
# LLM closets as leftovers and rebuild over them next run.
|
||||
"normalize_version": NORMALIZE_VERSION,
|
||||
},
|
||||
)
|
||||
|
||||
processed += 1
|
||||
n_topics = len(parsed.get("topics", []))
|
||||
print(f" [{i}/{len(sources)}] ✓ {os.path.basename(source)} — {n_topics} topics")
|
||||
|
||||
print(f"\nDone. {processed} regenerated, {failed} failed.")
|
||||
if total_input or total_output:
|
||||
print(f"Tokens: {total_input:,} in + {total_output:,} out (cost depends on provider)")
|
||||
|
||||
return {
|
||||
"processed": processed,
|
||||
"failed": failed,
|
||||
"input_tokens": total_input,
|
||||
"output_tokens": total_output,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Regenerate closets via a user-configured LLM (OpenAI-compatible API)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--palace",
|
||||
default=os.path.expanduser("~/.mempalace/palace"),
|
||||
help="Path to the palace",
|
||||
)
|
||||
parser.add_argument("--wing", default=None, help="Limit to one wing")
|
||||
parser.add_argument("--sample", type=int, default=0, help="Only process first N source files")
|
||||
parser.add_argument("--dry-run", action="store_true", help="List work without calling the LLM")
|
||||
parser.add_argument(
|
||||
"--endpoint",
|
||||
default=None,
|
||||
help="LLM base URL (overrides $LLM_ENDPOINT), e.g. http://localhost:11434/v1",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--key",
|
||||
default=None,
|
||||
help="LLM bearer token (overrides $LLM_KEY). Optional for local inference.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
default=None,
|
||||
help='LLM model name (overrides $LLM_MODEL), e.g. "gpt-4o-mini" or "llama3:8b"',
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
cfg = LLMConfig(endpoint=args.endpoint, key=args.key, model=args.model)
|
||||
regenerate_closets(
|
||||
args.palace, wing=args.wing, sample=args.sample, dry_run=args.dry_run, cfg=cfg
|
||||
)
|
||||
+22
-1
@@ -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)
|
||||
|
||||
+147
-32
@@ -16,7 +16,33 @@ from datetime import datetime
|
||||
from collections import defaultdict
|
||||
|
||||
from .normalize import normalize
|
||||
from .palace import SKIP_DIRS, get_collection, file_already_mined
|
||||
from .palace import (
|
||||
NORMALIZE_VERSION,
|
||||
SKIP_DIRS,
|
||||
file_already_mined,
|
||||
get_collection,
|
||||
mine_lock,
|
||||
)
|
||||
|
||||
|
||||
# Cached hall keywords — avoids re-reading config per drawer
|
||||
_HALL_KEYWORDS_CACHE = None
|
||||
|
||||
|
||||
def _detect_hall_cached(content: str) -> str:
|
||||
"""Route content to a hall using cached keywords. Same logic as miner.detect_hall."""
|
||||
global _HALL_KEYWORDS_CACHE
|
||||
if _HALL_KEYWORDS_CACHE is None:
|
||||
from .config import MempalaceConfig
|
||||
|
||||
_HALL_KEYWORDS_CACHE = MempalaceConfig().hall_keywords
|
||||
content_lower = content[:3000].lower()
|
||||
scores = {}
|
||||
for hall, keywords in _HALL_KEYWORDS_CACHE.items():
|
||||
score = sum(1 for kw in keywords if kw in content_lower)
|
||||
if score > 0:
|
||||
scores[hall] = score
|
||||
return max(scores, key=scores.get) if scores else "general"
|
||||
|
||||
|
||||
# File types that might contain conversations
|
||||
@@ -28,9 +54,35 @@ 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",
|
||||
"normalize_version": NORMALIZE_VERSION,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CHUNKING — exchange pairs for conversations
|
||||
# =============================================================================
|
||||
@@ -51,7 +103,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 +127,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,
|
||||
@@ -229,6 +299,63 @@ def scan_convos(convo_dir: str) -> list:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _file_chunks_locked(collection, source_file, chunks, wing, room, agent, extract_mode):
|
||||
"""Lock the source file, purge stale drawers, and upsert fresh chunks.
|
||||
|
||||
Combines the per-file serialization that prevents concurrent agents from
|
||||
duplicating work (via mine_lock) with the normalize-version rebuild
|
||||
contract (purge-before-insert so pre-v2 drawers don't survive).
|
||||
|
||||
Returns (drawers_added, room_counts_delta, skipped).
|
||||
"""
|
||||
room_counts_delta: dict = defaultdict(int)
|
||||
drawers_added = 0
|
||||
with mine_lock(source_file):
|
||||
# Re-check after lock — another agent may have just finished this file
|
||||
# at the current schema. A stale-version hit here returns False, so we
|
||||
# still fall through to the purge+rebuild path below.
|
||||
if file_already_mined(collection, source_file):
|
||||
return 0, room_counts_delta, True
|
||||
|
||||
# Purge stale drawers first. When the normalize schema bumps,
|
||||
# file_already_mined() returned False for pre-v2 drawers — clean
|
||||
# them out so the source doesn't end up with mixed old/new drawers.
|
||||
try:
|
||||
collection.delete(where={"source_file": source_file})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for chunk in chunks:
|
||||
chunk_room = chunk.get("memory_type", room) if extract_mode == "general" else room
|
||||
if extract_mode == "general":
|
||||
room_counts_delta[chunk_room] += 1
|
||||
drawer_id = f"drawer_{wing}_{chunk_room}_{hashlib.sha256((source_file + str(chunk['chunk_index'])).encode()).hexdigest()[:24]}"
|
||||
try:
|
||||
collection.upsert(
|
||||
documents=[chunk["content"]],
|
||||
ids=[drawer_id],
|
||||
metadatas=[
|
||||
{
|
||||
"wing": wing,
|
||||
"room": chunk_room,
|
||||
"hall": _detect_hall_cached(chunk["content"]),
|
||||
"source_file": source_file,
|
||||
"chunk_index": chunk["chunk_index"],
|
||||
"added_by": agent,
|
||||
"filed_at": datetime.now().isoformat(),
|
||||
"ingest_mode": "convos",
|
||||
"extract_mode": extract_mode,
|
||||
"normalize_version": NORMALIZE_VERSION,
|
||||
}
|
||||
],
|
||||
)
|
||||
drawers_added += 1
|
||||
except Exception as e:
|
||||
if "already exists" not in str(e).lower():
|
||||
raise
|
||||
return drawers_added, room_counts_delta, False
|
||||
|
||||
|
||||
def mine_convos(
|
||||
convo_dir: str,
|
||||
palace_path: str,
|
||||
@@ -282,9 +409,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 +428,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)
|
||||
@@ -326,34 +459,16 @@ def mine_convos(
|
||||
if extract_mode != "general":
|
||||
room_counts[room] += 1
|
||||
|
||||
# File each chunk
|
||||
drawers_added = 0
|
||||
for chunk in chunks:
|
||||
chunk_room = chunk.get("memory_type", room) if extract_mode == "general" else room
|
||||
if extract_mode == "general":
|
||||
room_counts[chunk_room] += 1
|
||||
drawer_id = f"drawer_{wing}_{chunk_room}_{hashlib.sha256((source_file + str(chunk['chunk_index'])).encode()).hexdigest()[:24]}"
|
||||
try:
|
||||
collection.add(
|
||||
documents=[chunk["content"]],
|
||||
ids=[drawer_id],
|
||||
metadatas=[
|
||||
{
|
||||
"wing": wing,
|
||||
"room": chunk_room,
|
||||
"source_file": source_file,
|
||||
"chunk_index": chunk["chunk_index"],
|
||||
"added_by": agent,
|
||||
"filed_at": datetime.now().isoformat(),
|
||||
"ingest_mode": "convos",
|
||||
"extract_mode": extract_mode,
|
||||
}
|
||||
],
|
||||
)
|
||||
drawers_added += 1
|
||||
except Exception as e:
|
||||
if "already exists" not in str(e).lower():
|
||||
raise
|
||||
# Lock + purge stale + file fresh chunks. Lock serializes concurrent
|
||||
# agents; purge removes pre-v2 drawers so the schema bump applies.
|
||||
drawers_added, room_delta, skipped = _file_chunks_locked(
|
||||
collection, source_file, chunks, wing, room, agent, extract_mode
|
||||
)
|
||||
if skipped:
|
||||
files_skipped += 1
|
||||
continue
|
||||
for r, n in room_delta.items():
|
||||
room_counts[r] += n
|
||||
|
||||
total_drawers += drawers_added
|
||||
print(f" ✓ [{i:4}/{len(files)}] {filepath.name[:50]:50} +{drawers_added}")
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
dedup.py — Detect and remove near-duplicate drawers
|
||||
====================================================
|
||||
|
||||
When the same files are mined multiple times, near-identical drawers
|
||||
accumulate. This module finds drawers from the same source_file that
|
||||
are too similar (cosine distance < threshold), keeps the longest/richest
|
||||
version, and deletes the rest.
|
||||
|
||||
No API calls — uses ChromaDB's built-in embedding similarity.
|
||||
|
||||
Usage (standalone):
|
||||
python -m mempalace.dedup # dedup all
|
||||
python -m mempalace.dedup --dry-run # preview only
|
||||
python -m mempalace.dedup --threshold 0.10 # stricter (near-identical only)
|
||||
python -m mempalace.dedup --threshold 0.35 # looser (catches paraphrased content)
|
||||
python -m mempalace.dedup --wing my_project # scope to one wing
|
||||
python -m mempalace.dedup --stats # stats only
|
||||
python -m mempalace.dedup --source "my_project" # filter by source
|
||||
|
||||
Usage (from CLI):
|
||||
mempalace dedup [--dry-run] [--threshold 0.15] [--stats]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
import chromadb
|
||||
|
||||
|
||||
COLLECTION_NAME = "mempalace_drawers"
|
||||
# Cosine DISTANCE threshold (not similarity). Lower = stricter.
|
||||
# 0.15 = ~85% cosine similarity — catches near-identical chunks.
|
||||
# For looser dedup of paraphrased content, try 0.3–0.4.
|
||||
DEFAULT_THRESHOLD = 0.15
|
||||
MIN_DRAWERS_TO_CHECK = 5
|
||||
|
||||
|
||||
def _get_palace_path():
|
||||
"""Resolve palace path from config."""
|
||||
try:
|
||||
from .config import MempalaceConfig
|
||||
|
||||
return MempalaceConfig().palace_path
|
||||
except Exception:
|
||||
return os.path.join(os.path.expanduser("~"), ".mempalace", "palace")
|
||||
|
||||
|
||||
def get_source_groups(col, min_count=MIN_DRAWERS_TO_CHECK, source_pattern=None, wing=None):
|
||||
"""Group drawers by source_file, return groups with min_count+ entries.
|
||||
|
||||
If wing is specified, only considers drawers in that wing. This catches
|
||||
cross-wing duplicates when the same source was mined into multiple wings.
|
||||
"""
|
||||
total = col.count()
|
||||
groups = defaultdict(list)
|
||||
|
||||
offset = 0
|
||||
batch_size = 1000
|
||||
while offset < total:
|
||||
kwargs = {"limit": batch_size, "offset": offset, "include": ["metadatas"]}
|
||||
if wing:
|
||||
kwargs["where"] = {"wing": wing}
|
||||
batch = col.get(**kwargs)
|
||||
if not batch["ids"]:
|
||||
break
|
||||
for did, meta in zip(batch["ids"], batch["metadatas"]):
|
||||
src = meta.get("source_file", "unknown")
|
||||
if source_pattern and source_pattern.lower() not in src.lower():
|
||||
continue
|
||||
groups[src].append(did)
|
||||
offset += len(batch["ids"])
|
||||
|
||||
return {src: ids for src, ids in groups.items() if len(ids) >= min_count}
|
||||
|
||||
|
||||
def dedup_source_group(col, drawer_ids, threshold=DEFAULT_THRESHOLD, dry_run=True):
|
||||
"""Dedup drawers within one source_file group.
|
||||
|
||||
Greedy: sort by doc length (longest first), keep if not too similar
|
||||
to any already-kept drawer. Returns (kept_ids, deleted_ids).
|
||||
"""
|
||||
data = col.get(ids=drawer_ids, include=["documents", "metadatas"])
|
||||
items = list(zip(data["ids"], data["documents"], data["metadatas"]))
|
||||
items.sort(key=lambda x: len(x[1] or ""), reverse=True)
|
||||
|
||||
kept = []
|
||||
to_delete = []
|
||||
|
||||
for did, doc, meta in items:
|
||||
if not doc or len(doc) < 20:
|
||||
to_delete.append(did)
|
||||
continue
|
||||
|
||||
if not kept:
|
||||
kept.append((did, doc))
|
||||
continue
|
||||
|
||||
try:
|
||||
results = col.query(
|
||||
query_texts=[doc],
|
||||
n_results=min(len(kept), 5),
|
||||
include=["distances"],
|
||||
)
|
||||
dists = results["distances"][0] if results["distances"] else []
|
||||
kept_ids_set = {k[0] for k in kept}
|
||||
|
||||
is_dup = False
|
||||
for rid, dist in zip(results["ids"][0], dists):
|
||||
if rid in kept_ids_set and dist < threshold:
|
||||
is_dup = True
|
||||
break
|
||||
|
||||
if is_dup:
|
||||
to_delete.append(did)
|
||||
else:
|
||||
kept.append((did, doc))
|
||||
except Exception:
|
||||
kept.append((did, doc))
|
||||
|
||||
if to_delete and not dry_run:
|
||||
for i in range(0, len(to_delete), 500):
|
||||
col.delete(ids=to_delete[i : i + 500])
|
||||
|
||||
return [k[0] for k in kept], to_delete
|
||||
|
||||
|
||||
def show_stats(palace_path=None):
|
||||
"""Show duplication statistics without making changes."""
|
||||
palace_path = palace_path or _get_palace_path()
|
||||
client = chromadb.PersistentClient(path=palace_path)
|
||||
col = client.get_collection(COLLECTION_NAME)
|
||||
|
||||
groups = get_source_groups(col)
|
||||
|
||||
total_drawers = sum(len(ids) for ids in groups.values())
|
||||
print(f"\n Sources with {MIN_DRAWERS_TO_CHECK}+ drawers: {len(groups)}")
|
||||
print(f" Total drawers in those sources: {total_drawers:,}")
|
||||
|
||||
print("\n Top 15 by drawer count:")
|
||||
sorted_groups = sorted(groups.items(), key=lambda x: len(x[1]), reverse=True)
|
||||
for src, ids in sorted_groups[:15]:
|
||||
print(f" {len(ids):4d} {src[:65]}")
|
||||
|
||||
estimated_dups = sum(int(len(ids) * 0.4) for ids in groups.values() if len(ids) > 20)
|
||||
print(f"\n Estimated duplicates (groups > 20): ~{estimated_dups:,}")
|
||||
|
||||
|
||||
def dedup_palace(
|
||||
palace_path=None,
|
||||
threshold=DEFAULT_THRESHOLD,
|
||||
dry_run=True,
|
||||
source_pattern=None,
|
||||
min_count=MIN_DRAWERS_TO_CHECK,
|
||||
wing=None,
|
||||
):
|
||||
"""Main entry point: deduplicate near-identical drawers across the palace."""
|
||||
palace_path = palace_path or _get_palace_path()
|
||||
|
||||
print(f"\n{'=' * 55}")
|
||||
print(" MemPalace Deduplicator")
|
||||
print(f"{'=' * 55}")
|
||||
|
||||
client = chromadb.PersistentClient(path=palace_path)
|
||||
col = client.get_collection(COLLECTION_NAME)
|
||||
|
||||
print(f" Palace: {palace_path}")
|
||||
print(f" Drawers: {col.count():,}")
|
||||
print(f" Threshold: {threshold}")
|
||||
print(f" Mode: {'DRY RUN' if dry_run else 'LIVE'}")
|
||||
print(f"{'─' * 55}")
|
||||
|
||||
if wing:
|
||||
print(f" Wing: {wing}")
|
||||
groups = get_source_groups(col, min_count, source_pattern, wing=wing)
|
||||
print(f"\n Sources to check: {len(groups)}")
|
||||
|
||||
t0 = time.time()
|
||||
total_kept = 0
|
||||
total_deleted = 0
|
||||
|
||||
sorted_groups = sorted(groups.items(), key=lambda x: len(x[1]), reverse=True)
|
||||
|
||||
for i, (src, drawer_ids) in enumerate(sorted_groups):
|
||||
kept, deleted = dedup_source_group(col, drawer_ids, threshold, dry_run)
|
||||
total_kept += len(kept)
|
||||
total_deleted += len(deleted)
|
||||
|
||||
if deleted:
|
||||
print(
|
||||
f" [{i + 1:3d}/{len(groups)}] "
|
||||
f"{src[:50]:50s} {len(drawer_ids):4d} → {len(kept):4d} "
|
||||
f"(-{len(deleted)})"
|
||||
)
|
||||
|
||||
elapsed = time.time() - t0
|
||||
|
||||
print(f"\n{'─' * 55}")
|
||||
print(f" Done in {elapsed:.1f}s")
|
||||
print(
|
||||
f" Drawers: {total_kept + total_deleted:,} → {total_kept:,} (-{total_deleted:,} removed)"
|
||||
)
|
||||
print(f" Palace after: {col.count():,} drawers")
|
||||
|
||||
if dry_run:
|
||||
print("\n [DRY RUN] No changes written. Re-run without --dry-run to apply.")
|
||||
|
||||
print(f"{'=' * 55}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Deduplicate near-identical drawers")
|
||||
parser.add_argument("--palace", default=None, help="Palace directory path")
|
||||
parser.add_argument(
|
||||
"--threshold",
|
||||
type=float,
|
||||
default=DEFAULT_THRESHOLD,
|
||||
help=f"Cosine distance threshold (default: {DEFAULT_THRESHOLD})",
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true", help="Preview without deleting")
|
||||
parser.add_argument("--stats", action="store_true", help="Show stats only")
|
||||
parser.add_argument("--wing", default=None, help="Scope dedup to a single wing")
|
||||
parser.add_argument("--source", default=None, help="Filter by source file pattern")
|
||||
args = parser.parse_args()
|
||||
|
||||
path = os.path.expanduser(args.palace) if args.palace else None
|
||||
|
||||
if args.stats:
|
||||
show_stats(palace_path=path)
|
||||
else:
|
||||
dedup_palace(
|
||||
palace_path=path,
|
||||
threshold=args.threshold,
|
||||
dry_run=args.dry_run,
|
||||
source_pattern=args.source,
|
||||
wing=args.wing,
|
||||
)
|
||||
+15
-1
@@ -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):
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
diary_ingest.py — Ingest daily summary files into the palace.
|
||||
|
||||
Architecture:
|
||||
- ONE drawer per (wing, day) — full verbatim content, upserted as the day grows.
|
||||
- Closets pack topics up to CLOSET_CHAR_LIMIT, never split mid-topic.
|
||||
- A re-ingest fully purges the prior day's closets before rebuilding so a
|
||||
shorter day never leaves orphans behind.
|
||||
- Only new entries are processed by default (tracks entry count in a state
|
||||
file under ``~/.mempalace/state/`` — never inside the user's diary dir).
|
||||
- Per-file ``mine_lock`` so concurrent ingest from two terminals can't race.
|
||||
- Entities extracted and stamped on metadata for filterable search.
|
||||
|
||||
Usage:
|
||||
python -m mempalace.diary_ingest --dir ~/daily_summaries --palace ~/.mempalace/palace
|
||||
python -m mempalace.diary_ingest --dir ~/daily_summaries --palace ~/.mempalace/palace --force
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from .miner import _extract_entities_for_metadata
|
||||
from .palace import (
|
||||
build_closet_lines,
|
||||
get_closets_collection,
|
||||
get_collection,
|
||||
mine_lock,
|
||||
purge_file_closets,
|
||||
upsert_closet_lines,
|
||||
)
|
||||
|
||||
DIARY_ENTRY_RE = re.compile(r"^## .+", re.MULTILINE)
|
||||
|
||||
|
||||
def _state_file_for(palace_path: str, diary_dir: Path) -> Path:
|
||||
"""Return the per-(palace, diary-dir) state-file path under ~/.mempalace/state.
|
||||
|
||||
Keyed by sha256 of (palace_path, diary_dir) so multiple diary folders
|
||||
pointing at the same palace each get an independent state file. The
|
||||
state file is *never* written inside the user's diary directory.
|
||||
"""
|
||||
state_root = Path(os.path.expanduser("~")) / ".mempalace" / "state"
|
||||
state_root.mkdir(parents=True, exist_ok=True)
|
||||
key = hashlib.sha256(f"{palace_path}|{diary_dir}".encode()).hexdigest()[:24]
|
||||
return state_root / f"diary_ingest_{key}.json"
|
||||
|
||||
|
||||
def _split_entries(text):
|
||||
"""Split diary text into (header, body) pairs per ## entry."""
|
||||
parts = DIARY_ENTRY_RE.split(text)
|
||||
headers = DIARY_ENTRY_RE.findall(text)
|
||||
entries = []
|
||||
for i, header in enumerate(headers):
|
||||
body = parts[i + 1] if i + 1 < len(parts) else ""
|
||||
entries.append((header.strip(), body.strip()))
|
||||
return entries
|
||||
|
||||
|
||||
def _diary_drawer_id(wing: str, date_str: str) -> str:
|
||||
"""Stable, wing-scoped drawer ID. Two diaries (e.g. 'work' vs 'personal')
|
||||
sharing the same date never collide."""
|
||||
suffix = hashlib.sha256(f"{wing}|{date_str}".encode()).hexdigest()[:24]
|
||||
return f"drawer_diary_{suffix}"
|
||||
|
||||
|
||||
def _diary_closet_id_base(wing: str, date_str: str) -> str:
|
||||
suffix = hashlib.sha256(f"{wing}|{date_str}".encode()).hexdigest()[:24]
|
||||
return f"closet_diary_{suffix}"
|
||||
|
||||
|
||||
def ingest_diaries(
|
||||
diary_dir,
|
||||
palace_path,
|
||||
wing="diary",
|
||||
force=False,
|
||||
):
|
||||
"""Ingest daily summary files into the palace.
|
||||
|
||||
Each date file gets ONE drawer keyed by ``(wing, date)`` and closets that
|
||||
pack topics atomically up to ``CLOSET_CHAR_LIMIT``. ``force=True`` rebuilds
|
||||
every entry's closets from scratch (purging stale ones); the default
|
||||
incremental mode only processes entries appended since the last run.
|
||||
"""
|
||||
diary_dir = Path(diary_dir).expanduser().resolve()
|
||||
if not diary_dir.exists():
|
||||
print(f"Diary directory not found: {diary_dir}")
|
||||
return {"days_updated": 0, "closets_created": 0}
|
||||
|
||||
diary_files = sorted(diary_dir.glob("*.md"))
|
||||
if not diary_files:
|
||||
print(f"No .md files in {diary_dir}")
|
||||
return {"days_updated": 0, "closets_created": 0}
|
||||
|
||||
state_file = _state_file_for(str(palace_path), diary_dir)
|
||||
if force or not state_file.exists():
|
||||
state: dict = {}
|
||||
else:
|
||||
try:
|
||||
state = json.loads(state_file.read_text())
|
||||
except Exception:
|
||||
state = {}
|
||||
|
||||
drawers_col = get_collection(palace_path)
|
||||
closets_col = get_closets_collection(palace_path)
|
||||
|
||||
days_updated = 0
|
||||
closets_created = 0
|
||||
|
||||
for diary_path in diary_files:
|
||||
text = diary_path.read_text(encoding="utf-8", errors="replace")
|
||||
if len(text.strip()) < 50:
|
||||
continue
|
||||
|
||||
date_match = re.match(r"(\d{4}-\d{2}-\d{2})", diary_path.stem)
|
||||
if not date_match:
|
||||
continue
|
||||
date_str = date_match.group(1)
|
||||
|
||||
# Skip if content hasn't changed
|
||||
state_key = f"{wing}|{diary_path.name}"
|
||||
prev_size = state.get(state_key, {}).get("size", 0)
|
||||
curr_size = len(text)
|
||||
if curr_size == prev_size and not force:
|
||||
continue
|
||||
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
drawer_id = _diary_drawer_id(wing, date_str)
|
||||
entities = _extract_entities_for_metadata(text)
|
||||
source_file = str(diary_path)
|
||||
|
||||
# Serialize per source — two terminals running ingest at once must
|
||||
# not interleave the upsert + closet-rebuild.
|
||||
with mine_lock(source_file):
|
||||
drawer_meta = {
|
||||
"date": date_str,
|
||||
"wing": wing,
|
||||
"room": "daily",
|
||||
"source_file": source_file,
|
||||
"source_session": "daily_diary",
|
||||
"filed_at": now_iso,
|
||||
}
|
||||
if entities:
|
||||
drawer_meta["entities"] = entities
|
||||
drawers_col.upsert(
|
||||
documents=[text],
|
||||
ids=[drawer_id],
|
||||
metadatas=[drawer_meta],
|
||||
)
|
||||
|
||||
entries = _split_entries(text)
|
||||
prev_entry_count = state.get(state_key, {}).get("entry_count", 0)
|
||||
new_entries = entries if force else entries[prev_entry_count:]
|
||||
|
||||
if new_entries:
|
||||
all_lines = []
|
||||
for header, body in new_entries:
|
||||
entry_text = f"{header}\n{body}"
|
||||
entry_lines = build_closet_lines(
|
||||
source_file, [drawer_id], entry_text, wing, "daily"
|
||||
)
|
||||
all_lines.extend(entry_lines)
|
||||
|
||||
if all_lines:
|
||||
closet_id_base = _diary_closet_id_base(wing, date_str)
|
||||
closet_meta = {
|
||||
"date": date_str,
|
||||
"wing": wing,
|
||||
"room": "daily",
|
||||
"source_file": source_file,
|
||||
"filed_at": now_iso,
|
||||
}
|
||||
if entities:
|
||||
closet_meta["entities"] = entities
|
||||
# On a force rebuild, wipe any leftover numbered closets
|
||||
# from a longer prior run before re-writing.
|
||||
if force:
|
||||
purge_file_closets(closets_col, source_file)
|
||||
n = upsert_closet_lines(closets_col, closet_id_base, all_lines, closet_meta)
|
||||
closets_created += n
|
||||
|
||||
state[state_key] = {
|
||||
"size": curr_size,
|
||||
"entry_count": len(entries),
|
||||
"ingested_at": now_iso,
|
||||
}
|
||||
days_updated += 1
|
||||
|
||||
state_file.write_text(json.dumps(state, indent=2))
|
||||
if days_updated:
|
||||
print(f"Diary: {days_updated} days updated, {closets_created} new closets")
|
||||
|
||||
return {"days_updated": days_updated, "closets_created": closets_created}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Ingest daily summaries into the palace")
|
||||
parser.add_argument("--dir", required=True, help="Path to daily_summaries directory")
|
||||
parser.add_argument("--palace", default=os.path.expanduser("~/.mempalace/palace"))
|
||||
parser.add_argument("--wing", default="diary")
|
||||
parser.add_argument("--force", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
ingest_diaries(args.dir, args.palace, wing=args.wing, force=args.force)
|
||||
@@ -17,6 +17,7 @@ Usage:
|
||||
|
||||
import re
|
||||
import os
|
||||
import functools
|
||||
from pathlib import Path
|
||||
from collections import defaultdict
|
||||
|
||||
@@ -60,6 +61,8 @@ PRONOUN_PATTERNS = [
|
||||
r"\btheir\b",
|
||||
]
|
||||
|
||||
PRONOUN_RE = re.compile("|".join(PRONOUN_PATTERNS), re.IGNORECASE)
|
||||
|
||||
# Person signals — dialogue markers
|
||||
DIALOGUE_PATTERNS = [
|
||||
r"^>\s*{name}[:\s]", # > Speaker: ...
|
||||
@@ -466,6 +469,7 @@ def extract_candidates(text: str) -> dict:
|
||||
# ==================== SIGNAL SCORING ====================
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=128)
|
||||
def _build_patterns(name: str) -> dict:
|
||||
"""Pre-compile all regex patterns for a single entity name."""
|
||||
n = re.escape(name)
|
||||
@@ -515,11 +519,9 @@ def score_entity(name: str, text: str, lines: list) -> dict:
|
||||
name_line_indices = [i for i, line in enumerate(lines) if name_lower in line.lower()]
|
||||
pronoun_hits = 0
|
||||
for idx in name_line_indices:
|
||||
window_text = " ".join(lines[max(0, idx - 2) : idx + 3]).lower()
|
||||
for pronoun_pattern in PRONOUN_PATTERNS:
|
||||
if re.search(pronoun_pattern, window_text):
|
||||
pronoun_hits += 1
|
||||
break
|
||||
window_text = " ".join(lines[max(0, idx - 2) : idx + 3])
|
||||
if PRONOUN_RE.search(window_text):
|
||||
pronoun_hits += 1
|
||||
if pronoun_hits > 0:
|
||||
person_score += pronoun_hits * 2
|
||||
person_signals.append(f"pronoun nearby ({pronoun_hits}x)")
|
||||
@@ -760,7 +762,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,335 @@
|
||||
"""
|
||||
fact_checker.py — Verify text against known facts in the palace.
|
||||
|
||||
Checks AI responses, diary entries, and new content against the entity
|
||||
registry and knowledge graph for three classes of issue:
|
||||
|
||||
* similar_name — text mentions a name that's one/two edits
|
||||
away from *another* registered name, raising
|
||||
the possibility of a typo or mix-up.
|
||||
* relationship_mismatch — text asserts a role between two entities
|
||||
(e.g. "Bob is Alice's brother") while the KG
|
||||
records a *different* current role for the
|
||||
same subject/object pair.
|
||||
* stale_fact — text asserts a fact that the KG marks closed
|
||||
(``valid_to`` in the past).
|
||||
|
||||
Purely offline. Inputs: entity_registry JSON + KG SQLite. No network.
|
||||
|
||||
Usage:
|
||||
from mempalace.fact_checker import check_text
|
||||
issues = check_text("Bob is Alice's brother", palace_path)
|
||||
|
||||
# CLI
|
||||
python -m mempalace.fact_checker "Bob is Alice's brother" \\
|
||||
--palace ~/.mempalace/palace
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Share miner's mtime-cached registry loader so we don't double-read
|
||||
# ~/.mempalace/known_entities.json on every check_text call.
|
||||
from .miner import _load_known_entities_raw
|
||||
|
||||
|
||||
# Narrow detection patterns — parse "X is Y's Z" and "X's Z is Y".
|
||||
# Names are captured greedily as word sequences (letters + optional
|
||||
# capitalized follow-ons) so simple multi-token names still work.
|
||||
# Relationship words are constrained to sane lengths to avoid matching
|
||||
# arbitrary filler.
|
||||
_RELATIONSHIP_PATTERNS = [
|
||||
# "Bob is Alice's brother" → subject=Bob, possessor=Alice, role=brother
|
||||
re.compile(r"\b([A-Z][\w-]+)\s+is\s+([A-Z][\w-]+)'s\s+([a-z]{3,20})\b"),
|
||||
# "Alice's brother is Bob" → possessor=Alice, role=brother, subject=Bob
|
||||
re.compile(r"\b([A-Z][\w-]+)'s\s+([a-z]{3,20})\s+is\s+([A-Z][\w-]+)\b"),
|
||||
]
|
||||
|
||||
|
||||
def check_text(text: str, palace_path: str = None, config=None) -> list:
|
||||
"""Return a list of issues detected in ``text``.
|
||||
|
||||
Empty list means "no contradictions found" — absence of evidence, not
|
||||
evidence of absence. The detector is deliberately conservative:
|
||||
every issue is anchored to a specific KG fact or registry entry.
|
||||
"""
|
||||
if config is None:
|
||||
from .config import MempalaceConfig
|
||||
|
||||
config = MempalaceConfig()
|
||||
if palace_path is None:
|
||||
palace_path = config.palace_path
|
||||
|
||||
if not text:
|
||||
return []
|
||||
|
||||
issues: list = []
|
||||
entity_names_raw = _load_known_entities_raw()
|
||||
|
||||
issues.extend(_check_entity_confusion(text, entity_names_raw))
|
||||
issues.extend(_check_kg_contradictions(text, palace_path))
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
# ── entity-name confusion ────────────────────────────────────────────
|
||||
|
||||
|
||||
def _flatten_names(entity_names_raw: dict) -> set:
|
||||
"""Flatten a ``{category: [names]}`` or ``{category: {name: meta}}``
|
||||
registry into a set of names."""
|
||||
flat: set = set()
|
||||
for cat in entity_names_raw.values():
|
||||
if isinstance(cat, list):
|
||||
flat.update(str(n) for n in cat if n)
|
||||
elif isinstance(cat, dict):
|
||||
flat.update(str(k) for k in cat.keys() if k)
|
||||
return flat
|
||||
|
||||
|
||||
def _check_entity_confusion(text: str, entity_names_raw: dict) -> list:
|
||||
"""Flag names mentioned in the text that are edit-distance ≤ 2 from
|
||||
a *different* registered name — a common typo / mix-up pattern.
|
||||
|
||||
Performance note: the original O(n²) pairwise scan over the full
|
||||
registry is gone. We first identify which names actually appear in
|
||||
the text, then only compute edit distance between *mentioned* names
|
||||
and the rest of the registry. This makes the cost O(m × n) where m
|
||||
is the handful of names in the text, not the full registry.
|
||||
"""
|
||||
all_names = _flatten_names(entity_names_raw)
|
||||
if not all_names:
|
||||
return []
|
||||
|
||||
# Which names from the registry actually appear in the text?
|
||||
mentioned: list = []
|
||||
for name in all_names:
|
||||
if re.search(r"\b" + re.escape(name) + r"\b", text, re.IGNORECASE):
|
||||
mentioned.append(name)
|
||||
if not mentioned:
|
||||
return []
|
||||
|
||||
issues: list = []
|
||||
seen_pairs: set = set()
|
||||
for name_a in mentioned:
|
||||
a_lower = name_a.lower()
|
||||
for name_b in all_names:
|
||||
if name_b == name_a:
|
||||
continue
|
||||
# Dedupe by unordered pair so we don't double-report.
|
||||
pair_key = tuple(sorted((name_a.lower(), name_b.lower())))
|
||||
if pair_key in seen_pairs:
|
||||
continue
|
||||
# Only flag when name_b is a *different* registry entry that
|
||||
# was NOT mentioned — otherwise both names in the text is
|
||||
# just the user writing about two people.
|
||||
if name_b in mentioned:
|
||||
seen_pairs.add(pair_key)
|
||||
continue
|
||||
distance = _edit_distance(a_lower, name_b.lower())
|
||||
if 0 < distance <= 2:
|
||||
issues.append(
|
||||
{
|
||||
"type": "similar_name",
|
||||
"detail": (
|
||||
f"'{name_a}' mentioned — did you mean "
|
||||
f"'{name_b}'? (edit distance {distance})"
|
||||
),
|
||||
"names": [name_a, name_b],
|
||||
"distance": distance,
|
||||
}
|
||||
)
|
||||
seen_pairs.add(pair_key)
|
||||
return issues
|
||||
|
||||
|
||||
# ── KG contradictions ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _extract_claims(text: str) -> list:
|
||||
"""Yield structured (subject, predicate, object) claims from ``text``.
|
||||
|
||||
The two supported surface forms are "X is Y's Z" and "X's Z is Y",
|
||||
both of which resolve to the triple ``(X, Z, Y)`` — ``X`` has role
|
||||
``Z`` with respect to ``Y``. Matches are case-preserving for the
|
||||
entity names (KG lookup is case-insensitive on normalized IDs).
|
||||
"""
|
||||
claims: list = []
|
||||
for pat in _RELATIONSHIP_PATTERNS:
|
||||
for match in pat.finditer(text):
|
||||
groups = match.groups()
|
||||
if pat is _RELATIONSHIP_PATTERNS[0]:
|
||||
subject, possessor, role = groups[0], groups[1], groups[2]
|
||||
else:
|
||||
possessor, role, subject = groups[0], groups[1], groups[2]
|
||||
claims.append(
|
||||
{
|
||||
"subject": subject,
|
||||
"predicate": role.lower(),
|
||||
"object": possessor,
|
||||
"span": match.group(0),
|
||||
}
|
||||
)
|
||||
return claims
|
||||
|
||||
|
||||
def _check_kg_contradictions(text: str, palace_path: str) -> list:
|
||||
"""Compare each claim in ``text`` against the KG.
|
||||
|
||||
For every claim ``(subject, predicate, object)`` parsed from the
|
||||
text, look up the subject's current KG triples:
|
||||
|
||||
* ``relationship_mismatch`` fires when the KG records a fact about
|
||||
the same ``(subject, object)`` pair but with a *different*
|
||||
predicate — e.g. text says "brother" but KG says "husband".
|
||||
* ``stale_fact`` fires when the KG has the exact ``(subject,
|
||||
predicate, object)`` triple but its ``valid_to`` is in the past,
|
||||
meaning the claim is no longer current.
|
||||
"""
|
||||
claims = _extract_claims(text)
|
||||
if not claims:
|
||||
return []
|
||||
|
||||
try:
|
||||
from .knowledge_graph import KnowledgeGraph
|
||||
|
||||
# KG lives alongside the palace collection; mcp_server uses the
|
||||
# same convention (see _kg init). Pass ``db_path`` — the previous
|
||||
# code passed a nonexistent ``palace_path`` kwarg which raised
|
||||
# TypeError, silently swallowed by the outer except and rendered
|
||||
# the entire KG-check path dead.
|
||||
kg = KnowledgeGraph(db_path=os.path.join(palace_path, "knowledge_graph.sqlite3"))
|
||||
except Exception:
|
||||
# KG unavailable (brand-new palace, corrupted DB, etc.) — skip.
|
||||
return []
|
||||
|
||||
issues: list = []
|
||||
for claim in claims:
|
||||
subject = claim["subject"]
|
||||
claim_pred = claim["predicate"]
|
||||
claim_obj = claim["object"]
|
||||
try:
|
||||
facts = kg.query_entity(subject, direction="outgoing")
|
||||
except Exception:
|
||||
continue
|
||||
if not facts:
|
||||
continue
|
||||
|
||||
current_facts = [f for f in facts if f.get("current")]
|
||||
|
||||
# Mismatch: KG fact about same (subject, object) pair but different predicate.
|
||||
for fact in current_facts:
|
||||
if not _objects_match(fact.get("object"), claim_obj):
|
||||
continue
|
||||
kg_pred = (fact.get("predicate") or "").lower()
|
||||
if kg_pred and kg_pred != claim_pred:
|
||||
issues.append(
|
||||
{
|
||||
"type": "relationship_mismatch",
|
||||
"detail": (
|
||||
f"Text says '{claim['span']}' but KG records "
|
||||
f"{subject} {kg_pred} {fact.get('object')}"
|
||||
),
|
||||
"entity": subject,
|
||||
"claim": {
|
||||
"predicate": claim_pred,
|
||||
"object": claim_obj,
|
||||
},
|
||||
"kg_fact": {
|
||||
"predicate": kg_pred,
|
||||
"object": fact.get("object"),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Stale fact: exact match on (subject, predicate, object) but KG
|
||||
# closed the window in the past.
|
||||
now_iso = datetime.now(timezone.utc).date().isoformat()
|
||||
for fact in facts:
|
||||
if fact.get("current"):
|
||||
continue
|
||||
kg_pred = (fact.get("predicate") or "").lower()
|
||||
if kg_pred != claim_pred:
|
||||
continue
|
||||
if not _objects_match(fact.get("object"), claim_obj):
|
||||
continue
|
||||
valid_to = fact.get("valid_to")
|
||||
if valid_to and str(valid_to) < now_iso:
|
||||
issues.append(
|
||||
{
|
||||
"type": "stale_fact",
|
||||
"detail": (
|
||||
f"Text says '{claim['span']}' but KG marks "
|
||||
f"this fact closed on {valid_to}"
|
||||
),
|
||||
"entity": subject,
|
||||
"valid_to": valid_to,
|
||||
}
|
||||
)
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def _objects_match(kg_obj, claim_obj: str) -> bool:
|
||||
if kg_obj is None or not claim_obj:
|
||||
return False
|
||||
return str(kg_obj).strip().lower() == claim_obj.strip().lower()
|
||||
|
||||
|
||||
# ── Levenshtein helper (tight iterative version) ─────────────────────
|
||||
|
||||
|
||||
def _edit_distance(s1: str, s2: str) -> int:
|
||||
"""Levenshtein distance. O(len(s1) * len(s2)) time, O(len(s2)) space."""
|
||||
if len(s1) < len(s2):
|
||||
s1, s2 = s2, s1
|
||||
if not s2:
|
||||
return len(s1)
|
||||
prev = list(range(len(s2) + 1))
|
||||
for i, c1 in enumerate(s1):
|
||||
curr = [i + 1]
|
||||
for j, c2 in enumerate(s2):
|
||||
curr.append(
|
||||
min(
|
||||
prev[j + 1] + 1,
|
||||
curr[j] + 1,
|
||||
prev[j] + (0 if c1 == c2 else 1),
|
||||
)
|
||||
)
|
||||
prev = curr
|
||||
return prev[-1]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Check text against known facts in the MemPalace palace.",
|
||||
epilog="Exits 0 when no issues found, 1 when one or more issues detected.",
|
||||
)
|
||||
parser.add_argument("text", nargs="?", help="Text to check (or use --stdin).")
|
||||
parser.add_argument(
|
||||
"--palace",
|
||||
default=os.path.expanduser("~/.mempalace/palace"),
|
||||
help="Path to the palace directory.",
|
||||
)
|
||||
parser.add_argument("--stdin", action="store_true", help="Read text from stdin.")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.stdin:
|
||||
text_in = sys.stdin.read()
|
||||
elif args.text:
|
||||
text_in = args.text
|
||||
else:
|
||||
parser.error("Provide text as argument or use --stdin.")
|
||||
|
||||
found = check_text(text_in, palace_path=args.palace)
|
||||
if found:
|
||||
print(json.dumps(found, indent=2))
|
||||
sys.exit(1)
|
||||
print("No contradictions found.")
|
||||
+21
-9
@@ -18,18 +18,22 @@ SAVE_INTERVAL = 15
|
||||
STATE_DIR = Path.home() / ".mempalace" / "hook_state"
|
||||
|
||||
STOP_BLOCK_REASON = (
|
||||
"AUTO-SAVE checkpoint. Save key topics, decisions, quotes, and code "
|
||||
"from this session to your memory system. Organize into appropriate "
|
||||
"categories. Use verbatim quotes where possible. Continue conversation "
|
||||
"after saving."
|
||||
"AUTO-SAVE checkpoint (MemPalace). Save this session's key content:\n"
|
||||
"1. mempalace_diary_write — AAAK-compressed session summary\n"
|
||||
"2. mempalace_add_drawer — verbatim quotes, decisions, code snippets\n"
|
||||
"3. mempalace_kg_add — entity relationships (optional)\n"
|
||||
"Do NOT write to Claude Code's native auto-memory (.md files). "
|
||||
"Continue conversation after saving."
|
||||
)
|
||||
|
||||
PRECOMPACT_BLOCK_REASON = (
|
||||
"COMPACTION IMMINENT. Save ALL topics, decisions, quotes, code, and "
|
||||
"important context from this session to your memory system. Be thorough "
|
||||
"\u2014 after compaction, detailed context will be lost. Organize into "
|
||||
"appropriate categories. Use verbatim quotes where possible. Save "
|
||||
"everything, then allow compaction to proceed."
|
||||
"COMPACTION IMMINENT (MemPalace). Save ALL session content before context is lost:\n"
|
||||
"1. mempalace_diary_write — thorough AAAK-compressed session summary\n"
|
||||
"2. mempalace_add_drawer — ALL verbatim quotes, decisions, code, context\n"
|
||||
"3. mempalace_kg_add — entity relationships (optional)\n"
|
||||
"Be thorough \u2014 after compaction, detailed context will be lost. "
|
||||
"Do NOT write to Claude Code's native auto-memory (.md files). "
|
||||
"Save everything to MemPalace, then allow compaction to proceed."
|
||||
)
|
||||
|
||||
|
||||
@@ -63,6 +67,14 @@ def _count_human_messages(transcript_path: str) -> int:
|
||||
if "<command-message>" in text:
|
||||
continue
|
||||
count += 1
|
||||
# Also handle Codex CLI transcript format
|
||||
# {"type": "event_msg", "payload": {"type": "user_message", "message": "..."}}
|
||||
elif entry.get("type") == "event_msg":
|
||||
payload = entry.get("payload", {})
|
||||
if isinstance(payload, dict) and payload.get("type") == "user_message":
|
||||
msg_text = payload.get("message", "")
|
||||
if isinstance(msg_text, str) and "<command-message>" not in msg_text:
|
||||
count += 1
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
except OSError:
|
||||
|
||||
@@ -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": "(構建|修復|添加|刪除|確認|創建|實現|修理|編寫|測試|驗證|更新|配置|啟動|停止)(?:了|完成|成功)"
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ AI memory system. Store everything, find anything. Local, free, no API key.
|
||||
mempalace compress Compress palace storage
|
||||
mempalace status Show palace status
|
||||
mempalace repair Rebuild vector index
|
||||
mempalace mcp Show MCP setup command
|
||||
mempalace hook run Run hook logic (for harness integration)
|
||||
mempalace instructions <name> Output skill instructions
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+12
-34
@@ -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:
|
||||
|
||||
+780
-62
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,245 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
mempalace migrate — Recover a palace created with a different ChromaDB version.
|
||||
|
||||
Reads documents and metadata directly from the palace's SQLite database
|
||||
(bypassing ChromaDB's API, which fails on version-mismatched palaces),
|
||||
then re-imports everything into a fresh palace using the currently installed
|
||||
ChromaDB version.
|
||||
|
||||
This fixes the 3.0.0 → 3.1.0 upgrade path where chromadb was downgraded
|
||||
from 1.5.x to 0.6.x, breaking the on-disk storage format.
|
||||
|
||||
Usage:
|
||||
mempalace migrate # migrate default palace
|
||||
mempalace migrate --palace /path/to/palace # migrate specific palace
|
||||
mempalace migrate --dry-run # show what would be migrated
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def extract_drawers_from_sqlite(db_path: str) -> list:
|
||||
"""Read all drawers directly from ChromaDB's SQLite, bypassing the API.
|
||||
|
||||
Works regardless of which ChromaDB version created the database.
|
||||
Returns list of dicts with 'id', 'document', and 'metadata' keys.
|
||||
"""
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
# Get all embedding IDs and their documents
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT e.embedding_id,
|
||||
MAX(CASE WHEN em.key = 'chroma:document' THEN em.string_value END) as document
|
||||
FROM embeddings e
|
||||
JOIN embedding_metadata em ON em.id = e.id
|
||||
GROUP BY e.embedding_id
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
drawers = []
|
||||
for row in rows:
|
||||
embedding_id = row["embedding_id"]
|
||||
document = row["document"]
|
||||
if not document:
|
||||
continue
|
||||
|
||||
# Get metadata for this embedding
|
||||
meta_rows = conn.execute(
|
||||
"""
|
||||
SELECT em.key, em.string_value, em.int_value, em.float_value, em.bool_value
|
||||
FROM embedding_metadata em
|
||||
JOIN embeddings e ON e.id = em.id
|
||||
WHERE e.embedding_id = ?
|
||||
AND em.key NOT LIKE 'chroma:%'
|
||||
""",
|
||||
(embedding_id,),
|
||||
).fetchall()
|
||||
|
||||
metadata = {}
|
||||
for mr in meta_rows:
|
||||
key = mr["key"]
|
||||
if mr["string_value"] is not None:
|
||||
metadata[key] = mr["string_value"]
|
||||
elif mr["int_value"] is not None:
|
||||
metadata[key] = mr["int_value"]
|
||||
elif mr["float_value"] is not None:
|
||||
metadata[key] = mr["float_value"]
|
||||
elif mr["bool_value"] is not None:
|
||||
metadata[key] = bool(mr["bool_value"])
|
||||
|
||||
drawers.append(
|
||||
{
|
||||
"id": embedding_id,
|
||||
"document": document,
|
||||
"metadata": metadata,
|
||||
}
|
||||
)
|
||||
|
||||
conn.close()
|
||||
return drawers
|
||||
|
||||
|
||||
def detect_chromadb_version(db_path: str) -> str:
|
||||
"""Detect which ChromaDB version created the database by checking schema."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
# 1.x has schema_str column in collections table
|
||||
cols = [r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()]
|
||||
if "schema_str" in cols:
|
||||
return "1.x"
|
||||
# 0.6.x has embeddings_queue but no schema_str
|
||||
tables = [
|
||||
r[0]
|
||||
for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
|
||||
]
|
||||
if "embeddings_queue" in tables:
|
||||
return "0.6.x"
|
||||
return "unknown"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
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.abspath(os.path.expanduser(palace_path))
|
||||
db_path = os.path.join(palace_path, "chroma.sqlite3")
|
||||
|
||||
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
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(" MemPalace Migrate")
|
||||
print(f"{'=' * 60}\n")
|
||||
print(f" Palace: {palace_path}")
|
||||
print(f" Database: {db_path}")
|
||||
print(f" DB size: {os.path.getsize(db_path) / 1024 / 1024:.1f} MB")
|
||||
|
||||
# Detect version
|
||||
source_version = detect_chromadb_version(db_path)
|
||||
print(f" Source: ChromaDB {source_version}")
|
||||
print(f" Target: ChromaDB {chromadb.__version__}")
|
||||
|
||||
# Try reading with current chromadb first
|
||||
try:
|
||||
client = chromadb.PersistentClient(path=palace_path)
|
||||
col = client.get_collection("mempalace_drawers")
|
||||
count = col.count()
|
||||
print(f"\n Palace is already readable by chromadb {chromadb.__version__}.")
|
||||
print(f" {count} drawers found. No migration needed.")
|
||||
return True
|
||||
except Exception:
|
||||
print(f"\n Palace is NOT readable by chromadb {chromadb.__version__}.")
|
||||
print(" Extracting from SQLite directly...")
|
||||
|
||||
# Extract all drawers via raw SQL
|
||||
drawers = extract_drawers_from_sqlite(db_path)
|
||||
print(f" Extracted {len(drawers)} drawers from SQLite")
|
||||
|
||||
if not drawers:
|
||||
print(" Nothing to migrate.")
|
||||
return True
|
||||
|
||||
# Show summary
|
||||
wings = defaultdict(lambda: defaultdict(int))
|
||||
for d in drawers:
|
||||
w = d["metadata"].get("wing", "?")
|
||||
r = d["metadata"].get("room", "?")
|
||||
wings[w][r] += 1
|
||||
|
||||
print("\n Summary:")
|
||||
for wing, rooms in sorted(wings.items()):
|
||||
total = sum(rooms.values())
|
||||
print(f" WING: {wing} ({total} drawers)")
|
||||
for room, count in sorted(rooms.items(), key=lambda x: -x[1]):
|
||||
print(f" ROOM: {room:30} {count:5}")
|
||||
|
||||
if dry_run:
|
||||
print("\n DRY RUN — no changes made.")
|
||||
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}"
|
||||
print(f"\n Backing up to {backup_path}...")
|
||||
shutil.copytree(palace_path, backup_path)
|
||||
|
||||
# Build fresh palace in a temp directory (avoids chromadb reading old state)
|
||||
import tempfile
|
||||
|
||||
temp_palace = tempfile.mkdtemp(prefix="mempalace_migrate_")
|
||||
print(f" Creating fresh palace in {temp_palace}...")
|
||||
client = chromadb.PersistentClient(path=temp_palace)
|
||||
col = client.get_or_create_collection("mempalace_drawers", metadata={"hnsw:space": "cosine"})
|
||||
|
||||
# Re-import in batches
|
||||
batch_size = 500
|
||||
imported = 0
|
||||
for i in range(0, len(drawers), batch_size):
|
||||
batch = drawers[i : i + batch_size]
|
||||
col.add(
|
||||
ids=[d["id"] for d in batch],
|
||||
documents=[d["document"] for d in batch],
|
||||
metadatas=[d["metadata"] for d in batch],
|
||||
)
|
||||
imported += len(batch)
|
||||
print(f" Imported {imported}/{len(drawers)} drawers...")
|
||||
|
||||
# Verify before swapping
|
||||
final_count = col.count()
|
||||
del col
|
||||
del client
|
||||
|
||||
# Swap: remove old palace, move new one into place
|
||||
print(" Swapping old palace for migrated version...")
|
||||
shutil.rmtree(palace_path)
|
||||
shutil.move(temp_palace, palace_path)
|
||||
|
||||
print("\n Migration complete.")
|
||||
print(f" Drawers migrated: {final_count}")
|
||||
print(f" Backup at: {backup_path}")
|
||||
|
||||
if final_count != len(drawers):
|
||||
print(f" WARNING: Expected {len(drawers)}, got {final_count}")
|
||||
|
||||
print(f"\n{'=' * 60}\n")
|
||||
return True
|
||||
+222
-22
@@ -15,9 +15,17 @@ from pathlib import Path
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
|
||||
import chromadb
|
||||
|
||||
from .palace import SKIP_DIRS, get_collection, file_already_mined
|
||||
from .palace import (
|
||||
NORMALIZE_VERSION,
|
||||
SKIP_DIRS,
|
||||
build_closet_lines,
|
||||
file_already_mined,
|
||||
get_closets_collection,
|
||||
get_collection,
|
||||
mine_lock,
|
||||
purge_file_closets,
|
||||
upsert_closet_lines,
|
||||
)
|
||||
|
||||
READABLE_EXTENSIONS = {
|
||||
".txt",
|
||||
@@ -370,6 +378,143 @@ def chunk_text(content: str, source_file: str) -> list:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
_ENTITY_REGISTRY_PATH = os.path.join(os.path.expanduser("~"), ".mempalace", "known_entities.json")
|
||||
_ENTITY_REGISTRY_CACHE: dict = {"mtime": None, "names": frozenset(), "raw": {}}
|
||||
_ENTITY_EXTRACT_WINDOW = 5000 # chars of content scanned for capitalized words
|
||||
_ENTITY_METADATA_LIMIT = 25 # max entities packed into the metadata field
|
||||
|
||||
|
||||
def _refresh_known_entities_cache() -> None:
|
||||
"""Reload ``~/.mempalace/known_entities.json`` into the module cache if
|
||||
its mtime changed since the last read. Shared by ``_load_known_entities``
|
||||
(flat set) and ``_load_known_entities_raw`` (category dict), so callers
|
||||
can pick whichever shape they need without duplicating the mtime-gated
|
||||
disk read.
|
||||
"""
|
||||
try:
|
||||
mtime = os.path.getmtime(_ENTITY_REGISTRY_PATH)
|
||||
except OSError:
|
||||
if _ENTITY_REGISTRY_CACHE["mtime"] is not None:
|
||||
_ENTITY_REGISTRY_CACHE["mtime"] = None
|
||||
_ENTITY_REGISTRY_CACHE["names"] = frozenset()
|
||||
_ENTITY_REGISTRY_CACHE["raw"] = {}
|
||||
return
|
||||
|
||||
if _ENTITY_REGISTRY_CACHE["mtime"] == mtime:
|
||||
return
|
||||
|
||||
names: set = set()
|
||||
raw: dict = {}
|
||||
try:
|
||||
import json
|
||||
|
||||
with open(_ENTITY_REGISTRY_PATH, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
raw = data
|
||||
for cat in data.values():
|
||||
if isinstance(cat, list):
|
||||
names.update(str(n) for n in cat if n)
|
||||
elif isinstance(cat, dict):
|
||||
names.update(str(k) for k in cat.keys() if k)
|
||||
except Exception:
|
||||
names = set()
|
||||
raw = {}
|
||||
|
||||
_ENTITY_REGISTRY_CACHE["mtime"] = mtime
|
||||
_ENTITY_REGISTRY_CACHE["names"] = frozenset(names)
|
||||
_ENTITY_REGISTRY_CACHE["raw"] = raw
|
||||
|
||||
|
||||
def _load_known_entities() -> frozenset:
|
||||
"""Flat set of every known entity name (across all categories).
|
||||
|
||||
Cached by mtime; invalidated when the registry file changes.
|
||||
"""
|
||||
_refresh_known_entities_cache()
|
||||
return _ENTITY_REGISTRY_CACHE["names"]
|
||||
|
||||
|
||||
def _load_known_entities_raw() -> dict:
|
||||
"""Full category-dict view of the registry, shape
|
||||
``{"category": ["Name1", ...], ...}``. Cached by mtime.
|
||||
|
||||
Consumed by modules (e.g., fact_checker) that need to reason about
|
||||
categories rather than a flat name set. Never returns a mutable
|
||||
reference to the cache — callers get a shallow copy.
|
||||
"""
|
||||
_refresh_known_entities_cache()
|
||||
return dict(_ENTITY_REGISTRY_CACHE["raw"])
|
||||
|
||||
|
||||
_HALL_KEYWORDS_CACHE = None
|
||||
|
||||
|
||||
def detect_hall(content: str) -> str:
|
||||
"""Route content to a hall based on keyword scoring.
|
||||
|
||||
Halls connect rooms within a wing — they categorize the TYPE of content
|
||||
(emotional, technical, family, etc.) while rooms categorize the TOPIC.
|
||||
"""
|
||||
global _HALL_KEYWORDS_CACHE
|
||||
if _HALL_KEYWORDS_CACHE is None:
|
||||
from .config import MempalaceConfig
|
||||
|
||||
_HALL_KEYWORDS_CACHE = MempalaceConfig().hall_keywords
|
||||
content_lower = content[:3000].lower()
|
||||
|
||||
scores = {}
|
||||
for hall, keywords in _HALL_KEYWORDS_CACHE.items():
|
||||
score = sum(1 for kw in keywords if kw in content_lower)
|
||||
if score > 0:
|
||||
scores[hall] = score
|
||||
|
||||
if scores:
|
||||
return max(scores, key=scores.get)
|
||||
return "general"
|
||||
|
||||
|
||||
def _extract_entities_for_metadata(content: str) -> str:
|
||||
"""Extract entity names from content for metadata tagging.
|
||||
|
||||
Combines the user's known-entity registry (cached across calls) with
|
||||
capitalized words appearing ≥2 times in the first ``_ENTITY_EXTRACT_WINDOW``
|
||||
chars. Filters out the closet stoplist (``When``, ``After``, ``The``, …)
|
||||
so sentence-starters don't masquerade as proper nouns.
|
||||
|
||||
Returns semicolon-separated string suitable for ChromaDB metadata
|
||||
filtering. The list is truncated to ``_ENTITY_METADATA_LIMIT`` entries
|
||||
*before* joining so a name is never cut in half.
|
||||
"""
|
||||
import re
|
||||
|
||||
from .palace import _ENTITY_STOPLIST
|
||||
|
||||
matched: set = set()
|
||||
|
||||
known = _load_known_entities()
|
||||
for name in known:
|
||||
if re.search(r"(?<!\w)" + re.escape(name) + r"(?!\w)", content):
|
||||
matched.add(name)
|
||||
|
||||
window = content[:_ENTITY_EXTRACT_WINDOW]
|
||||
words = re.findall(r"\b[A-Z][a-z]{2,}\b", window)
|
||||
freq: dict = {}
|
||||
for w in words:
|
||||
if w in _ENTITY_STOPLIST:
|
||||
continue
|
||||
freq[w] = freq.get(w, 0) + 1
|
||||
for w, c in freq.items():
|
||||
if c >= 2 and len(w) > 2:
|
||||
matched.add(w)
|
||||
|
||||
if not matched:
|
||||
return ""
|
||||
# Truncate the *list*, not the joined string — never split a name.
|
||||
capped = sorted(matched)[:_ENTITY_METADATA_LIMIT]
|
||||
return ";".join(capped)
|
||||
|
||||
|
||||
def add_drawer(
|
||||
collection, wing: str, room: str, content: str, source_file: str, chunk_index: int, agent: str
|
||||
):
|
||||
@@ -383,12 +528,19 @@ def add_drawer(
|
||||
"chunk_index": chunk_index,
|
||||
"added_by": agent,
|
||||
"filed_at": datetime.now().isoformat(),
|
||||
"normalize_version": NORMALIZE_VERSION,
|
||||
}
|
||||
# Store file mtime so we can detect modifications later.
|
||||
try:
|
||||
metadata["source_mtime"] = os.path.getmtime(source_file)
|
||||
except OSError:
|
||||
pass
|
||||
# Tag with hall for graph connectivity within wings
|
||||
metadata["hall"] = detect_hall(content)
|
||||
# Tag with entity names for filterable search
|
||||
entities = _extract_entities_for_metadata(content)
|
||||
if entities:
|
||||
metadata["entities"] = entities
|
||||
collection.upsert(
|
||||
documents=[content],
|
||||
ids=[drawer_id],
|
||||
@@ -412,22 +564,23 @@ def process_file(
|
||||
rooms: list,
|
||||
agent: str,
|
||||
dry_run: bool,
|
||||
closets_col=None,
|
||||
) -> tuple:
|
||||
"""Read, chunk, route, and file one file. Returns (drawer_count, room_name)."""
|
||||
|
||||
# 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)
|
||||
@@ -436,19 +589,63 @@ def process_file(
|
||||
print(f" [DRY RUN] {filepath.name} → room:{room} ({len(chunks)} drawers)")
|
||||
return len(chunks), room
|
||||
|
||||
drawers_added = 0
|
||||
for chunk in chunks:
|
||||
added = add_drawer(
|
||||
collection=collection,
|
||||
wing=wing,
|
||||
room=room,
|
||||
content=chunk["content"],
|
||||
source_file=source_file,
|
||||
chunk_index=chunk["chunk_index"],
|
||||
agent=agent,
|
||||
)
|
||||
if added:
|
||||
drawers_added += 1
|
||||
# Lock this file so concurrent agents don't interleave delete+insert.
|
||||
# Without the lock, two agents can both pass file_already_mined(),
|
||||
# both delete, and both insert — creating duplicates or losing data.
|
||||
with mine_lock(source_file):
|
||||
# Re-check after acquiring lock — another agent may have just finished
|
||||
if file_already_mined(collection, source_file, check_mtime=True):
|
||||
return 0, room
|
||||
|
||||
# Purge stale drawers for this file before re-inserting the fresh chunks.
|
||||
# Converts modified-file re-mines from upsert-over-existing-IDs (which hits
|
||||
# hnswlib's thread-unsafe updatePoint path and can segfault on macOS ARM
|
||||
# with chromadb 0.6.3) into a clean delete+insert, bypassing the update
|
||||
# path entirely.
|
||||
try:
|
||||
collection.delete(where={"source_file": source_file})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
drawers_added = 0
|
||||
for chunk in chunks:
|
||||
added = add_drawer(
|
||||
collection=collection,
|
||||
wing=wing,
|
||||
room=room,
|
||||
content=chunk["content"],
|
||||
source_file=source_file,
|
||||
chunk_index=chunk["chunk_index"],
|
||||
agent=agent,
|
||||
)
|
||||
if added:
|
||||
drawers_added += 1
|
||||
|
||||
# Build closet — the searchable index pointing to these drawers.
|
||||
# Purge first: a re-mine (mtime change or normalize_version bump) must
|
||||
# fully replace the prior closets, not append to them.
|
||||
if closets_col and drawers_added > 0:
|
||||
drawer_ids = [
|
||||
f"drawer_{wing}_{room}_{hashlib.sha256((source_file + str(c['chunk_index'])).encode()).hexdigest()[:24]}"
|
||||
for c in chunks
|
||||
]
|
||||
closet_lines = build_closet_lines(source_file, drawer_ids, content, wing, room)
|
||||
closet_id_base = (
|
||||
f"closet_{wing}_{room}_{hashlib.sha256(source_file.encode()).hexdigest()[:24]}"
|
||||
)
|
||||
entities = _extract_entities_for_metadata(content)
|
||||
closet_meta = {
|
||||
"wing": wing,
|
||||
"room": room,
|
||||
"source_file": source_file,
|
||||
"drawer_count": drawers_added,
|
||||
"filed_at": datetime.now().isoformat(),
|
||||
"normalize_version": NORMALIZE_VERSION,
|
||||
}
|
||||
if entities:
|
||||
closet_meta["entities"] = entities
|
||||
purge_file_closets(closets_col, source_file)
|
||||
upsert_closet_lines(closets_col, closet_id_base, closet_lines, closet_meta)
|
||||
|
||||
return drawers_added, room
|
||||
|
||||
@@ -570,8 +767,10 @@ def mine(
|
||||
|
||||
if not dry_run:
|
||||
collection = get_collection(palace_path)
|
||||
closets_col = get_closets_collection(palace_path)
|
||||
else:
|
||||
collection = None
|
||||
closets_col = None
|
||||
|
||||
total_drawers = 0
|
||||
files_skipped = 0
|
||||
@@ -586,6 +785,7 @@ def mine(
|
||||
rooms=rooms,
|
||||
agent=agent,
|
||||
dry_run=dry_run,
|
||||
closets_col=closets_col,
|
||||
)
|
||||
if drawers == 0 and not dry_run:
|
||||
files_skipped += 1
|
||||
@@ -615,15 +815,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))
|
||||
|
||||
+288
-34
@@ -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)
|
||||
@@ -16,10 +16,93 @@ No API key. No internet. Everything local.
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ─── Noise stripping ─────────────────────────────────────────────────────
|
||||
# Claude Code and other tools inject system tags, hook output, and UI chrome
|
||||
# into transcripts. These waste drawer space and pollute search results.
|
||||
#
|
||||
# Verbatim is sacred — every pattern here is anchored to line boundaries and
|
||||
# refuses to cross blank lines, so a stray unclosed tag in one message can
|
||||
# never eat content from neighboring messages. When in doubt, leave text
|
||||
# alone.
|
||||
|
||||
_NOISE_TAGS = (
|
||||
"system-reminder",
|
||||
"command-message",
|
||||
"command-name",
|
||||
"task-notification",
|
||||
"user-prompt-submit-hook",
|
||||
"hook_output",
|
||||
)
|
||||
|
||||
|
||||
def _tag_pattern(name: str) -> "re.Pattern[str]":
|
||||
# Opening tag must begin a line (optionally after a `> ` blockquote marker,
|
||||
# since _messages_to_transcript prefixes lines with `> `). Body is lazy but
|
||||
# forbidden from crossing a blank line, so a dangling open tag can't span
|
||||
# multiple messages. Closing tag eats optional trailing whitespace + newline.
|
||||
return re.compile(
|
||||
rf"(?m)^(?:> )?<{name}(?:\s[^>]*)?>" rf"(?:(?!\n\s*\n)[\s\S])*?" rf"</{name}>[ \t]*\n?"
|
||||
)
|
||||
|
||||
|
||||
_NOISE_TAG_PATTERNS = [_tag_pattern(t) for t in _NOISE_TAGS]
|
||||
|
||||
# Strings that identify an entire noise line when found at its start.
|
||||
# Matched case-sensitively and anchored to line-start so user prose mentioning
|
||||
# e.g. "current time:" in a sentence is untouched.
|
||||
_NOISE_LINE_PREFIXES = (
|
||||
"CURRENT TIME:",
|
||||
"VERIFIED FACTS (do not contradict)",
|
||||
"AGENT SPECIALIZATION:",
|
||||
"Checking verified facts...",
|
||||
"Injecting timestamp...",
|
||||
"Starting background pipeline...",
|
||||
"Checking emotional weights...",
|
||||
"Auto-save reminder...",
|
||||
"Checking pipeline...",
|
||||
"MemPalace auto-save checkpoint.",
|
||||
)
|
||||
|
||||
_NOISE_LINE_PATTERNS = [
|
||||
re.compile(rf"(?m)^(?:> )?{re.escape(p)}.*\n?") for p in _NOISE_LINE_PREFIXES
|
||||
]
|
||||
|
||||
# Claude Code TUI hook-run chrome, e.g. "Ran 2 Stop hook", "Ran 1 PreCompact hook".
|
||||
# Line-anchored, case-sensitive, explicit hook names — prose like
|
||||
# "our CI has a stop hook" stays intact.
|
||||
_HOOK_LINE_RE = re.compile(
|
||||
r"(?m)^(?:> )?Ran \d+ (?:Stop|PreCompact|PreToolUse|PostToolUse|UserPromptSubmit|Notification|SessionStart|SessionEnd) hook[s]?.*\n?"
|
||||
)
|
||||
|
||||
# "… +N lines" collapsed-output marker, line-anchored.
|
||||
_COLLAPSED_LINES_RE = re.compile(r"(?m)^(?:> )?…\s*\+\d+ lines.*\n?")
|
||||
|
||||
|
||||
def strip_noise(text: str) -> str:
|
||||
"""Remove system tags, hook output, and Claude Code UI chrome from text.
|
||||
|
||||
All patterns are line-anchored. User prose that happens to mention these
|
||||
strings inline (e.g., documenting them) is preserved verbatim.
|
||||
"""
|
||||
for pat in _NOISE_TAG_PATTERNS:
|
||||
text = pat.sub("", text)
|
||||
for pat in _NOISE_LINE_PATTERNS:
|
||||
text = pat.sub("", text)
|
||||
text = _HOOK_LINE_RE.sub("", text)
|
||||
text = _COLLAPSED_LINES_RE.sub("", text)
|
||||
# Strip the Claude Code collapsed-output chrome "[N tokens] (ctrl+o to expand)".
|
||||
# Narrow shape — a bare "(ctrl+o to expand)" in user prose stays intact.
|
||||
text = re.sub(r"\s*\[\d+\s+tokens?\]\s*\(ctrl\+o to expand\)", "", text)
|
||||
# Collapse runs of blank lines created by the removals
|
||||
text = re.sub(r"\n{4,}", "\n\n\n", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def normalize(filepath: str) -> str:
|
||||
"""
|
||||
Load a file and normalize to transcript format if it's a chat export.
|
||||
@@ -30,7 +113,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()
|
||||
@@ -40,12 +123,14 @@ def normalize(filepath: str) -> str:
|
||||
if not content.strip():
|
||||
return content
|
||||
|
||||
# Already has > markers — pass through
|
||||
# Already has > markers — pass through unchanged.
|
||||
lines = content.split("\n")
|
||||
if sum(1 for line in lines if line.strip().startswith(">")) >= 3:
|
||||
return content
|
||||
|
||||
# Try JSON normalization
|
||||
# Try JSON normalization. strip_noise is applied inside the Claude Code
|
||||
# JSONL parser (the only format that injects system tags/hook chrome);
|
||||
# other formats pass through verbatim.
|
||||
ext = Path(filepath).suffix.lower()
|
||||
if ext in (".json", ".jsonl") or content.strip()[:1] in ("{", "["):
|
||||
normalized = _try_normalize_json(content)
|
||||
@@ -83,6 +168,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 +179,48 @@ 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)
|
||||
# Strip Claude Code system-injected noise per message, never across
|
||||
# message boundaries — prevents span-eating.
|
||||
if text:
|
||||
messages.append(("user", text))
|
||||
text = strip_noise(text)
|
||||
if 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))
|
||||
text = strip_noise(text)
|
||||
if 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 +281,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 +397,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 +412,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:
|
||||
|
||||
+259
-17
@@ -1,11 +1,14 @@
|
||||
"""
|
||||
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 contextlib
|
||||
import hashlib
|
||||
import os
|
||||
import chromadb
|
||||
|
||||
from .backends.chroma import ChromaBackend
|
||||
|
||||
SKIP_DIRS = {
|
||||
".git",
|
||||
@@ -33,39 +36,278 @@ 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)
|
||||
# Schema version for drawer normalization. Bump when the normalization
|
||||
# pipeline changes in a way that existing drawers should be rebuilt to pick up
|
||||
# (e.g., new noise-stripping rules). `file_already_mined` treats drawers with
|
||||
# a missing or stale `normalize_version` as "not mined", so the next mine pass
|
||||
# silently rebuilds them — users don't need to manually erase + re-mine.
|
||||
#
|
||||
# v2 (2026-04): introduced strip_noise() for Claude Code JSONL; previous
|
||||
# drawers stored system tags / hook chrome verbatim.
|
||||
NORMALIZE_VERSION = 2
|
||||
|
||||
|
||||
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 get_closets_collection(palace_path: str, create: bool = True):
|
||||
"""Get the closets collection — the searchable index layer."""
|
||||
return get_collection(palace_path, collection_name="mempalace_closets", create=create)
|
||||
|
||||
|
||||
CLOSET_CHAR_LIMIT = 1500 # fill closet until ~1500 chars, then start a new one
|
||||
CLOSET_EXTRACT_WINDOW = 5000 # how many chars of source content to scan for entities/topics
|
||||
|
||||
# Common capitalized words that look like proper nouns but are usually
|
||||
# sentence-starters or filler. Filtered out of entity extraction.
|
||||
_ENTITY_STOPLIST = frozenset(
|
||||
{
|
||||
"The",
|
||||
"This",
|
||||
"That",
|
||||
"These",
|
||||
"Those",
|
||||
"When",
|
||||
"Where",
|
||||
"What",
|
||||
"Why",
|
||||
"Who",
|
||||
"Which",
|
||||
"How",
|
||||
"After",
|
||||
"Before",
|
||||
"Then",
|
||||
"Now",
|
||||
"Here",
|
||||
"There",
|
||||
"And",
|
||||
"But",
|
||||
"Or",
|
||||
"Yet",
|
||||
"So",
|
||||
"If",
|
||||
"Else",
|
||||
"Yes",
|
||||
"No",
|
||||
"Maybe",
|
||||
"Okay",
|
||||
"User",
|
||||
"Assistant",
|
||||
"System",
|
||||
"Tool",
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
"Sunday",
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def build_closet_lines(source_file, drawer_ids, content, wing, room):
|
||||
"""Build compact closet pointer lines from drawer content.
|
||||
|
||||
Returns a LIST of lines (not joined). Each line is one complete topic
|
||||
pointer — never split across closets.
|
||||
|
||||
Format: topic|entities|→drawer_ids
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
drawer_ref = ",".join(drawer_ids[:3])
|
||||
window = content[:CLOSET_EXTRACT_WINDOW]
|
||||
|
||||
# Extract proper nouns (capitalized words, 2+ occurrences). Filter out
|
||||
# common sentence-starters that aren't real entities.
|
||||
words = re.findall(r"\b[A-Z][a-z]{2,}\b", window)
|
||||
word_freq = {}
|
||||
for w in words:
|
||||
if w in _ENTITY_STOPLIST:
|
||||
continue
|
||||
word_freq[w] = word_freq.get(w, 0) + 1
|
||||
entities = sorted(
|
||||
[w for w, c in word_freq.items() if c >= 2],
|
||||
key=lambda w: -word_freq[w],
|
||||
)[:5]
|
||||
entity_str = ";".join(entities) if entities else ""
|
||||
|
||||
# Extract key phrases — action verbs + context
|
||||
topics = []
|
||||
for pattern in [
|
||||
r"(?:built|fixed|wrote|added|pushed|tested|created|decided|migrated|reviewed|deployed|configured|removed|updated)\s+[\w\s]{3,40}",
|
||||
]:
|
||||
topics.extend(re.findall(pattern, window, re.IGNORECASE))
|
||||
# Also grab section headers if present
|
||||
for header in re.findall(r"^#{1,3}\s+(.{5,60})$", window, re.MULTILINE):
|
||||
topics.append(header.strip())
|
||||
# Dedupe preserving order
|
||||
topics = list(dict.fromkeys(t.strip().lower() for t in topics))[:12]
|
||||
|
||||
# Extract quotes
|
||||
quotes = re.findall(r'"([^"]{15,150})"', window)
|
||||
|
||||
# Build pointer lines — each one is atomic, never split
|
||||
lines = []
|
||||
for topic in topics:
|
||||
lines.append(f"{topic}|{entity_str}|→{drawer_ref}")
|
||||
for quote in quotes[:3]:
|
||||
lines.append(f'"{quote}"|{entity_str}|→{drawer_ref}')
|
||||
|
||||
# Always have at least one line
|
||||
if not lines:
|
||||
name = Path(source_file).stem[:40]
|
||||
lines.append(f"{wing}/{room}/{name}|{entity_str}|→{drawer_ref}")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def purge_file_closets(closets_col, source_file: str) -> None:
|
||||
"""Delete every closet associated with ``source_file``.
|
||||
|
||||
Call this before ``upsert_closet_lines`` on a re-mine so stale topics
|
||||
from a prior schema/version don't survive in the closet collection.
|
||||
Mirrors the drawer-purge step in process_file().
|
||||
"""
|
||||
try:
|
||||
os.chmod(palace_path, 0o700)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
client = chromadb.PersistentClient(path=palace_path)
|
||||
try:
|
||||
return client.get_collection(collection_name)
|
||||
closets_col.delete(where={"source_file": source_file})
|
||||
except Exception:
|
||||
return client.create_collection(collection_name)
|
||||
pass
|
||||
|
||||
|
||||
def upsert_closet_lines(closets_col, closet_id_base, lines, metadata):
|
||||
"""Write topic lines to closets, packed greedily without splitting a line.
|
||||
|
||||
Closets are deterministically numbered (``..._01``, ``..._02``, …) and
|
||||
each ``upsert`` fully overwrites the prior content at that ID. Callers
|
||||
are expected to ``purge_file_closets`` first when re-mining a source
|
||||
file so stale-numbered closets from larger prior runs don't leak.
|
||||
|
||||
Returns the number of closets written.
|
||||
"""
|
||||
closet_num = 1
|
||||
current_lines: list = []
|
||||
current_chars = 0
|
||||
closets_written = 0
|
||||
|
||||
def _flush():
|
||||
nonlocal closets_written
|
||||
if not current_lines:
|
||||
return
|
||||
closet_id = f"{closet_id_base}_{closet_num:02d}"
|
||||
text = "\n".join(current_lines)
|
||||
closets_col.upsert(documents=[text], ids=[closet_id], metadatas=[metadata])
|
||||
closets_written += 1
|
||||
|
||||
for line in lines:
|
||||
line_len = len(line)
|
||||
# Would this line fit whole in the current closet?
|
||||
if current_chars > 0 and current_chars + line_len + 1 > CLOSET_CHAR_LIMIT:
|
||||
_flush()
|
||||
closet_num += 1
|
||||
current_lines = []
|
||||
current_chars = 0
|
||||
|
||||
current_lines.append(line)
|
||||
current_chars += line_len + 1 # +1 for newline
|
||||
|
||||
_flush()
|
||||
return closets_written
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def mine_lock(source_file: str):
|
||||
"""Cross-platform file lock for mine operations.
|
||||
|
||||
Prevents multiple agents from mining the same file simultaneously,
|
||||
which causes duplicate drawers when the delete+insert cycle interleaves.
|
||||
"""
|
||||
lock_dir = os.path.join(os.path.expanduser("~"), ".mempalace", "locks")
|
||||
os.makedirs(lock_dir, exist_ok=True)
|
||||
lock_path = os.path.join(
|
||||
lock_dir, hashlib.sha256(source_file.encode()).hexdigest()[:16] + ".lock"
|
||||
)
|
||||
|
||||
lf = open(lock_path, "w")
|
||||
try:
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
msvcrt.locking(lf.fileno(), msvcrt.LK_LOCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(lf, fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
msvcrt.locking(lf.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(lf, fcntl.LOCK_UN)
|
||||
except Exception:
|
||||
pass
|
||||
lf.close()
|
||||
|
||||
|
||||
def file_already_mined(collection, source_file: str, check_mtime: bool = False) -> bool:
|
||||
"""Check if a file has already been filed in the palace.
|
||||
|
||||
When check_mtime=True (used by project miner), returns False if the file
|
||||
has been modified since it was last mined, so it gets re-mined.
|
||||
When check_mtime=False (used by convo miner), just checks existence.
|
||||
Returns False (so the file gets re-mined) when:
|
||||
- no drawers exist for this source_file
|
||||
- the stored `normalize_version` is missing or older than the current
|
||||
schema (triggers silent rebuild after a normalization upgrade)
|
||||
- `check_mtime=True` and the file's mtime differs from the stored one
|
||||
|
||||
When check_mtime=True (used by project miner), also re-mines on content
|
||||
change. When check_mtime=False (used by convo miner), transcripts are
|
||||
assumed immutable, so only the version gate triggers a rebuild.
|
||||
"""
|
||||
try:
|
||||
results = collection.get(where={"source_file": source_file}, limit=1)
|
||||
if not results.get("ids"):
|
||||
return False
|
||||
stored_meta = results.get("metadatas", [{}])[0] or {}
|
||||
# Pre-v2 drawers have no version field — treat them as stale.
|
||||
stored_version = stored_meta.get("normalize_version", 1)
|
||||
if stored_version < NORMALIZE_VERSION:
|
||||
return False
|
||||
if check_mtime:
|
||||
stored_meta = results.get("metadatas", [{}])[0]
|
||||
stored_mtime = stored_meta.get("source_mtime")
|
||||
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
|
||||
|
||||
+237
-5
@@ -15,17 +15,25 @@ Enables queries like:
|
||||
No external graph DB needed — built from ChromaDB metadata.
|
||||
"""
|
||||
|
||||
from collections import defaultdict, Counter
|
||||
from .config import MempalaceConfig
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import chromadb
|
||||
from .config import MempalaceConfig
|
||||
from .palace import get_collection as _get_palace_collection
|
||||
from .palace import mine_lock
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -225,3 +233,227 @@ def _fuzzy_match(query: str, nodes: dict, n: int = 5):
|
||||
scored.append((room, 0.5))
|
||||
scored.sort(key=lambda x: -x[1])
|
||||
return [r for r, _ in scored[:n]]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EXPLICIT TUNNELS — agent-created cross-wing links
|
||||
# =============================================================================
|
||||
# Passive tunnels are discovered from shared room names across wings.
|
||||
# Explicit tunnels are created by agents when they notice a connection
|
||||
# between two specific drawers or rooms in different wings/projects.
|
||||
#
|
||||
# Stored as a JSON file at ~/.mempalace/tunnels.json so they persist
|
||||
# across palace rebuilds (not in ChromaDB which can be recreated).
|
||||
|
||||
|
||||
_TUNNEL_FILE = os.path.join(os.path.expanduser("~"), ".mempalace", "tunnels.json")
|
||||
|
||||
|
||||
def _load_tunnels():
|
||||
"""Load explicit tunnels from disk.
|
||||
|
||||
Returns an empty list if the file is missing or corrupt (e.g. truncated
|
||||
by a crash mid-write on a system that lacks atomic-rename semantics).
|
||||
"""
|
||||
if not os.path.exists(_TUNNEL_FILE):
|
||||
return []
|
||||
try:
|
||||
with open(_TUNNEL_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception:
|
||||
return []
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
|
||||
def _save_tunnels(tunnels):
|
||||
"""Persist explicit tunnels atomically.
|
||||
|
||||
Writes to ``tunnels.json.tmp`` then ``os.replace``s it into place, so
|
||||
a crash mid-write can never leave a partial/empty tunnels.json that
|
||||
silently wipes every tunnel on next read.
|
||||
"""
|
||||
os.makedirs(os.path.dirname(_TUNNEL_FILE), exist_ok=True)
|
||||
tmp_path = _TUNNEL_FILE + ".tmp"
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(tunnels, f, indent=2)
|
||||
f.flush()
|
||||
try:
|
||||
os.fsync(f.fileno())
|
||||
except OSError:
|
||||
# Not all filesystems (or Windows file handles) support fsync — tolerate.
|
||||
pass
|
||||
os.replace(tmp_path, _TUNNEL_FILE)
|
||||
|
||||
|
||||
def _endpoint_key(wing: str, room: str) -> str:
|
||||
return f"{wing}/{room}"
|
||||
|
||||
|
||||
def _canonical_tunnel_id(
|
||||
source_wing: str, source_room: str, target_wing: str, target_room: str
|
||||
) -> str:
|
||||
"""Compute a symmetric tunnel ID.
|
||||
|
||||
Tunnels are conceptually undirected — "auth relates to users" is the
|
||||
same connection as "users relates to auth". Sort the two endpoints
|
||||
before hashing so ``create_tunnel(A, B)`` and ``create_tunnel(B, A)``
|
||||
resolve to the same ID and dedup into one record.
|
||||
"""
|
||||
src = _endpoint_key(source_wing, source_room)
|
||||
tgt = _endpoint_key(target_wing, target_room)
|
||||
a, b = sorted((src, tgt))
|
||||
return hashlib.sha256(f"{a}↔{b}".encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def _require_name(value: str, field: str) -> str:
|
||||
"""Reject empty / non-string endpoint identifiers."""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"{field} must be a non-empty string")
|
||||
return value.strip()
|
||||
|
||||
|
||||
def create_tunnel(
|
||||
source_wing: str,
|
||||
source_room: str,
|
||||
target_wing: str,
|
||||
target_room: str,
|
||||
label: str = "",
|
||||
source_drawer_id: str = None,
|
||||
target_drawer_id: str = None,
|
||||
):
|
||||
"""Create an explicit (symmetric) tunnel between two locations in the palace.
|
||||
|
||||
Tunnels are undirected: ``create_tunnel(A, B)`` and ``create_tunnel(B, A)``
|
||||
resolve to the same canonical ID. A second call with the same endpoints
|
||||
updates the stored label (and drawer IDs, if provided) rather than
|
||||
creating a duplicate.
|
||||
|
||||
The ``source`` / ``target`` fields on the returned dict preserve the
|
||||
argument order the caller used, so callers can display it directionally
|
||||
if they like. The ID and dedup are symmetric.
|
||||
|
||||
Args:
|
||||
source_wing: Wing of the source (e.g., "project_api").
|
||||
source_room: Room in the source wing.
|
||||
target_wing: Wing of the target (e.g., "project_database").
|
||||
target_room: Room in the target wing.
|
||||
label: Description of the connection.
|
||||
source_drawer_id: Optional specific drawer ID.
|
||||
target_drawer_id: Optional specific drawer ID.
|
||||
|
||||
Returns:
|
||||
The stored tunnel dict.
|
||||
|
||||
Raises:
|
||||
ValueError: if any wing or room is empty or non-string.
|
||||
"""
|
||||
source_wing = _require_name(source_wing, "source_wing")
|
||||
source_room = _require_name(source_room, "source_room")
|
||||
target_wing = _require_name(target_wing, "target_wing")
|
||||
target_room = _require_name(target_room, "target_room")
|
||||
|
||||
tunnel_id = _canonical_tunnel_id(source_wing, source_room, target_wing, target_room)
|
||||
|
||||
tunnel = {
|
||||
"id": tunnel_id,
|
||||
"source": {"wing": source_wing, "room": source_room},
|
||||
"target": {"wing": target_wing, "room": target_room},
|
||||
"label": label,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
if source_drawer_id:
|
||||
tunnel["source"]["drawer_id"] = source_drawer_id
|
||||
if target_drawer_id:
|
||||
tunnel["target"]["drawer_id"] = target_drawer_id
|
||||
|
||||
# Serialize the load → mutate → save cycle. Without this, two concurrent
|
||||
# create_tunnel calls can both read the same snapshot and the later
|
||||
# writer silently drops the earlier writer's tunnel.
|
||||
with mine_lock(_TUNNEL_FILE):
|
||||
tunnels = _load_tunnels()
|
||||
for existing in tunnels:
|
||||
if existing.get("id") == tunnel_id:
|
||||
# Preserve original creation timestamp on label updates.
|
||||
tunnel["created_at"] = existing.get("created_at", tunnel["created_at"])
|
||||
tunnel["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
existing.clear()
|
||||
existing.update(tunnel)
|
||||
_save_tunnels(tunnels)
|
||||
return existing
|
||||
tunnels.append(tunnel)
|
||||
_save_tunnels(tunnels)
|
||||
return tunnel
|
||||
|
||||
|
||||
def list_tunnels(wing: str = None):
|
||||
"""List all explicit tunnels, optionally filtered by wing.
|
||||
|
||||
Returns tunnels where ``wing`` appears as either source or target
|
||||
(tunnels are symmetric, so either endpoint is a valid filter match).
|
||||
"""
|
||||
tunnels = _load_tunnels()
|
||||
if wing:
|
||||
tunnels = [t for t in tunnels if t["source"]["wing"] == wing or t["target"]["wing"] == wing]
|
||||
return tunnels
|
||||
|
||||
|
||||
def delete_tunnel(tunnel_id: str):
|
||||
"""Delete an explicit tunnel by ID. Returns ``{"deleted": <id>}``."""
|
||||
with mine_lock(_TUNNEL_FILE):
|
||||
tunnels = _load_tunnels()
|
||||
tunnels = [t for t in tunnels if t.get("id") != tunnel_id]
|
||||
_save_tunnels(tunnels)
|
||||
return {"deleted": tunnel_id}
|
||||
|
||||
|
||||
def follow_tunnels(wing: str, room: str, col=None, config=None):
|
||||
"""Follow explicit tunnels from a room — returns connected drawers.
|
||||
|
||||
Given a location (wing/room), finds all tunnels leading from or to it,
|
||||
and optionally fetches the connected drawer content.
|
||||
"""
|
||||
tunnels = _load_tunnels()
|
||||
connections = []
|
||||
|
||||
for t in tunnels:
|
||||
src = t["source"]
|
||||
tgt = t["target"]
|
||||
|
||||
if src["wing"] == wing and src["room"] == room:
|
||||
connections.append(
|
||||
{
|
||||
"direction": "outgoing",
|
||||
"connected_wing": tgt["wing"],
|
||||
"connected_room": tgt["room"],
|
||||
"label": t.get("label", ""),
|
||||
"drawer_id": tgt.get("drawer_id"),
|
||||
"tunnel_id": t["id"],
|
||||
}
|
||||
)
|
||||
elif tgt["wing"] == wing and tgt["room"] == room:
|
||||
connections.append(
|
||||
{
|
||||
"direction": "incoming",
|
||||
"connected_wing": src["wing"],
|
||||
"connected_room": src["room"],
|
||||
"label": t.get("label", ""),
|
||||
"drawer_id": src.get("drawer_id"),
|
||||
"tunnel_id": t["id"],
|
||||
}
|
||||
)
|
||||
|
||||
# If we have a collection, fetch drawer content for connected items
|
||||
if col and connections:
|
||||
drawer_ids = [c["drawer_id"] for c in connections if c.get("drawer_id")]
|
||||
if drawer_ids:
|
||||
try:
|
||||
results = col.get(ids=drawer_ids, include=["documents", "metadatas"])
|
||||
drawer_map = dict(zip(results["ids"], results["documents"]))
|
||||
for c in connections:
|
||||
did = c.get("drawer_id")
|
||||
if did and did in drawer_map:
|
||||
c["drawer_preview"] = drawer_map[did][:300]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return connections
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
query_sanitizer.py — Mitigate system prompt contamination in search queries.
|
||||
|
||||
Problem: AI agents sometimes prepend system prompts (2000+ chars) to search queries.
|
||||
Embedding models represent the concatenated string as a single vector where the
|
||||
system prompt overwhelms the actual question (typically 10-50 chars), causing
|
||||
near-total retrieval failure (89.8% → 1.0% R@10). See Issue #333.
|
||||
|
||||
Approach: "Mitigation" (減災) — not perfect prevention, but prevents the cliff.
|
||||
|
||||
Expected recovery:
|
||||
Step 1 passthrough (≤200 chars) → no degradation, ~89.8%
|
||||
Step 2 question extraction (?found) → near-full recovery, ~85-89%
|
||||
Step 3 tail sentence extraction → moderate recovery, ~80-89%
|
||||
Step 4 tail truncation (fallback) → minimum viable, ~70-80%
|
||||
|
||||
Without sanitizer: 1.0% (catastrophic silent failure)
|
||||
Worst case with sanitizer: ~70-80% (survivable)
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("mempalace_mcp")
|
||||
|
||||
# --- Constants ---
|
||||
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]+")
|
||||
|
||||
# Question detector: ends with ? or ? (possibly with trailing whitespace/quotes)
|
||||
_QUESTION_MARK = re.compile(r'[??]\s*["\']?\s*$')
|
||||
|
||||
|
||||
def sanitize_query(raw_query: str) -> dict:
|
||||
"""
|
||||
Extract the actual search intent from a potentially contaminated query.
|
||||
|
||||
Args:
|
||||
raw_query: The raw query string from the AI agent, possibly containing
|
||||
system prompt content prepended to the actual question.
|
||||
|
||||
Returns:
|
||||
dict with keys:
|
||||
clean_query (str): The sanitized query to use for embedding search
|
||||
was_sanitized (bool): Whether any sanitization was applied
|
||||
original_length (int): Length of the raw input
|
||||
clean_length (int): Length of the sanitized output
|
||||
method (str): Which extraction method was used
|
||||
- "passthrough": query was short enough, no action taken
|
||||
- "question_extraction": found and extracted a question sentence
|
||||
- "tail_sentence": extracted the last meaningful sentence
|
||||
- "tail_truncation": fallback — took the last MAX_QUERY_LENGTH chars
|
||||
"""
|
||||
if not raw_query or not raw_query.strip():
|
||||
return {
|
||||
"clean_query": raw_query or "",
|
||||
"was_sanitized": False,
|
||||
"original_length": len(raw_query) if raw_query else 0,
|
||||
"clean_length": len(raw_query) if raw_query else 0,
|
||||
"method": "passthrough",
|
||||
}
|
||||
|
||||
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 {
|
||||
"clean_query": raw_query,
|
||||
"was_sanitized": False,
|
||||
"original_length": original_length,
|
||||
"clean_length": original_length,
|
||||
"method": "passthrough",
|
||||
}
|
||||
|
||||
# --- Step 2: Question extraction ---
|
||||
# Split into sentences and find ones ending with ?
|
||||
sentences = [s.strip() for s in _SENTENCE_SPLIT.split(raw_query) if s.strip()]
|
||||
|
||||
# Also split on newlines to catch questions on their own line
|
||||
all_segments = []
|
||||
for s in raw_query.split("\n"):
|
||||
s = s.strip()
|
||||
if s:
|
||||
all_segments.append(s)
|
||||
|
||||
# Look for question marks in segments (prefer later ones = more likely the actual query)
|
||||
question_sentences = []
|
||||
for seg in reversed(all_segments):
|
||||
if _QUESTION_MARK.search(seg):
|
||||
question_sentences.append(seg)
|
||||
|
||||
if not question_sentences:
|
||||
# Also check the sentence-split results
|
||||
for sent in reversed(sentences):
|
||||
if "?" in sent or "?" in sent:
|
||||
question_sentences.append(sent)
|
||||
|
||||
if question_sentences:
|
||||
# Take the last (most recent) question found
|
||||
candidate = question_sentences[0].strip()
|
||||
if len(candidate) >= MIN_QUERY_LENGTH:
|
||||
# Apply length guard
|
||||
if len(candidate) > MAX_QUERY_LENGTH:
|
||||
candidate = _trim_candidate(candidate)
|
||||
logger.warning(
|
||||
"Query sanitized: %d → %d chars (method=question_extraction)",
|
||||
original_length,
|
||||
len(candidate),
|
||||
)
|
||||
return {
|
||||
"clean_query": candidate,
|
||||
"was_sanitized": True,
|
||||
"original_length": original_length,
|
||||
"clean_length": len(candidate),
|
||||
"method": "question_extraction",
|
||||
}
|
||||
|
||||
# --- Step 3: Tail sentence extraction ---
|
||||
# System prompts are prepended, so the actual query is near the end.
|
||||
# Walk backwards through segments to find the last meaningful sentence.
|
||||
for seg in reversed(all_segments):
|
||||
seg = seg.strip()
|
||||
if len(seg) >= MIN_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,
|
||||
len(candidate),
|
||||
)
|
||||
return {
|
||||
"clean_query": candidate,
|
||||
"was_sanitized": True,
|
||||
"original_length": original_length,
|
||||
"clean_length": len(candidate),
|
||||
"method": "tail_sentence",
|
||||
}
|
||||
|
||||
# --- Step 4: Tail truncation (fallback) ---
|
||||
# Nothing worked — just take the last MAX_QUERY_LENGTH characters.
|
||||
candidate = raw_query[-MAX_QUERY_LENGTH:].strip()
|
||||
logger.warning(
|
||||
"Query sanitized: %d → %d chars (method=tail_truncation)", original_length, len(candidate)
|
||||
)
|
||||
return {
|
||||
"clean_query": candidate,
|
||||
"was_sanitized": True,
|
||||
"original_length": original_length,
|
||||
"clean_length": len(candidate),
|
||||
"method": "tail_truncation",
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
repair.py — Scan, prune corrupt entries, and rebuild HNSW index
|
||||
================================================================
|
||||
|
||||
When ChromaDB's HNSW index accumulates duplicate entries (from repeated
|
||||
add() calls with the same ID), link_lists.bin can grow unbounded —
|
||||
terabytes on large palaces — eventually causing segfaults.
|
||||
|
||||
This module provides three operations:
|
||||
|
||||
scan — find every corrupt/unfetchable ID in the palace
|
||||
prune — delete only the corrupt IDs (surgical)
|
||||
rebuild — extract all drawers, delete the collection, recreate with
|
||||
correct HNSW settings, and upsert everything back
|
||||
|
||||
The rebuild backs up ONLY chroma.sqlite3 (the source of truth), not the
|
||||
full palace directory — so it works even when link_lists.bin is bloated.
|
||||
|
||||
Usage (standalone):
|
||||
python -m mempalace.repair scan [--wing X]
|
||||
python -m mempalace.repair prune --confirm
|
||||
python -m mempalace.repair rebuild
|
||||
|
||||
Usage (from CLI):
|
||||
mempalace repair
|
||||
mempalace repair-scan [--wing X]
|
||||
mempalace repair-prune --confirm
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
|
||||
import chromadb
|
||||
|
||||
|
||||
COLLECTION_NAME = "mempalace_drawers"
|
||||
|
||||
|
||||
def _get_palace_path():
|
||||
"""Resolve palace path from config."""
|
||||
try:
|
||||
from .config import MempalaceConfig
|
||||
|
||||
return MempalaceConfig().palace_path
|
||||
except Exception:
|
||||
default = os.path.join(os.path.expanduser("~"), ".mempalace", "palace")
|
||||
return default
|
||||
|
||||
|
||||
def _paginate_ids(col, where=None):
|
||||
"""Pull all IDs in a collection using pagination."""
|
||||
ids = []
|
||||
page = 1000
|
||||
offset = 0
|
||||
while True:
|
||||
try:
|
||||
r = col.get(where=where, include=[], limit=page, offset=offset)
|
||||
except Exception:
|
||||
try:
|
||||
r = col.get(where=where, include=[], limit=page)
|
||||
new_ids = [i for i in r["ids"] if i not in set(ids)]
|
||||
if not new_ids:
|
||||
break
|
||||
ids.extend(new_ids)
|
||||
offset += len(new_ids)
|
||||
continue
|
||||
except Exception:
|
||||
break
|
||||
n = len(r["ids"]) if r["ids"] else 0
|
||||
if n == 0:
|
||||
break
|
||||
ids.extend(r["ids"])
|
||||
offset += n
|
||||
if n < page:
|
||||
break
|
||||
return ids
|
||||
|
||||
|
||||
def scan_palace(palace_path=None, only_wing=None):
|
||||
"""Scan the palace for corrupt/unfetchable IDs.
|
||||
|
||||
Probes in batches of 100, falls back to per-ID on failure.
|
||||
Writes corrupt_ids.txt to the palace directory for the prune step.
|
||||
|
||||
Returns (good_set, bad_set).
|
||||
"""
|
||||
palace_path = palace_path or _get_palace_path()
|
||||
print(f"\n Palace: {palace_path}")
|
||||
print(" Loading...")
|
||||
|
||||
client = chromadb.PersistentClient(path=palace_path)
|
||||
col = client.get_collection(COLLECTION_NAME)
|
||||
|
||||
where = {"wing": only_wing} if only_wing else None
|
||||
total = col.count()
|
||||
print(f" Collection: {COLLECTION_NAME}, total: {total:,}")
|
||||
if only_wing:
|
||||
print(f" Scanning wing: {only_wing}")
|
||||
|
||||
print("\n Step 1: listing all IDs...")
|
||||
t0 = time.time()
|
||||
all_ids = _paginate_ids(col, where=where)
|
||||
print(f" Found {len(all_ids):,} IDs in {time.time() - t0:.1f}s\n")
|
||||
|
||||
if not all_ids:
|
||||
print(" Nothing to scan.")
|
||||
return set(), set()
|
||||
|
||||
print(" Step 2: probing each ID (batches of 100)...")
|
||||
t0 = time.time()
|
||||
good_set = set()
|
||||
bad_set = set()
|
||||
batch = 100
|
||||
|
||||
for i in range(0, len(all_ids), batch):
|
||||
chunk = all_ids[i : i + batch]
|
||||
try:
|
||||
r = col.get(ids=chunk, include=["documents"])
|
||||
for got in r["ids"]:
|
||||
good_set.add(got)
|
||||
for mid in chunk:
|
||||
if mid not in good_set:
|
||||
bad_set.add(mid)
|
||||
except Exception:
|
||||
for sid in chunk:
|
||||
try:
|
||||
r = col.get(ids=[sid], include=["documents"])
|
||||
if r["ids"]:
|
||||
good_set.add(sid)
|
||||
else:
|
||||
bad_set.add(sid)
|
||||
except Exception:
|
||||
bad_set.add(sid)
|
||||
|
||||
if (i // batch) % 50 == 0:
|
||||
elapsed = time.time() - t0
|
||||
rate = (i + batch) / max(elapsed, 0.01)
|
||||
eta = (len(all_ids) - i - batch) / max(rate, 0.01)
|
||||
print(
|
||||
f" {i + batch:>6}/{len(all_ids):>6} "
|
||||
f"good={len(good_set):>6} bad={len(bad_set):>6} "
|
||||
f"eta={eta:.0f}s"
|
||||
)
|
||||
|
||||
print(f"\n Scan complete in {time.time() - t0:.1f}s")
|
||||
print(f" GOOD: {len(good_set):,}")
|
||||
print(f" BAD: {len(bad_set):,} ({len(bad_set) / max(len(all_ids), 1) * 100:.1f}%)")
|
||||
|
||||
bad_file = os.path.join(palace_path, "corrupt_ids.txt")
|
||||
with open(bad_file, "w") as f:
|
||||
for bid in sorted(bad_set):
|
||||
f.write(bid + "\n")
|
||||
print(f"\n Bad IDs written to: {bad_file}")
|
||||
return good_set, bad_set
|
||||
|
||||
|
||||
def prune_corrupt(palace_path=None, confirm=False):
|
||||
"""Delete corrupt IDs listed in corrupt_ids.txt."""
|
||||
palace_path = palace_path or _get_palace_path()
|
||||
bad_file = os.path.join(palace_path, "corrupt_ids.txt")
|
||||
|
||||
if not os.path.exists(bad_file):
|
||||
print(" No corrupt_ids.txt found — run scan first.")
|
||||
return
|
||||
|
||||
with open(bad_file) as f:
|
||||
bad_ids = [line.strip() for line in f if line.strip()]
|
||||
print(f" {len(bad_ids):,} corrupt IDs queued for deletion")
|
||||
|
||||
if not confirm:
|
||||
print("\n DRY RUN — no deletions performed.")
|
||||
print(" Re-run with --confirm to actually delete.")
|
||||
return
|
||||
|
||||
client = chromadb.PersistentClient(path=palace_path)
|
||||
col = client.get_collection(COLLECTION_NAME)
|
||||
before = col.count()
|
||||
print(f" Collection size before: {before:,}")
|
||||
|
||||
batch = 100
|
||||
deleted = 0
|
||||
failed = 0
|
||||
for i in range(0, len(bad_ids), batch):
|
||||
chunk = bad_ids[i : i + batch]
|
||||
try:
|
||||
col.delete(ids=chunk)
|
||||
deleted += len(chunk)
|
||||
except Exception:
|
||||
for sid in chunk:
|
||||
try:
|
||||
col.delete(ids=[sid])
|
||||
deleted += 1
|
||||
except Exception:
|
||||
failed += 1
|
||||
if (i // batch) % 20 == 0:
|
||||
print(f" deleted {deleted}/{len(bad_ids)} (failed: {failed})")
|
||||
|
||||
after = col.count()
|
||||
print(f"\n Deleted: {deleted:,}")
|
||||
print(f" Failed: {failed:,}")
|
||||
print(f" Collection size: {before:,} → {after:,}")
|
||||
|
||||
|
||||
def rebuild_index(palace_path=None):
|
||||
"""Rebuild the HNSW index from scratch.
|
||||
|
||||
1. Extract all drawers via ChromaDB get()
|
||||
2. Back up ONLY chroma.sqlite3 (not the bloated HNSW files)
|
||||
3. Delete and recreate the collection with hnsw:space=cosine
|
||||
4. Upsert all drawers back
|
||||
"""
|
||||
palace_path = palace_path or _get_palace_path()
|
||||
|
||||
if not os.path.isdir(palace_path):
|
||||
print(f"\n No palace found at {palace_path}")
|
||||
return
|
||||
|
||||
print(f"\n{'=' * 55}")
|
||||
print(" MemPalace Repair — Index Rebuild")
|
||||
print(f"{'=' * 55}\n")
|
||||
print(f" Palace: {palace_path}")
|
||||
|
||||
client = chromadb.PersistentClient(path=palace_path)
|
||||
try:
|
||||
col = client.get_collection(COLLECTION_NAME)
|
||||
total = col.count()
|
||||
except Exception as e:
|
||||
print(f" Error reading palace: {e}")
|
||||
print(" Palace may need to be re-mined from source files.")
|
||||
return
|
||||
|
||||
print(f" Drawers found: {total}")
|
||||
|
||||
if total == 0:
|
||||
print(" Nothing to repair.")
|
||||
return
|
||||
|
||||
# Extract all drawers in batches
|
||||
print("\n Extracting drawers...")
|
||||
batch_size = 5000
|
||||
all_ids = []
|
||||
all_docs = []
|
||||
all_metas = []
|
||||
offset = 0
|
||||
while offset < total:
|
||||
batch = col.get(limit=batch_size, offset=offset, include=["documents", "metadatas"])
|
||||
if not batch["ids"]:
|
||||
break
|
||||
all_ids.extend(batch["ids"])
|
||||
all_docs.extend(batch["documents"])
|
||||
all_metas.extend(batch["metadatas"])
|
||||
offset += len(batch["ids"])
|
||||
print(f" Extracted {len(all_ids)} drawers")
|
||||
|
||||
# Back up ONLY the SQLite database, not the bloated HNSW files
|
||||
sqlite_path = os.path.join(palace_path, "chroma.sqlite3")
|
||||
if os.path.exists(sqlite_path):
|
||||
backup_path = sqlite_path + ".backup"
|
||||
print(f" Backing up chroma.sqlite3 ({os.path.getsize(sqlite_path) / 1e6:.0f} MB)...")
|
||||
shutil.copy2(sqlite_path, backup_path)
|
||||
print(f" Backup: {backup_path}")
|
||||
|
||||
# Rebuild with correct HNSW settings
|
||||
print(" Rebuilding collection with hnsw:space=cosine...")
|
||||
client.delete_collection(COLLECTION_NAME)
|
||||
new_col = client.create_collection(COLLECTION_NAME, metadata={"hnsw:space": "cosine"})
|
||||
|
||||
filed = 0
|
||||
for i in range(0, len(all_ids), batch_size):
|
||||
batch_ids = all_ids[i : i + batch_size]
|
||||
batch_docs = all_docs[i : i + batch_size]
|
||||
batch_metas = all_metas[i : i + batch_size]
|
||||
new_col.upsert(documents=batch_docs, ids=batch_ids, metadatas=batch_metas)
|
||||
filed += len(batch_ids)
|
||||
print(f" Re-filed {filed}/{len(all_ids)} drawers...")
|
||||
|
||||
print(f"\n Repair complete. {filed} drawers rebuilt.")
|
||||
print(" HNSW index is now clean with cosine distance metric.")
|
||||
print(f"\n{'=' * 55}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
p = argparse.ArgumentParser(description="MemPalace repair tools")
|
||||
p.add_argument("command", choices=["scan", "prune", "rebuild"])
|
||||
p.add_argument("--palace", default=None, help="Palace directory path")
|
||||
p.add_argument("--wing", default=None, help="Scan only this wing")
|
||||
p.add_argument("--confirm", action="store_true", help="Actually delete corrupt IDs")
|
||||
args = p.parse_args()
|
||||
|
||||
path = os.path.expanduser(args.palace) if args.palace else None
|
||||
|
||||
if args.command == "scan":
|
||||
scan_palace(palace_path=path, only_wing=args.wing)
|
||||
elif args.command == "prune":
|
||||
prune_corrupt(palace_path=path, confirm=args.confirm)
|
||||
elif args.command == "rebuild":
|
||||
rebuild_index(palace_path=path)
|
||||
@@ -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]
|
||||
|
||||
+381
-46
@@ -2,14 +2,23 @@
|
||||
"""
|
||||
searcher.py — Find anything. Exact words.
|
||||
|
||||
Semantic search against the palace.
|
||||
Returns verbatim text — the actual words, never summaries.
|
||||
Hybrid search: BM25 keyword matching + vector semantic similarity. The
|
||||
drawer query is the floor — always runs — and closet hits add a rank-based
|
||||
boost when they agree. Closets are a ranking *signal*, never a gate, so
|
||||
weak closets (regex extraction on narrative content) can only help, never
|
||||
hide drawers the direct path would have found.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import chromadb
|
||||
from .palace import get_closets_collection, get_collection
|
||||
|
||||
# Closet pointer line format: "topic|entities|→drawer_id_a,drawer_id_b"
|
||||
# Multiple lines may join with newlines inside one closet document.
|
||||
_CLOSET_DRAWER_REF_RE = re.compile(r"→([\w,]+)")
|
||||
|
||||
logger = logging.getLogger("mempalace_mcp")
|
||||
|
||||
@@ -18,27 +27,214 @@ class SearchError(Exception):
|
||||
"""Raised when search cannot proceed (e.g. no palace found)."""
|
||||
|
||||
|
||||
_TOKEN_RE = re.compile(r"\w{2,}", re.UNICODE)
|
||||
|
||||
|
||||
def _tokenize(text: str) -> list:
|
||||
"""Lowercase + strip to alphanumeric tokens of length ≥ 2."""
|
||||
return _TOKEN_RE.findall(text.lower())
|
||||
|
||||
|
||||
def _bm25_scores(
|
||||
query: str,
|
||||
documents: list,
|
||||
k1: float = 1.5,
|
||||
b: float = 0.75,
|
||||
) -> list:
|
||||
"""Compute Okapi-BM25 scores for ``query`` against each document.
|
||||
|
||||
IDF is computed over the *provided corpus* using the Lucene/BM25+
|
||||
smoothed formula ``log((N - df + 0.5) / (df + 0.5) + 1)``, which is
|
||||
always non-negative. This is well-defined for re-ranking a small
|
||||
candidate set returned by vector retrieval — IDF then reflects how
|
||||
discriminative each query term is *within the candidates*, exactly
|
||||
what's needed to reorder them.
|
||||
|
||||
Parameters mirror Okapi-BM25 conventions:
|
||||
k1 — term-frequency saturation (1.2-2.0 typical, 1.5 default)
|
||||
b — length normalization (0.0 = none, 1.0 = full, 0.75 default)
|
||||
|
||||
Returns a list of scores in the same order as ``documents``.
|
||||
"""
|
||||
n_docs = len(documents)
|
||||
query_terms = set(_tokenize(query))
|
||||
if not query_terms or n_docs == 0:
|
||||
return [0.0] * n_docs
|
||||
|
||||
tokenized = [_tokenize(d) for d in documents]
|
||||
doc_lens = [len(toks) for toks in tokenized]
|
||||
if not any(doc_lens):
|
||||
return [0.0] * n_docs
|
||||
avgdl = sum(doc_lens) / n_docs or 1.0
|
||||
|
||||
# Document frequency: how many docs contain each query term?
|
||||
df = {term: 0 for term in query_terms}
|
||||
for toks in tokenized:
|
||||
seen = set(toks) & query_terms
|
||||
for term in seen:
|
||||
df[term] += 1
|
||||
|
||||
idf = {term: math.log((n_docs - df[term] + 0.5) / (df[term] + 0.5) + 1) for term in query_terms}
|
||||
|
||||
scores = []
|
||||
for toks, dl in zip(tokenized, doc_lens):
|
||||
if dl == 0:
|
||||
scores.append(0.0)
|
||||
continue
|
||||
tf: dict = {}
|
||||
for t in toks:
|
||||
if t in query_terms:
|
||||
tf[t] = tf.get(t, 0) + 1
|
||||
score = 0.0
|
||||
for term, freq in tf.items():
|
||||
num = freq * (k1 + 1)
|
||||
den = freq + k1 * (1 - b + b * dl / avgdl)
|
||||
score += idf[term] * num / den
|
||||
scores.append(score)
|
||||
return scores
|
||||
|
||||
|
||||
def _hybrid_rank(
|
||||
results: list,
|
||||
query: str,
|
||||
vector_weight: float = 0.6,
|
||||
bm25_weight: float = 0.4,
|
||||
) -> list:
|
||||
"""Re-rank ``results`` by a convex combination of vector similarity and BM25.
|
||||
|
||||
* Vector similarity uses absolute cosine sim ``max(0, 1 - distance)`` —
|
||||
ChromaDB's hnsw cosine distance lives in ``[0, 2]`` (0 = identical).
|
||||
Absolute (not relative-to-max) means adding/removing a candidate
|
||||
can't reshuffle the others.
|
||||
* BM25 is real Okapi-BM25 with corpus-relative IDF over the candidates
|
||||
themselves. Since the absolute scale is unbounded, BM25 is min-max
|
||||
normalized within the candidate set so weights are commensurable.
|
||||
|
||||
Mutates each result dict to add ``bm25_score`` and reorders the list
|
||||
in place. Returns the same list for convenience.
|
||||
"""
|
||||
if not results:
|
||||
return results
|
||||
|
||||
docs = [r.get("text", "") for r in results]
|
||||
bm25_raw = _bm25_scores(query, docs)
|
||||
max_bm25 = max(bm25_raw) if bm25_raw else 0.0
|
||||
bm25_norm = [s / max_bm25 for s in bm25_raw] if max_bm25 > 0 else [0.0] * len(bm25_raw)
|
||||
|
||||
scored = []
|
||||
for r, raw, norm in zip(results, bm25_raw, bm25_norm):
|
||||
vec_sim = max(0.0, 1.0 - r.get("distance", 1.0))
|
||||
r["bm25_score"] = round(raw, 3)
|
||||
scored.append((vector_weight * vec_sim + bm25_weight * norm, r))
|
||||
|
||||
scored.sort(key=lambda pair: pair[0], reverse=True)
|
||||
results[:] = [r for _, r in scored]
|
||||
return results
|
||||
|
||||
|
||||
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 _extract_drawer_ids_from_closet(closet_doc: str) -> list:
|
||||
"""Parse all `→drawer_id_a,drawer_id_b` pointers out of a closet document.
|
||||
|
||||
Preserves order and dedupes.
|
||||
"""
|
||||
seen: dict = {}
|
||||
for match in _CLOSET_DRAWER_REF_RE.findall(closet_doc):
|
||||
for did in match.split(","):
|
||||
did = did.strip()
|
||||
if did and did not in seen:
|
||||
seen[did] = None
|
||||
return list(seen.keys())
|
||||
|
||||
|
||||
def _expand_with_neighbors(drawers_col, matched_doc: str, matched_meta: dict, radius: int = 1):
|
||||
"""Expand a matched drawer with its ±radius sibling chunks in the same source file.
|
||||
|
||||
Motivation — "drawer-grep context" feature: a closet hit returns one
|
||||
drawer, but the chunk boundary may clip mid-thought (e.g., the matched
|
||||
chunk says "here's a breakdown:" and the actual breakdown lives in the
|
||||
next chunk). Fetching the small neighborhood around the match gives
|
||||
callers enough context without forcing a follow-up ``get_drawer`` call.
|
||||
|
||||
Returns a dict with:
|
||||
``text`` combined chunks in chunk_index order
|
||||
``drawer_index`` the matched chunk's index in the source file
|
||||
``total_drawers`` total drawer count for the source file (or None)
|
||||
|
||||
On any ChromaDB failure or missing metadata, falls back to returning the
|
||||
matched drawer alone so search never breaks because neighbor expansion
|
||||
failed.
|
||||
"""
|
||||
src = matched_meta.get("source_file")
|
||||
chunk_idx = matched_meta.get("chunk_index")
|
||||
if not src or not isinstance(chunk_idx, int):
|
||||
return {"text": matched_doc, "drawer_index": chunk_idx, "total_drawers": None}
|
||||
|
||||
target_indexes = [chunk_idx + offset for offset in range(-radius, radius + 1)]
|
||||
try:
|
||||
neighbors = drawers_col.get(
|
||||
where={
|
||||
"$and": [
|
||||
{"source_file": src},
|
||||
{"chunk_index": {"$in": target_indexes}},
|
||||
]
|
||||
},
|
||||
include=["documents", "metadatas"],
|
||||
)
|
||||
except Exception:
|
||||
return {"text": matched_doc, "drawer_index": chunk_idx, "total_drawers": None}
|
||||
|
||||
indexed_docs = []
|
||||
for doc, meta in zip(neighbors.get("documents") or [], neighbors.get("metadatas") or []):
|
||||
ci = meta.get("chunk_index")
|
||||
if isinstance(ci, int):
|
||||
indexed_docs.append((ci, doc))
|
||||
indexed_docs.sort(key=lambda pair: pair[0])
|
||||
|
||||
if not indexed_docs:
|
||||
combined_text = matched_doc
|
||||
else:
|
||||
combined_text = "\n\n".join(doc for _, doc in indexed_docs)
|
||||
|
||||
# Cheap total_drawers lookup: metadata-only scan of the source file.
|
||||
total_drawers = None
|
||||
try:
|
||||
all_meta = drawers_col.get(where={"source_file": src}, include=["metadatas"])
|
||||
ids = all_meta.get("ids") or []
|
||||
total_drawers = len(ids) if ids else None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"text": combined_text,
|
||||
"drawer_index": chunk_idx,
|
||||
"total_drawers": total_drawers,
|
||||
}
|
||||
|
||||
|
||||
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 +268,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 +287,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")
|
||||
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,46 +318,170 @@ 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)
|
||||
|
||||
# Hybrid retrieval: always query drawers directly (the floor), then use
|
||||
# closet hits to boost rankings. Closets are a ranking SIGNAL, never a
|
||||
# GATE — direct drawer search is always the baseline.
|
||||
#
|
||||
# This avoids the "weak-closets regression" where narrative content
|
||||
# produces low-signal closets (regex extraction matches few topics)
|
||||
# and closet-first routing hides drawers that direct search would find.
|
||||
try:
|
||||
kwargs = {
|
||||
dkwargs = {
|
||||
"query_texts": [query],
|
||||
"n_results": n_results,
|
||||
"n_results": n_results * 3, # over-fetch for re-ranking
|
||||
"include": ["documents", "metadatas", "distances"],
|
||||
}
|
||||
if where:
|
||||
kwargs["where"] = where
|
||||
|
||||
results = col.query(**kwargs)
|
||||
dkwargs["where"] = where
|
||||
drawer_results = drawers_col.query(**dkwargs)
|
||||
except Exception as e:
|
||||
return {"error": f"Search error: {e}"}
|
||||
|
||||
docs = results["documents"][0]
|
||||
metas = results["metadatas"][0]
|
||||
dists = results["distances"][0]
|
||||
# Gather closet hits (best-per-source) to build a boost lookup.
|
||||
closet_boost_by_source: dict = {} # source_file -> (rank, closet_dist, preview)
|
||||
try:
|
||||
closets_col = get_closets_collection(palace_path, create=False)
|
||||
ckwargs = {
|
||||
"query_texts": [query],
|
||||
"n_results": n_results * 2,
|
||||
"include": ["documents", "metadatas", "distances"],
|
||||
}
|
||||
if where:
|
||||
ckwargs["where"] = where
|
||||
closet_results = closets_col.query(**ckwargs)
|
||||
for rank, (cdoc, cmeta, cdist) in enumerate(
|
||||
zip(
|
||||
closet_results["documents"][0],
|
||||
closet_results["metadatas"][0],
|
||||
closet_results["distances"][0],
|
||||
)
|
||||
):
|
||||
source = cmeta.get("source_file", "")
|
||||
if source and source not in closet_boost_by_source:
|
||||
closet_boost_by_source[source] = (rank, cdist, cdoc[:200])
|
||||
except Exception:
|
||||
pass # no closets yet — hybrid degrades to pure drawer search
|
||||
|
||||
hits = []
|
||||
for doc, meta, dist in zip(docs, metas, dists):
|
||||
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),
|
||||
}
|
||||
)
|
||||
# Rank-based boost. The ordinal signal ("which closet matched best") is
|
||||
# more reliable than absolute distance on narrative content, where
|
||||
# closet distances cluster in 1.2-1.5 range regardless of match quality.
|
||||
CLOSET_RANK_BOOSTS = [0.40, 0.25, 0.15, 0.08, 0.04]
|
||||
CLOSET_DISTANCE_CAP = 1.5 # cosine dist > 1.5 = too weak to use as signal
|
||||
|
||||
scored: list = []
|
||||
for doc, meta, dist in zip(
|
||||
drawer_results["documents"][0],
|
||||
drawer_results["metadatas"][0],
|
||||
drawer_results["distances"][0],
|
||||
):
|
||||
# Filter on raw distance before rounding to avoid precision loss.
|
||||
if max_distance > 0.0 and dist > max_distance:
|
||||
continue
|
||||
|
||||
source = meta.get("source_file", "") or ""
|
||||
boost = 0.0
|
||||
matched_via = "drawer"
|
||||
closet_preview = None
|
||||
if source in closet_boost_by_source:
|
||||
c_rank, c_dist, c_preview = closet_boost_by_source[source]
|
||||
if c_dist <= CLOSET_DISTANCE_CAP and c_rank < len(CLOSET_RANK_BOOSTS):
|
||||
boost = CLOSET_RANK_BOOSTS[c_rank]
|
||||
matched_via = "drawer+closet"
|
||||
closet_preview = c_preview
|
||||
|
||||
effective_dist = dist - boost
|
||||
entry = {
|
||||
"text": doc,
|
||||
"wing": meta.get("wing", "unknown"),
|
||||
"room": meta.get("room", "unknown"),
|
||||
"source_file": Path(source).name if source else "?",
|
||||
"similarity": round(max(0.0, 1 - effective_dist), 3),
|
||||
"distance": round(dist, 4),
|
||||
"effective_distance": round(effective_dist, 4),
|
||||
"closet_boost": round(boost, 3),
|
||||
"matched_via": matched_via,
|
||||
# Internal: retain the full source_file path + chunk_index so the
|
||||
# enrichment step below doesn't have to reverse-lookup via
|
||||
# basename-suffix matching (which silently collides when two
|
||||
# files share a basename across different directories).
|
||||
"_sort_key": effective_dist,
|
||||
"_source_file_full": source,
|
||||
"_chunk_index": meta.get("chunk_index"),
|
||||
}
|
||||
if closet_preview:
|
||||
entry["closet_preview"] = closet_preview
|
||||
scored.append(entry)
|
||||
|
||||
scored.sort(key=lambda h: h["_sort_key"])
|
||||
hits = scored[:n_results]
|
||||
|
||||
# Drawer-grep enrichment: for closet-boosted hits whose source has
|
||||
# multiple drawers, return the keyword-best chunk + its immediate
|
||||
# neighbors instead of just the drawer vector search landed on. The
|
||||
# closet said "this source is relevant"; vector may have picked the
|
||||
# wrong chunk within it; grep picks the right one.
|
||||
MAX_HYDRATION_CHARS = 10000
|
||||
for h in hits:
|
||||
if h["matched_via"] == "drawer":
|
||||
continue
|
||||
full_source = h.get("_source_file_full") or ""
|
||||
if not full_source:
|
||||
continue
|
||||
try:
|
||||
source_drawers = drawers_col.get(
|
||||
where={"source_file": full_source},
|
||||
include=["documents", "metadatas"],
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
docs = source_drawers.get("documents") or []
|
||||
metas_ = source_drawers.get("metadatas") or []
|
||||
if len(docs) <= 1:
|
||||
continue
|
||||
|
||||
# Sort by chunk_index so best_idx + neighbors are positional.
|
||||
indexed = []
|
||||
for idx, (d, m) in enumerate(zip(docs, metas_)):
|
||||
ci = m.get("chunk_index", idx) if isinstance(m, dict) else idx
|
||||
if not isinstance(ci, int):
|
||||
ci = idx
|
||||
indexed.append((ci, d))
|
||||
indexed.sort(key=lambda p: p[0])
|
||||
ordered_docs = [d for _, d in indexed]
|
||||
|
||||
query_terms = set(_tokenize(query))
|
||||
best_idx, best_score = 0, -1
|
||||
for idx, d in enumerate(ordered_docs):
|
||||
d_lower = d.lower()
|
||||
s = sum(1 for t in query_terms if t in d_lower)
|
||||
if s > best_score:
|
||||
best_score, best_idx = s, idx
|
||||
|
||||
start = max(0, best_idx - 1)
|
||||
end = min(len(ordered_docs), best_idx + 2)
|
||||
expanded = "\n\n".join(ordered_docs[start:end])
|
||||
if len(expanded) > MAX_HYDRATION_CHARS:
|
||||
expanded = (
|
||||
expanded[:MAX_HYDRATION_CHARS]
|
||||
+ f"\n\n[...truncated. {len(ordered_docs)} total drawers. "
|
||||
"Use mempalace_get_drawer for full content.]"
|
||||
)
|
||||
h["text"] = expanded
|
||||
h["drawer_index"] = best_idx
|
||||
h["total_drawers"] = len(ordered_docs)
|
||||
|
||||
# BM25 hybrid re-rank within the final candidate set.
|
||||
hits = _hybrid_rank(hits, query)
|
||||
for h in hits:
|
||||
h.pop("_sort_key", None)
|
||||
h.pop("_source_file_full", None)
|
||||
h.pop("_chunk_index", None)
|
||||
|
||||
return {
|
||||
"query": query,
|
||||
"filters": {"wing": wing, "room": room},
|
||||
"total_before_filter": len(drawer_results["documents"][0]),
|
||||
"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.3.0"
|
||||
|
||||
+8
-4
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mempalace"
|
||||
version = "3.1.0"
|
||||
version = "3.3.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"
|
||||
@@ -25,7 +25,7 @@ classifiers = [
|
||||
"Topic :: Utilities",
|
||||
]
|
||||
dependencies = [
|
||||
"chromadb>=0.5.0,<0.7",
|
||||
"chromadb>=0.5.0",
|
||||
"pyyaml>=6.0,<7",
|
||||
]
|
||||
|
||||
@@ -35,7 +35,7 @@ Repository = "https://github.com/milla-jovovich/mempalace"
|
||||
"Bug Tracker" = "https://github.com/milla-jovovich/mempalace/issues"
|
||||
|
||||
[project.scripts]
|
||||
mempalace = "mempalace:main"
|
||||
mempalace = "mempalace.cli:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=7.0", "pytest-cov>=4.0", "ruff>=0.4.0", "psutil>=5.9"]
|
||||
@@ -54,11 +54,15 @@ packages = ["mempalace"]
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py39"
|
||||
extend-exclude = ["benchmarks"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W"]
|
||||
select = ["E", "F", "W", "C901"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.ruff.lint.mccabe]
|
||||
max-complexity = 25
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
|
||||
|
||||
+4
-2
@@ -101,7 +101,7 @@ def config(tmp_dir, palace_path):
|
||||
def collection(palace_path):
|
||||
"""A ChromaDB collection pre-seeded in the temp palace."""
|
||||
client = chromadb.PersistentClient(path=palace_path)
|
||||
col = client.get_or_create_collection("mempalace_drawers")
|
||||
col = client.get_or_create_collection("mempalace_drawers", metadata={"hnsw:space": "cosine"})
|
||||
yield col
|
||||
client.delete_collection("mempalace_drawers")
|
||||
del client
|
||||
@@ -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,142 @@
|
||||
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_chroma_backend_creates_collection_with_cosine_distance(tmp_path):
|
||||
palace_path = tmp_path / "palace"
|
||||
|
||||
ChromaBackend().get_collection(
|
||||
str(palace_path),
|
||||
collection_name="mempalace_drawers",
|
||||
create=True,
|
||||
)
|
||||
|
||||
client = chromadb.PersistentClient(path=str(palace_path))
|
||||
col = client.get_collection("mempalace_drawers")
|
||||
assert col.metadata.get("hnsw:space") == "cosine"
|
||||
|
||||
|
||||
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
|
||||
+82
-9
@@ -2,6 +2,7 @@
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -326,6 +327,35 @@ def test_main_split_dispatches():
|
||||
mock_cmd.assert_called_once()
|
||||
|
||||
|
||||
def test_mcp_command_prints_setup_guidance(monkeypatch, capsys):
|
||||
monkeypatch.setattr(sys, "argv", ["mempalace", "mcp"])
|
||||
|
||||
main()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "MemPalace MCP quick setup:" in captured.out
|
||||
assert "claude mcp add mempalace -- python -m mempalace.mcp_server" in captured.out
|
||||
assert "\nOptional custom palace:\n" in captured.out
|
||||
assert "python -m mempalace.mcp_server --palace /path/to/palace" in captured.out
|
||||
assert "[--palace /path/to/palace]" not in captured.out
|
||||
assert captured.err == ""
|
||||
|
||||
|
||||
def test_mcp_command_uses_custom_palace_path_when_provided(monkeypatch, capsys):
|
||||
monkeypatch.setattr(sys, "argv", ["mempalace", "--palace", "~/tmp/my palace", "mcp"])
|
||||
|
||||
main()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
expanded = str(Path("~/tmp/my palace").expanduser())
|
||||
|
||||
assert "python -m mempalace.mcp_server --palace" in captured.out
|
||||
assert expanded in captured.out
|
||||
assert "Optional custom palace:" not in captured.out
|
||||
assert "[--palace /path/to/palace]" not in captured.out
|
||||
assert captured.err == ""
|
||||
|
||||
|
||||
def test_main_hook_no_subcommand_prints_help(capsys):
|
||||
with patch("sys.argv", ["mempalace", "hook"]):
|
||||
main()
|
||||
@@ -393,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()
|
||||
@@ -413,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()
|
||||
@@ -431,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
|
||||
@@ -453,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 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -516,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)
|
||||
|
||||
@@ -534,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")
|
||||
@@ -589,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)
|
||||
|
||||
@@ -606,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()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
"""Unit tests for the optional LLM-based closet regeneration.
|
||||
|
||||
These tests don't hit the network. They mock urllib to verify:
|
||||
- LLMConfig correctly reads env vars and CLI overrides
|
||||
- missing config is reported cleanly
|
||||
- the OpenAI-compatible request shape is correct
|
||||
- response parsing handles the standard chat-completions payload
|
||||
"""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
|
||||
from mempalace.closet_llm import (
|
||||
LLMConfig,
|
||||
_call_llm,
|
||||
_parsed_to_closet_lines,
|
||||
regenerate_closets,
|
||||
)
|
||||
|
||||
|
||||
# ── LLMConfig ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestLLMConfig:
|
||||
def test_reads_env_vars(self, monkeypatch):
|
||||
monkeypatch.setenv("LLM_ENDPOINT", "http://localhost:11434/v1")
|
||||
monkeypatch.setenv("LLM_KEY", "sk-abc")
|
||||
monkeypatch.setenv("LLM_MODEL", "llama3:8b")
|
||||
c = LLMConfig()
|
||||
assert c.endpoint == "http://localhost:11434/v1"
|
||||
assert c.key == "sk-abc"
|
||||
assert c.model == "llama3:8b"
|
||||
|
||||
def test_cli_flags_override_env(self, monkeypatch):
|
||||
monkeypatch.setenv("LLM_ENDPOINT", "http://env-endpoint/v1")
|
||||
monkeypatch.setenv("LLM_MODEL", "env-model")
|
||||
c = LLMConfig(endpoint="http://flag-endpoint/v1", model="flag-model")
|
||||
assert c.endpoint == "http://flag-endpoint/v1"
|
||||
assert c.model == "flag-model"
|
||||
|
||||
def test_trailing_slash_stripped(self):
|
||||
c = LLMConfig(endpoint="http://foo/v1/", model="m")
|
||||
assert c.endpoint == "http://foo/v1"
|
||||
|
||||
def test_missing_reports_required(self, monkeypatch):
|
||||
monkeypatch.delenv("LLM_ENDPOINT", raising=False)
|
||||
monkeypatch.delenv("LLM_KEY", raising=False)
|
||||
monkeypatch.delenv("LLM_MODEL", raising=False)
|
||||
c = LLMConfig()
|
||||
missing = c.missing()
|
||||
assert any("ENDPOINT" in m for m in missing)
|
||||
assert any("MODEL" in m for m in missing)
|
||||
# key is optional
|
||||
assert not any("KEY" in m for m in missing)
|
||||
|
||||
def test_key_is_optional(self, monkeypatch):
|
||||
monkeypatch.delenv("LLM_KEY", raising=False)
|
||||
c = LLMConfig(endpoint="http://local/v1", model="m")
|
||||
assert c.missing() == []
|
||||
|
||||
|
||||
# ── _parsed_to_closet_lines ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestParsedToLines:
|
||||
def test_topics_become_pointers(self):
|
||||
parsed = {"topics": ["authentication", "jwt tokens"], "quotes": [], "summary": ""}
|
||||
lines = _parsed_to_closet_lines(parsed, ["d1", "d2"], "Alice;Bob")
|
||||
assert len(lines) == 2
|
||||
assert "authentication|Alice;Bob|→d1,d2" in lines
|
||||
assert "jwt tokens|Alice;Bob|→d1,d2" in lines
|
||||
|
||||
def test_quotes_and_summary_included(self):
|
||||
parsed = {
|
||||
"topics": ["t1"],
|
||||
"quotes": ["[Igor] we ship Friday"],
|
||||
"summary": "Release planning discussion",
|
||||
}
|
||||
lines = _parsed_to_closet_lines(parsed, ["d1"], "")
|
||||
joined = "\n".join(lines)
|
||||
assert "we ship Friday" in joined
|
||||
assert "Release planning discussion" in joined
|
||||
|
||||
def test_caps_topics_at_15(self):
|
||||
parsed = {"topics": [f"t{i}" for i in range(20)], "quotes": [], "summary": ""}
|
||||
lines = _parsed_to_closet_lines(parsed, ["d1"], "")
|
||||
assert len(lines) == 15
|
||||
|
||||
|
||||
# ── _call_llm (HTTP mocked) ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
"""Mimics urlopen's context-manager response."""
|
||||
|
||||
def __init__(self, payload: dict, status: int = 200):
|
||||
self._body = json.dumps(payload).encode("utf-8")
|
||||
self.status = status
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return self._body
|
||||
|
||||
|
||||
class TestCallLLM:
|
||||
def _make_cfg(self):
|
||||
return LLMConfig(endpoint="http://localhost:11434/v1", key="sk-test", model="llama3:8b")
|
||||
|
||||
def test_request_shape_and_parsing(self):
|
||||
cfg = self._make_cfg()
|
||||
captured = {}
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
captured["url"] = req.full_url
|
||||
captured["headers"] = dict(req.header_items())
|
||||
captured["body"] = json.loads(req.data.decode("utf-8"))
|
||||
return _FakeResp(
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": json.dumps(
|
||||
{
|
||||
"topics": ["postgres"],
|
||||
"quotes": ["[Igor] migrate now"],
|
||||
"summary": "db migration",
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 42, "completion_tokens": 17},
|
||||
}
|
||||
)
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
|
||||
parsed, usage = _call_llm(cfg, "/tmp/test.md", "w", "r", "content body")
|
||||
|
||||
assert parsed["topics"] == ["postgres"]
|
||||
assert usage["prompt_tokens"] == 42
|
||||
assert captured["url"] == "http://localhost:11434/v1/chat/completions"
|
||||
# Authorization header is stored capitalized-then-lowercase depending on urllib version
|
||||
auth_vals = {v for k, v in captured["headers"].items() if k.lower() == "authorization"}
|
||||
assert "Bearer sk-test" in auth_vals
|
||||
assert captured["body"]["model"] == "llama3:8b"
|
||||
assert captured["body"]["messages"][0]["role"] == "user"
|
||||
|
||||
def test_omits_auth_header_when_no_key(self):
|
||||
cfg = LLMConfig(endpoint="http://localhost:11434/v1", model="llama3:8b")
|
||||
captured_headers = {}
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
captured_headers.update({k.lower(): v for k, v in req.header_items()})
|
||||
return _FakeResp(
|
||||
{
|
||||
"choices": [{"message": {"content": '{"topics":[],"quotes":[],"summary":""}'}}],
|
||||
"usage": {"prompt_tokens": 0, "completion_tokens": 0},
|
||||
}
|
||||
)
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
|
||||
_call_llm(cfg, "/tmp/x", "w", "r", "c")
|
||||
|
||||
assert "authorization" not in captured_headers
|
||||
|
||||
def test_strips_code_fences(self):
|
||||
cfg = self._make_cfg()
|
||||
fenced = '```json\n{"topics":["t1"],"quotes":[],"summary":""}\n```'
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
return _FakeResp(
|
||||
{
|
||||
"choices": [{"message": {"content": fenced}}],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1},
|
||||
}
|
||||
)
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
|
||||
parsed, _ = _call_llm(cfg, "/tmp/x", "w", "r", "c")
|
||||
assert parsed == {"topics": ["t1"], "quotes": [], "summary": ""}
|
||||
|
||||
def test_returns_none_on_invalid_json(self):
|
||||
cfg = self._make_cfg()
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
return _FakeResp(
|
||||
{
|
||||
"choices": [{"message": {"content": "not json at all"}}],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1},
|
||||
}
|
||||
)
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
|
||||
parsed, usage = _call_llm(cfg, "/tmp/x", "w", "r", "c")
|
||||
assert parsed is None
|
||||
|
||||
|
||||
# ── regenerate_closets error paths ───────────────────────────────────────
|
||||
|
||||
|
||||
class TestRegenerateClosets:
|
||||
def test_missing_config_returns_error(self, monkeypatch):
|
||||
monkeypatch.delenv("LLM_ENDPOINT", raising=False)
|
||||
monkeypatch.delenv("LLM_MODEL", raising=False)
|
||||
with tempfile.TemporaryDirectory() as palace:
|
||||
result = regenerate_closets(palace)
|
||||
assert result["error"] == "missing-config"
|
||||
assert any("ENDPOINT" in m for m in result["missing"])
|
||||
|
||||
def test_regen_purges_regex_closets_and_stamps_normalize_version(self, tmp_path):
|
||||
"""Regression: before the hardening, regex closets for the same
|
||||
source survived alongside fresh LLM closets (the old path used a
|
||||
bare ``closets_col.delete(ids=...)`` with a swallowed exception).
|
||||
Now we go through ``purge_file_closets`` + ``mine_lock`` + stamp
|
||||
``NORMALIZE_VERSION`` so the next mine's stale-version gate doesn't
|
||||
treat the LLM closets as leftovers to rebuild over."""
|
||||
from mempalace.palace import (
|
||||
NORMALIZE_VERSION,
|
||||
get_closets_collection,
|
||||
get_collection,
|
||||
upsert_closet_lines,
|
||||
)
|
||||
|
||||
palace = str(tmp_path / "palace")
|
||||
# Seed one drawer and a pre-existing regex closet for the same source.
|
||||
source = "/proj/story.md"
|
||||
drawers = get_collection(palace, create=True)
|
||||
drawers.upsert(
|
||||
ids=["drawer_01"],
|
||||
documents=["Content about JWT authentication."],
|
||||
metadatas=[
|
||||
{
|
||||
"wing": "project",
|
||||
"room": "auth",
|
||||
"source_file": source,
|
||||
"entities": "",
|
||||
}
|
||||
],
|
||||
)
|
||||
closets = get_closets_collection(palace)
|
||||
upsert_closet_lines(
|
||||
closets,
|
||||
closet_id_base="closet_old_regex",
|
||||
lines=["STALE_REGEX_TOPIC|;|→drawer_01"],
|
||||
metadata={
|
||||
"wing": "project",
|
||||
"room": "auth",
|
||||
"source_file": source,
|
||||
"generated_by": "regex",
|
||||
},
|
||||
)
|
||||
|
||||
cfg = LLMConfig(endpoint="http://local/v1", model="llama3:8b")
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
return _FakeResp(
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": json.dumps(
|
||||
{
|
||||
"topics": ["jwt auth", "session expiry"],
|
||||
"quotes": [],
|
||||
"summary": "auth refactor",
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5},
|
||||
}
|
||||
)
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
|
||||
result = regenerate_closets(palace, cfg=cfg)
|
||||
|
||||
assert result["processed"] == 1 and result["failed"] == 0
|
||||
|
||||
# Every surviving closet for this source must be LLM-generated and
|
||||
# must carry the current NORMALIZE_VERSION.
|
||||
survivors = closets.get(where={"source_file": source}, include=["documents", "metadatas"])
|
||||
assert survivors["ids"], "LLM closets should have been written"
|
||||
joined = "\n".join(survivors["documents"])
|
||||
assert (
|
||||
"STALE_REGEX_TOPIC" not in joined
|
||||
), "pre-existing regex closet was not purged before LLM write"
|
||||
assert "jwt auth" in joined
|
||||
for meta in survivors["metadatas"]:
|
||||
assert meta.get("generated_by", "").startswith("llm:")
|
||||
assert meta.get("normalize_version") == NORMALIZE_VERSION
|
||||
|
||||
def test_regen_uses_basename_not_split_slash(self, tmp_path, monkeypatch):
|
||||
"""Regression: the old closet_id base used ``source.split('/')[-1]``
|
||||
which silently degrades on Windows paths (``C:\\proj\\a.md`` →
|
||||
the whole string). ``os.path.basename`` handles both separators."""
|
||||
from mempalace.palace import get_collection, get_closets_collection
|
||||
|
||||
palace = str(tmp_path / "palace")
|
||||
# Use a path whose basename differs between '/' split and
|
||||
# os.path.basename only on a platform-aware function, but verify
|
||||
# at minimum that IDs encode just the filename, not the full path.
|
||||
source = "/deep/nested/project/dir/mydoc.md"
|
||||
drawers = get_collection(palace, create=True)
|
||||
drawers.upsert(
|
||||
ids=["d1"],
|
||||
documents=["body"],
|
||||
metadatas=[{"wing": "w", "room": "r", "source_file": source, "entities": ""}],
|
||||
)
|
||||
|
||||
cfg = LLMConfig(endpoint="http://local/v1", model="m")
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
return _FakeResp(
|
||||
{
|
||||
"choices": [
|
||||
{"message": {"content": '{"topics":["t1"],"quotes":[],"summary":""}'}}
|
||||
],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1},
|
||||
}
|
||||
)
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
|
||||
regenerate_closets(palace, cfg=cfg)
|
||||
|
||||
closets = get_closets_collection(palace)
|
||||
ids = closets.get(where={"source_file": source}).get("ids", [])
|
||||
assert ids
|
||||
# IDs must not leak the full path (would happen if we used
|
||||
# source.split('/')[-1] on Windows, or forgot to strip entirely).
|
||||
for cid in ids:
|
||||
assert "/" not in cid
|
||||
assert "mydoc.md" in cid
|
||||
@@ -0,0 +1,974 @@
|
||||
"""
|
||||
test_closets.py — Tests for the closet (searchable index) layer and the
|
||||
features that ride on top of it: mine_lock serialization, entity metadata,
|
||||
hybrid BM25+vector search, and diary ingest.
|
||||
|
||||
Coverage map:
|
||||
* mine_lock — acquire/release, blocks concurrent acquisition.
|
||||
* build_closet_lines — pointer-line shape, header pickup, entity stoplist
|
||||
(regression for "When/After/The"), real-name survival, fallback line.
|
||||
* upsert_closet_lines — pure overwrite (regression for the append bug),
|
||||
char-limit packing without splitting a line.
|
||||
* purge_file_closets — scoped to source_file.
|
||||
* Project-miner end-to-end rebuild — re-mining with fewer topics fully
|
||||
purges leftover numbered closets from a larger prior run.
|
||||
* _extract_drawer_ids_from_closet — pointer parsing + dedup.
|
||||
* search_memories hybrid path — drawer query always the floor,
|
||||
closets boost matching source_file, matched_via reflects both signals,
|
||||
no whole-file glue, max_distance enforcement.
|
||||
* Entity metadata — extracted, stoplist applied, registry cached by mtime.
|
||||
* Real BM25 — real IDF over candidate corpus, hybrid rerank.
|
||||
* Diary ingest — drawers + closets created, incremental skips, state
|
||||
file lives outside the diary dir, wing-prefixed drawer IDs prevent
|
||||
cross-diary collisions, force=True purges leftover closets.
|
||||
"""
|
||||
|
||||
import json
|
||||
import multiprocessing
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
import yaml
|
||||
|
||||
from mempalace.miner import (
|
||||
_extract_entities_for_metadata,
|
||||
_load_known_entities,
|
||||
mine,
|
||||
)
|
||||
from mempalace.palace import (
|
||||
CLOSET_CHAR_LIMIT,
|
||||
build_closet_lines,
|
||||
get_closets_collection,
|
||||
get_collection,
|
||||
mine_lock,
|
||||
purge_file_closets,
|
||||
upsert_closet_lines,
|
||||
)
|
||||
from mempalace.palace_graph import (
|
||||
create_tunnel,
|
||||
delete_tunnel,
|
||||
follow_tunnels,
|
||||
list_tunnels,
|
||||
)
|
||||
from mempalace.searcher import (
|
||||
_bm25_scores,
|
||||
_expand_with_neighbors,
|
||||
_extract_drawer_ids_from_closet,
|
||||
_hybrid_rank,
|
||||
search_memories,
|
||||
)
|
||||
|
||||
|
||||
# ── mine_lock ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _lock_worker(target: str, name: str, hold_seconds: float, log_path: str) -> None:
|
||||
"""Worker for multiprocessing-spawn concurrency test. Writes its
|
||||
critical-section enter/exit timestamps to ``log_path`` so the test
|
||||
can verify the sections did not overlap in time."""
|
||||
import time as _time
|
||||
|
||||
from mempalace.palace import mine_lock as _mine_lock
|
||||
|
||||
with _mine_lock(target):
|
||||
t_enter = _time.time()
|
||||
_time.sleep(hold_seconds)
|
||||
t_exit = _time.time()
|
||||
# Append atomically so concurrent writers don't stomp each other.
|
||||
with open(log_path, "a") as f:
|
||||
f.write(f"{name} {t_enter} {t_exit}\n")
|
||||
f.flush()
|
||||
|
||||
|
||||
class TestMineLock:
|
||||
def test_lock_acquires_and_releases(self, tmp_path):
|
||||
target = str(tmp_path / "lock_target.txt")
|
||||
with mine_lock(target):
|
||||
lock_dir = os.path.expanduser("~/.mempalace/locks")
|
||||
assert os.path.isdir(lock_dir)
|
||||
# Re-acquire after release should succeed instantly.
|
||||
start = time.time()
|
||||
with mine_lock(target):
|
||||
pass
|
||||
assert time.time() - start < 1.0
|
||||
|
||||
def test_lock_blocks_concurrent_access(self, tmp_path):
|
||||
"""The lock's contract is inter-*process* (multi-agent), not
|
||||
inter-thread. Use multiprocessing so the test reflects the real
|
||||
use case and is portable: on macOS/BSD ``fcntl.flock`` is
|
||||
per-process, so two threads would both acquire — a thread-based
|
||||
test would flake there even when the lock is correct.
|
||||
|
||||
Verify mutual exclusion by the effect the critical section
|
||||
actually has — each worker records its enter/exit timestamps
|
||||
under the lock, and the test asserts the two intervals do not
|
||||
overlap. This is robust to spawn-overhead timing, unlike
|
||||
"second worker waited at least N seconds" which flakes when CI
|
||||
spawn latency eats into the hold window.
|
||||
"""
|
||||
target = str(tmp_path / "concurrent_lock.txt")
|
||||
log_path = str(tmp_path / "critical_section.log")
|
||||
# Spawn so the same code path runs on every OS (macOS 3.8+ and
|
||||
# Windows already default to spawn; Linux is fork by default).
|
||||
ctx = multiprocessing.get_context("spawn")
|
||||
|
||||
# Each worker holds the lock for HOLD seconds. With real mutual
|
||||
# exclusion, the two [enter, exit] intervals must be disjoint.
|
||||
HOLD = 0.3
|
||||
p1 = ctx.Process(target=_lock_worker, args=(target, "a", HOLD, log_path))
|
||||
p2 = ctx.Process(target=_lock_worker, args=(target, "b", HOLD, log_path))
|
||||
p1.start()
|
||||
p2.start()
|
||||
p1.join(timeout=30)
|
||||
p2.join(timeout=30)
|
||||
|
||||
assert p1.exitcode == 0, f"p1 exited non-zero: {p1.exitcode}"
|
||||
assert p2.exitcode == 0, f"p2 exited non-zero: {p2.exitcode}"
|
||||
|
||||
# Parse the log: "<name> <enter_ts> <exit_ts>".
|
||||
intervals = []
|
||||
with open(log_path) as f:
|
||||
for line in f:
|
||||
parts = line.strip().split()
|
||||
if len(parts) == 3:
|
||||
intervals.append((parts[0], float(parts[1]), float(parts[2])))
|
||||
assert len(intervals) == 2, f"expected two critical sections, got {intervals}"
|
||||
|
||||
# Sort by entry time and verify the second entry is after the first exit.
|
||||
intervals.sort(key=lambda iv: iv[1])
|
||||
(_, enter_a, exit_a), (_, enter_b, exit_b) = intervals
|
||||
assert (
|
||||
enter_a < exit_a <= enter_b < exit_b
|
||||
), f"critical sections overlapped — lock failed to serialize: {intervals}"
|
||||
|
||||
|
||||
# ── build_closet_lines ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildClosetLines:
|
||||
def test_emits_pointer_line_shape(self):
|
||||
content = (
|
||||
"# Auth rewrite\n\n"
|
||||
"Decided we need to migrate to passkeys. "
|
||||
"Built the prototype with WebAuthn. "
|
||||
"Reviewed the API surface."
|
||||
)
|
||||
lines = build_closet_lines(
|
||||
"/proj/auth.md",
|
||||
["drawer_proj_backend_aaa", "drawer_proj_backend_bbb"],
|
||||
content,
|
||||
wing="proj",
|
||||
room="backend",
|
||||
)
|
||||
assert lines, "should always emit at least one line"
|
||||
for line in lines:
|
||||
assert "→" in line, f"line missing pointer arrow: {line!r}"
|
||||
parts = line.split("|")
|
||||
assert len(parts) == 3, f"expected topic|entities|→refs, got {line!r}"
|
||||
assert parts[2].startswith("→")
|
||||
|
||||
def test_extracts_section_headers_as_topics(self):
|
||||
content = "# First Header\nbody\n## Second Header\nmore body"
|
||||
lines = build_closet_lines("/x.md", ["d1"], content, "w", "r")
|
||||
joined = "\n".join(lines).lower()
|
||||
assert "first header" in joined
|
||||
assert "second header" in joined
|
||||
|
||||
def test_entity_stoplist_filters_sentence_starters(self):
|
||||
# "When", "After", "The" repeat 3+ times — old code would index them
|
||||
# as entities. Stoplist drops them.
|
||||
content = (
|
||||
"When the pipeline ran, the result was good. "
|
||||
"When the user logged in, the token was issued. "
|
||||
"After the migration, the latency dropped. "
|
||||
"After the rollback, the latency rose. "
|
||||
"The new flow is stable. The audit cleared."
|
||||
)
|
||||
lines = build_closet_lines("/x.md", ["d1"], content, "w", "r")
|
||||
entity_segments = [line.split("|")[1] for line in lines]
|
||||
for seg in entity_segments:
|
||||
tokens = set(seg.split(";")) if seg else set()
|
||||
assert "When" not in tokens
|
||||
assert "After" not in tokens
|
||||
assert "The" not in tokens
|
||||
|
||||
def test_real_proper_nouns_survive_stoplist(self):
|
||||
content = (
|
||||
"Igor reviewed the diff. Milla wrote the spec. "
|
||||
"Igor pushed the fix. Milla approved the PR. "
|
||||
"Igor and Milla shipped together."
|
||||
)
|
||||
lines = build_closet_lines("/x.md", ["d1"], content, "w", "r")
|
||||
joined_entities = ";".join(line.split("|")[1] for line in lines)
|
||||
assert "Igor" in joined_entities
|
||||
assert "Milla" in joined_entities
|
||||
|
||||
def test_emits_fallback_line_when_nothing_extractable(self):
|
||||
content = "lorem ipsum dolor sit amet consectetur adipiscing elit"
|
||||
lines = build_closet_lines("/x/notes.txt", ["d1"], content, "wing", "room")
|
||||
assert len(lines) == 1
|
||||
assert "wing/room/notes" in lines[0]
|
||||
assert "→d1" in lines[0]
|
||||
|
||||
def test_pointer_references_first_three_drawers(self):
|
||||
ids = [f"drawer_{i}" for i in range(10)]
|
||||
lines = build_closet_lines("/x.md", ids, "# A\n# B", "w", "r")
|
||||
assert all("→drawer_0,drawer_1,drawer_2" in line for line in lines)
|
||||
|
||||
|
||||
# ── upsert_closet_lines ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestUpsertClosetLines:
|
||||
def test_overwrites_existing_closet_does_not_append(self, palace_path):
|
||||
col = get_closets_collection(palace_path)
|
||||
base = "closet_test_room_abc"
|
||||
meta = {"wing": "test", "room": "room", "source_file": "/x.md"}
|
||||
|
||||
upsert_closet_lines(col, base, ["alpha|;|→d1", "beta|;|→d2", "gamma|;|→d3"], meta)
|
||||
first = col.get(ids=[f"{base}_01"])
|
||||
assert "alpha" in first["documents"][0]
|
||||
|
||||
# Second mine — entirely different lines. Must replace, not append.
|
||||
upsert_closet_lines(col, base, ["delta|;|→d4", "epsilon|;|→d5"], meta)
|
||||
second = col.get(ids=[f"{base}_01"])
|
||||
doc = second["documents"][0]
|
||||
assert "delta" in doc
|
||||
assert "epsilon" in doc
|
||||
assert "alpha" not in doc, "old closet line leaked into rebuild"
|
||||
assert "beta" not in doc
|
||||
|
||||
def test_packs_into_multiple_closets_without_splitting_lines(self, palace_path):
|
||||
col = get_closets_collection(palace_path)
|
||||
base = "closet_pack_room_def"
|
||||
meta = {"wing": "test", "room": "room", "source_file": "/y.md"}
|
||||
|
||||
line = "x" * 600 # well under CLOSET_CHAR_LIMIT
|
||||
n_written = upsert_closet_lines(col, base, [line, line, line, line], meta)
|
||||
# 4 lines @ 601 chars each = 2404 — should pack into 2 closets
|
||||
assert n_written == 2
|
||||
|
||||
for i in range(1, n_written + 1):
|
||||
doc = col.get(ids=[f"{base}_{i:02d}"])["documents"][0]
|
||||
for chunk in doc.split("\n"):
|
||||
assert len(chunk) == 600, f"line was truncated in closet {i}"
|
||||
assert len(doc) <= CLOSET_CHAR_LIMIT
|
||||
|
||||
|
||||
# ── purge_file_closets ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPurgeFileClosets:
|
||||
def test_deletes_only_the_targeted_source(self, palace_path):
|
||||
col = get_closets_collection(palace_path)
|
||||
col.upsert(
|
||||
ids=["closet_a_01", "closet_b_01"],
|
||||
documents=["a|;|→d1", "b|;|→d2"],
|
||||
metadatas=[
|
||||
{"source_file": "/keep.md", "wing": "w", "room": "r"},
|
||||
{"source_file": "/drop.md", "wing": "w", "room": "r"},
|
||||
],
|
||||
)
|
||||
purge_file_closets(col, "/drop.md")
|
||||
remaining_ids = set(col.get()["ids"])
|
||||
assert "closet_a_01" in remaining_ids
|
||||
assert "closet_b_01" not in remaining_ids
|
||||
|
||||
|
||||
# ── project miner: closet rebuild end-to-end ──────────────────────────
|
||||
|
||||
|
||||
class TestMinerClosetRebuild:
|
||||
def test_remine_replaces_closets_completely(self, tmp_path):
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
(project / "mempalace.yaml").write_text(
|
||||
yaml.dump({"wing": "proj", "rooms": [{"name": "general", "description": "x"}]})
|
||||
)
|
||||
target = project / "doc.md"
|
||||
|
||||
# First mine — long content produces multiple numbered closets.
|
||||
first_topics = "\n\n".join(f"# Topic {i}\n" + ("filler text " * 30) for i in range(15))
|
||||
target.write_text(first_topics)
|
||||
palace = tmp_path / "palace"
|
||||
mine(str(project), str(palace), wing_override="proj", agent="test")
|
||||
|
||||
col = get_closets_collection(str(palace))
|
||||
first_pass = col.get(where={"source_file": str(target)})
|
||||
assert first_pass["ids"], "first mine should have written closets"
|
||||
first_ids = set(first_pass["ids"])
|
||||
assert any("topic 0" in (d or "").lower() for d in first_pass["documents"])
|
||||
|
||||
# Touch mtime + shrink content so the rebuild produces fewer closets.
|
||||
target.write_text("# Only Topic Now\n" + ("short body " * 5))
|
||||
new_mtime = os.path.getmtime(target) + 60
|
||||
os.utime(target, (new_mtime, new_mtime))
|
||||
time.sleep(0.01)
|
||||
|
||||
mine(str(project), str(palace), wing_override="proj", agent="test")
|
||||
|
||||
col = get_closets_collection(str(palace))
|
||||
second_pass = col.get(where={"source_file": str(target)})
|
||||
second_docs = "\n".join(second_pass["documents"]).lower()
|
||||
assert "only topic now" in second_docs
|
||||
for i in range(15):
|
||||
assert (
|
||||
f"topic {i}\n" not in second_docs
|
||||
), f"stale 'Topic {i}' from first mine survived the rebuild"
|
||||
# Numbered closets that existed only in the larger first run must be gone.
|
||||
leftover = first_ids - set(second_pass["ids"])
|
||||
for stale_id in leftover:
|
||||
assert not col.get(ids=[stale_id])[
|
||||
"ids"
|
||||
], f"orphan closet {stale_id} from larger first run survived purge"
|
||||
|
||||
|
||||
# ── _extract_drawer_ids_from_closet ───────────────────────────────────
|
||||
|
||||
|
||||
class TestExtractDrawerIds:
|
||||
def test_parses_single_pointer(self):
|
||||
assert _extract_drawer_ids_from_closet("topic|;|→drawer_x") == ["drawer_x"]
|
||||
|
||||
def test_parses_multiple_pointers_per_line(self):
|
||||
line = "topic|ent|→drawer_a,drawer_b,drawer_c"
|
||||
assert _extract_drawer_ids_from_closet(line) == ["drawer_a", "drawer_b", "drawer_c"]
|
||||
|
||||
def test_dedupes_across_lines(self):
|
||||
doc = "one|;|→drawer_a,drawer_b\ntwo|;|→drawer_b,drawer_c"
|
||||
assert _extract_drawer_ids_from_closet(doc) == ["drawer_a", "drawer_b", "drawer_c"]
|
||||
|
||||
def test_empty_doc_returns_empty(self):
|
||||
assert _extract_drawer_ids_from_closet("") == []
|
||||
assert _extract_drawer_ids_from_closet("no arrows here") == []
|
||||
|
||||
|
||||
# ── search_memories closet-first path ────────────────────────────────
|
||||
|
||||
|
||||
class TestSearchMemoriesHybrid:
|
||||
def test_pure_drawer_when_no_closets(self, palace_path, seeded_collection):
|
||||
"""Palaces without closets return results via direct drawer search —
|
||||
every hit must advertise that the closet signal was absent."""
|
||||
result = search_memories("JWT authentication", palace_path)
|
||||
assert result["results"], "should still find drawer hits"
|
||||
for hit in result["results"]:
|
||||
assert hit.get("matched_via") == "drawer"
|
||||
assert hit.get("closet_boost") == 0.0
|
||||
assert "closet_preview" not in hit
|
||||
|
||||
def test_closet_boost_marks_hit_as_drawer_plus_closet(self, palace_path, seeded_collection):
|
||||
"""When a closet agrees with direct search on source_file, the
|
||||
matching drawer's ``matched_via`` switches to ``drawer+closet`` and
|
||||
``closet_preview`` exposes the hydrated index line."""
|
||||
closets = get_closets_collection(palace_path)
|
||||
# Seed the closet against the same source_file the drawer uses so
|
||||
# the boost lookup keys align.
|
||||
closets.upsert(
|
||||
ids=["closet_proj_backend_aaa_01"],
|
||||
documents=["JWT auth tokens|;|→drawer_proj_backend_aaa"],
|
||||
metadatas=[{"wing": "project", "room": "backend", "source_file": "auth.py"}],
|
||||
)
|
||||
|
||||
result = search_memories("JWT authentication", palace_path)
|
||||
assert result["results"], "hybrid search should still return results"
|
||||
# The JWT-bearing drawer should surface with closet agreement.
|
||||
boosted = [h for h in result["results"] if h["matched_via"] == "drawer+closet"]
|
||||
assert boosted, "closet agreement should promote the matching source"
|
||||
top = boosted[0]
|
||||
assert "JWT" in top["text"]
|
||||
assert top["closet_boost"] > 0
|
||||
assert "→drawer_proj_backend_aaa" in top["closet_preview"]
|
||||
|
||||
def test_max_distance_filters_hybrid_hits(self, palace_path, seeded_collection):
|
||||
closets = get_closets_collection(palace_path)
|
||||
closets.upsert(
|
||||
ids=["closet_proj_backend_aaa_01"],
|
||||
documents=["JWT auth tokens|;|→drawer_proj_backend_aaa"],
|
||||
metadatas=[{"wing": "project", "room": "backend", "source_file": "auth.py"}],
|
||||
)
|
||||
result = search_memories(
|
||||
"completely unrelated query about quantum gardening",
|
||||
palace_path,
|
||||
max_distance=0.001,
|
||||
)
|
||||
for hit in result["results"]:
|
||||
assert hit["distance"] <= 0.001
|
||||
|
||||
|
||||
# ── entity metadata ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEntityMetadata:
|
||||
def test_extracts_capitalized_names(self):
|
||||
text = "Ben reviewed the code. Ben approved it. Igor flagged two issues. Igor fixed them."
|
||||
entities = _extract_entities_for_metadata(text)
|
||||
assert "Ben" in entities
|
||||
assert "Igor" in entities
|
||||
|
||||
def test_empty_for_no_entities(self):
|
||||
text = "this is all lowercase with no proper nouns at all"
|
||||
assert _extract_entities_for_metadata(text) == ""
|
||||
|
||||
def test_semicolon_separated(self):
|
||||
text = "Alice and Bob met Charlie. Alice said hello. Bob agreed. Charlie laughed."
|
||||
entities = _extract_entities_for_metadata(text)
|
||||
assert ";" in entities
|
||||
|
||||
def test_stoplist_filters_sentence_starters(self):
|
||||
# Same regression as the closet entity test — "When/After/The" must
|
||||
# not become entities just because they're capitalized 2+ times.
|
||||
text = (
|
||||
"When the build broke, the team paged. "
|
||||
"When the fix landed, the alarm cleared. "
|
||||
"After the rollback, the queue drained. "
|
||||
"After the deploy, the latency normalized."
|
||||
)
|
||||
entities = _extract_entities_for_metadata(text)
|
||||
tokens = set(entities.split(";")) if entities else set()
|
||||
assert "When" not in tokens
|
||||
assert "After" not in tokens
|
||||
assert "The" not in tokens
|
||||
|
||||
def test_capped_list_never_truncates_a_name(self):
|
||||
# 30 distinct repeated proper nouns — extraction should cap the list
|
||||
# before joining so a name never gets cut in half.
|
||||
# Use morphologically distinct stems so the [A-Z][a-z]+ regex sees
|
||||
# each as its own token.
|
||||
names = [
|
||||
"Anna",
|
||||
"Brian",
|
||||
"Carol",
|
||||
"David",
|
||||
"Elena",
|
||||
"Frank",
|
||||
"Grace",
|
||||
"Harold",
|
||||
"Iris",
|
||||
"Julian",
|
||||
"Kira",
|
||||
"Liam",
|
||||
"Maya",
|
||||
"Noah",
|
||||
"Oscar",
|
||||
"Penny",
|
||||
"Quinn",
|
||||
"Rosa",
|
||||
"Sergei",
|
||||
"Tara",
|
||||
"Umar",
|
||||
"Vera",
|
||||
"Walter",
|
||||
"Xander",
|
||||
"Yvonne",
|
||||
"Zachary",
|
||||
"Amelia",
|
||||
"Boris",
|
||||
"Clara",
|
||||
"Dmitri",
|
||||
]
|
||||
text = " ".join(f"{n} met {n}." for n in names)
|
||||
entities = _extract_entities_for_metadata(text)
|
||||
extracted = [n for n in entities.split(";") if n]
|
||||
assert extracted, "should have extracted some entities"
|
||||
for name in extracted:
|
||||
assert name in names, f"truncation produced a partial token: {name!r}"
|
||||
|
||||
def test_known_registry_is_cached_by_mtime(self, monkeypatch, tmp_path):
|
||||
# Point the registry at a temp file we control, exercise the cache.
|
||||
registry = tmp_path / "known_entities.json"
|
||||
registry.write_text(json.dumps({"people": ["Zelda"]}))
|
||||
from mempalace import miner
|
||||
|
||||
monkeypatch.setattr(miner, "_ENTITY_REGISTRY_PATH", str(registry))
|
||||
miner._ENTITY_REGISTRY_CACHE["mtime"] = None
|
||||
miner._ENTITY_REGISTRY_CACHE["names"] = frozenset()
|
||||
|
||||
first = _load_known_entities()
|
||||
assert "Zelda" in first
|
||||
|
||||
# Second call without changing mtime: must reuse cache, not re-read.
|
||||
read_count = {"n": 0}
|
||||
original_open = open
|
||||
|
||||
def counting_open(path, *a, **kw):
|
||||
if str(path) == str(registry):
|
||||
read_count["n"] += 1
|
||||
return original_open(path, *a, **kw)
|
||||
|
||||
monkeypatch.setattr("builtins.open", counting_open)
|
||||
_load_known_entities()
|
||||
assert read_count["n"] == 0, "registry should not be re-read when mtime unchanged"
|
||||
|
||||
# Bump mtime → cache must invalidate.
|
||||
new_mtime = os.path.getmtime(registry) + 5
|
||||
os.utime(registry, (new_mtime, new_mtime))
|
||||
registry.write_text(json.dumps({"people": ["Zelda", "Link"]}))
|
||||
os.utime(registry, (new_mtime, new_mtime))
|
||||
names = _load_known_entities()
|
||||
assert "Link" in names
|
||||
|
||||
|
||||
# ── BM25 hybrid search (real IDF over candidate corpus) ──────────────
|
||||
|
||||
|
||||
class TestBM25:
|
||||
def test_scores_positive_for_matching_doc(self):
|
||||
scores = _bm25_scores(
|
||||
"database migration",
|
||||
["We migrated the database to Postgres.", "unrelated cookery tips"],
|
||||
)
|
||||
assert scores[0] > 0
|
||||
assert scores[1] == 0.0
|
||||
|
||||
def test_scores_zero_when_no_overlap(self):
|
||||
scores = _bm25_scores("quantum physics", ["We built a web app in React"])
|
||||
assert scores == [0.0]
|
||||
|
||||
def test_idf_downweights_terms_present_in_every_doc(self):
|
||||
# "database" appears in every candidate → low IDF → low contribution.
|
||||
# "vacuum" is unique to one → high IDF → that doc dominates.
|
||||
scores = _bm25_scores(
|
||||
"database vacuum",
|
||||
[
|
||||
"database backup nightly schedule",
|
||||
"database vacuum scheduled weekly",
|
||||
"database failover plan",
|
||||
],
|
||||
)
|
||||
assert scores[1] == max(scores), "doc with the rare query term should win on IDF"
|
||||
|
||||
def test_empty_inputs_return_zeros(self):
|
||||
assert _bm25_scores("", ["hello world"]) == [0.0]
|
||||
assert _bm25_scores("query here", []) == []
|
||||
assert _bm25_scores("query", [""]) == [0.0]
|
||||
|
||||
def test_hybrid_rank_promotes_keyword_match(self):
|
||||
results = [
|
||||
{"text": "database schema design for Postgres", "distance": 0.5},
|
||||
{"text": "unrelated topic about cooking", "distance": 0.3},
|
||||
]
|
||||
ranked = _hybrid_rank(results, "database Postgres schema")
|
||||
# The keyword-rich result outranks the closer-vector but irrelevant one.
|
||||
assert "database" in ranked[0]["text"]
|
||||
# bm25_score field is exposed for debugging.
|
||||
assert "bm25_score" in ranked[0]
|
||||
# No internal scoring leak.
|
||||
assert "_hybrid_score" not in ranked[0]
|
||||
|
||||
def test_hybrid_rank_absolute_normalization(self):
|
||||
# Adding a much-worse result to the candidate set must NOT reshuffle
|
||||
# the top two — proves we're using absolute (1 - dist) and not
|
||||
# dist / max_dist normalization.
|
||||
base = [
|
||||
{"text": "alpha alpha alpha", "distance": 0.1},
|
||||
{"text": "beta beta beta", "distance": 0.4},
|
||||
]
|
||||
ranked_short = _hybrid_rank([dict(r) for r in base], "alpha")
|
||||
with_outlier = base + [{"text": "gamma gamma gamma", "distance": 1.9}]
|
||||
ranked_long = _hybrid_rank([dict(r) for r in with_outlier], "alpha")
|
||||
assert ranked_short[0]["text"] == ranked_long[0]["text"]
|
||||
assert ranked_short[1]["text"] == ranked_long[1]["text"]
|
||||
|
||||
|
||||
# ── diary ingest ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDiaryIngest:
|
||||
def test_ingest_creates_drawers_and_closets(self, tmp_path):
|
||||
diary_dir = tmp_path / "diaries"
|
||||
diary_dir.mkdir()
|
||||
(diary_dir / "2026-04-13.md").write_text(
|
||||
"# 2026-04-13\n\n## 10:00 PDT — Test\n\nBuilt the auth system.\n"
|
||||
)
|
||||
palace_dir = tmp_path / "palace"
|
||||
|
||||
from mempalace.diary_ingest import ingest_diaries
|
||||
|
||||
result = ingest_diaries(str(diary_dir), str(palace_dir), force=True)
|
||||
assert result["days_updated"] >= 1
|
||||
assert get_collection(str(palace_dir)).count() >= 1
|
||||
|
||||
def test_ingest_skips_unchanged_on_second_run(self, tmp_path):
|
||||
diary_dir = tmp_path / "diaries"
|
||||
diary_dir.mkdir()
|
||||
(diary_dir / "2026-04-13.md").write_text(
|
||||
"# 2026-04-13\n\n## 10:00 — Test\n\nContent here that's long enough.\n"
|
||||
)
|
||||
palace_dir = tmp_path / "palace"
|
||||
|
||||
from mempalace.diary_ingest import ingest_diaries
|
||||
|
||||
ingest_diaries(str(diary_dir), str(palace_dir), force=True)
|
||||
result = ingest_diaries(str(diary_dir), str(palace_dir))
|
||||
assert result["days_updated"] == 0
|
||||
|
||||
def test_state_file_lives_outside_diary_dir(self, tmp_path):
|
||||
# Regression: the original implementation wrote
|
||||
# ``.diary_ingest_state.json`` *inside* the user's diary directory,
|
||||
# polluting their content folder. State must live under
|
||||
# ``~/.mempalace/state/`` instead.
|
||||
diary_dir = tmp_path / "diaries"
|
||||
diary_dir.mkdir()
|
||||
(diary_dir / "2026-04-13.md").write_text(
|
||||
"# 2026-04-13\n\n## 10:00 — Test\n\nBody content here long enough.\n"
|
||||
)
|
||||
palace_dir = tmp_path / "palace"
|
||||
|
||||
from mempalace.diary_ingest import _state_file_for, ingest_diaries
|
||||
|
||||
ingest_diaries(str(diary_dir), str(palace_dir), force=True)
|
||||
|
||||
# No state file inside the user's diary dir.
|
||||
for entry in diary_dir.iterdir():
|
||||
assert (
|
||||
"diary_ingest" not in entry.name
|
||||
), f"state file leaked into user diary dir: {entry}"
|
||||
|
||||
# State file does exist under ~/.mempalace/state/.
|
||||
state_path = _state_file_for(str(palace_dir), diary_dir.resolve())
|
||||
assert state_path.exists()
|
||||
# Platform-neutral path check: compare parents rather than a hardcoded
|
||||
# separator string that would fail on Windows (``\.mempalace\state\``).
|
||||
assert state_path.parent.name == "state"
|
||||
assert state_path.parent.parent.name == ".mempalace"
|
||||
|
||||
def test_wing_prefixed_drawer_id_prevents_cross_diary_collision(self, tmp_path):
|
||||
# Regression: the original implementation used
|
||||
# ``drawer_diary_{date_str}`` regardless of wing — two diaries with
|
||||
# the same date in different wings would clobber each other.
|
||||
date_md = "# 2026-04-13\n\n## 10:00 — entry\n\nThis is the day's content.\n"
|
||||
|
||||
# Two separate diary dirs, ingested into the same palace under
|
||||
# different wings. Each must produce a distinct drawer.
|
||||
personal_dir = tmp_path / "personal"
|
||||
personal_dir.mkdir()
|
||||
(personal_dir / "2026-04-13.md").write_text(date_md + "Personal-only marker.\n")
|
||||
|
||||
work_dir = tmp_path / "work"
|
||||
work_dir.mkdir()
|
||||
(work_dir / "2026-04-13.md").write_text(date_md + "Work-only marker.\n")
|
||||
|
||||
palace_dir = tmp_path / "palace"
|
||||
|
||||
from mempalace.diary_ingest import _diary_drawer_id, ingest_diaries
|
||||
|
||||
ingest_diaries(str(personal_dir), str(palace_dir), wing="personal", force=True)
|
||||
ingest_diaries(str(work_dir), str(palace_dir), wing="work", force=True)
|
||||
|
||||
col = get_collection(str(palace_dir))
|
||||
personal_id = _diary_drawer_id("personal", "2026-04-13")
|
||||
work_id = _diary_drawer_id("work", "2026-04-13")
|
||||
assert personal_id != work_id
|
||||
|
||||
personal = col.get(ids=[personal_id])
|
||||
work = col.get(ids=[work_id])
|
||||
assert personal["ids"] == [personal_id]
|
||||
assert work["ids"] == [work_id]
|
||||
assert "Personal-only marker." in personal["documents"][0]
|
||||
assert "Work-only marker." in work["documents"][0]
|
||||
|
||||
|
||||
# ── cross-wing tunnels ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTunnels:
|
||||
"""Tunnels are explicit cross-wing connections stored in
|
||||
``~/.mempalace/tunnels.json``. Each test points the module-level
|
||||
``_TUNNEL_FILE`` at a fresh tmp file so tests don't cross-contaminate
|
||||
or touch the user's real tunnels."""
|
||||
|
||||
def setup_method(self):
|
||||
import mempalace.palace_graph as pg
|
||||
|
||||
self._orig = pg._TUNNEL_FILE
|
||||
self._tmpdir = tempfile.mkdtemp()
|
||||
pg._TUNNEL_FILE = os.path.join(self._tmpdir, "tunnels.json")
|
||||
|
||||
def teardown_method(self):
|
||||
import mempalace.palace_graph as pg
|
||||
|
||||
pg._TUNNEL_FILE = self._orig
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(self._tmpdir, ignore_errors=True)
|
||||
|
||||
def test_create_tunnel(self):
|
||||
t = create_tunnel("wing_api", "auth", "wing_db", "users", label="auth uses users table")
|
||||
assert t["id"]
|
||||
assert t["source"]["wing"] == "wing_api"
|
||||
assert t["source"]["room"] == "auth"
|
||||
assert t["target"]["wing"] == "wing_db"
|
||||
assert t["target"]["room"] == "users"
|
||||
assert t["label"] == "auth uses users table"
|
||||
|
||||
def test_list_tunnels_with_and_without_filter(self):
|
||||
create_tunnel("wing_a", "room1", "wing_b", "room2")
|
||||
create_tunnel("wing_a", "room3", "wing_c", "room4")
|
||||
assert len(list_tunnels()) == 2
|
||||
# Filtering by a wing that appears on either endpoint.
|
||||
assert len(list_tunnels("wing_a")) == 2
|
||||
assert len(list_tunnels("wing_c")) == 1
|
||||
assert len(list_tunnels("wing_nonexistent")) == 0
|
||||
|
||||
def test_delete_tunnel(self):
|
||||
t = create_tunnel("wing_x", "r1", "wing_y", "r2")
|
||||
delete_tunnel(t["id"])
|
||||
assert list_tunnels() == []
|
||||
|
||||
def test_dedup_same_endpoints_updates_label(self):
|
||||
create_tunnel("wing_a", "r1", "wing_b", "r2", label="first")
|
||||
create_tunnel("wing_a", "r1", "wing_b", "r2", label="updated")
|
||||
tunnels = list_tunnels()
|
||||
assert len(tunnels) == 1
|
||||
assert tunnels[0]["label"] == "updated"
|
||||
|
||||
def test_follow_tunnels_returns_connected_endpoints(self):
|
||||
create_tunnel("wing_api", "auth", "wing_db", "users")
|
||||
create_tunnel("wing_api", "auth", "wing_frontend", "login")
|
||||
# Unrelated tunnel that must not surface.
|
||||
create_tunnel("wing_other", "notes", "wing_misc", "scratch")
|
||||
|
||||
connections = follow_tunnels("wing_api", "auth")
|
||||
assert len(connections) == 2
|
||||
wings = {c["connected_wing"] for c in connections}
|
||||
assert wings == {"wing_db", "wing_frontend"}
|
||||
|
||||
# ── regression: symmetry, durability, validation, concurrency ─────
|
||||
|
||||
def test_tunnel_is_symmetric(self):
|
||||
"""Regression: tunnels are undirected. create(A, B) and create(B, A)
|
||||
must resolve to the same canonical ID and dedupe into one record —
|
||||
the second call updates the label instead of creating a dupe."""
|
||||
first = create_tunnel("wing_a", "r1", "wing_b", "r2", label="forward")
|
||||
second = create_tunnel("wing_b", "r2", "wing_a", "r1", label="reversed")
|
||||
assert first["id"] == second["id"]
|
||||
assert len(list_tunnels()) == 1
|
||||
assert list_tunnels()[0]["label"] == "reversed"
|
||||
|
||||
def test_follow_tunnels_works_from_either_endpoint(self):
|
||||
"""Symmetric: you can follow_tunnels from either end of the link."""
|
||||
create_tunnel("wing_api", "auth", "wing_db", "users", label="auth uses users")
|
||||
from_source = follow_tunnels("wing_api", "auth")
|
||||
from_target = follow_tunnels("wing_db", "users")
|
||||
assert len(from_source) == 1
|
||||
assert len(from_target) == 1
|
||||
assert from_source[0]["connected_wing"] == "wing_db"
|
||||
assert from_target[0]["connected_wing"] == "wing_api"
|
||||
# Both surfaces should carry the same label.
|
||||
assert from_source[0]["label"] == "auth uses users"
|
||||
assert from_target[0]["label"] == "auth uses users"
|
||||
|
||||
def test_empty_endpoint_fields_rejected(self):
|
||||
"""Regression: create_tunnel must reject empty strings on any
|
||||
endpoint field so the JSON store can't grow phantom tunnels."""
|
||||
import pytest
|
||||
|
||||
for args in [
|
||||
("", "r1", "wing", "r2"),
|
||||
("wing", "", "wing", "r2"),
|
||||
("wing", "r1", "", "r2"),
|
||||
("wing", "r1", "wing", ""),
|
||||
(" ", "r1", "wing", "r2"), # whitespace-only also rejected
|
||||
]:
|
||||
with pytest.raises(ValueError):
|
||||
create_tunnel(*args)
|
||||
|
||||
def test_corrupt_tunnel_file_does_not_lose_new_writes(self):
|
||||
"""A truncated/corrupt tunnels.json (crash mid-write on a system
|
||||
without atomic rename) must not leak into subsequent reads — the
|
||||
file should be treated as empty and a fresh create_tunnel should
|
||||
persist cleanly."""
|
||||
import mempalace.palace_graph as pg
|
||||
|
||||
# Simulate a crash that left a truncated file behind.
|
||||
with open(pg._TUNNEL_FILE, "w") as f:
|
||||
f.write("{not valid json")
|
||||
|
||||
# Load should return [] rather than raising.
|
||||
assert list_tunnels() == []
|
||||
|
||||
# A subsequent create must persist (atomic write replaces the corrupt file).
|
||||
t = create_tunnel("wing_a", "r1", "wing_b", "r2")
|
||||
assert list_tunnels() == [t]
|
||||
|
||||
def test_atomic_write_leaves_no_stray_tmp_file(self):
|
||||
"""Regression: _save_tunnels uses write-then-os.replace. After a
|
||||
successful create, there must be no leftover ``tunnels.json.tmp``."""
|
||||
import mempalace.palace_graph as pg
|
||||
|
||||
create_tunnel("wing_a", "r1", "wing_b", "r2")
|
||||
assert os.path.exists(pg._TUNNEL_FILE)
|
||||
assert not os.path.exists(pg._TUNNEL_FILE + ".tmp")
|
||||
|
||||
def test_concurrent_creates_preserve_all_tunnels(self):
|
||||
"""Regression: two concurrent create_tunnel calls must not clobber
|
||||
each other. Without the mine_lock around load+save, the later
|
||||
writer's snapshot would overwrite the earlier writer's tunnel."""
|
||||
barrier = threading.Barrier(5)
|
||||
errors: list = []
|
||||
|
||||
def worker(i):
|
||||
try:
|
||||
barrier.wait(timeout=2)
|
||||
create_tunnel(f"wing_{i}", "r", "wing_shared", "hub")
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=worker, args=(i,)) for i in range(5)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert not errors, f"worker raised: {errors}"
|
||||
tunnels = list_tunnels()
|
||||
assert len(tunnels) == 5, (
|
||||
f"expected 5 concurrent tunnels, got {len(tunnels)} — " "write race dropped some"
|
||||
)
|
||||
|
||||
def test_created_at_is_timezone_aware(self):
|
||||
"""Regression: created_at must be tz-aware UTC, not naive."""
|
||||
t = create_tunnel("wing_a", "r1", "wing_b", "r2")
|
||||
# ISO format with tz offset contains '+' or 'Z'.
|
||||
assert t["created_at"].endswith("+00:00") or t["created_at"].endswith("Z")
|
||||
|
||||
|
||||
# ── drawer-grep neighbor expansion ────────────────────────────────────
|
||||
#
|
||||
# When a closet hit lands on a drawer whose chunk boundary clips a thought
|
||||
# (matched chunk says "here's a breakdown:" and the breakdown lives in the
|
||||
# next chunk), the closet path now expands to ±1 neighbor chunks from the
|
||||
# same source file. These tests pin that behavior end-to-end and at the
|
||||
# helper level.
|
||||
|
||||
|
||||
class TestDrawerGrepExpansion:
|
||||
def _seed_source_file(self, palace_path, source: str, n_chunks: int):
|
||||
"""Helper: put N sequential drawers for a single source file into
|
||||
the palace and return the drawer IDs keyed by chunk_index."""
|
||||
col = get_collection(palace_path)
|
||||
ids = [f"drawer_test_room_{source.replace('/', '_')}_{i:03d}" for i in range(n_chunks)]
|
||||
docs = [f"chunk_{i} content about topic alpha" for i in range(n_chunks)]
|
||||
metas = [
|
||||
{
|
||||
"wing": "test",
|
||||
"room": "room",
|
||||
"source_file": source,
|
||||
"chunk_index": i,
|
||||
"filed_at": "2026-04-13T00:00:00",
|
||||
}
|
||||
for i in range(n_chunks)
|
||||
]
|
||||
col.upsert(ids=ids, documents=docs, metadatas=metas)
|
||||
return col, {i: ids[i] for i in range(n_chunks)}
|
||||
|
||||
def test_expand_returns_matched_plus_neighbors(self, palace_path):
|
||||
col, by_idx = self._seed_source_file(palace_path, "/proj/doc.md", n_chunks=5)
|
||||
matched_meta = {"source_file": "/proj/doc.md", "chunk_index": 2}
|
||||
matched_doc = "chunk_2 content about topic alpha"
|
||||
|
||||
out = _expand_with_neighbors(col, matched_doc, matched_meta, radius=1)
|
||||
assert out["drawer_index"] == 2
|
||||
assert out["total_drawers"] == 5
|
||||
# Expect chunks 1, 2, 3 joined in chunk_index order.
|
||||
text = out["text"]
|
||||
assert "chunk_1" in text
|
||||
assert "chunk_2" in text
|
||||
assert "chunk_3" in text
|
||||
# No leakage of non-neighbors.
|
||||
assert "chunk_0" not in text
|
||||
assert "chunk_4" not in text
|
||||
# Ordering preserved — chunk_1 before chunk_2 before chunk_3.
|
||||
assert text.index("chunk_1") < text.index("chunk_2") < text.index("chunk_3")
|
||||
|
||||
def test_expand_at_start_of_file_only_has_next_neighbor(self, palace_path):
|
||||
col, _ = self._seed_source_file(palace_path, "/proj/edge_start.md", n_chunks=3)
|
||||
out = _expand_with_neighbors(
|
||||
col,
|
||||
"chunk_0 content",
|
||||
{"source_file": "/proj/edge_start.md", "chunk_index": 0},
|
||||
)
|
||||
assert out["drawer_index"] == 0
|
||||
assert out["total_drawers"] == 3
|
||||
assert "chunk_0" in out["text"]
|
||||
assert "chunk_1" in out["text"]
|
||||
# No chunk_-1 could exist; the expansion must not invent one.
|
||||
assert "chunk_-1" not in out["text"]
|
||||
|
||||
def test_expand_at_end_of_file_only_has_prev_neighbor(self, palace_path):
|
||||
col, _ = self._seed_source_file(palace_path, "/proj/edge_end.md", n_chunks=3)
|
||||
out = _expand_with_neighbors(
|
||||
col,
|
||||
"chunk_2 content",
|
||||
{"source_file": "/proj/edge_end.md", "chunk_index": 2},
|
||||
)
|
||||
assert out["drawer_index"] == 2
|
||||
assert out["total_drawers"] == 3
|
||||
assert "chunk_1" in out["text"]
|
||||
assert "chunk_2" in out["text"]
|
||||
# No chunk_3 exists.
|
||||
assert "chunk_3" not in out["text"]
|
||||
|
||||
def test_expand_single_drawer_file_returns_just_matched(self, palace_path):
|
||||
col, _ = self._seed_source_file(palace_path, "/proj/lone.md", n_chunks=1)
|
||||
out = _expand_with_neighbors(
|
||||
col,
|
||||
"chunk_0 content",
|
||||
{"source_file": "/proj/lone.md", "chunk_index": 0},
|
||||
)
|
||||
assert out["drawer_index"] == 0
|
||||
assert out["total_drawers"] == 1
|
||||
assert out["text"] == "chunk_0 content about topic alpha"
|
||||
|
||||
def test_expand_falls_back_when_metadata_missing(self, palace_path):
|
||||
col = get_collection(palace_path)
|
||||
# No source_file / chunk_index in meta — degrade gracefully.
|
||||
out = _expand_with_neighbors(col, "matched doc", {})
|
||||
assert out["text"] == "matched doc"
|
||||
assert out["drawer_index"] is None
|
||||
assert out["total_drawers"] is None
|
||||
|
||||
def test_hybrid_search_enrichment_populates_drawer_index_and_total(self, palace_path):
|
||||
"""End-to-end: when a closet boosts a source with many drawers, the
|
||||
enrichment step runs drawer-grep across all chunks of that source
|
||||
and exposes drawer_index + total_drawers on the hit (so the client
|
||||
knows which chunk was expanded around)."""
|
||||
col = get_collection(palace_path)
|
||||
source = "/proj/indexed.md"
|
||||
# Seed 5 drawers for one source file.
|
||||
for i in range(5):
|
||||
col.upsert(
|
||||
ids=[f"drawer_proj_backend_indexed_{i:03d}"],
|
||||
documents=[f"chunk_{i} talks about JWT authentication flow"],
|
||||
metadatas=[
|
||||
{
|
||||
"wing": "project",
|
||||
"room": "backend",
|
||||
"source_file": source,
|
||||
"chunk_index": i,
|
||||
"filed_at": "2026-04-13T00:00:00",
|
||||
}
|
||||
],
|
||||
)
|
||||
# Closet pointing at chunk_2 for this source.
|
||||
closets = get_closets_collection(palace_path)
|
||||
closets.upsert(
|
||||
ids=["closet_proj_backend_indexed_01"],
|
||||
documents=["JWT auth|;|→drawer_proj_backend_indexed_002"],
|
||||
metadatas=[{"wing": "project", "room": "backend", "source_file": source}],
|
||||
)
|
||||
|
||||
result = search_memories("JWT authentication", palace_path)
|
||||
assert result["results"]
|
||||
# The hybrid path promotes the closet-agreeing source to drawer+closet.
|
||||
boosted = [h for h in result["results"] if h["matched_via"] == "drawer+closet"]
|
||||
assert boosted, "hybrid search should mark the closet-agreeing source"
|
||||
top = boosted[0]
|
||||
assert top["total_drawers"] == 5
|
||||
assert isinstance(top["drawer_index"], int)
|
||||
# Enriched text must include the grep-best chunk plus one neighbor
|
||||
# on each side (chunk boundary may clip).
|
||||
assert "chunk_" in top["text"]
|
||||
+37
-1
@@ -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,133 @@ 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)
|
||||
|
||||
|
||||
def test_mine_convos_rebuilds_stale_drawers_after_schema_bump(capsys):
|
||||
"""When stored drawers have an older normalize_version, the next mine
|
||||
silently purges them and refiles — no manual erase required.
|
||||
|
||||
This is what makes the strip_noise upgrade apply to existing corpora:
|
||||
users just run `mempalace mine` again and old noise-filled drawers get
|
||||
replaced with clean ones."""
|
||||
from mempalace.palace import NORMALIZE_VERSION
|
||||
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
convo_path = Path(tmpdir) / "chat.txt"
|
||||
convo_path.write_text(
|
||||
"> What is memory?\nMemory is persistence.\n\n"
|
||||
"> Why does it matter?\nIt enables continuity.\n\n"
|
||||
"> How do we build it?\nWith structured storage.\n"
|
||||
)
|
||||
palace_path = os.path.join(tmpdir, "palace")
|
||||
|
||||
# First mine — stamps drawers with NORMALIZE_VERSION
|
||||
mine_convos(tmpdir, palace_path, wing="test")
|
||||
capsys.readouterr()
|
||||
|
||||
client = chromadb.PersistentClient(path=palace_path)
|
||||
col = client.get_collection("mempalace_drawers")
|
||||
resolved = str(Path(tmpdir).resolve() / "chat.txt")
|
||||
first_pass = col.get(where={"source_file": resolved})
|
||||
first_ids = set(first_pass["ids"])
|
||||
assert first_ids, "first mine should produce drawers"
|
||||
for meta in first_pass["metadatas"]:
|
||||
assert meta.get("normalize_version") == NORMALIZE_VERSION
|
||||
|
||||
# Simulate pre-v2 drawers: rewrite metadata to an older version,
|
||||
# and replace content with "noise" so we can see it get cleaned up.
|
||||
stale_metas = []
|
||||
for meta in first_pass["metadatas"]:
|
||||
stale = dict(meta)
|
||||
stale["normalize_version"] = 1
|
||||
stale_metas.append(stale)
|
||||
col.update(
|
||||
ids=list(first_pass["ids"]),
|
||||
documents=["STALE NOISE"] * len(first_pass["ids"]),
|
||||
metadatas=stale_metas,
|
||||
)
|
||||
# Add an extra orphan drawer that should also be purged.
|
||||
col.add(
|
||||
ids=["orphan_drawer"],
|
||||
documents=["OLD ORPHAN"],
|
||||
metadatas=[
|
||||
{
|
||||
"wing": "test",
|
||||
"room": "default",
|
||||
"source_file": resolved,
|
||||
"chunk_index": 999,
|
||||
"normalize_version": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
del col, client
|
||||
|
||||
# Second mine — version gate should trigger rebuild
|
||||
mine_convos(tmpdir, palace_path, wing="test")
|
||||
out = capsys.readouterr().out
|
||||
assert (
|
||||
"Files skipped (already filed): 0" in out
|
||||
), "stale drawers should force a rebuild, not a skip"
|
||||
|
||||
client = chromadb.PersistentClient(path=palace_path)
|
||||
col = client.get_collection("mempalace_drawers")
|
||||
rebuilt = col.get(where={"source_file": resolved})
|
||||
# Orphan is gone
|
||||
assert "orphan_drawer" not in rebuilt["ids"]
|
||||
# No stale content survived
|
||||
assert all("STALE NOISE" not in d for d in rebuilt["documents"])
|
||||
assert all("OLD ORPHAN" not in d for d in rebuilt["documents"])
|
||||
# All rebuilt drawers carry the current version
|
||||
for meta in rebuilt["metadatas"]:
|
||||
assert meta.get("normalize_version") == NORMALIZE_VERSION
|
||||
del col, client
|
||||
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):
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Tests for mempalace.dedup — near-duplicate drawer detection and removal."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
from mempalace import dedup
|
||||
|
||||
|
||||
# ── get_source_groups ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_get_source_groups_basic():
|
||||
col = MagicMock()
|
||||
col.count.return_value = 5
|
||||
col.get.side_effect = [
|
||||
{
|
||||
"ids": ["d1", "d2", "d3", "d4", "d5"],
|
||||
"metadatas": [
|
||||
{"source_file": "a.txt"},
|
||||
{"source_file": "a.txt"},
|
||||
{"source_file": "a.txt"},
|
||||
{"source_file": "a.txt"},
|
||||
{"source_file": "a.txt"},
|
||||
],
|
||||
},
|
||||
{"ids": []},
|
||||
]
|
||||
groups = dedup.get_source_groups(col, min_count=5)
|
||||
assert "a.txt" in groups
|
||||
assert len(groups["a.txt"]) == 5
|
||||
|
||||
|
||||
def test_get_source_groups_below_min():
|
||||
col = MagicMock()
|
||||
col.count.return_value = 2
|
||||
col.get.side_effect = [
|
||||
{
|
||||
"ids": ["d1", "d2"],
|
||||
"metadatas": [
|
||||
{"source_file": "a.txt"},
|
||||
{"source_file": "a.txt"},
|
||||
],
|
||||
},
|
||||
{"ids": []},
|
||||
]
|
||||
groups = dedup.get_source_groups(col, min_count=5)
|
||||
assert len(groups) == 0
|
||||
|
||||
|
||||
def test_get_source_groups_source_filter():
|
||||
col = MagicMock()
|
||||
col.count.return_value = 6
|
||||
col.get.side_effect = [
|
||||
{
|
||||
"ids": ["d1", "d2", "d3", "d4", "d5", "d6"],
|
||||
"metadatas": [
|
||||
{"source_file": "project_a.txt"},
|
||||
{"source_file": "project_a.txt"},
|
||||
{"source_file": "project_a.txt"},
|
||||
{"source_file": "project_a.txt"},
|
||||
{"source_file": "project_a.txt"},
|
||||
{"source_file": "other.txt"},
|
||||
],
|
||||
},
|
||||
{"ids": []},
|
||||
]
|
||||
groups = dedup.get_source_groups(col, min_count=5, source_pattern="project_a")
|
||||
assert "project_a.txt" in groups
|
||||
assert "other.txt" not in groups
|
||||
|
||||
|
||||
def test_get_source_groups_wing_filter():
|
||||
col = MagicMock()
|
||||
col.count.return_value = 5
|
||||
col.get.side_effect = [
|
||||
{
|
||||
"ids": ["d1", "d2", "d3", "d4", "d5"],
|
||||
"metadatas": [
|
||||
{"source_file": "a.txt"},
|
||||
{"source_file": "a.txt"},
|
||||
{"source_file": "a.txt"},
|
||||
{"source_file": "a.txt"},
|
||||
{"source_file": "a.txt"},
|
||||
],
|
||||
},
|
||||
{"ids": []},
|
||||
]
|
||||
dedup.get_source_groups(col, min_count=5, wing="my_wing")
|
||||
# Verify where filter was passed
|
||||
first_call = col.get.call_args_list[0]
|
||||
assert first_call.kwargs.get("where") == {"wing": "my_wing"}
|
||||
|
||||
|
||||
def test_get_source_groups_missing_source_file():
|
||||
col = MagicMock()
|
||||
col.count.return_value = 5
|
||||
col.get.side_effect = [
|
||||
{
|
||||
"ids": ["d1", "d2", "d3", "d4", "d5"],
|
||||
"metadatas": [{}, {}, {}, {}, {}],
|
||||
},
|
||||
{"ids": []},
|
||||
]
|
||||
groups = dedup.get_source_groups(col, min_count=5)
|
||||
assert "unknown" in groups
|
||||
|
||||
|
||||
# ── dedup_source_group ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_dedup_source_group_all_unique():
|
||||
col = MagicMock()
|
||||
col.get.return_value = {
|
||||
"ids": ["d1", "d2"],
|
||||
"documents": ["long document one content here", "different document two here"],
|
||||
"metadatas": [{"wing": "a"}, {"wing": "a"}],
|
||||
}
|
||||
col.query.return_value = {
|
||||
"ids": [["d1"]],
|
||||
"distances": [[0.8]], # far apart = unique
|
||||
}
|
||||
kept, deleted = dedup.dedup_source_group(col, ["d1", "d2"], threshold=0.15, dry_run=True)
|
||||
assert len(kept) == 2
|
||||
assert len(deleted) == 0
|
||||
|
||||
|
||||
def test_dedup_source_group_with_duplicate():
|
||||
col = MagicMock()
|
||||
col.get.return_value = {
|
||||
"ids": ["d1", "d2"],
|
||||
"documents": [
|
||||
"long document content that is fairly long",
|
||||
"long document content that is fairly long",
|
||||
],
|
||||
"metadatas": [{"wing": "a"}, {"wing": "a"}],
|
||||
}
|
||||
col.query.return_value = {
|
||||
"ids": [["d1"]],
|
||||
"distances": [[0.05]], # very close = duplicate
|
||||
}
|
||||
kept, deleted = dedup.dedup_source_group(col, ["d1", "d2"], threshold=0.15, dry_run=True)
|
||||
assert len(kept) == 1
|
||||
assert len(deleted) == 1
|
||||
|
||||
|
||||
def test_dedup_source_group_short_docs_deleted():
|
||||
col = MagicMock()
|
||||
col.get.return_value = {
|
||||
"ids": ["d1", "d2"],
|
||||
"documents": ["long enough document to keep in the palace", "tiny"],
|
||||
"metadatas": [{"wing": "a"}, {"wing": "a"}],
|
||||
}
|
||||
kept, deleted = dedup.dedup_source_group(col, ["d1", "d2"], threshold=0.15, dry_run=True)
|
||||
assert "d2" in deleted # too short
|
||||
|
||||
|
||||
def test_dedup_source_group_empty_doc_deleted():
|
||||
col = MagicMock()
|
||||
col.get.return_value = {
|
||||
"ids": ["d1", "d2"],
|
||||
"documents": ["real document content here that is long enough", None],
|
||||
"metadatas": [{"wing": "a"}, {"wing": "a"}],
|
||||
}
|
||||
kept, deleted = dedup.dedup_source_group(col, ["d1", "d2"], threshold=0.15, dry_run=True)
|
||||
assert "d2" in deleted
|
||||
|
||||
|
||||
def test_dedup_source_group_live_deletes():
|
||||
col = MagicMock()
|
||||
col.get.return_value = {
|
||||
"ids": ["d1", "d2"],
|
||||
"documents": ["long document content here enough", "long document content here enough"],
|
||||
"metadatas": [{"wing": "a"}, {"wing": "a"}],
|
||||
}
|
||||
col.query.return_value = {
|
||||
"ids": [["d1"]],
|
||||
"distances": [[0.05]],
|
||||
}
|
||||
kept, deleted = dedup.dedup_source_group(col, ["d1", "d2"], threshold=0.15, dry_run=False)
|
||||
col.delete.assert_called_once()
|
||||
|
||||
|
||||
def test_dedup_source_group_query_failure_keeps():
|
||||
col = MagicMock()
|
||||
col.get.return_value = {
|
||||
"ids": ["d1", "d2"],
|
||||
"documents": [
|
||||
"long document one content here enough",
|
||||
"long document two content here enough",
|
||||
],
|
||||
"metadatas": [{"wing": "a"}, {"wing": "a"}],
|
||||
}
|
||||
col.query.side_effect = Exception("query failed")
|
||||
kept, deleted = dedup.dedup_source_group(col, ["d1", "d2"], threshold=0.15, dry_run=True)
|
||||
assert len(kept) == 2 # both kept on error
|
||||
|
||||
|
||||
# ── show_stats ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@patch("mempalace.dedup.chromadb")
|
||||
def test_show_stats(mock_chromadb, tmp_path):
|
||||
mock_col = MagicMock()
|
||||
mock_col.count.return_value = 5
|
||||
mock_col.get.side_effect = [
|
||||
{
|
||||
"ids": ["d1", "d2", "d3", "d4", "d5"],
|
||||
"metadatas": [
|
||||
{"source_file": "a.txt"},
|
||||
{"source_file": "a.txt"},
|
||||
{"source_file": "a.txt"},
|
||||
{"source_file": "a.txt"},
|
||||
{"source_file": "a.txt"},
|
||||
],
|
||||
},
|
||||
{"ids": []},
|
||||
]
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_collection.return_value = mock_col
|
||||
mock_chromadb.PersistentClient.return_value = mock_client
|
||||
|
||||
dedup.show_stats(palace_path=str(tmp_path)) # should not raise
|
||||
|
||||
|
||||
# ── dedup_palace ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@patch("mempalace.dedup.dedup_source_group")
|
||||
@patch("mempalace.dedup.get_source_groups")
|
||||
@patch("mempalace.dedup.chromadb")
|
||||
def test_dedup_palace_dry_run(mock_chromadb, mock_groups, mock_dedup_group, tmp_path):
|
||||
mock_col = MagicMock()
|
||||
mock_col.count.return_value = 10
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_collection.return_value = mock_col
|
||||
mock_chromadb.PersistentClient.return_value = mock_client
|
||||
|
||||
mock_groups.return_value = {"a.txt": ["d1", "d2", "d3", "d4", "d5"]}
|
||||
mock_dedup_group.return_value = (["d1", "d2", "d3"], ["d4", "d5"])
|
||||
|
||||
dedup.dedup_palace(palace_path=str(tmp_path), dry_run=True)
|
||||
mock_dedup_group.assert_called_once()
|
||||
|
||||
|
||||
@patch("mempalace.dedup.dedup_source_group")
|
||||
@patch("mempalace.dedup.get_source_groups")
|
||||
@patch("mempalace.dedup.chromadb")
|
||||
def test_dedup_palace_with_wing(mock_chromadb, mock_groups, mock_dedup_group, tmp_path):
|
||||
mock_col = MagicMock()
|
||||
mock_col.count.return_value = 10
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_collection.return_value = mock_col
|
||||
mock_chromadb.PersistentClient.return_value = mock_client
|
||||
|
||||
mock_groups.return_value = {}
|
||||
dedup.dedup_palace(palace_path=str(tmp_path), wing="test_wing", dry_run=True)
|
||||
mock_groups.assert_called_once_with(mock_col, 5, None, wing="test_wing")
|
||||
|
||||
|
||||
@patch("mempalace.dedup.dedup_source_group")
|
||||
@patch("mempalace.dedup.get_source_groups")
|
||||
@patch("mempalace.dedup.chromadb")
|
||||
def test_dedup_palace_no_groups(mock_chromadb, mock_groups, mock_dedup_group, tmp_path):
|
||||
mock_col = MagicMock()
|
||||
mock_col.count.return_value = 3
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_collection.return_value = mock_col
|
||||
mock_chromadb.PersistentClient.return_value = mock_client
|
||||
|
||||
mock_groups.return_value = {}
|
||||
dedup.dedup_palace(palace_path=str(tmp_path), dry_run=True)
|
||||
mock_dedup_group.assert_not_called()
|
||||
@@ -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)
|
||||
@@ -0,0 +1,288 @@
|
||||
"""
|
||||
test_fact_checker.py — Regression + integration tests for fact_checker.
|
||||
|
||||
Covers every detection path + the three bugs the original PR silently
|
||||
hid behind ``except Exception: pass``:
|
||||
|
||||
* ``kg.query()`` doesn't exist — code must use ``query_entity``.
|
||||
* ``KnowledgeGraph(palace_path=...)`` is not a valid kwarg — code
|
||||
must pass ``db_path``.
|
||||
* O(n²) edit-distance over the full registry — must filter to names
|
||||
actually mentioned in the text.
|
||||
|
||||
Also pins the three feature contracts:
|
||||
* similar_name — "Mila" vs "Milla" in a registry with both.
|
||||
* relationship_mismatch — "Bob is Alice's brother" vs KG "husband".
|
||||
* stale_fact — claim matches a triple whose valid_to is in the past.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mempalace.fact_checker import (
|
||||
_check_entity_confusion,
|
||||
_edit_distance,
|
||||
_extract_claims,
|
||||
_flatten_names,
|
||||
check_text,
|
||||
)
|
||||
from mempalace.knowledge_graph import KnowledgeGraph
|
||||
|
||||
|
||||
# ── claim extraction ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExtractClaims:
|
||||
def test_parses_x_is_ys_z(self):
|
||||
claims = _extract_claims("Bob is Alice's brother")
|
||||
assert len(claims) == 1
|
||||
assert claims[0] == {
|
||||
"subject": "Bob",
|
||||
"predicate": "brother",
|
||||
"object": "Alice",
|
||||
"span": "Bob is Alice's brother",
|
||||
}
|
||||
|
||||
def test_parses_xs_z_is_y(self):
|
||||
claims = _extract_claims("Alice's brother is Bob")
|
||||
assert len(claims) == 1
|
||||
assert claims[0]["subject"] == "Bob"
|
||||
assert claims[0]["predicate"] == "brother"
|
||||
assert claims[0]["object"] == "Alice"
|
||||
|
||||
def test_ignores_sentences_without_possessive_role(self):
|
||||
assert _extract_claims("Bob drove to the store today") == []
|
||||
assert _extract_claims("Just some prose without relationships") == []
|
||||
|
||||
def test_multiple_claims_in_one_text(self):
|
||||
claims = _extract_claims("Bob is Alice's brother. Carol is Dave's sister.")
|
||||
subjects = {c["subject"] for c in claims}
|
||||
assert subjects == {"Bob", "Carol"}
|
||||
|
||||
|
||||
# ── entity confusion ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEntityConfusion:
|
||||
def test_flags_near_name_when_only_one_mentioned(self):
|
||||
registry = {"people": ["Milla", "Mila"]}
|
||||
issues = _check_entity_confusion("I spoke with Mila today.", registry)
|
||||
# "Mila" mentioned, "Milla" not — registry has both at edit-distance 1,
|
||||
# flag the possible confusion.
|
||||
assert len(issues) == 1
|
||||
assert issues[0]["type"] == "similar_name"
|
||||
assert set(issues[0]["names"]) == {"Mila", "Milla"}
|
||||
assert issues[0]["distance"] == 1
|
||||
|
||||
def test_no_false_positive_when_both_names_mentioned(self):
|
||||
"""Regression: a text discussing both Mila and Milla is fine —
|
||||
the user clearly knows they're different. Don't nag."""
|
||||
registry = {"people": ["Milla", "Mila"]}
|
||||
issues = _check_entity_confusion("Mila and Milla met for lunch.", registry)
|
||||
assert issues == []
|
||||
|
||||
def test_no_issues_when_registry_empty(self):
|
||||
assert _check_entity_confusion("Bob said hi", {}) == []
|
||||
assert _check_entity_confusion("Bob said hi", {"people": []}) == []
|
||||
|
||||
def test_no_issues_when_no_mentioned_names(self):
|
||||
registry = {"people": ["Zelda", "Link", "Sheik"]}
|
||||
assert _check_entity_confusion("nothing relevant here", registry) == []
|
||||
|
||||
def test_registry_dict_shape_is_supported(self):
|
||||
# Some registries store {"people": {"Alice": {...meta}}}; we still
|
||||
# need to surface the keys as candidate names.
|
||||
registry = {"people": {"Milla": {"role": "creator"}, "Mila": {}}}
|
||||
issues = _check_entity_confusion("I messaged Mila yesterday", registry)
|
||||
assert any("Milla" in (i["names"] or []) for i in issues)
|
||||
|
||||
|
||||
class TestEditDistance:
|
||||
def test_basic_distances(self):
|
||||
assert _edit_distance("kitten", "sitting") == 3
|
||||
assert _edit_distance("mila", "milla") == 1
|
||||
assert _edit_distance("abc", "abc") == 0
|
||||
|
||||
def test_empty_strings(self):
|
||||
assert _edit_distance("", "") == 0
|
||||
assert _edit_distance("abc", "") == 3
|
||||
assert _edit_distance("", "abc") == 3
|
||||
|
||||
def test_performance_bounded_by_mentioned_names(self):
|
||||
"""Regression: an earlier implementation did O(n²) pairwise
|
||||
edit-distance over every registry entry on every check_text call.
|
||||
With 100 names and zero mentions, the call must return in a blink
|
||||
because no edit-distance comparison should even start."""
|
||||
import time
|
||||
|
||||
# 500 random names, none of which appear in the text.
|
||||
registry = {"people": [f"Zelda{i:03d}" for i in range(500)]}
|
||||
text = "completely irrelevant prose with no registered names at all"
|
||||
|
||||
start = time.perf_counter()
|
||||
issues = _check_entity_confusion(text, registry)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
assert issues == []
|
||||
# Even an unoptimized implementation should beat this by orders
|
||||
# of magnitude once we've filtered to mentioned names (which is
|
||||
# 0 here) — if it's still doing O(n²), we'll blow past.
|
||||
assert elapsed < 0.2, f"entity confusion took {elapsed:.3f}s on empty mentions"
|
||||
|
||||
|
||||
# ── _flatten_names helper ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFlattenNames:
|
||||
def test_handles_list_categories(self):
|
||||
assert _flatten_names({"people": ["Ada", "Bob"]}) == {"Ada", "Bob"}
|
||||
|
||||
def test_handles_dict_categories(self):
|
||||
assert _flatten_names({"people": {"Ada": {}, "Bob": {}}}) == {"Ada", "Bob"}
|
||||
|
||||
def test_skips_falsy_entries(self):
|
||||
assert _flatten_names({"people": ["Ada", "", None, "Bob"]}) == {"Ada", "Bob"}
|
||||
|
||||
|
||||
# ── KG integration (uses a real tmp SQLite palace) ───────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def palace_with_kg(tmp_path):
|
||||
"""Palace directory with a real KG pre-seeded with a few triples.
|
||||
|
||||
The KG file lives at ``<palace>/knowledge_graph.sqlite3`` — same
|
||||
convention used by the MCP server. Fact-checker must find it via
|
||||
that path, not via a bogus ``palace_path`` kwarg.
|
||||
"""
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
db = str(palace / "knowledge_graph.sqlite3")
|
||||
kg = KnowledgeGraph(db_path=db)
|
||||
yield palace, kg
|
||||
|
||||
|
||||
class TestKGContradictions:
|
||||
def test_kg_init_uses_db_path_not_palace_path_kwarg(self):
|
||||
"""Regression: the original code passed ``palace_path=`` to a
|
||||
constructor whose only kwarg is ``db_path``. That raised
|
||||
TypeError — silently swallowed — and the KG path became dead
|
||||
code. This test pins the correct call signature."""
|
||||
# Simply construct via the correct signature; raising means the
|
||||
# KG constructor has changed in a way that fact_checker must too.
|
||||
kg = KnowledgeGraph(db_path=":memory:")
|
||||
# query_entity must exist (this is the method fact_checker calls).
|
||||
assert callable(getattr(kg, "query_entity", None))
|
||||
# The API that fact_checker used to call does NOT exist.
|
||||
assert not hasattr(kg, "query")
|
||||
|
||||
def test_relationship_mismatch_detected(self, palace_with_kg):
|
||||
"""The feature's headline example: text says brother, KG says husband."""
|
||||
palace, kg = palace_with_kg
|
||||
kg.add_triple("Bob", "husband_of", "Alice", valid_from="2020-01-01")
|
||||
|
||||
issues = check_text("Bob is Alice's husband_of", str(palace))
|
||||
# Exact-predicate + same object → no mismatch.
|
||||
assert all(i["type"] != "relationship_mismatch" for i in issues)
|
||||
|
||||
issues = check_text("Bob is Alice's brother", str(palace))
|
||||
mismatches = [i for i in issues if i["type"] == "relationship_mismatch"]
|
||||
assert mismatches, "should flag text/KG mismatch for same (subject, object)"
|
||||
m = mismatches[0]
|
||||
assert m["entity"] == "Bob"
|
||||
assert m["claim"]["predicate"] == "brother"
|
||||
assert m["kg_fact"]["predicate"] == "husband_of"
|
||||
|
||||
def test_no_false_positive_when_kg_has_no_facts_about_subject(self, palace_with_kg):
|
||||
palace, _ = palace_with_kg
|
||||
# KG is empty → no mismatch should fire.
|
||||
assert check_text("Bob is Alice's brother", str(palace)) == []
|
||||
|
||||
def test_stale_fact_detected(self, palace_with_kg):
|
||||
palace, kg = palace_with_kg
|
||||
# An old relationship that was superseded in 2023. Using a
|
||||
# possessive-shape claim so the narrow claim-extraction regex
|
||||
# actually reaches the stale-fact branch.
|
||||
kg.add_triple(
|
||||
"Bob",
|
||||
"brother",
|
||||
"Alice",
|
||||
valid_from="2010-01-01",
|
||||
valid_to="2023-06-01",
|
||||
)
|
||||
issues = check_text("Bob is Alice's brother", str(palace))
|
||||
stale = [i for i in issues if i["type"] == "stale_fact"]
|
||||
assert stale, "should flag closed-window fact as stale"
|
||||
assert stale[0]["entity"] == "Bob"
|
||||
assert stale[0]["valid_to"].startswith("2023")
|
||||
|
||||
def test_current_fact_same_triple_is_not_flagged(self, palace_with_kg):
|
||||
palace, kg = palace_with_kg
|
||||
kg.add_triple("Bob", "brother", "Alice", valid_from="2010-01-01")
|
||||
issues = check_text("Bob is Alice's brother", str(palace))
|
||||
assert issues == []
|
||||
|
||||
def test_missing_palace_does_not_crash(self, tmp_path):
|
||||
"""Brand-new palace (no KG file yet) — check_text must return []
|
||||
rather than raising or hanging."""
|
||||
nonexistent = str(tmp_path / "never_created")
|
||||
assert check_text("Bob is Alice's brother", nonexistent) == []
|
||||
|
||||
|
||||
# ── end-to-end check_text contract ───────────────────────────────────
|
||||
|
||||
|
||||
class TestCheckTextContract:
|
||||
def test_empty_text_returns_empty_list(self, tmp_path):
|
||||
assert check_text("", str(tmp_path / "palace")) == []
|
||||
|
||||
def test_registry_confusion_path_isolated_from_kg(self, tmp_path, monkeypatch):
|
||||
"""If the registry file is present but the KG is missing, the
|
||||
similar-name path must still fire. Prior implementations had
|
||||
such entangled state that one failure killed both paths."""
|
||||
# Bypass the real registry by pointing cache at a temp file.
|
||||
registry = tmp_path / "known_entities.json"
|
||||
registry.write_text(json.dumps({"people": ["Milla", "Mila"]}))
|
||||
from mempalace import miner
|
||||
|
||||
monkeypatch.setattr(miner, "_ENTITY_REGISTRY_PATH", str(registry))
|
||||
miner._ENTITY_REGISTRY_CACHE.update({"mtime": None, "names": frozenset(), "raw": {}})
|
||||
|
||||
issues = check_text("Chatted with Mila.", str(tmp_path / "nonexistent_palace"))
|
||||
assert any(i["type"] == "similar_name" for i in issues)
|
||||
|
||||
|
||||
# ── CLI ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCLI:
|
||||
def test_exits_nonzero_when_issues_found(self, tmp_path, monkeypatch, capsys):
|
||||
"""The CLI exit code is how shell scripts / hooks know to act —
|
||||
pin it explicitly."""
|
||||
registry = tmp_path / "known_entities.json"
|
||||
registry.write_text(json.dumps({"people": ["Milla", "Mila"]}))
|
||||
from mempalace import fact_checker, miner
|
||||
|
||||
monkeypatch.setattr(miner, "_ENTITY_REGISTRY_PATH", str(registry))
|
||||
miner._ENTITY_REGISTRY_CACHE.update({"mtime": None, "names": frozenset(), "raw": {}})
|
||||
|
||||
# Simulate argv: "Mila said hi"
|
||||
monkeypatch.setattr(
|
||||
"sys.argv",
|
||||
["fact_checker", "Mila said hi", "--palace", str(tmp_path / "palace")],
|
||||
)
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
# Re-exec the __main__ block via runpy.
|
||||
import runpy
|
||||
|
||||
runpy.run_module("mempalace.fact_checker", run_name="__main__")
|
||||
# Issues found → exit code 1.
|
||||
assert excinfo.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "similar_name" in out
|
||||
# Silence unused import warning.
|
||||
_ = (MagicMock, patch, fact_checker)
|
||||
@@ -0,0 +1,173 @@
|
||||
"""TDD tests for hall detection in miners.
|
||||
|
||||
Written BEFORE the code — these define what correct hall assignment looks like.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class TestDetectHall:
|
||||
"""The detect_hall function should exist and route content to the right hall."""
|
||||
|
||||
def test_function_exists(self):
|
||||
from mempalace.miner import detect_hall
|
||||
|
||||
assert callable(detect_hall)
|
||||
|
||||
def test_technical_content(self):
|
||||
from mempalace.miner import detect_hall
|
||||
|
||||
text = "Fixed the python script bug in the error handler code"
|
||||
assert detect_hall(text) == "technical"
|
||||
|
||||
def test_emotions_content(self):
|
||||
from mempalace.miner import detect_hall
|
||||
|
||||
text = "I feel so happy today, tears of joy, I love this"
|
||||
assert detect_hall(text) == "emotions"
|
||||
|
||||
def test_family_content(self):
|
||||
from mempalace.miner import detect_hall
|
||||
|
||||
text = "The kids had a great day, my daughter was amazing"
|
||||
assert detect_hall(text) == "family"
|
||||
|
||||
def test_memory_content(self):
|
||||
from mempalace.miner import detect_hall
|
||||
|
||||
text = "I remember when we archived all those files, recall the conversation"
|
||||
assert detect_hall(text) == "memory"
|
||||
|
||||
def test_creative_content(self):
|
||||
from mempalace.miner import detect_hall
|
||||
|
||||
text = "The game design for the player app looks great"
|
||||
assert detect_hall(text) == "creative"
|
||||
|
||||
def test_identity_content(self):
|
||||
from mempalace.miner import detect_hall
|
||||
|
||||
text = "Who am I really? My identity and persona and sense of self"
|
||||
assert detect_hall(text) == "identity"
|
||||
|
||||
def test_consciousness_content(self):
|
||||
from mempalace.miner import detect_hall
|
||||
|
||||
text = "Am I conscious? Is this awareness real? Does my soul exist?"
|
||||
assert detect_hall(text) == "consciousness"
|
||||
|
||||
def test_general_fallback(self):
|
||||
from mempalace.miner import detect_hall
|
||||
|
||||
text = "The weather is nice today in California"
|
||||
assert detect_hall(text) == "general"
|
||||
|
||||
def test_highest_score_wins(self):
|
||||
from mempalace.miner import detect_hall
|
||||
|
||||
# More technical keywords than emotional
|
||||
text = "Fixed the python bug in the code script, felt happy about it"
|
||||
assert detect_hall(text) == "technical"
|
||||
|
||||
|
||||
class TestDrawerHasHallMetadata:
|
||||
"""When a drawer is created, it must have a hall field in metadata."""
|
||||
|
||||
def test_add_drawer_includes_hall(self, palace_path):
|
||||
from mempalace.palace import get_collection
|
||||
from mempalace.miner import add_drawer
|
||||
|
||||
col = get_collection(palace_path)
|
||||
add_drawer(
|
||||
collection=col,
|
||||
wing="test",
|
||||
room="general",
|
||||
content="Fixed the python script bug in the error handler code",
|
||||
source_file=os.path.join(palace_path, "test.py"),
|
||||
chunk_index=0,
|
||||
agent="test",
|
||||
)
|
||||
results = col.get(limit=1, include=["metadatas"])
|
||||
meta = results["metadatas"][0]
|
||||
assert "hall" in meta, "Drawer metadata must include 'hall' field"
|
||||
assert meta["hall"] == "technical"
|
||||
|
||||
|
||||
class TestConvoMinerWritesHalls:
|
||||
"""Conversation miner must also tag drawers with hall metadata."""
|
||||
|
||||
def test_convo_miner_drawers_have_hall(self, tmp_dir):
|
||||
from mempalace.palace import get_collection
|
||||
from mempalace.convo_miner import mine_convos
|
||||
|
||||
palace_dir = os.path.join(tmp_dir, "palace")
|
||||
os.makedirs(palace_dir)
|
||||
convo_dir = os.path.join(tmp_dir, "convos")
|
||||
os.makedirs(convo_dir)
|
||||
# Create a conversation file with technical content
|
||||
with open(os.path.join(convo_dir, "session.txt"), "w") as f:
|
||||
f.write("> How do I fix the python script bug?\n")
|
||||
f.write("You need to check the error handler code and fix the traceback.\n")
|
||||
f.write("> What about the database migration?\n")
|
||||
f.write("Run the migration script to update the schema.\n")
|
||||
|
||||
mine_convos(convo_dir, palace_dir, wing="test", agent="test")
|
||||
|
||||
col = get_collection(palace_dir, create=False)
|
||||
results = col.get(limit=10, include=["metadatas"])
|
||||
# At least some drawers should exist and have hall
|
||||
assert len(results["ids"]) > 0, "No drawers created by convo_miner"
|
||||
for meta in results["metadatas"]:
|
||||
if meta.get("ingest_mode") == "convos":
|
||||
assert "hall" in meta, f"Convo drawer missing hall metadata: {meta}"
|
||||
|
||||
|
||||
class TestDetectHallCaching:
|
||||
"""detect_hall should cache config to avoid disk reads per drawer."""
|
||||
|
||||
def test_detect_hall_does_not_reread_config(self):
|
||||
"""After first call, config should be cached — no new MempalaceConfig()."""
|
||||
import mempalace.miner as miner_mod
|
||||
|
||||
# Reset cache
|
||||
miner_mod._HALL_KEYWORDS_CACHE = None
|
||||
|
||||
# First call loads config
|
||||
miner_mod.detect_hall("Fixed the python bug in the code")
|
||||
assert miner_mod._HALL_KEYWORDS_CACHE is not None
|
||||
|
||||
# Save reference
|
||||
cached_ref = miner_mod._HALL_KEYWORDS_CACHE
|
||||
|
||||
# Second call should use same cached object
|
||||
miner_mod.detect_hall("I feel so happy today")
|
||||
assert miner_mod._HALL_KEYWORDS_CACHE is cached_ref
|
||||
|
||||
|
||||
class TestMineProjectWritesHalls:
|
||||
"""Full mine pipeline must produce drawers with hall metadata."""
|
||||
|
||||
def test_mined_drawers_have_hall(self, tmp_dir):
|
||||
from mempalace.palace import get_collection
|
||||
from mempalace.miner import mine
|
||||
|
||||
palace_dir = os.path.join(tmp_dir, "palace")
|
||||
os.makedirs(palace_dir)
|
||||
project_dir = os.path.join(tmp_dir, "project")
|
||||
os.makedirs(project_dir)
|
||||
# Create config
|
||||
config = {"wing": "test", "rooms": [{"name": "general", "description": "all"}]}
|
||||
with open(os.path.join(project_dir, "mempalace.yaml"), "w") as f:
|
||||
yaml.dump(config, f)
|
||||
# Create test file with technical content
|
||||
with open(os.path.join(project_dir, "code.py"), "w") as f:
|
||||
f.write("def fix_bug():\n # Fixed python script error in handler\n pass\n")
|
||||
|
||||
mine(project_dir, palace_dir, wing_override="test", agent="test")
|
||||
|
||||
col = get_collection(palace_dir, create=False)
|
||||
results = col.get(limit=10, include=["metadatas"])
|
||||
for meta in results["metadatas"]:
|
||||
assert "hall" in meta, f"Drawer missing hall metadata: {meta}"
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Tests for the hybrid closet+drawer retrieval in search_memories.
|
||||
|
||||
The hybrid path queries drawers directly (the floor) AND closets, applying a
|
||||
rank-based boost to drawers whose source_file appears in top closet hits.
|
||||
This avoids the "weak-closets regression" where low-signal closets (from
|
||||
regex extraction on narrative content) could hide drawers that direct
|
||||
search would have found.
|
||||
"""
|
||||
|
||||
from mempalace.palace import (
|
||||
get_closets_collection,
|
||||
get_collection,
|
||||
upsert_closet_lines,
|
||||
)
|
||||
from mempalace.searcher import search_memories
|
||||
|
||||
|
||||
def _seed_drawers(palace_path):
|
||||
"""Insert 4 short drawers with deterministic content."""
|
||||
col = get_collection(palace_path, create=True)
|
||||
col.upsert(
|
||||
ids=["D1", "D2", "D3", "D4"],
|
||||
documents=[
|
||||
"We switched the auth service to use JWT tokens with a 24h expiry.",
|
||||
"Database migration to PostgreSQL 15 completed last Tuesday.",
|
||||
"The frontend team is debating whether to adopt TanStack Query.",
|
||||
"Kafka consumer rebalance timeout set to 45 seconds after incident.",
|
||||
],
|
||||
metadatas=[
|
||||
{"wing": "backend", "room": "auth", "source_file": "fixture_D1.md"},
|
||||
{"wing": "backend", "room": "db", "source_file": "fixture_D2.md"},
|
||||
{"wing": "frontend", "room": "state", "source_file": "fixture_D3.md"},
|
||||
{"wing": "backend", "room": "queue", "source_file": "fixture_D4.md"},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _seed_strong_closet_for(palace_path, drawer_id, source_file, topics):
|
||||
"""Insert a closet whose content strongly overlaps the query keywords."""
|
||||
col = get_closets_collection(palace_path)
|
||||
lines = [f"{t}||→{drawer_id}" for t in topics]
|
||||
upsert_closet_lines(
|
||||
col,
|
||||
closet_id_base=f"closet_{drawer_id}",
|
||||
lines=lines,
|
||||
metadata={
|
||||
"wing": "backend",
|
||||
"room": "auth",
|
||||
"source_file": source_file,
|
||||
"generated_by": "test",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── core invariant: closets can only HELP, never HIDE ─────────────────────
|
||||
|
||||
|
||||
class TestHybridInvariant:
|
||||
def test_no_closets_degrades_to_direct_drawer_search(self, tmp_path):
|
||||
palace = str(tmp_path / "palace")
|
||||
_seed_drawers(palace)
|
||||
# No closets created.
|
||||
result = search_memories("Kafka rebalance timeout", palace, n_results=3)
|
||||
ids = [h["source_file"] for h in result["results"]]
|
||||
assert ids, "should return results"
|
||||
assert "fixture_D4.md" in ids, "direct drawer search alone should surface the Kafka drawer"
|
||||
|
||||
def test_weak_closets_do_not_hide_direct_drawer_hits(self, tmp_path):
|
||||
"""A closet that points at a wrong drawer must NOT suppress the
|
||||
drawer that direct search would have ranked first."""
|
||||
palace = str(tmp_path / "palace")
|
||||
_seed_drawers(palace)
|
||||
# Seed a misleading closet: it matches a generic phrase but points at D3.
|
||||
_seed_strong_closet_for(
|
||||
palace,
|
||||
drawer_id="D3",
|
||||
source_file="fixture_D3.md",
|
||||
topics=["Kafka queue tuning", "consumer rebalance config"],
|
||||
)
|
||||
result = search_memories("Kafka consumer rebalance timeout", palace, n_results=5)
|
||||
ids = [h["source_file"] for h in result["results"]]
|
||||
assert "fixture_D4.md" in ids, (
|
||||
"D4 must appear — direct drawer search alone would rank it first. "
|
||||
"Closet pointing to D3 should only boost D3, never hide D4."
|
||||
)
|
||||
|
||||
def test_closet_boost_lifts_matching_drawer(self, tmp_path):
|
||||
"""When a closet agrees with direct search, the matching drawer
|
||||
should be boosted to rank 1."""
|
||||
palace = str(tmp_path / "palace")
|
||||
_seed_drawers(palace)
|
||||
_seed_strong_closet_for(
|
||||
palace,
|
||||
drawer_id="D1",
|
||||
source_file="fixture_D1.md",
|
||||
topics=["JWT auth tokens", "session expiry", "authentication service"],
|
||||
)
|
||||
result = search_memories("JWT auth tokens expiry", palace, n_results=3)
|
||||
ids = [h["source_file"] for h in result["results"]]
|
||||
assert ids[0] == "fixture_D1.md"
|
||||
top = result["results"][0]
|
||||
assert top["matched_via"] == "drawer+closet"
|
||||
assert top["closet_boost"] > 0
|
||||
|
||||
|
||||
# ── closet_boost metadata ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestClosetMetadata:
|
||||
def test_closet_preview_exposed_when_boosted(self, tmp_path):
|
||||
palace = str(tmp_path / "palace")
|
||||
_seed_drawers(palace)
|
||||
_seed_strong_closet_for(
|
||||
palace,
|
||||
drawer_id="D1",
|
||||
source_file="fixture_D1.md",
|
||||
topics=["JWT auth tokens", "24h expiry", "authentication"],
|
||||
)
|
||||
result = search_memories("JWT authentication", palace, n_results=2)
|
||||
top = result["results"][0]
|
||||
assert top["source_file"] == "fixture_D1.md"
|
||||
assert "closet_preview" in top
|
||||
|
||||
def test_drawer_only_hits_have_no_closet_preview(self, tmp_path):
|
||||
palace = str(tmp_path / "palace")
|
||||
_seed_drawers(palace)
|
||||
# No closets
|
||||
result = search_memories("TanStack Query", palace, n_results=2)
|
||||
assert result["results"]
|
||||
for h in result["results"]:
|
||||
assert h["matched_via"] == "drawer"
|
||||
assert "closet_preview" not in h
|
||||
assert h["closet_boost"] == 0.0
|
||||
+33
-95
@@ -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(
|
||||
|
||||
+387
-1
@@ -6,7 +6,11 @@ dispatch layer (integration-level). Uses isolated palace + KG fixtures
|
||||
via monkeypatch to avoid touching real data.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _patch_mcp_server(monkeypatch, config, kg):
|
||||
@@ -27,7 +31,10 @@ def _get_collection(palace_path, create=False):
|
||||
|
||||
client = chromadb.PersistentClient(path=palace_path)
|
||||
if create:
|
||||
return client, client.get_or_create_collection("mempalace_drawers")
|
||||
return (
|
||||
client,
|
||||
client.get_or_create_collection("mempalace_drawers", metadata={"hnsw:space": "cosine"}),
|
||||
)
|
||||
return client, client.get_collection("mempalace_drawers")
|
||||
|
||||
|
||||
@@ -92,6 +99,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 +152,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 +302,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: 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: 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: 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: 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 +406,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 +463,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 +646,146 @@ class TestDiaryTools:
|
||||
|
||||
r = tool_diary_read(agent_name="Nobody")
|
||||
assert r["entries"] == []
|
||||
|
||||
def test_diary_write_same_second_shared_prefix_no_collision(
|
||||
self, monkeypatch, config, palace_path, kg
|
||||
):
|
||||
_patch_mcp_server(monkeypatch, config, kg)
|
||||
_client, _col = _get_collection(palace_path, create=True)
|
||||
del _client
|
||||
|
||||
from mempalace import mcp_server
|
||||
|
||||
class FrozenDateTime:
|
||||
calls = [
|
||||
datetime(2026, 4, 13, 22, 15, 30, 123456),
|
||||
datetime(2026, 4, 13, 22, 15, 30, 123457),
|
||||
]
|
||||
fallback = datetime(2026, 4, 13, 22, 15, 30, 123457)
|
||||
|
||||
@classmethod
|
||||
def now(cls):
|
||||
if cls.calls:
|
||||
return cls.calls.pop(0)
|
||||
return cls.fallback
|
||||
|
||||
monkeypatch.setattr(mcp_server, "datetime", FrozenDateTime)
|
||||
|
||||
from mempalace.mcp_server import tool_diary_read, tool_diary_write
|
||||
|
||||
entry1 = "A" * 50 + " entry one"
|
||||
entry2 = "A" * 50 + " entry two"
|
||||
|
||||
result1 = tool_diary_write(agent_name="TestAgent", entry=entry1, topic="status")
|
||||
result2 = tool_diary_write(agent_name="TestAgent", entry=entry2, topic="status")
|
||||
|
||||
assert result1["success"] is True
|
||||
assert result2["success"] is True
|
||||
assert result1["entry_id"] != result2["entry_id"]
|
||||
|
||||
read_result = tool_diary_read(agent_name="TestAgent")
|
||||
contents = [entry["content"] for entry in read_result["entries"]]
|
||||
assert read_result["total"] == 2
|
||||
assert entry1 in contents
|
||||
assert entry2 in contents
|
||||
|
||||
|
||||
# ── 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()
|
||||
+132
-7
@@ -6,8 +6,8 @@ from pathlib import Path
|
||||
import chromadb
|
||||
import yaml
|
||||
|
||||
from mempalace.miner import mine, scan_project
|
||||
from mempalace.palace import file_already_mined
|
||||
from mempalace.miner import mine, scan_project, status
|
||||
from mempalace.palace import NORMALIZE_VERSION, file_already_mined
|
||||
|
||||
|
||||
def write_file(path: Path, content: str):
|
||||
@@ -27,7 +27,8 @@ def test_project_mining():
|
||||
os.makedirs(project_root / "backend")
|
||||
|
||||
write_file(
|
||||
project_root / "backend" / "app.py", "def main():\n print('hello world')\n" * 20
|
||||
project_root / "backend" / "app.py",
|
||||
"def main():\n print('hello world')\n" * 20,
|
||||
)
|
||||
with open(project_root / "mempalace.yaml", "w") as f:
|
||||
yaml.dump(
|
||||
@@ -215,7 +216,9 @@ def test_file_already_mined_check_mtime():
|
||||
palace_path = os.path.join(tmpdir, "palace")
|
||||
os.makedirs(palace_path)
|
||||
client = chromadb.PersistentClient(path=palace_path)
|
||||
col = client.get_or_create_collection("mempalace_drawers")
|
||||
col = client.get_or_create_collection(
|
||||
"mempalace_drawers", metadata={"hnsw:space": "cosine"}
|
||||
)
|
||||
|
||||
test_file = os.path.join(tmpdir, "test.txt")
|
||||
with open(test_file, "w") as f:
|
||||
@@ -227,11 +230,17 @@ def test_file_already_mined_check_mtime():
|
||||
assert file_already_mined(col, test_file) is False
|
||||
assert file_already_mined(col, test_file, check_mtime=True) is False
|
||||
|
||||
# Add it with mtime
|
||||
# Add it with mtime + current normalize_version
|
||||
col.add(
|
||||
ids=["d1"],
|
||||
documents=["hello world"],
|
||||
metadatas=[{"source_file": test_file, "source_mtime": str(mtime)}],
|
||||
metadatas=[
|
||||
{
|
||||
"source_file": test_file,
|
||||
"source_mtime": str(mtime),
|
||||
"normalize_version": NORMALIZE_VERSION,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
# Already mined (no mtime check)
|
||||
@@ -253,10 +262,126 @@ def test_file_already_mined_check_mtime():
|
||||
col.add(
|
||||
ids=["d2"],
|
||||
documents=["other"],
|
||||
metadatas=[{"source_file": "/fake/no_mtime.txt"}],
|
||||
metadatas=[
|
||||
{
|
||||
"source_file": "/fake/no_mtime.txt",
|
||||
"normalize_version": NORMALIZE_VERSION,
|
||||
}
|
||||
],
|
||||
)
|
||||
assert file_already_mined(col, "/fake/no_mtime.txt", check_mtime=True) is False
|
||||
finally:
|
||||
# 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()
|
||||
|
||||
|
||||
# ── normalize_version schema gate ───────────────────────────────────────
|
||||
#
|
||||
# When the normalization pipeline changes shape (e.g., strip_noise lands),
|
||||
# `NORMALIZE_VERSION` is bumped so pre-existing drawers can be silently
|
||||
# rebuilt on the next mine. These tests pin that contract.
|
||||
|
||||
|
||||
def test_file_already_mined_returns_false_for_stale_normalize_version():
|
||||
"""Pre-v2 drawers (no field, or older integer) must not short-circuit."""
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
palace_path = os.path.join(tmpdir, "palace")
|
||||
os.makedirs(palace_path)
|
||||
client = chromadb.PersistentClient(path=palace_path)
|
||||
col = client.get_or_create_collection("mempalace_drawers")
|
||||
|
||||
# Pre-v2 drawer: no normalize_version field at all
|
||||
col.add(
|
||||
ids=["d_old"],
|
||||
documents=["old"],
|
||||
metadatas=[{"source_file": "/fake/old.jsonl"}],
|
||||
)
|
||||
assert file_already_mined(col, "/fake/old.jsonl") is False
|
||||
|
||||
# Explicitly older version
|
||||
col.add(
|
||||
ids=["d_v1"],
|
||||
documents=["v1"],
|
||||
metadatas=[{"source_file": "/fake/v1.jsonl", "normalize_version": 1}],
|
||||
)
|
||||
assert file_already_mined(col, "/fake/v1.jsonl") is False
|
||||
|
||||
# Current version — short-circuits
|
||||
col.add(
|
||||
ids=["d_current"],
|
||||
documents=["cur"],
|
||||
metadatas=[
|
||||
{
|
||||
"source_file": "/fake/current.jsonl",
|
||||
"normalize_version": NORMALIZE_VERSION,
|
||||
}
|
||||
],
|
||||
)
|
||||
assert file_already_mined(col, "/fake/current.jsonl") is True
|
||||
finally:
|
||||
del col, client
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
def test_add_drawer_stamps_normalize_version(tmp_path):
|
||||
"""Fresh drawers carry the current schema version so future upgrades work."""
|
||||
from mempalace.miner import add_drawer
|
||||
|
||||
palace_path = tmp_path / "palace"
|
||||
palace_path.mkdir()
|
||||
client = chromadb.PersistentClient(path=str(palace_path))
|
||||
col = client.get_or_create_collection("mempalace_drawers")
|
||||
try:
|
||||
added = add_drawer(
|
||||
collection=col,
|
||||
wing="test",
|
||||
room="notes",
|
||||
content="hello",
|
||||
source_file=str(tmp_path / "src.md"),
|
||||
chunk_index=0,
|
||||
agent="unit",
|
||||
)
|
||||
assert added is True
|
||||
stored = col.get(limit=1)
|
||||
meta = stored["metadatas"][0]
|
||||
assert meta["normalize_version"] == NORMALIZE_VERSION
|
||||
finally:
|
||||
del col, client
|
||||
|
||||
+687
-2
@@ -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,
|
||||
@@ -11,6 +13,7 @@ from mempalace.normalize import (
|
||||
_try_normalize_json,
|
||||
_try_slack_json,
|
||||
normalize,
|
||||
strip_noise,
|
||||
)
|
||||
|
||||
|
||||
@@ -81,7 +84,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 +102,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 +525,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 +842,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):
|
||||
@@ -509,3 +1049,148 @@ def test_normalize_rejects_large_file():
|
||||
assert False, "Should have raised IOError"
|
||||
except IOError as e:
|
||||
assert "too large" in str(e).lower()
|
||||
|
||||
|
||||
# ── strip_noise() — verbatim-safety boundary tests ─────────────────────
|
||||
#
|
||||
# The "Verbatim always" design principle requires that we never delete
|
||||
# user-authored text. These tests pin down the boundary between system
|
||||
# noise (which we strip) and user prose that happens to mention the same
|
||||
# strings (which must survive untouched).
|
||||
|
||||
|
||||
class TestStripNoisePreservesUserContent:
|
||||
"""User prose that mentions noise strings inline must be preserved."""
|
||||
|
||||
def test_user_discusses_stop_hook_in_prose(self):
|
||||
# Regression: original regex with IGNORECASE + `.*\n?` ate the second
|
||||
# sentence from real user commentary.
|
||||
text = (
|
||||
"> User:\n"
|
||||
"> Our CI has a stop hook that rejects merges after 5pm. "
|
||||
"Ran 2 stop hooks last week.\n"
|
||||
"> Assistant:\n"
|
||||
"> Got it."
|
||||
)
|
||||
assert strip_noise(text) == text.strip()
|
||||
|
||||
def test_user_mentions_system_reminder_inline(self):
|
||||
# Inline <system-reminder> tags inside user prose (e.g. documenting
|
||||
# Claude Code behavior) must not be stripped.
|
||||
text = (
|
||||
"> User:\n"
|
||||
"> Here is what Claude Code emits: "
|
||||
"<system-reminder>Auto-save reminder...</system-reminder>"
|
||||
" — I want to ignore it."
|
||||
)
|
||||
assert strip_noise(text) == text.strip()
|
||||
|
||||
def test_ctrl_o_hint_in_prose_preserved(self):
|
||||
# Regression: original `.*\(ctrl\+o to expand\).*\n?` nuked the whole
|
||||
# line whenever a user documented the TUI shortcut.
|
||||
text = (
|
||||
"> User:\n"
|
||||
"> In the TUI you hit (ctrl+o to expand) to see more. "
|
||||
"That is the shortcut I want to document."
|
||||
)
|
||||
assert strip_noise(text) == text.strip()
|
||||
|
||||
def test_current_time_inline_in_prose(self):
|
||||
text = "> User:\n> At CURRENT TIME: the meeting starts, not before."
|
||||
assert strip_noise(text) == text.strip()
|
||||
|
||||
def test_plus_n_lines_marker_inline(self):
|
||||
text = "> User:\n> The log showed … +50 lines of stack trace, useful."
|
||||
assert strip_noise(text) == text.strip()
|
||||
|
||||
def test_dangling_open_tag_does_not_span_messages(self):
|
||||
# THE span-eating bug: a stray unclosed <system-reminder> in one
|
||||
# message must NOT merge with a closing tag in another message and
|
||||
# silently delete everything in between.
|
||||
text = (
|
||||
"> User 1: normal content <system-reminder>A\n"
|
||||
"> Assistant: reply\n"
|
||||
"> User 2: more content</system-reminder> tail"
|
||||
)
|
||||
out = strip_noise(text)
|
||||
assert "Assistant: reply" in out
|
||||
assert "User 2: more content" in out
|
||||
assert "User 1: normal content" in out
|
||||
|
||||
|
||||
class TestStripNoiseRemovesSystemChrome:
|
||||
"""System-injected noise with standalone/line-anchored shape must be stripped."""
|
||||
|
||||
def test_strips_line_anchored_system_reminder_block(self):
|
||||
text = (
|
||||
"> User:\n"
|
||||
"<system-reminder>\n"
|
||||
"Auto-save reminder...\n"
|
||||
"</system-reminder>\n"
|
||||
"> Real message."
|
||||
)
|
||||
out = strip_noise(text)
|
||||
assert "system-reminder" not in out
|
||||
assert "Auto-save reminder" not in out
|
||||
assert "Real message." in out
|
||||
|
||||
def test_strips_system_reminder_with_blockquote_prefix(self):
|
||||
# _messages_to_transcript prefixes lines with "> ", so the line
|
||||
# anchor must also accept that shape.
|
||||
text = "> User:\n" "> <system-reminder>Injected noise</system-reminder>\n" "> Real message."
|
||||
out = strip_noise(text)
|
||||
assert "Injected noise" not in out
|
||||
assert "Real message." in out
|
||||
|
||||
def test_strips_standalone_ran_hook_line(self):
|
||||
text = "Ran 2 Stop hook\n> User: real content"
|
||||
out = strip_noise(text)
|
||||
assert "Ran 2 Stop hook" not in out
|
||||
assert "real content" in out
|
||||
|
||||
def test_strips_known_hook_names(self):
|
||||
for hook in ("Stop", "PreCompact", "PreToolUse", "PostToolUse", "UserPromptSubmit"):
|
||||
text = f"Ran 1 {hook} hook\n> User: content"
|
||||
assert hook not in strip_noise(text)
|
||||
|
||||
def test_strips_current_time_standalone(self):
|
||||
text = "CURRENT TIME: 2026-04-13 10:00 UTC\n> User: Hello"
|
||||
out = strip_noise(text)
|
||||
assert "CURRENT TIME" not in out
|
||||
assert "Hello" in out
|
||||
|
||||
def test_strips_collapsed_lines_marker(self):
|
||||
text = "… +42 lines\n> User: Hello"
|
||||
out = strip_noise(text)
|
||||
assert "+42 lines" not in out
|
||||
assert "Hello" in out
|
||||
|
||||
def test_strips_token_count_ctrl_o_chrome(self):
|
||||
# Claude Code's actual collapsed-output chrome: "[N tokens] (ctrl+o to expand)"
|
||||
text = "> Assistant: some output [5 tokens] (ctrl+o to expand)\n> User: ok"
|
||||
out = strip_noise(text)
|
||||
assert "(ctrl+o to expand)" not in out
|
||||
assert "[5 tokens]" not in out
|
||||
assert "some output" in out
|
||||
|
||||
def test_strips_each_known_noise_tag(self):
|
||||
for tag in (
|
||||
"system-reminder",
|
||||
"command-message",
|
||||
"command-name",
|
||||
"task-notification",
|
||||
"user-prompt-submit-hook",
|
||||
"hook_output",
|
||||
):
|
||||
text = f"> User:\n<{tag}>junk</{tag}>\n> Real."
|
||||
out = strip_noise(text)
|
||||
assert tag not in out, f"{tag} leaked into output"
|
||||
assert "Real." in out
|
||||
|
||||
def test_collapses_excessive_blank_lines(self):
|
||||
text = "line one\n\n\n\n\n\nline two"
|
||||
out = strip_noise(text)
|
||||
assert "line one" in out
|
||||
assert "line two" in out
|
||||
# Should collapse to no more than 3 newlines
|
||||
assert "\n\n\n\n" not in out
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
Tests for query_sanitizer.py — system prompt contamination mitigation (#333).
|
||||
|
||||
Tests cover all 4 pipeline stages:
|
||||
Step 1: passthrough (short queries)
|
||||
Step 2: question extraction
|
||||
Step 3: tail sentence extraction
|
||||
Step 4: tail truncation (fallback)
|
||||
"""
|
||||
|
||||
from mempalace.query_sanitizer import (
|
||||
MAX_QUERY_LENGTH,
|
||||
MIN_QUERY_LENGTH,
|
||||
SAFE_QUERY_LENGTH,
|
||||
sanitize_query,
|
||||
)
|
||||
|
||||
|
||||
class TestPassthrough:
|
||||
"""Step 1: Queries under SAFE_QUERY_LENGTH pass through unchanged."""
|
||||
|
||||
def test_short_query_unchanged(self):
|
||||
result = sanitize_query("What is Rust error handling?")
|
||||
assert result["clean_query"] == "What is Rust error handling?"
|
||||
assert result["was_sanitized"] is False
|
||||
assert result["method"] == "passthrough"
|
||||
|
||||
def test_empty_query(self):
|
||||
result = sanitize_query("")
|
||||
assert result["clean_query"] == ""
|
||||
assert result["was_sanitized"] is False
|
||||
assert result["method"] == "passthrough"
|
||||
|
||||
def test_none_query(self):
|
||||
result = sanitize_query(None)
|
||||
assert result["was_sanitized"] is False
|
||||
assert result["method"] == "passthrough"
|
||||
|
||||
def test_exactly_safe_length(self):
|
||||
query = "a" * SAFE_QUERY_LENGTH
|
||||
result = sanitize_query(query)
|
||||
assert result["was_sanitized"] is False
|
||||
assert result["method"] == "passthrough"
|
||||
|
||||
def test_one_over_safe_length_triggers_sanitization(self):
|
||||
query = "a" * (SAFE_QUERY_LENGTH + 1)
|
||||
result = sanitize_query(query)
|
||||
# Will go through sanitization pipeline (may or may not change the query)
|
||||
assert result["original_length"] == SAFE_QUERY_LENGTH + 1
|
||||
|
||||
|
||||
class TestQuestionExtraction:
|
||||
"""Step 2: Extract question sentences (ending with ?)."""
|
||||
|
||||
def test_question_at_end_of_long_text(self):
|
||||
system_prompt = "You are a helpful assistant. " * 50 # ~1400 chars
|
||||
query = system_prompt + "What is the best way to handle errors in Rust?"
|
||||
result = sanitize_query(query)
|
||||
assert result["was_sanitized"] is True
|
||||
assert "error" in result["clean_query"].lower() or "Rust" in result["clean_query"]
|
||||
assert result["method"] == "question_extraction"
|
||||
|
||||
def test_japanese_question_mark(self):
|
||||
system_prompt = "You are a helpful assistant. " * 50
|
||||
query = system_prompt + "Rustのエラーハンドリング方法は?"
|
||||
result = sanitize_query(query)
|
||||
assert result["was_sanitized"] is True
|
||||
assert "Rust" in result["clean_query"] or "エラー" in result["clean_query"]
|
||||
assert result["method"] == "question_extraction"
|
||||
|
||||
def test_multiple_questions_takes_last(self):
|
||||
system_prompt = "You are a helpful assistant. " * 50
|
||||
query = system_prompt + "What is Python?\nHow does Rust handle errors?"
|
||||
result = sanitize_query(query)
|
||||
assert "Rust" in result["clean_query"] or "error" in result["clean_query"].lower()
|
||||
|
||||
def test_question_in_system_prompt_ignored_when_real_question_exists(self):
|
||||
# System prompt contains a question, but real query also has one
|
||||
system_prompt = "Are you ready to help? " * 30 + "\n"
|
||||
real_query = "What databases does MemPalace support?"
|
||||
query = system_prompt + real_query
|
||||
result = sanitize_query(query)
|
||||
assert result["was_sanitized"] is True
|
||||
assert "MemPalace" in result["clean_query"] or "database" in result["clean_query"].lower()
|
||||
|
||||
|
||||
class TestTailSentence:
|
||||
"""Step 3: Extract the last meaningful sentence when no question mark found."""
|
||||
|
||||
def test_command_style_query(self):
|
||||
system_prompt = "You are a helpful assistant. " * 50
|
||||
query = system_prompt + "Show me all Rust error handling patterns"
|
||||
result = sanitize_query(query)
|
||||
assert result["was_sanitized"] is True
|
||||
assert "Rust" in result["clean_query"] or "error" in result["clean_query"].lower()
|
||||
assert result["method"] in ("tail_sentence", "question_extraction")
|
||||
|
||||
def test_keyword_style_query(self):
|
||||
system_prompt = "System configuration loaded. " * 60
|
||||
query = system_prompt + "\nMemPalace ChromaDB integration setup"
|
||||
result = sanitize_query(query)
|
||||
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."""
|
||||
|
||||
def test_single_long_line_no_sentences(self):
|
||||
# Short lines only — no segment reaches MIN_QUERY_LENGTH; fallback truncates tail
|
||||
filler = "\n".join(["ab"] * 200)
|
||||
result = sanitize_query(filler)
|
||||
assert result["was_sanitized"] is True
|
||||
assert len(result["clean_query"]) <= MAX_QUERY_LENGTH
|
||||
assert result["method"] == "tail_truncation"
|
||||
|
||||
def test_truncation_preserves_tail(self):
|
||||
filler = "x" * 1000 + "IMPORTANT_QUERY_CONTENT"
|
||||
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 + "?"
|
||||
system_prompt = "Context. " * 100
|
||||
query = system_prompt + long_question
|
||||
result = sanitize_query(query)
|
||||
assert len(result["clean_query"]) <= MAX_QUERY_LENGTH
|
||||
|
||||
def test_extraction_too_short_falls_through(self):
|
||||
# Question mark found but the sentence is too short
|
||||
system_prompt = "You are helpful. " * 50
|
||||
query = system_prompt + "\nOK?"
|
||||
result = sanitize_query(query)
|
||||
# "OK?" is only 3 chars < MIN_QUERY_LENGTH, should fall through
|
||||
assert result["was_sanitized"] is True
|
||||
|
||||
|
||||
class TestMetadata:
|
||||
"""Verify sanitizer metadata is correct."""
|
||||
|
||||
def test_original_length_preserved(self):
|
||||
system_prompt = "You are a helpful assistant. " * 50
|
||||
query = system_prompt + "What is Rust?"
|
||||
result = sanitize_query(query)
|
||||
assert result["original_length"] == len(query.strip())
|
||||
|
||||
def test_clean_length_matches_clean_query(self):
|
||||
system_prompt = "You are a helpful assistant. " * 50
|
||||
query = system_prompt + "What is Rust?"
|
||||
result = sanitize_query(query)
|
||||
assert result["clean_length"] == len(result["clean_query"])
|
||||
|
||||
def test_sanitized_flag_true_when_changed(self):
|
||||
system_prompt = "You are a helpful assistant. " * 50
|
||||
query = system_prompt + "What is Rust?"
|
||||
result = sanitize_query(query)
|
||||
assert result["was_sanitized"] is True
|
||||
|
||||
def test_sanitized_flag_false_when_unchanged(self):
|
||||
result = sanitize_query("Short query")
|
||||
assert result["was_sanitized"] is False
|
||||
|
||||
|
||||
class TestRealWorldScenarios:
|
||||
"""Simulate realistic system prompt contamination patterns."""
|
||||
|
||||
def test_mempalace_wakeup_prepended(self):
|
||||
"""Simulates mempalace wake-up output prepended to a query."""
|
||||
wakeup = (
|
||||
"MemPalace loaded. Wings: technical, emotions, identity. "
|
||||
"Rooms: chromadb-setup, error-handling, project-planning. "
|
||||
"Total drawers: 234. Knowledge graph: 89 entities, 156 triples. "
|
||||
"AAAK dialect active. Protocol: verify before responding. "
|
||||
) * 5 # ~1000 chars
|
||||
real_query = "How did we decide on the database architecture?"
|
||||
query = wakeup + real_query
|
||||
result = sanitize_query(query)
|
||||
assert result["was_sanitized"] is True
|
||||
assert len(result["clean_query"]) <= MAX_QUERY_LENGTH
|
||||
# Should recover something meaningful
|
||||
assert len(result["clean_query"]) >= MIN_QUERY_LENGTH
|
||||
|
||||
def test_memory_md_prepended(self):
|
||||
"""Simulates MEMORY.md content prepended to a query."""
|
||||
memory_md = (
|
||||
"# Project Memory\n"
|
||||
"## Architecture Decisions\n"
|
||||
"- Use ChromaDB for vector storage\n"
|
||||
"- MCP protocol for tool integration\n"
|
||||
"- AAAK compression for efficient storage\n"
|
||||
) * 10 # ~750 chars
|
||||
real_query = "What were the performance benchmarks for the search system?"
|
||||
query = memory_md + "\n" + real_query
|
||||
result = sanitize_query(query)
|
||||
assert result["was_sanitized"] is True
|
||||
assert result["method"] in ("question_extraction", "tail_sentence")
|
||||
|
||||
def test_2000_char_system_prompt_with_question(self):
|
||||
"""The exact scenario from Issue #333 — 2000 chars prepended."""
|
||||
system_prompt = "You are an AI assistant with access to tools. " * 45 # ~2000 chars
|
||||
real_query = "What is the status of the MemPalace project?"
|
||||
query = system_prompt + real_query
|
||||
result = sanitize_query(query)
|
||||
assert result["was_sanitized"] is True
|
||||
assert result["original_length"] > 2000
|
||||
assert result["clean_length"] <= MAX_QUERY_LENGTH
|
||||
assert result["method"] == "question_extraction"
|
||||
@@ -0,0 +1,737 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
test_readme_claims.py — TDD verification of every major README claim against actual code.
|
||||
|
||||
Each test verifies a specific claim made in README.md. If a test fails, either
|
||||
the README is wrong or the code hasn't shipped the feature yet. Fix one or the
|
||||
other until all tests pass — that's when the README matches reality.
|
||||
|
||||
Based on the audit at ~/Desktop/readme_audit.md (2026-04-13).
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers — locate repo root and parse README / source files
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
MEMPALACE_PKG = REPO_ROOT / "mempalace"
|
||||
README_PATH = REPO_ROOT / "README.md"
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
def _readme() -> str:
|
||||
return _read(README_PATH)
|
||||
|
||||
|
||||
def _tools_dict_keys() -> list:
|
||||
"""Return the list of tool names registered in the TOOLS dict."""
|
||||
# Import the module-level TOOLS dict. We can't just import mcp_server
|
||||
# because it calls chromadb on import, so we parse the source instead.
|
||||
src = _read(MEMPALACE_PKG / "mcp_server.py")
|
||||
return re.findall(r'"(mempalace_\w+)":\s*\{', src)
|
||||
|
||||
|
||||
def _readme_tool_table_names() -> list:
|
||||
"""Return tool names listed in the README's MCP tool table."""
|
||||
readme = _readme()
|
||||
return re.findall(r"^\| `(mempalace_\w+)`", readme, re.MULTILINE)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Tool count — README says 19, verify actual count
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestToolCount:
|
||||
"""README claims '19 tools available through MCP' in multiple places."""
|
||||
|
||||
def test_readme_tool_count_matches_code(self):
|
||||
"""Claim: README says 19 tools. Actual TOOLS dict may differ.
|
||||
|
||||
This test asserts the REAL tool count so the README can be updated.
|
||||
If TOOLS has 25 entries, the README should say 25, not 19.
|
||||
"""
|
||||
actual_count = len(_tools_dict_keys())
|
||||
readme = _readme()
|
||||
# Find all "19 tools" claims in README
|
||||
claimed_counts = re.findall(r"(\d+)\s+tools", readme)
|
||||
for claimed in claimed_counts:
|
||||
assert int(claimed) == actual_count, (
|
||||
f"README claims {claimed} tools but TOOLS dict has {actual_count}. "
|
||||
f"Update every occurrence of '{claimed} tools' to '{actual_count} tools'."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Every tool listed in README actually exists in TOOLS dict
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReadmeToolsExistInCode:
|
||||
"""Every tool name in the README tool table must be a key in TOOLS."""
|
||||
|
||||
def test_every_readme_tool_exists_in_tools_dict(self):
|
||||
"""Claim: README lists tools like mempalace_get_aaak_spec.
|
||||
Each one must actually be registered in the TOOLS dict."""
|
||||
code_tools = set(_tools_dict_keys())
|
||||
readme_tools = _readme_tool_table_names()
|
||||
assert len(readme_tools) > 0, "Could not parse any tools from README table"
|
||||
|
||||
missing = [t for t in readme_tools if t not in code_tools]
|
||||
assert missing == [], (
|
||||
f"README lists tools that don't exist in TOOLS dict: {missing}. "
|
||||
f"Either add them to mcp_server.py or remove them from README."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. No tool in TOOLS dict is missing from README's tool table
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNoUnlistedTools:
|
||||
"""Every tool in the TOOLS dict should be documented in the README."""
|
||||
|
||||
def test_no_undocumented_tools(self):
|
||||
"""Claim: README's tool table is complete.
|
||||
Any tool in TOOLS but not in README is undocumented."""
|
||||
code_tools = set(_tools_dict_keys())
|
||||
readme_tools = set(_readme_tool_table_names())
|
||||
|
||||
undocumented = sorted(code_tools - readme_tools)
|
||||
assert undocumented == [], (
|
||||
f"Tools in TOOLS dict but missing from README: {undocumented}. "
|
||||
f"Add rows for these to the tool table in README.md."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Closets collection exists — palace.py has get_closets_collection()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClosetsExist:
|
||||
"""README describes closets as a core architectural feature."""
|
||||
|
||||
def test_get_closets_collection_exists(self):
|
||||
"""Claim: closets are a shipped feature.
|
||||
palace.py must export get_closets_collection()."""
|
||||
src = _read(MEMPALACE_PKG / "palace.py")
|
||||
assert "def get_closets_collection(" in src, (
|
||||
"palace.py does not define get_closets_collection(). "
|
||||
"Closets are described in README but the collection function is missing."
|
||||
)
|
||||
|
||||
def test_closets_importable(self):
|
||||
"""get_closets_collection should be importable from mempalace.palace."""
|
||||
from mempalace.palace import get_closets_collection
|
||||
|
||||
assert callable(get_closets_collection)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Closet-first search exists in searcher.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClosetFirstSearch:
|
||||
"""README implies search goes through closets, not just direct drawer query."""
|
||||
|
||||
def test_closet_boost_search_exists(self):
|
||||
"""Claim: search uses closets as a boost signal.
|
||||
searcher.py must have CLOSET_RANK_BOOSTS and query closets_col."""
|
||||
src = _read(MEMPALACE_PKG / "searcher.py")
|
||||
assert "CLOSET_RANK_BOOSTS" in src, (
|
||||
"searcher.py has no closet boost logic. "
|
||||
"README describes closet-based search but searcher.py has no closet ranking."
|
||||
)
|
||||
|
||||
def test_searcher_imports_closets(self):
|
||||
"""searcher.py must import get_closets_collection to use closets."""
|
||||
src = _read(MEMPALACE_PKG / "searcher.py")
|
||||
assert "get_closets_collection" in src, (
|
||||
"searcher.py does not reference get_closets_collection. "
|
||||
"Closet-first search can't work without the closets collection."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. BM25 hybrid search functions exist
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBM25HybridSearch:
|
||||
"""README claims 'BM25 hybrid search'. Verify the functions exist."""
|
||||
|
||||
def test_bm25_in_searcher(self):
|
||||
"""Claim: BM25 hybrid search is shipped.
|
||||
searcher.py must have BM25 scoring or hybrid ranking logic."""
|
||||
src = _read(MEMPALACE_PKG / "searcher.py")
|
||||
has_bm25 = any(
|
||||
term in src.lower()
|
||||
for term in [
|
||||
"bm25",
|
||||
"_bm25_score",
|
||||
"_hybrid_rank",
|
||||
"hybrid_search",
|
||||
"bm25_score",
|
||||
"rank_bm25",
|
||||
]
|
||||
)
|
||||
assert has_bm25, (
|
||||
"searcher.py has no BM25 or hybrid search function. "
|
||||
"README claims BM25 hybrid search but it's not in the code."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Entity metadata extraction exists in miner.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEntityMetadataExtraction:
|
||||
"""README implies entity extraction populates drawer/closet metadata."""
|
||||
|
||||
def test_entity_extraction_in_palace_or_miner(self):
|
||||
"""Claim: entity extraction is part of the mining pipeline.
|
||||
Either miner.py or palace.py must extract entities."""
|
||||
miner_src = _read(MEMPALACE_PKG / "miner.py")
|
||||
palace_src = _read(MEMPALACE_PKG / "palace.py")
|
||||
# Entity extraction can be in either file — palace.py has it for closets
|
||||
has_entity_extraction = (
|
||||
"entities" in palace_src and "_ENTITY_STOPLIST" in palace_src
|
||||
) or "extract_entities" in miner_src
|
||||
assert has_entity_extraction, (
|
||||
"No entity extraction found in miner.py or palace.py. "
|
||||
"README implies entities are extracted during mining."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. strip_noise function exists in normalize.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStripNoise:
|
||||
"""normalize.py should have strip_noise() for cleaning input text."""
|
||||
|
||||
def test_strip_noise_exists(self):
|
||||
"""Claim: normalize.py has noise stripping.
|
||||
Function strip_noise must exist."""
|
||||
src = _read(MEMPALACE_PKG / "normalize.py")
|
||||
assert "def strip_noise(" in src, (
|
||||
"normalize.py does not define strip_noise(). "
|
||||
"This function is referenced in the normalization pipeline."
|
||||
)
|
||||
|
||||
def test_strip_noise_importable(self):
|
||||
"""strip_noise should be importable from mempalace.normalize."""
|
||||
from mempalace.normalize import strip_noise
|
||||
|
||||
assert callable(strip_noise)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. diary_ingest.py module exists and is importable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDiaryIngest:
|
||||
"""README describes diary ingest (day-based). Module must exist."""
|
||||
|
||||
def test_diary_ingest_module_exists(self):
|
||||
"""Claim: diary_ingest.py is a shipped module.
|
||||
File must exist at mempalace/diary_ingest.py."""
|
||||
path = MEMPALACE_PKG / "diary_ingest.py"
|
||||
assert path.is_file(), (
|
||||
"mempalace/diary_ingest.py does not exist. "
|
||||
"README describes diary ingest but the module is missing (still in an unmerged PR?)."
|
||||
)
|
||||
|
||||
def test_diary_ingest_importable(self):
|
||||
"""diary_ingest should be importable."""
|
||||
try:
|
||||
importlib.import_module("mempalace.diary_ingest")
|
||||
except ImportError:
|
||||
pytest.fail(
|
||||
"mempalace.diary_ingest is not importable. Module must exist and import cleanly."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. fact_checker.py module exists and is importable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactChecker:
|
||||
"""README has a 'Contradiction detection' section implying fact_checker.py."""
|
||||
|
||||
def test_fact_checker_module_exists(self):
|
||||
"""Claim: contradiction detection is shipped.
|
||||
fact_checker.py must exist at mempalace/fact_checker.py."""
|
||||
path = MEMPALACE_PKG / "fact_checker.py"
|
||||
assert path.is_file(), (
|
||||
"mempalace/fact_checker.py does not exist. "
|
||||
"README describes contradiction detection but the module is missing."
|
||||
)
|
||||
|
||||
def test_fact_checker_importable(self):
|
||||
"""fact_checker should be importable."""
|
||||
try:
|
||||
importlib.import_module("mempalace.fact_checker")
|
||||
except ImportError:
|
||||
pytest.fail(
|
||||
"mempalace.fact_checker is not importable. Module must exist and import cleanly."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11. Tunnel functions exist in palace_graph.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTunnelFunctions:
|
||||
"""README describes tunnels — connections between wings."""
|
||||
|
||||
def test_find_tunnels_exists(self):
|
||||
"""Claim: tunnels connect rooms across wings.
|
||||
palace_graph.py must have find_tunnels()."""
|
||||
src = _read(MEMPALACE_PKG / "palace_graph.py")
|
||||
assert "def find_tunnels(" in src, (
|
||||
"palace_graph.py has no find_tunnels() function. "
|
||||
"README describes tunnels but the function is missing."
|
||||
)
|
||||
|
||||
def test_traverse_exists(self):
|
||||
"""Claim: you can walk the palace graph.
|
||||
palace_graph.py must have traverse()."""
|
||||
src = _read(MEMPALACE_PKG / "palace_graph.py")
|
||||
assert "def traverse(" in src, "palace_graph.py has no traverse() function."
|
||||
|
||||
def test_graph_stats_exists(self):
|
||||
"""palace_graph.py must have graph_stats()."""
|
||||
src = _read(MEMPALACE_PKG / "palace_graph.py")
|
||||
assert "def graph_stats(" in src, "palace_graph.py has no graph_stats() function."
|
||||
|
||||
def test_tunnel_functions_importable(self):
|
||||
"""find_tunnels, traverse, graph_stats should be importable."""
|
||||
from mempalace.palace_graph import find_tunnels, traverse, graph_stats
|
||||
|
||||
assert callable(find_tunnels)
|
||||
assert callable(traverse)
|
||||
assert callable(graph_stats)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 12. closet_llm.py module exists and is importable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClosetLLM:
|
||||
"""README describes LLM-based closet regeneration. Module must exist."""
|
||||
|
||||
def test_closet_llm_module_exists(self):
|
||||
"""Claim: LLM-based closet regen is shipped.
|
||||
closet_llm.py must exist at mempalace/closet_llm.py."""
|
||||
path = MEMPALACE_PKG / "closet_llm.py"
|
||||
assert path.is_file(), (
|
||||
"mempalace/closet_llm.py does not exist. "
|
||||
"README describes LLM closet regeneration but the module is missing."
|
||||
)
|
||||
|
||||
def test_closet_llm_importable(self):
|
||||
"""closet_llm should be importable."""
|
||||
try:
|
||||
importlib.import_module("mempalace.closet_llm")
|
||||
except ImportError:
|
||||
pytest.fail(
|
||||
"mempalace.closet_llm is not importable. Module must exist and import cleanly."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 13. mine_lock exists in palace.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMineLock:
|
||||
"""Multi-agent file locking must be shipped (PR #784 was merged)."""
|
||||
|
||||
def test_mine_lock_exists(self):
|
||||
"""Claim: multi-agent file locking is shipped.
|
||||
palace.py must define mine_lock."""
|
||||
src = _read(MEMPALACE_PKG / "palace.py")
|
||||
assert "def mine_lock(" in src, (
|
||||
"palace.py does not define mine_lock(). "
|
||||
"Multi-agent locking is claimed as shipped but function is missing."
|
||||
)
|
||||
|
||||
def test_mine_lock_importable(self):
|
||||
"""mine_lock should be importable from mempalace.palace."""
|
||||
from mempalace.palace import mine_lock
|
||||
|
||||
assert callable(mine_lock)
|
||||
|
||||
def test_mine_lock_is_context_manager(self):
|
||||
"""mine_lock should be a context manager (used with `with` statement)."""
|
||||
src = _read(MEMPALACE_PKG / "palace.py")
|
||||
# It should be decorated with @contextlib.contextmanager or similar
|
||||
# Find the mine_lock definition and check for context manager pattern
|
||||
assert "@contextlib.contextmanager" in src or "def __enter__" in src, (
|
||||
"mine_lock does not appear to be a context manager. "
|
||||
"It should be usable with `with mine_lock(path):` syntax."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 14. Version in version.py matches pyproject.toml
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVersionConsistency:
|
||||
"""version.py and pyproject.toml must agree on the version string."""
|
||||
|
||||
def test_version_py_matches_pyproject(self):
|
||||
"""Claim: single source of truth for version.
|
||||
version.py __version__ must match pyproject.toml version."""
|
||||
version_src = _read(MEMPALACE_PKG / "version.py")
|
||||
version_match = re.search(r'__version__\s*=\s*"([^"]+)"', version_src)
|
||||
assert version_match, "Could not parse __version__ from version.py"
|
||||
code_version = version_match.group(1)
|
||||
|
||||
pyproject_src = _read(REPO_ROOT / "pyproject.toml")
|
||||
pyproject_match = re.search(r'^version\s*=\s*"([^"]+)"', pyproject_src, re.MULTILINE)
|
||||
assert pyproject_match, "Could not parse version from pyproject.toml"
|
||||
toml_version = pyproject_match.group(1)
|
||||
|
||||
assert code_version == toml_version, (
|
||||
f"version.py says {code_version} but pyproject.toml says {toml_version}. "
|
||||
f"These must match."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 15. Version badge URL in README matches version.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVersionBadge:
|
||||
"""README version badge must show the current version, not a stale one."""
|
||||
|
||||
def test_readme_badge_matches_version_py(self):
|
||||
"""Claim: README badge shows current version.
|
||||
The shields.io badge URL must contain the version from version.py."""
|
||||
version_src = _read(MEMPALACE_PKG / "version.py")
|
||||
version_match = re.search(r'__version__\s*=\s*"([^"]+)"', version_src)
|
||||
assert version_match, "Could not parse __version__ from version.py"
|
||||
code_version = version_match.group(1)
|
||||
|
||||
readme = _readme()
|
||||
# Find the version badge URL
|
||||
badge_match = re.search(r"shields\.io/badge/version-([^-]+)-", readme)
|
||||
assert badge_match, "Could not find version badge URL in README"
|
||||
badge_version = badge_match.group(1)
|
||||
|
||||
assert badge_version == code_version, (
|
||||
f"README badge says {badge_version} but version.py says {code_version}. "
|
||||
f"Update the badge URL in README.md."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 16. dialect.py docstring does NOT say "lossless"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDialectNotLossless:
|
||||
"""The April 7 correction: AAAK is lossy, not lossless."""
|
||||
|
||||
def test_dialect_docstring_says_not_lossless(self):
|
||||
"""Claim: dialect.py correctly says AAAK is NOT lossless.
|
||||
The docstring must contain 'NOT lossless' or 'lossy'."""
|
||||
src = _read(MEMPALACE_PKG / "dialect.py")
|
||||
# Check the module docstring (first ~20 lines)
|
||||
docstring_area = src[:1000]
|
||||
assert "NOT lossless" in docstring_area or "lossy" in docstring_area.lower(), (
|
||||
"dialect.py docstring does not disclaim losslessness. "
|
||||
"After the April 7 correction, it must say AAAK is NOT lossless."
|
||||
)
|
||||
|
||||
def test_dialect_docstring_does_not_claim_lossless(self):
|
||||
"""The docstring must not positively claim 'lossless compression'."""
|
||||
src = _read(MEMPALACE_PKG / "dialect.py")
|
||||
docstring_area = src[:1000]
|
||||
# "NOT lossless" is OK; bare "lossless" without negation is not
|
||||
# Remove the "NOT lossless" disclaimer before checking
|
||||
cleaned = docstring_area.replace("NOT lossless", "")
|
||||
assert "lossless" not in cleaned.lower(), (
|
||||
"dialect.py docstring still claims 'lossless' somewhere. "
|
||||
"AAAK is lossy — remove any positive lossless claims."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 17. README file reference table for dialect.py does NOT say "lossless"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReadmeDialectNotLossless:
|
||||
"""README's file reference table must not say dialect.py is lossless."""
|
||||
|
||||
def test_readme_dialect_line_not_lossless(self):
|
||||
"""Claim: April 7 correction applied to README file table.
|
||||
The dialect.py row must not say 'lossless'."""
|
||||
readme = _readme()
|
||||
# Find the line with dialect.py in the file reference table
|
||||
dialect_lines = [
|
||||
line for line in readme.splitlines() if "dialect.py" in line and "|" in line
|
||||
]
|
||||
assert len(dialect_lines) > 0, "Could not find dialect.py in README file table"
|
||||
|
||||
for line in dialect_lines:
|
||||
assert "lossless" not in line.lower(), (
|
||||
f"README file table still says dialect.py is lossless: {line.strip()!r}. "
|
||||
f"After April 7 correction, this must say 'lossy' or remove the lossless claim."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 18. Hall keywords in config.py — verify miners actually WRITE hall metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHallMetadata:
|
||||
"""README describes 5 hall types. Miners must actually write hall metadata."""
|
||||
|
||||
def test_hall_keywords_defined_in_config(self):
|
||||
"""Prerequisite: DEFAULT_HALL_KEYWORDS must exist in config.py."""
|
||||
src = _read(MEMPALACE_PKG / "config.py")
|
||||
assert "DEFAULT_HALL_KEYWORDS" in src, (
|
||||
"config.py does not define DEFAULT_HALL_KEYWORDS. "
|
||||
"Hall types are described in README but not defined in config."
|
||||
)
|
||||
|
||||
def test_miners_write_hall_metadata(self):
|
||||
"""Claim: halls are populated. At least one miner must write a 'hall'
|
||||
field into drawer metadata.
|
||||
|
||||
If no miner writes hall metadata, the halls described in README are
|
||||
a schema ghost — defined but never populated."""
|
||||
miner_src = _read(MEMPALACE_PKG / "miner.py")
|
||||
convo_miner_src = _read(MEMPALACE_PKG / "convo_miner.py")
|
||||
|
||||
# Check if either miner references 'hall' in the metadata it writes
|
||||
writes_hall = (
|
||||
'"hall"' in miner_src
|
||||
or "'hall'" in miner_src
|
||||
or '"hall"' in convo_miner_src
|
||||
or "'hall'" in convo_miner_src
|
||||
)
|
||||
assert writes_hall, (
|
||||
"Neither miner.py nor convo_miner.py writes a 'hall' field to drawer metadata. "
|
||||
"README describes 5 hall types (hall_facts, hall_events, hall_discoveries, "
|
||||
"hall_preferences, hall_advice) but no mining code populates them. "
|
||||
"Halls are a schema ghost — defined in config, read by palace_graph, "
|
||||
"but never written by any pipeline."
|
||||
)
|
||||
|
||||
def test_readme_hall_types_match_config(self):
|
||||
"""If README lists specific hall names, they should appear in config."""
|
||||
# README mentions these 5 halls
|
||||
readme_halls = [
|
||||
"hall_facts",
|
||||
"hall_events",
|
||||
"hall_discoveries",
|
||||
"hall_preferences",
|
||||
"hall_advice",
|
||||
]
|
||||
for hall in readme_halls:
|
||||
# These should either be in config or README should not list them
|
||||
# The hall_ prefix is a README convention; config uses keyword groups
|
||||
# like "emotions", "consciousness" etc. Check if they're consistent.
|
||||
pass # This is a documentation check; the real test is #18b above
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 19. Backend abstraction exists
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBackendAbstraction:
|
||||
"""Backend seam for pluggable storage backends."""
|
||||
|
||||
def test_backends_base_exists(self):
|
||||
"""Claim: pluggable backends.
|
||||
backends/base.py must define an abstract base class."""
|
||||
path = MEMPALACE_PKG / "backends" / "base.py"
|
||||
assert (
|
||||
path.is_file()
|
||||
), "mempalace/backends/base.py does not exist. Backend abstraction layer is missing."
|
||||
src = _read(path)
|
||||
assert (
|
||||
"ABC" in src or "abstractmethod" in src
|
||||
), "backends/base.py does not define an abstract base class."
|
||||
|
||||
def test_backends_chroma_exists(self):
|
||||
"""Claim: ChromaDB backend implementation.
|
||||
backends/chroma.py must exist and subclass the base."""
|
||||
path = MEMPALACE_PKG / "backends" / "chroma.py"
|
||||
assert path.is_file(), "mempalace/backends/chroma.py does not exist."
|
||||
src = _read(path)
|
||||
assert (
|
||||
"BaseCollection" in src or "base" in src
|
||||
), "backends/chroma.py does not reference the base class."
|
||||
|
||||
def test_backends_importable(self):
|
||||
"""Both backend modules should be importable."""
|
||||
from mempalace.backends.base import BaseCollection
|
||||
from mempalace.backends.chroma import ChromaBackend
|
||||
|
||||
assert BaseCollection is not None
|
||||
assert ChromaBackend is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 20. i18n module exists with at least 8 language files
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestI18n:
|
||||
"""i18n support — 8 languages."""
|
||||
|
||||
def test_i18n_directory_exists(self):
|
||||
"""i18n directory must exist."""
|
||||
path = MEMPALACE_PKG / "i18n"
|
||||
assert path.is_dir(), "mempalace/i18n/ directory does not exist."
|
||||
|
||||
def test_at_least_8_language_files(self):
|
||||
"""Claim: 8 languages supported.
|
||||
i18n/ must contain at least 8 .json language files."""
|
||||
path = MEMPALACE_PKG / "i18n"
|
||||
json_files = list(path.glob("*.json"))
|
||||
assert len(json_files) >= 8, (
|
||||
f"i18n/ has only {len(json_files)} language files, expected >= 8. "
|
||||
f"Files found: {[f.name for f in json_files]}"
|
||||
)
|
||||
|
||||
def test_english_baseline_exists(self):
|
||||
"""en.json must exist as the baseline language file."""
|
||||
path = MEMPALACE_PKG / "i18n" / "en.json"
|
||||
assert (
|
||||
path.is_file()
|
||||
), "mempalace/i18n/en.json does not exist. English baseline is required."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 21. Wake-up token cost — check layers.py vs README's "~170 tokens"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWakeUpTokenCost:
|
||||
"""README claims '~170 tokens' for wake-up. layers.py says otherwise."""
|
||||
|
||||
def test_readme_wakeup_cost_matches_layers(self):
|
||||
"""Claim: README says ~170 tokens for wake-up.
|
||||
layers.py docstring says L0 ~100 tokens, L1 ~500-800 tokens.
|
||||
Total = 600-900, not 170.
|
||||
|
||||
If the README means '170 tokens of critical facts' (just the AAAK
|
||||
portion), it should say so clearly. If it means total wake-up cost,
|
||||
it must match layers.py."""
|
||||
readme = _readme()
|
||||
layers_src = _read(MEMPALACE_PKG / "layers.py")
|
||||
|
||||
# What layers.py says
|
||||
assert "~600-900 tokens" in layers_src or "600-900" in layers_src, (
|
||||
"layers.py docstring does not mention 600-900 tokens. "
|
||||
"Check if the wake-up cost documentation has changed."
|
||||
)
|
||||
|
||||
# What README says
|
||||
readme_170_claims = re.findall(r"~?170 tokens", readme)
|
||||
|
||||
if readme_170_claims:
|
||||
# README claims 170 tokens but layers.py says 600-900.
|
||||
# This test enforces that README must match the code.
|
||||
# Either README should say 600-900 or layers.py should say 170.
|
||||
# Since we trust code over docs, the README is wrong.
|
||||
pytest.fail(
|
||||
f"README claims '~170 tokens' for wake-up ({len(readme_170_claims)} occurrences) "
|
||||
f"but layers.py says L0+L1 = ~600-900 tokens. "
|
||||
f"Either update README to match layers.py, or clarify that '170 tokens' "
|
||||
f"refers to a specific subset (e.g., AAAK-compressed facts only)."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bonus: pyproject.toml version in README project structure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReadmeProjectStructureVersion:
|
||||
"""README's project structure section says pyproject.toml version."""
|
||||
|
||||
def test_readme_pyproject_version_claim(self):
|
||||
"""Claim: README says 'pyproject.toml — package config (v3.0.0)' or similar.
|
||||
Must match actual pyproject.toml version."""
|
||||
readme = _readme()
|
||||
pyproject_src = _read(REPO_ROOT / "pyproject.toml")
|
||||
pyproject_match = re.search(r'^version\s*=\s*"([^"]+)"', pyproject_src, re.MULTILINE)
|
||||
assert pyproject_match, "Could not parse version from pyproject.toml"
|
||||
actual_version = pyproject_match.group(1)
|
||||
|
||||
# Find any version claim near pyproject.toml in README
|
||||
version_in_readme = re.search(r"pyproject\.toml.*?v?([\d]+\.[\d]+\.[\d]+)", readme)
|
||||
if version_in_readme:
|
||||
readme_version = version_in_readme.group(1)
|
||||
assert readme_version == actual_version, (
|
||||
f"README says pyproject.toml is v{readme_version} "
|
||||
f"but actual version is {actual_version}."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bonus: README tool count consistency (all mentions must agree)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReadmeToolCountConsistency:
|
||||
"""README mentions tool count in multiple places — they must all agree."""
|
||||
|
||||
def test_all_tool_count_mentions_consistent(self):
|
||||
"""Every place README says 'N tools' must use the same number."""
|
||||
readme = _readme()
|
||||
counts = re.findall(r"(\d+)\s+tools", readme)
|
||||
if len(counts) > 1:
|
||||
unique = set(counts)
|
||||
assert (
|
||||
len(unique) == 1
|
||||
), f"README mentions different tool counts: {counts}. All occurrences must agree."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bonus: get_aaak_spec tool handler exists
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAAAKSpecToolHandler:
|
||||
"""If mempalace_get_aaak_spec is in TOOLS, its handler must exist."""
|
||||
|
||||
def test_aaak_spec_handler_exists(self):
|
||||
"""The handler function for get_aaak_spec must be defined."""
|
||||
src = _read(MEMPALACE_PKG / "mcp_server.py")
|
||||
tools = _tools_dict_keys()
|
||||
if "mempalace_get_aaak_spec" in tools:
|
||||
assert "def tool_get_aaak_spec(" in src, (
|
||||
"mempalace_get_aaak_spec is in TOOLS dict but "
|
||||
"tool_get_aaak_spec() handler function is not defined."
|
||||
)
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Tests for mempalace.repair — scan, prune, and rebuild HNSW index."""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
from mempalace import repair
|
||||
|
||||
|
||||
# ── _get_palace_path ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@patch("mempalace.repair.MempalaceConfig", create=True)
|
||||
def test_get_palace_path_from_config(mock_config_cls):
|
||||
mock_config_cls.return_value.palace_path = "/configured/palace"
|
||||
with patch.dict("sys.modules", {}):
|
||||
# Force reimport to pick up the mock
|
||||
result = repair._get_palace_path()
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
def test_get_palace_path_fallback():
|
||||
with patch("mempalace.repair._get_palace_path") as mock_get:
|
||||
mock_get.return_value = os.path.join(os.path.expanduser("~"), ".mempalace", "palace")
|
||||
result = mock_get()
|
||||
assert ".mempalace" in result
|
||||
|
||||
|
||||
# ── _paginate_ids ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_paginate_ids_single_batch():
|
||||
col = MagicMock()
|
||||
col.get.return_value = {"ids": ["id1", "id2", "id3"]}
|
||||
ids = repair._paginate_ids(col)
|
||||
assert ids == ["id1", "id2", "id3"]
|
||||
|
||||
|
||||
def test_paginate_ids_empty():
|
||||
col = MagicMock()
|
||||
col.get.return_value = {"ids": []}
|
||||
ids = repair._paginate_ids(col)
|
||||
assert ids == []
|
||||
|
||||
|
||||
def test_paginate_ids_with_where():
|
||||
col = MagicMock()
|
||||
col.get.return_value = {"ids": ["id1"]}
|
||||
repair._paginate_ids(col, where={"wing": "test"})
|
||||
col.get.assert_called_with(where={"wing": "test"}, include=[], limit=1000, offset=0)
|
||||
|
||||
|
||||
def test_paginate_ids_offset_exception_fallback():
|
||||
col = MagicMock()
|
||||
# First call raises, fallback returns ids, second fallback returns empty
|
||||
col.get.side_effect = [
|
||||
Exception("offset bug"),
|
||||
{"ids": ["id1", "id2"]},
|
||||
Exception("offset bug"),
|
||||
{"ids": ["id1", "id2"]}, # same ids = no new = break
|
||||
]
|
||||
ids = repair._paginate_ids(col)
|
||||
assert "id1" in ids
|
||||
|
||||
|
||||
# ── scan_palace ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@patch("mempalace.repair.chromadb")
|
||||
def test_scan_palace_no_ids(mock_chromadb, tmp_path):
|
||||
mock_col = MagicMock()
|
||||
mock_col.count.return_value = 0
|
||||
mock_col.get.return_value = {"ids": []}
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_collection.return_value = mock_col
|
||||
mock_chromadb.PersistentClient.return_value = mock_client
|
||||
|
||||
good, bad = repair.scan_palace(palace_path=str(tmp_path))
|
||||
assert good == set()
|
||||
assert bad == set()
|
||||
|
||||
|
||||
@patch("mempalace.repair.chromadb")
|
||||
def test_scan_palace_all_good(mock_chromadb, tmp_path):
|
||||
mock_col = MagicMock()
|
||||
mock_col.count.return_value = 2
|
||||
# _paginate_ids call
|
||||
mock_col.get.side_effect = [
|
||||
{"ids": ["id1", "id2"]}, # paginate
|
||||
{"ids": ["id1", "id2"]}, # probe batch — both returned
|
||||
]
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_collection.return_value = mock_col
|
||||
mock_chromadb.PersistentClient.return_value = mock_client
|
||||
|
||||
good, bad = repair.scan_palace(palace_path=str(tmp_path))
|
||||
assert "id1" in good
|
||||
assert "id2" in good
|
||||
assert len(bad) == 0
|
||||
|
||||
|
||||
@patch("mempalace.repair.chromadb")
|
||||
def test_scan_palace_with_bad_ids(mock_chromadb, tmp_path):
|
||||
mock_col = MagicMock()
|
||||
mock_col.count.return_value = 2
|
||||
|
||||
def get_side_effect(**kwargs):
|
||||
ids = kwargs.get("ids", None)
|
||||
if ids is None:
|
||||
# paginate call
|
||||
return {"ids": ["good1", "bad1"]}
|
||||
if "bad1" in ids and len(ids) == 1:
|
||||
raise Exception("corrupt")
|
||||
if "good1" in ids and len(ids) == 1:
|
||||
return {"ids": ["good1"]}
|
||||
# batch probe — raise to force per-id
|
||||
raise Exception("batch fail")
|
||||
|
||||
mock_col.get.side_effect = get_side_effect
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_collection.return_value = mock_col
|
||||
mock_chromadb.PersistentClient.return_value = mock_client
|
||||
|
||||
good, bad = repair.scan_palace(palace_path=str(tmp_path))
|
||||
assert "good1" in good
|
||||
assert "bad1" in bad
|
||||
|
||||
|
||||
@patch("mempalace.repair.chromadb")
|
||||
def test_scan_palace_with_wing_filter(mock_chromadb, tmp_path):
|
||||
mock_col = MagicMock()
|
||||
mock_col.count.return_value = 1
|
||||
mock_col.get.side_effect = [
|
||||
{"ids": ["id1"]}, # paginate
|
||||
{"ids": ["id1"]}, # probe
|
||||
]
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_collection.return_value = mock_col
|
||||
mock_chromadb.PersistentClient.return_value = mock_client
|
||||
|
||||
repair.scan_palace(palace_path=str(tmp_path), only_wing="test_wing")
|
||||
# Verify where filter was passed
|
||||
first_call = mock_col.get.call_args_list[0]
|
||||
assert first_call.kwargs.get("where") == {"wing": "test_wing"}
|
||||
|
||||
|
||||
# ── prune_corrupt ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@patch("mempalace.repair.chromadb")
|
||||
def test_prune_corrupt_no_file(mock_chromadb, tmp_path):
|
||||
# Should print message and return without error
|
||||
repair.prune_corrupt(palace_path=str(tmp_path))
|
||||
|
||||
|
||||
@patch("mempalace.repair.chromadb")
|
||||
def test_prune_corrupt_dry_run(mock_chromadb, tmp_path):
|
||||
bad_file = tmp_path / "corrupt_ids.txt"
|
||||
bad_file.write_text("bad1\nbad2\n")
|
||||
repair.prune_corrupt(palace_path=str(tmp_path), confirm=False)
|
||||
# No chromadb calls in dry run
|
||||
mock_chromadb.PersistentClient.assert_not_called()
|
||||
|
||||
|
||||
@patch("mempalace.repair.chromadb")
|
||||
def test_prune_corrupt_confirmed(mock_chromadb, tmp_path):
|
||||
bad_file = tmp_path / "corrupt_ids.txt"
|
||||
bad_file.write_text("bad1\nbad2\n")
|
||||
|
||||
mock_col = MagicMock()
|
||||
mock_col.count.side_effect = [10, 8]
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_collection.return_value = mock_col
|
||||
mock_chromadb.PersistentClient.return_value = mock_client
|
||||
|
||||
repair.prune_corrupt(palace_path=str(tmp_path), confirm=True)
|
||||
mock_col.delete.assert_called_once()
|
||||
|
||||
|
||||
@patch("mempalace.repair.chromadb")
|
||||
def test_prune_corrupt_delete_failure_fallback(mock_chromadb, tmp_path):
|
||||
bad_file = tmp_path / "corrupt_ids.txt"
|
||||
bad_file.write_text("bad1\nbad2\n")
|
||||
|
||||
mock_col = MagicMock()
|
||||
mock_col.count.side_effect = [10, 8]
|
||||
# Batch delete fails, per-id succeeds
|
||||
mock_col.delete.side_effect = [Exception("batch fail"), None, None]
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_collection.return_value = mock_col
|
||||
mock_chromadb.PersistentClient.return_value = mock_client
|
||||
|
||||
repair.prune_corrupt(palace_path=str(tmp_path), confirm=True)
|
||||
assert mock_col.delete.call_count == 3 # 1 batch + 2 individual
|
||||
|
||||
|
||||
# ── rebuild_index ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@patch("mempalace.repair.chromadb")
|
||||
def test_rebuild_index_no_palace(mock_chromadb, tmp_path):
|
||||
nonexistent = str(tmp_path / "nope")
|
||||
repair.rebuild_index(palace_path=nonexistent)
|
||||
mock_chromadb.PersistentClient.assert_not_called()
|
||||
|
||||
|
||||
@patch("mempalace.repair.shutil")
|
||||
@patch("mempalace.repair.chromadb")
|
||||
def test_rebuild_index_empty_palace(mock_chromadb, mock_shutil, tmp_path):
|
||||
mock_col = MagicMock()
|
||||
mock_col.count.return_value = 0
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_collection.return_value = mock_col
|
||||
mock_chromadb.PersistentClient.return_value = mock_client
|
||||
|
||||
repair.rebuild_index(palace_path=str(tmp_path))
|
||||
mock_client.delete_collection.assert_not_called()
|
||||
|
||||
|
||||
@patch("mempalace.repair.shutil")
|
||||
@patch("mempalace.repair.chromadb")
|
||||
def test_rebuild_index_success(mock_chromadb, mock_shutil, tmp_path):
|
||||
# Create a fake sqlite file
|
||||
sqlite_path = tmp_path / "chroma.sqlite3"
|
||||
sqlite_path.write_text("fake")
|
||||
|
||||
mock_col = MagicMock()
|
||||
mock_col.count.return_value = 2
|
||||
mock_col.get.return_value = {
|
||||
"ids": ["id1", "id2"],
|
||||
"documents": ["doc1", "doc2"],
|
||||
"metadatas": [{"wing": "a"}, {"wing": "b"}],
|
||||
}
|
||||
|
||||
mock_new_col = MagicMock()
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_collection.return_value = mock_col
|
||||
mock_client.create_collection.return_value = mock_new_col
|
||||
mock_chromadb.PersistentClient.return_value = mock_client
|
||||
|
||||
repair.rebuild_index(palace_path=str(tmp_path))
|
||||
|
||||
# Verify: backed up sqlite only (not copytree)
|
||||
mock_shutil.copy2.assert_called_once()
|
||||
assert "chroma.sqlite3" in str(mock_shutil.copy2.call_args)
|
||||
|
||||
# Verify: deleted and recreated with cosine
|
||||
mock_client.delete_collection.assert_called_once_with("mempalace_drawers")
|
||||
mock_client.create_collection.assert_called_once_with(
|
||||
"mempalace_drawers", metadata={"hnsw:space": "cosine"}
|
||||
)
|
||||
|
||||
# Verify: used upsert not add
|
||||
mock_new_col.upsert.assert_called_once()
|
||||
mock_new_col.add.assert_not_called()
|
||||
|
||||
|
||||
@patch("mempalace.repair.shutil")
|
||||
@patch("mempalace.repair.chromadb")
|
||||
def test_rebuild_index_error_reading(mock_chromadb, mock_shutil, tmp_path):
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_collection.side_effect = Exception("corrupt")
|
||||
mock_chromadb.PersistentClient.return_value = mock_client
|
||||
|
||||
repair.rebuild_index(palace_path=str(tmp_path))
|
||||
mock_client.delete_collection.assert_not_called()
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -76,6 +76,12 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocorrect"
|
||||
version = "2.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/cb/55fd549def80011b09dbd7bef6ad06ec4453745294bcfe6c63a270070046/autocorrect-2.6.1.tar.gz", hash = "sha256:2bc68192dc645b44bece2613caac338e93548c3dac9c563095b27224c7fd4391", size = 622775, upload-time = "2021-12-04T20:33:56.928Z" }
|
||||
|
||||
[[package]]
|
||||
name = "backoff"
|
||||
version = "2.2.1"
|
||||
@@ -437,6 +443,249 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.10.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.10'",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/ad/d1c25053764b4c42eb294aae92ab617d2e4f803397f9c7c8295caa77a260/coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3", size = 217978, upload-time = "2025-09-21T20:03:30.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/2f/b9f9daa39b80ece0b9548bbb723381e29bc664822d9a12c2135f8922c22b/coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c", size = 218370, upload-time = "2025-09-21T20:03:32.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/6e/30d006c3b469e58449650642383dddf1c8fb63d44fdf92994bfd46570695/coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396", size = 244802, upload-time = "2025-09-21T20:03:33.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/49/8a070782ce7e6b94ff6a0b6d7c65ba6bc3091d92a92cef4cd4eb0767965c/coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40", size = 246625, upload-time = "2025-09-21T20:03:36.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/92/1c1c5a9e8677ce56d42b97bdaca337b2d4d9ebe703d8c174ede52dbabd5f/coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594", size = 248399, upload-time = "2025-09-21T20:03:38.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/54/b140edee7257e815de7426d5d9846b58505dffc29795fff2dfb7f8a1c5a0/coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a", size = 245142, upload-time = "2025-09-21T20:03:40.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/9e/6d6b8295940b118e8b7083b29226c71f6154f7ff41e9ca431f03de2eac0d/coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b", size = 246284, upload-time = "2025-09-21T20:03:42.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/e5/5e957ca747d43dbe4d9714358375c7546cb3cb533007b6813fc20fce37ad/coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3", size = 244353, upload-time = "2025-09-21T20:03:44.218Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/45/540fc5cc92536a1b783b7ef99450bd55a4b3af234aae35a18a339973ce30/coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0", size = 244430, upload-time = "2025-09-21T20:03:46.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/0b/8287b2e5b38c8fe15d7e3398849bb58d382aedc0864ea0fa1820e8630491/coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f", size = 245311, upload-time = "2025-09-21T20:03:48.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/1d/29724999984740f0c86d03e6420b942439bf5bd7f54d4382cae386a9d1e9/coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431", size = 220500, upload-time = "2025-09-21T20:03:50.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/11/4b1e6b129943f905ca54c339f343877b55b365ae2558806c1be4f7476ed5/coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07", size = 221408, upload-time = "2025-09-21T20:03:51.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
toml = [
|
||||
{ name = "tomli", marker = "python_full_version < '3.10'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.13.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.14'",
|
||||
"python_full_version == '3.13.*'",
|
||||
"python_full_version >= '3.11' and python_full_version < '3.13'",
|
||||
"python_full_version == '3.10.*'",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/33/e8c48488c29a73fd089f9d71f9653c1be7478f2ad6b5bc870db11a55d23d/coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5", size = 219255, upload-time = "2026-03-17T10:29:51.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/bd/b0ebe9f677d7f4b74a3e115eec7ddd4bcf892074963a00d91e8b164a6386/coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf", size = 219772, upload-time = "2026-03-17T10:29:52.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/cc/5cb9502f4e01972f54eedd48218bb203fe81e294be606a2bc93970208013/coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8", size = 246532, upload-time = "2026-03-17T10:29:54.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/d8/3217636d86c7e7b12e126e4f30ef1581047da73140614523af7495ed5f2d/coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4", size = 248333, upload-time = "2026-03-17T10:29:56.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/30/2002ac6729ba2d4357438e2ed3c447ad8562866c8c63fc16f6dfc33afe56/coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d", size = 250211, upload-time = "2026-03-17T10:29:57.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/85/552496626d6b9359eb0e2f86f920037c9cbfba09b24d914c6e1528155f7d/coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930", size = 252125, upload-time = "2026-03-17T10:29:59.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/21/40256eabdcbccdb6acf6b381b3016a154399a75fe39d406f790ae84d1f3c/coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d", size = 247219, upload-time = "2026-03-17T10:30:01.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/e8/96e2a6c3f21a0ea77d7830b254a1542d0328acc8d7bdf6a284ba7e529f77/coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40", size = 248248, upload-time = "2026-03-17T10:30:03.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/ba/8477f549e554827da390ec659f3c38e4b6d95470f4daafc2d8ff94eaa9c2/coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878", size = 246254, upload-time = "2026-03-17T10:30:04.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/59/bc22aef0e6aa179d5b1b001e8b3654785e9adf27ef24c93dc4228ebd5d68/coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400", size = 250067, upload-time = "2026-03-17T10:30:06.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/1b/c6a023a160806a5137dca53468fd97530d6acad24a22003b1578a9c2e429/coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0", size = 246521, upload-time = "2026-03-17T10:30:08.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/3f/3532c85a55aa2f899fa17c186f831cfa1aa434d88ff792a709636f64130e/coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0", size = 247126, upload-time = "2026-03-17T10:30:09.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/2e/b9d56af4a24ef45dfbcda88e06870cb7d57b2b0bfa3a888d79b4c8debd76/coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58", size = 221860, upload-time = "2026-03-17T10:30:11.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/cc/d938417e7a4d7f0433ad4edee8bb2acdc60dc7ac5af19e2a07a048ecbee3/coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e", size = 222788, upload-time = "2026-03-17T10:30:12.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
toml = [
|
||||
{ name = "tomli", marker = "python_full_version >= '3.10' and python_full_version <= '3.11'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distro"
|
||||
version = "1.9.0"
|
||||
@@ -959,7 +1208,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mempalace"
|
||||
version = "3.0.0"
|
||||
version = "3.3.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "chromadb" },
|
||||
@@ -968,30 +1217,42 @@ dependencies = [
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "psutil" },
|
||||
{ name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||
{ name = "pytest", version = "9.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
spellcheck = [
|
||||
{ name = "autocorrect" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "psutil" },
|
||||
{ name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||
{ name = "pytest", version = "9.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "chromadb", specifier = ">=0.4.0,<1" },
|
||||
{ name = "autocorrect", marker = "extra == 'spellcheck'", specifier = ">=2.0" },
|
||||
{ name = "chromadb", specifier = ">=0.5.0,<0.7" },
|
||||
{ name = "psutil", marker = "extra == 'dev'", specifier = ">=5.9" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0" },
|
||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0,<7" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
provides-extras = ["dev", "spellcheck"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "psutil", specifier = ">=5.9" },
|
||||
{ name = "pytest", specifier = ">=7.0" },
|
||||
{ name = "pytest-cov", specifier = ">=4.0" },
|
||||
{ name = "ruff", specifier = ">=0.4.0" },
|
||||
]
|
||||
|
||||
@@ -2000,6 +2261,34 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
version = "7.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.5"
|
||||
@@ -2230,6 +2519,22 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "7.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.10'" },
|
||||
{ name = "coverage", version = "7.13.5", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||
{ name = "pytest", version = "9.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
.vitepress/dist
|
||||
.vitepress/cache
|
||||
.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 || '/')
|
||||
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/MemPalace/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/MemPalace/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=="],
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user