Merge pull request #878 from MemPalace/develop
release: sync develop → main (v3.3.0 manifest, SECURITY.md, version guard, Pages CNAME)
This commit is contained in:
@@ -2,14 +2,14 @@
|
|||||||
"name": "mempalace",
|
"name": "mempalace",
|
||||||
"owner": {
|
"owner": {
|
||||||
"name": "milla-jovovich",
|
"name": "milla-jovovich",
|
||||||
"url": "https://github.com/milla-jovovich"
|
"url": "https://github.com/MemPalace"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "mempalace",
|
"name": "mempalace",
|
||||||
"source": "./.claude-plugin",
|
"source": "./.claude-plugin",
|
||||||
"description": "AI memory system — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, guided setup.",
|
"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.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "milla-jovovich"
|
"name": "milla-jovovich"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mempalace",
|
"name": "mempalace",
|
||||||
"version": "3.0.14",
|
"version": "3.3.0",
|
||||||
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, and guided setup.",
|
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, and guided setup.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "milla-jovovich"
|
"name": "milla-jovovich"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mempalace",
|
"name": "mempalace",
|
||||||
"version": "3.0.14",
|
"version": "3.3.0",
|
||||||
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, and guided setup.",
|
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, and guided setup.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "milla-jovovich"
|
"name": "milla-jovovich"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: Deploy Docs
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, develop]
|
branches: [develop]
|
||||||
paths:
|
paths:
|
||||||
- ".github/workflows/deploy-docs.yml"
|
- ".github/workflows/deploy-docs.yml"
|
||||||
- "website/**"
|
- "website/**"
|
||||||
@@ -51,7 +51,7 @@ jobs:
|
|||||||
path: website/.vitepress/dist
|
path: website/.vitepress/dist
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
if: github.ref_name == 'main' || github.ref_name == 'develop'
|
if: github.ref_name == 'develop'
|
||||||
environment:
|
environment:
|
||||||
name: github-pages
|
name: github-pages
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -67,7 +67,7 @@ Other memory systems try to fix this by letting AI decide what's worth rememberi
|
|||||||
>
|
>
|
||||||
> **What's still true and reproducible:**
|
> **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).
|
> - **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.
|
> - 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.
|
> - The architecture (wings, rooms, closets, drawers) is real and useful, even if it's not a magical retrieval boost.
|
||||||
>
|
>
|
||||||
@@ -78,7 +78,7 @@ Other memory systems try to fix this by letting AI decide what's worth rememberi
|
|||||||
> 3. Wiring `fact_checker.py` into the KG ops so the contradiction detection claim becomes true
|
> 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)
|
> 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.
|
> **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*
|
> — *Milla Jovovich & Ben Sigman*
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ After the one-time setup (install → init → mine), you don't run MemPalace co
|
|||||||
Native marketplace install:
|
Native marketplace install:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
claude plugin marketplace add milla-jovovich/mempalace
|
claude plugin marketplace add MemPalace/mempalace
|
||||||
claude plugin install --scope user mempalace
|
claude plugin install --scope user mempalace
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -251,7 +251,7 @@ You say what you're looking for and boom, it already knows which wing to go to.
|
|||||||
**Rooms** — specific topics within a wing. Auth, billing, deploy — endless rooms.
|
**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.
|
**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.
|
**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).)
|
**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/MemPalace/mempalace/issues/30).)
|
||||||
**Drawers** — the original verbatim files. The exact words, never summarized.
|
**Drawers** — the original verbatim files. The exact words, never summarized.
|
||||||
|
|
||||||
**Halls** are memory types — the same in every wing, acting as corridors:
|
**Halls** are memory types — the same in every wing, acting as corridors:
|
||||||
@@ -307,11 +307,11 @@ AAAK is a lossy abbreviation system — entity codes, structural markers, and se
|
|||||||
- **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.
|
- **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.
|
- **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).
|
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/MemPalace/mempalace/issues/43) and [#27](https://github.com/MemPalace/mempalace/issues/27).
|
||||||
|
|
||||||
### Contradiction Detection (experimental, not yet wired into KG)
|
### 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:
|
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/MemPalace/mempalace/issues/27)). When enabled it catches things like:
|
||||||
|
|
||||||
```
|
```
|
||||||
Input: "Soren finished the auth migration"
|
Input: "Soren finished the auth migration"
|
||||||
@@ -463,7 +463,7 @@ Letta charges $20–200/mo for agent-managed memory. MemPalace does it with a wi
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Via plugin (recommended)
|
# Via plugin (recommended)
|
||||||
claude plugin marketplace add milla-jovovich/mempalace
|
claude plugin marketplace add MemPalace/mempalace
|
||||||
claude plugin install --scope user mempalace
|
claude plugin install --scope user mempalace
|
||||||
|
|
||||||
# Or manually
|
# Or manually
|
||||||
@@ -743,10 +743,10 @@ MIT — see [LICENSE](LICENSE).
|
|||||||
|
|
||||||
<!-- Link Definitions -->
|
<!-- Link Definitions -->
|
||||||
[version-shield]: https://img.shields.io/badge/version-3.3.0-4dc9f6?style=flat-square&labelColor=0a0e14
|
[version-shield]: https://img.shields.io/badge/version-3.3.0-4dc9f6?style=flat-square&labelColor=0a0e14
|
||||||
[release-link]: https://github.com/milla-jovovich/mempalace/releases
|
[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-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/
|
[python-link]: https://www.python.org/
|
||||||
[license-shield]: https://img.shields.io/badge/license-MIT-b0e8ff?style=flat-square&labelColor=0a0e14
|
[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-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
|
[discord-link]: https://discord.com/invite/ycTQQCu6kn
|
||||||
|
|||||||
+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).
|
||||||
@@ -133,11 +133,20 @@ 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"
|
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
|
if [ -n "$MEMPAL_DIR" ] && [ -d "$MEMPAL_DIR" ]; then
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
MINE_DIR="$MEMPAL_DIR"
|
||||||
REPO_DIR="$(dirname "$SCRIPT_DIR")"
|
fi
|
||||||
python3 -m mempalace mine "$MEMPAL_DIR" >> "$STATE_DIR/hook.log" 2>&1 &
|
if [ -n "$MINE_DIR" ]; then
|
||||||
|
"$PYTHON" -m mempalace mine "$MINE_DIR" >> "$STATE_DIR/hook.log" 2>&1 &
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Notify the AI that a checkpoint happened — but do NOT ask it to write
|
# Notify the AI that a checkpoint happened — but do NOT ask it to write
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ class BaseCollection(ABC):
|
|||||||
) -> None:
|
) -> None:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def update(self, **kwargs: Any) -> None:
|
||||||
|
"""Update existing records. Must raise if any ID is missing."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def query(self, **kwargs: Any) -> Dict[str, Any]:
|
def query(self, **kwargs: Any) -> Dict[str, Any]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ class ChromaCollection(BaseCollection):
|
|||||||
def upsert(self, *, documents, ids, metadatas=None):
|
def upsert(self, *, documents, ids, metadatas=None):
|
||||||
self._collection.upsert(documents=documents, ids=ids, metadatas=metadatas)
|
self._collection.upsert(documents=documents, ids=ids, metadatas=metadatas)
|
||||||
|
|
||||||
|
def update(self, **kwargs):
|
||||||
|
self._collection.update(**kwargs)
|
||||||
|
|
||||||
def query(self, **kwargs):
|
def query(self, **kwargs):
|
||||||
return self._collection.query(**kwargs)
|
return self._collection.query(**kwargs)
|
||||||
|
|
||||||
@@ -71,6 +74,44 @@ class ChromaCollection(BaseCollection):
|
|||||||
class ChromaBackend:
|
class ChromaBackend:
|
||||||
"""Factory for MemPalace's default ChromaDB backend."""
|
"""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):
|
def get_collection(self, palace_path: str, collection_name: str, create: bool = False):
|
||||||
if not create and not os.path.isdir(palace_path):
|
if not create and not os.path.isdir(palace_path):
|
||||||
raise FileNotFoundError(palace_path)
|
raise FileNotFoundError(palace_path)
|
||||||
@@ -82,8 +123,7 @@ class ChromaBackend:
|
|||||||
except (OSError, NotImplementedError):
|
except (OSError, NotImplementedError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
_fix_blob_seq_ids(palace_path)
|
client = self._client(palace_path)
|
||||||
client = chromadb.PersistentClient(path=palace_path)
|
|
||||||
if create:
|
if create:
|
||||||
collection = client.get_or_create_collection(
|
collection = client.get_or_create_collection(
|
||||||
collection_name, metadata={"hnsw:space": "cosine"}
|
collection_name, metadata={"hnsw:space": "cosine"}
|
||||||
@@ -91,3 +131,22 @@ class ChromaBackend:
|
|||||||
else:
|
else:
|
||||||
collection = client.get_collection(collection_name)
|
collection = client.get_collection(collection_name)
|
||||||
return ChromaCollection(collection)
|
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)
|
||||||
|
|||||||
+10
-11
@@ -172,8 +172,8 @@ def cmd_status(args):
|
|||||||
|
|
||||||
def cmd_repair(args):
|
def cmd_repair(args):
|
||||||
"""Rebuild palace vector index from SQLite metadata."""
|
"""Rebuild palace vector index from SQLite metadata."""
|
||||||
import chromadb
|
|
||||||
import shutil
|
import shutil
|
||||||
|
from .backends.chroma import ChromaBackend
|
||||||
from .migrate import confirm_destructive_action, contains_palace_database
|
from .migrate import confirm_destructive_action, contains_palace_database
|
||||||
|
|
||||||
palace_path = os.path.abspath(
|
palace_path = os.path.abspath(
|
||||||
@@ -193,10 +193,11 @@ def cmd_repair(args):
|
|||||||
print(f"{'=' * 55}\n")
|
print(f"{'=' * 55}\n")
|
||||||
print(f" Palace: {palace_path}")
|
print(f" Palace: {palace_path}")
|
||||||
|
|
||||||
|
backend = ChromaBackend()
|
||||||
|
|
||||||
# Try to read existing drawers
|
# Try to read existing drawers
|
||||||
try:
|
try:
|
||||||
client = chromadb.PersistentClient(path=palace_path)
|
col = backend.get_collection(palace_path, "mempalace_drawers")
|
||||||
col = client.get_collection("mempalace_drawers")
|
|
||||||
total = col.count()
|
total = col.count()
|
||||||
print(f" Drawers found: {total}")
|
print(f" Drawers found: {total}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -243,8 +244,8 @@ def cmd_repair(args):
|
|||||||
shutil.copytree(palace_path, backup_path)
|
shutil.copytree(palace_path, backup_path)
|
||||||
|
|
||||||
print(" Rebuilding collection...")
|
print(" Rebuilding collection...")
|
||||||
client.delete_collection("mempalace_drawers")
|
backend.delete_collection(palace_path, "mempalace_drawers")
|
||||||
new_col = client.create_collection("mempalace_drawers", metadata={"hnsw:space": "cosine"})
|
new_col = backend.create_collection(palace_path, "mempalace_drawers")
|
||||||
|
|
||||||
filed = 0
|
filed = 0
|
||||||
for i in range(0, len(all_ids), batch_size):
|
for i in range(0, len(all_ids), batch_size):
|
||||||
@@ -297,7 +298,7 @@ def cmd_mcp(args):
|
|||||||
|
|
||||||
def cmd_compress(args):
|
def cmd_compress(args):
|
||||||
"""Compress drawers in a wing using AAAK Dialect."""
|
"""Compress drawers in a wing using AAAK Dialect."""
|
||||||
import chromadb
|
from .backends.chroma import ChromaBackend
|
||||||
from .dialect import Dialect
|
from .dialect import Dialect
|
||||||
|
|
||||||
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
|
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
|
||||||
@@ -317,9 +318,9 @@ def cmd_compress(args):
|
|||||||
dialect = Dialect()
|
dialect = Dialect()
|
||||||
|
|
||||||
# Connect to palace
|
# Connect to palace
|
||||||
|
backend = ChromaBackend()
|
||||||
try:
|
try:
|
||||||
client = chromadb.PersistentClient(path=palace_path)
|
col = backend.get_collection(palace_path, "mempalace_drawers")
|
||||||
col = client.get_collection("mempalace_drawers")
|
|
||||||
except Exception:
|
except Exception:
|
||||||
print(f"\n No palace found at {palace_path}")
|
print(f"\n No palace found at {palace_path}")
|
||||||
print(" Run: mempalace init <dir> then mempalace mine <dir>")
|
print(" Run: mempalace init <dir> then mempalace mine <dir>")
|
||||||
@@ -394,9 +395,7 @@ def cmd_compress(args):
|
|||||||
# Store compressed versions (unless dry-run)
|
# Store compressed versions (unless dry-run)
|
||||||
if not args.dry_run:
|
if not args.dry_run:
|
||||||
try:
|
try:
|
||||||
comp_col = client.get_or_create_collection(
|
comp_col = backend.get_or_create_collection(palace_path, "mempalace_compressed")
|
||||||
"mempalace_compressed", metadata={"hnsw:space": "cosine"}
|
|
||||||
)
|
|
||||||
for doc_id, compressed, meta, stats in compressed_entries:
|
for doc_id, compressed, meta, stats in compressed_entries:
|
||||||
comp_meta = dict(meta)
|
comp_meta = dict(meta)
|
||||||
comp_meta["compression_ratio"] = round(stats["size_ratio"], 1)
|
comp_meta["compression_ratio"] = round(stats["size_ratio"], 1)
|
||||||
|
|||||||
+3
-5
@@ -27,7 +27,7 @@ import os
|
|||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
import chromadb
|
from .backends.chroma import ChromaBackend
|
||||||
|
|
||||||
|
|
||||||
COLLECTION_NAME = "mempalace_drawers"
|
COLLECTION_NAME = "mempalace_drawers"
|
||||||
@@ -130,8 +130,7 @@ def dedup_source_group(col, drawer_ids, threshold=DEFAULT_THRESHOLD, dry_run=Tru
|
|||||||
def show_stats(palace_path=None):
|
def show_stats(palace_path=None):
|
||||||
"""Show duplication statistics without making changes."""
|
"""Show duplication statistics without making changes."""
|
||||||
palace_path = palace_path or _get_palace_path()
|
palace_path = palace_path or _get_palace_path()
|
||||||
client = chromadb.PersistentClient(path=palace_path)
|
col = ChromaBackend().get_collection(palace_path, COLLECTION_NAME)
|
||||||
col = client.get_collection(COLLECTION_NAME)
|
|
||||||
|
|
||||||
groups = get_source_groups(col)
|
groups = get_source_groups(col)
|
||||||
|
|
||||||
@@ -163,8 +162,7 @@ def dedup_palace(
|
|||||||
print(" MemPalace Deduplicator")
|
print(" MemPalace Deduplicator")
|
||||||
print(f"{'=' * 55}")
|
print(f"{'=' * 55}")
|
||||||
|
|
||||||
client = chromadb.PersistentClient(path=palace_path)
|
col = ChromaBackend().get_collection(palace_path, COLLECTION_NAME)
|
||||||
col = client.get_collection(COLLECTION_NAME)
|
|
||||||
|
|
||||||
print(f" Palace: {palace_path}")
|
print(f" Palace: {palace_path}")
|
||||||
print(f" Drawers: {col.count():,}")
|
print(f" Drawers: {col.count():,}")
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from .config import MempalaceConfig, sanitize_name, sanitize_content
|
from .config import MempalaceConfig, sanitize_name, sanitize_content
|
||||||
from .version import __version__
|
from .version import __version__
|
||||||
import chromadb
|
from .backends.chroma import ChromaBackend, ChromaCollection
|
||||||
from .query_sanitizer import sanitize_query
|
from .query_sanitizer import sanitize_query
|
||||||
from .searcher import search_memories
|
from .searcher import search_memories
|
||||||
from .palace_graph import (
|
from .palace_graph import (
|
||||||
@@ -177,7 +177,7 @@ def _get_client():
|
|||||||
mtime_changed = current_mtime != 0.0 and abs(current_mtime - _palace_db_mtime) > 0.01
|
mtime_changed = current_mtime != 0.0 and abs(current_mtime - _palace_db_mtime) > 0.01
|
||||||
|
|
||||||
if _client_cache is None or inode_changed or mtime_changed:
|
if _client_cache is None or inode_changed or mtime_changed:
|
||||||
_client_cache = chromadb.PersistentClient(path=_config.palace_path)
|
_client_cache = ChromaBackend.make_client(_config.palace_path)
|
||||||
_collection_cache = None
|
_collection_cache = None
|
||||||
_metadata_cache = None
|
_metadata_cache = None
|
||||||
_metadata_cache_time = 0
|
_metadata_cache_time = 0
|
||||||
@@ -192,13 +192,15 @@ def _get_collection(create=False):
|
|||||||
try:
|
try:
|
||||||
client = _get_client()
|
client = _get_client()
|
||||||
if create:
|
if create:
|
||||||
_collection_cache = client.get_or_create_collection(
|
_collection_cache = ChromaCollection(
|
||||||
_config.collection_name, metadata={"hnsw:space": "cosine"}
|
client.get_or_create_collection(
|
||||||
|
_config.collection_name, metadata={"hnsw:space": "cosine"}
|
||||||
|
)
|
||||||
)
|
)
|
||||||
_metadata_cache = None
|
_metadata_cache = None
|
||||||
_metadata_cache_time = 0
|
_metadata_cache_time = 0
|
||||||
elif _collection_cache is None:
|
elif _collection_cache is None:
|
||||||
_collection_cache = client.get_collection(_config.collection_name)
|
_collection_cache = ChromaCollection(client.get_collection(_config.collection_name))
|
||||||
_metadata_cache = None
|
_metadata_cache = None
|
||||||
_metadata_cache_time = 0
|
_metadata_cache_time = 0
|
||||||
return _collection_cache
|
return _collection_cache
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ def confirm_destructive_action(
|
|||||||
|
|
||||||
def migrate(palace_path: str, dry_run: bool = False, confirm: bool = False):
|
def migrate(palace_path: str, dry_run: bool = False, confirm: bool = False):
|
||||||
"""Migrate a palace to the currently installed ChromaDB version."""
|
"""Migrate a palace to the currently installed ChromaDB version."""
|
||||||
import chromadb
|
from .backends.chroma import ChromaBackend
|
||||||
|
|
||||||
palace_path = os.path.abspath(os.path.expanduser(palace_path))
|
palace_path = os.path.abspath(os.path.expanduser(palace_path))
|
||||||
db_path = os.path.join(palace_path, "chroma.sqlite3")
|
db_path = os.path.join(palace_path, "chroma.sqlite3")
|
||||||
@@ -152,19 +152,19 @@ def migrate(palace_path: str, dry_run: bool = False, confirm: bool = False):
|
|||||||
|
|
||||||
# Detect version
|
# Detect version
|
||||||
source_version = detect_chromadb_version(db_path)
|
source_version = detect_chromadb_version(db_path)
|
||||||
|
target_version = ChromaBackend.backend_version()
|
||||||
print(f" Source: ChromaDB {source_version}")
|
print(f" Source: ChromaDB {source_version}")
|
||||||
print(f" Target: ChromaDB {chromadb.__version__}")
|
print(f" Target: ChromaDB {target_version}")
|
||||||
|
|
||||||
# Try reading with current chromadb first
|
# Try reading with current chromadb first
|
||||||
try:
|
try:
|
||||||
client = chromadb.PersistentClient(path=palace_path)
|
col = ChromaBackend().get_collection(palace_path, "mempalace_drawers")
|
||||||
col = client.get_collection("mempalace_drawers")
|
|
||||||
count = col.count()
|
count = col.count()
|
||||||
print(f"\n Palace is already readable by chromadb {chromadb.__version__}.")
|
print(f"\n Palace is already readable by chromadb {target_version}.")
|
||||||
print(f" {count} drawers found. No migration needed.")
|
print(f" {count} drawers found. No migration needed.")
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
print(f"\n Palace is NOT readable by chromadb {chromadb.__version__}.")
|
print(f"\n Palace is NOT readable by chromadb {target_version}.")
|
||||||
print(" Extracting from SQLite directly...")
|
print(" Extracting from SQLite directly...")
|
||||||
|
|
||||||
# Extract all drawers via raw SQL
|
# Extract all drawers via raw SQL
|
||||||
@@ -208,8 +208,8 @@ def migrate(palace_path: str, dry_run: bool = False, confirm: bool = False):
|
|||||||
|
|
||||||
temp_palace = tempfile.mkdtemp(prefix="mempalace_migrate_")
|
temp_palace = tempfile.mkdtemp(prefix="mempalace_migrate_")
|
||||||
print(f" Creating fresh palace in {temp_palace}...")
|
print(f" Creating fresh palace in {temp_palace}...")
|
||||||
client = chromadb.PersistentClient(path=temp_palace)
|
fresh_backend = ChromaBackend()
|
||||||
col = client.get_or_create_collection("mempalace_drawers", metadata={"hnsw:space": "cosine"})
|
col = fresh_backend.get_or_create_collection(temp_palace, "mempalace_drawers")
|
||||||
|
|
||||||
# Re-import in batches
|
# Re-import in batches
|
||||||
batch_size = 500
|
batch_size = 500
|
||||||
@@ -227,7 +227,7 @@ def migrate(palace_path: str, dry_run: bool = False, confirm: bool = False):
|
|||||||
# Verify before swapping
|
# Verify before swapping
|
||||||
final_count = col.count()
|
final_count = col.count()
|
||||||
del col
|
del col
|
||||||
del client
|
del fresh_backend
|
||||||
|
|
||||||
# Swap: remove old palace, move new one into place
|
# Swap: remove old palace, move new one into place
|
||||||
print(" Swapping old palace for migrated version...")
|
print(" Swapping old palace for migrated version...")
|
||||||
|
|||||||
+7
-9
@@ -32,7 +32,7 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import chromadb
|
from .backends.chroma import ChromaBackend
|
||||||
|
|
||||||
|
|
||||||
COLLECTION_NAME = "mempalace_drawers"
|
COLLECTION_NAME = "mempalace_drawers"
|
||||||
@@ -90,8 +90,7 @@ def scan_palace(palace_path=None, only_wing=None):
|
|||||||
print(f"\n Palace: {palace_path}")
|
print(f"\n Palace: {palace_path}")
|
||||||
print(" Loading...")
|
print(" Loading...")
|
||||||
|
|
||||||
client = chromadb.PersistentClient(path=palace_path)
|
col = ChromaBackend().get_collection(palace_path, COLLECTION_NAME)
|
||||||
col = client.get_collection(COLLECTION_NAME)
|
|
||||||
|
|
||||||
where = {"wing": only_wing} if only_wing else None
|
where = {"wing": only_wing} if only_wing else None
|
||||||
total = col.count()
|
total = col.count()
|
||||||
@@ -174,8 +173,7 @@ def prune_corrupt(palace_path=None, confirm=False):
|
|||||||
print(" Re-run with --confirm to actually delete.")
|
print(" Re-run with --confirm to actually delete.")
|
||||||
return
|
return
|
||||||
|
|
||||||
client = chromadb.PersistentClient(path=palace_path)
|
col = ChromaBackend().get_collection(palace_path, COLLECTION_NAME)
|
||||||
col = client.get_collection(COLLECTION_NAME)
|
|
||||||
before = col.count()
|
before = col.count()
|
||||||
print(f" Collection size before: {before:,}")
|
print(f" Collection size before: {before:,}")
|
||||||
|
|
||||||
@@ -222,9 +220,9 @@ def rebuild_index(palace_path=None):
|
|||||||
print(f"{'=' * 55}\n")
|
print(f"{'=' * 55}\n")
|
||||||
print(f" Palace: {palace_path}")
|
print(f" Palace: {palace_path}")
|
||||||
|
|
||||||
client = chromadb.PersistentClient(path=palace_path)
|
backend = ChromaBackend()
|
||||||
try:
|
try:
|
||||||
col = client.get_collection(COLLECTION_NAME)
|
col = backend.get_collection(palace_path, COLLECTION_NAME)
|
||||||
total = col.count()
|
total = col.count()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" Error reading palace: {e}")
|
print(f" Error reading palace: {e}")
|
||||||
@@ -264,8 +262,8 @@ def rebuild_index(palace_path=None):
|
|||||||
|
|
||||||
# Rebuild with correct HNSW settings
|
# Rebuild with correct HNSW settings
|
||||||
print(" Rebuilding collection with hnsw:space=cosine...")
|
print(" Rebuilding collection with hnsw:space=cosine...")
|
||||||
client.delete_collection(COLLECTION_NAME)
|
backend.delete_collection(palace_path, COLLECTION_NAME)
|
||||||
new_col = client.create_collection(COLLECTION_NAME, metadata={"hnsw:space": "cosine"})
|
new_col = backend.create_collection(palace_path, COLLECTION_NAME)
|
||||||
|
|
||||||
filed = 0
|
filed = 0
|
||||||
for i in range(0, len(all_ids), batch_size):
|
for i in range(0, len(all_ids), batch_size):
|
||||||
|
|||||||
+3
-3
@@ -30,9 +30,9 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://github.com/milla-jovovich/mempalace"
|
Homepage = "https://github.com/MemPalace/mempalace"
|
||||||
Repository = "https://github.com/milla-jovovich/mempalace"
|
Repository = "https://github.com/MemPalace/mempalace"
|
||||||
"Bug Tracker" = "https://github.com/milla-jovovich/mempalace/issues"
|
"Bug Tracker" = "https://github.com/MemPalace/mempalace/issues"
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
mempalace = "mempalace.cli:main"
|
mempalace = "mempalace.cli:main"
|
||||||
|
|||||||
+41
-65
@@ -412,12 +412,21 @@ def test_main_compress_dispatches():
|
|||||||
# ── cmd_repair ─────────────────────────────────────────────────────────
|
# ── 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")
|
@patch("mempalace.cli.MempalaceConfig")
|
||||||
def test_cmd_repair_no_palace(mock_config_cls, tmp_path, capsys):
|
def test_cmd_repair_no_palace(mock_config_cls, tmp_path, capsys):
|
||||||
mock_config_cls.return_value.palace_path = str(tmp_path / "nonexistent")
|
mock_config_cls.return_value.palace_path = str(tmp_path / "nonexistent")
|
||||||
args = argparse.Namespace(palace=None)
|
args = argparse.Namespace(palace=None)
|
||||||
mock_chromadb = MagicMock()
|
with patch("mempalace.backends.chroma.ChromaBackend"):
|
||||||
with patch.dict("sys.modules", {"chromadb": mock_chromadb}):
|
|
||||||
cmd_repair(args)
|
cmd_repair(args)
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "No palace found" in out
|
assert "No palace found" in out
|
||||||
@@ -429,8 +438,7 @@ def test_cmd_repair_requires_palace_database(mock_config_cls, tmp_path, capsys):
|
|||||||
palace_dir.mkdir()
|
palace_dir.mkdir()
|
||||||
mock_config_cls.return_value.palace_path = str(palace_dir)
|
mock_config_cls.return_value.palace_path = str(palace_dir)
|
||||||
args = argparse.Namespace(palace=None)
|
args = argparse.Namespace(palace=None)
|
||||||
mock_chromadb = MagicMock()
|
with patch("mempalace.backends.chroma.ChromaBackend"):
|
||||||
with patch.dict("sys.modules", {"chromadb": mock_chromadb}):
|
|
||||||
cmd_repair(args)
|
cmd_repair(args)
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "No palace database found" in out
|
assert "No palace database found" in out
|
||||||
@@ -443,11 +451,9 @@ def test_cmd_repair_error_reading(mock_config_cls, tmp_path, capsys):
|
|||||||
(palace_dir / "chroma.sqlite3").write_text("db")
|
(palace_dir / "chroma.sqlite3").write_text("db")
|
||||||
mock_config_cls.return_value.palace_path = str(palace_dir)
|
mock_config_cls.return_value.palace_path = str(palace_dir)
|
||||||
args = argparse.Namespace(palace=None)
|
args = argparse.Namespace(palace=None)
|
||||||
mock_chromadb = MagicMock()
|
mock_backend = MagicMock()
|
||||||
mock_client = MagicMock()
|
mock_backend.get_collection.side_effect = Exception("corrupt db")
|
||||||
mock_client.get_collection.side_effect = Exception("corrupt db")
|
with patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend):
|
||||||
mock_chromadb.PersistentClient.return_value = mock_client
|
|
||||||
with patch.dict("sys.modules", {"chromadb": mock_chromadb}):
|
|
||||||
cmd_repair(args)
|
cmd_repair(args)
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "Error reading palace" in out
|
assert "Error reading palace" in out
|
||||||
@@ -460,13 +466,10 @@ def test_cmd_repair_zero_drawers(mock_config_cls, tmp_path, capsys):
|
|||||||
(palace_dir / "chroma.sqlite3").write_text("db")
|
(palace_dir / "chroma.sqlite3").write_text("db")
|
||||||
mock_config_cls.return_value.palace_path = str(palace_dir)
|
mock_config_cls.return_value.palace_path = str(palace_dir)
|
||||||
args = argparse.Namespace(palace=None)
|
args = argparse.Namespace(palace=None)
|
||||||
mock_chromadb = MagicMock()
|
|
||||||
mock_col = MagicMock()
|
mock_col = MagicMock()
|
||||||
mock_col.count.return_value = 0
|
mock_col.count.return_value = 0
|
||||||
mock_client = MagicMock()
|
mock_backend = _mock_backend_for(col=mock_col)
|
||||||
mock_client.get_collection.return_value = mock_col
|
with patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend):
|
||||||
mock_chromadb.PersistentClient.return_value = mock_client
|
|
||||||
with patch.dict("sys.modules", {"chromadb": mock_chromadb}):
|
|
||||||
cmd_repair(args)
|
cmd_repair(args)
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "Nothing to repair" in out
|
assert "Nothing to repair" in out
|
||||||
@@ -479,7 +482,6 @@ def test_cmd_repair_success(mock_config_cls, tmp_path, capsys):
|
|||||||
(palace_dir / "chroma.sqlite3").write_text("db")
|
(palace_dir / "chroma.sqlite3").write_text("db")
|
||||||
mock_config_cls.return_value.palace_path = str(palace_dir)
|
mock_config_cls.return_value.palace_path = str(palace_dir)
|
||||||
args = argparse.Namespace(palace=None, yes=True)
|
args = argparse.Namespace(palace=None, yes=True)
|
||||||
mock_chromadb = MagicMock()
|
|
||||||
mock_col = MagicMock()
|
mock_col = MagicMock()
|
||||||
mock_col.count.return_value = 2
|
mock_col.count.return_value = 2
|
||||||
mock_col.get.return_value = {
|
mock_col.get.return_value = {
|
||||||
@@ -487,12 +489,9 @@ def test_cmd_repair_success(mock_config_cls, tmp_path, capsys):
|
|||||||
"documents": ["doc1", "doc2"],
|
"documents": ["doc1", "doc2"],
|
||||||
"metadatas": [{"wing": "a"}, {"wing": "b"}],
|
"metadatas": [{"wing": "a"}, {"wing": "b"}],
|
||||||
}
|
}
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.get_collection.return_value = mock_col
|
|
||||||
mock_new_col = MagicMock()
|
mock_new_col = MagicMock()
|
||||||
mock_client.create_collection.return_value = mock_new_col
|
mock_backend = _mock_backend_for(col=mock_col, new_col=mock_new_col)
|
||||||
mock_chromadb.PersistentClient.return_value = mock_client
|
with patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend):
|
||||||
with patch.dict("sys.modules", {"chromadb": mock_chromadb}):
|
|
||||||
cmd_repair(args)
|
cmd_repair(args)
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "Repair complete" in out
|
assert "Repair complete" in out
|
||||||
@@ -506,20 +505,17 @@ def test_cmd_repair_aborts_without_confirmation(mock_config_cls, tmp_path, capsy
|
|||||||
(palace_dir / "chroma.sqlite3").write_text("db")
|
(palace_dir / "chroma.sqlite3").write_text("db")
|
||||||
mock_config_cls.return_value.palace_path = str(palace_dir)
|
mock_config_cls.return_value.palace_path = str(palace_dir)
|
||||||
args = argparse.Namespace(palace=None)
|
args = argparse.Namespace(palace=None)
|
||||||
mock_chromadb = MagicMock()
|
|
||||||
mock_col = MagicMock()
|
mock_col = MagicMock()
|
||||||
mock_col.count.return_value = 1
|
mock_col.count.return_value = 1
|
||||||
mock_client = MagicMock()
|
mock_backend = _mock_backend_for(col=mock_col)
|
||||||
mock_client.get_collection.return_value = mock_col
|
|
||||||
mock_chromadb.PersistentClient.return_value = mock_client
|
|
||||||
with (
|
with (
|
||||||
patch.dict("sys.modules", {"chromadb": mock_chromadb}),
|
patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend),
|
||||||
patch("builtins.input", return_value="n"),
|
patch("builtins.input", return_value="n"),
|
||||||
):
|
):
|
||||||
cmd_repair(args)
|
cmd_repair(args)
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "Aborted." in out
|
assert "Aborted." in out
|
||||||
mock_client.create_collection.assert_not_called()
|
mock_backend.create_collection.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
# ── cmd_compress ───────────────────────────────────────────────────────
|
# ── cmd_compress ───────────────────────────────────────────────────────
|
||||||
@@ -529,10 +525,10 @@ def test_cmd_repair_aborts_without_confirmation(mock_config_cls, tmp_path, capsy
|
|||||||
def test_cmd_compress_no_palace(mock_config_cls, capsys):
|
def test_cmd_compress_no_palace(mock_config_cls, capsys):
|
||||||
mock_config_cls.return_value.palace_path = "/fake/palace"
|
mock_config_cls.return_value.palace_path = "/fake/palace"
|
||||||
args = argparse.Namespace(palace=None, wing=None, dry_run=False, config=None)
|
args = argparse.Namespace(palace=None, wing=None, dry_run=False, config=None)
|
||||||
mock_chromadb = MagicMock()
|
mock_backend = MagicMock()
|
||||||
mock_chromadb.PersistentClient.side_effect = Exception("no palace")
|
mock_backend.get_collection.side_effect = Exception("no palace")
|
||||||
with (
|
with (
|
||||||
patch.dict("sys.modules", {"chromadb": mock_chromadb}),
|
patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend),
|
||||||
pytest.raises(SystemExit),
|
pytest.raises(SystemExit),
|
||||||
):
|
):
|
||||||
cmd_compress(args)
|
cmd_compress(args)
|
||||||
@@ -542,13 +538,10 @@ def test_cmd_compress_no_palace(mock_config_cls, capsys):
|
|||||||
def test_cmd_compress_no_drawers(mock_config_cls, capsys):
|
def test_cmd_compress_no_drawers(mock_config_cls, capsys):
|
||||||
mock_config_cls.return_value.palace_path = "/fake/palace"
|
mock_config_cls.return_value.palace_path = "/fake/palace"
|
||||||
args = argparse.Namespace(palace=None, wing="mywing", dry_run=False, config=None)
|
args = argparse.Namespace(palace=None, wing="mywing", dry_run=False, config=None)
|
||||||
mock_chromadb = MagicMock()
|
|
||||||
mock_col = MagicMock()
|
mock_col = MagicMock()
|
||||||
mock_col.get.return_value = {"documents": [], "metadatas": [], "ids": []}
|
mock_col.get.return_value = {"documents": [], "metadatas": [], "ids": []}
|
||||||
mock_client = MagicMock()
|
mock_backend = _mock_backend_for(col=mock_col)
|
||||||
mock_client.get_collection.return_value = mock_col
|
with patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend):
|
||||||
mock_chromadb.PersistentClient.return_value = mock_client
|
|
||||||
with patch.dict("sys.modules", {"chromadb": mock_chromadb}):
|
|
||||||
cmd_compress(args)
|
cmd_compress(args)
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "No drawers found" in out
|
assert "No drawers found" in out
|
||||||
@@ -567,7 +560,6 @@ def _make_mock_dialect_module(dialect_instance):
|
|||||||
def test_cmd_compress_dry_run(mock_config_cls, capsys):
|
def test_cmd_compress_dry_run(mock_config_cls, capsys):
|
||||||
mock_config_cls.return_value.palace_path = "/fake/palace"
|
mock_config_cls.return_value.palace_path = "/fake/palace"
|
||||||
args = argparse.Namespace(palace=None, wing=None, dry_run=True, config=None)
|
args = argparse.Namespace(palace=None, wing=None, dry_run=True, config=None)
|
||||||
mock_chromadb = MagicMock()
|
|
||||||
mock_col = MagicMock()
|
mock_col = MagicMock()
|
||||||
mock_col.get.side_effect = [
|
mock_col.get.side_effect = [
|
||||||
{
|
{
|
||||||
@@ -577,9 +569,7 @@ def test_cmd_compress_dry_run(mock_config_cls, capsys):
|
|||||||
},
|
},
|
||||||
{"documents": [], "metadatas": [], "ids": []},
|
{"documents": [], "metadatas": [], "ids": []},
|
||||||
]
|
]
|
||||||
mock_client = MagicMock()
|
mock_backend = _mock_backend_for(col=mock_col)
|
||||||
mock_client.get_collection.return_value = mock_col
|
|
||||||
mock_chromadb.PersistentClient.return_value = mock_client
|
|
||||||
|
|
||||||
mock_dialect = MagicMock()
|
mock_dialect = MagicMock()
|
||||||
mock_dialect.compress.return_value = "compressed"
|
mock_dialect.compress.return_value = "compressed"
|
||||||
@@ -593,12 +583,9 @@ def test_cmd_compress_dry_run(mock_config_cls, capsys):
|
|||||||
}
|
}
|
||||||
mock_dialect_mod = _make_mock_dialect_module(mock_dialect)
|
mock_dialect_mod = _make_mock_dialect_module(mock_dialect)
|
||||||
|
|
||||||
with patch.dict(
|
with (
|
||||||
"sys.modules",
|
patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend),
|
||||||
{
|
patch.dict("sys.modules", {"mempalace.dialect": mock_dialect_mod}),
|
||||||
"chromadb": mock_chromadb,
|
|
||||||
"mempalace.dialect": mock_dialect_mod,
|
|
||||||
},
|
|
||||||
):
|
):
|
||||||
cmd_compress(args)
|
cmd_compress(args)
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
@@ -613,22 +600,16 @@ def test_cmd_compress_with_config(mock_config_cls, tmp_path, capsys):
|
|||||||
config_file = tmp_path / "entities.json"
|
config_file = tmp_path / "entities.json"
|
||||||
config_file.write_text('{"people": [], "projects": []}')
|
config_file.write_text('{"people": [], "projects": []}')
|
||||||
args = argparse.Namespace(palace=None, wing=None, dry_run=True, config=str(config_file))
|
args = argparse.Namespace(palace=None, wing=None, dry_run=True, config=str(config_file))
|
||||||
mock_chromadb = MagicMock()
|
|
||||||
mock_col = MagicMock()
|
mock_col = MagicMock()
|
||||||
mock_col.get.return_value = {"documents": [], "metadatas": [], "ids": []}
|
mock_col.get.return_value = {"documents": [], "metadatas": [], "ids": []}
|
||||||
mock_client = MagicMock()
|
mock_backend = _mock_backend_for(col=mock_col)
|
||||||
mock_client.get_collection.return_value = mock_col
|
|
||||||
mock_chromadb.PersistentClient.return_value = mock_client
|
|
||||||
|
|
||||||
mock_dialect = MagicMock()
|
mock_dialect = MagicMock()
|
||||||
mock_dialect_mod = _make_mock_dialect_module(mock_dialect)
|
mock_dialect_mod = _make_mock_dialect_module(mock_dialect)
|
||||||
|
|
||||||
with patch.dict(
|
with (
|
||||||
"sys.modules",
|
patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend),
|
||||||
{
|
patch.dict("sys.modules", {"mempalace.dialect": mock_dialect_mod}),
|
||||||
"chromadb": mock_chromadb,
|
|
||||||
"mempalace.dialect": mock_dialect_mod,
|
|
||||||
},
|
|
||||||
):
|
):
|
||||||
cmd_compress(args)
|
cmd_compress(args)
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
@@ -640,7 +621,6 @@ def test_cmd_compress_stores_results(mock_config_cls, capsys):
|
|||||||
"""Non-dry-run compress stores to mempalace_compressed collection."""
|
"""Non-dry-run compress stores to mempalace_compressed collection."""
|
||||||
mock_config_cls.return_value.palace_path = "/fake/palace"
|
mock_config_cls.return_value.palace_path = "/fake/palace"
|
||||||
args = argparse.Namespace(palace=None, wing=None, dry_run=False, config=None)
|
args = argparse.Namespace(palace=None, wing=None, dry_run=False, config=None)
|
||||||
mock_chromadb = MagicMock()
|
|
||||||
mock_col = MagicMock()
|
mock_col = MagicMock()
|
||||||
mock_col.get.side_effect = [
|
mock_col.get.side_effect = [
|
||||||
{
|
{
|
||||||
@@ -650,11 +630,10 @@ def test_cmd_compress_stores_results(mock_config_cls, capsys):
|
|||||||
},
|
},
|
||||||
{"documents": [], "metadatas": [], "ids": []},
|
{"documents": [], "metadatas": [], "ids": []},
|
||||||
]
|
]
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.get_collection.return_value = mock_col
|
|
||||||
mock_comp_col = MagicMock()
|
mock_comp_col = MagicMock()
|
||||||
mock_client.get_or_create_collection.return_value = mock_comp_col
|
mock_backend = MagicMock()
|
||||||
mock_chromadb.PersistentClient.return_value = mock_client
|
mock_backend.get_collection.return_value = mock_col
|
||||||
|
mock_backend.get_or_create_collection.return_value = mock_comp_col
|
||||||
|
|
||||||
mock_dialect = MagicMock()
|
mock_dialect = MagicMock()
|
||||||
mock_dialect.compress.return_value = "compressed"
|
mock_dialect.compress.return_value = "compressed"
|
||||||
@@ -668,12 +647,9 @@ def test_cmd_compress_stores_results(mock_config_cls, capsys):
|
|||||||
}
|
}
|
||||||
mock_dialect_mod = _make_mock_dialect_module(mock_dialect)
|
mock_dialect_mod = _make_mock_dialect_module(mock_dialect)
|
||||||
|
|
||||||
with patch.dict(
|
with (
|
||||||
"sys.modules",
|
patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend),
|
||||||
{
|
patch.dict("sys.modules", {"mempalace.dialect": mock_dialect_mod}),
|
||||||
"chromadb": mock_chromadb,
|
|
||||||
"mempalace.dialect": mock_dialect_mod,
|
|
||||||
},
|
|
||||||
):
|
):
|
||||||
cmd_compress(args)
|
cmd_compress(args)
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
|
|||||||
+19
-20
@@ -198,8 +198,15 @@ def test_dedup_source_group_query_failure_keeps():
|
|||||||
# ── show_stats ────────────────────────────────────────────────────────
|
# ── show_stats ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@patch("mempalace.dedup.chromadb")
|
def _install_mock_backend(mock_backend_cls, collection):
|
||||||
def test_show_stats(mock_chromadb, tmp_path):
|
mock_backend = MagicMock()
|
||||||
|
mock_backend.get_collection.return_value = collection
|
||||||
|
mock_backend_cls.return_value = mock_backend
|
||||||
|
return mock_backend
|
||||||
|
|
||||||
|
|
||||||
|
@patch("mempalace.dedup.ChromaBackend")
|
||||||
|
def test_show_stats(mock_backend_cls, tmp_path):
|
||||||
mock_col = MagicMock()
|
mock_col = MagicMock()
|
||||||
mock_col.count.return_value = 5
|
mock_col.count.return_value = 5
|
||||||
mock_col.get.side_effect = [
|
mock_col.get.side_effect = [
|
||||||
@@ -215,9 +222,7 @@ def test_show_stats(mock_chromadb, tmp_path):
|
|||||||
},
|
},
|
||||||
{"ids": []},
|
{"ids": []},
|
||||||
]
|
]
|
||||||
mock_client = MagicMock()
|
_install_mock_backend(mock_backend_cls, mock_col)
|
||||||
mock_client.get_collection.return_value = mock_col
|
|
||||||
mock_chromadb.PersistentClient.return_value = mock_client
|
|
||||||
|
|
||||||
dedup.show_stats(palace_path=str(tmp_path)) # should not raise
|
dedup.show_stats(palace_path=str(tmp_path)) # should not raise
|
||||||
|
|
||||||
@@ -227,13 +232,11 @@ def test_show_stats(mock_chromadb, tmp_path):
|
|||||||
|
|
||||||
@patch("mempalace.dedup.dedup_source_group")
|
@patch("mempalace.dedup.dedup_source_group")
|
||||||
@patch("mempalace.dedup.get_source_groups")
|
@patch("mempalace.dedup.get_source_groups")
|
||||||
@patch("mempalace.dedup.chromadb")
|
@patch("mempalace.dedup.ChromaBackend")
|
||||||
def test_dedup_palace_dry_run(mock_chromadb, mock_groups, mock_dedup_group, tmp_path):
|
def test_dedup_palace_dry_run(mock_backend_cls, mock_groups, mock_dedup_group, tmp_path):
|
||||||
mock_col = MagicMock()
|
mock_col = MagicMock()
|
||||||
mock_col.count.return_value = 10
|
mock_col.count.return_value = 10
|
||||||
mock_client = MagicMock()
|
_install_mock_backend(mock_backend_cls, mock_col)
|
||||||
mock_client.get_collection.return_value = mock_col
|
|
||||||
mock_chromadb.PersistentClient.return_value = mock_client
|
|
||||||
|
|
||||||
mock_groups.return_value = {"a.txt": ["d1", "d2", "d3", "d4", "d5"]}
|
mock_groups.return_value = {"a.txt": ["d1", "d2", "d3", "d4", "d5"]}
|
||||||
mock_dedup_group.return_value = (["d1", "d2", "d3"], ["d4", "d5"])
|
mock_dedup_group.return_value = (["d1", "d2", "d3"], ["d4", "d5"])
|
||||||
@@ -244,13 +247,11 @@ def test_dedup_palace_dry_run(mock_chromadb, mock_groups, mock_dedup_group, tmp_
|
|||||||
|
|
||||||
@patch("mempalace.dedup.dedup_source_group")
|
@patch("mempalace.dedup.dedup_source_group")
|
||||||
@patch("mempalace.dedup.get_source_groups")
|
@patch("mempalace.dedup.get_source_groups")
|
||||||
@patch("mempalace.dedup.chromadb")
|
@patch("mempalace.dedup.ChromaBackend")
|
||||||
def test_dedup_palace_with_wing(mock_chromadb, mock_groups, mock_dedup_group, tmp_path):
|
def test_dedup_palace_with_wing(mock_backend_cls, mock_groups, mock_dedup_group, tmp_path):
|
||||||
mock_col = MagicMock()
|
mock_col = MagicMock()
|
||||||
mock_col.count.return_value = 10
|
mock_col.count.return_value = 10
|
||||||
mock_client = MagicMock()
|
_install_mock_backend(mock_backend_cls, mock_col)
|
||||||
mock_client.get_collection.return_value = mock_col
|
|
||||||
mock_chromadb.PersistentClient.return_value = mock_client
|
|
||||||
|
|
||||||
mock_groups.return_value = {}
|
mock_groups.return_value = {}
|
||||||
dedup.dedup_palace(palace_path=str(tmp_path), wing="test_wing", dry_run=True)
|
dedup.dedup_palace(palace_path=str(tmp_path), wing="test_wing", dry_run=True)
|
||||||
@@ -259,13 +260,11 @@ def test_dedup_palace_with_wing(mock_chromadb, mock_groups, mock_dedup_group, tm
|
|||||||
|
|
||||||
@patch("mempalace.dedup.dedup_source_group")
|
@patch("mempalace.dedup.dedup_source_group")
|
||||||
@patch("mempalace.dedup.get_source_groups")
|
@patch("mempalace.dedup.get_source_groups")
|
||||||
@patch("mempalace.dedup.chromadb")
|
@patch("mempalace.dedup.ChromaBackend")
|
||||||
def test_dedup_palace_no_groups(mock_chromadb, mock_groups, mock_dedup_group, tmp_path):
|
def test_dedup_palace_no_groups(mock_backend_cls, mock_groups, mock_dedup_group, tmp_path):
|
||||||
mock_col = MagicMock()
|
mock_col = MagicMock()
|
||||||
mock_col.count.return_value = 3
|
mock_col.count.return_value = 3
|
||||||
mock_client = MagicMock()
|
_install_mock_backend(mock_backend_cls, mock_col)
|
||||||
mock_client.get_collection.return_value = mock_col
|
|
||||||
mock_chromadb.PersistentClient.return_value = mock_client
|
|
||||||
|
|
||||||
mock_groups.return_value = {}
|
mock_groups.return_value = {}
|
||||||
dedup.dedup_palace(palace_path=str(tmp_path), dry_run=True)
|
dedup.dedup_palace(palace_path=str(tmp_path), dry_run=True)
|
||||||
|
|||||||
+52
-62
@@ -66,22 +66,28 @@ def test_paginate_ids_offset_exception_fallback():
|
|||||||
# ── scan_palace ───────────────────────────────────────────────────────
|
# ── scan_palace ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@patch("mempalace.repair.chromadb")
|
def _install_mock_backend(mock_backend_cls, collection):
|
||||||
def test_scan_palace_no_ids(mock_chromadb, tmp_path):
|
"""Wire mock_backend_cls so ChromaBackend().get_collection(...) returns *collection*."""
|
||||||
|
mock_backend = MagicMock()
|
||||||
|
mock_backend.get_collection.return_value = collection
|
||||||
|
mock_backend_cls.return_value = mock_backend
|
||||||
|
return mock_backend
|
||||||
|
|
||||||
|
|
||||||
|
@patch("mempalace.repair.ChromaBackend")
|
||||||
|
def test_scan_palace_no_ids(mock_backend_cls, tmp_path):
|
||||||
mock_col = MagicMock()
|
mock_col = MagicMock()
|
||||||
mock_col.count.return_value = 0
|
mock_col.count.return_value = 0
|
||||||
mock_col.get.return_value = {"ids": []}
|
mock_col.get.return_value = {"ids": []}
|
||||||
mock_client = MagicMock()
|
_install_mock_backend(mock_backend_cls, mock_col)
|
||||||
mock_client.get_collection.return_value = mock_col
|
|
||||||
mock_chromadb.PersistentClient.return_value = mock_client
|
|
||||||
|
|
||||||
good, bad = repair.scan_palace(palace_path=str(tmp_path))
|
good, bad = repair.scan_palace(palace_path=str(tmp_path))
|
||||||
assert good == set()
|
assert good == set()
|
||||||
assert bad == set()
|
assert bad == set()
|
||||||
|
|
||||||
|
|
||||||
@patch("mempalace.repair.chromadb")
|
@patch("mempalace.repair.ChromaBackend")
|
||||||
def test_scan_palace_all_good(mock_chromadb, tmp_path):
|
def test_scan_palace_all_good(mock_backend_cls, tmp_path):
|
||||||
mock_col = MagicMock()
|
mock_col = MagicMock()
|
||||||
mock_col.count.return_value = 2
|
mock_col.count.return_value = 2
|
||||||
# _paginate_ids call
|
# _paginate_ids call
|
||||||
@@ -89,9 +95,7 @@ def test_scan_palace_all_good(mock_chromadb, tmp_path):
|
|||||||
{"ids": ["id1", "id2"]}, # paginate
|
{"ids": ["id1", "id2"]}, # paginate
|
||||||
{"ids": ["id1", "id2"]}, # probe batch — both returned
|
{"ids": ["id1", "id2"]}, # probe batch — both returned
|
||||||
]
|
]
|
||||||
mock_client = MagicMock()
|
_install_mock_backend(mock_backend_cls, mock_col)
|
||||||
mock_client.get_collection.return_value = mock_col
|
|
||||||
mock_chromadb.PersistentClient.return_value = mock_client
|
|
||||||
|
|
||||||
good, bad = repair.scan_palace(palace_path=str(tmp_path))
|
good, bad = repair.scan_palace(palace_path=str(tmp_path))
|
||||||
assert "id1" in good
|
assert "id1" in good
|
||||||
@@ -99,8 +103,8 @@ def test_scan_palace_all_good(mock_chromadb, tmp_path):
|
|||||||
assert len(bad) == 0
|
assert len(bad) == 0
|
||||||
|
|
||||||
|
|
||||||
@patch("mempalace.repair.chromadb")
|
@patch("mempalace.repair.ChromaBackend")
|
||||||
def test_scan_palace_with_bad_ids(mock_chromadb, tmp_path):
|
def test_scan_palace_with_bad_ids(mock_backend_cls, tmp_path):
|
||||||
mock_col = MagicMock()
|
mock_col = MagicMock()
|
||||||
mock_col.count.return_value = 2
|
mock_col.count.return_value = 2
|
||||||
|
|
||||||
@@ -117,26 +121,22 @@ def test_scan_palace_with_bad_ids(mock_chromadb, tmp_path):
|
|||||||
raise Exception("batch fail")
|
raise Exception("batch fail")
|
||||||
|
|
||||||
mock_col.get.side_effect = get_side_effect
|
mock_col.get.side_effect = get_side_effect
|
||||||
mock_client = MagicMock()
|
_install_mock_backend(mock_backend_cls, mock_col)
|
||||||
mock_client.get_collection.return_value = mock_col
|
|
||||||
mock_chromadb.PersistentClient.return_value = mock_client
|
|
||||||
|
|
||||||
good, bad = repair.scan_palace(palace_path=str(tmp_path))
|
good, bad = repair.scan_palace(palace_path=str(tmp_path))
|
||||||
assert "good1" in good
|
assert "good1" in good
|
||||||
assert "bad1" in bad
|
assert "bad1" in bad
|
||||||
|
|
||||||
|
|
||||||
@patch("mempalace.repair.chromadb")
|
@patch("mempalace.repair.ChromaBackend")
|
||||||
def test_scan_palace_with_wing_filter(mock_chromadb, tmp_path):
|
def test_scan_palace_with_wing_filter(mock_backend_cls, tmp_path):
|
||||||
mock_col = MagicMock()
|
mock_col = MagicMock()
|
||||||
mock_col.count.return_value = 1
|
mock_col.count.return_value = 1
|
||||||
mock_col.get.side_effect = [
|
mock_col.get.side_effect = [
|
||||||
{"ids": ["id1"]}, # paginate
|
{"ids": ["id1"]}, # paginate
|
||||||
{"ids": ["id1"]}, # probe
|
{"ids": ["id1"]}, # probe
|
||||||
]
|
]
|
||||||
mock_client = MagicMock()
|
_install_mock_backend(mock_backend_cls, mock_col)
|
||||||
mock_client.get_collection.return_value = mock_col
|
|
||||||
mock_chromadb.PersistentClient.return_value = mock_client
|
|
||||||
|
|
||||||
repair.scan_palace(palace_path=str(tmp_path), only_wing="test_wing")
|
repair.scan_palace(palace_path=str(tmp_path), only_wing="test_wing")
|
||||||
# Verify where filter was passed
|
# Verify where filter was passed
|
||||||
@@ -147,38 +147,36 @@ def test_scan_palace_with_wing_filter(mock_chromadb, tmp_path):
|
|||||||
# ── prune_corrupt ─────────────────────────────────────────────────────
|
# ── prune_corrupt ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@patch("mempalace.repair.chromadb")
|
@patch("mempalace.repair.ChromaBackend")
|
||||||
def test_prune_corrupt_no_file(mock_chromadb, tmp_path):
|
def test_prune_corrupt_no_file(mock_backend_cls, tmp_path):
|
||||||
# Should print message and return without error
|
# Should print message and return without error
|
||||||
repair.prune_corrupt(palace_path=str(tmp_path))
|
repair.prune_corrupt(palace_path=str(tmp_path))
|
||||||
|
|
||||||
|
|
||||||
@patch("mempalace.repair.chromadb")
|
@patch("mempalace.repair.ChromaBackend")
|
||||||
def test_prune_corrupt_dry_run(mock_chromadb, tmp_path):
|
def test_prune_corrupt_dry_run(mock_backend_cls, tmp_path):
|
||||||
bad_file = tmp_path / "corrupt_ids.txt"
|
bad_file = tmp_path / "corrupt_ids.txt"
|
||||||
bad_file.write_text("bad1\nbad2\n")
|
bad_file.write_text("bad1\nbad2\n")
|
||||||
repair.prune_corrupt(palace_path=str(tmp_path), confirm=False)
|
repair.prune_corrupt(palace_path=str(tmp_path), confirm=False)
|
||||||
# No chromadb calls in dry run
|
# No backend calls in dry run
|
||||||
mock_chromadb.PersistentClient.assert_not_called()
|
mock_backend_cls.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@patch("mempalace.repair.chromadb")
|
@patch("mempalace.repair.ChromaBackend")
|
||||||
def test_prune_corrupt_confirmed(mock_chromadb, tmp_path):
|
def test_prune_corrupt_confirmed(mock_backend_cls, tmp_path):
|
||||||
bad_file = tmp_path / "corrupt_ids.txt"
|
bad_file = tmp_path / "corrupt_ids.txt"
|
||||||
bad_file.write_text("bad1\nbad2\n")
|
bad_file.write_text("bad1\nbad2\n")
|
||||||
|
|
||||||
mock_col = MagicMock()
|
mock_col = MagicMock()
|
||||||
mock_col.count.side_effect = [10, 8]
|
mock_col.count.side_effect = [10, 8]
|
||||||
mock_client = MagicMock()
|
_install_mock_backend(mock_backend_cls, mock_col)
|
||||||
mock_client.get_collection.return_value = mock_col
|
|
||||||
mock_chromadb.PersistentClient.return_value = mock_client
|
|
||||||
|
|
||||||
repair.prune_corrupt(palace_path=str(tmp_path), confirm=True)
|
repair.prune_corrupt(palace_path=str(tmp_path), confirm=True)
|
||||||
mock_col.delete.assert_called_once()
|
mock_col.delete.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@patch("mempalace.repair.chromadb")
|
@patch("mempalace.repair.ChromaBackend")
|
||||||
def test_prune_corrupt_delete_failure_fallback(mock_chromadb, tmp_path):
|
def test_prune_corrupt_delete_failure_fallback(mock_backend_cls, tmp_path):
|
||||||
bad_file = tmp_path / "corrupt_ids.txt"
|
bad_file = tmp_path / "corrupt_ids.txt"
|
||||||
bad_file.write_text("bad1\nbad2\n")
|
bad_file.write_text("bad1\nbad2\n")
|
||||||
|
|
||||||
@@ -186,9 +184,7 @@ def test_prune_corrupt_delete_failure_fallback(mock_chromadb, tmp_path):
|
|||||||
mock_col.count.side_effect = [10, 8]
|
mock_col.count.side_effect = [10, 8]
|
||||||
# Batch delete fails, per-id succeeds
|
# Batch delete fails, per-id succeeds
|
||||||
mock_col.delete.side_effect = [Exception("batch fail"), None, None]
|
mock_col.delete.side_effect = [Exception("batch fail"), None, None]
|
||||||
mock_client = MagicMock()
|
_install_mock_backend(mock_backend_cls, mock_col)
|
||||||
mock_client.get_collection.return_value = mock_col
|
|
||||||
mock_chromadb.PersistentClient.return_value = mock_client
|
|
||||||
|
|
||||||
repair.prune_corrupt(palace_path=str(tmp_path), confirm=True)
|
repair.prune_corrupt(palace_path=str(tmp_path), confirm=True)
|
||||||
assert mock_col.delete.call_count == 3 # 1 batch + 2 individual
|
assert mock_col.delete.call_count == 3 # 1 batch + 2 individual
|
||||||
@@ -197,29 +193,27 @@ def test_prune_corrupt_delete_failure_fallback(mock_chromadb, tmp_path):
|
|||||||
# ── rebuild_index ─────────────────────────────────────────────────────
|
# ── rebuild_index ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@patch("mempalace.repair.chromadb")
|
@patch("mempalace.repair.ChromaBackend")
|
||||||
def test_rebuild_index_no_palace(mock_chromadb, tmp_path):
|
def test_rebuild_index_no_palace(mock_backend_cls, tmp_path):
|
||||||
nonexistent = str(tmp_path / "nope")
|
nonexistent = str(tmp_path / "nope")
|
||||||
repair.rebuild_index(palace_path=nonexistent)
|
repair.rebuild_index(palace_path=nonexistent)
|
||||||
mock_chromadb.PersistentClient.assert_not_called()
|
mock_backend_cls.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@patch("mempalace.repair.shutil")
|
@patch("mempalace.repair.shutil")
|
||||||
@patch("mempalace.repair.chromadb")
|
@patch("mempalace.repair.ChromaBackend")
|
||||||
def test_rebuild_index_empty_palace(mock_chromadb, mock_shutil, tmp_path):
|
def test_rebuild_index_empty_palace(mock_backend_cls, mock_shutil, tmp_path):
|
||||||
mock_col = MagicMock()
|
mock_col = MagicMock()
|
||||||
mock_col.count.return_value = 0
|
mock_col.count.return_value = 0
|
||||||
mock_client = MagicMock()
|
mock_backend = _install_mock_backend(mock_backend_cls, mock_col)
|
||||||
mock_client.get_collection.return_value = mock_col
|
|
||||||
mock_chromadb.PersistentClient.return_value = mock_client
|
|
||||||
|
|
||||||
repair.rebuild_index(palace_path=str(tmp_path))
|
repair.rebuild_index(palace_path=str(tmp_path))
|
||||||
mock_client.delete_collection.assert_not_called()
|
mock_backend.delete_collection.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@patch("mempalace.repair.shutil")
|
@patch("mempalace.repair.shutil")
|
||||||
@patch("mempalace.repair.chromadb")
|
@patch("mempalace.repair.ChromaBackend")
|
||||||
def test_rebuild_index_success(mock_chromadb, mock_shutil, tmp_path):
|
def test_rebuild_index_success(mock_backend_cls, mock_shutil, tmp_path):
|
||||||
# Create a fake sqlite file
|
# Create a fake sqlite file
|
||||||
sqlite_path = tmp_path / "chroma.sqlite3"
|
sqlite_path = tmp_path / "chroma.sqlite3"
|
||||||
sqlite_path.write_text("fake")
|
sqlite_path.write_text("fake")
|
||||||
@@ -233,10 +227,8 @@ def test_rebuild_index_success(mock_chromadb, mock_shutil, tmp_path):
|
|||||||
}
|
}
|
||||||
|
|
||||||
mock_new_col = MagicMock()
|
mock_new_col = MagicMock()
|
||||||
mock_client = MagicMock()
|
mock_backend = _install_mock_backend(mock_backend_cls, mock_col)
|
||||||
mock_client.get_collection.return_value = mock_col
|
mock_backend.create_collection.return_value = mock_new_col
|
||||||
mock_client.create_collection.return_value = mock_new_col
|
|
||||||
mock_chromadb.PersistentClient.return_value = mock_client
|
|
||||||
|
|
||||||
repair.rebuild_index(palace_path=str(tmp_path))
|
repair.rebuild_index(palace_path=str(tmp_path))
|
||||||
|
|
||||||
@@ -244,11 +236,9 @@ def test_rebuild_index_success(mock_chromadb, mock_shutil, tmp_path):
|
|||||||
mock_shutil.copy2.assert_called_once()
|
mock_shutil.copy2.assert_called_once()
|
||||||
assert "chroma.sqlite3" in str(mock_shutil.copy2.call_args)
|
assert "chroma.sqlite3" in str(mock_shutil.copy2.call_args)
|
||||||
|
|
||||||
# Verify: deleted and recreated with cosine
|
# Verify: deleted and recreated (cosine is the backend default)
|
||||||
mock_client.delete_collection.assert_called_once_with("mempalace_drawers")
|
mock_backend.delete_collection.assert_called_once_with(str(tmp_path), "mempalace_drawers")
|
||||||
mock_client.create_collection.assert_called_once_with(
|
mock_backend.create_collection.assert_called_once_with(str(tmp_path), "mempalace_drawers")
|
||||||
"mempalace_drawers", metadata={"hnsw:space": "cosine"}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify: used upsert not add
|
# Verify: used upsert not add
|
||||||
mock_new_col.upsert.assert_called_once()
|
mock_new_col.upsert.assert_called_once()
|
||||||
@@ -256,11 +246,11 @@ def test_rebuild_index_success(mock_chromadb, mock_shutil, tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
@patch("mempalace.repair.shutil")
|
@patch("mempalace.repair.shutil")
|
||||||
@patch("mempalace.repair.chromadb")
|
@patch("mempalace.repair.ChromaBackend")
|
||||||
def test_rebuild_index_error_reading(mock_chromadb, mock_shutil, tmp_path):
|
def test_rebuild_index_error_reading(mock_backend_cls, mock_shutil, tmp_path):
|
||||||
mock_client = MagicMock()
|
mock_backend = MagicMock()
|
||||||
mock_client.get_collection.side_effect = Exception("corrupt")
|
mock_backend.get_collection.side_effect = Exception("corrupt")
|
||||||
mock_chromadb.PersistentClient.return_value = mock_client
|
mock_backend_cls.return_value = mock_backend
|
||||||
|
|
||||||
repair.rebuild_index(palace_path=str(tmp_path))
|
repair.rebuild_index(palace_path=str(tmp_path))
|
||||||
mock_client.delete_collection.assert_not_called()
|
mock_backend.delete_collection.assert_not_called()
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""TDD: save hook must actually mine conversations without MEMPAL_DIR.
|
||||||
|
|
||||||
|
The save hook should auto-discover the conversation transcript and mine it
|
||||||
|
without the user needing to set MEMPAL_DIR. Currently MEMPAL_DIR defaults
|
||||||
|
to empty, which means the mining block is skipped and nothing is saved
|
||||||
|
despite the hook telling the agent "saved in background."
|
||||||
|
|
||||||
|
Written BEFORE the fix.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaveHookAutoMines:
|
||||||
|
"""The save hook must mine the active transcript automatically."""
|
||||||
|
|
||||||
|
def test_hook_mines_transcript_path(self):
|
||||||
|
"""The hook receives TRANSCRIPT_PATH from Claude Code.
|
||||||
|
It should use that to mine the conversation, not depend on MEMPAL_DIR."""
|
||||||
|
hook_path = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(__file__)),
|
||||||
|
"hooks",
|
||||||
|
"mempal_save_hook.sh",
|
||||||
|
)
|
||||||
|
src = open(hook_path).read()
|
||||||
|
|
||||||
|
# The hook ALREADY receives TRANSCRIPT_PATH in the JSON input.
|
||||||
|
# It should use this to mine the current session's transcript
|
||||||
|
# regardless of whether MEMPAL_DIR is set.
|
||||||
|
# The hook must have a path that uses TRANSCRIPT_PATH to determine
|
||||||
|
# what to mine, separate from the MEMPAL_DIR path.
|
||||||
|
uses_transcript = "TRANSCRIPT_PATH" in src
|
||||||
|
has_mine = "mempalace mine" in src
|
||||||
|
# TRANSCRIPT_PATH must appear in the mining logic, not just the parse block
|
||||||
|
transcript_drives_mine = "MINE_DIR" in src and "dirname" in src and "TRANSCRIPT_PATH" in src
|
||||||
|
|
||||||
|
assert uses_transcript and has_mine and transcript_drives_mine, (
|
||||||
|
"Save hook only mines when MEMPAL_DIR is set (defaults to empty). "
|
||||||
|
"The hook receives TRANSCRIPT_PATH from Claude Code — it should "
|
||||||
|
"mine that file automatically so conversations are saved without "
|
||||||
|
"the user setting an env var. Currently the hook says 'saved in "
|
||||||
|
"background' but nothing actually saves."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_mempal_dir_default_not_empty(self):
|
||||||
|
"""If MEMPAL_DIR is still used, it should have a sensible default,
|
||||||
|
not an empty string that silently disables mining."""
|
||||||
|
hook_path = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(__file__)),
|
||||||
|
"hooks",
|
||||||
|
"mempal_save_hook.sh",
|
||||||
|
)
|
||||||
|
src = open(hook_path).read()
|
||||||
|
|
||||||
|
# Check if MEMPAL_DIR defaults to empty
|
||||||
|
has_empty_default = 'MEMPAL_DIR=""' in src
|
||||||
|
|
||||||
|
# If it defaults to empty, mining is silently disabled
|
||||||
|
if has_empty_default:
|
||||||
|
# There must be an alternative mining path that doesn't need MEMPAL_DIR
|
||||||
|
has_alternative = (
|
||||||
|
src.count("mempalace mine") > 1
|
||||||
|
or "TRANSCRIPT_PATH" in src.split("mempalace mine")[0]
|
||||||
|
)
|
||||||
|
assert has_alternative, (
|
||||||
|
'MEMPAL_DIR defaults to "" which silently disables mining. '
|
||||||
|
"Either set a default path or add transcript-based mining."
|
||||||
|
)
|
||||||
@@ -1239,7 +1239,7 @@ dev = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "autocorrect", marker = "extra == 'spellcheck'", specifier = ">=2.0" },
|
{ name = "autocorrect", marker = "extra == 'spellcheck'", specifier = ">=2.0" },
|
||||||
{ name = "chromadb", specifier = ">=0.5.0,<0.7" },
|
{ name = "chromadb", specifier = ">=0.5.0" },
|
||||||
{ name = "psutil", marker = "extra == 'dev'", specifier = ">=5.9" },
|
{ name = "psutil", marker = "extra == 'dev'", specifier = ">=5.9" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" },
|
||||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" },
|
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" },
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
mempalaceofficial.com
|
||||||
Reference in New Issue
Block a user