Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6889c6ff33 | ||
|
|
b552bcf3ea | ||
|
|
05ad2dc194 | ||
|
|
fd89303fe1 | ||
|
|
2087869752 | ||
|
|
55a004fe1e | ||
|
|
c5e249bba8 | ||
|
|
65f99ad7e6 | ||
|
|
29112fab82 | ||
|
|
4215be3926 | ||
|
|
09fe2dda3c | ||
|
|
939d4c1e74 | ||
|
|
88f5b5fa0e | ||
|
|
cde0f5b9e7 | ||
|
|
973bd62a9a | ||
|
|
8bf940f861 | ||
|
|
21da870bd0 | ||
|
|
d4c942417a | ||
|
|
f895bc58e6 | ||
|
|
6caac50138 | ||
|
|
0174b93d0f | ||
|
|
122ce38811 | ||
|
|
57b0b14192 | ||
|
|
4221589df2 | ||
|
|
3d13a72ae0 | ||
|
|
33a98fb9d1 | ||
|
|
ce3ae0a668 | ||
|
|
69453b2180 | ||
|
|
2e998db0b9 | ||
|
|
73a2f82d5b | ||
|
|
312b3b5f0e | ||
|
|
4b998de77a | ||
|
|
3e49522a42 | ||
|
|
d6bd7de5f6 | ||
|
|
b87ada3c96 | ||
|
|
3bac3654c4 | ||
|
|
c722c91e2a | ||
|
|
b214aced90 | ||
|
|
56b6a6360f | ||
|
|
966937d620 | ||
|
|
b524b31839 | ||
|
|
e61dc2adf8 | ||
|
|
a15094ce60 | ||
|
|
ecd44f7cb7 | ||
|
|
b226251ddf | ||
|
|
0aee6f3ed9 | ||
|
|
6a73eb2e20 | ||
|
|
54a386d925 | ||
|
|
f20f45a2da | ||
|
|
f36d04e4a4 | ||
|
|
d565718922 | ||
|
|
ced1fc955d | ||
|
|
db4c52e8be | ||
|
|
107685930d | ||
|
|
bf3b9c5979 | ||
|
|
f20a1a30fe | ||
|
|
65bf1ebda3 | ||
|
|
61d02e10fe | ||
|
|
ca0682abe3 | ||
|
|
8df7b9bf2c | ||
|
|
29bc868c89 | ||
|
|
3094c0bd10 | ||
|
|
205867a09f | ||
|
|
03692273c0 | ||
|
|
df986fd5e2 | ||
|
|
1fa0e57d74 | ||
|
|
a3f4674963 | ||
|
|
c9b3245994 | ||
|
|
6a5e73c856 | ||
|
|
3ac75d0fdb | ||
|
|
21793cfb48 | ||
|
|
4741bc0055 | ||
|
|
8630e7e3e0 | ||
|
|
da89a11bda | ||
|
|
e1d24d8087 | ||
|
|
6378588102 | ||
|
|
1ea00fb168 | ||
|
|
a035293998 | ||
|
|
e8e93b53c0 | ||
|
|
73f038c0c0 | ||
|
|
4de9e135ce | ||
|
|
625162edd3 | ||
|
|
43189b65d7 | ||
|
|
26e313e031 | ||
|
|
ff6a907834 | ||
|
|
97a4b0e749 | ||
|
|
162edf39fe | ||
|
|
06240c73b3 | ||
|
|
3415e70dc6 | ||
|
|
79c9c0e517 | ||
|
|
1bff7d9907 | ||
|
|
f7d703fd5b | ||
|
|
0f217f7c80 | ||
|
|
001700ce72 | ||
|
|
b060171c59 | ||
|
|
267a644f4f | ||
|
|
5a2f7db371 | ||
|
|
c877e46224 | ||
|
|
06c4289999 | ||
|
|
962776c2e3 | ||
|
|
4aa7e1eebd | ||
|
|
045023f449 | ||
|
|
52392ad5b4 | ||
|
|
e8d06e5e4a | ||
|
|
41696df1ac | ||
|
|
63dd165fed | ||
|
|
5320246297 | ||
|
|
62df24599e | ||
|
|
09e0bcea66 | ||
|
|
c038b8ebdd | ||
|
|
f7fcd51164 | ||
|
|
8dc5970ca9 | ||
|
|
a74586c627 | ||
|
|
1dc20e307b | ||
|
|
e052074624 | ||
|
|
7192552624 | ||
|
|
c4baceccb4 | ||
|
|
d16de9fe69 | ||
|
|
6b7dcc53d4 | ||
|
|
d886a62d8a | ||
|
|
1263c3c91e | ||
|
|
f3c6770ecf | ||
|
|
339f96a4d9 | ||
|
|
e9201fb617 | ||
|
|
20255b05be | ||
|
|
32d7f4376b | ||
|
|
95a8d7176a | ||
|
|
49a6a69da4 | ||
|
|
5db651a543 | ||
|
|
21d4a23430 | ||
|
|
c9fbf7c50a | ||
|
|
a3b7988d87 | ||
|
|
744ede76c0 | ||
|
|
28e263748b | ||
|
|
7e5eeda9a5 | ||
|
|
ca2598a9f6 | ||
|
|
ab46c8ebf1 | ||
|
|
386da51ae5 | ||
|
|
09f218cbb2 | ||
|
|
297517db0e | ||
|
|
69d6e2f7f3 | ||
|
|
53d779311e | ||
|
|
1e86892e62 | ||
|
|
8e446f904c | ||
|
|
4d581cbb73 | ||
|
|
4a6147f903 | ||
|
|
971b92da5d | ||
|
|
e2a9bb05d3 | ||
|
|
1b4ce0b1f8 | ||
|
|
f72ffbbcb2 | ||
|
|
f935e85ead | ||
|
|
ee60cad652 | ||
|
|
124f5bf7ba | ||
|
|
d3d7184f4e | ||
|
|
9b99c136ee | ||
|
|
30a431924b | ||
|
|
921db176c3 | ||
|
|
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
|
||||
```
|
||||
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
"name": "mempalace",
|
||||
"owner": {
|
||||
"name": "milla-jovovich",
|
||||
"url": "https://github.com/milla-jovovich"
|
||||
"url": "https://github.com/MemPalace"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "mempalace",
|
||||
"source": "./.claude-plugin",
|
||||
"description": "AI memory system — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, guided setup.",
|
||||
"version": "3.0.14",
|
||||
"version": "3.3.1",
|
||||
"author": {
|
||||
"name": "milla-jovovich"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mempalace",
|
||||
"version": "3.0.14",
|
||||
"version": "3.3.1",
|
||||
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, and guided setup.",
|
||||
"author": {
|
||||
"name": "milla-jovovich"
|
||||
@@ -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
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mempalace",
|
||||
"version": "3.0.14",
|
||||
"version": "3.3.1",
|
||||
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, and guided setup.",
|
||||
"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,25 @@
|
||||
{
|
||||
"name": "MemPalace",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:3.11",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {}
|
||||
},
|
||||
"postCreateCommand": "bash .devcontainer/post-create.sh",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"ms-python.python",
|
||||
"ms-python.debugpy",
|
||||
"charliermarsh.ruff"
|
||||
],
|
||||
"settings": {
|
||||
"python.defaultInterpreterPath": "/usr/local/bin/python",
|
||||
"python.testing.pytestEnabled": true,
|
||||
"python.testing.pytestArgs": ["tests/", "-v", "--ignore=tests/benchmarks"],
|
||||
"ruff.importStrategy": "fromEnvironment",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== MemPalace Dev Container Setup ==="
|
||||
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Match CI's ruff pin (pyproject only sets a floor; without this contributors
|
||||
# get a newer ruff locally than CI runs, causing phantom lint failures).
|
||||
pip install "ruff>=0.4.0,<0.5"
|
||||
|
||||
pip install pre-commit
|
||||
pre-commit install
|
||||
|
||||
echo ""
|
||||
echo "=== Verification ==="
|
||||
echo "python: $(python --version)"
|
||||
echo "pytest: $(python -m pytest --version 2>&1 | head -1)"
|
||||
echo "ruff: $(python -m ruff --version 2>&1 | head -1)"
|
||||
echo ""
|
||||
echo "Ready. Run: pytest tests/ -v --ignore=tests/benchmarks"
|
||||
@@ -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: [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 == '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
|
||||
@@ -0,0 +1,101 @@
|
||||
name: Version Guard
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
paths:
|
||||
- 'pyproject.toml'
|
||||
- 'mempalace/version.py'
|
||||
- '.claude-plugin/marketplace.json'
|
||||
- '.claude-plugin/plugin.json'
|
||||
- '.codex-plugin/plugin.json'
|
||||
- '.github/workflows/version-guard.yml'
|
||||
|
||||
jobs:
|
||||
check-versions:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Extract versions from all sources
|
||||
id: versions
|
||||
run: |
|
||||
set -euo pipefail
|
||||
py_version=$(grep -E '^__version__' mempalace/version.py | cut -d'"' -f2)
|
||||
pyproject_version=$(grep -E '^version' pyproject.toml | head -1 | cut -d'"' -f2)
|
||||
marketplace_version=$(jq -r '.plugins[0].version' .claude-plugin/marketplace.json)
|
||||
plugin_version=$(jq -r '.version' .claude-plugin/plugin.json)
|
||||
codex_version=$(jq -r '.version' .codex-plugin/plugin.json)
|
||||
|
||||
echo "py_version=$py_version" >> "$GITHUB_OUTPUT"
|
||||
echo "pyproject_version=$pyproject_version" >> "$GITHUB_OUTPUT"
|
||||
echo "marketplace_version=$marketplace_version" >> "$GITHUB_OUTPUT"
|
||||
echo "plugin_version=$plugin_version" >> "$GITHUB_OUTPUT"
|
||||
echo "codex_version=$codex_version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
{
|
||||
echo "## Detected versions"
|
||||
echo ""
|
||||
echo "| Source | Version |"
|
||||
echo "| --- | --- |"
|
||||
echo "| mempalace/version.py | \`$py_version\` |"
|
||||
echo "| pyproject.toml | \`$pyproject_version\` |"
|
||||
echo "| .claude-plugin/marketplace.json | \`$marketplace_version\` |"
|
||||
echo "| .claude-plugin/plugin.json | \`$plugin_version\` |"
|
||||
echo "| .codex-plugin/plugin.json | \`$codex_version\` |"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Verify all sources agree
|
||||
env:
|
||||
PY: ${{ steps.versions.outputs.py_version }}
|
||||
PYPROJECT: ${{ steps.versions.outputs.pyproject_version }}
|
||||
MARKETPLACE: ${{ steps.versions.outputs.marketplace_version }}
|
||||
PLUGIN: ${{ steps.versions.outputs.plugin_version }}
|
||||
CODEX: ${{ steps.versions.outputs.codex_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
fail=0
|
||||
check() {
|
||||
local name="$1" value="$2" expected="$3"
|
||||
if [[ "$value" != "$expected" ]]; then
|
||||
echo "::error file=$name::version mismatch — expected $expected, got $value"
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
# All five must agree with each other (use version.py as the reference, per CLAUDE.md)
|
||||
check "pyproject.toml" "$PYPROJECT" "$PY"
|
||||
check ".claude-plugin/marketplace.json" "$MARKETPLACE" "$PY"
|
||||
check ".claude-plugin/plugin.json" "$PLUGIN" "$PY"
|
||||
check ".codex-plugin/plugin.json" "$CODEX" "$PY"
|
||||
exit $fail
|
||||
|
||||
- name: Verify tag matches manifest (tag pushes only)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
PY: ${{ steps.versions.outputs.py_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag_version="${GITHUB_REF_NAME#v}"
|
||||
|
||||
# Semver pre-release tags (v3.4.0-rc1, v1.0.0-beta.2, ...) are treated
|
||||
# as internal/staging and are not validated against the manifest. They
|
||||
# do not flow to end users via `/plugin update`, which reads the
|
||||
# manifest on the default branch.
|
||||
if [[ "$tag_version" == *-* ]]; then
|
||||
echo "Pre-release tag $GITHUB_REF_NAME — skipping strict manifest match."
|
||||
{
|
||||
echo ""
|
||||
echo "> Pre-release tag detected: \`$GITHUB_REF_NAME\`."
|
||||
echo "> Manifest ($PY) is not required to match. Pre-releases are not published via \`/plugin update\`."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$tag_version" != "$PY" ]]; then
|
||||
echo "::error::tag $GITHUB_REF_NAME does not match manifest version $PY"
|
||||
echo "Bump mempalace/version.py, pyproject.toml, and all plugin manifests before tagging a stable release."
|
||||
echo "For an internal/staging tag, use a semver pre-release suffix (e.g. v${PY}-rc1)."
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag $GITHUB_REF_NAME matches manifest version $PY"
|
||||
+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
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.9.0
|
||||
# Keep in lock-step with the ruff version pinned in .github/workflows/ci.yml
|
||||
# (>=0.4.0,<0.5). Using a newer rev here produces a different formatter
|
||||
# output than CI and breaks `ruff format --check` in the lint job.
|
||||
rev: v0.4.10
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
# 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/).
|
||||
|
||||
---
|
||||
|
||||
## [3.3.1] — 2026-04-16
|
||||
|
||||
### New Features
|
||||
|
||||
**Multi-language entity detection** — lexical patterns (person verbs, pronouns, dialogue markers, project verbs, stopwords, candidate character classes) now live in the optional `entity` section of each locale JSON under `mempalace/i18n/<lang>.json`. Every public function in `entity_detector` accepts a `languages=` tuple and unions patterns across enabled locales. Default stays `("en",)` so existing English-only callers are unchanged. (#911)
|
||||
|
||||
- **Five new fully-supported locales** with CLI strings, AAAK compression instructions, and entity-detection patterns:
|
||||
- Brazilian Portuguese `pt-br` (#156)
|
||||
- Russian `ru` (#760)
|
||||
- Italian `it` (#907)
|
||||
- Hindi `hi` (#773)
|
||||
- Indonesian `id` (#778)
|
||||
- **`MempalaceConfig.entity_languages`** — persistent palace-level language selection; `MEMPALACE_ENTITY_LANGUAGES` env override; `mempalace init --lang en,pt-br` flag that saves to `~/.mempalace/config.json` (#911)
|
||||
- **Per-language `candidate_pattern`** — non-Latin scripts register their own character class, so names like `João`, `Инна`, `राज` are no longer silently dropped by the ASCII-only default (#911)
|
||||
- **VSCode devcontainer** matching the CI environment (#881)
|
||||
- `MEMPAL_VERBOSE` env toggle — developers see diaries surfaced in chat while the default remains silent (#871)
|
||||
- `created_at` timestamps included in search results (#846)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
**i18n / Unicode**
|
||||
|
||||
- Script-aware word boundaries for combining-mark scripts — Python's `\b` fails on Devanagari vowel signs (`ा ी ु`), Arabic, Hebrew, Thai, Tamil, Khmer etc., truncating names like `अनीता` → `अनीत` and making person-verb patterns never fire. Locales now declare an optional `boundary_chars` field and the i18n loader expands `\b` into a script-aware lookaround boundary (#932)
|
||||
- Case-insensitive BCP 47 language code resolution — `--lang PT-BR`, `zh-cn`, `Pt-Br` previously fell through to English silently; now resolve to the canonical locale file via lowercase matching, with the entity-pattern cache keyed on the canonical form so casing variations share one cache entry (#928)
|
||||
- Wire i18n candidate patterns into `miner._extract_entities_for_metadata()`, `palace.build_closet_lines()`, and `entity_registry.extract_unknown_candidates()` — three code paths that still hardcoded ASCII-only `[A-Z][a-z]{2,}` and silently missed Cyrillic, accented Latin, and non-Latin entity metadata tags (#931)
|
||||
- Explicit `encoding="utf-8"` on `Path.read_text()` calls across entity_registry, instructions_cli, split_mega_files, and onboarding tests — prevents Windows GBK (and other non-UTF-8) locales from corrupting UTF-8 files (#946, #776)
|
||||
- `ko.json` `status_drawers` used `{drawers}` instead of `{count}`, showing the raw template string instead of the number (#758)
|
||||
- Move `test_i18n.py` from inside the installed package into `tests/` so pytest actually collects it; remove the `sys.path.insert` hack (#758)
|
||||
- `Dialect.from_config()` defaulted to `current_lang()` (module-global) when config had no `lang` key — replaced with explicit `"en"` fallback for determinism (#758)
|
||||
|
||||
**Other**
|
||||
|
||||
- Guard `KnowledgeGraph.close()` and `query_relationship`/`timeline`/`stats` methods with the instance lock to prevent concurrent-access corruption (#887, #884)
|
||||
- Replace invalid `{"decision": "allow"}` with `{}` in hook responses — the string wasn't a valid decision value and triggered schema warnings (#885)
|
||||
- `entity_registry.research()` defaults to local-only — previously made outbound Wikipedia HTTPS requests without explicit user opt-in; callers now must pass `allow_network=True` (#811)
|
||||
- Precompact hook no longer blocks compaction when it fails or takes too long (#856, #858, #863)
|
||||
- Redirect stdout to stderr during MCP server import so library logging can't corrupt the JSON-RPC channel (#225, #864)
|
||||
- `mempalace init` auto-adds per-project files to `.gitignore` in git repositories so users don't accidentally commit `mempalace.yaml` / `entities.json` (#185, #866)
|
||||
- Searcher guards against empty ChromaDB query results that previously raised on edge-case corpora (#195, #865)
|
||||
- Return empty status instead of an error on a cold-start palace with no drawers yet (#830, #831)
|
||||
- Restrict file permissions on sensitive palace data (#814)
|
||||
- Slack transcript importer writes a provenance header and preserves speaker IDs (#815)
|
||||
- Allow `mempalace mine` to run in directories without a local `mempalace.yaml` and surface the missing-yaml warning on stderr (#604)
|
||||
- Security hook injection fix (#812)
|
||||
- Save hook auto-mines transcripts even when `MEMPAL_DIR` is unset (#840)
|
||||
- Pin the Pages custom domain via a shipped `CNAME` in the deploy artifact (#877)
|
||||
- Version drift safeguard — sync pyproject + `version.py` + README badge in one place (#876)
|
||||
- Deploy docs workflow now runs on `develop` only, preventing accidental main-branch deploys (#845)
|
||||
|
||||
### Improvements
|
||||
|
||||
- Regex compilation optimization for entity extraction — pre-compile per-entity pattern sets once and cache by `(name, languages)` tuple, so multi-language callers don't thrash the cache (#880)
|
||||
- Knowledge-graph value sanitization now preserves natural punctuation (commas, colons, parentheses) that commonly appears in KG subject/object values (#873)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Clarify that `mempalace init` requires a `<dir>` argument in CLI help text (#210, #862)
|
||||
- Domain name and specific impostor sites called out in the scam-alert section (#869)
|
||||
- Tightened `SECURITY.md` with a real version-support policy and the GHPVR-only reporting channel (#810)
|
||||
- Fixed stale `pyproject.toml` URLs (#853)
|
||||
- v4 planning prep (#852)
|
||||
|
||||
### Internal
|
||||
|
||||
- `palace_graph` tunnel helper test coverage (#908)
|
||||
|
||||
---
|
||||
|
||||
## [3.3.0] — 2026-04-13
|
||||
|
||||
### 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)
|
||||
- Rewrite `README.md` and `mempalaceofficial.com` benchmark pages to remove category-error cross-system comparisons (R@5 retrieval recall had been listed next to competitor QA accuracy under one column), remove the retracted "+34% palace boost" claim from the surfaces where it had remained, replace the `100%` Haiku-rerank headline with the honest held-out `98.4%` R@5, drop the LoCoMo `100%` top-50 row (retrieval-bypass artefact), and fix the broken `aya-thekeeper/mempal` reproduction URL (#875)
|
||||
- Add `docs/HISTORY.md` as the canonical home for corrections, retractions, and public notices; move the 2026-04-07 "Note from Milla & Ben" and the 2026-04-11 impostor-domain notice out of `README.md`
|
||||
- Add v3.3.0 reproduction result JSONLs and the deterministic `seed=42` 50/450 LongMemEval split under `benchmarks/` — every BENCHMARKS.md claim reproduces exactly
|
||||
|
||||
### 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`
|
||||
+7
-4
@@ -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`
|
||||
@@ -79,7 +82,7 @@ If you're planning a significant change, open an issue first to discuss the appr
|
||||
- **Verbatim first**: Never summarize user content. Store exact words.
|
||||
- **Local first**: Everything runs on the user's machine. No cloud dependencies.
|
||||
- **Zero API by default**: Core features must work without any API key.
|
||||
- **Palace structure matters**: Wings, halls, and rooms aren't cosmetic — they drive a 34% retrieval improvement. Respect the hierarchy.
|
||||
- **Palace structure is scoping, not magic**: Wings, halls, and rooms act as metadata filters in the underlying vector store. They keep retrieval predictable when a palace holds many unrelated projects or people. Respect the hierarchy — but don't present it as a novel retrieval mechanism.
|
||||
|
||||
## Community
|
||||
|
||||
|
||||
+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!✨
|
||||
@@ -1,717 +1,187 @@
|
||||
> [!CAUTION]
|
||||
> **Scam alert.** The only official sources for MemPalace are this
|
||||
> [GitHub repository](https://github.com/MemPalace/mempalace), the
|
||||
> [PyPI package](https://pypi.org/project/mempalace/), and the docs site at
|
||||
> **[mempalaceofficial.com](https://mempalaceofficial.com)**. Any other
|
||||
> domain — including `mempalace.tech` — is an impostor and may distribute
|
||||
> malware. Details and timeline: [docs/HISTORY.md](docs/HISTORY.md).
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="assets/mempalace_logo.png" alt="MemPalace" width="280">
|
||||
<img src="assets/mempalace_logo.png" alt="MemPalace" width="240">
|
||||
|
||||
# MemPalace
|
||||
|
||||
### The highest-scoring AI memory system ever benchmarked. And it's free.
|
||||
|
||||
<br>
|
||||
|
||||
Every conversation you have with an AI — every decision, every debugging session, every architecture debate — disappears when the session ends. Six months of work, gone. You start over every time.
|
||||
|
||||
Other memory systems try to fix this by letting AI decide what's worth remembering. It extracts "user prefers Postgres" and throws away the conversation where you explained *why*. MemPalace takes a different approach: **store everything, then make it findable.**
|
||||
|
||||
**The Palace** — Ancient Greek orators memorized entire speeches by placing ideas in rooms of an imaginary building. Walk through the building, find the idea. MemPalace applies the same principle to AI memory: your conversations are organized into wings (people and projects), halls (types of memory), and rooms (specific ideas). No AI decides what matters — you keep every word, and the structure gives you a navigable map instead of a flat search index.
|
||||
|
||||
**Raw verbatim storage** — MemPalace stores your actual exchanges in ChromaDB without summarization or extraction. The 96.6% LongMemEval result comes from this raw mode. We don't burn an LLM to decide what's "worth remembering" — we keep everything and let semantic search find it.
|
||||
|
||||
**AAAK (experimental)** — A lossy abbreviation dialect for packing repeated entities into fewer tokens at scale. Readable by any LLM that reads text — Claude, GPT, Gemini, Llama, Mistral — no decoder needed. **AAAK is a separate compression layer, not the storage default**, and on the LongMemEval benchmark it currently regresses vs raw mode (84.2% vs 96.6%). We're iterating. See the [note above](#a-note-from-milla--ben--april-7-2026) for the honest status.
|
||||
|
||||
**Local, open, adaptable** — MemPalace runs entirely on your machine, on any data you have locally, without using any external API or services. It has been tested on conversations — but it can be adapted for different types of datastores. This is why we're open-sourcing it.
|
||||
|
||||
<br>
|
||||
Local-first AI memory. Verbatim storage, pluggable backend, 96.6% R@5 raw on LongMemEval — zero API calls.
|
||||
|
||||
[![][version-shield]][release-link]
|
||||
[![][python-shield]][python-link]
|
||||
[![][license-shield]][license-link]
|
||||
[![][discord-shield]][discord-link]
|
||||
|
||||
<br>
|
||||
|
||||
[Quick Start](#quick-start) · [The Palace](#the-palace) · [AAAK Dialect](#aaak-dialect-experimental) · [Benchmarks](#benchmarks) · [MCP Tools](#mcp-server)
|
||||
|
||||
<br>
|
||||
|
||||
### Highest LongMemEval score ever published — free or paid.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><strong>96.6%</strong><br><sub>LongMemEval R@5<br><b>raw mode</b>, zero API calls</sub></td>
|
||||
<td align="center"><strong>500/500</strong><br><sub>questions tested<br>independently reproduced</sub></td>
|
||||
<td align="center"><strong>$0</strong><br><sub>No subscription<br>No cloud. Local only.</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>Reproducible — runners in <a href="benchmarks/">benchmarks/</a>. <a href="benchmarks/BENCHMARKS.md">Full results</a>. The 96.6% is from <b>raw verbatim mode</b>, not AAAK or rooms mode (those score lower — see <a href="#a-note-from-milla--ben--april-7-2026">note above</a>).</sub>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## A Note from Milla & Ben — April 7, 2026
|
||||
## What it is
|
||||
|
||||
> The community caught real problems in this README within hours of launch and we want to address them directly.
|
||||
>
|
||||
> **What we got wrong:**
|
||||
>
|
||||
> - **The AAAK token example was incorrect.** We used a rough heuristic (`len(text)//3`) for token counts instead of an actual tokenizer. Real counts via OpenAI's tokenizer: the English example is 66 tokens, the AAAK example is 73. AAAK does not save tokens at small scales — it's designed for *repeated entities at scale*, and the README example was a bad demonstration of that. We're rewriting it.
|
||||
>
|
||||
> - **"30x lossless compression" was overstated.** AAAK is a lossy abbreviation system (entity codes, sentence truncation). Independent benchmarks show AAAK mode scores **84.2% R@5 vs raw mode's 96.6%** on LongMemEval — a 12.4 point regression. The honest framing is: AAAK is an experimental compression layer that trades fidelity for token density, and **the 96.6% headline number is from RAW mode, not AAAK**.
|
||||
>
|
||||
> - **"+34% palace boost" was misleading.** That number compares unfiltered search to wing+room metadata filtering. Metadata filtering is a standard ChromaDB feature, not a novel retrieval mechanism. Real and useful, but not a moat.
|
||||
>
|
||||
> - **"Contradiction detection"** exists as a separate utility (`fact_checker.py`) but is not currently wired into the knowledge graph operations as the README implied.
|
||||
>
|
||||
> - **"100% with Haiku rerank"** is real (we have the result files) but the rerank pipeline is not in the public benchmark scripts. We're adding it.
|
||||
>
|
||||
> **What's still true and reproducible:**
|
||||
>
|
||||
> - **96.6% R@5 on LongMemEval in raw mode**, on 500 questions, zero API calls — independently reproduced on M2 Ultra in under 5 minutes by [@gizmax](https://github.com/milla-jovovich/mempalace/issues/39).
|
||||
> - Local, free, no subscription, no cloud, no data leaving your machine.
|
||||
> - The architecture (wings, rooms, closets, drawers) is real and useful, even if it's not a magical retrieval boost.
|
||||
>
|
||||
> **What we're doing:**
|
||||
>
|
||||
> 1. Rewriting the AAAK example with real tokenizer counts and a scenario where AAAK actually demonstrates compression
|
||||
> 2. Adding `mode raw / aaak / rooms` clearly to the benchmark documentation so the trade-offs are visible
|
||||
> 3. Wiring `fact_checker.py` into the KG ops so the contradiction detection claim becomes true
|
||||
> 4. Pinning ChromaDB to a tested range (Issue #100), fixing the shell injection in hooks (#110), and addressing the macOS ARM64 segfault (#74)
|
||||
>
|
||||
> **Thank you to everyone who poked holes in this.** Brutal honest criticism is exactly what makes open source work, and it's what we asked for. Special thanks to [@panuhorsmalahti](https://github.com/milla-jovovich/mempalace/issues/43), [@lhl](https://github.com/milla-jovovich/mempalace/issues/27), [@gizmax](https://github.com/milla-jovovich/mempalace/issues/39), and everyone who filed an issue or a PR in the first 48 hours. We're listening, we're fixing, and we'd rather be right than impressive.
|
||||
>
|
||||
> — *Milla Jovovich & Ben Sigman*
|
||||
MemPalace stores your conversation history as verbatim text and retrieves
|
||||
it with semantic search. It does not summarize, extract, or paraphrase.
|
||||
The index is structured — people and projects become *wings*, topics
|
||||
become *rooms*, and original content lives in *drawers* — so searches
|
||||
can be scoped rather than run against a flat corpus.
|
||||
|
||||
The retrieval layer is pluggable. The current default is ChromaDB; the
|
||||
interface is defined in [`mempalace/backends/base.py`](mempalace/backends/base.py)
|
||||
and alternative backends can be dropped in without touching the rest of
|
||||
the system.
|
||||
|
||||
Nothing leaves your machine unless you opt in.
|
||||
|
||||
Architecture, concepts, and mining flows:
|
||||
[mempalaceofficial.com/concepts/the-palace](https://mempalaceofficial.com/concepts/the-palace.html).
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install mempalace
|
||||
|
||||
# Set up your world — who you work with, what your projects are
|
||||
mempalace init ~/projects/myapp
|
||||
```
|
||||
|
||||
# Mine your data
|
||||
mempalace mine ~/projects/myapp # projects — code, docs, notes
|
||||
mempalace mine ~/chats/ --mode convos # convos — Claude, ChatGPT, Slack exports
|
||||
mempalace mine ~/chats/ --mode convos --extract general # general — classifies into decisions, milestones, problems
|
||||
## Quickstart
|
||||
|
||||
# Search anything you've ever discussed
|
||||
```bash
|
||||
# Mine content into the palace
|
||||
mempalace mine ~/projects/myapp # project files
|
||||
mempalace mine ~/chats/ --mode convos # conversation exports
|
||||
|
||||
# Search
|
||||
mempalace search "why did we switch to GraphQL"
|
||||
|
||||
# Your AI remembers
|
||||
mempalace status
|
||||
# Load context for a new session
|
||||
mempalace wake-up
|
||||
```
|
||||
|
||||
Three mining modes: **projects** (code and docs), **convos** (conversation exports), and **general** (auto-classifies into decisions, preferences, milestones, problems, and emotional context). Everything stays on your machine.
|
||||
|
||||
---
|
||||
|
||||
## How You Actually Use It
|
||||
|
||||
After the one-time setup (install → init → mine), you don't run MemPalace commands manually. Your AI uses it for you. There are two ways, depending on which AI you use.
|
||||
|
||||
### With Claude Code (recommended)
|
||||
|
||||
Native marketplace install:
|
||||
|
||||
```bash
|
||||
claude plugin marketplace add milla-jovovich/mempalace
|
||||
claude plugin install --scope user mempalace
|
||||
```
|
||||
|
||||
Restart Claude Code, then type `/skills` to verify "mempalace" appears.
|
||||
|
||||
### With Claude, ChatGPT, Cursor, Gemini (MCP-compatible tools)
|
||||
|
||||
```bash
|
||||
# Connect MemPalace once
|
||||
claude mcp add mempalace -- python -m mempalace.mcp_server
|
||||
```
|
||||
|
||||
Now your AI has 19 tools available through MCP. Ask it anything:
|
||||
|
||||
> *"What did we decide about auth last month?"*
|
||||
|
||||
Claude calls `mempalace_search` automatically, gets verbatim results, and answers you. You never type `mempalace search` again. The AI handles it.
|
||||
|
||||
MemPalace also works natively with **Gemini CLI** (which handles the server and save hooks automatically) — see the [Gemini CLI Integration Guide](examples/gemini_cli_setup.md).
|
||||
|
||||
### With local models (Llama, Mistral, or any offline LLM)
|
||||
|
||||
Local models generally don't speak MCP yet. Two approaches:
|
||||
|
||||
**1. Wake-up command** — load your world into the model's context:
|
||||
|
||||
```bash
|
||||
mempalace wake-up > context.txt
|
||||
# Paste context.txt into your local model's system prompt
|
||||
```
|
||||
|
||||
This gives your local model ~170 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:
|
||||
|
||||
```bash
|
||||
mempalace search "auth decisions" > results.txt
|
||||
# Include results.txt in your prompt
|
||||
```
|
||||
|
||||
Or use the Python API:
|
||||
|
||||
```python
|
||||
from mempalace.searcher import search_memories
|
||||
results = search_memories("auth decisions", palace_path="~/.mempalace/palace")
|
||||
# Inject into your local model's context
|
||||
```
|
||||
|
||||
Either way — your entire memory stack runs offline. ChromaDB on your machine, Llama on your machine, AAAK for compression, zero cloud calls.
|
||||
|
||||
---
|
||||
|
||||
## The Problem
|
||||
|
||||
Decisions happen in conversations now. Not in docs. Not in Jira. In conversations with Claude, ChatGPT, Copilot. The reasoning, the tradeoffs, the "we tried X and it failed because Y" — all trapped in chat windows that evaporate when the session ends.
|
||||
|
||||
**Six months of daily AI use = 19.5 million tokens.** That's every decision, every debugging session, every architecture debate. Gone.
|
||||
|
||||
| Approach | Tokens loaded | Annual cost |
|
||||
|----------|--------------|-------------|
|
||||
| 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 + 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.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
### The Palace
|
||||
|
||||
The layout is fairly simple, though it took a long time to get there.
|
||||
|
||||
It starts with a **wing**. Every project, person, or topic you're filing gets its own wing in the palace.
|
||||
|
||||
Each wing has **rooms** connected to it, where information is divided into subjects that relate to that wing — so every room is a different element of what your project contains. Project ideas could be one room, employees could be another, financial statements another. There can be an endless number of rooms that split the wing into sections. The MemPalace install detects these for you automatically, and of course you can personalize it any way you feel is right.
|
||||
|
||||
Every room has a **closet** connected to it, and here's where things get interesting. We've developed an AI language called **AAAK**. Don't ask — it's a whole story of its own. Your agent learns the AAAK shorthand every time it wakes up. Because AAAK is essentially English, but a very truncated version, your agent understands how to use it in seconds. It comes as part of the install, built into the MemPalace code. In our next update, we'll add AAAK directly to the closets, which will be a real game changer — the amount of info in the closets will be much bigger, but it will take up far less space and far less reading time for your agent.
|
||||
|
||||
Inside those closets are **drawers**, and those drawers are where your original files live. In this first version, we haven't used AAAK as a closet tool, but even so, the summaries have shown **96.6% recall** in all the benchmarks we've done across multiple benchmarking platforms. Once the closets use AAAK, searches will be even faster while keeping every word exact. But even now, the closet approach has been a huge boon to how much info is stored in a small space — it's used to easily point your AI agent to the drawer where your original file lives. You never lose anything, and all this happens in seconds.
|
||||
|
||||
There are also **halls**, which connect rooms within a wing, and **tunnels**, which connect rooms from different wings to one another. So finding things becomes truly effortless — we've given the AI a clean and organized way to know where to start searching, without having to look through every keyword in huge folders.
|
||||
|
||||
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 │ │
|
||||
│ └──────────┘ └──────────┘ │
|
||||
└─────────┼──────────────────────────────────────────────────┘
|
||||
│
|
||||
tunnel
|
||||
│
|
||||
┌─────────┼──────────────────────────────────────────────────┐
|
||||
│ WING: Project │
|
||||
│ │ │
|
||||
│ ┌────┴─────┐ ──hall── ┌──────────┐ │
|
||||
│ │ Room A │ │ Room C │ │
|
||||
│ └────┬─────┘ └──────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Closet │ ───▶ │ Drawer │ │
|
||||
│ └──────────┘ └──────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Wings** — a person or project. As many as you need.
|
||||
**Rooms** — specific topics within a wing. Auth, billing, deploy — endless rooms.
|
||||
**Halls** — connections between related rooms *within* the same wing. If Room A (auth) and Room B (security) are related, a hall links them.
|
||||
**Tunnels** — connections *between* wings. When Person A and a Project both have a room about "auth," a tunnel cross-references them automatically.
|
||||
**Closets** — summaries that point to the original content. (In v3.0.0 these are plain-text summaries; AAAK-encoded closets are coming in a future update — see [Task #30](https://github.com/milla-jovovich/mempalace/issues/30).)
|
||||
**Drawers** — the original verbatim files. The exact words, never summarized.
|
||||
|
||||
**Halls** are memory types — the same in every wing, acting as corridors:
|
||||
- `hall_facts` — decisions made, choices locked in
|
||||
- `hall_events` — sessions, milestones, debugging
|
||||
- `hall_discoveries` — breakthroughs, new insights
|
||||
- `hall_preferences` — habits, likes, opinions
|
||||
- `hall_advice` — recommendations and solutions
|
||||
|
||||
**Rooms** are named ideas — `auth-migration`, `graphql-switch`, `ci-pipeline`. When the same room appears in different wings, it creates a **tunnel** — connecting the same topic across domains:
|
||||
|
||||
```
|
||||
wing_kai / hall_events / auth-migration → "Kai debugged the OAuth token refresh"
|
||||
wing_driftwood / hall_facts / auth-migration → "team decided to migrate auth to Clerk"
|
||||
wing_priya / hall_advice / auth-migration → "Priya approved Clerk over Auth0"
|
||||
```
|
||||
|
||||
Same room. Three wings. The tunnel connects them.
|
||||
|
||||
### Why Structure Matters
|
||||
|
||||
Tested on 22,000+ real conversation memories:
|
||||
|
||||
```
|
||||
Search all closets: 60.9% R@10
|
||||
Search within wing: 73.1% (+12%)
|
||||
Search wing + hall: 84.8% (+24%)
|
||||
Search wing + room: 94.8% (+34%)
|
||||
```
|
||||
|
||||
Wings and rooms aren't cosmetic. They're a **34% retrieval improvement**. The palace structure is the product.
|
||||
|
||||
### The Memory Stack
|
||||
|
||||
| Layer | What | Size | When |
|
||||
|-------|------|------|------|
|
||||
| **L0** | Identity — who is this AI? | ~50 tokens | Always loaded |
|
||||
| **L1** | Critical facts — team, projects, preferences | ~120 tokens (AAAK) | Always loaded |
|
||||
| **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.
|
||||
|
||||
### AAAK Dialect (experimental)
|
||||
|
||||
AAAK is a lossy abbreviation system — entity codes, structural markers, and sentence truncation — designed to pack repeated entities and relationships into fewer tokens at scale. It is **readable by any LLM that reads text** (Claude, GPT, Gemini, Llama, Mistral) without a decoder, so a local model can use it without any cloud dependency.
|
||||
|
||||
**Honest status (April 2026):**
|
||||
|
||||
- **AAAK is lossy, not lossless.** It uses regex-based abbreviation, not reversible compression.
|
||||
- **It does not save tokens at small scales.** Short text already tokenizes efficiently. AAAK overhead (codes, separators) costs more than it saves on a few sentences.
|
||||
- **It can save tokens at scale** — in scenarios with many repeated entities (a team mentioned hundreds of times, the same project across thousands of sessions), the entity codes amortize.
|
||||
- **AAAK currently regresses LongMemEval** vs raw verbatim retrieval (84.2% R@5 vs 96.6%). The 96.6% headline number is from **raw mode**, not AAAK mode.
|
||||
- **The MemPalace storage default is raw verbatim text in ChromaDB** — that's where the benchmark wins come from. AAAK is a separate compression layer for context loading, not the storage format.
|
||||
|
||||
We're iterating on the dialect spec, adding a real tokenizer for stats, and exploring better break points for when to use it. Track progress in [Issue #43](https://github.com/milla-jovovich/mempalace/issues/43) and [#27](https://github.com/milla-jovovich/mempalace/issues/27).
|
||||
|
||||
### Contradiction Detection (experimental, not yet wired into KG)
|
||||
|
||||
A separate utility (`fact_checker.py`) can check assertions against entity facts. It's not currently called automatically by the knowledge graph operations — this is being fixed (track in [Issue #27](https://github.com/milla-jovovich/mempalace/issues/27)). When enabled it catches things like:
|
||||
|
||||
```
|
||||
Input: "Soren finished the auth migration"
|
||||
Output: 🔴 AUTH-MIGRATION: attribution conflict — Maya was assigned, not Soren
|
||||
|
||||
Input: "Kai has been here 2 years"
|
||||
Output: 🟡 KAI: wrong_tenure — records show 3 years (started 2023-04)
|
||||
|
||||
Input: "The sprint ends Friday"
|
||||
Output: 🟡 SPRINT: stale_date — current sprint ends Thursday (updated 2 days ago)
|
||||
```
|
||||
|
||||
Facts checked against the knowledge graph. Ages, dates, and tenures calculated dynamically — not hardcoded.
|
||||
|
||||
---
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
### Solo developer across multiple projects
|
||||
|
||||
```bash
|
||||
# Mine each project's conversations
|
||||
mempalace mine ~/chats/orion/ --mode convos --wing orion
|
||||
mempalace mine ~/chats/nova/ --mode convos --wing nova
|
||||
mempalace mine ~/chats/helios/ --mode convos --wing helios
|
||||
|
||||
# Six months later: "why did I use Postgres here?"
|
||||
mempalace search "database decision" --wing orion
|
||||
# → "Chose Postgres over SQLite because Orion needs concurrent writes
|
||||
# and the dataset will exceed 10GB. Decided 2025-11-03."
|
||||
|
||||
# Cross-project search
|
||||
mempalace search "rate limiting approach"
|
||||
# → finds your approach in Orion AND Nova, shows the differences
|
||||
```
|
||||
|
||||
### Team lead managing a product
|
||||
|
||||
```bash
|
||||
# Mine Slack exports and AI conversations
|
||||
mempalace mine ~/exports/slack/ --mode convos --wing driftwood
|
||||
mempalace mine ~/.claude/projects/ --mode convos
|
||||
|
||||
# "What did Soren work on last sprint?"
|
||||
mempalace search "Soren sprint" --wing driftwood
|
||||
# → 14 closets: OAuth refactor, dark mode, component library migration
|
||||
|
||||
# "Who decided to use Clerk?"
|
||||
mempalace search "Clerk decision" --wing driftwood
|
||||
# → "Kai recommended Clerk over Auth0 — pricing + developer experience.
|
||||
# Team agreed 2026-01-15. Maya handling the migration."
|
||||
```
|
||||
|
||||
### Before mining: split mega-files
|
||||
|
||||
Some transcript exports concatenate multiple sessions into one huge file:
|
||||
|
||||
```bash
|
||||
mempalace split ~/chats/ # split into per-session files
|
||||
mempalace split ~/chats/ --dry-run # preview first
|
||||
mempalace split ~/chats/ --min-sessions 3 # only split files with 3+ sessions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Knowledge Graph
|
||||
|
||||
Temporal entity-relationship triples — like Zep's Graphiti, but SQLite instead of Neo4j. Local and free.
|
||||
|
||||
```python
|
||||
from mempalace.knowledge_graph import KnowledgeGraph
|
||||
|
||||
kg = KnowledgeGraph()
|
||||
kg.add_triple("Kai", "works_on", "Orion", valid_from="2025-06-01")
|
||||
kg.add_triple("Maya", "assigned_to", "auth-migration", valid_from="2026-01-15")
|
||||
kg.add_triple("Maya", "completed", "auth-migration", valid_from="2026-02-01")
|
||||
|
||||
# What's Kai working on?
|
||||
kg.query_entity("Kai")
|
||||
# → [Kai → works_on → Orion (current), Kai → recommended → Clerk (2026-01)]
|
||||
|
||||
# What was true in January?
|
||||
kg.query_entity("Maya", as_of="2026-01-20")
|
||||
# → [Maya → assigned_to → auth-migration (active)]
|
||||
|
||||
# Timeline
|
||||
kg.timeline("Orion")
|
||||
# → chronological story of the project
|
||||
```
|
||||
|
||||
Facts have validity windows. When something stops being true, invalidate it:
|
||||
|
||||
```python
|
||||
kg.invalidate("Kai", "works_on", "Orion", ended="2026-03-01")
|
||||
```
|
||||
|
||||
Now queries for Kai's current work won't return Orion. Historical queries still will.
|
||||
|
||||
| Feature | MemPalace | Zep (Graphiti) |
|
||||
|---------|-----------|----------------|
|
||||
| Storage | SQLite (local) | Neo4j (cloud) |
|
||||
| Cost | Free | $25/mo+ |
|
||||
| Temporal validity | Yes | Yes |
|
||||
| Self-hosted | Always | Enterprise only |
|
||||
| Privacy | Everything local | SOC 2, HIPAA |
|
||||
|
||||
---
|
||||
|
||||
## Specialist Agents
|
||||
|
||||
Create agents that focus on specific areas. Each agent gets its own wing and diary in the palace — not in your CLAUDE.md. Add 50 agents, your config stays the same size.
|
||||
|
||||
```
|
||||
~/.mempalace/agents/
|
||||
├── reviewer.json # code quality, patterns, bugs
|
||||
├── architect.json # design decisions, tradeoffs
|
||||
└── ops.json # deploys, incidents, infra
|
||||
```
|
||||
|
||||
Your CLAUDE.md just needs one line:
|
||||
|
||||
```
|
||||
You have MemPalace agents. Run mempalace_list_agents to see them.
|
||||
```
|
||||
|
||||
The AI discovers its agents from the palace at runtime. Each agent:
|
||||
|
||||
- **Has a focus** — what it pays attention to
|
||||
- **Keeps a diary** — written in AAAK, persists across sessions
|
||||
- **Builds expertise** — reads its own history to stay sharp in its domain
|
||||
|
||||
```
|
||||
# Agent writes to its diary after a code review
|
||||
mempalace_diary_write("reviewer",
|
||||
"PR#42|auth.bypass.found|missing.middleware.check|pattern:3rd.time.this.quarter|★★★★")
|
||||
|
||||
# Agent reads back its history
|
||||
mempalace_diary_read("reviewer", last_n=10)
|
||||
# → last 10 findings, compressed in AAAK
|
||||
```
|
||||
|
||||
Each agent is a specialist lens on your data. The reviewer remembers every bug pattern it's seen. The architect remembers every design decision. The ops agent remembers every incident. They don't share a scratchpad — they each maintain their own memory.
|
||||
|
||||
Letta charges $20–200/mo for agent-managed memory. MemPalace does it with a wing.
|
||||
|
||||
---
|
||||
|
||||
## MCP Server
|
||||
|
||||
```bash
|
||||
# Via plugin (recommended)
|
||||
claude plugin marketplace add milla-jovovich/mempalace
|
||||
claude plugin install --scope user mempalace
|
||||
|
||||
# Or manually
|
||||
claude mcp add mempalace -- python -m mempalace.mcp_server
|
||||
```
|
||||
|
||||
### 19 Tools
|
||||
|
||||
**Palace (read)**
|
||||
|
||||
| Tool | What |
|
||||
|------|------|
|
||||
| `mempalace_status` | Palace overview + AAAK spec + memory protocol |
|
||||
| `mempalace_list_wings` | Wings with counts |
|
||||
| `mempalace_list_rooms` | Rooms within a wing |
|
||||
| `mempalace_get_taxonomy` | Full wing → room → count tree |
|
||||
| `mempalace_search` | Semantic search with wing/room filters |
|
||||
| `mempalace_check_duplicate` | Check before filing |
|
||||
| `mempalace_get_aaak_spec` | AAAK dialect reference |
|
||||
|
||||
**Palace (write)**
|
||||
|
||||
| Tool | What |
|
||||
|------|------|
|
||||
| `mempalace_add_drawer` | File verbatim content |
|
||||
| `mempalace_delete_drawer` | Remove by ID |
|
||||
|
||||
**Knowledge Graph**
|
||||
|
||||
| Tool | What |
|
||||
|------|------|
|
||||
| `mempalace_kg_query` | Entity relationships with time filtering |
|
||||
| `mempalace_kg_add` | Add facts |
|
||||
| `mempalace_kg_invalidate` | Mark facts as ended |
|
||||
| `mempalace_kg_timeline` | Chronological entity story |
|
||||
| `mempalace_kg_stats` | Graph overview |
|
||||
|
||||
**Navigation**
|
||||
|
||||
| Tool | What |
|
||||
|------|------|
|
||||
| `mempalace_traverse` | Walk the graph from a room across wings |
|
||||
| `mempalace_find_tunnels` | Find rooms bridging two wings |
|
||||
| `mempalace_graph_stats` | Graph connectivity overview |
|
||||
|
||||
**Agent Diary**
|
||||
|
||||
| Tool | What |
|
||||
|------|------|
|
||||
| `mempalace_diary_write` | Write AAAK diary entry |
|
||||
| `mempalace_diary_read` | Read recent diary entries |
|
||||
|
||||
The AI learns AAAK and the memory protocol automatically from the `mempalace_status` response. No manual configuration.
|
||||
|
||||
---
|
||||
|
||||
## Auto-Save Hooks
|
||||
|
||||
Two hooks for Claude Code that automatically save memories during work:
|
||||
|
||||
**Save Hook** — every 15 messages, triggers a structured save. Topics, decisions, quotes, code changes. Also regenerates the critical facts layer.
|
||||
|
||||
**PreCompact Hook** — fires before context compression. Emergency save before the window shrinks.
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"Stop": [{"matcher": "", "hooks": [{"type": "command", "command": "/path/to/mempalace/hooks/mempal_save_hook.sh"}]}],
|
||||
"PreCompact": [{"matcher": "", "hooks": [{"type": "command", "command": "/path/to/mempalace/hooks/mempal_precompact_hook.sh"}]}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Optional auto-ingest:** Set the `MEMPAL_DIR` environment variable to a directory path and the hooks will automatically run `mempalace mine` on that directory during each save trigger (background on stop, synchronous on precompact).
|
||||
For Claude Code, Gemini CLI, MCP-compatible tools, and local models, see
|
||||
[mempalaceofficial.com/guide/getting-started](https://mempalaceofficial.com/guide/getting-started.html).
|
||||
|
||||
---
|
||||
|
||||
## Benchmarks
|
||||
|
||||
Tested on standard academic benchmarks — reproducible, published datasets.
|
||||
All numbers below are reproducible from this repository with the commands
|
||||
in [`benchmarks/BENCHMARKS.md`](benchmarks/BENCHMARKS.md). Full
|
||||
per-question result files are committed under `benchmarks/results_*`.
|
||||
|
||||
| Benchmark | Mode | Score | API Calls |
|
||||
|-----------|------|-------|-----------|
|
||||
| **LongMemEval R@5** | Raw (ChromaDB only) | **96.6%** | Zero |
|
||||
| **LongMemEval R@5** | Hybrid + Haiku rerank | **100%** (500/500) | ~500 |
|
||||
| **LoCoMo R@10** | Raw, session level | **60.3%** | Zero |
|
||||
| **Personal palace R@10** | Heuristic bench | **85%** | Zero |
|
||||
| **Palace structure impact** | Wing+room filtering | **+34%** R@10 | Zero |
|
||||
**LongMemEval — retrieval recall (R@5, 500 questions):**
|
||||
|
||||
The 96.6% raw score is the highest published LongMemEval result requiring no API key, no cloud, and no LLM at any stage.
|
||||
| Mode | R@5 | LLM required |
|
||||
|---|---|---|
|
||||
| Raw (semantic search, no heuristics, no LLM) | **96.6%** | None |
|
||||
| Hybrid v4, held-out 450q (tuned on 50 dev, not seen during training) | **98.4%** | None |
|
||||
| Hybrid v4 + LLM rerank (full 500) | ≥99% | Any capable model |
|
||||
|
||||
### vs Published Systems
|
||||
The raw 96.6% requires no API key, no cloud, and no LLM at any stage. The
|
||||
hybrid pipeline adds keyword boosting, temporal-proximity boosting, and
|
||||
preference-pattern extraction; the held-out 98.4% is the honest
|
||||
generalisable figure.
|
||||
|
||||
| System | LongMemEval R@5 | API Required | Cost |
|
||||
|--------|----------------|--------------|------|
|
||||
| **MemPalace (hybrid)** | **100%** | Optional | Free |
|
||||
| Supermemory ASMR | ~99% | Yes | — |
|
||||
| **MemPalace (raw)** | **96.6%** | **None** | **Free** |
|
||||
| Mastra | 94.87% | Yes (GPT) | API costs |
|
||||
| Mem0 | ~85% | Yes | $19–249/mo |
|
||||
| Zep | ~85% | Yes | $25/mo+ |
|
||||
The rerank pipeline promotes the best candidate out of the top-20
|
||||
retrieved sessions using an LLM reader. It works with any reasonably
|
||||
capable model — we have reproduced it with Claude Haiku, Claude Sonnet,
|
||||
and minimax-m2.7 via Ollama Cloud (no Anthropic dependency). The gap
|
||||
between raw and reranked is model-agnostic; we do not headline a "100%"
|
||||
number because the last 0.6% was reached by inspecting specific wrong
|
||||
answers, which `benchmarks/BENCHMARKS.md` flags as teaching to the test.
|
||||
|
||||
---
|
||||
**Other benchmarks (full results in [`benchmarks/BENCHMARKS.md`](benchmarks/BENCHMARKS.md)):**
|
||||
|
||||
## All Commands
|
||||
| Benchmark | Metric | Score | Notes |
|
||||
|---|---|---|---|
|
||||
| LoCoMo (session, top-10, no rerank) | R@10 | 60.3% | 1,986 questions |
|
||||
| LoCoMo (hybrid v5, top-10, no rerank) | R@10 | 88.9% | Same set |
|
||||
| ConvoMem (all categories, 250 items) | Avg recall | 92.9% | 50 per category |
|
||||
| MemBench (ACL 2025, 8,500 items) | R@5 | 80.3% | All categories |
|
||||
|
||||
We deliberately do not include a side-by-side comparison against Mem0,
|
||||
Mastra, Hindsight, Supermemory, or Zep. Those projects publish different
|
||||
metrics on different splits, and placing retrieval recall next to
|
||||
end-to-end QA accuracy is not an honest comparison. See each project's
|
||||
own research page for their published numbers.
|
||||
|
||||
**Reproducing every result:**
|
||||
|
||||
```bash
|
||||
# Setup
|
||||
mempalace init <dir> # guided onboarding + AAAK bootstrap
|
||||
|
||||
# Mining
|
||||
mempalace mine <dir> # mine project files
|
||||
mempalace mine <dir> --mode convos # mine conversation exports
|
||||
mempalace mine <dir> --mode convos --wing myapp # tag with a wing name
|
||||
|
||||
# Splitting
|
||||
mempalace split <dir> # split concatenated transcripts
|
||||
mempalace split <dir> --dry-run # preview
|
||||
|
||||
# Search
|
||||
mempalace search "query" # search everything
|
||||
mempalace search "query" --wing myapp # within a wing
|
||||
mempalace search "query" --room auth-migration # within a room
|
||||
|
||||
# Memory stack
|
||||
mempalace wake-up # load L0 + L1 context
|
||||
mempalace wake-up --wing driftwood # project-specific
|
||||
|
||||
# Compression
|
||||
mempalace compress --wing myapp # AAAK compress
|
||||
|
||||
# Status
|
||||
mempalace status # palace overview
|
||||
git clone https://github.com/MemPalace/mempalace.git
|
||||
cd mempalace
|
||||
pip install -e ".[dev]"
|
||||
# see benchmarks/README.md for dataset download commands
|
||||
python benchmarks/longmemeval_bench.py /path/to/longmemeval_s_cleaned.json
|
||||
```
|
||||
|
||||
All commands accept `--palace <path>` to override the default location.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
## Knowledge graph
|
||||
|
||||
### Global (`~/.mempalace/config.json`)
|
||||
MemPalace includes a temporal entity-relationship graph with validity
|
||||
windows — add, query, invalidate, timeline — backed by local SQLite.
|
||||
Usage and tool reference:
|
||||
[mempalaceofficial.com/concepts/knowledge-graph](https://mempalaceofficial.com/concepts/knowledge-graph.html).
|
||||
|
||||
```json
|
||||
{
|
||||
"palace_path": "/custom/path/to/palace",
|
||||
"collection_name": "mempalace_drawers",
|
||||
"people_map": {"Kai": "KAI", "Priya": "PRI"}
|
||||
}
|
||||
```
|
||||
## MCP server
|
||||
|
||||
### Wing config (`~/.mempalace/wing_config.json`)
|
||||
29 MCP tools cover palace reads/writes, knowledge-graph operations,
|
||||
cross-wing navigation, drawer management, and agent diaries. Installation
|
||||
and the full tool list:
|
||||
[mempalaceofficial.com/reference/mcp-tools](https://mempalaceofficial.com/reference/mcp-tools.html).
|
||||
|
||||
Generated by `mempalace init`. Maps your people and projects to wings:
|
||||
## Agents
|
||||
|
||||
```json
|
||||
{
|
||||
"default_wing": "wing_general",
|
||||
"wings": {
|
||||
"wing_kai": {"type": "person", "keywords": ["kai", "kai's"]},
|
||||
"wing_driftwood": {"type": "project", "keywords": ["driftwood", "analytics", "saas"]}
|
||||
}
|
||||
}
|
||||
```
|
||||
Each specialist agent gets its own wing and diary in the palace.
|
||||
Discoverable at runtime via `mempalace_list_agents` — no bloat in your
|
||||
system prompt:
|
||||
[mempalaceofficial.com/concepts/agents](https://mempalaceofficial.com/concepts/agents.html).
|
||||
|
||||
### Identity (`~/.mempalace/identity.txt`)
|
||||
## Auto-save hooks
|
||||
|
||||
Plain text. Becomes Layer 0 — loaded every session.
|
||||
|
||||
---
|
||||
|
||||
## File Reference
|
||||
|
||||
| File | What |
|
||||
|------|------|
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `knowledge_graph.py` | Temporal entity-relationship graph (SQLite) |
|
||||
| `palace_graph.py` | Room-based navigation graph |
|
||||
| `onboarding.py` | Guided setup — generates AAAK bootstrap + wing config |
|
||||
| `entity_registry.py` | Entity code registry |
|
||||
| `entity_detector.py` | Auto-detect people and projects from content |
|
||||
| `split_mega_files.py` | Split concatenated transcripts into per-session files |
|
||||
| `hooks/mempal_save_hook.sh` | Auto-save every N messages |
|
||||
| `hooks/mempal_precompact_hook.sh` | Emergency save before compaction |
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
mempalace/
|
||||
├── README.md ← you are here
|
||||
├── mempalace/ ← core package (README)
|
||||
│ ├── cli.py ← CLI entry point
|
||||
│ ├── mcp_server.py ← MCP server (19 tools)
|
||||
│ ├── knowledge_graph.py ← temporal entity graph
|
||||
│ ├── palace_graph.py ← room navigation graph
|
||||
│ ├── dialect.py ← AAAK compression
|
||||
│ ├── miner.py ← project file ingest
|
||||
│ ├── convo_miner.py ← conversation ingest
|
||||
│ ├── searcher.py ← semantic search
|
||||
│ ├── onboarding.py ← guided setup
|
||||
│ └── ... ← see mempalace/README.md
|
||||
├── benchmarks/ ← reproducible benchmark runners
|
||||
│ ├── README.md ← reproduction guide
|
||||
│ ├── BENCHMARKS.md ← full results + methodology
|
||||
│ ├── longmemeval_bench.py ← LongMemEval runner
|
||||
│ ├── locomo_bench.py ← LoCoMo runner
|
||||
│ └── membench_bench.py ← MemBench runner
|
||||
├── hooks/ ← Claude Code auto-save hooks
|
||||
│ ├── README.md ← hook setup guide
|
||||
│ ├── mempal_save_hook.sh ← save every N messages
|
||||
│ └── mempal_precompact_hook.sh ← emergency save
|
||||
├── examples/ ← usage examples
|
||||
│ ├── basic_mining.py
|
||||
│ ├── convo_import.py
|
||||
│ └── mcp_setup.md
|
||||
├── tests/ ← test suite (README)
|
||||
├── assets/ ← logo + brand assets
|
||||
└── pyproject.toml ← package config (v3.0.0)
|
||||
```
|
||||
Two Claude Code hooks save periodically and before context compression:
|
||||
[mempalaceofficial.com/guide/hooks](https://mempalaceofficial.com/guide/hooks.html).
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.9+
|
||||
- `chromadb>=0.4.0`
|
||||
- `pyyaml>=6.0`
|
||||
- A vector-store backend (ChromaDB by default)
|
||||
- ~300 MB disk for the default embedding model
|
||||
|
||||
No API key. No internet after install. Everything local.
|
||||
No API key is required for the core benchmark path.
|
||||
|
||||
```bash
|
||||
pip install mempalace
|
||||
```
|
||||
## Docs
|
||||
|
||||
---
|
||||
- Getting started → [mempalaceofficial.com/guide/getting-started](https://mempalaceofficial.com/guide/getting-started.html)
|
||||
- CLI reference → [mempalaceofficial.com/reference/cli](https://mempalaceofficial.com/reference/cli.html)
|
||||
- Python API → [mempalaceofficial.com/reference/python-api](https://mempalaceofficial.com/reference/python-api.html)
|
||||
- Full benchmark methodology → [benchmarks/BENCHMARKS.md](benchmarks/BENCHMARKS.md)
|
||||
- Release notes → [CHANGELOG.md](CHANGELOG.md)
|
||||
- Corrections and public notices → [docs/HISTORY.md](docs/HISTORY.md)
|
||||
|
||||
## Contributing
|
||||
|
||||
PRs welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup and guidelines.
|
||||
PRs welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
<!-- Link Definitions -->
|
||||
[version-shield]: https://img.shields.io/badge/version-3.1.0-4dc9f6?style=flat-square&labelColor=0a0e14
|
||||
[release-link]: https://github.com/milla-jovovich/mempalace/releases
|
||||
[version-shield]: https://img.shields.io/badge/version-3.3.1-4dc9f6?style=flat-square&labelColor=0a0e14
|
||||
[release-link]: https://github.com/MemPalace/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/
|
||||
[license-shield]: https://img.shields.io/badge/license-MIT-b0e8ff?style=flat-square&labelColor=0a0e14
|
||||
[license-link]: https://github.com/milla-jovovich/mempalace/blob/main/LICENSE
|
||||
[license-link]: https://github.com/MemPalace/mempalace/blob/main/LICENSE
|
||||
[discord-shield]: https://img.shields.io/badge/discord-join-5865F2?style=flat-square&labelColor=0a0e14&logo=discord&logoColor=5865F2
|
||||
[discord-link]: https://discord.com/invite/ycTQQCu6kn
|
||||
|
||||
+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.
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
MemPalace follows semantic versioning. Security fixes land on the current major version line.
|
||||
|
||||
| Version | Supported |
|
||||
| ------------------ | --------- |
|
||||
| 3.x (current) | Yes |
|
||||
| 2.x and earlier | No |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do not report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
We take the security of MemPalace seriously. If you believe you have found a security vulnerability, please report it privately using **GitHub Private Vulnerability Reporting**:
|
||||
|
||||
1. Open the [Security tab](https://github.com/MemPalace/mempalace/security) of this repository.
|
||||
2. Click **Advisories** → **Report a vulnerability**.
|
||||
3. Fill in the form with the details below.
|
||||
|
||||
### What to include in your report
|
||||
|
||||
- A descriptive summary of the vulnerability.
|
||||
- Detailed steps to reproduce the issue (including any proof-of-concept scripts or specific file paths).
|
||||
- The affected version(s) and platform(s).
|
||||
- The potential impact and severity.
|
||||
|
||||
### What to expect
|
||||
|
||||
- We aim to acknowledge receipt within 48 hours.
|
||||
- We will triage the issue and keep you updated on progress toward a patch.
|
||||
- Once the vulnerability is resolved and an update is released, we will publish a security advisory and credit you for the discovery (if you wish to be credited).
|
||||
+48
-14
@@ -41,23 +41,57 @@ Both are real. Both are reproducible. Neither is the whole picture alone.
|
||||
|
||||
## Comparison vs Published Systems (LongMemEval)
|
||||
|
||||
| # | System | R@5 | LLM Required | Which LLM | Notes |
|
||||
> **Important caveat — read before quoting this table.**
|
||||
> MemPal's `R@5` in this table is **retrieval recall**: is the labelled
|
||||
> session for this question inside the top-5 retrieved candidates?
|
||||
>
|
||||
> Several of the other systems below publish **end-to-end QA accuracy** —
|
||||
> a different metric that scores whether the system's generated answer
|
||||
> is correct. Retrieval recall and QA accuracy are not comparable; a
|
||||
> system can have 100% retrieval recall and 40% QA accuracy, and vice
|
||||
> versa.
|
||||
>
|
||||
> - **Mastra's 94.87%** is binary QA accuracy with GPT-5-mini, per
|
||||
> [mastra.ai/research/observational-memory](https://mastra.ai/research/observational-memory).
|
||||
> - **Supermemory ASMR's ~99%** is QA accuracy with an 8-/12-agent
|
||||
> ensemble, and the authors explicitly frame it as an experimental
|
||||
> proof-of-concept, not production, per
|
||||
> [their ASMR post](https://supermemory.ai/blog/we-broke-the-frontier-in-agent-memory-introducing-99-sota-memory-system/).
|
||||
> - **Mem0** does not publish a LongMemEval number; their published
|
||||
> metric is LoCoMo QA accuracy (~66.9%), per
|
||||
> [mem0.ai/research](https://mem0.ai/research).
|
||||
>
|
||||
> The table is kept here as a historical record of how the comparison
|
||||
> was originally framed. Public-facing pages (`README.md`,
|
||||
> `mempalaceofficial.com`) no longer present this table, per issue
|
||||
> [#875](https://github.com/MemPalace/mempalace/issues/875). For a fair
|
||||
> head-to-head, run the same metric on the same split.
|
||||
|
||||
| # | System | R@5 (retrieval recall, unless noted) | LLM Required | Which LLM | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | **MemPal (hybrid v4 + rerank)** | **100%** | Optional | Haiku | Reproducible, 500/500 |
|
||||
| 2 | Supermemory ASMR | ~99% | Yes | Undisclosed | Research only, not in production |
|
||||
| 1 | **MemPal (hybrid v4 + Haiku rerank)** | **100%** | Optional | Haiku | 500/500 — but the 99.4%→100% step tuned on 3 specific wrong answers (see "Benchmark Integrity" below). Held-out 450q is 98.4%. |
|
||||
| 2 | Supermemory ASMR | ~99% *(QA accuracy, not R@5)* | Yes | Ensemble of Gemini 2.0 Flash / GPT-4o-mini | Experimental, not production, per authors |
|
||||
| 3 | MemPal (hybrid v3 + rerank) | 99.4% | Optional | Haiku | Reproducible |
|
||||
| 3 | MemPal (palace + rerank) | 99.4% | Optional | Haiku | Independent architecture |
|
||||
| 4 | Mastra | 94.87% | Yes | GPT-5-mini | — |
|
||||
| 5 | **MemPal (raw, no LLM)** | **96.6%** | **None** | **None** | **Highest zero-API score published** |
|
||||
| 6 | Hindsight | 91.4% | Yes | Gemini-3 | — |
|
||||
| 7 | Supermemory (production) | ~85% | Yes | Undisclosed | — |
|
||||
| 8 | Stella (dense retriever) | ~85% | None | None | Academic baseline |
|
||||
| 9 | Contriever | ~78% | None | None | Academic baseline |
|
||||
| 4 | Mastra | 94.87% *(QA accuracy, not R@5)* | Yes | GPT-5-mini | Different metric — not directly comparable to R@5 |
|
||||
| 5 | **MemPal (raw, no LLM)** | **96.6%** | **None** | **None** | **Reproducible, 500/500** |
|
||||
| 6 | MemPal hybrid v4 held-out 450 | 98.4% | None | None | Honest generalisable hybrid-pipeline figure |
|
||||
| 7 | Hindsight | 91.4% *(per their release, metric unverified)* | Yes | Gemini-3 | Check their published methodology |
|
||||
| 8 | Stella (dense retriever) | ~85% | None | None | Academic retrieval baseline |
|
||||
| 9 | Contriever | ~78% | None | None | Academic retrieval baseline |
|
||||
| 10 | BM25 (sparse) | ~70% | None | None | Keyword baseline |
|
||||
|
||||
**MemPal raw (96.6%) is the highest published LongMemEval score that requires no API key, no cloud, and no LLM at any stage.**
|
||||
The MemPal raw 96.6% is the headline we ship on public surfaces: it's
|
||||
retrieval recall, it requires no API key, and it reproduces.
|
||||
|
||||
**MemPal hybrid v4 + Haiku rerank (100%) is the first perfect score on LongMemEval — 500/500 questions, all 6 question types at 100%.**
|
||||
The MemPal hybrid v4 + Haiku rerank 100% remains an internal
|
||||
result — reproducible with `--mode hybrid_v4 --llm-rerank` — but we
|
||||
don't quote it on public pages because the final 0.6% was reached by
|
||||
inspecting three specific wrong answers (see "Benchmark Integrity"
|
||||
below), which is teaching to the test. The honest generalisable figure
|
||||
when an LLM is in the loop is the held-out 98.4% R@5 on 450 unseen
|
||||
questions, or the model-agnostic 99.2% R@5 / 100% R@10 we reproduced
|
||||
with minimax-m2.7 on the full 500.
|
||||
|
||||
---
|
||||
|
||||
@@ -308,9 +342,9 @@ The palace classifies each question into one of 5 halls. Pass 1 searches only wi
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
git clone -b ben/benchmarking https://github.com/aya-thekeeper/mempal.git
|
||||
cd mempal
|
||||
pip install chromadb pyyaml
|
||||
git clone https://github.com/MemPalace/mempalace.git
|
||||
cd mempalace
|
||||
pip install -e ".[dev]"
|
||||
mkdir -p /tmp/longmemeval-data
|
||||
curl -fsSL -o /tmp/longmemeval-data/longmemeval_s_cleaned.json \
|
||||
https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned/resolve/main/longmemeval_s_cleaned.json
|
||||
|
||||
@@ -196,9 +196,9 @@ python benchmarks/longmemeval_bench.py data/longmemeval_s_cleaned.json --mode hy
|
||||
|
||||
```bash
|
||||
# Setup
|
||||
git clone -b ben/benchmarking https://github.com/aya-thekeeper/mempal.git
|
||||
cd mempal
|
||||
pip install chromadb
|
||||
git clone https://github.com/MemPalace/mempalace.git
|
||||
cd mempalace
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Download data
|
||||
mkdir -p /tmp/longmemeval-data
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# MemPal Benchmarks — Reproduction Guide
|
||||
# MemPalace Benchmarks — Reproduction Guide
|
||||
|
||||
Run the exact same benchmarks we report. Clone, install, run.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
git clone -b ben/benchmarking https://github.com/aya-thekeeper/mempal.git
|
||||
cd mempal
|
||||
pip install chromadb pyyaml
|
||||
git clone https://github.com/MemPalace/mempalace.git
|
||||
cd mempalace
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
## Benchmark 1: LongMemEval (500 questions)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
{
|
||||
"dev": [
|
||||
"cc06de0d",
|
||||
"f9e8c073",
|
||||
"b320f3f8",
|
||||
"a89d7624",
|
||||
"311778f1",
|
||||
"gpt4_59c863d7",
|
||||
"bbf86515",
|
||||
"099778bb",
|
||||
"e831120c",
|
||||
"dcfa8644",
|
||||
"8fb83627",
|
||||
"e66b632c",
|
||||
"gpt4_7fce9456",
|
||||
"55241a1f",
|
||||
"352ab8bd",
|
||||
"f4f1d8a4",
|
||||
"830ce83f",
|
||||
"2311e44b",
|
||||
"09ba9854",
|
||||
"gpt4_a1b77f9c",
|
||||
"07741c45",
|
||||
"gpt4_70e84552",
|
||||
"b46e15ee",
|
||||
"6071bd76",
|
||||
"6f9b354f",
|
||||
"1d4da289",
|
||||
"gpt4_8279ba02",
|
||||
"6456829e_abs",
|
||||
"0db4c65d",
|
||||
"d6062bb9",
|
||||
"60bf93ed_abs",
|
||||
"d3ab962e",
|
||||
"87f22b4a",
|
||||
"e01b8e2f",
|
||||
"gpt4_7ddcf75f",
|
||||
"8ebdbe50",
|
||||
"26bdc477",
|
||||
"29f2956b_abs",
|
||||
"2311e44b_abs",
|
||||
"75f70248",
|
||||
"852ce960",
|
||||
"f0e564bc",
|
||||
"fca70973",
|
||||
"3c1045c8",
|
||||
"18bc8abd",
|
||||
"afdc33df",
|
||||
"54026fce",
|
||||
"b9cfe692",
|
||||
"6456829e",
|
||||
"e6041065"
|
||||
],
|
||||
"held_out": [
|
||||
"gpt4_15e38248",
|
||||
"gpt4_2ba83207",
|
||||
"2133c1b5_abs",
|
||||
"gpt4_8279ba03",
|
||||
"76d63226",
|
||||
"1192316e",
|
||||
"gpt4_fa19884d",
|
||||
"gpt4_372c3eed_abs",
|
||||
"1a8a66a6",
|
||||
"gpt4_fe651585",
|
||||
"e25c3b8d",
|
||||
"945e3d21",
|
||||
"86b68151",
|
||||
"1c0ddc50",
|
||||
"1e043500",
|
||||
"d682f1a2",
|
||||
"gpt4_b5700ca0",
|
||||
"91b15a6e",
|
||||
"ce6d2d27",
|
||||
"f523d9fe",
|
||||
"7024f17c",
|
||||
"8752c811",
|
||||
"gpt4_f420262d",
|
||||
"d01c6aa8",
|
||||
"4b24c848",
|
||||
"7e974930",
|
||||
"3fdac837",
|
||||
"gpt4_b4a80587",
|
||||
"c18a7dc8",
|
||||
"80ec1f4f_abs",
|
||||
"7527f7e2",
|
||||
"6ade9755",
|
||||
"89941a94",
|
||||
"gpt4_1d80365e",
|
||||
"2133c1b5",
|
||||
"06db6396",
|
||||
"gpt4_88806d6e",
|
||||
"88432d0a",
|
||||
"3ba21379",
|
||||
"0862e8bf",
|
||||
"aae3761f",
|
||||
"5025383b",
|
||||
"gpt4_e061b84f",
|
||||
"73d42213",
|
||||
"4bc144e2",
|
||||
"gpt4_5501fe77",
|
||||
"00ca467f",
|
||||
"dfde3500",
|
||||
"01493427",
|
||||
"b6025781",
|
||||
"a96c20ee_abs",
|
||||
"982b5123_abs",
|
||||
"gpt4_fa19884c",
|
||||
"gpt4_1a1dc16d",
|
||||
"28dc39ac",
|
||||
"gpt4_2d58bcd6",
|
||||
"51c32626",
|
||||
"c4ea545c",
|
||||
"1da05512",
|
||||
"gpt4_385a5000",
|
||||
"577d4d32",
|
||||
"72e3ee87",
|
||||
"f4f1d8a4_abs",
|
||||
"9d25d4e0",
|
||||
"b29f3365",
|
||||
"b759caee",
|
||||
"10e09553",
|
||||
"1d4e3b97",
|
||||
"d52b4f67",
|
||||
"gpt4_e072b769",
|
||||
"58ef2f1c",
|
||||
"6e984301",
|
||||
"41275add",
|
||||
"gpt4_59149c77",
|
||||
"2ebe6c90",
|
||||
"1cea1afa",
|
||||
"gpt4_1e4a8aec",
|
||||
"6c49646a",
|
||||
"8a2466db",
|
||||
"gpt4_65aabe59",
|
||||
"gpt4_93159ced",
|
||||
"51a45a95",
|
||||
"af8d2e46",
|
||||
"561fabcd",
|
||||
"370a8ff4",
|
||||
"gpt4_d84a3211",
|
||||
"gpt4_7a0daae1",
|
||||
"2a1811e2",
|
||||
"gpt4_78cf46a3",
|
||||
"1568498a",
|
||||
"6b7dfb22",
|
||||
"6ae235be",
|
||||
"bc8a6e93_abs",
|
||||
"681a1674",
|
||||
"06878be2",
|
||||
"1a1907b4",
|
||||
"0e4e4c46",
|
||||
"gpt4_85da3956",
|
||||
"gpt4_f420262c",
|
||||
"2bf43736",
|
||||
"bc149d6b",
|
||||
"09d032c9",
|
||||
"5c40ec5b",
|
||||
"eac54adc",
|
||||
"993da5e2",
|
||||
"71a3fd6b",
|
||||
"gpt4_0b2f1d21",
|
||||
"ad7109d1",
|
||||
"4c36ccef",
|
||||
"c8c3f81d",
|
||||
"edced276_abs",
|
||||
"0bc8ad92",
|
||||
"gpt4_468eb064",
|
||||
"2ebe6c92",
|
||||
"cc6d1ec1",
|
||||
"4dfccbf8",
|
||||
"95228167",
|
||||
"ba358f49",
|
||||
"45dc21b6",
|
||||
"db467c8c",
|
||||
"720133ac",
|
||||
"67e0d0f2",
|
||||
"cc5ded98",
|
||||
"726462e0",
|
||||
"4100d0a0",
|
||||
"3a704032",
|
||||
"gpt4_7ca326fa",
|
||||
"ec81a493",
|
||||
"618f13b2",
|
||||
"58470ed2",
|
||||
"gpt4_4fc4f797",
|
||||
"60036106",
|
||||
"157a136e",
|
||||
"6222b6eb",
|
||||
"69fee5aa",
|
||||
"19b5f2b3_abs",
|
||||
"gpt4_d12ceb0e",
|
||||
"51b23612",
|
||||
"2318644b",
|
||||
"3fe836c9",
|
||||
"gpt4_7de946e7",
|
||||
"71017277",
|
||||
"f0853d11",
|
||||
"dc439ea3",
|
||||
"gpt4_2f91af09",
|
||||
"9a707b81",
|
||||
"bc8a6e93",
|
||||
"c14c00dd",
|
||||
"8979f9ec",
|
||||
"cf22b7bf",
|
||||
"gpt4_ec93e27f",
|
||||
"gpt4_468eb063",
|
||||
"41698283",
|
||||
"1de5cff2",
|
||||
"21d02d0d",
|
||||
"c7cf7dfd",
|
||||
"gpt4_ab202e7f",
|
||||
"dccbc061",
|
||||
"078150f1",
|
||||
"e3038f8c",
|
||||
"gpt4_c27434e8_abs",
|
||||
"2698e78f",
|
||||
"031748ae_abs",
|
||||
"gpt4_59149c78",
|
||||
"c8f1aeed",
|
||||
"184da446",
|
||||
"gpt4_b5700ca9",
|
||||
"89527b6b",
|
||||
"0977f2af",
|
||||
"853b0a1d",
|
||||
"a346bb18",
|
||||
"3249768e",
|
||||
"gpt4_2f8be40d",
|
||||
"gpt4_93159ced_abs",
|
||||
"eeda8a6d",
|
||||
"7a8d0b71",
|
||||
"95bcc1c8",
|
||||
"gpt4_2487a7cb",
|
||||
"85fa3a3f",
|
||||
"7e00a6cb",
|
||||
"e3fc4d6e",
|
||||
"59524333",
|
||||
"37f165cf",
|
||||
"0ddfec37",
|
||||
"60bf93ed",
|
||||
"d7c942c3",
|
||||
"80ec1f4f",
|
||||
"ceb54acb",
|
||||
"9aaed6a3",
|
||||
"gpt4_4929293a",
|
||||
"ed4ddc30",
|
||||
"545bd2b5",
|
||||
"2788b940",
|
||||
"ef9cf60a",
|
||||
"gpt4_7f6b06db",
|
||||
"0ea62687",
|
||||
"3d86fd0a",
|
||||
"3e321797",
|
||||
"d24813b1",
|
||||
"38146c39",
|
||||
"efc3f7c2",
|
||||
"7401057b",
|
||||
"5809eb10",
|
||||
"28bcfaac",
|
||||
"1903aded",
|
||||
"gpt4_194be4b3",
|
||||
"gpt4_e414231f",
|
||||
"0ddfec37_abs",
|
||||
"c2ac3c61",
|
||||
"gpt4_4ef30696",
|
||||
"1f2b8d4f",
|
||||
"0f05491a",
|
||||
"8550ddae",
|
||||
"8077ef71",
|
||||
"b86304ba",
|
||||
"e61a7584",
|
||||
"8cf51dda",
|
||||
"gpt4_2f584639",
|
||||
"08e075c7",
|
||||
"5d3d2817",
|
||||
"7405e8b1",
|
||||
"a3045048",
|
||||
"gpt4_731e37d7",
|
||||
"c8090214_abs",
|
||||
"36580ce8",
|
||||
"ba358f49_abs",
|
||||
"gpt4_d6585ce8",
|
||||
"e56a43b9",
|
||||
"2c63a862",
|
||||
"gpt4_5438fa52",
|
||||
"07b6f563",
|
||||
"gpt4_31ff4165",
|
||||
"0bb5a684",
|
||||
"71315a70",
|
||||
"gpt4_cd90e484",
|
||||
"gpt4_8c8961ae",
|
||||
"gpt4_fe651585_abs",
|
||||
"36b9f61e",
|
||||
"gpt4_b0863698",
|
||||
"gpt4_1d4ab0c9",
|
||||
"15745da0_abs",
|
||||
"0862e8bf_abs",
|
||||
"bcbe585f",
|
||||
"a2f3aa27",
|
||||
"gpt4_6dc9b45b",
|
||||
"ccb36322",
|
||||
"f685340e",
|
||||
"9ea5eabc",
|
||||
"gpt4_372c3eed",
|
||||
"37d43f65",
|
||||
"bf659f65",
|
||||
"b0479f84",
|
||||
"gpt4_213fd887",
|
||||
"e4e14d04",
|
||||
"f8c5f88b",
|
||||
"gpt4_18c2b244",
|
||||
"a11281a2",
|
||||
"gpt4_2655b836",
|
||||
"e47becba",
|
||||
"gpt4_74aed68e",
|
||||
"gpt4_af6db32f",
|
||||
"6cb6f249",
|
||||
"77eafa52",
|
||||
"gpt4_93f6379c",
|
||||
"e8a79c70",
|
||||
"7a87bd0c",
|
||||
"gpt4_6ed717ea",
|
||||
"d6233ab6",
|
||||
"c19f7a0b",
|
||||
"gpt4_61e13b3c",
|
||||
"d23cf73b",
|
||||
"gpt4_1e4a8aeb",
|
||||
"ba61f0b9",
|
||||
"118b2229",
|
||||
"488d3006",
|
||||
"c4a1ceb8",
|
||||
"8e91e7d9",
|
||||
"42ec0761",
|
||||
"65240037",
|
||||
"fea54f57",
|
||||
"c8090214",
|
||||
"b01defab",
|
||||
"6aeb4375_abs",
|
||||
"faba32e5",
|
||||
"c5e8278d",
|
||||
"gpt4_e414231e",
|
||||
"eeda8a6d_abs",
|
||||
"gpt4_8e165409",
|
||||
"af082822",
|
||||
"22d2cb42",
|
||||
"92a0aa75",
|
||||
"1c549ce4",
|
||||
"25e5aa4f",
|
||||
"gpt4_68e94288",
|
||||
"4baee567",
|
||||
"18dcd5a5",
|
||||
"dad224aa",
|
||||
"gpt4_f2262a51",
|
||||
"29f2956b",
|
||||
"21436231",
|
||||
"19b5f2b3",
|
||||
"gpt4_1916e0ea",
|
||||
"gpt4_45189cb4",
|
||||
"0a995998",
|
||||
"b6019101",
|
||||
"9bbe84a2",
|
||||
"61f8c8f8",
|
||||
"9a707b82",
|
||||
"8cf4d046",
|
||||
"eac54add",
|
||||
"75832dbd",
|
||||
"gpt4_98f46fc6",
|
||||
"d596882b",
|
||||
"88432d0a_abs",
|
||||
"16c90bf4",
|
||||
"f685340e_abs",
|
||||
"b5ef892d",
|
||||
"gpt4_f49edff3",
|
||||
"gpt4_483dd43c",
|
||||
"bb7c3b45",
|
||||
"gpt4_7abb270c",
|
||||
"gpt4_9a159967",
|
||||
"07741c44",
|
||||
"4d6b87c8",
|
||||
"6aeb4375",
|
||||
"gpt4_d6585ce9",
|
||||
"60472f9c",
|
||||
"caf9ead2",
|
||||
"32260d93",
|
||||
"60159905",
|
||||
"0a34ad58",
|
||||
"a40e080f",
|
||||
"10d9b85a",
|
||||
"a06e4cfe",
|
||||
"4f54b7c9",
|
||||
"6613b389",
|
||||
"70b3e69b",
|
||||
"gpt4_7bc6cf22",
|
||||
"gpt4_0a05b494",
|
||||
"778164c6",
|
||||
"195a1a1b",
|
||||
"8464fc84",
|
||||
"b46e15ed",
|
||||
"603deb26",
|
||||
"eaca4986",
|
||||
"2698e78f_abs",
|
||||
"gpt4_21adecb5",
|
||||
"2e6d26dc",
|
||||
"5831f84d",
|
||||
"08f4fc43",
|
||||
"3f1e9474",
|
||||
"c9f37c46",
|
||||
"gpt4_2f56ae70",
|
||||
"1b9b7252",
|
||||
"35a27287",
|
||||
"gpt4_d31cdae3",
|
||||
"129d1232",
|
||||
"4adc0475",
|
||||
"27016adc",
|
||||
"46a3abf7",
|
||||
"9ee3ecd6",
|
||||
"982b5123",
|
||||
"09ba9854_abs",
|
||||
"0e5e2d1a",
|
||||
"e9327a54",
|
||||
"86f00804",
|
||||
"e982271f",
|
||||
"7161e7e2",
|
||||
"57f827a0",
|
||||
"6a27ffc2",
|
||||
"edced276",
|
||||
"gpt4_d9af6064",
|
||||
"75499fd8",
|
||||
"60d45044",
|
||||
"gpt4_70e84552_abs",
|
||||
"2ce6a0f2",
|
||||
"gpt4_4929293b",
|
||||
"a1cc6108",
|
||||
"gpt4_5dcc0aab",
|
||||
"a3838d2b",
|
||||
"c7dc5443",
|
||||
"505af2f5",
|
||||
"gpt4_68e94287",
|
||||
"15745da0",
|
||||
"0100672e",
|
||||
"a82c026e",
|
||||
"5e1b23de",
|
||||
"71017276",
|
||||
"89941a93",
|
||||
"6b168ec8",
|
||||
"affe2881",
|
||||
"0edc2aef",
|
||||
"gpt4_2312f94c",
|
||||
"a4996e51",
|
||||
"c6853660",
|
||||
"ef66a6e5",
|
||||
"8a137a7f",
|
||||
"a96c20ee",
|
||||
"fca762bc",
|
||||
"ac031881",
|
||||
"d905b33f",
|
||||
"e493bb7c",
|
||||
"a9f6b44c",
|
||||
"dd2973ad",
|
||||
"8aef76bc",
|
||||
"f35224e0",
|
||||
"8b9d4367",
|
||||
"gpt4_c27434e8",
|
||||
"gpt4_a56e767c",
|
||||
"eace081b",
|
||||
"5a4f22c0",
|
||||
"58bf7951",
|
||||
"c4f10528",
|
||||
"50635ada",
|
||||
"06f04340",
|
||||
"0bc8ad93",
|
||||
"e5ba910e_abs",
|
||||
"5a7937c8",
|
||||
"a3332713",
|
||||
"4388e9dd",
|
||||
"8c18457d",
|
||||
"gpt4_2c50253f",
|
||||
"6a1eabeb",
|
||||
"b3c15d39",
|
||||
"gpt4_e061b84g",
|
||||
"3b6f954b",
|
||||
"gpt4_76048e76",
|
||||
"4dfccbf7",
|
||||
"2b8f3739",
|
||||
"d851d5ba",
|
||||
"4fd1909e",
|
||||
"94f70d80",
|
||||
"66f24dbb",
|
||||
"a08a253f",
|
||||
"6e984302",
|
||||
"001be529",
|
||||
"gpt4_a2d1d1f6",
|
||||
"cc539528",
|
||||
"e48988bc",
|
||||
"gpt4_4cd9eba1",
|
||||
"8e9d538c",
|
||||
"a1eacc2a",
|
||||
"6d550036",
|
||||
"gpt4_e05b82a6",
|
||||
"81507db6",
|
||||
"caf03d32",
|
||||
"031748ae",
|
||||
"c960da58",
|
||||
"1faac195",
|
||||
"gpt4_4edbafa2"
|
||||
],
|
||||
"seed": 42,
|
||||
"dev_size": 50
|
||||
}
|
||||
+70
-41
@@ -510,11 +510,20 @@ def palace_assign_rooms(sessions, sample_id, api_key, cache, model="claude-haiku
|
||||
|
||||
|
||||
def llm_rerank_locomo(
|
||||
question, retrieved_ids, retrieved_docs, api_key, top_k=10, model="claude-sonnet-4-6"
|
||||
question,
|
||||
retrieved_ids,
|
||||
retrieved_docs,
|
||||
api_key,
|
||||
top_k=10,
|
||||
model="claude-sonnet-4-6",
|
||||
backend="anthropic",
|
||||
base_url="",
|
||||
):
|
||||
"""
|
||||
Ask LLM to pick the single most relevant document for this question.
|
||||
Returns reordered retrieved_ids with the best candidate first.
|
||||
|
||||
Supports backend="anthropic" (default) or "ollama" (OpenAI-compat endpoint).
|
||||
"""
|
||||
candidates = retrieved_ids[:top_k]
|
||||
candidate_docs = retrieved_docs[:top_k]
|
||||
@@ -522,7 +531,6 @@ def llm_rerank_locomo(
|
||||
if len(candidates) <= 1:
|
||||
return retrieved_ids
|
||||
|
||||
# Build numbered list of candidates
|
||||
lines = []
|
||||
for i, (cid, doc) in enumerate(zip(candidates, candidate_docs), 1):
|
||||
snippet = doc[:300].replace("\n", " ")
|
||||
@@ -534,35 +542,51 @@ def llm_rerank_locomo(
|
||||
f"Reply with just the number (1-{len(candidates)}).\n\n" + "\n".join(lines)
|
||||
)
|
||||
|
||||
payload = json.dumps(
|
||||
{
|
||||
"model": model,
|
||||
"max_tokens": 8,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
data=payload,
|
||||
headers={
|
||||
if backend == "ollama":
|
||||
url = (base_url or "http://localhost:11434").rstrip("/") + "/v1/chat/completions"
|
||||
payload = json.dumps(
|
||||
{
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": 1024,
|
||||
"temperature": 0.0,
|
||||
}
|
||||
).encode("utf-8")
|
||||
headers = {"content-type": "application/json"}
|
||||
if api_key:
|
||||
headers["authorization"] = f"Bearer {api_key}"
|
||||
else:
|
||||
url = "https://api.anthropic.com/v1/messages"
|
||||
payload = json.dumps(
|
||||
{
|
||||
"model": model,
|
||||
"max_tokens": 8,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
}
|
||||
).encode("utf-8")
|
||||
headers = {
|
||||
"x-api-key": api_key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
}
|
||||
|
||||
req = urllib.request.Request(url, data=payload, headers=headers, method="POST")
|
||||
|
||||
import socket as _socket
|
||||
|
||||
for _attempt in range(3):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
with urllib.request.urlopen(req, timeout=120 if backend == "ollama" else 30) as resp:
|
||||
result = json.loads(resp.read())
|
||||
raw = result["content"][0]["text"].strip()
|
||||
m = re.search(r"\b(\d+)\b", raw)
|
||||
if backend == "ollama":
|
||||
msg = result["choices"][0]["message"]
|
||||
raw = (msg.get("content") or "").strip() or (msg.get("reasoning") or "").strip()
|
||||
else:
|
||||
raw = result["content"][0]["text"].strip()
|
||||
# Take LAST integer — reasoning models often count candidates first
|
||||
m = re.search(r"\b(\d+)\b", raw[::-1])
|
||||
if m:
|
||||
pick = int(m.group(1))
|
||||
pick = int(m.group(1)[::-1])
|
||||
if 1 <= pick <= len(candidates):
|
||||
chosen_id = candidates[pick - 1]
|
||||
reordered = [chosen_id] + [cid for cid in retrieved_ids if cid != chosen_id]
|
||||
@@ -580,29 +604,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 ""
|
||||
|
||||
|
||||
@@ -625,6 +632,8 @@ def run_benchmark(
|
||||
palace_cache_file=None,
|
||||
palace_model="claude-haiku-4-5-20251001",
|
||||
embed_model="default",
|
||||
llm_backend="anthropic",
|
||||
llm_base_url="",
|
||||
):
|
||||
"""Run LoCoMo retrieval benchmark."""
|
||||
with open(data_file) as f:
|
||||
@@ -636,8 +645,12 @@ def run_benchmark(
|
||||
api_key = ""
|
||||
if llm_rerank_enabled or mode == "palace":
|
||||
api_key = _load_api_key(llm_key)
|
||||
if not api_key:
|
||||
print(f"ERROR: --mode {mode} requires an API key (--llm-key or ANTHROPIC_API_KEY).")
|
||||
# Ollama backend doesn't require an Anthropic key. Palace mode still does
|
||||
# (it uses Anthropic for room-assignment indexing) — so only relax the
|
||||
# requirement when rerank is the ONLY llm use and backend is ollama.
|
||||
needs_key = mode == "palace" or (llm_rerank_enabled and llm_backend == "anthropic")
|
||||
if needs_key and not api_key:
|
||||
print(f"ERROR: --mode {mode} / --llm-rerank (anthropic) requires an API key.")
|
||||
sys.exit(1)
|
||||
|
||||
# Palace mode: load or create room assignment cache
|
||||
@@ -905,6 +918,8 @@ def run_benchmark(
|
||||
api_key,
|
||||
top_k=rerank_pool,
|
||||
model=llm_model,
|
||||
backend=llm_backend,
|
||||
base_url=llm_base_url,
|
||||
)
|
||||
|
||||
# Compute recall
|
||||
@@ -1030,6 +1045,18 @@ if __name__ == "__main__":
|
||||
help="Model for LLM rerank (default: claude-sonnet-4-6)",
|
||||
)
|
||||
parser.add_argument("--llm-key", default="", help="API key (or set ANTHROPIC_API_KEY env var)")
|
||||
parser.add_argument(
|
||||
"--llm-backend",
|
||||
choices=["anthropic", "ollama"],
|
||||
default="anthropic",
|
||||
help="Which API for --llm-rerank. 'anthropic' (default) or 'ollama' "
|
||||
"(OpenAI-compat /v1/chat/completions — works for local + Ollama Cloud).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--llm-base-url",
|
||||
default="",
|
||||
help="Override base URL for --llm-backend ollama. Default: http://localhost:11434.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--hybrid-weight",
|
||||
type=float,
|
||||
@@ -1066,4 +1093,6 @@ if __name__ == "__main__":
|
||||
palace_cache_file=args.palace_cache,
|
||||
palace_model=args.palace_model,
|
||||
embed_model=args.embed_model,
|
||||
llm_backend=args.llm_backend,
|
||||
llm_base_url=args.llm_base_url,
|
||||
)
|
||||
|
||||
+103
-66
@@ -2763,7 +2763,15 @@ def build_palace_and_retrieve_diary(
|
||||
|
||||
|
||||
def llm_rerank(
|
||||
question, rankings, corpus, corpus_ids, api_key, top_k=10, model="claude-haiku-4-5-20251001"
|
||||
question,
|
||||
rankings,
|
||||
corpus,
|
||||
corpus_ids,
|
||||
api_key,
|
||||
top_k=10,
|
||||
model="claude-haiku-4-5-20251001",
|
||||
backend="anthropic",
|
||||
base_url="",
|
||||
):
|
||||
"""
|
||||
Use an LLM to re-rank the top-k retrieved sessions.
|
||||
@@ -2772,19 +2780,22 @@ def llm_rerank(
|
||||
which single session is most relevant to the question. That session
|
||||
is promoted to rank 1; the rest stay in their existing order.
|
||||
|
||||
This closes the gap for "preference" and jargon-dense "assistant"
|
||||
failures where the right session is in top-10 semantically but not
|
||||
top-5 — because the semantic gap (battery life ↔ phone hardware) is
|
||||
too large for embeddings to bridge.
|
||||
Supports two backends:
|
||||
- "anthropic": hits https://api.anthropic.com/v1/messages with x-api-key.
|
||||
- "ollama": hits {base_url}/v1/chat/completions (OpenAI-compat) —
|
||||
works for local Ollama (default http://localhost:11434)
|
||||
and Ollama Cloud (:cloud model tags).
|
||||
|
||||
Args:
|
||||
question: The benchmark question string
|
||||
rankings: Current ranked list of corpus indices (from any mode)
|
||||
corpus: List of document strings
|
||||
corpus_ids: List of corpus IDs (parallel to corpus)
|
||||
api_key: Anthropic API key string
|
||||
top_k: How many top sessions to send to LLM (default: 10)
|
||||
model: Claude model ID for reranking (default: haiku)
|
||||
question: The benchmark question string
|
||||
rankings: Current ranked list of corpus indices (from any mode)
|
||||
corpus: List of document strings
|
||||
corpus_ids: List of corpus IDs (parallel to corpus)
|
||||
api_key: Anthropic API key (only required for backend="anthropic")
|
||||
top_k: How many top sessions to send to LLM (default: 10)
|
||||
model: Model id (Claude model for anthropic, e.g. "minimax-m2.7:cloud" for ollama)
|
||||
backend: "anthropic" or "ollama"
|
||||
base_url: Override base URL (ollama default: http://localhost:11434)
|
||||
|
||||
Returns:
|
||||
Reordered rankings list with LLM's best pick promoted to rank 1.
|
||||
@@ -2796,7 +2807,6 @@ def llm_rerank(
|
||||
if not candidates:
|
||||
return rankings
|
||||
|
||||
# Format sessions for the prompt — first 500 chars each, labelled 1..N
|
||||
session_blocks = []
|
||||
for rank, idx in enumerate(candidates):
|
||||
text = corpus[idx][:500].replace("\n", " ").strip()
|
||||
@@ -2813,80 +2823,79 @@ def llm_rerank(
|
||||
f"Most relevant session number:"
|
||||
)
|
||||
|
||||
payload = json.dumps(
|
||||
{
|
||||
"model": model,
|
||||
"max_tokens": 8,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
data=payload,
|
||||
headers={
|
||||
if backend == "ollama":
|
||||
url = (base_url or "http://localhost:11434").rstrip("/") + "/v1/chat/completions"
|
||||
payload = json.dumps(
|
||||
{
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": 1024,
|
||||
"temperature": 0.0,
|
||||
}
|
||||
).encode("utf-8")
|
||||
headers = {"content-type": "application/json"}
|
||||
if api_key:
|
||||
headers["authorization"] = f"Bearer {api_key}"
|
||||
else:
|
||||
url = "https://api.anthropic.com/v1/messages"
|
||||
payload = json.dumps(
|
||||
{
|
||||
"model": model,
|
||||
"max_tokens": 8,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
}
|
||||
).encode("utf-8")
|
||||
headers = {
|
||||
"x-api-key": api_key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
}
|
||||
|
||||
req = urllib.request.Request(url, data=payload, headers=headers, method="POST")
|
||||
|
||||
import socket as _socket
|
||||
|
||||
for _attempt in range(3):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
with urllib.request.urlopen(req, timeout=120 if backend == "ollama" else 20) as resp:
|
||||
result = json.loads(resp.read())
|
||||
raw = result["content"][0]["text"].strip()
|
||||
# Parse just the first integer from Haiku's response
|
||||
m = re.search(r"\b(\d+)\b", raw)
|
||||
if backend == "ollama":
|
||||
msg = result["choices"][0]["message"]
|
||||
# Reasoning models (e.g. minimax-m2.7) may emit final answer in "content"
|
||||
# or embed it in "reasoning". Try content first, fall back to reasoning.
|
||||
raw = (msg.get("content") or "").strip()
|
||||
if not raw:
|
||||
raw = (msg.get("reasoning") or "").strip()
|
||||
else:
|
||||
raw = result["content"][0]["text"].strip()
|
||||
m = re.search(
|
||||
r"\b(\d+)\b", raw[::-1]
|
||||
) # take LAST integer (rerank models often reason first)
|
||||
if m:
|
||||
pick = int(m.group(1))
|
||||
pick = int(m.group(1)[::-1])
|
||||
if 1 <= pick <= len(candidates):
|
||||
chosen_idx = candidates[pick - 1]
|
||||
reordered = [chosen_idx] + [i for i in rankings if i != chosen_idx]
|
||||
return reordered
|
||||
break # Got a response, even if unparseable — don't retry
|
||||
break
|
||||
except (_socket.timeout, TimeoutError):
|
||||
if _attempt < 2:
|
||||
import time as _time
|
||||
|
||||
_time.sleep(3) # brief pause then retry
|
||||
# else fall through to return rankings
|
||||
_time.sleep(3)
|
||||
except (urllib.error.URLError, KeyError, ValueError, IndexError, OSError):
|
||||
break # Non-timeout error — fall back immediately
|
||||
break
|
||||
|
||||
return rankings
|
||||
|
||||
|
||||
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 ""
|
||||
|
||||
|
||||
@@ -2939,6 +2948,8 @@ def run_benchmark(
|
||||
skip_precompute=False,
|
||||
split_file=None,
|
||||
split_subset=None,
|
||||
llm_backend="anthropic",
|
||||
llm_base_url="",
|
||||
):
|
||||
"""Run the full benchmark.
|
||||
|
||||
@@ -2967,11 +2978,14 @@ def run_benchmark(
|
||||
api_key = ""
|
||||
if llm_rerank_enabled or mode == "diary":
|
||||
api_key = _load_api_key(llm_key)
|
||||
if not api_key:
|
||||
# Ollama backend doesn't require an Anthropic API key; a local/cloud Ollama
|
||||
# daemon with the requested model pulled is enough. Diary mode is always anthropic.
|
||||
needs_key = (llm_backend == "anthropic") or (mode == "diary")
|
||||
if needs_key and 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'."
|
||||
"ERROR: --llm-rerank (anthropic backend) / --mode diary requires an API key. "
|
||||
"Set ANTHROPIC_API_KEY or use --llm-key. For ollama backend, pass "
|
||||
"--llm-backend ollama."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -3121,7 +3135,15 @@ def run_benchmark(
|
||||
if llm_rerank_enabled:
|
||||
rerank_pool = 20 if mode in ("hybrid_v3", "hybrid_v4", "palace") else 10
|
||||
rankings = llm_rerank(
|
||||
question, rankings, corpus, corpus_ids, api_key, top_k=rerank_pool, model=llm_model
|
||||
question,
|
||||
rankings,
|
||||
corpus,
|
||||
corpus_ids,
|
||||
api_key,
|
||||
top_k=rerank_pool,
|
||||
model=llm_model,
|
||||
backend=llm_backend,
|
||||
base_url=llm_base_url,
|
||||
)
|
||||
|
||||
# Evaluate at session level
|
||||
@@ -3290,15 +3312,28 @@ 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",
|
||||
default="claude-haiku-4-5-20251001",
|
||||
help="Model for LLM re-ranking and diary ingest "
|
||||
"(default: claude-haiku-4-5-20251001). "
|
||||
"Use 'claude-sonnet-4-6' for Sonnet comparison.",
|
||||
"Use 'claude-sonnet-4-6' for Sonnet comparison. "
|
||||
"With --llm-backend ollama, use an Ollama model tag like 'minimax-m2.7:cloud'.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--llm-backend",
|
||||
choices=["anthropic", "ollama"],
|
||||
default="anthropic",
|
||||
help="Which API to hit for --llm-rerank. 'anthropic' (default) uses Anthropic's "
|
||||
"/v1/messages endpoint. 'ollama' uses Ollama's OpenAI-compatible "
|
||||
"/v1/chat/completions endpoint (works with local Ollama and Ollama Cloud).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--llm-base-url",
|
||||
default="",
|
||||
help="Override base URL for --llm-backend ollama. Defaults to http://localhost:11434.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--diary-cache",
|
||||
@@ -3402,4 +3437,6 @@ if __name__ == "__main__":
|
||||
args.skip_precompute,
|
||||
split_file=args.split_file,
|
||||
split_subset=split_subset,
|
||||
llm_backend=args.llm_backend,
|
||||
llm_base_url=args.llm_base_url,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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.
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
# MemPalace — History, Corrections, and Public Notices
|
||||
|
||||
This file is the canonical record of post-launch corrections, public notices,
|
||||
and retractions that affect MemPalace's public claims. Newest first.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-14 — Benchmark table rewrite (issue [#875](https://github.com/MemPalace/mempalace/issues/875))
|
||||
|
||||
A community audit identified a category error in the public benchmark tables
|
||||
on `README.md` and `mempalaceofficial.com`: MemPalace's retrieval recall
|
||||
numbers (R@5, R@10) were listed in the same columns as competitors'
|
||||
end-to-end QA accuracy numbers. They are different metrics and are not
|
||||
comparable — a system can have 100% retrieval recall and 40% QA accuracy.
|
||||
|
||||
The audit also found that the retracted "+34% palace boost" claim (see the
|
||||
April 7 note below) was still present in multiple surfaces despite that
|
||||
retraction, and that two competitor numbers (`Mem0 ~85%`, `Zep ~85%`) had no
|
||||
published source and did not match the metrics those projects actually
|
||||
publish.
|
||||
|
||||
What changed in this PR:
|
||||
|
||||
- The headline number on all surfaces is now **96.6% R@5 on LongMemEval in
|
||||
raw mode**, independently reproduced on Linux x86_64 against the tagged
|
||||
v3.3.0 release on 2026-04-14. Result JSONLs are committed under
|
||||
`benchmarks/results_*.jsonl` (see PR description for the scorecard).
|
||||
- The **"100% with Haiku rerank"** claim has been removed from all public
|
||||
comparison tables. It reproduces on our machines and with a different LLM
|
||||
family (minimax-m2.7 via Ollama Cloud: 99.2% R@5 / 100.0% R@10 on the full
|
||||
500-question LongMemEval set) — but the 99.4% → 100% step was developed
|
||||
by inspecting three specific wrong answers (`benchmarks/BENCHMARKS.md` has
|
||||
called this "teaching to the test" since February). It belongs in the
|
||||
methodology document, not in a headline.
|
||||
- The **honest held-out number** for the hybrid pipeline — 98.4% R@5 on 450
|
||||
questions that `hybrid_v4` was never tuned on, deterministic seed — is now
|
||||
the comparable figure when an LLM rerank is involved.
|
||||
- The **retracted "+34% palace boost"** has been removed from
|
||||
`README.md`, `website/concepts/the-palace.md`,
|
||||
`website/guide/searching.md`, and `website/reference/contributing.md`.
|
||||
Wing and room filters remain useful — they're standard metadata filters —
|
||||
but they are not presented as a novel retrieval improvement.
|
||||
- **Competitor comparison tables** mixing retrieval recall with QA accuracy
|
||||
have been removed from `README.md` and `website/reference/benchmarks.md`.
|
||||
Where MemPalace can be fairly compared on the same metric, we link to the
|
||||
cited source. Otherwise we report our own numbers and let readers draw
|
||||
their own conclusions.
|
||||
- **Reproduction instructions** in `benchmarks/BENCHMARKS.md` and
|
||||
`benchmarks/README.md` were pointing at a defunct branch
|
||||
(`aya-thekeeper/mempal`); they now point at `MemPalace/mempalace`.
|
||||
- The **LoCoMo 100% R@10 with top-50 rerank** row has been removed from
|
||||
public comparison surfaces. With per-conversation session counts of 19–32
|
||||
and `top_k=50`, the retrieval stage returns every session in the
|
||||
conversation by construction, so the number measures an LLM's
|
||||
reading comprehension over the whole conversation, not retrieval.
|
||||
|
||||
Thanks to [@dial481](https://github.com/MemPalace/mempalace/issues/875) for
|
||||
the detailed audit and to [@rohitg00](https://github.com/rohitg00) for the
|
||||
parallel write-up in Discussion #747.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-11 — Impostor domains and malware
|
||||
|
||||
Several community members (issues #267, #326, #506) reported fake MemPalace
|
||||
websites distributing malware. The only official surfaces for this project
|
||||
are:
|
||||
|
||||
- This GitHub repository: [github.com/MemPalace/mempalace](https://github.com/MemPalace/mempalace)
|
||||
- The PyPI package: [pypi.org/project/mempalace](https://pypi.org/project/mempalace/)
|
||||
- The docs site: [mempalaceofficial.com](https://mempalaceofficial.com)
|
||||
|
||||
Any other domain — `mempalace.tech` being the one most commonly reported —
|
||||
is not ours. Never run install scripts from unofficial sites.
|
||||
|
||||
Thanks to our community members for flagging the problem.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-07 — A Note from Milla & Ben
|
||||
|
||||
> The community caught real problems in this README within hours of launch
|
||||
> and we want to address them directly.
|
||||
>
|
||||
> **What we got wrong:**
|
||||
>
|
||||
> - **The AAAK token example was incorrect.** We used a rough heuristic
|
||||
> (`len(text)//3`) for token counts instead of an actual tokenizer. Real
|
||||
> counts via OpenAI's tokenizer: the English example is 66 tokens, the
|
||||
> AAAK example is 73. AAAK does not save tokens at small scales — it's
|
||||
> designed for *repeated entities at scale*, and the README example was a
|
||||
> bad demonstration of that. We're rewriting it.
|
||||
>
|
||||
> - **"30x lossless compression" was overstated.** AAAK is a lossy
|
||||
> abbreviation system (entity codes, sentence truncation). Independent
|
||||
> benchmarks show AAAK mode scores **84.2% R@5 vs raw mode's 96.6%** on
|
||||
> LongMemEval — a 12.4 point regression. The honest framing is: AAAK is
|
||||
> an experimental compression layer that trades fidelity for token
|
||||
> density, and **the 96.6% headline number is from RAW mode, not AAAK**.
|
||||
>
|
||||
> - **"+34% palace boost" was misleading.** That number compares unfiltered
|
||||
> search to wing+room metadata filtering. Metadata filtering is a
|
||||
> standard feature of the underlying vector store, not a novel retrieval
|
||||
> mechanism. Real and useful, but not a moat.
|
||||
>
|
||||
> - **"Contradiction detection"** exists as a separate utility
|
||||
> (`fact_checker.py`) but is not currently wired into the knowledge graph
|
||||
> operations as the README implied.
|
||||
>
|
||||
> - **"100% with Haiku rerank"** is real (we have the result files) but
|
||||
> the rerank pipeline is not in the public benchmark scripts. We're
|
||||
> adding it.
|
||||
>
|
||||
> **What's still true and reproducible:**
|
||||
>
|
||||
> - **96.6% R@5 on LongMemEval in raw mode**, on 500 questions, zero API
|
||||
> calls — independently reproduced on M2 Ultra in under 5 minutes by
|
||||
> [@gizmax](https://github.com/MemPalace/mempalace/issues/39).
|
||||
> - Local, free, no subscription, no cloud, no data leaving your machine.
|
||||
> - The architecture (wings, rooms, closets, drawers) is real and useful,
|
||||
> even if it's not a magical retrieval boost.
|
||||
>
|
||||
> **What we're doing:**
|
||||
>
|
||||
> 1. Rewriting the AAAK example with real tokenizer counts and a scenario
|
||||
> where AAAK actually demonstrates compression
|
||||
> 2. Adding `mode raw / aaak / rooms` clearly to the benchmark
|
||||
> documentation so the trade-offs are visible
|
||||
> 3. Wiring `fact_checker.py` into the KG ops so the contradiction
|
||||
> detection claim becomes true
|
||||
> 4. Pinning the vector store dependency to a tested range (issue #100),
|
||||
> fixing the shell injection in hooks (#110), and addressing the macOS
|
||||
> ARM64 segfault (#74)
|
||||
>
|
||||
> **Thank you to everyone who poked holes in this.** Brutal honest
|
||||
> criticism is exactly what makes open source work, and it's what we asked
|
||||
> for. Special thanks to
|
||||
> [@panuhorsmalahti](https://github.com/MemPalace/mempalace/issues/43),
|
||||
> [@lhl](https://github.com/MemPalace/mempalace/issues/27),
|
||||
> [@gizmax](https://github.com/MemPalace/mempalace/issues/39), and everyone
|
||||
> who filed an issue or a PR in the first 48 hours. We're listening, we're
|
||||
> fixing, and we'd rather be right than impressive.
|
||||
>
|
||||
> — *Milla Jovovich & Ben Sigman*
|
||||
@@ -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,6 @@ 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
|
||||
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."
|
||||
}
|
||||
HOOKJSON
|
||||
# Silent: return empty JSON to not block. "decision": "allow" is invalid —
|
||||
# only "block" or {} are recognized.
|
||||
echo '{}'
|
||||
|
||||
+36
-12
@@ -65,15 +65,18 @@ MEMPAL_DIR=""
|
||||
INPUT=$(cat)
|
||||
|
||||
# Parse all fields in a single Python call (3x faster than separate invocations)
|
||||
# SECURITY: All values are sanitized before being interpolated into shell assignments.
|
||||
# stop_hook_active is coerced to a strict True/False to prevent command injection via eval.
|
||||
eval $(echo "$INPUT" | python3 -c "
|
||||
import sys, json
|
||||
import sys, json, re
|
||||
data = json.load(sys.stdin)
|
||||
sid = data.get('session_id', 'unknown')
|
||||
sha = data.get('stop_hook_active', False)
|
||||
sha_raw = data.get('stop_hook_active', False)
|
||||
tp = data.get('transcript_path', '')
|
||||
# Shell-safe output — only allow alphanumeric, underscore, hyphen, slash, dot, tilde
|
||||
import re
|
||||
safe = lambda s: re.sub(r'[^a-zA-Z0-9_/.\-~]', '', str(s))
|
||||
# Coerce stop_hook_active to strict boolean string
|
||||
sha = 'True' if sha_raw is True or str(sha_raw).lower() in ('true', '1', 'yes') else 'False'
|
||||
print(f'SESSION_ID=\"{safe(sid)}\"')
|
||||
print(f'STOP_HOOK_ACTIVE=\"{sha}\"')
|
||||
print(f'TRANSCRIPT_PATH=\"{safe(tp)}\"')
|
||||
@@ -118,7 +121,11 @@ fi
|
||||
LAST_SAVE_FILE="$STATE_DIR/${SESSION_ID}_last_save"
|
||||
LAST_SAVE=0
|
||||
if [ -f "$LAST_SAVE_FILE" ]; then
|
||||
LAST_SAVE=$(cat "$LAST_SAVE_FILE")
|
||||
LAST_SAVE_RAW=$(cat "$LAST_SAVE_FILE")
|
||||
# SECURITY: Validate as plain integer before arithmetic to prevent command injection
|
||||
if [[ "$LAST_SAVE_RAW" =~ ^[0-9]+$ ]]; then
|
||||
LAST_SAVE="$LAST_SAVE_RAW"
|
||||
fi
|
||||
fi
|
||||
|
||||
SINCE_LAST=$((EXCHANGE_COUNT - LAST_SAVE))
|
||||
@@ -133,21 +140,38 @@ if [ "$SINCE_LAST" -ge "$SAVE_INTERVAL" ] && [ "$EXCHANGE_COUNT" -gt 0 ]; then
|
||||
|
||||
echo "[$(date '+%H:%M:%S')] TRIGGERING SAVE at exchange $EXCHANGE_COUNT" >> "$STATE_DIR/hook.log"
|
||||
|
||||
# Optional: run mempalace ingest in background if MEMPAL_DIR is set
|
||||
# Auto-mine the transcript. Two paths:
|
||||
# 1. TRANSCRIPT_PATH (from Claude Code) — mine the directory it lives in
|
||||
# 2. MEMPAL_DIR (user-configured) — mine that directory
|
||||
# At least one should work. If neither is set, nothing mines.
|
||||
PYTHON="$(command -v python3)"
|
||||
MINE_DIR=""
|
||||
if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
|
||||
MINE_DIR="$(dirname "$TRANSCRIPT_PATH")"
|
||||
fi
|
||||
if [ -n "$MEMPAL_DIR" ] && [ -d "$MEMPAL_DIR" ]; then
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
python3 -m mempalace mine "$MEMPAL_DIR" >> "$STATE_DIR/hook.log" 2>&1 &
|
||||
MINE_DIR="$MEMPAL_DIR"
|
||||
fi
|
||||
if [ -n "$MINE_DIR" ]; then
|
||||
"$PYTHON" -m mempalace mine "$MINE_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
|
||||
cat << 'HOOKJSON'
|
||||
# MEMPAL_VERBOSE toggle:
|
||||
# true = developer mode — block and show diaries/code in chat
|
||||
# false = silent mode (default) — save in background, no chat clutter
|
||||
# Set via: export MEMPAL_VERBOSE=true
|
||||
if [ "$MEMPAL_VERBOSE" = "true" ] || [ "$MEMPAL_VERBOSE" = "1" ]; then
|
||||
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."
|
||||
"reason": "MemPalace save checkpoint. Write a brief session diary entry covering key topics, decisions, and code changes since the last save. Use verbatim quotes where possible. Continue after saving."
|
||||
}
|
||||
HOOKJSON
|
||||
else
|
||||
# Silent mode: return empty JSON to not block. "decision": "allow" is
|
||||
# not a valid value — only "block" or {} are recognized.
|
||||
echo '{}'
|
||||
fi
|
||||
else
|
||||
# Not time yet — let the AI stop normally
|
||||
echo "{}"
|
||||
|
||||
@@ -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,49 @@
|
||||
"""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 update(self, **kwargs: Any) -> None:
|
||||
"""Update existing records. Must raise if any ID is missing."""
|
||||
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,152 @@
|
||||
"""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 update(self, **kwargs):
|
||||
self._collection.update(**kwargs)
|
||||
|
||||
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 __init__(self):
|
||||
# Per-instance client cache: palace_path -> chromadb.PersistentClient
|
||||
self._clients: dict = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _client(self, palace_path: str):
|
||||
"""Return a cached PersistentClient for *palace_path*, creating one if needed."""
|
||||
if palace_path not in self._clients:
|
||||
_fix_blob_seq_ids(palace_path)
|
||||
self._clients[palace_path] = chromadb.PersistentClient(path=palace_path)
|
||||
return self._clients[palace_path]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public static helpers (for callers that manage their own caching)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def make_client(palace_path: str):
|
||||
"""Create and return a fresh PersistentClient (fix BLOB seq_ids first).
|
||||
|
||||
Intended for long-lived callers (e.g. mcp_server) that keep their own
|
||||
inode/mtime-based client cache.
|
||||
"""
|
||||
_fix_blob_seq_ids(palace_path)
|
||||
return chromadb.PersistentClient(path=palace_path)
|
||||
|
||||
@staticmethod
|
||||
def backend_version() -> str:
|
||||
"""Return the installed chromadb package version string."""
|
||||
return chromadb.__version__
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Collection lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
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
|
||||
|
||||
client = self._client(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)
|
||||
|
||||
def get_or_create_collection(
|
||||
self, palace_path: str, collection_name: str
|
||||
) -> "ChromaCollection":
|
||||
"""Shorthand for get_collection(..., create=True)."""
|
||||
return self.get_collection(palace_path, collection_name, create=True)
|
||||
|
||||
def delete_collection(self, palace_path: str, collection_name: str) -> None:
|
||||
"""Delete *collection_name* from the palace at *palace_path*."""
|
||||
self._client(palace_path).delete_collection(collection_name)
|
||||
|
||||
def create_collection(
|
||||
self, palace_path: str, collection_name: str, hnsw_space: str = "cosine"
|
||||
) -> "ChromaCollection":
|
||||
"""Create (not get-or-create) *collection_name* with cosine HNSW space."""
|
||||
collection = self._client(palace_path).create_collection(
|
||||
collection_name, metadata={"hnsw:space": hnsw_space}
|
||||
)
|
||||
return ChromaCollection(collection)
|
||||
+163
-22
@@ -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,24 +29,69 @@ Examples:
|
||||
|
||||
import os
|
||||
import sys
|
||||
import shlex
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from .config import MempalaceConfig
|
||||
|
||||
|
||||
_MEMPALACE_PROJECT_FILES = ("mempalace.yaml", "entities.json")
|
||||
|
||||
|
||||
def _ensure_mempalace_files_gitignored(project_dir) -> bool:
|
||||
"""If project_dir is a git repo, ensure MemPalace's per-project files
|
||||
are listed in .gitignore so they don't get committed by accident.
|
||||
|
||||
Returns True if .gitignore was updated, False otherwise. Issue #185:
|
||||
`mempalace init` writes mempalace.yaml + entities.json into the
|
||||
project root, where they previously had no protection against being
|
||||
staged into git.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
project_path = Path(project_dir).expanduser().resolve()
|
||||
if not (project_path / ".git").exists():
|
||||
return False
|
||||
gitignore = project_path / ".gitignore"
|
||||
existing = gitignore.read_text() if gitignore.exists() else ""
|
||||
existing_lines = {line.strip() for line in existing.splitlines()}
|
||||
missing = [p for p in _MEMPALACE_PROJECT_FILES if p not in existing_lines]
|
||||
if not missing:
|
||||
return False
|
||||
prefix = "" if not existing or existing.endswith("\n") else "\n"
|
||||
block = prefix + "\n# MemPalace per-project files (issue #185)\n" + "\n".join(missing) + "\n"
|
||||
with open(gitignore, "a") as f:
|
||||
f.write(block)
|
||||
print(f" Added {', '.join(missing)} to {gitignore.name}")
|
||||
return True
|
||||
|
||||
|
||||
def cmd_init(args):
|
||||
import json
|
||||
from pathlib import Path
|
||||
from .entity_detector import scan_for_detection, detect_entities, confirm_entities
|
||||
from .room_detector_local import detect_rooms_local
|
||||
|
||||
cfg = MempalaceConfig()
|
||||
|
||||
# Resolve entity-detection languages: --lang overrides config.
|
||||
lang_arg = getattr(args, "lang", None)
|
||||
if lang_arg:
|
||||
languages = [s.strip() for s in lang_arg.split(",") if s.strip()] or ["en"]
|
||||
cfg.set_entity_languages(languages)
|
||||
else:
|
||||
languages = cfg.entity_languages
|
||||
languages_tuple = tuple(languages)
|
||||
|
||||
# Pass 1: auto-detect people and projects from file content
|
||||
print(f"\n Scanning for entities in: {args.dir}")
|
||||
if languages_tuple != ("en",):
|
||||
print(f" Languages: {', '.join(languages_tuple)}")
|
||||
files = scan_for_detection(args.dir)
|
||||
if files:
|
||||
print(f" Reading {len(files)} files...")
|
||||
detected = detect_entities(files)
|
||||
detected = detect_entities(files, languages=languages_tuple)
|
||||
total = len(detected["people"]) + len(detected["projects"]) + len(detected["uncertain"])
|
||||
if total > 0:
|
||||
confirmed = confirm_entities(detected, yes=getattr(args, "yes", False))
|
||||
@@ -60,7 +106,10 @@ def cmd_init(args):
|
||||
|
||||
# Pass 2: detect rooms from folder structure
|
||||
detect_rooms_local(project_dir=args.dir, yes=getattr(args, "yes", False))
|
||||
MempalaceConfig().init()
|
||||
cfg.init()
|
||||
|
||||
# Pass 3: protect git repos from accidentally committing per-project files
|
||||
_ensure_mempalace_files_gitignored(args.dir)
|
||||
|
||||
|
||||
def cmd_mine(args):
|
||||
@@ -132,7 +181,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 +198,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
|
||||
|
||||
@@ -157,24 +219,32 @@ def cmd_status(args):
|
||||
|
||||
def cmd_repair(args):
|
||||
"""Rebuild palace vector index from SQLite metadata."""
|
||||
import chromadb
|
||||
import shutil
|
||||
from .backends.chroma import ChromaBackend
|
||||
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")
|
||||
print(f"{'=' * 55}\n")
|
||||
print(f" Palace: {palace_path}")
|
||||
|
||||
backend = ChromaBackend()
|
||||
|
||||
# Try to read existing drawers
|
||||
try:
|
||||
client = chromadb.PersistentClient(path=palace_path)
|
||||
col = client.get_collection("mempalace_drawers")
|
||||
col = backend.get_collection(palace_path, "mempalace_drawers")
|
||||
total = col.count()
|
||||
print(f" Drawers found: {total}")
|
||||
except Exception as e:
|
||||
@@ -186,6 +256,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 +277,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")
|
||||
backend.delete_collection(palace_path, "mempalace_drawers")
|
||||
new_col = backend.create_collection(palace_path, "mempalace_drawers")
|
||||
|
||||
filed = 0
|
||||
for i in range(0, len(all_ids), batch_size):
|
||||
@@ -241,9 +322,30 @@ 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
|
||||
from .backends.chroma import ChromaBackend
|
||||
from .dialect import Dialect
|
||||
|
||||
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
|
||||
@@ -263,9 +365,9 @@ def cmd_compress(args):
|
||||
dialect = Dialect()
|
||||
|
||||
# Connect to palace
|
||||
backend = ChromaBackend()
|
||||
try:
|
||||
client = chromadb.PersistentClient(path=palace_path)
|
||||
col = client.get_collection("mempalace_drawers")
|
||||
col = backend.get_collection(palace_path, "mempalace_drawers")
|
||||
except Exception:
|
||||
print(f"\n No palace found at {palace_path}")
|
||||
print(" Run: mempalace init <dir> then mempalace mine <dir>")
|
||||
@@ -278,7 +380,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 +424,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 +434,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 +442,11 @@ 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 = backend.get_or_create_collection(palace_path, "mempalace_compressed")
|
||||
for doc_id, compressed, meta, stats in compressed_entries:
|
||||
comp_meta = dict(meta)
|
||||
comp_meta["compression_ratio"] = round(stats["ratio"], 1)
|
||||
comp_meta["original_tokens"] = stats["original_tokens"]
|
||||
comp_meta["compression_ratio"] = round(stats["size_ratio"], 1)
|
||||
comp_meta["original_tokens"] = stats["original_tokens_est"]
|
||||
comp_col.upsert(
|
||||
ids=[doc_id],
|
||||
documents=[compressed],
|
||||
@@ -355,8 +461,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 +487,19 @@ 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)",
|
||||
)
|
||||
p_init.add_argument(
|
||||
"--lang",
|
||||
default=None,
|
||||
help=(
|
||||
"Comma-separated language codes for entity detection "
|
||||
"(e.g. 'en' or 'en,pt-br'). Defaults to value from config "
|
||||
"(MEMPALACE_ENTITY_LANGUAGES env var or config.json), or 'en'. "
|
||||
"When given, the value is also persisted to config.json."
|
||||
),
|
||||
)
|
||||
|
||||
# mine
|
||||
@@ -499,9 +618,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 +671,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
|
||||
)
|
||||
+86
-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:
|
||||
@@ -47,6 +47,30 @@ def sanitize_name(value: str, field_name: str = "name") -> str:
|
||||
return value
|
||||
|
||||
|
||||
def sanitize_kg_value(value: str, field_name: str = "value") -> str:
|
||||
"""Validate a knowledge-graph entity name (subject or object).
|
||||
|
||||
More permissive than sanitize_name — allows punctuation like commas,
|
||||
colons, and parentheses that are common in natural-language KG values.
|
||||
Only blocks null bytes and over-length strings.
|
||||
|
||||
Not used for wing/room names (which have filesystem constraints) or
|
||||
predicates (which should be simple relationship identifiers).
|
||||
"""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"{field_name} must be a non-empty string")
|
||||
|
||||
value = value.strip()
|
||||
|
||||
if len(value) > MAX_NAME_LENGTH:
|
||||
raise ValueError(f"{field_name} exceeds maximum length of {MAX_NAME_LENGTH} characters")
|
||||
|
||||
if "\x00" in value:
|
||||
raise ValueError(f"{field_name} contains null bytes")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def sanitize_content(value: str, max_length: int = 100_000) -> str:
|
||||
"""Validate drawer/diary content length."""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
@@ -173,6 +197,63 @@ class MempalaceConfig:
|
||||
"""Mapping of hall names to keyword lists."""
|
||||
return self._file_config.get("hall_keywords", DEFAULT_HALL_KEYWORDS)
|
||||
|
||||
@property
|
||||
def entity_languages(self):
|
||||
"""Languages whose entity-detection patterns should be applied.
|
||||
|
||||
Reads from env var ``MEMPALACE_ENTITY_LANGUAGES`` (comma-separated)
|
||||
first, then the ``entity_languages`` field in ``config.json``,
|
||||
defaulting to ``["en"]``.
|
||||
"""
|
||||
env_val = os.environ.get("MEMPALACE_ENTITY_LANGUAGES") or os.environ.get(
|
||||
"MEMPAL_ENTITY_LANGUAGES"
|
||||
)
|
||||
if env_val:
|
||||
return [s.strip() for s in env_val.split(",") if s.strip()] or ["en"]
|
||||
cfg = self._file_config.get("entity_languages")
|
||||
if isinstance(cfg, list) and cfg:
|
||||
return [str(s) for s in cfg]
|
||||
return ["en"]
|
||||
|
||||
def set_entity_languages(self, languages):
|
||||
"""Persist the entity-detection language list to ``config.json``."""
|
||||
normalized = [s.strip() for s in languages if s and s.strip()]
|
||||
if not normalized:
|
||||
normalized = ["en"]
|
||||
self._file_config["entity_languages"] = normalized
|
||||
self._config_dir.mkdir(parents=True, exist_ok=True)
|
||||
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
|
||||
try:
|
||||
self._config_file.chmod(0o600)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
return normalized
|
||||
|
||||
@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)
|
||||
@@ -206,4 +287,8 @@ class MempalaceConfig:
|
||||
self._config_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(self._people_map_file, "w") as f:
|
||||
json.dump(people_map, f, indent=2)
|
||||
try:
|
||||
self._people_map_file.chmod(0o600)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
return self._people_map_file
|
||||
|
||||
+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,237 @@
|
||||
"""
|
||||
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
|
||||
|
||||
from .backends.chroma import ChromaBackend
|
||||
|
||||
|
||||
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()
|
||||
col = ChromaBackend().get_collection(palace_path, 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}")
|
||||
|
||||
col = ChromaBackend().get_collection(palace_path, 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,
|
||||
)
|
||||
+18
-2
@@ -158,6 +158,8 @@ _FLAG_SIGNALS = {
|
||||
}
|
||||
|
||||
# Common filler/stop words to strip from topic extraction
|
||||
_ALPHA_RE = re.compile(r"[^a-zA-Z]")
|
||||
|
||||
_STOP_WORDS = {
|
||||
"the",
|
||||
"a",
|
||||
@@ -317,13 +319,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 +338,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 +362,7 @@ class Dialect:
|
||||
return cls(
|
||||
entities=config.get("entities", {}),
|
||||
skip_names=config.get("skip_names", []),
|
||||
lang=config.get("lang", "en"),
|
||||
)
|
||||
|
||||
def save_config(self, config_path: str):
|
||||
@@ -527,7 +543,7 @@ class Dialect:
|
||||
# Fallback: find capitalized words that look like names (2+ chars, not sentence-start)
|
||||
words = text.split()
|
||||
for i, w in enumerate(words):
|
||||
clean = re.sub(r"[^a-zA-Z]", "", w)
|
||||
clean = _ALPHA_RE.sub("", w)
|
||||
if (
|
||||
len(clean) >= 2
|
||||
and clean[0].isupper()
|
||||
|
||||
@@ -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)
|
||||
+153
-416
@@ -9,391 +9,70 @@ Two-pass approach:
|
||||
Used by mempalace init before mining begins.
|
||||
The confirmed entity map feeds the miner as the taxonomy.
|
||||
|
||||
Multi-language support:
|
||||
All lexical patterns (person verbs, pronouns, dialogue markers, project
|
||||
verbs, stopwords, and the candidate-extraction character class) live in
|
||||
the ``entity`` section of ``mempalace/i18n/<lang>.json``. Every public
|
||||
function accepts a ``languages`` tuple and applies the union of the
|
||||
requested locales' patterns. The default is ``("en",)`` — existing
|
||||
English-only callers behave exactly as before.
|
||||
|
||||
To add a new language: add an ``entity`` section to that locale's JSON.
|
||||
No code changes required.
|
||||
|
||||
Usage:
|
||||
from entity_detector import detect_entities, confirm_entities
|
||||
candidates = detect_entities(file_paths)
|
||||
from mempalace.entity_detector import detect_entities, confirm_entities
|
||||
candidates = detect_entities(file_paths) # English only
|
||||
candidates = detect_entities(paths, languages=("en", "pt-br"))
|
||||
confirmed = confirm_entities(candidates) # interactive review
|
||||
"""
|
||||
|
||||
import re
|
||||
import os
|
||||
import functools
|
||||
from pathlib import Path
|
||||
from collections import defaultdict
|
||||
|
||||
from mempalace.i18n import get_entity_patterns
|
||||
|
||||
# ==================== SIGNAL PATTERNS ====================
|
||||
|
||||
# Person signals — things people do
|
||||
PERSON_VERB_PATTERNS = [
|
||||
r"\b{name}\s+said\b",
|
||||
r"\b{name}\s+asked\b",
|
||||
r"\b{name}\s+told\b",
|
||||
r"\b{name}\s+replied\b",
|
||||
r"\b{name}\s+laughed\b",
|
||||
r"\b{name}\s+smiled\b",
|
||||
r"\b{name}\s+cried\b",
|
||||
r"\b{name}\s+felt\b",
|
||||
r"\b{name}\s+thinks?\b",
|
||||
r"\b{name}\s+wants?\b",
|
||||
r"\b{name}\s+loves?\b",
|
||||
r"\b{name}\s+hates?\b",
|
||||
r"\b{name}\s+knows?\b",
|
||||
r"\b{name}\s+decided\b",
|
||||
r"\b{name}\s+pushed\b",
|
||||
r"\b{name}\s+wrote\b",
|
||||
r"\bhey\s+{name}\b",
|
||||
r"\bthanks?\s+{name}\b",
|
||||
r"\bhi\s+{name}\b",
|
||||
r"\bdear\s+{name}\b",
|
||||
]
|
||||
# ==================== LANGUAGE-AWARE PATTERN LOADING ====================
|
||||
|
||||
# Person signals — pronouns resolving nearby
|
||||
PRONOUN_PATTERNS = [
|
||||
r"\bshe\b",
|
||||
r"\bher\b",
|
||||
r"\bhers\b",
|
||||
r"\bhe\b",
|
||||
r"\bhim\b",
|
||||
r"\bhis\b",
|
||||
r"\bthey\b",
|
||||
r"\bthem\b",
|
||||
r"\btheir\b",
|
||||
]
|
||||
|
||||
# Person signals — dialogue markers
|
||||
DIALOGUE_PATTERNS = [
|
||||
r"^>\s*{name}[:\s]", # > Speaker: ...
|
||||
r"^{name}:\s", # Speaker: ...
|
||||
r"^\[{name}\]", # [Speaker]
|
||||
r'"{name}\s+said',
|
||||
]
|
||||
def _normalize_langs(languages) -> tuple:
|
||||
"""Coerce a language input into a non-empty hashable tuple."""
|
||||
if not languages:
|
||||
return ("en",)
|
||||
if isinstance(languages, str):
|
||||
return (languages,)
|
||||
return tuple(languages)
|
||||
|
||||
# Project signals — things projects have/do
|
||||
PROJECT_VERB_PATTERNS = [
|
||||
r"\bbuilding\s+{name}\b",
|
||||
r"\bbuilt\s+{name}\b",
|
||||
r"\bship(?:ping|ped)?\s+{name}\b",
|
||||
r"\blaunch(?:ing|ed)?\s+{name}\b",
|
||||
r"\bdeploy(?:ing|ed)?\s+{name}\b",
|
||||
r"\binstall(?:ing|ed)?\s+{name}\b",
|
||||
r"\bthe\s+{name}\s+architecture\b",
|
||||
r"\bthe\s+{name}\s+pipeline\b",
|
||||
r"\bthe\s+{name}\s+system\b",
|
||||
r"\bthe\s+{name}\s+repo\b",
|
||||
r"\b{name}\s+v\d+\b", # MemPal v2
|
||||
r"\b{name}\.py\b", # mempalace.py
|
||||
r"\b{name}-core\b", # mempal-core (hyphen only, not underscore)
|
||||
r"\b{name}-local\b",
|
||||
r"\bimport\s+{name}\b",
|
||||
r"\bpip\s+install\s+{name}\b",
|
||||
]
|
||||
|
||||
# Words that are almost certainly NOT entities
|
||||
STOPWORDS = {
|
||||
"the",
|
||||
"a",
|
||||
"an",
|
||||
"and",
|
||||
"or",
|
||||
"but",
|
||||
"in",
|
||||
"on",
|
||||
"at",
|
||||
"to",
|
||||
"for",
|
||||
"of",
|
||||
"with",
|
||||
"by",
|
||||
"from",
|
||||
"as",
|
||||
"is",
|
||||
"was",
|
||||
"are",
|
||||
"were",
|
||||
"be",
|
||||
"been",
|
||||
"being",
|
||||
"have",
|
||||
"has",
|
||||
"had",
|
||||
"do",
|
||||
"does",
|
||||
"did",
|
||||
"will",
|
||||
"would",
|
||||
"could",
|
||||
"should",
|
||||
"may",
|
||||
"might",
|
||||
"must",
|
||||
"shall",
|
||||
"can",
|
||||
"this",
|
||||
"that",
|
||||
"these",
|
||||
"those",
|
||||
"it",
|
||||
"its",
|
||||
"they",
|
||||
"them",
|
||||
"their",
|
||||
"we",
|
||||
"our",
|
||||
"you",
|
||||
"your",
|
||||
"i",
|
||||
"my",
|
||||
"me",
|
||||
"he",
|
||||
"she",
|
||||
"his",
|
||||
"her",
|
||||
"who",
|
||||
"what",
|
||||
"when",
|
||||
"where",
|
||||
"why",
|
||||
"how",
|
||||
"which",
|
||||
"if",
|
||||
"then",
|
||||
"so",
|
||||
"not",
|
||||
"no",
|
||||
"yes",
|
||||
"ok",
|
||||
"okay",
|
||||
"just",
|
||||
"very",
|
||||
"really",
|
||||
"also",
|
||||
"already",
|
||||
"still",
|
||||
"even",
|
||||
"only",
|
||||
"here",
|
||||
"there",
|
||||
"now",
|
||||
"then",
|
||||
"too",
|
||||
"up",
|
||||
"out",
|
||||
"about",
|
||||
"like",
|
||||
"use",
|
||||
"get",
|
||||
"got",
|
||||
"make",
|
||||
"made",
|
||||
"take",
|
||||
"put",
|
||||
"come",
|
||||
"go",
|
||||
"see",
|
||||
"know",
|
||||
"think",
|
||||
"true",
|
||||
"false",
|
||||
"none",
|
||||
"null",
|
||||
"new",
|
||||
"old",
|
||||
"all",
|
||||
"any",
|
||||
"some",
|
||||
"true",
|
||||
"false",
|
||||
"return",
|
||||
"print",
|
||||
"def",
|
||||
"class",
|
||||
"import",
|
||||
"from",
|
||||
# Common capitalized words in prose that aren't entities
|
||||
"step",
|
||||
"usage",
|
||||
"run",
|
||||
"check",
|
||||
"find",
|
||||
"add",
|
||||
"get",
|
||||
"set",
|
||||
"list",
|
||||
"args",
|
||||
"dict",
|
||||
"str",
|
||||
"int",
|
||||
"bool",
|
||||
"path",
|
||||
"file",
|
||||
"type",
|
||||
"name",
|
||||
"note",
|
||||
"example",
|
||||
"option",
|
||||
"result",
|
||||
"error",
|
||||
"warning",
|
||||
"info",
|
||||
"every",
|
||||
"each",
|
||||
"more",
|
||||
"less",
|
||||
"next",
|
||||
"last",
|
||||
"first",
|
||||
"second",
|
||||
"stack",
|
||||
"layer",
|
||||
"mode",
|
||||
"test",
|
||||
"stop",
|
||||
"start",
|
||||
"copy",
|
||||
"move",
|
||||
"source",
|
||||
"target",
|
||||
"output",
|
||||
"input",
|
||||
"data",
|
||||
"item",
|
||||
"key",
|
||||
"value",
|
||||
"returns",
|
||||
"raises",
|
||||
"yields",
|
||||
"none",
|
||||
"self",
|
||||
"cls",
|
||||
"kwargs",
|
||||
# Common sentence-starting / abstract words that aren't entities
|
||||
"world",
|
||||
"well",
|
||||
"want",
|
||||
"topic",
|
||||
"choose",
|
||||
"social",
|
||||
"cars",
|
||||
"phones",
|
||||
"healthcare",
|
||||
"ex",
|
||||
"machina",
|
||||
"deus",
|
||||
"human",
|
||||
"humans",
|
||||
"people",
|
||||
"things",
|
||||
"something",
|
||||
"nothing",
|
||||
"everything",
|
||||
"anything",
|
||||
"someone",
|
||||
"everyone",
|
||||
"anyone",
|
||||
"way",
|
||||
"time",
|
||||
"day",
|
||||
"life",
|
||||
"place",
|
||||
"thing",
|
||||
"part",
|
||||
"kind",
|
||||
"sort",
|
||||
"case",
|
||||
"point",
|
||||
"idea",
|
||||
"fact",
|
||||
"sense",
|
||||
"question",
|
||||
"answer",
|
||||
"reason",
|
||||
"number",
|
||||
"version",
|
||||
"system",
|
||||
# Greetings and filler words at sentence starts
|
||||
"hey",
|
||||
"hi",
|
||||
"hello",
|
||||
"thanks",
|
||||
"thank",
|
||||
"right",
|
||||
"let",
|
||||
"ok",
|
||||
# UI/action words that appear in how-to content
|
||||
"click",
|
||||
"hit",
|
||||
"press",
|
||||
"tap",
|
||||
"drag",
|
||||
"drop",
|
||||
"open",
|
||||
"close",
|
||||
"save",
|
||||
"load",
|
||||
"launch",
|
||||
"install",
|
||||
"download",
|
||||
"upload",
|
||||
"scroll",
|
||||
"select",
|
||||
"enter",
|
||||
"submit",
|
||||
"cancel",
|
||||
"confirm",
|
||||
"delete",
|
||||
"copy",
|
||||
"paste",
|
||||
"type",
|
||||
"write",
|
||||
"read",
|
||||
"search",
|
||||
"find",
|
||||
"show",
|
||||
"hide",
|
||||
# Common filesystem/technical capitalized words
|
||||
"desktop",
|
||||
"documents",
|
||||
"downloads",
|
||||
"users",
|
||||
"home",
|
||||
"library",
|
||||
"applications",
|
||||
"system",
|
||||
"preferences",
|
||||
"settings",
|
||||
"terminal",
|
||||
# Abstract/topic words
|
||||
"actor",
|
||||
"vector",
|
||||
"remote",
|
||||
"control",
|
||||
"duration",
|
||||
"fetch",
|
||||
# Abstract concepts that appear as subjects but aren't entities
|
||||
"agents",
|
||||
"tools",
|
||||
"others",
|
||||
"guards",
|
||||
"ethics",
|
||||
"regulation",
|
||||
"learning",
|
||||
"thinking",
|
||||
"memory",
|
||||
"language",
|
||||
"intelligence",
|
||||
"technology",
|
||||
"society",
|
||||
"culture",
|
||||
"future",
|
||||
"history",
|
||||
"science",
|
||||
"model",
|
||||
"models",
|
||||
"network",
|
||||
"networks",
|
||||
"training",
|
||||
"inference",
|
||||
}
|
||||
@functools.lru_cache(maxsize=32)
|
||||
def _get_stopwords(languages: tuple) -> frozenset:
|
||||
"""Return the union of stopwords across the given languages."""
|
||||
patterns = get_entity_patterns(languages)
|
||||
return frozenset(patterns["stopwords"])
|
||||
|
||||
|
||||
# ==================== BACKWARD-COMPAT MODULE CONSTANTS ====================
|
||||
#
|
||||
# These mirror the old module-level constants so existing imports keep working.
|
||||
# They reflect the English defaults and are populated at import time from
|
||||
# ``mempalace/i18n/en.json``. Callers that need multi-language behavior should
|
||||
# pass the ``languages`` parameter to the public functions below.
|
||||
|
||||
_EN = get_entity_patterns(("en",))
|
||||
|
||||
PERSON_VERB_PATTERNS = list(_EN["person_verb_patterns"])
|
||||
PRONOUN_PATTERNS = list(_EN["pronoun_patterns"])
|
||||
PRONOUN_RE = re.compile("|".join(PRONOUN_PATTERNS), re.IGNORECASE) if PRONOUN_PATTERNS else None
|
||||
DIALOGUE_PATTERNS = list(_EN["dialogue_patterns"])
|
||||
PROJECT_VERB_PATTERNS = list(_EN["project_verb_patterns"])
|
||||
STOPWORDS = set(_EN["stopwords"])
|
||||
|
||||
|
||||
# ==================== EXTENSION POINTS (not language-scoped) ====================
|
||||
|
||||
# For entity detection — prose only, no code files
|
||||
# Code files have too many capitalized names (classes, functions) that aren't entities
|
||||
@@ -440,55 +119,107 @@ SKIP_DIRS = {
|
||||
# ==================== CANDIDATE EXTRACTION ====================
|
||||
|
||||
|
||||
def extract_candidates(text: str) -> dict:
|
||||
def extract_candidates(text: str, languages=("en",)) -> dict:
|
||||
"""
|
||||
Extract all capitalized proper noun candidates from text.
|
||||
Returns {name: frequency} for names appearing 3+ times.
|
||||
"""
|
||||
# Find all capitalized words (not at sentence start — harder, so we use frequency as filter)
|
||||
raw = re.findall(r"\b([A-Z][a-z]{1,19})\b", text)
|
||||
|
||||
counts = defaultdict(int)
|
||||
for word in raw:
|
||||
if word.lower() not in STOPWORDS and len(word) > 1:
|
||||
Each language contributes its own character-class pattern (e.g. ASCII
|
||||
for English, Latin+diacritics for pt-br, Cyrillic for Russian,
|
||||
Devanagari for Hindi). Matches from all languages are unioned.
|
||||
"""
|
||||
langs = _normalize_langs(languages)
|
||||
patterns = get_entity_patterns(langs)
|
||||
stopwords = _get_stopwords(langs)
|
||||
|
||||
counts: defaultdict = defaultdict(int)
|
||||
|
||||
# Single-word candidates — one pre-wrapped pattern per language
|
||||
for wrapped_pat in patterns["candidate_patterns"]:
|
||||
try:
|
||||
rx = re.compile(wrapped_pat)
|
||||
except re.error:
|
||||
continue
|
||||
for word in rx.findall(text):
|
||||
if word.lower() in stopwords:
|
||||
continue
|
||||
if len(word) < 2:
|
||||
continue
|
||||
counts[word] += 1
|
||||
|
||||
# Also find multi-word proper nouns (e.g. "Memory Palace", "Claude Code")
|
||||
multi = re.findall(r"\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b", text)
|
||||
for phrase in multi:
|
||||
if not any(w.lower() in STOPWORDS for w in phrase.split()):
|
||||
# Multi-word candidates — one pre-wrapped pattern per language
|
||||
for wrapped_pat in patterns["multi_word_patterns"]:
|
||||
try:
|
||||
rx = re.compile(wrapped_pat)
|
||||
except re.error:
|
||||
continue
|
||||
for phrase in rx.findall(text):
|
||||
if any(w.lower() in stopwords for w in phrase.split()):
|
||||
continue
|
||||
counts[phrase] += 1
|
||||
|
||||
# Filter: must appear at least 3 times to be a candidate
|
||||
return {name: count for name, count in counts.items() if count >= 3}
|
||||
|
||||
|
||||
# ==================== SIGNAL SCORING ====================
|
||||
|
||||
|
||||
def _build_patterns(name: str) -> dict:
|
||||
"""Pre-compile all regex patterns for a single entity name."""
|
||||
@functools.lru_cache(maxsize=256)
|
||||
def _build_patterns(name: str, languages: tuple = ("en",)) -> dict:
|
||||
"""Pre-compile all regex patterns for a single entity name, per language set."""
|
||||
n = re.escape(name)
|
||||
langs = _normalize_langs(languages)
|
||||
sources = get_entity_patterns(langs)
|
||||
|
||||
def _compile_each(raw_patterns, flags=re.IGNORECASE):
|
||||
compiled = []
|
||||
for p in raw_patterns:
|
||||
try:
|
||||
compiled.append(re.compile(p.format(name=n), flags))
|
||||
except (re.error, KeyError, IndexError):
|
||||
continue
|
||||
return compiled
|
||||
|
||||
direct_sources = sources.get("direct_address_patterns") or []
|
||||
direct_compiled = []
|
||||
for raw in direct_sources:
|
||||
try:
|
||||
direct_compiled.append(re.compile(raw.format(name=n), re.IGNORECASE))
|
||||
except (re.error, KeyError, IndexError):
|
||||
continue
|
||||
|
||||
return {
|
||||
"dialogue": [
|
||||
re.compile(p.format(name=n), re.MULTILINE | re.IGNORECASE) for p in DIALOGUE_PATTERNS
|
||||
],
|
||||
"person_verbs": [re.compile(p.format(name=n), re.IGNORECASE) for p in PERSON_VERB_PATTERNS],
|
||||
"project_verbs": [
|
||||
re.compile(p.format(name=n), re.IGNORECASE) for p in PROJECT_VERB_PATTERNS
|
||||
],
|
||||
"direct": re.compile(rf"\bhey\s+{n}\b|\bthanks?\s+{n}\b|\bhi\s+{n}\b", re.IGNORECASE),
|
||||
"dialogue": _compile_each(sources["dialogue_patterns"], re.MULTILINE | re.IGNORECASE),
|
||||
"person_verbs": _compile_each(sources["person_verb_patterns"]),
|
||||
"project_verbs": _compile_each(sources["project_verb_patterns"]),
|
||||
"direct": direct_compiled,
|
||||
"versioned": re.compile(rf"\b{n}[-v]\w+", re.IGNORECASE),
|
||||
"code_ref": re.compile(rf"\b{n}\.(py|js|ts|yaml|yml|json|sh)\b", re.IGNORECASE),
|
||||
}
|
||||
|
||||
|
||||
def score_entity(name: str, text: str, lines: list) -> dict:
|
||||
@functools.lru_cache(maxsize=32)
|
||||
def _pronoun_re(languages: tuple):
|
||||
"""Compile a combined pronoun regex for the given languages."""
|
||||
langs = _normalize_langs(languages)
|
||||
patterns = get_entity_patterns(langs)
|
||||
pronouns = patterns.get("pronoun_patterns") or []
|
||||
if not pronouns:
|
||||
return None
|
||||
try:
|
||||
return re.compile("|".join(pronouns), re.IGNORECASE)
|
||||
except re.error:
|
||||
return None
|
||||
|
||||
|
||||
def score_entity(name: str, text: str, lines: list, languages=("en",)) -> dict:
|
||||
"""
|
||||
Score a candidate entity as person vs project.
|
||||
Returns scores and the signals that fired.
|
||||
"""
|
||||
patterns = _build_patterns(name)
|
||||
langs = _normalize_langs(languages)
|
||||
patterns = _build_patterns(name, langs)
|
||||
pronoun_re = _pronoun_re(langs)
|
||||
person_score = 0
|
||||
project_score = 0
|
||||
person_signals = []
|
||||
@@ -511,24 +242,25 @@ def score_entity(name: str, text: str, lines: list) -> dict:
|
||||
person_signals.append(f"'{name} ...' action ({matches}x)")
|
||||
|
||||
# Pronoun proximity — pronouns within 3 lines of the name
|
||||
name_lower = name.lower()
|
||||
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):
|
||||
if pronoun_re is not None:
|
||||
name_lower = name.lower()
|
||||
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])
|
||||
if pronoun_re.search(window_text):
|
||||
pronoun_hits += 1
|
||||
break
|
||||
if pronoun_hits > 0:
|
||||
person_score += pronoun_hits * 2
|
||||
person_signals.append(f"pronoun nearby ({pronoun_hits}x)")
|
||||
if pronoun_hits > 0:
|
||||
person_score += pronoun_hits * 2
|
||||
person_signals.append(f"pronoun nearby ({pronoun_hits}x)")
|
||||
|
||||
# Direct address
|
||||
direct = len(patterns["direct"].findall(text))
|
||||
if direct > 0:
|
||||
person_score += direct * 4
|
||||
person_signals.append(f"addressed directly ({direct}x)")
|
||||
direct_hits = 0
|
||||
for rx in patterns["direct"]:
|
||||
direct_hits += len(rx.findall(text))
|
||||
if direct_hits > 0:
|
||||
person_score += direct_hits * 4
|
||||
person_signals.append(f"addressed directly ({direct_hits}x)")
|
||||
|
||||
# --- Project signals ---
|
||||
|
||||
@@ -629,13 +361,15 @@ def classify_entity(name: str, frequency: int, scores: dict) -> dict:
|
||||
# ==================== MAIN DETECT ====================
|
||||
|
||||
|
||||
def detect_entities(file_paths: list, max_files: int = 10) -> dict:
|
||||
def detect_entities(file_paths: list, max_files: int = 10, languages=("en",)) -> dict:
|
||||
"""
|
||||
Scan files and detect entity candidates.
|
||||
|
||||
Args:
|
||||
file_paths: List of Path objects to scan
|
||||
max_files: Max files to read (for speed)
|
||||
languages: Tuple of language codes whose entity patterns should be
|
||||
applied (union). Defaults to ``("en",)``.
|
||||
|
||||
Returns:
|
||||
{
|
||||
@@ -644,6 +378,8 @@ def detect_entities(file_paths: list, max_files: int = 10) -> dict:
|
||||
"uncertain":[...entity dicts...],
|
||||
}
|
||||
"""
|
||||
langs = _normalize_langs(languages)
|
||||
|
||||
# Collect text from files
|
||||
all_text = []
|
||||
all_lines = []
|
||||
@@ -666,7 +402,7 @@ def detect_entities(file_paths: list, max_files: int = 10) -> dict:
|
||||
combined_text = "\n".join(all_text)
|
||||
|
||||
# Extract candidates
|
||||
candidates = extract_candidates(combined_text)
|
||||
candidates = extract_candidates(combined_text, languages=langs)
|
||||
|
||||
if not candidates:
|
||||
return {"people": [], "projects": [], "uncertain": []}
|
||||
@@ -677,7 +413,7 @@ def detect_entities(file_paths: list, max_files: int = 10) -> dict:
|
||||
uncertain = []
|
||||
|
||||
for name, frequency in sorted(candidates.items(), key=lambda x: x[1], reverse=True):
|
||||
scores = score_entity(name, combined_text, all_lines)
|
||||
scores = score_entity(name, combined_text, all_lines, languages=langs)
|
||||
entity = classify_entity(name, frequency, scores)
|
||||
|
||||
if entity["type"] == "person":
|
||||
@@ -760,7 +496,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":
|
||||
@@ -841,13 +577,14 @@ if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python entity_detector.py <directory>")
|
||||
print("Usage: python entity_detector.py <directory> [lang1,lang2,...]")
|
||||
sys.exit(1)
|
||||
|
||||
project_dir = sys.argv[1]
|
||||
print(f"Scanning: {project_dir}")
|
||||
langs = tuple(sys.argv[2].split(",")) if len(sys.argv) >= 3 else ("en",)
|
||||
print(f"Scanning: {project_dir} (languages: {', '.join(langs)})")
|
||||
files = scan_for_detection(project_dir)
|
||||
print(f"Reading {len(files)} files...")
|
||||
detected = detect_entities(files)
|
||||
detected = detect_entities(files, languages=langs)
|
||||
confirmed = confirm_entities(detected)
|
||||
print("Confirmed entities:", confirmed)
|
||||
|
||||
@@ -178,6 +178,12 @@ def _wikipedia_lookup(word: str) -> dict:
|
||||
Look up a word via Wikipedia REST API.
|
||||
Returns inferred type (person/place/concept/unknown) + confidence + summary.
|
||||
Free, no API key, handles disambiguation pages.
|
||||
|
||||
**Privacy warning:** This function makes an outbound HTTPS request to
|
||||
en.wikipedia.org, sending the queried word over the network. It should
|
||||
only be called when the caller has explicitly opted in via
|
||||
``allow_network=True`` in :meth:`EntityRegistry.research`. The default
|
||||
behaviour of ``research()`` is local-only (no network calls).
|
||||
"""
|
||||
try:
|
||||
url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{urllib.parse.quote(word)}"
|
||||
@@ -244,13 +250,14 @@ def _wikipedia_lookup(word: str) -> dict:
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
# Not in Wikipedia — strong signal it's a proper noun (unusual name, nickname)
|
||||
# Not in Wikipedia — this tells us nothing definitive about
|
||||
# the word. Return "unknown" so the caller can decide.
|
||||
return {
|
||||
"inferred_type": "person",
|
||||
"confidence": 0.70,
|
||||
"inferred_type": "unknown",
|
||||
"confidence": 0.3,
|
||||
"wiki_summary": None,
|
||||
"wiki_title": None,
|
||||
"note": "not found in Wikipedia — likely a proper noun or unusual name",
|
||||
"note": "not found in Wikipedia",
|
||||
}
|
||||
return {"inferred_type": "unknown", "confidence": 0.0, "wiki_summary": None}
|
||||
except (urllib.error.URLError, OSError, json.JSONDecodeError, KeyError):
|
||||
@@ -301,7 +308,7 @@ class EntityRegistry:
|
||||
path = (Path(config_dir) / "entity_registry.json") if config_dir else cls.DEFAULT_PATH
|
||||
if path.exists():
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return cls(data, path)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
@@ -309,7 +316,15 @@ class EntityRegistry:
|
||||
|
||||
def save(self):
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
self._path.parent.chmod(0o700)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
self._path.write_text(json.dumps(self._data, indent=2), encoding="utf-8")
|
||||
try:
|
||||
self._path.chmod(0o600)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _empty() -> dict:
|
||||
@@ -502,20 +517,41 @@ class EntityRegistry:
|
||||
|
||||
# ── Research unknown words ───────────────────────────────────────────────
|
||||
|
||||
def research(self, word: str, auto_confirm: bool = False) -> dict:
|
||||
def research(self, word: str, auto_confirm: bool = False, allow_network: bool = False) -> dict:
|
||||
"""
|
||||
Research an unknown word via Wikipedia.
|
||||
Caches result. If auto_confirm=False, marks as unconfirmed (needs user review).
|
||||
Returns the lookup result.
|
||||
Research an unknown word.
|
||||
|
||||
By default this is **local-only**: it checks the wiki cache and
|
||||
returns ``"unknown"`` for uncached words. Pass
|
||||
``allow_network=True`` to explicitly opt in to an outbound
|
||||
Wikipedia lookup. This design honours the project's
|
||||
*local-first, zero API* and *privacy by architecture* principles
|
||||
— no data leaves the machine unless the caller requests it.
|
||||
|
||||
Caches result. If *auto_confirm* is ``False``, marks the entry
|
||||
as unconfirmed (needs user review).
|
||||
"""
|
||||
# Already cached?
|
||||
cache = self._data.setdefault("wiki_cache", {})
|
||||
# Check cache (read-only — no mutation when allow_network is False)
|
||||
cache = self._data.get("wiki_cache", {})
|
||||
if word in cache:
|
||||
return cache[word]
|
||||
|
||||
if not allow_network:
|
||||
return {
|
||||
"inferred_type": "unknown",
|
||||
"confidence": 0.0,
|
||||
"wiki_summary": None,
|
||||
"wiki_title": None,
|
||||
"word": word,
|
||||
"confirmed": False,
|
||||
"note": "network lookup disabled — pass allow_network=True to query Wikipedia",
|
||||
}
|
||||
|
||||
# Network path — ensure wiki_cache key exists before writing
|
||||
cache = self._data.setdefault("wiki_cache", {})
|
||||
result = _wikipedia_lookup(word)
|
||||
result["word"] = word
|
||||
result["confirmed"] = auto_confirm
|
||||
result.setdefault("word", word)
|
||||
result.setdefault("confirmed", auto_confirm)
|
||||
|
||||
cache[word] = result
|
||||
self.save()
|
||||
@@ -547,15 +583,19 @@ class EntityRegistry:
|
||||
|
||||
# ── Learn from sessions ──────────────────────────────────────────────────
|
||||
|
||||
def learn_from_text(self, text: str, min_confidence: float = 0.75) -> list:
|
||||
def learn_from_text(self, text: str, min_confidence: float = 0.75, languages=("en",)) -> list:
|
||||
"""
|
||||
Scan session text for new entity candidates.
|
||||
Returns list of newly discovered candidates for review.
|
||||
|
||||
``languages`` is forwarded to entity detection — pass the user's
|
||||
configured ``MempalaceConfig().entity_languages`` to match the
|
||||
locales used at ``mempalace init`` time.
|
||||
"""
|
||||
from mempalace.entity_detector import extract_candidates, score_entity, classify_entity
|
||||
|
||||
lines = text.splitlines()
|
||||
candidates = extract_candidates(text)
|
||||
candidates = extract_candidates(text, languages=languages)
|
||||
new_candidates = []
|
||||
|
||||
for name, frequency in candidates.items():
|
||||
@@ -563,7 +603,7 @@ class EntityRegistry:
|
||||
if name in self.people or name in self.projects:
|
||||
continue
|
||||
|
||||
scores = score_entity(name, text, lines)
|
||||
scores = score_entity(name, text, lines, languages=languages)
|
||||
entity = classify_entity(name, frequency, scores)
|
||||
|
||||
if entity["type"] == "person" and entity["confidence"] >= min_confidence:
|
||||
@@ -616,7 +656,9 @@ class EntityRegistry:
|
||||
Find capitalized words in query that aren't in registry or common words.
|
||||
These are candidates for Wikipedia research.
|
||||
"""
|
||||
candidates = re.findall(r"\b[A-Z][a-z]{2,15}\b", query)
|
||||
from .palace import _candidate_entity_words
|
||||
|
||||
candidates = _candidate_entity_words(query)
|
||||
unknown = []
|
||||
for word in set(candidates):
|
||||
if word.lower() in COMMON_ENGLISH_WORDS:
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
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)
|
||||
try:
|
||||
os.chmod(output_dir, 0o700)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
|
||||
# Track which room files have been opened (so we can append vs overwrite)
|
||||
opened_rooms: set[tuple[str, str]] = set()
|
||||
# Track which wing directories have been created and chmoded
|
||||
created_wing_dirs: set[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)
|
||||
if wing_dir not in created_wing_dirs:
|
||||
os.makedirs(wing_dir, exist_ok=True)
|
||||
try:
|
||||
os.chmod(wing_dir, 0o700)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
created_wing_dirs.add(wing_dir)
|
||||
|
||||
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.")
|
||||
+113
-41
@@ -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."
|
||||
)
|
||||
|
||||
|
||||
@@ -39,9 +43,32 @@ def _sanitize_session_id(session_id: str) -> str:
|
||||
return sanitized or "unknown"
|
||||
|
||||
|
||||
def _validate_transcript_path(transcript_path: str) -> Path:
|
||||
"""Validate and resolve a transcript path, rejecting paths outside expected roots.
|
||||
|
||||
Returns a resolved Path if valid, or None if the path should be rejected.
|
||||
Accepted paths must:
|
||||
- Have a .jsonl or .json extension
|
||||
- Not contain '..' after resolution (path traversal prevention)
|
||||
"""
|
||||
if not transcript_path:
|
||||
return None
|
||||
path = Path(transcript_path).expanduser().resolve()
|
||||
if path.suffix not in (".jsonl", ".json"):
|
||||
return None
|
||||
# Reject if the original input contained '..' traversal components
|
||||
if ".." in Path(transcript_path).parts:
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
def _count_human_messages(transcript_path: str) -> int:
|
||||
"""Count human messages in a JSONL transcript, skipping command-messages."""
|
||||
path = Path(transcript_path).expanduser()
|
||||
path = _validate_transcript_path(transcript_path)
|
||||
if path is None:
|
||||
if transcript_path:
|
||||
_log(f"WARNING: transcript_path rejected by validator: {transcript_path!r}")
|
||||
return 0
|
||||
if not path.is_file():
|
||||
return 0
|
||||
count = 0
|
||||
@@ -63,6 +90,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:
|
||||
@@ -70,14 +105,30 @@ def _count_human_messages(transcript_path: str) -> int:
|
||||
return count
|
||||
|
||||
|
||||
_state_dir_initialized = False
|
||||
|
||||
|
||||
def _log(message: str):
|
||||
"""Append to hook state log file."""
|
||||
global _state_dir_initialized
|
||||
try:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if not _state_dir_initialized:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
STATE_DIR.chmod(0o700)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
_state_dir_initialized = True
|
||||
log_path = STATE_DIR / "hook.log"
|
||||
is_new = not log_path.exists()
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
with open(log_path, "a") as f:
|
||||
f.write(f"[{timestamp}] {message}\n")
|
||||
if is_new:
|
||||
try:
|
||||
log_path.chmod(0o600)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@@ -87,20 +138,53 @@ def _output(data: dict):
|
||||
print(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
def _maybe_auto_ingest():
|
||||
"""If MEMPAL_DIR is set and exists, run mempalace mine in background."""
|
||||
def _get_mine_dir(transcript_path: str = "") -> str:
|
||||
"""Determine directory to mine from MEMPAL_DIR or transcript path."""
|
||||
mempal_dir = os.environ.get("MEMPAL_DIR", "")
|
||||
if mempal_dir and os.path.isdir(mempal_dir):
|
||||
try:
|
||||
log_path = STATE_DIR / "hook.log"
|
||||
with open(log_path, "a") as log_f:
|
||||
subprocess.Popen(
|
||||
[sys.executable, "-m", "mempalace", "mine", mempal_dir],
|
||||
stdout=log_f,
|
||||
stderr=log_f,
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
return mempal_dir
|
||||
if transcript_path:
|
||||
path = Path(transcript_path).expanduser()
|
||||
if path.is_file():
|
||||
return str(path.parent)
|
||||
return ""
|
||||
|
||||
|
||||
def _maybe_auto_ingest(transcript_path: str = ""):
|
||||
"""Run mempalace mine in background if a mine directory is available."""
|
||||
mine_dir = _get_mine_dir(transcript_path)
|
||||
if not mine_dir:
|
||||
return
|
||||
try:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log_path = STATE_DIR / "hook.log"
|
||||
with open(log_path, "a") as log_f:
|
||||
subprocess.Popen(
|
||||
[sys.executable, "-m", "mempalace", "mine", mine_dir],
|
||||
stdout=log_f,
|
||||
stderr=log_f,
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _mine_sync(transcript_path: str = ""):
|
||||
"""Run mempalace mine synchronously (for precompact -- data must land first)."""
|
||||
mine_dir = _get_mine_dir(transcript_path)
|
||||
if not mine_dir:
|
||||
return
|
||||
try:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log_path = STATE_DIR / "hook.log"
|
||||
with open(log_path, "a") as log_f:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "mempalace", "mine", mine_dir],
|
||||
stdout=log_f,
|
||||
stderr=log_f,
|
||||
timeout=60,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
|
||||
SUPPORTED_HARNESSES = {"claude-code", "codex"}
|
||||
@@ -157,7 +241,7 @@ def hook_stop(data: dict, harness: str):
|
||||
_log(f"TRIGGERING SAVE at exchange {exchange_count}")
|
||||
|
||||
# Optional: auto-ingest if MEMPAL_DIR is set
|
||||
_maybe_auto_ingest()
|
||||
_maybe_auto_ingest(transcript_path)
|
||||
|
||||
_output({"decision": "block", "reason": STOP_BLOCK_REASON})
|
||||
else:
|
||||
@@ -179,29 +263,17 @@ def hook_session_start(data: dict, harness: str):
|
||||
|
||||
|
||||
def hook_precompact(data: dict, harness: str):
|
||||
"""Precompact hook: always block with comprehensive save instruction."""
|
||||
"""Precompact hook: mine transcript synchronously, then allow compaction."""
|
||||
parsed = _parse_harness_input(data, harness)
|
||||
session_id = parsed["session_id"]
|
||||
transcript_path = parsed["transcript_path"]
|
||||
|
||||
_log(f"PRE-COMPACT triggered for session {session_id}")
|
||||
|
||||
# Optional: auto-ingest synchronously before compaction (so memories land first)
|
||||
mempal_dir = os.environ.get("MEMPAL_DIR", "")
|
||||
if mempal_dir and os.path.isdir(mempal_dir):
|
||||
try:
|
||||
log_path = STATE_DIR / "hook.log"
|
||||
with open(log_path, "a") as log_f:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "mempalace", "mine", mempal_dir],
|
||||
stdout=log_f,
|
||||
stderr=log_f,
|
||||
timeout=60,
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
# Mine synchronously so data lands before compaction proceeds
|
||||
_mine_sync(transcript_path)
|
||||
|
||||
# Always block -- compaction = save everything
|
||||
_output({"decision": "block", "reason": PRECOMPACT_BLOCK_REASON})
|
||||
_output({})
|
||||
|
||||
|
||||
def run_hook(hook_name: str, harness: str):
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
"""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
|
||||
|
||||
Each locale JSON may include an ``entity`` section with patterns used by
|
||||
``mempalace.entity_detector``. See ``get_entity_patterns`` for the merge rules
|
||||
and the README section "Adding a new language" for the schema.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
_LANG_DIR = Path(__file__).parent
|
||||
_strings: dict = {}
|
||||
_current_lang: str = "en"
|
||||
|
||||
# Cache: tuple(langs) -> merged entity pattern dict
|
||||
_entity_cache: dict = {}
|
||||
|
||||
|
||||
def _canonical_lang(lang: str) -> Optional[str]:
|
||||
"""Resolve a language code to its on-disk canonical filename stem.
|
||||
|
||||
BCP 47 tags are case-insensitive (RFC 5646 §2.1.1), and the locale
|
||||
files mix conventions (``pt-br.json`` vs ``zh-CN.json``). Match on
|
||||
lowercase so callers can pass ``PT-BR``, ``zh-cn``, ``Pt-Br``, etc.
|
||||
Returns ``None`` if no file matches.
|
||||
"""
|
||||
if not lang:
|
||||
return None
|
||||
target = lang.strip().lower()
|
||||
for path in _LANG_DIR.glob("*.json"):
|
||||
if path.stem.lower() == target:
|
||||
return path.stem
|
||||
return None
|
||||
|
||||
|
||||
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
|
||||
canonical = _canonical_lang(lang)
|
||||
if canonical is None:
|
||||
canonical = "en"
|
||||
lang_file = _LANG_DIR / f"{canonical}.json"
|
||||
_strings = json.loads(lang_file.read_text(encoding="utf-8"))
|
||||
_current_lang = canonical
|
||||
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", {})
|
||||
|
||||
|
||||
def _load_entity_section(lang: str) -> dict:
|
||||
"""Load the raw entity section for one language. Returns {} if missing."""
|
||||
canonical = _canonical_lang(lang)
|
||||
if canonical is None:
|
||||
return {}
|
||||
lang_file = _LANG_DIR / f"{canonical}.json"
|
||||
try:
|
||||
data = json.loads(lang_file.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
return data.get("entity", {}) or {}
|
||||
|
||||
|
||||
def _script_boundary(chars: str) -> str:
|
||||
"""Build a lookaround-based word boundary expression.
|
||||
|
||||
Python's built-in ``\\b`` is a transition between ``\\w`` and non-``\\w``.
|
||||
``\\w`` covers Unicode Letter and Number categories but NOT Marks (category
|
||||
Mc/Mn), so for scripts whose words contain combining vowel signs — Devanagari
|
||||
(ा ी ु), Arabic (ـَ ـِ ـُ), Hebrew (ִ ֵ), Thai, Tamil, Burmese, Khmer — the
|
||||
default ``\\b`` drops the trailing mark, truncating names like ``अनीता`` to
|
||||
``अनीत`` and failing to match ``\\bकहा\\b`` because the trailing matra is
|
||||
not a word character.
|
||||
|
||||
Locales with such scripts declare ``boundary_chars`` in their entity section
|
||||
(e.g. ``"\\\\w\\\\u0900-\\\\u097F"`` for Hindi). This function returns a
|
||||
regex fragment equivalent to ``\\b`` but where the "word" side is defined
|
||||
as any char matching ``[chars]`` rather than just ``\\w``.
|
||||
"""
|
||||
return (
|
||||
rf"(?:(?<=[{chars}])(?=[^{chars}])"
|
||||
rf"|(?<=[^{chars}])(?=[{chars}])"
|
||||
rf"|^(?=[{chars}])"
|
||||
rf"|(?<=[{chars}])$)"
|
||||
)
|
||||
|
||||
|
||||
def _expand_b(pattern: str, boundary_chars: str) -> str:
|
||||
"""Replace every literal ``\\b`` in ``pattern`` with a script-aware boundary.
|
||||
|
||||
``boundary_chars`` is the inside-word character class (without brackets).
|
||||
If it's falsy, the pattern is returned unchanged so ``\\b`` keeps its
|
||||
default Python ``re`` semantics.
|
||||
"""
|
||||
if not boundary_chars:
|
||||
return pattern
|
||||
return pattern.replace(r"\b", _script_boundary(boundary_chars))
|
||||
|
||||
|
||||
def _wrap_candidate(raw_pat: str, boundary_chars: str) -> str:
|
||||
"""Wrap a candidate/multi-word extraction pattern with a capture group
|
||||
and word boundaries appropriate for its locale.
|
||||
|
||||
Default: ``\\b(raw)\\b``. With ``boundary_chars``: the script-aware
|
||||
equivalent, so names ending in combining marks are matched in full.
|
||||
"""
|
||||
if boundary_chars:
|
||||
b = _script_boundary(boundary_chars)
|
||||
return f"{b}({raw_pat}){b}"
|
||||
return rf"\b({raw_pat})\b"
|
||||
|
||||
|
||||
def _collect_entity_section(section: dict, acc: dict) -> None:
|
||||
"""Merge one language's entity section into the running accumulator.
|
||||
|
||||
Handles boundary expansion in-place so the caller merges already-expanded
|
||||
strings: `candidate_patterns` and `multi_word_patterns` are pre-wrapped
|
||||
with the locale's boundary (capture group included, ready to compile);
|
||||
every ``\\b`` inside person/pronoun/dialogue/project/direct patterns is
|
||||
replaced with the locale's script-aware boundary.
|
||||
"""
|
||||
boundary_chars = section.get("boundary_chars")
|
||||
if section.get("candidate_pattern"):
|
||||
acc["candidate_patterns"].append(
|
||||
_wrap_candidate(section["candidate_pattern"], boundary_chars)
|
||||
)
|
||||
if section.get("multi_word_pattern"):
|
||||
acc["multi_word_patterns"].append(
|
||||
_wrap_candidate(section["multi_word_pattern"], boundary_chars)
|
||||
)
|
||||
if section.get("direct_address_pattern"):
|
||||
acc["direct_address"].append(_expand_b(section["direct_address_pattern"], boundary_chars))
|
||||
acc["person_verbs"].extend(
|
||||
_expand_b(p, boundary_chars) for p in section.get("person_verb_patterns", [])
|
||||
)
|
||||
acc["pronouns"].extend(
|
||||
_expand_b(p, boundary_chars) for p in section.get("pronoun_patterns", [])
|
||||
)
|
||||
acc["dialogue"].extend(
|
||||
_expand_b(p, boundary_chars) for p in section.get("dialogue_patterns", [])
|
||||
)
|
||||
acc["project_verbs"].extend(
|
||||
_expand_b(p, boundary_chars) for p in section.get("project_verb_patterns", [])
|
||||
)
|
||||
acc["stopwords"].update(w.lower() for w in section.get("stopwords", []))
|
||||
|
||||
|
||||
def get_entity_patterns(languages=("en",)) -> dict:
|
||||
"""Return merged entity detection patterns for the requested languages.
|
||||
|
||||
Entity detection patterns live under each locale's ``entity`` section.
|
||||
This function merges them into a single dict for consumption by
|
||||
``mempalace.entity_detector``.
|
||||
|
||||
Merge rules:
|
||||
- List fields (person_verb_patterns, pronoun_patterns, dialogue_patterns,
|
||||
project_verb_patterns) are concatenated in the order of ``languages``,
|
||||
with duplicates removed while preserving first occurrence.
|
||||
- ``stopwords`` is the set union across all languages, returned as a
|
||||
sorted list.
|
||||
- ``candidate_patterns`` and ``multi_word_patterns`` are returned as
|
||||
**fully-wrapped regex strings** (boundary + capture group applied);
|
||||
the consumer compiles them directly with no further wrapping.
|
||||
- ``direct_address_pattern`` is returned as a list of per-language
|
||||
alternation patterns (not concatenated — each is applied separately).
|
||||
|
||||
Locales with combining-mark scripts can declare ``boundary_chars`` in
|
||||
their entity section (e.g. ``"\\\\w\\\\u0900-\\\\u097F"`` for Hindi);
|
||||
every ``\\b`` inside that locale's patterns — plus the candidate/multi-
|
||||
word wrapping — is expanded to a script-aware lookaround boundary that
|
||||
treats the declared characters as "inside-word".
|
||||
|
||||
If ``languages`` is empty or no requested language declares entity data,
|
||||
English is used as a fallback so callers always get a working config.
|
||||
"""
|
||||
if not languages:
|
||||
languages = ("en",)
|
||||
# Normalize via canonical filename so callers using different casing
|
||||
# (e.g. "PT-BR" vs "pt-br") share the same cache entry and load the
|
||||
# same locale file. Unknown codes are kept as-is so the merge loop's
|
||||
# "found_any" branch fires the English fallback exactly once.
|
||||
languages = tuple(_canonical_lang(lang) or lang for lang in languages)
|
||||
key = languages
|
||||
if key in _entity_cache:
|
||||
return _entity_cache[key]
|
||||
|
||||
acc = {
|
||||
"candidate_patterns": [],
|
||||
"multi_word_patterns": [],
|
||||
"person_verbs": [],
|
||||
"pronouns": [],
|
||||
"dialogue": [],
|
||||
"direct_address": [],
|
||||
"project_verbs": [],
|
||||
"stopwords": set(),
|
||||
}
|
||||
|
||||
found_any = False
|
||||
for lang in languages:
|
||||
section = _load_entity_section(lang)
|
||||
if not section:
|
||||
continue
|
||||
found_any = True
|
||||
_collect_entity_section(section, acc)
|
||||
|
||||
if not found_any:
|
||||
# Fallback: load English directly so callers always get a working config.
|
||||
_collect_entity_section(_load_entity_section("en"), acc)
|
||||
|
||||
merged = {
|
||||
"candidate_patterns": acc["candidate_patterns"],
|
||||
"multi_word_patterns": acc["multi_word_patterns"],
|
||||
"person_verb_patterns": _dedupe(acc["person_verbs"]),
|
||||
"pronoun_patterns": _dedupe(acc["pronouns"]),
|
||||
"dialogue_patterns": _dedupe(acc["dialogue"]),
|
||||
"direct_address_patterns": acc["direct_address"],
|
||||
"project_verb_patterns": _dedupe(acc["project_verbs"]),
|
||||
"stopwords": sorted(acc["stopwords"]),
|
||||
}
|
||||
_entity_cache[key] = merged
|
||||
return merged
|
||||
|
||||
|
||||
def _dedupe(items: list) -> list:
|
||||
"""Remove duplicates while preserving first-occurrence order."""
|
||||
seen = set()
|
||||
out = []
|
||||
for item in items:
|
||||
if item not in seen:
|
||||
seen.add(item)
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
# 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,146 @@
|
||||
{
|
||||
"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}"
|
||||
},
|
||||
"entity": {
|
||||
"candidate_pattern": "[A-Z][a-z]{1,19}",
|
||||
"multi_word_pattern": "[A-Z][a-z]+(?:\\s+[A-Z][a-z]+)+",
|
||||
"person_verb_patterns": [
|
||||
"\\b{name}\\s+said\\b",
|
||||
"\\b{name}\\s+asked\\b",
|
||||
"\\b{name}\\s+told\\b",
|
||||
"\\b{name}\\s+replied\\b",
|
||||
"\\b{name}\\s+laughed\\b",
|
||||
"\\b{name}\\s+smiled\\b",
|
||||
"\\b{name}\\s+cried\\b",
|
||||
"\\b{name}\\s+felt\\b",
|
||||
"\\b{name}\\s+thinks?\\b",
|
||||
"\\b{name}\\s+wants?\\b",
|
||||
"\\b{name}\\s+loves?\\b",
|
||||
"\\b{name}\\s+hates?\\b",
|
||||
"\\b{name}\\s+knows?\\b",
|
||||
"\\b{name}\\s+decided\\b",
|
||||
"\\b{name}\\s+pushed\\b",
|
||||
"\\b{name}\\s+wrote\\b",
|
||||
"\\bhey\\s+{name}\\b",
|
||||
"\\bthanks?\\s+{name}\\b",
|
||||
"\\bhi\\s+{name}\\b",
|
||||
"\\bdear\\s+{name}\\b"
|
||||
],
|
||||
"pronoun_patterns": [
|
||||
"\\bshe\\b",
|
||||
"\\bher\\b",
|
||||
"\\bhers\\b",
|
||||
"\\bhe\\b",
|
||||
"\\bhim\\b",
|
||||
"\\bhis\\b",
|
||||
"\\bthey\\b",
|
||||
"\\bthem\\b",
|
||||
"\\btheir\\b"
|
||||
],
|
||||
"dialogue_patterns": [
|
||||
"^>\\s*{name}[:\\s]",
|
||||
"^{name}:\\s",
|
||||
"^\\[{name}\\]",
|
||||
"\"{name}\\s+said"
|
||||
],
|
||||
"direct_address_pattern": "\\bhey\\s+{name}\\b|\\bthanks?\\s+{name}\\b|\\bhi\\s+{name}\\b",
|
||||
"project_verb_patterns": [
|
||||
"\\bbuilding\\s+{name}\\b",
|
||||
"\\bbuilt\\s+{name}\\b",
|
||||
"\\bship(?:ping|ped)?\\s+{name}\\b",
|
||||
"\\blaunch(?:ing|ed)?\\s+{name}\\b",
|
||||
"\\bdeploy(?:ing|ed)?\\s+{name}\\b",
|
||||
"\\binstall(?:ing|ed)?\\s+{name}\\b",
|
||||
"\\bthe\\s+{name}\\s+architecture\\b",
|
||||
"\\bthe\\s+{name}\\s+pipeline\\b",
|
||||
"\\bthe\\s+{name}\\s+system\\b",
|
||||
"\\bthe\\s+{name}\\s+repo\\b",
|
||||
"\\b{name}\\s+v\\d+\\b",
|
||||
"\\b{name}\\.py\\b",
|
||||
"\\b{name}-core\\b",
|
||||
"\\b{name}-local\\b",
|
||||
"\\bimport\\s+{name}\\b",
|
||||
"\\bpip\\s+install\\s+{name}\\b"
|
||||
],
|
||||
"stopwords": [
|
||||
"the", "a", "an", "and", "or", "but", "in", "on", "at", "to",
|
||||
"for", "of", "with", "by", "from", "as", "is", "was", "are", "were",
|
||||
"be", "been", "being", "have", "has", "had", "do", "does", "did",
|
||||
"will", "would", "could", "should", "may", "might", "must", "shall", "can",
|
||||
"this", "that", "these", "those", "it", "its", "they", "them", "their",
|
||||
"we", "our", "you", "your", "i", "my", "me", "he", "she", "his", "her",
|
||||
"who", "what", "when", "where", "why", "how", "which",
|
||||
"if", "then", "so", "not", "no", "yes", "ok", "okay",
|
||||
"just", "very", "really", "also", "already", "still", "even", "only",
|
||||
"here", "there", "now", "too", "up", "out", "about", "like",
|
||||
"use", "get", "got", "make", "made", "take", "put", "come", "go", "see",
|
||||
"know", "think", "true", "false", "none", "null", "new", "old", "all", "any", "some",
|
||||
"return", "print", "def", "class", "import",
|
||||
"step", "usage", "run", "check", "find", "add", "set", "list",
|
||||
"args", "dict", "str", "int", "bool", "path", "file", "type", "name",
|
||||
"note", "example", "option", "result", "error", "warning", "info",
|
||||
"every", "each", "more", "less", "next", "last", "first", "second",
|
||||
"stack", "layer", "mode", "test", "stop", "start", "copy", "move",
|
||||
"source", "target", "output", "input", "data", "item", "key", "value",
|
||||
"returns", "raises", "yields", "self", "cls", "kwargs",
|
||||
"world", "well", "want", "topic", "choose", "social", "cars", "phones",
|
||||
"healthcare", "ex", "machina", "deus", "human", "humans", "people",
|
||||
"things", "something", "nothing", "everything", "anything", "someone",
|
||||
"everyone", "anyone", "way", "time", "day", "life", "place", "thing",
|
||||
"part", "kind", "sort", "case", "point", "idea", "fact", "sense",
|
||||
"question", "answer", "reason", "number", "version", "system",
|
||||
"hey", "hi", "hello", "thanks", "thank", "right", "let",
|
||||
"click", "hit", "press", "tap", "drag", "drop", "open", "close",
|
||||
"save", "load", "launch", "install", "download", "upload", "scroll",
|
||||
"select", "enter", "submit", "cancel", "confirm", "delete", "paste",
|
||||
"write", "read", "search", "show", "hide",
|
||||
"desktop", "documents", "downloads", "users", "home", "library",
|
||||
"applications", "preferences", "settings", "terminal",
|
||||
"actor", "vector", "remote", "control", "duration", "fetch",
|
||||
"agents", "tools", "others", "guards", "ethics", "regulation",
|
||||
"learning", "thinking", "memory", "language", "intelligence",
|
||||
"technology", "society", "culture", "future", "history", "science",
|
||||
"model", "models", "network", "networks", "training", "inference"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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,105 @@
|
||||
{
|
||||
"lang": "hi",
|
||||
"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 <dir>"
|
||||
},
|
||||
"aaak": {
|
||||
"instruction": "इंडेक्स प्रारूप में संपीड़न करें। शब्दों के बीच हाइफ़न और अवधारणाओं के बीच पाइप का प्रयोग करें। आर्टिकल और अनावश्यक शब्द हटाएँ। नाम और संख्याएँ सटीक रखें।"
|
||||
},
|
||||
"regex": {
|
||||
"topic_pattern": "[\\u0900-\\u097F]{2,}|[A-Za-z][A-Za-z0-9_]{2,}",
|
||||
"stop_words": "यह वह ये वे कुछ कई अधिकांश प्रत्येक हर अन्य केवल ऐसा बहुत होगा सकता चाहिए ज़रूर हाँ ठीक भी फिर अब पहले अभी वापस पूरा बनाना लेना देना जानना सोचना चाहना ज़रूरत जा आ आना जाना ढूँढना काम जोड़ा सहेजा सत्र सारांश वार्तालाप स्रोत विषय के एक बार बस वास्तव में कहाँ यहाँ वहाँ धन्यवाद कृपया सही गलत करें किया करता करती चलाएँ उपयोग",
|
||||
"quote_pattern": "\"([^\"]{20,200})\"",
|
||||
"action_pattern": "(?:बनाया|सुधारा|लिखा|जोड़ा|भेजा|मापा|परीक्षण किया|समीक्षा की|निर्मित किया|हटाया|अद्यतन किया|विन्यस्त किया|तैनात किया|स्थानांतरित किया)\\s+[\\w\\s\\u0900-\\u097F]{3,30}"
|
||||
},
|
||||
"entity": {
|
||||
"boundary_chars": "\\w\\u0900-\\u097F",
|
||||
"candidate_pattern": "[\\u0900-\\u097F]{2,20}",
|
||||
"multi_word_pattern": "[\\u0900-\\u097F]+(?:\\s+[\\u0900-\\u097F]+)+",
|
||||
"person_verb_patterns": [
|
||||
"\\b{name}\\s+ने\\s+कहा\\b",
|
||||
"\\b{name}\\s+ने\\s+पूछा\\b",
|
||||
"\\b{name}\\s+ने\\s+बोला\\b",
|
||||
"\\b{name}\\s+ने\\s+बताया\\b",
|
||||
"\\b{name}\\s+हँसा\\b",
|
||||
"\\b{name}\\s+मुस्कुराया\\b",
|
||||
"\\b{name}\\s+रोया\\b",
|
||||
"\\b{name}\\s+सोचा\\b",
|
||||
"\\b{name}\\s+चाहा\\b",
|
||||
"\\b{name}\\s+पसंद\\s+किया\\b",
|
||||
"\\b{name}\\s+नफरत\\s+की\\b",
|
||||
"\\b{name}\\s+जानता\\s+है\\b"
|
||||
],
|
||||
"pronoun_patterns": [
|
||||
"\\bवह\\b",
|
||||
"\\bउसने\\b",
|
||||
"\\bउसे\\b",
|
||||
"\\bउसका\\b",
|
||||
"\\bउन्होंने\\b",
|
||||
"\\bउनका\\b",
|
||||
"\\bवे\\b"
|
||||
],
|
||||
"dialogue_patterns": [
|
||||
"^>\\s*{name}[:\\s]",
|
||||
"^{name}:\\s",
|
||||
"^\\[{name}\\]",
|
||||
"\"{name}\\s+ने\\s+कहा"
|
||||
],
|
||||
"direct_address_pattern": "\\bअरे\\s+{name}\\b|\\bनमस्ते\\s+{name}\\b|\\bधन्यवाद\\s+{name}\\b",
|
||||
"project_verb_patterns": [
|
||||
"\\b{name}\\s+बना\\s+रहा\\s+है\\b",
|
||||
"\\b{name}\\s+बनाया\\b",
|
||||
"\\b{name}\\s+लॉन्च\\s+किया\\b",
|
||||
"\\b{name}\\s+तैनात\\s+किया\\b",
|
||||
"\\b{name}\\s+इंस्टॉल\\s+किया\\b"
|
||||
],
|
||||
"stopwords": [
|
||||
"यह", "वह", "ये", "वे",
|
||||
"मैं", "हम", "आप", "तुम",
|
||||
"मेरा", "हमारा", "आपका", "उसका", "उनका",
|
||||
"मुझे", "हमें", "आपको", "उसे", "उन्हें",
|
||||
"का", "के", "की",
|
||||
"को", "से", "में", "पर",
|
||||
"के लिए", "के साथ", "के बारे में", "द्वारा",
|
||||
"और", "या", "लेकिन", "क्योंकि", "तो", "यदि",
|
||||
"भी", "ही", "सिर्फ", "केवल",
|
||||
"है", "हैं", "था", "थे", "थी",
|
||||
"हो", "होगा", "होता", "होती",
|
||||
"कर", "करना", "किया", "करते", "करती",
|
||||
"नहीं", "हाँ", "शायद", "ज़रूर",
|
||||
"क्या", "कौन", "कब", "कहाँ", "क्यों", "कैसे",
|
||||
"अब", "तब", "यहाँ", "वहाँ",
|
||||
"बहुत", "कम", "अधिक",
|
||||
"कुछ", "कोई", "सब", "हर"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
{
|
||||
"lang": "id",
|
||||
"label": "Bahasa Indonesia",
|
||||
"terms": {
|
||||
"palace": "istana",
|
||||
"wing": "sayap",
|
||||
"hall": "lorong",
|
||||
"closet": "lemari",
|
||||
"drawer": "laci",
|
||||
"mine": "tambang",
|
||||
"search": "cari",
|
||||
"status": "status",
|
||||
"init": "inisialisasi",
|
||||
"repair": "perbaiki",
|
||||
"migrate": "migrasi",
|
||||
"entity": "entitas",
|
||||
"topic": "topik"
|
||||
},
|
||||
"cli": {
|
||||
"mine_start": "Menambang {path}...",
|
||||
"mine_complete": "Selesai. {closets} lemari dan {drawers} laci berhasil dibuat.",
|
||||
"mine_skip": "Sudah pernah ditambang. Gunakan --force untuk menambang ulang.",
|
||||
"search_no_results": "Tidak ada hasil untuk: {query}",
|
||||
"search_results": "Ditemukan {count} hasil:",
|
||||
"status_palace": "Istana: {path}",
|
||||
"status_wings": "{count} sayap",
|
||||
"status_closets": "{count} lemari",
|
||||
"status_drawers": "{count} laci",
|
||||
"init_complete": "Istana diinisialisasi pada {path}",
|
||||
"init_exists": "Istana sudah ada pada {path}",
|
||||
"repair_complete": "Perbaikan selesai. {fixed} masalah berhasil diperbaiki.",
|
||||
"migrate_complete": "Migrasi selesai.",
|
||||
"no_palace": "Istana tidak ditemukan. Jalankan: mempalace init <dir>"
|
||||
},
|
||||
"aaak": {
|
||||
"instruction": "Ringkas ke format indeks. Gunakan tanda hubung (-) antar kata dan garis vertikal (|) antar konsep. Buang kata fungsi dan kata pengisi yang tidak penting. Pertahankan nama serta angka tetap persis."
|
||||
},
|
||||
"regex": {
|
||||
"topic_pattern": "[A-Z][a-z]{2,}|[A-Za-z][A-Za-z0-9_/-]{2,}",
|
||||
"stop_words": "yang untuk pada ke para namun menurut antara dia ia seperti jika sehingga kembali dan tidak ini karena kepada oleh saat harus sementara setelah belum kami sekitar bagi serta di dari telah sebagai masih hal ketika adalah itu dalam bisa bahwa atau hanya kita dengan akan juga ada mereka sudah saya terhadap secara agar lain anda begitu mengapa kenapa yaitu yakni daripada itulah lagi maka tentang demi di mana ke mana pula sambil sebelum sesudah supaya guna kah pun sampai sedangkan selagi tetapi apakah kecuali sebab selain seolah seraya seterusnya tanpa agak boleh dapat dsb dst dll dahulu dulunya anu demikian tapi ingin nggak gak ga ngga enggak mari nanti melainkan oh ok oke seharusnya sebetulnya setiap setidaknya sesuatu pasti saja toh ya walau tolong tentu amat apalagi bagaimanapun udah banget bgt nih dong kok sih deh aja pun",
|
||||
"quote_pattern": "\"([^\"]{20,200})\"|“([^”]{20,200})”|‘([^’]{20,200})’",
|
||||
"action_pattern": "(?:dibangun|membangun|ngembangin|diperbaiki|memperbaiki|ditulis|menulis|ditambahkan|menambahkan|dibuat|membuat|diperbarui|memperbarui|diulas|mengulas|diuji|menguji|diukur|mengukur|dikonfigurasi|mengonfigurasi|dideploy|deploy|nge-?deploy|ngebuild|build|dikirim|push|dipush|nge-?push|dirilis|rilis|dimigrasi|migrasi|dibundle)\\s+[\\w\\s./_-]{3,30}"
|
||||
},
|
||||
"entity": {
|
||||
"candidate_pattern": "[A-Z][a-z]{1,19}|[A-Z]{2,10}|[A-Za-z][A-Za-z0-9_]*[A-Z][A-Za-z0-9_]*|[a-z]+[A-Z][A-Za-z0-9_]*",
|
||||
"multi_word_pattern": "(?:[A-Z][a-z]+|[A-Z]{2,10})(?:\\s+(?:[A-Z][a-z]+|[A-Z]{2,10}))+",
|
||||
"person_verb_patterns": [
|
||||
"\\b{name}\\s+berkata\\b",
|
||||
"\\b{name}\\s+mengatakan\\b",
|
||||
"\\b{name}\\s+bilang\\b",
|
||||
"\\b{name}\\s+ngomong\\b",
|
||||
"\\b{name}\\s+ceritain\\b",
|
||||
"\\b{name}\\s+bertanya\\b",
|
||||
"\\b{name}\\s+menanyakan\\b",
|
||||
"\\b{name}\\s+tanya\\b",
|
||||
"\\b{name}\\s+nanya\\b",
|
||||
"\\b{name}\\s+menjawab\\b",
|
||||
"\\b{name}\\s+jawab\\b",
|
||||
"\\b{name}\\s+balas\\b",
|
||||
"\\b{name}\\s+reply\\b",
|
||||
"\\b{name}\\s+membalas\\b",
|
||||
"\\b{name}\\s+menjelaskan\\b",
|
||||
"\\b{name}\\s+cerita\\b",
|
||||
"\\b{name}\\s+tertawa\\b",
|
||||
"\\b{name}\\s+tersenyum\\b",
|
||||
"\\b{name}\\s+menangis\\b",
|
||||
"\\b{name}\\s+merasa\\b",
|
||||
"\\b{name}\\s+memikirkan\\b",
|
||||
"\\b{name}\\s+berpikir\\b",
|
||||
"\\b{name}\\s+pikir\\b",
|
||||
"\\b{name}\\s+ingin\\b",
|
||||
"\\b{name}\\s+mau\\b",
|
||||
"\\b{name}\\s+suka\\b",
|
||||
"\\b{name}\\s+benci\\b",
|
||||
"\\b{name}\\s+tahu\\b",
|
||||
"\\b{name}\\s+memutuskan\\b",
|
||||
"\\b{name}\\s+memilih\\b",
|
||||
"\\b{name}\\s+decided\\b",
|
||||
"\\b{name}\\s+menulis\\b",
|
||||
"\\b{name}\\s+nulis\\b",
|
||||
"\\b{name}\\s+ngetik\\b",
|
||||
"\\b{name}\\s+push\\b",
|
||||
"\\b{name}\\s+nge-?push\\b",
|
||||
"\\b{name}\\s+review(?:ed)?\\b",
|
||||
"\\b{name}\\s+nge-?review\\b",
|
||||
"\\b{name}\\s+approve(?:d)?\\b",
|
||||
"\\b{name}\\s+di-?approve\\b"
|
||||
],
|
||||
"pronoun_patterns": [
|
||||
"\\bdia\\b",
|
||||
"\\bia\\b",
|
||||
"\\bbeliau\\b",
|
||||
"\\bmereka\\b"
|
||||
],
|
||||
"dialogue_patterns": [
|
||||
"^>\\s*{name}[:\\s]",
|
||||
"^{name}:\\s",
|
||||
"^\\[{name}\\]",
|
||||
"\"{name}\\s+berkata",
|
||||
"\"{name}\\s+bilang"
|
||||
],
|
||||
"direct_address_pattern": "\\bhai\\s+{name}\\b|\\bhalo\\s+{name}\\b|\\bhi\\s+{name}\\b|\\bhei\\s+{name}\\b|\\bterima\\s+kasih\\s+{name}\\b|\\bmakasih\\s+{name}\\b|\\bmakasi\\s+{name}\\b|\\bpak\\s+{name}\\b|\\bbu\\s+{name}\\b|\\bmas\\s+{name}\\b|\\bmbak\\s+{name}\\b|\\bkak\\s+{name}\\b",
|
||||
"project_verb_patterns": [
|
||||
"\\bmembangun\\s+{name}\\b",
|
||||
"\\bbangun\\s+{name}\\b",
|
||||
"\\bdibangun\\s+{name}\\b",
|
||||
"\\bngembangin\\s+{name}\\b",
|
||||
"\\bmengerjakan\\s+{name}\\b",
|
||||
"\\bngerjain\\s+{name}\\b",
|
||||
"\\bgarap\\s+{name}\\b",
|
||||
"\\bbuild(?:ing)?\\s+{name}\\b",
|
||||
"\\bnge-?build\\s+{name}\\b",
|
||||
"\\bmerilis\\s+{name}\\b",
|
||||
"\\brilis\\s+{name}\\b",
|
||||
"\\bship(?:ping|ped)?\\s+{name}\\b",
|
||||
"\\bmeluncurkan\\s+{name}\\b",
|
||||
"\\blaunch(?:ing|ed)?\\s+{name}\\b",
|
||||
"\\bdeploy(?:ing|ed)?\\s+{name}\\b",
|
||||
"\\bdideploy\\s+{name}\\b",
|
||||
"\\bmendeploy\\s+{name}\\b",
|
||||
"\\bnge-?deploy\\s+{name}\\b",
|
||||
"\\binstall(?:ing|ed)?\\s+{name}\\b",
|
||||
"\\bmenginstal\\s+{name}\\b",
|
||||
"\\bmemasang\\s+{name}\\b",
|
||||
"\\bpush\\s+{name}\\b",
|
||||
"\\bnge-?push\\s+{name}\\b",
|
||||
"\\breview(?:ing|ed)?\\s+{name}\\b",
|
||||
"\\barsitektur\\s+{name}\\b",
|
||||
"\\bpipeline\\s+{name}\\b",
|
||||
"\\b{name}\\s+v\\d+\\b",
|
||||
"\\b{name}\\.py\\b",
|
||||
"\\b{name}-core\\b",
|
||||
"\\b{name}-local\\b",
|
||||
"\\bimport\\s+{name}\\b",
|
||||
"\\bpip\\s+install\\s+{name}\\b"
|
||||
],
|
||||
"stopwords": [
|
||||
"yang",
|
||||
"dan",
|
||||
"atau",
|
||||
"tetapi",
|
||||
"namun",
|
||||
"karena",
|
||||
"jadi",
|
||||
"kalau",
|
||||
"jika",
|
||||
"ketika",
|
||||
"saat",
|
||||
"supaya",
|
||||
"agar",
|
||||
"untuk",
|
||||
"dari",
|
||||
"ke",
|
||||
"di",
|
||||
"pada",
|
||||
"dalam",
|
||||
"dengan",
|
||||
"tanpa",
|
||||
"antara",
|
||||
"tentang",
|
||||
"sebagai",
|
||||
"oleh",
|
||||
"terhadap",
|
||||
"ini",
|
||||
"itu",
|
||||
"tersebut",
|
||||
"sini",
|
||||
"situ",
|
||||
"sana",
|
||||
"saya",
|
||||
"aku",
|
||||
"kami",
|
||||
"kita",
|
||||
"kamu",
|
||||
"anda",
|
||||
"dia",
|
||||
"ia",
|
||||
"beliau",
|
||||
"mereka",
|
||||
"ada",
|
||||
"tidak",
|
||||
"bukan",
|
||||
"iya",
|
||||
"ya",
|
||||
"oke",
|
||||
"ok",
|
||||
"baik",
|
||||
"nah",
|
||||
"nih",
|
||||
"dong",
|
||||
"deh",
|
||||
"kok",
|
||||
"sih",
|
||||
"aja",
|
||||
"juga",
|
||||
"lagi",
|
||||
"sudah",
|
||||
"udah",
|
||||
"belum",
|
||||
"masih",
|
||||
"baru",
|
||||
"pernah",
|
||||
"selalu",
|
||||
"sering",
|
||||
"jarang",
|
||||
"banyak",
|
||||
"sedikit",
|
||||
"lebih",
|
||||
"kurang",
|
||||
"semua",
|
||||
"setiap",
|
||||
"beberapa",
|
||||
"sesuatu",
|
||||
"apa",
|
||||
"siapa",
|
||||
"mana",
|
||||
"kapan",
|
||||
"mengapa",
|
||||
"kenapa",
|
||||
"bagaimana",
|
||||
"bisa",
|
||||
"harus",
|
||||
"mau",
|
||||
"ingin",
|
||||
"tahu",
|
||||
"coba",
|
||||
"pak",
|
||||
"bu",
|
||||
"mas",
|
||||
"mbak",
|
||||
"kak"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"lang": "it",
|
||||
"label": "Italiano",
|
||||
"terms": {
|
||||
"palace": "palazzo",
|
||||
"wing": "ala",
|
||||
"hall": "corridoio",
|
||||
"closet": "armadio",
|
||||
"drawer": "cassetto",
|
||||
"mine": "estrarre",
|
||||
"search": "cercare",
|
||||
"status": "stato",
|
||||
"init": "inizializzare",
|
||||
"repair": "riparare",
|
||||
"migrate": "migrare",
|
||||
"entity": "entità",
|
||||
"topic": "argomento"
|
||||
},
|
||||
"cli": {
|
||||
"mine_start": "Estrazione di {path}...",
|
||||
"mine_complete": "Fatto. {closets} armadi, {drawers} cassetti creati.",
|
||||
"mine_skip": "Già estratto. Usa --force per estrarre di nuovo.",
|
||||
"search_no_results": "Nessun risultato per: {query}",
|
||||
"search_results": "{count} risultati trovati:",
|
||||
"status_palace": "Palazzo: {path}",
|
||||
"status_wings": "{count} ali",
|
||||
"status_closets": "{count} armadi",
|
||||
"status_drawers": "{count} cassetti",
|
||||
"init_complete": "Palazzo inizializzato in {path}",
|
||||
"init_exists": "Esiste già un palazzo in {path}",
|
||||
"repair_complete": "Riparazione completata. {fixed} problemi risolti.",
|
||||
"migrate_complete": "Migrazione completata.",
|
||||
"no_palace": "Nessun palazzo trovato. Esegui: mempalace init <cartella>"
|
||||
},
|
||||
"aaak": {
|
||||
"instruction": "Comprimi in italiano. Trattini tra le parole, pipe tra i concetti. Elimina articoli e parole di riempimento. Mantieni nomi propri e numeri esatti."
|
||||
},
|
||||
"regex": {
|
||||
"topic_pattern": "[A-ZÀ-Ú][a-zà-ú]{2,}|[A-Za-zÀ-ÿ][A-Za-zÀ-ÿ0-9_]{2,}",
|
||||
"stop_words": "il lo la i gli le un uno una di del della dello dei degli delle al allo alla ai agli alle in con su per tra fra da dal dalla dallo dai dagli dalle e o ma che chi cui come dove quando perché mentre anche ancora già molto poco solo sempre mai essere avere sono sei siamo siete era erano stato stata questo questa questi queste quello quella quelli quelle mio mia miei mie tuo tua tuoi tue suo sua suoi sue nostro nostra nostri nostre vostro vostra vostri vostre loro",
|
||||
"quote_pattern": "«\\s*([^»]{10,200})\\s*»|\"([^\"]{10,200})\"",
|
||||
"action_pattern": "(?:costruito|corretto|scritto|aggiunto|inviato|misurato|testato|revisionato|creato|eliminato|aggiornato|configurato|distribuito|migrato)\\s+[\\wÀ-ÿ\\s]{3,30}"
|
||||
},
|
||||
"entity": {
|
||||
"candidate_pattern": "[A-ZÀ-Ú][a-zà-ÿ]{1,19}",
|
||||
"multi_word_pattern": "[A-ZÀ-Ú][a-zà-ÿ]+(?:\\s+[A-ZÀ-Ú][a-zà-ÿ]+)+",
|
||||
"person_verb_patterns": [
|
||||
"\\b{name}\\s+ha\\s+detto\\b",
|
||||
"\\b{name}\\s+ha\\s+chiesto\\b",
|
||||
"\\b{name}\\s+ha\\s+risposto\\b",
|
||||
"\\b{name}\\s+ha\\s+riferito\\b",
|
||||
"\\b{name}\\s+ha\\s+riso\\b",
|
||||
"\\b{name}\\s+ha\\s+sorriso\\b",
|
||||
"\\b{name}\\s+ha\\s+pianto\\b",
|
||||
"\\b{name}\\s+ha\\s+sentito\\b",
|
||||
"\\b{name}\\s+pensa\\b",
|
||||
"\\b{name}\\s+vuole\\b",
|
||||
"\\b{name}\\s+ama\\b",
|
||||
"\\b{name}\\s+odia\\b",
|
||||
"\\b{name}\\s+sa\\b",
|
||||
"\\b{name}\\s+ha\\s+deciso\\b",
|
||||
"\\b{name}\\s+ha\\s+scritto\\b"
|
||||
],
|
||||
"pronoun_patterns": [
|
||||
"\\blei\\b",
|
||||
"\\blui\\b",
|
||||
"\\bloro\\b",
|
||||
"\\bgli\\b",
|
||||
"\\ble\\b"
|
||||
],
|
||||
"dialogue_patterns": [
|
||||
"^>\\s*{name}[:\\s]",
|
||||
"^{name}:\\s",
|
||||
"^\\[{name}\\]",
|
||||
"\"{name}\\s+ha\\s+detto"
|
||||
],
|
||||
"direct_address_pattern": "\\bciao\\s+{name}\\b|\\bgrazie\\s+{name}\\b|\\bsalve\\s+{name}\\b|\\bcaro\\s+{name}\\b|\\bcara\\s+{name}\\b",
|
||||
"project_verb_patterns": [
|
||||
"\\bstiamo\\s+costruendo\\s+{name}\\b",
|
||||
"\\babbiamo\\s+costruito\\s+{name}\\b",
|
||||
"\\bstiamo\\s+lanciando\\s+{name}\\b",
|
||||
"\\babbiamo\\s+lanciato\\s+{name}\\b",
|
||||
"\\babbiamo\\s+distribuito\\s+{name}\\b",
|
||||
"\\babbiamo\\s+installato\\s+{name}\\b",
|
||||
"\\bil\\s+progetto\\s+{name}\\b",
|
||||
"\\bil\\s+sistema\\s+{name}\\b",
|
||||
"\\bimport\\s+{name}\\b",
|
||||
"\\bpip\\s+install\\s+{name}\\b"
|
||||
],
|
||||
"stopwords": [
|
||||
"ciao",
|
||||
"salve",
|
||||
"grazie",
|
||||
"prego",
|
||||
"sì",
|
||||
"si",
|
||||
"no",
|
||||
"forse",
|
||||
"qui",
|
||||
"qua",
|
||||
"lì",
|
||||
"oggi",
|
||||
"ieri",
|
||||
"domani",
|
||||
"sempre",
|
||||
"mai",
|
||||
"ancora",
|
||||
"anche",
|
||||
"molto",
|
||||
"poco",
|
||||
"bene",
|
||||
"male",
|
||||
"così",
|
||||
"poi",
|
||||
"prima",
|
||||
"dopo",
|
||||
"tra",
|
||||
"fra",
|
||||
"con",
|
||||
"senza",
|
||||
"per",
|
||||
"verso",
|
||||
"contro",
|
||||
"durante",
|
||||
"mentre",
|
||||
"sopra",
|
||||
"sotto",
|
||||
"oltre",
|
||||
"oppure",
|
||||
"ma",
|
||||
"però",
|
||||
"tuttavia",
|
||||
"anche",
|
||||
"se",
|
||||
"quando",
|
||||
"finché",
|
||||
"perché",
|
||||
"quindi",
|
||||
"dunque",
|
||||
"allora",
|
||||
"forse",
|
||||
"magari",
|
||||
"abbiamo",
|
||||
"stiamo",
|
||||
"essere",
|
||||
"avere",
|
||||
"sono",
|
||||
"sei",
|
||||
"siamo",
|
||||
"siete",
|
||||
"era",
|
||||
"erano",
|
||||
"stato",
|
||||
"stata",
|
||||
"questo",
|
||||
"questa",
|
||||
"questi",
|
||||
"queste",
|
||||
"quello",
|
||||
"quella",
|
||||
"quelli",
|
||||
"quelle",
|
||||
"mio",
|
||||
"mia",
|
||||
"miei",
|
||||
"mie",
|
||||
"tuo",
|
||||
"tua",
|
||||
"tuoi",
|
||||
"tue",
|
||||
"suo",
|
||||
"sua",
|
||||
"suoi",
|
||||
"sue",
|
||||
"nostro",
|
||||
"nostra",
|
||||
"nostri",
|
||||
"nostre",
|
||||
"vostro",
|
||||
"vostra",
|
||||
"vostri",
|
||||
"vostre",
|
||||
"loro"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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": "서랍 {count}개",
|
||||
"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,173 @@
|
||||
{
|
||||
"lang": "pt-br",
|
||||
"label": "Português (Brasil)",
|
||||
"terms": {
|
||||
"palace": "palácio",
|
||||
"wing": "ala",
|
||||
"hall": "corredor",
|
||||
"closet": "armário",
|
||||
"drawer": "gaveta",
|
||||
"mine": "minerar",
|
||||
"search": "buscar",
|
||||
"status": "status",
|
||||
"init": "inicializar",
|
||||
"repair": "reparar",
|
||||
"migrate": "migrar",
|
||||
"entity": "entidade",
|
||||
"topic": "tópico"
|
||||
},
|
||||
"cli": {
|
||||
"mine_start": "Minerando {path}...",
|
||||
"mine_complete": "Pronto. {closets} armários, {drawers} gavetas criados.",
|
||||
"mine_skip": "Já minerado. Use --force para refazer.",
|
||||
"search_no_results": "Sem resultados para: {query}",
|
||||
"search_results": "{count} resultados encontrados:",
|
||||
"status_palace": "Palácio: {path}",
|
||||
"status_wings": "{count} alas",
|
||||
"status_closets": "{count} armários",
|
||||
"status_drawers": "{count} gavetas",
|
||||
"init_complete": "Palácio inicializado em {path}",
|
||||
"init_exists": "Já existe um palácio em {path}",
|
||||
"repair_complete": "Reparo completo. {fixed} problemas corrigidos.",
|
||||
"migrate_complete": "Migração completa.",
|
||||
"no_palace": "Nenhum palácio encontrado. Execute: mempalace init <diretório>"
|
||||
},
|
||||
"aaak": {
|
||||
"instruction": "Comprima em português. Hifens entre palavras, pipes entre conceitos. Remova artigos e palavras de preenchimento. Mantenha nomes próprios e números exatos."
|
||||
},
|
||||
"regex": {
|
||||
"topic_pattern": "[A-ZÀ-Ú][a-zà-ÿ]{2,}|[A-Za-zÀ-ÿ]{3,}",
|
||||
"stop_words": "o a os as um uma uns umas de do da dos das em no na nos nas por para com sem sobre entre ao aos seu sua seus suas meu minha meus minhas tu teu tua que quem qual onde quando porque embora mas porém também muito mais como este esta estes estas esse essa esses essas aquele aquela é são está estão foi ser estar ter sido",
|
||||
"quote_pattern": "\"([^\"]{10,200})\"|«([^»]{10,200})»",
|
||||
"action_pattern": "(?:construído|corrigido|escrito|adicionado|enviado|medido|testado|revisado|criado|excluído|atualizado|configurado|implantado|migrado)\\s+[\\wà-ÿ\\s]{3,30}"
|
||||
},
|
||||
"entity": {
|
||||
"candidate_pattern": "[A-ZÀ-Ú][a-zà-ÿ]{1,19}",
|
||||
"multi_word_pattern": "[A-ZÀ-Ú][a-zà-ÿ]+(?:\\s+[A-ZÀ-Ú][a-zà-ÿ]+)+",
|
||||
"person_verb_patterns": [
|
||||
"\\b{name}\\s+disse\\b",
|
||||
"\\b{name}\\s+perguntou\\b",
|
||||
"\\b{name}\\s+respondeu\\b",
|
||||
"\\b{name}\\s+contou\\b",
|
||||
"\\b{name}\\s+riu\\b",
|
||||
"\\b{name}\\s+sorriu\\b",
|
||||
"\\b{name}\\s+chorou\\b",
|
||||
"\\b{name}\\s+sentiu\\b",
|
||||
"\\b{name}\\s+pensa\\b",
|
||||
"\\b{name}\\s+quer\\b",
|
||||
"\\b{name}\\s+ama\\b",
|
||||
"\\b{name}\\s+odeia\\b",
|
||||
"\\b{name}\\s+sabe\\b",
|
||||
"\\b{name}\\s+decidiu\\b",
|
||||
"\\b{name}\\s+escreveu\\b"
|
||||
],
|
||||
"pronoun_patterns": [
|
||||
"\\bela\\b",
|
||||
"\\bdela\\b",
|
||||
"\\bele\\b",
|
||||
"\\bdele\\b",
|
||||
"\\beles\\b",
|
||||
"\\belas\\b",
|
||||
"\\bdeles\\b",
|
||||
"\\bdelas\\b",
|
||||
"\\bvocê\\b",
|
||||
"\\bvocês\\b",
|
||||
"\\bseu\\b",
|
||||
"\\bsua\\b",
|
||||
"\\bseus\\b",
|
||||
"\\bsuas\\b"
|
||||
],
|
||||
"dialogue_patterns": [
|
||||
"^>\\s*{name}[:\\s]",
|
||||
"^{name}:\\s",
|
||||
"^\\[{name}\\]",
|
||||
"\"{name}\\s+disse"
|
||||
],
|
||||
"direct_address_pattern": "\\boi\\s+{name}\\b|\\bol[áa]\\s+{name}\\b|\\bobrigad[oa]\\s+{name}\\b|\\bcaro\\s+{name}\\b|\\bcara\\s+{name}\\b",
|
||||
"project_verb_patterns": [
|
||||
"\\bconstruindo\\s+{name}\\b",
|
||||
"\\bconstruiu\\s+{name}\\b",
|
||||
"\\blançando\\s+{name}\\b",
|
||||
"\\blançou\\s+{name}\\b",
|
||||
"\\bimplantando\\s+{name}\\b",
|
||||
"\\bimplantou\\s+{name}\\b",
|
||||
"\\binstalando\\s+{name}\\b",
|
||||
"\\binstalou\\s+{name}\\b",
|
||||
"\\bo\\s+sistema\\s+{name}\\b",
|
||||
"\\bo\\s+projeto\\s+{name}\\b",
|
||||
"\\bimport\\s+{name}\\b",
|
||||
"\\bpip\\s+install\\s+{name}\\b"
|
||||
],
|
||||
"stopwords": [
|
||||
"oi",
|
||||
"ola",
|
||||
"olá",
|
||||
"obrigado",
|
||||
"obrigada",
|
||||
"sim",
|
||||
"não",
|
||||
"talvez",
|
||||
"aqui",
|
||||
"ali",
|
||||
"lá",
|
||||
"agora",
|
||||
"hoje",
|
||||
"ontem",
|
||||
"amanhã",
|
||||
"sempre",
|
||||
"nunca",
|
||||
"ainda",
|
||||
"também",
|
||||
"muito",
|
||||
"pouco",
|
||||
"bem",
|
||||
"mal",
|
||||
"assim",
|
||||
"então",
|
||||
"depois",
|
||||
"antes",
|
||||
"durante",
|
||||
"sobre",
|
||||
"entre",
|
||||
"para",
|
||||
"como",
|
||||
"mas",
|
||||
"porém",
|
||||
"contudo",
|
||||
"embora",
|
||||
"enquanto",
|
||||
"porque",
|
||||
"portanto",
|
||||
"logo",
|
||||
"todavia",
|
||||
"desde",
|
||||
"contra",
|
||||
"perante",
|
||||
"após",
|
||||
"mediante",
|
||||
"conforme",
|
||||
"segundo",
|
||||
"exceto",
|
||||
"pois",
|
||||
"apenas",
|
||||
"mais",
|
||||
"menos",
|
||||
"cada",
|
||||
"todo",
|
||||
"toda",
|
||||
"todos",
|
||||
"todas",
|
||||
"tudo",
|
||||
"nada",
|
||||
"algo",
|
||||
"onde",
|
||||
"quando",
|
||||
"qual",
|
||||
"quem",
|
||||
"isso",
|
||||
"isto",
|
||||
"ser",
|
||||
"ter"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"lang": "ru",
|
||||
"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": "[А-ЯЁ][а-яё]{2,}|[A-Z][a-z]{2,}|[A-Za-z][A-Za-z0-9_]{2,}",
|
||||
"stop_words": "это этот эта эти тот та те тех некоторые много каждый другой только такой очень будет может должен надо хорошо также даже потом сейчас уже ещё обратно сделано делать брать давать знать думать хотеть нужно если когда просто правда ладно вообще конечно например значит кстати наверное видимо похоже получается собственно кажется",
|
||||
"quote_pattern": "«\\s*([^»]{10,200})\\s*»|\"([^\"]{10,200})\"",
|
||||
"action_pattern": "(?:построил|исправил|написал|добавил|запустил|протестировал|проверил|создал|удалил|обновил|настроил|развернул|перенёс|собрал)\\s+[\\wа-яёА-ЯЁ\\s]{3,30}"
|
||||
},
|
||||
"entity": {
|
||||
"candidate_pattern": "[А-ЯЁ][а-яё]{1,19}",
|
||||
"multi_word_pattern": "[А-ЯЁ][а-яё]+(?:\\s+[А-ЯЁ][а-яё]+)+",
|
||||
"person_verb_patterns": [
|
||||
"\\b{name}\\s+сказал[аи]?\\b",
|
||||
"\\b{name}\\s+спросил[аи]?\\b",
|
||||
"\\b{name}\\s+ответил[аи]?\\b",
|
||||
"\\b{name}\\s+рассказал[аи]?\\b",
|
||||
"\\b{name}\\s+засмеял(ся|ась|ись)\\b",
|
||||
"\\b{name}\\s+улыбнул(ся|ась|ись)\\b",
|
||||
"\\b{name}\\s+заплакал[аи]?\\b",
|
||||
"\\b{name}\\s+почувствовал[аи]?\\b",
|
||||
"\\b{name}\\s+думает\\b",
|
||||
"\\b{name}\\s+хочет\\b",
|
||||
"\\b{name}\\s+любит\\b",
|
||||
"\\b{name}\\s+ненавидит\\b",
|
||||
"\\b{name}\\s+знает\\b",
|
||||
"\\b{name}\\s+решил[аи]?\\b",
|
||||
"\\b{name}\\s+написал[аи]?\\b"
|
||||
],
|
||||
"pronoun_patterns": [
|
||||
"\\bона\\b",
|
||||
"\\bеё\\b",
|
||||
"\\bей\\b",
|
||||
"\\bон\\b",
|
||||
"\\bего\\b",
|
||||
"\\bему\\b",
|
||||
"\\bони\\b",
|
||||
"\\bих\\b",
|
||||
"\\bим\\b"
|
||||
],
|
||||
"dialogue_patterns": [
|
||||
"^>\\s*{name}[:\\s]",
|
||||
"^{name}:\\s",
|
||||
"^\\[{name}\\]",
|
||||
"\"{name}\\s+сказал"
|
||||
],
|
||||
"direct_address_pattern": "\\bпривет\\s+{name}\\b|\\bспасибо\\s+{name}\\b|\\bздравствуй(те)?\\s+{name}\\b|\\bуважаемый\\s+{name}\\b|\\bуважаемая\\s+{name}\\b|\\bдорогой\\s+{name}\\b|\\bдорогая\\s+{name}\\b",
|
||||
"project_verb_patterns": [
|
||||
"\\bсобираю\\s+{name}\\b",
|
||||
"\\bсобрал\\s+{name}\\b",
|
||||
"\\bзапускаю\\s+{name}\\b",
|
||||
"\\bзапустил\\s+{name}\\b",
|
||||
"\\bразвернул\\s+{name}\\b",
|
||||
"\\bустановил\\s+{name}\\b",
|
||||
"\\bсистема\\s+{name}\\b",
|
||||
"\\bпроект\\s+{name}\\b",
|
||||
"\\bimport\\s+{name}\\b",
|
||||
"\\bpip\\s+install\\s+{name}\\b"
|
||||
],
|
||||
"stopwords": [
|
||||
"привет",
|
||||
"здравствуйте",
|
||||
"спасибо",
|
||||
"пожалуйста",
|
||||
"да",
|
||||
"нет",
|
||||
"может",
|
||||
"наверное",
|
||||
"здесь",
|
||||
"там",
|
||||
"тут",
|
||||
"сейчас",
|
||||
"сегодня",
|
||||
"вчера",
|
||||
"завтра",
|
||||
"всегда",
|
||||
"никогда",
|
||||
"ещё",
|
||||
"тоже",
|
||||
"очень",
|
||||
"мало",
|
||||
"хорошо",
|
||||
"плохо",
|
||||
"так",
|
||||
"потом",
|
||||
"перед",
|
||||
"после",
|
||||
"между",
|
||||
"около",
|
||||
"вместе",
|
||||
"без",
|
||||
"для",
|
||||
"над",
|
||||
"под",
|
||||
"при",
|
||||
"про",
|
||||
"через",
|
||||
"против",
|
||||
"вместо",
|
||||
"кроме",
|
||||
"среди",
|
||||
"вокруг",
|
||||
"вдоль",
|
||||
"ради",
|
||||
"напротив",
|
||||
"благодаря",
|
||||
"согласно",
|
||||
"навстречу",
|
||||
"или",
|
||||
"либо",
|
||||
"но",
|
||||
"однако",
|
||||
"зато",
|
||||
"хотя",
|
||||
"если",
|
||||
"когда",
|
||||
"пока",
|
||||
"чтобы",
|
||||
"потому",
|
||||
"поэтому",
|
||||
"причём",
|
||||
"притом",
|
||||
"будто",
|
||||
"словно"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -25,4 +25,4 @@ def run_instructions(name: str):
|
||||
print(f"Instructions file not found: {md_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(md_path.read_text())
|
||||
print(md_path.read_text(encoding="utf-8"))
|
||||
|
||||
+152
-135
@@ -39,6 +39,7 @@ import hashlib
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -49,8 +50,14 @@ DEFAULT_KG_PATH = os.path.expanduser("~/.mempalace/knowledge_graph.sqlite3")
|
||||
class KnowledgeGraph:
|
||||
def __init__(self, db_path: str = None):
|
||||
self.db_path = db_path or DEFAULT_KG_PATH
|
||||
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
db_parent = Path(self.db_path).parent
|
||||
db_parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
db_parent.chmod(0o700)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
self._connection = None
|
||||
self._lock = threading.Lock()
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
@@ -97,9 +104,10 @@ class KnowledgeGraph:
|
||||
|
||||
def close(self):
|
||||
"""Close the database connection."""
|
||||
if self._connection is not None:
|
||||
self._connection.close()
|
||||
self._connection = None
|
||||
with self._lock:
|
||||
if self._connection is not None:
|
||||
self._connection.close()
|
||||
self._connection = None
|
||||
|
||||
def _entity_id(self, name: str) -> str:
|
||||
return name.lower().replace(" ", "_").replace("'", "")
|
||||
@@ -110,12 +118,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 +151,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 +196,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,58 +214,58 @@ 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
|
||||
|
||||
def query_relationship(self, predicate: str, as_of: str = None):
|
||||
"""Get all triples with a given relationship type."""
|
||||
pred = predicate.lower().replace(" ", "_")
|
||||
conn = self._conn()
|
||||
query = """
|
||||
SELECT t.*, s.name as sub_name, o.name as obj_name
|
||||
FROM triples t
|
||||
@@ -266,45 +279,48 @@ class KnowledgeGraph:
|
||||
params.extend([as_of, as_of])
|
||||
|
||||
results = []
|
||||
for row in conn.execute(query, params).fetchall():
|
||||
results.append(
|
||||
{
|
||||
"subject": row["sub_name"],
|
||||
"predicate": pred,
|
||||
"object": row["obj_name"],
|
||||
"valid_from": row["valid_from"],
|
||||
"valid_to": row["valid_to"],
|
||||
"current": row["valid_to"] is None,
|
||||
}
|
||||
)
|
||||
with self._lock:
|
||||
conn = self._conn()
|
||||
for row in conn.execute(query, params).fetchall():
|
||||
results.append(
|
||||
{
|
||||
"subject": row["sub_name"],
|
||||
"predicate": pred,
|
||||
"object": row["obj_name"],
|
||||
"valid_from": row["valid_from"],
|
||||
"valid_to": row["valid_to"],
|
||||
"current": row["valid_to"] is None,
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
def timeline(self, entity_name: str = None):
|
||||
"""Get all facts in chronological order, optionally filtered by entity."""
|
||||
conn = self._conn()
|
||||
if entity_name:
|
||||
eid = self._entity_id(entity_name)
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT t.*, s.name as sub_name, o.name as obj_name
|
||||
FROM triples t
|
||||
JOIN entities s ON t.subject = s.id
|
||||
JOIN entities o ON t.object = o.id
|
||||
WHERE (t.subject = ? OR t.object = ?)
|
||||
ORDER BY t.valid_from ASC NULLS LAST
|
||||
LIMIT 100
|
||||
""",
|
||||
(eid, eid),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute("""
|
||||
SELECT t.*, s.name as sub_name, o.name as obj_name
|
||||
FROM triples t
|
||||
JOIN entities s ON t.subject = s.id
|
||||
JOIN entities o ON t.object = o.id
|
||||
ORDER BY t.valid_from ASC NULLS LAST
|
||||
LIMIT 100
|
||||
""").fetchall()
|
||||
with self._lock:
|
||||
conn = self._conn()
|
||||
if entity_name:
|
||||
eid = self._entity_id(entity_name)
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT t.*, s.name as sub_name, o.name as obj_name
|
||||
FROM triples t
|
||||
JOIN entities s ON t.subject = s.id
|
||||
JOIN entities o ON t.object = o.id
|
||||
WHERE (t.subject = ? OR t.object = ?)
|
||||
ORDER BY t.valid_from ASC NULLS LAST
|
||||
LIMIT 100
|
||||
""",
|
||||
(eid, eid),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute("""
|
||||
SELECT t.*, s.name as sub_name, o.name as obj_name
|
||||
FROM triples t
|
||||
JOIN entities s ON t.subject = s.id
|
||||
JOIN entities o ON t.object = o.id
|
||||
ORDER BY t.valid_from ASC NULLS LAST
|
||||
LIMIT 100
|
||||
""").fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -321,19 +337,20 @@ class KnowledgeGraph:
|
||||
# ── Stats ─────────────────────────────────────────────────────────────
|
||||
|
||||
def stats(self):
|
||||
conn = self._conn()
|
||||
entities = conn.execute("SELECT COUNT(*) as cnt FROM entities").fetchone()["cnt"]
|
||||
triples = conn.execute("SELECT COUNT(*) as cnt FROM triples").fetchone()["cnt"]
|
||||
current = conn.execute(
|
||||
"SELECT COUNT(*) as cnt FROM triples WHERE valid_to IS NULL"
|
||||
).fetchone()["cnt"]
|
||||
expired = triples - current
|
||||
predicates = [
|
||||
r["predicate"]
|
||||
for r in conn.execute(
|
||||
"SELECT DISTINCT predicate FROM triples ORDER BY predicate"
|
||||
).fetchall()
|
||||
]
|
||||
with self._lock:
|
||||
conn = self._conn()
|
||||
entities = conn.execute("SELECT COUNT(*) as cnt FROM entities").fetchone()["cnt"]
|
||||
triples = conn.execute("SELECT COUNT(*) as cnt FROM triples").fetchone()["cnt"]
|
||||
current = conn.execute(
|
||||
"SELECT COUNT(*) as cnt FROM triples WHERE valid_to IS NULL"
|
||||
).fetchone()["cnt"]
|
||||
expired = triples - current
|
||||
predicates = [
|
||||
r["predicate"]
|
||||
for r in conn.execute(
|
||||
"SELECT DISTINCT predicate FROM triples ORDER BY predicate"
|
||||
).fetchall()
|
||||
]
|
||||
return {
|
||||
"entities": entities,
|
||||
"triples": triples,
|
||||
|
||||
+18
-40
@@ -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 _first_or_empty, 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],
|
||||
@@ -286,9 +272,9 @@ class Layer3:
|
||||
except Exception as e:
|
||||
return f"Search error: {e}"
|
||||
|
||||
docs = results["documents"][0]
|
||||
metas = results["metadatas"][0]
|
||||
dists = results["distances"][0]
|
||||
docs = _first_or_empty(results, "documents")
|
||||
metas = _first_or_empty(results, "metadatas")
|
||||
dists = _first_or_empty(results, "distances")
|
||||
|
||||
if not docs:
|
||||
return "No results found."
|
||||
@@ -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],
|
||||
@@ -344,9 +323,9 @@ class Layer3:
|
||||
|
||||
hits = []
|
||||
for doc, meta, dist in zip(
|
||||
results["documents"][0],
|
||||
results["metadatas"][0],
|
||||
results["distances"][0],
|
||||
_first_or_empty(results, "documents"),
|
||||
_first_or_empty(results, "metadatas"),
|
||||
_first_or_empty(results, "distances"),
|
||||
):
|
||||
hits.append(
|
||||
{
|
||||
@@ -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:
|
||||
|
||||
+840
-77
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."""
|
||||
from .backends.chroma import ChromaBackend
|
||||
|
||||
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)
|
||||
target_version = ChromaBackend.backend_version()
|
||||
print(f" Source: ChromaDB {source_version}")
|
||||
print(f" Target: ChromaDB {target_version}")
|
||||
|
||||
# Try reading with current chromadb first
|
||||
try:
|
||||
col = ChromaBackend().get_collection(palace_path, "mempalace_drawers")
|
||||
count = col.count()
|
||||
print(f"\n Palace is already readable by chromadb {target_version}.")
|
||||
print(f" {count} drawers found. No migration needed.")
|
||||
return True
|
||||
except Exception:
|
||||
print(f"\n Palace is NOT readable by chromadb {target_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}...")
|
||||
fresh_backend = ChromaBackend()
|
||||
col = fresh_backend.get_or_create_collection(temp_palace, "mempalace_drawers")
|
||||
|
||||
# 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 fresh_backend
|
||||
|
||||
# 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
|
||||
+245
-27
@@ -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",
|
||||
@@ -256,16 +264,32 @@ def load_config(project_dir: str) -> dict:
|
||||
"""Load mempalace.yaml from project directory (falls back to mempal.yaml)."""
|
||||
import yaml
|
||||
|
||||
config_path = Path(project_dir).expanduser().resolve() / "mempalace.yaml"
|
||||
resolved_project_dir = Path(project_dir).expanduser().resolve()
|
||||
config_path = resolved_project_dir / "mempalace.yaml"
|
||||
if not config_path.exists():
|
||||
# Fallback to legacy name
|
||||
legacy_path = Path(project_dir).expanduser().resolve() / "mempal.yaml"
|
||||
legacy_path = resolved_project_dir / "mempal.yaml"
|
||||
if legacy_path.exists():
|
||||
config_path = legacy_path
|
||||
else:
|
||||
print(f"ERROR: No mempalace.yaml found in {project_dir}")
|
||||
print(f"Run: mempalace init {project_dir}")
|
||||
sys.exit(1)
|
||||
wing_name = resolved_project_dir.name
|
||||
print(
|
||||
f" No mempalace.yaml found in {resolved_project_dir} "
|
||||
f"— using auto-detected defaults (wing='{wing_name}'). "
|
||||
"Directories with the same basename will share a wing; "
|
||||
"add mempalace.yaml to disambiguate.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return {
|
||||
"wing": wing_name,
|
||||
"rooms": [
|
||||
{
|
||||
"name": "general",
|
||||
"description": "All project files",
|
||||
"keywords": ["general"],
|
||||
}
|
||||
],
|
||||
}
|
||||
with open(config_path) as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
@@ -370,6 +394,145 @@ 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)
|
||||
|
||||
from .palace import _candidate_entity_words
|
||||
|
||||
window = content[:_ENTITY_EXTRACT_WINDOW]
|
||||
words = _candidate_entity_words(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 +546,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 +582,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 +607,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 +785,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 +803,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 +833,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))
|
||||
|
||||
+308
-39
@@ -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,9 +16,98 @@ No API key. No internet. Everything local.
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Provenance footer appended to Slack transcript output so downstream consumers
|
||||
# know the speaker roles are positionally assigned, not verified.
|
||||
_SLACK_PROVENANCE_FOOTER = (
|
||||
"\n[source: slack-export | multi-party chat — speaker roles are positional, not verified]"
|
||||
)
|
||||
|
||||
|
||||
# ─── 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:
|
||||
"""
|
||||
@@ -30,7 +119,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 +129,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 +174,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 +185,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 +287,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]:
|
||||
@@ -240,8 +373,13 @@ def _try_chatgpt_json(data) -> Optional[str]:
|
||||
def _try_slack_json(data) -> Optional[str]:
|
||||
"""
|
||||
Slack channel export: [{"type": "message", "user": "...", "text": "..."}]
|
||||
Optimized for 2-person DMs. In channels with 3+ people, alternating
|
||||
speakers are labeled user/assistant to preserve the exchange structure.
|
||||
|
||||
Slack exports are multi-party chats where no speaker is inherently the
|
||||
"user" or "assistant". To preserve exchange-pair chunking (which relies
|
||||
on ``>`` markers from the ``user`` role), we still alternate roles, but
|
||||
prefix each message with the speaker ID so downstream consumers can
|
||||
distinguish the original author. A provenance header marks the
|
||||
transcript as a Slack import.
|
||||
"""
|
||||
if not isinstance(data, list):
|
||||
return None
|
||||
@@ -251,7 +389,10 @@ def _try_slack_json(data) -> Optional[str]:
|
||||
for item in data:
|
||||
if not isinstance(item, dict) or item.get("type") != "message":
|
||||
continue
|
||||
user_id = item.get("user", item.get("username", ""))
|
||||
raw_user_id = item.get("user", item.get("username", ""))
|
||||
# Sanitize speaker ID: strip brackets, newlines, and control chars
|
||||
# to prevent chunk-boundary injection via crafted exports
|
||||
user_id = re.sub(r"[\[\]\n\r\x00-\x1f]", "_", raw_user_id).strip()
|
||||
text = item.get("text", "").strip()
|
||||
if not text or not user_id:
|
||||
continue
|
||||
@@ -264,14 +405,21 @@ def _try_slack_json(data) -> Optional[str]:
|
||||
else:
|
||||
seen_users[user_id] = "user"
|
||||
last_role = seen_users[user_id]
|
||||
messages.append((seen_users[user_id], text))
|
||||
# Prefix with speaker ID so the original author is preserved
|
||||
messages.append((seen_users[user_id], f"[{user_id}] {text}"))
|
||||
if len(messages) >= 2:
|
||||
return _messages_to_transcript(messages)
|
||||
return _messages_to_transcript(messages) + _SLACK_PROVENANCE_FOOTER
|
||||
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 +427,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:
|
||||
|
||||
+289
-17
@@ -1,11 +1,15 @@
|
||||
"""
|
||||
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
|
||||
import re
|
||||
|
||||
from .backends.chroma import ChromaBackend
|
||||
|
||||
SKIP_DIRS = {
|
||||
".git",
|
||||
@@ -33,39 +37,307 @@ 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",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
_CANDIDATE_RX_CACHE = None
|
||||
|
||||
|
||||
def _candidate_entity_words(text: str) -> list:
|
||||
"""Find entity candidate words using i18n-aware patterns.
|
||||
|
||||
Uses the same candidate_patterns as entity_detector (loaded from locale
|
||||
JSON files via get_entity_patterns), so non-Latin names (Cyrillic,
|
||||
accented Latin, etc.) are detected alongside ASCII names.
|
||||
"""
|
||||
global _CANDIDATE_RX_CACHE
|
||||
if _CANDIDATE_RX_CACHE is None:
|
||||
from .config import MempalaceConfig
|
||||
from .i18n import get_entity_patterns
|
||||
|
||||
patterns = get_entity_patterns(MempalaceConfig().entity_languages)
|
||||
rxs = []
|
||||
for pat in patterns["candidate_patterns"]:
|
||||
try:
|
||||
rxs.append(re.compile(pat))
|
||||
except re.error:
|
||||
continue
|
||||
_CANDIDATE_RX_CACHE = rxs
|
||||
words = []
|
||||
for rx in _CANDIDATE_RX_CACHE:
|
||||
words.extend(rx.findall(text))
|
||||
return words
|
||||
|
||||
|
||||
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 (2+ occurrences). Uses i18n-aware patterns so
|
||||
# non-Latin names (Cyrillic, accented Latin, etc.) are also detected.
|
||||
words = _candidate_entity_words(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,297 @@
|
||||
"""
|
||||
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
|
||||
|
||||
from .backends.chroma import ChromaBackend
|
||||
|
||||
|
||||
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...")
|
||||
|
||||
col = ChromaBackend().get_collection(palace_path, 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
|
||||
|
||||
col = ChromaBackend().get_collection(palace_path, 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}")
|
||||
|
||||
backend = ChromaBackend()
|
||||
try:
|
||||
col = backend.get_collection(palace_path, 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...")
|
||||
backend.delete_collection(palace_path, COLLECTION_NAME)
|
||||
new_col = backend.create_collection(palace_path, COLLECTION_NAME)
|
||||
|
||||
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]
|
||||
|
||||
+399
-49
@@ -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,228 @@ class SearchError(Exception):
|
||||
"""Raised when search cannot proceed (e.g. no palace found)."""
|
||||
|
||||
|
||||
_TOKEN_RE = re.compile(r"\w{2,}", re.UNICODE)
|
||||
|
||||
|
||||
def _first_or_empty(results: dict, key: str) -> list:
|
||||
"""Return the first inner list of a ChromaDB query result, or [].
|
||||
|
||||
ChromaDB returns shapes like ``{"documents": [["a", "b"]], ...}`` for a
|
||||
successful query, but ``{"documents": [], ...}`` (empty outer list) when
|
||||
the collection is empty or the filter excludes everything. Indexing
|
||||
``[0]`` blindly raises IndexError in that case (issue #195).
|
||||
"""
|
||||
outer = results.get(key)
|
||||
if not outer:
|
||||
return []
|
||||
return outer[0] or []
|
||||
|
||||
|
||||
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 = {
|
||||
@@ -55,9 +265,9 @@ def search(query: str, palace_path: str, wing: str = None, room: str = None, n_r
|
||||
print(f"\n Search error: {e}")
|
||||
raise SearchError(f"Search error: {e}") from e
|
||||
|
||||
docs = results["documents"][0]
|
||||
metas = results["metadatas"][0]
|
||||
dists = results["distances"][0]
|
||||
docs = _first_or_empty(results, "documents")
|
||||
metas = _first_or_empty(results, "metadatas")
|
||||
dists = _first_or_empty(results, "distances")
|
||||
|
||||
if not docs:
|
||||
print(f'\n No results found for: "{query}"')
|
||||
@@ -72,7 +282,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 +301,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 +332,171 @@ 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(
|
||||
_first_or_empty(closet_results, "documents"),
|
||||
_first_or_empty(closet_results, "metadatas"),
|
||||
_first_or_empty(closet_results, "distances"),
|
||||
)
|
||||
):
|
||||
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(
|
||||
_first_or_empty(drawer_results, "documents"),
|
||||
_first_or_empty(drawer_results, "metadatas"),
|
||||
_first_or_empty(drawer_results, "distances"),
|
||||
):
|
||||
# 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 "?",
|
||||
"created_at": meta.get("filed_at", "unknown"),
|
||||
"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(_first_or_empty(drawer_results, "documents")),
|
||||
"results": hits,
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ def _load_known_names_config(force_reload: bool = False):
|
||||
|
||||
if _KNOWN_NAMES_PATH.exists():
|
||||
try:
|
||||
_KNOWN_NAMES_CACHE = json.loads(_KNOWN_NAMES_PATH.read_text())
|
||||
_KNOWN_NAMES_CACHE = json.loads(_KNOWN_NAMES_PATH.read_text(encoding="utf-8"))
|
||||
return _KNOWN_NAMES_CACHE
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
@@ -184,7 +184,7 @@ def split_file(filepath, output_dir, dry_run=False):
|
||||
path = Path(filepath)
|
||||
max_size = 500 * 1024 * 1024 # 500 MB safety limit
|
||||
if path.stat().st_size > max_size:
|
||||
print(f" SKIP: {path.name} exceeds {max_size // (1024*1024)} MB limit")
|
||||
print(f" SKIP: {path.name} exceeds {max_size // (1024 * 1024)} MB limit")
|
||||
return []
|
||||
lines = path.read_text(errors="replace").splitlines(keepends=True)
|
||||
|
||||
@@ -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:
|
||||
@@ -273,7 +273,7 @@ def main():
|
||||
max_scan_size = 500 * 1024 * 1024 # 500 MB
|
||||
for f in files:
|
||||
if f.stat().st_size > max_scan_size:
|
||||
print(f" SKIP: {f.name} exceeds {max_scan_size // (1024*1024)} MB limit")
|
||||
print(f" SKIP: {f.name} exceeds {max_scan_size // (1024 * 1024)} MB limit")
|
||||
continue
|
||||
lines = f.read_text(errors="replace").splitlines(keepends=True)
|
||||
boundaries = find_session_boundaries(lines)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Single source of truth for the MemPalace package version."""
|
||||
|
||||
__version__ = "3.1.0"
|
||||
__version__ = "3.3.1"
|
||||
|
||||
+11
-7
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mempalace"
|
||||
version = "3.1.0"
|
||||
version = "3.3.1"
|
||||
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,17 +25,17 @@ classifiers = [
|
||||
"Topic :: Utilities",
|
||||
]
|
||||
dependencies = [
|
||||
"chromadb>=0.5.0,<0.7",
|
||||
"chromadb>=0.5.0",
|
||||
"pyyaml>=6.0,<7",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/milla-jovovich/mempalace"
|
||||
Repository = "https://github.com/milla-jovovich/mempalace"
|
||||
"Bug Tracker" = "https://github.com/milla-jovovich/mempalace/issues"
|
||||
Homepage = "https://github.com/MemPalace/mempalace"
|
||||
Repository = "https://github.com/MemPalace/mempalace"
|
||||
"Bug Tracker" = "https://github.com/MemPalace/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"
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import pytest
|
||||
import timeit
|
||||
import re
|
||||
|
||||
from mempalace.dialect import Dialect
|
||||
|
||||
def test_detect_entities_benchmark():
|
||||
dialect = Dialect()
|
||||
text = "Alice went to the market and met Bob who is a nice guy. They both discussed about Dr. Chen and how he solved the big issue. Another sentence with Name and Name2 and SomeName"
|
||||
|
||||
# Run the function multiple times to measure the performance
|
||||
number = 10000
|
||||
time = timeit.timeit(lambda: dialect._detect_entities_in_text(text), number=number)
|
||||
print(f"\nDialect._detect_entities_in_text benchmark: {time:.4f} seconds for {number} iterations")
|
||||
+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
|
||||
+116
-67
@@ -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()
|
||||
@@ -382,28 +412,48 @@ def test_main_compress_dispatches():
|
||||
# ── cmd_repair ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _mock_backend_for(col=None, new_col=None):
|
||||
"""Build a mock ChromaBackend whose get_collection/create_collection return *col* / *new_col*."""
|
||||
mock_backend = MagicMock()
|
||||
if col is not None:
|
||||
mock_backend.get_collection.return_value = col
|
||||
if new_col is not None:
|
||||
mock_backend.create_collection.return_value = new_col
|
||||
return mock_backend
|
||||
|
||||
|
||||
@patch("mempalace.cli.MempalaceConfig")
|
||||
def test_cmd_repair_no_palace(mock_config_cls, tmp_path, capsys):
|
||||
mock_config_cls.return_value.palace_path = str(tmp_path / "nonexistent")
|
||||
args = argparse.Namespace(palace=None)
|
||||
mock_chromadb = MagicMock()
|
||||
with patch.dict("sys.modules", {"chromadb": mock_chromadb}):
|
||||
with patch("mempalace.backends.chroma.ChromaBackend"):
|
||||
cmd_repair(args)
|
||||
out = capsys.readouterr().out
|
||||
assert "No palace found" in out
|
||||
|
||||
|
||||
@patch("mempalace.cli.MempalaceConfig")
|
||||
def test_cmd_repair_error_reading(mock_config_cls, tmp_path, capsys):
|
||||
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()
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_collection.side_effect = Exception("corrupt db")
|
||||
mock_chromadb.PersistentClient.return_value = mock_client
|
||||
with patch.dict("sys.modules", {"chromadb": mock_chromadb}):
|
||||
with patch("mempalace.backends.chroma.ChromaBackend"):
|
||||
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_backend = MagicMock()
|
||||
mock_backend.get_collection.side_effect = Exception("corrupt db")
|
||||
with patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend):
|
||||
cmd_repair(args)
|
||||
out = capsys.readouterr().out
|
||||
assert "Error reading palace" in out
|
||||
@@ -413,15 +463,13 @@ 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()
|
||||
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
|
||||
with patch.dict("sys.modules", {"chromadb": mock_chromadb}):
|
||||
mock_backend = _mock_backend_for(col=mock_col)
|
||||
with patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend):
|
||||
cmd_repair(args)
|
||||
out = capsys.readouterr().out
|
||||
assert "Nothing to repair" in out
|
||||
@@ -431,9 +479,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)
|
||||
mock_chromadb = MagicMock()
|
||||
args = argparse.Namespace(palace=None, yes=True)
|
||||
mock_col = MagicMock()
|
||||
mock_col.count.return_value = 2
|
||||
mock_col.get.return_value = {
|
||||
@@ -441,18 +489,35 @@ def test_cmd_repair_success(mock_config_cls, tmp_path, capsys):
|
||||
"documents": ["doc1", "doc2"],
|
||||
"metadatas": [{"wing": "a"}, {"wing": "b"}],
|
||||
}
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_collection.return_value = mock_col
|
||||
mock_new_col = MagicMock()
|
||||
mock_client.create_collection.return_value = mock_new_col
|
||||
mock_chromadb.PersistentClient.return_value = mock_client
|
||||
with patch.dict("sys.modules", {"chromadb": mock_chromadb}):
|
||||
mock_backend = _mock_backend_for(col=mock_col, new_col=mock_new_col)
|
||||
with patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend):
|
||||
cmd_repair(args)
|
||||
out = capsys.readouterr().out
|
||||
assert "Repair complete" in out
|
||||
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_col = MagicMock()
|
||||
mock_col.count.return_value = 1
|
||||
mock_backend = _mock_backend_for(col=mock_col)
|
||||
with (
|
||||
patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend),
|
||||
patch("builtins.input", return_value="n"),
|
||||
):
|
||||
cmd_repair(args)
|
||||
out = capsys.readouterr().out
|
||||
assert "Aborted." in out
|
||||
mock_backend.create_collection.assert_not_called()
|
||||
|
||||
|
||||
# ── cmd_compress ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -460,10 +525,10 @@ def test_cmd_repair_success(mock_config_cls, tmp_path, capsys):
|
||||
def test_cmd_compress_no_palace(mock_config_cls, capsys):
|
||||
mock_config_cls.return_value.palace_path = "/fake/palace"
|
||||
args = argparse.Namespace(palace=None, wing=None, dry_run=False, config=None)
|
||||
mock_chromadb = MagicMock()
|
||||
mock_chromadb.PersistentClient.side_effect = Exception("no palace")
|
||||
mock_backend = MagicMock()
|
||||
mock_backend.get_collection.side_effect = Exception("no palace")
|
||||
with (
|
||||
patch.dict("sys.modules", {"chromadb": mock_chromadb}),
|
||||
patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend),
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
cmd_compress(args)
|
||||
@@ -473,13 +538,10 @@ def test_cmd_compress_no_palace(mock_config_cls, capsys):
|
||||
def test_cmd_compress_no_drawers(mock_config_cls, capsys):
|
||||
mock_config_cls.return_value.palace_path = "/fake/palace"
|
||||
args = argparse.Namespace(palace=None, wing="mywing", dry_run=False, config=None)
|
||||
mock_chromadb = MagicMock()
|
||||
mock_col = MagicMock()
|
||||
mock_col.get.return_value = {"documents": [], "metadatas": [], "ids": []}
|
||||
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}):
|
||||
mock_backend = _mock_backend_for(col=mock_col)
|
||||
with patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend):
|
||||
cmd_compress(args)
|
||||
out = capsys.readouterr().out
|
||||
assert "No drawers found" in out
|
||||
@@ -498,7 +560,6 @@ def _make_mock_dialect_module(dialect_instance):
|
||||
def test_cmd_compress_dry_run(mock_config_cls, capsys):
|
||||
mock_config_cls.return_value.palace_path = "/fake/palace"
|
||||
args = argparse.Namespace(palace=None, wing=None, dry_run=True, config=None)
|
||||
mock_chromadb = MagicMock()
|
||||
mock_col = MagicMock()
|
||||
mock_col.get.side_effect = [
|
||||
{
|
||||
@@ -508,32 +569,29 @@ def test_cmd_compress_dry_run(mock_config_cls, capsys):
|
||||
},
|
||||
{"documents": [], "metadatas": [], "ids": []},
|
||||
]
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_collection.return_value = mock_col
|
||||
mock_chromadb.PersistentClient.return_value = mock_client
|
||||
mock_backend = _mock_backend_for(col=mock_col)
|
||||
|
||||
mock_dialect = MagicMock()
|
||||
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)
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"chromadb": mock_chromadb,
|
||||
"mempalace.dialect": mock_dialect_mod,
|
||||
},
|
||||
with (
|
||||
patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend),
|
||||
patch.dict("sys.modules", {"mempalace.dialect": mock_dialect_mod}),
|
||||
):
|
||||
cmd_compress(args)
|
||||
out = capsys.readouterr().out
|
||||
assert "dry run" in out.lower()
|
||||
assert "Compressing" in out
|
||||
assert "Total:" in out
|
||||
|
||||
|
||||
@patch("mempalace.cli.MempalaceConfig")
|
||||
@@ -542,22 +600,16 @@ def test_cmd_compress_with_config(mock_config_cls, tmp_path, capsys):
|
||||
config_file = tmp_path / "entities.json"
|
||||
config_file.write_text('{"people": [], "projects": []}')
|
||||
args = argparse.Namespace(palace=None, wing=None, dry_run=True, config=str(config_file))
|
||||
mock_chromadb = MagicMock()
|
||||
mock_col = MagicMock()
|
||||
mock_col.get.return_value = {"documents": [], "metadatas": [], "ids": []}
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_collection.return_value = mock_col
|
||||
mock_chromadb.PersistentClient.return_value = mock_client
|
||||
mock_backend = _mock_backend_for(col=mock_col)
|
||||
|
||||
mock_dialect = MagicMock()
|
||||
mock_dialect_mod = _make_mock_dialect_module(mock_dialect)
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"chromadb": mock_chromadb,
|
||||
"mempalace.dialect": mock_dialect_mod,
|
||||
},
|
||||
with (
|
||||
patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend),
|
||||
patch.dict("sys.modules", {"mempalace.dialect": mock_dialect_mod}),
|
||||
):
|
||||
cmd_compress(args)
|
||||
out = capsys.readouterr().out
|
||||
@@ -569,7 +621,6 @@ def test_cmd_compress_stores_results(mock_config_cls, capsys):
|
||||
"""Non-dry-run compress stores to mempalace_compressed collection."""
|
||||
mock_config_cls.return_value.palace_path = "/fake/palace"
|
||||
args = argparse.Namespace(palace=None, wing=None, dry_run=False, config=None)
|
||||
mock_chromadb = MagicMock()
|
||||
mock_col = MagicMock()
|
||||
mock_col.get.side_effect = [
|
||||
{
|
||||
@@ -579,33 +630,31 @@ def test_cmd_compress_stores_results(mock_config_cls, capsys):
|
||||
},
|
||||
{"documents": [], "metadatas": [], "ids": []},
|
||||
]
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_collection.return_value = mock_col
|
||||
mock_comp_col = MagicMock()
|
||||
mock_client.get_or_create_collection.return_value = mock_comp_col
|
||||
mock_chromadb.PersistentClient.return_value = mock_client
|
||||
mock_backend = MagicMock()
|
||||
mock_backend.get_collection.return_value = mock_col
|
||||
mock_backend.get_or_create_collection.return_value = mock_comp_col
|
||||
|
||||
mock_dialect = MagicMock()
|
||||
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)
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"chromadb": mock_chromadb,
|
||||
"mempalace.dialect": mock_dialect_mod,
|
||||
},
|
||||
with (
|
||||
patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend),
|
||||
patch.dict("sys.modules", {"mempalace.dialect": mock_dialect_mod}),
|
||||
):
|
||||
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"]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user